dev #916

Merged
ysCha merged 7 commits from dev into dev-deploy 2026-06-15 16:05:08 +09:00
12 changed files with 723 additions and 314 deletions

View File

@ -15,6 +15,8 @@ NEXT_PUBLIC_CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png?
NEXT_PUBLIC_Q_ORDER_AUTO_LOGIN_URL="https://q-order-dev.q-cells.jp/eos/login/autoLogin"
NEXT_PUBLIC_Q_MUSUBI_AUTO_LOGIN_URL="https://q-musubi-dev.q-cells.jp/qm/login/autoLogin"
NEXT_PUBLIC_ECONOMIC_SIMULATION_AUTH_URL="https://q-order-dev.q-cells.jp/eos/simulation/auth"
NEXT_PUBLIC_ECONOMIC_SIMULATION_URL="https://q-order-dev.q-cells.jp/eos/simulation/list"
# 테스트용
# AWS_REGION="ap-northeast-2"

View File

@ -14,6 +14,8 @@ NEXT_PUBLIC_CONVERTER_API_URL="https://v2.convertapi.com/convert/dwg/to/png?Secr
NEXT_PUBLIC_Q_ORDER_AUTO_LOGIN_URL="http://q-order-stg.q-cells.jp:8120/eos/login/autoLogin"
NEXT_PUBLIC_Q_MUSUBI_AUTO_LOGIN_URL="http://q-musubi-stg.q-cells.jp:8120/qm/login/autoLogin"
NEXT_PUBLIC_ECONOMIC_SIMULATION_AUTH_URL="http://q-order-stg.q-cells.jp:8120/eos/simulation/auth"
NEXT_PUBLIC_ECONOMIC_SIMULATION_URL="http://q-order-stg.q-cells.jp:8120/eos/simulation/list"
# 테스트용
# AWS_REGION="ap-northeast-2"

View File

@ -15,6 +15,8 @@ NEXT_PUBLIC_CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png?
NEXT_PUBLIC_Q_ORDER_AUTO_LOGIN_URL="http://q-order-stg.q-cells.jp:8120/eos/login/autoLogin"
NEXT_PUBLIC_Q_MUSUBI_AUTO_LOGIN_URL="http://q-musubi-stg.q-cells.jp:8120/qm/login/autoLogin"
NEXT_PUBLIC_ECONOMIC_SIMULATION_AUTH_URL="http://q-order-stg.q-cells.jp:8120/eos/simulation/auth"
NEXT_PUBLIC_ECONOMIC_SIMULATION_URL="http://q-order-stg.q-cells.jp:8120/eos/simulation/list"
# 테스트용
# AWS_REGION="ap-northeast-2"

View File

@ -15,6 +15,8 @@ NEXT_PUBLIC_CONVERTER_DXF_API_URL="https://v2.convertapi.com/convert/dxf/to/png?
NEXT_PUBLIC_Q_ORDER_AUTO_LOGIN_URL="https://q-order.q-cells.jp/eos/login/autoLogin"
NEXT_PUBLIC_Q_MUSUBI_AUTO_LOGIN_URL="https://q-musubi.q-cells.jp/qm/login/autoLogin"
NEXT_PUBLIC_ECONOMIC_SIMULATION_AUTH_URL="https://q-order.q-cells.jp/eos/simulation/auth"
NEXT_PUBLIC_ECONOMIC_SIMULATION_URL="https://q-order.q-cells.jp/eos/simulation/list"
# AWS_REGION="ap-northeast-2"
# AMPLIFY_BUCKET="interplug"

View File

@ -164,6 +164,29 @@ export default function Header(props) {
getAutoLoginParam()
}, [userSession])
const openEconomicSimulation = async () => {
const encRes = await promiseGet({
url: '/api/pwrGnrSimulation/encData',
params: { saleStoreId: sessionState.saleStoreId, userId: sessionState.userId },
})
const { encSaleStoreId, encUserId } = encRes.data
if (!encSaleStoreId || !encUserId) return
const authRes = await fetch(process.env.NEXT_PUBLIC_ECONOMIC_SIMULATION_AUTH_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ encSaleStoreId, encUserId }),
})
if (authRes.ok) {
window.open(process.env.NEXT_PUBLIC_ECONOMIC_SIMULATION_URL, 'economicSimulation', `height=${screen.height},width=${screen.width},fullscreen=yes`)
} else {
const body = await authRes.json()
swalFire({ text: body.resultMessage, type: 'error' })
}
}
const menus = [
{ id: 0, name: 'header.menus.home', url: '/', children: [] },
{
@ -187,6 +210,9 @@ export default function Header(props) {
],
},
{ id: 8, name: 'qna.title', url: '/community/qna', children: [] },
...(process.env.NEXT_PUBLIC_RUN_MODE !== 'production'
? [{ id: 9, name: 'header.menus.economic.simulation', action: openEconomicSimulation, children: [] }]
: []),
]
const syncSession = useCallback(() => {
@ -237,6 +263,11 @@ export default function Header(props) {
onMouseLeave={(e) => ToggleonMouse(e, 'remove', 'nav > ul')}
>
{menu.children.length === 0 ? (
menu.action ? (
<button key={`${menu.id}`} onClick={() => menu.action()}>
{getMessage(menu.name)}
</button>
) : (
<Link
key={`${menu.id}`}
href={menu.url}
@ -272,6 +303,7 @@ export default function Header(props) {
>
{getMessage(menu.name)}
</Link>
)
) : (
<Fragment key={`${menu.id}`}>
<button>{getMessage(menu.name)}</button>

View File

@ -253,58 +253,71 @@ export function useRefFiles() {
* @param converterUrl
*/
const handleUploadConvertRefFile = async (file, converterUrl) => {
const formData = new FormData()
formData.append('file', file)
try {
const formData = new FormData()
formData.append('file', file)
/** 캐드 도면 파일 변환 */
const res = await post({ url: converterUrl, data: formData })
logger.log('🚀 ~ handleUploadConvertRefFile ~ res:', res)
/** 캐드 도면 파일 변환 */
const res = await post({ url: converterUrl, data: formData })
logger.log('🚀 ~ handleUploadConvertRefFile ~ res:', res)
// Convert Base64 to Blob
const base64Data = res.Files[0].FileData
const byteCharacters = atob(base64Data)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i)
// 변환 API 가 정상 변환 대신 에러 본문(Code/Message)을 반환한 경우 그대로 throw [DXF-CONVERT-FAIL 2026-06-15]
if (!res?.Files?.[0]?.FileData) {
throw new Error(res?.Message || getMessage('common.message.ref.file.convertFail'))
}
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
// Convert Base64 to Blob
const base64Data = res.Files[0].FileData
const byteCharacters = atob(base64Data)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
const blob = new Blob(byteArrays, { type: 'image/png' })
// Create File object from Blob
const convertImg = new File([blob], res.Files[0].FileName, { type: 'image/png' })
const newFormData = new FormData()
newFormData.append('file', convertImg)
/** 캐드 도면 파일 업로드 */
const result = await post({
url: `${Config().baseUrl}/api/image/cad`,
data: newFormData,
})
logger.log('🚀 ~ handleUploadConvertRefFile ~ result:', result)
setCurrentBgImage(`${process.env.NEXT_PUBLIC_AWS_S3_BASE_URL}/${result.fileName}`)
// setCurrentBgImage(result.filePath)
setRefImage(file)
const params = {
objectId: currentCanvasPlan.id,
planNo: currentCanvasPlan.planNo,
// imagePath: `${process.env.NEXT_PUBLIC_HOST_URL}${res.filePath}`,
imagePath: `${result.filePath}`,
}
logger.log('🚀 ~ handleUploadImageRefFile ~ params:', params)
await setBackGroundImage(params)
} catch (error) {
// 변환/업로드 실패 시 API 응답 메시지를 그대로 노출 (없으면 기본 안내) [DXF-CONVERT-FAIL 2026-06-15]
logger.error('🚨 handleUploadConvertRefFile error:', error)
const apiMessage = error?.response?.data?.Message || error?.message || getMessage('common.message.ref.file.convertFail')
swalFire({ text: apiMessage, type: 'alert', icon: 'error' })
} finally {
setPopSpinnerStore(false)
}
const blob = new Blob(byteArrays, { type: 'image/png' })
// Create File object from Blob
const convertImg = new File([blob], res.Files[0].FileName, { type: 'image/png' })
const newFormData = new FormData()
newFormData.append('file', convertImg)
/** 캐드 도면 파일 업로드 */
const result = await post({
url: `${Config().baseUrl}/api/image/cad`,
data: newFormData,
})
logger.log('🚀 ~ handleUploadConvertRefFile ~ result:', result)
setCurrentBgImage(`${process.env.NEXT_PUBLIC_AWS_S3_BASE_URL}/${result.fileName}`)
// setCurrentBgImage(result.filePath)
setRefImage(file)
const params = {
objectId: currentCanvasPlan.id,
planNo: currentCanvasPlan.planNo,
// imagePath: `${process.env.NEXT_PUBLIC_HOST_URL}${res.filePath}`,
imagePath: `${result.filePath}`,
}
logger.log('🚀 ~ handleUploadImageRefFile ~ params:', params)
await setBackGroundImage(params)
setPopSpinnerStore(false)
}
/**

View File

@ -167,6 +167,184 @@ export function useEavesGableEdit(id) {
// R3 outside: 끝점이 roofLine 폴리곤 밖(경계 tol 초과)으로 이탈 금지.
// R4 anchor: 토글 전후로 움직이지 않은 roofLine 코너(stable corner)의 끝점 점유수 불변
// (코너에서 hip 이 떨어지거나 엉뚱한 코너로 횡단하면 점유수 변화).
// [KERAB-OFFSET-HELPER 2026-06-15] 출폭함수 빌딩블록 (1/2): 케라바 hip(확장라인) 식별.
// reclick 검증 코드에서 무손실 추출. surgical 적용 *전*(옛 폴리곤) 에 호출해야 한다 —
// outer endpoint = 옛 roof.points 변 위 끝점, side = hip 직선이 지나는 wall 코너(A/B).
const detectKerabHipMarks = (target, roof) => {
const marks = []
if (!roof || !Array.isArray(roof.innerLines) || !Array.isArray(roof.points)) return marks
const wA = { x: target.x1, y: target.y1 }
const wB = { x: target.x2, y: target.y2 }
const pts = roof.points
const isOnOldPolyEdge = (P) => {
for (let i = 0; i < pts.length; i++) {
const A = pts[i]
const B = pts[(i + 1) % pts.length]
const ddx = B.x - A.x
const ddy = B.y - A.y
const lenSq = ddx * ddx + ddy * ddy
if (lenSq < 1e-6) continue
const t = ((P.x - A.x) * ddx + (P.y - A.y) * ddy) / lenSq
if (t < -0.02 || t > 1.02) continue
const projX = A.x + t * ddx
const projY = A.y + t * ddy
const d = Math.hypot(P.x - projX, P.y - projY)
if (d < 2.0) return true
}
return false
}
for (const il of roof.innerLines) {
if (!il || (il.lineName !== 'hip' && il.lineName !== 'kerabPatternHip')) continue
const e1 = { x: il.x1, y: il.y1 }
const e2 = { x: il.x2, y: il.y2 }
const e1OnEdge = isOnOldPolyEdge(e1)
const e2OnEdge = isOnOldPolyEdge(e2)
let which = null
if (e1OnEdge && !e2OnEdge) which = 1
else if (e2OnEdge && !e1OnEdge) which = 2
if (which === null) continue
const outerEnd = which === 1 ? e1 : e2
const innerEnd = which === 1 ? e2 : e1
const ddx = outerEnd.x - innerEnd.x
const ddy = outerEnd.y - innerEnd.y
const llen = Math.hypot(ddx, ddy) || 1
const distPerp = (P) => Math.abs((ddx * (innerEnd.y - P.y) - ddy * (innerEnd.x - P.x)) / llen)
const dA = distPerp(wA)
const dB = distPerp(wB)
if (Math.min(dA, dB) > 5.0) continue
const side = dA <= dB ? 'A' : 'B'
marks.push({ il, which, side })
}
logger.log(
'[KERAB-OFFSET-ONLY-RECLICK-EAVES] hip marks ' +
JSON.stringify(marks.map((m) => ({ which: m.which, side: m.side, lineName: m.il.lineName }))),
)
return marks
}
// [KERAB-OFFSET-HELPER 2026-06-15] 출폭함수 빌딩블록 (2/2): 케라바 hip 을 45° ray-cast 확장.
// surgical 적용 *후*(새 폴리곤) 에 호출. wall 코너에서 옛 hip 방향(45°)으로 새 roofLine 변까지.
// CORNER-SNAP 금지 룰의 실제 구현부 — roofLine 코너가 아니라 교점까지 뻗는다.
// [45-UNIFY 2026-06-15] reclick·revert 두 경로의 45° 구현을 한 벌로 통합. 가드는 opt-in:
// - useRevertGuards=false(reclick): 순수 45° ray-cast (기존 reclick 동작 그대로).
// - useRevertGuards=true(revert): outerShared(골짜기 내부 hip 제외) + outerOnVertex(게이블 코너
// hip 은 같은 인덱스 새 꼭짓점으로 직접 스냅) 가드 적용. oldPolyPoints = 이동 전 폴리곤 꼭짓점.
const extendKerabHipsTo45 = (target, roof, hipMarks, options = {}) => {
if (!roof || !Array.isArray(roof.points) || !hipMarks || !hipMarks.length) return
const { useRevertGuards = false, oldPolyPoints = null } = options
const wA = { x: target.x1, y: target.y1 }
const wB = { x: target.x2, y: target.y2 }
const rps = roof.points
const M = rps.length
if (!M) return
const baseTag = useRevertGuards ? '[KERAB-REVERT-EXTEND-45]' : '[KERAB-OFFSET-ONLY-RECLICK-EAVES]'
const okTag = useRevertGuards ? '[KERAB-REVERT-EXTEND-45] ' : '[KERAB-OFFSET-ONLY-RECLICK-EAVES] extended '
const rayHit = (P, dir, A, B) => {
const sx = B.x - A.x
const sy = B.y - A.y
const denom = dir.x * sy - dir.y * sx
if (Math.abs(denom) < 1e-9) return Infinity
const ax = A.x - P.x
const ay = A.y - P.y
const t = (ax * sy - ay * sx) / denom
const s = (ax * dir.y - ay * dir.x) / denom
if (t > 1e-6 && s >= -1e-6 && s <= 1 + 1e-6) return t
return Infinity
}
const cast45 = (wCorner, innerEnd, outerOld, il, which, side) => {
const dx = outerOld.x - innerEnd.x
const dy = outerOld.y - innerEnd.y
const dlen = Math.hypot(dx, dy)
if (dlen < 1e-6) return null
const ux = dx / dlen
const uy = dy / dlen
let bestT = Infinity
for (let k = 0; k < M; k++) {
const A = rps[k]
const B = rps[(k + 1) % M]
const t = rayHit(wCorner, { x: ux, y: uy }, A, B)
if (t < bestT) bestT = t
}
if (!isFinite(bestT) || bestT < 0.5) {
logger.log(baseTag + ' no-hit ' + JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } }))
return null
}
return { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT }
}
for (const mark of hipMarks) {
const { il, which, side } = mark
const wCorner = side === 'A' ? wA : wB
const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
let hit
if (useRevertGuards) {
// outerShared: outer 끝이 다른 내부선과 공유 → 골짜기 내부 hip → 확장 스킵.
const SHARE_TOL = 2.0
const outerShared = (roof.innerLines || []).some(
(o) =>
o &&
o !== il &&
o.visible !== false &&
(Math.hypot(o.x1 - outerOld.x, o.y1 - outerOld.y) < SHARE_TOL ||
Math.hypot(o.x2 - outerOld.x, o.y2 - outerOld.y) < SHARE_TOL),
)
// outerOnVertex: outer 가 (이동 전) 폴리곤 꼭짓점이면 게이블 코너 hip → 같은 인덱스 새 꼭짓점 스냅.
const VERT_TOL = 2.0
const opp = Array.isArray(oldPolyPoints) ? oldPolyPoints : []
let vtxIdx = -1
for (let vi = 0; vi < opp.length; vi++) {
const p = opp[vi]
if (p && Math.hypot(p.x - outerOld.x, p.y - outerOld.y) < VERT_TOL) {
vtxIdx = vi
break
}
}
const outerOnVertex = vtxIdx >= 0
if (outerShared && !outerOnVertex) {
logger.log(baseTag + ' skip (interior hip — outer shared) ' + JSON.stringify({ lineName: il.lineName, which, side, outerOld }))
continue
}
if (outerOnVertex && rps.length === opp.length && rps[vtxIdx]) {
hit = { x: rps[vtxIdx].x, y: rps[vtxIdx].y }
} else {
hit = cast45(wCorner, innerEnd, outerOld, il, which, side)
if (!hit) continue
}
} else {
hit = cast45(wCorner, innerEnd, outerOld, il, which, side)
if (!hit) continue
}
const oldLen = Math.hypot(outerOld.x - innerEnd.x, outerOld.y - innerEnd.y)
const newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y)
const ratio = oldLen > 0 ? newLen / oldLen : 1
if (which === 1) il.set({ x1: hit.x, y1: hit.y })
else il.set({ x2: hit.x, y2: hit.y })
if (il.attributes) {
const oldPlane = il.attributes.planeSize ?? 0
const oldActual = il.attributes.actualSize ?? 0
il.attributes.planeSize = Math.round(oldPlane * ratio * 100) / 100
il.attributes.actualSize = Math.round(oldActual * ratio * 100) / 100
il.attributes.extended = true
}
il.__extended = true
if (typeof il.setCoords === 'function') il.setCoords()
if (typeof il.setLength === 'function') il.setLength()
if (typeof il.addLengthText === 'function') il.addLengthText()
logger.log(
okTag +
JSON.stringify({
lineName: il.lineName,
which,
side,
hit,
ratio: Number(ratio.toFixed(3)),
x1: il.x1,
y1: il.y1,
x2: il.x2,
y2: il.y2,
}),
)
}
}
const snapshotKerabState = (roof) => {
if (!roof || !Array.isArray(roof.innerLines)) return null
const lines = roof.innerLines
@ -441,137 +619,50 @@ export function useEavesGableEdit(id) {
// (케라바 ONLY-RECLICK 은 KERAB-PATTERN-CORNER-SNAP 필요하므로 그대로 둠.)
const isEaves = attributes?.type === LINE_TYPE.WALLLINE.EAVES
let reclickRoof = null
const hipMarks = []
let hasKerabPattern = false
let hipMarks = []
if (isEaves) {
reclickRoof = canvas
.getObjects()
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
if (reclickRoof && Array.isArray(reclickRoof.innerLines) && Array.isArray(reclickRoof.points)) {
const wA = { x: target.x1, y: target.y1 }
const wB = { x: target.x2, y: target.y2 }
const pts = reclickRoof.points
// outer endpoint 식별: 옛 roof.points 변 위 (perpendicular distance < 2) 인 끝점.
const isOnOldPolyEdge = (P) => {
for (let i = 0; i < pts.length; i++) {
const A = pts[i]
const B = pts[(i + 1) % pts.length]
const ddx = B.x - A.x
const ddy = B.y - A.y
const lenSq = ddx * ddx + ddy * ddy
if (lenSq < 1e-6) continue
const t = ((P.x - A.x) * ddx + (P.y - A.y) * ddy) / lenSq
if (t < -0.02 || t > 1.02) continue
const projX = A.x + t * ddx
const projY = A.y + t * ddy
const d = Math.hypot(P.x - projX, P.y - projY)
if (d < 2.0) return true
}
return false
}
for (const il of reclickRoof.innerLines) {
if (!il || il.lineName !== 'hip') continue
const e1 = { x: il.x1, y: il.y1 }
const e2 = { x: il.x2, y: il.y2 }
const e1OnEdge = isOnOldPolyEdge(e1)
const e2OnEdge = isOnOldPolyEdge(e2)
let which = null
if (e1OnEdge && !e2OnEdge) which = 1
else if (e2OnEdge && !e1OnEdge) which = 2
if (which === null) continue
const outerEnd = which === 1 ? e1 : e2
const innerEnd = which === 1 ? e2 : e1
// transit corner: hip 직선 위에 wA/wB 중 어느 코너가 있는지 (perpendicular distance).
const ddx = outerEnd.x - innerEnd.x
const ddy = outerEnd.y - innerEnd.y
const llen = Math.hypot(ddx, ddy) || 1
const distPerp = (P) => Math.abs((ddx * (innerEnd.y - P.y) - ddy * (innerEnd.x - P.x)) / llen)
const dA = distPerp(wA)
const dB = distPerp(wB)
if (Math.min(dA, dB) > 5.0) continue
const side = dA <= dB ? 'A' : 'B'
hipMarks.push({ il, which, side })
}
logger.log(
'[KERAB-OFFSET-ONLY-RECLICK-EAVES] hip marks ' +
JSON.stringify(hipMarks.map((m) => ({ which: m.which, side: m.side, lineName: m.il.lineName }))),
hasKerabPattern = reclickRoof.innerLines.some(
(il) => il && typeof il.lineName === 'string' && il.lineName.startsWith('kerabPattern'),
)
// surgical 적용 *전*(옛 폴리곤) 에 케라바 hip 식별 — outer endpoint 가 옛 변 위에 있어야 잡힌다.
hipMarks = detectKerabHipMarks(target, reclickRoof)
}
}
applyTargetOffsetSurgical(target, newOffset, isEaves ? { skipInnerLines: true } : undefined)
if (isEaves && reclickRoof && hipMarks.length) {
const wA = { x: target.x1, y: target.y1 }
const wB = { x: target.x2, y: target.y2 }
const rps = reclickRoof.points
const M = rps.length
const rayHit = (P, dir, A, B) => {
const sx = B.x - A.x
const sy = B.y - A.y
const denom = dir.x * sy - dir.y * sx
if (Math.abs(denom) < 1e-9) return Infinity
const ax = A.x - P.x
const ay = A.y - P.y
const t = (ax * sy - ay * sx) / denom
const s = (ax * dir.y - ay * dir.x) / denom
if (t > 1e-6 && s >= -1e-6 && s <= 1 + 1e-6) return t
return Infinity
}
for (const mark of hipMarks) {
const { il, which, side } = mark
const wCorner = side === 'A' ? wA : wB
const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
const dx = outerOld.x - innerEnd.x
const dy = outerOld.y - innerEnd.y
const dlen = Math.hypot(dx, dy)
if (dlen < 1e-6) continue
const ux = dx / dlen
const uy = dy / dlen
let bestT = Infinity
for (let k = 0; k < M; k++) {
const A = rps[k]
const B = rps[(k + 1) % M]
const t = rayHit(wCorner, { x: ux, y: uy }, A, B)
if (t < bestT) bestT = t
}
if (!isFinite(bestT) || bestT < 0.5) {
logger.log(
'[KERAB-OFFSET-ONLY-RECLICK-EAVES] no-hit ' +
JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } }),
)
continue
}
const hit = { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT }
const oldLen = Math.hypot(outerOld.x - innerEnd.x, outerOld.y - innerEnd.y)
const newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y)
const ratio = oldLen > 0 ? newLen / oldLen : 1
if (which === 1) il.set({ x1: hit.x, y1: hit.y })
else il.set({ x2: hit.x, y2: hit.y })
if (il.attributes) {
const oldPlane = il.attributes.planeSize ?? 0
const oldActual = il.attributes.actualSize ?? 0
il.attributes.planeSize = Math.round(oldPlane * ratio * 100) / 100
il.attributes.actualSize = Math.round(oldActual * ratio * 100) / 100
il.attributes.extended = true
}
il.__extended = true
// [KERAB-OFFSET-RECLICK-UNIFY 2026-06-15] 出幅 재변경(eaves→eaves)을 skLine 과 동일 경로로 통일.
// 출발 형상(skLine / A/B타입)에 무관하게 동일 규칙(= 출폭함수):
// - 케라바 hip(확장라인)은 surgical CORNER-SNAP 금지 → wall 코너에서 45° ray-cast 로 roofLine 까지.
// - 측면 수직선/변조각(normal line)은 surgical(recomputeNormalLine)이 새 코너로 추종.
// skLine: 케라바 패턴 없음 → skipInnerLines(전부 45°가 처리).
// A/B타입: 케라바 패턴 보유 → skipKerabHips(normal/ridge 는 surgical, hip 만 45°가 처리).
// 출폭 증감(확장/수축) 양방향이 한 경로에서 일관 처리됨.
const reclickBeforeSnap = isEaves && reclickRoof ? snapshotKerabState(reclickRoof) : null
applyTargetOffsetSurgical(
target,
newOffset,
isEaves ? (hasKerabPattern ? { skipKerabHips: true } : { skipInnerLines: true }) : undefined,
)
// surgical 적용 *후*(새 폴리곤) 에 케라바 hip 을 45° ray-cast 로 새 roofLine 변까지 확장.
if (isEaves && reclickRoof) extendKerabHipsTo45(target, reclickRoof, hipMarks)
// [KERAB-OFFSET-RECLICK-UNIFY 2026-06-15] A/B타입은 케라바 hip 외 inner line 좌표가
// surgical 내부(recomputeKerabPatternLine/recomputeNormalLine)에서 갱신됨 →
// 길이/치수 라벨만 새 좌표 기준으로 새로고침한다(hip 은 45° 블록에서 이미 갱신).
if (isEaves && hasKerabPattern && reclickRoof && Array.isArray(reclickRoof.innerLines)) {
for (const il of reclickRoof.innerLines) {
if (!il || il.lineName === 'kerabPatternHip') continue
if (typeof il.setCoords === 'function') il.setCoords()
if (typeof il.setLength === 'function') il.setLength()
if (typeof il.addLengthText === 'function') il.addLengthText()
logger.log(
'[KERAB-OFFSET-ONLY-RECLICK-EAVES] extended ' +
JSON.stringify({
lineName: il.lineName,
which,
side,
hit,
ratio: Number(ratio.toFixed(3)),
x1: il.x1,
y1: il.y1,
x2: il.x2,
y2: il.y2,
}),
)
}
logger.log('[KERAB-OFFSET-RECLICK-UNIFY] surgical label refresh done')
}
// [KERAB-OFFSET-RECLICK-RULECHECK 2026-06-15] 라인변경(출폭 재적용) 후 규칙 검사 — 사용자 요구.
if (isEaves && reclickRoof && reclickBeforeSnap) {
runKerabRuleCheck(reclickRoof, 'reclick', reclickBeforeSnap)
}
target.set({ attributes })
canvas.renderAll()
@ -604,6 +695,19 @@ export function useEavesGableEdit(id) {
}
}
// [KERAB-RECT-SOLVER 2026-06-15] 사각형이면 토글 이력과 무관한 결정론적 솔버로 처리.
// 최종 4변 타입만으로 내부선 전부 재생성(Y 불변식 + apex 멈춤 구조적 보장). 미지원 형상은
// false → 아래 기존 forward/revert/type 경로로 폴백.
if (radioTypeRef.current === '1' && (attributes?.type === LINE_TYPE.WALLLINE.EAVES || attributes?.type === LINE_TYPE.WALLLINE.GABLE)) {
const rectRoof = canvas
.getObjects()
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
if (rectRoof && solveRectangleKerab(rectRoof, target, attributes)) {
logger.log('[KERAB-RECT-SOLVER] handled deterministically → maze 우회')
return
}
}
// [2240 KERAB-SIMPLE 2026-05-20] 사용자 설명 정직 알고리즘:
// 1) target 양 끝점에 직접 끝이 닿은 hip 2개를 찾는다 (nearestRoofPoint 안 씀)
// 2) 두 hip 직선의 무한확장 교점 = apex
@ -2551,139 +2655,18 @@ export function useEavesGableEdit(id) {
// 게이블 코너 hip 판정용으로 이동 전 옛 폴리곤 꼭짓점을 미리 스냅샷한다.
const oldPolyPoints = (Array.isArray(roof.points) ? roof.points : []).map((p) => ({ x: p.x, y: p.y }))
if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0, { skipInnerLines: true })
const wA = { x: target.x1, y: target.y1 }
const wB = { x: target.x2, y: target.y2 }
const rps = Array.isArray(roof.points) ? roof.points : []
const M = rps.length
if (!M) return
const rayHit = (P, dir, A, B) => {
const sx = B.x - A.x
const sy = B.y - A.y
const denom = dir.x * sy - dir.y * sx
if (Math.abs(denom) < 1e-9) return Infinity
const ax = A.x - P.x
const ay = A.y - P.y
const t = (ax * sy - ay * sx) / denom
const s = (ax * dir.y - ay * dir.x) / denom
if (t > 1e-6 && s >= -1e-6 && s <= 1 + 1e-6) return t
return Infinity
}
// [45-UNIFY 2026-06-15] reclick 과 동일한 extendKerabHipsTo45 사용 (revert 가드 on):
// forward 가 붙여둔 __kerabRevertOuterWhich/Side 플래그로 marks 를 만든 뒤, surgical *후*
// 새 폴리곤에 대해 outerShared/outerOnVertex 가드를 적용해 45° 확장. 플래그는 사용 후 정리.
const marks = []
for (const il of roof.innerLines || []) {
if (!il || !il.__kerabRevertOuterWhich) continue
const which = il.__kerabRevertOuterWhich
const side = il.__kerabRevertOuterSide
const wCorner = side === 'A' ? wA : wB
const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
// [KERAB-REVERT-SHARED-ENDPOINT-GUARD 2026-06-10] outer(확장) 끝이 다른 내부선과
// 공유되면 확장하지 않는다. 골짜기(valley) 내부 hip 은 양 끝이 skeleton 교점이라 outer 도
// 공유 → 자동 제외. 게이블/코너 hip 의 outer 는 free(roofLine 변에 홀로 도달) → 확장.
// 판정이 출폭·snapshot 좌표와 무관한 구조 기준이라 다중 출폭 변경에도 안 깨진다.
// (구 c1/c2 가드는 c1/c2 를 현재 출폭으로, outerOld 를 forward 당시 출폭으로 잡아
// 출폭이 여러 번 바뀌면 게이블 hip 을 interior 로 오판 → SK 확장이 중간에 멈췄다.)
const SHARE_TOL = 2.0
const outerShared = (roof.innerLines || []).some(
(o) =>
o &&
o !== il &&
o.visible !== false &&
(Math.hypot(o.x1 - outerOld.x, o.y1 - outerOld.y) < SHARE_TOL ||
Math.hypot(o.x2 - outerOld.x, o.y2 - outerOld.y) < SHARE_TOL),
)
// [KERAB-REVERT-VERTEX-EXTEND 2026-06-11] TYPE 출신 게이블 hip 의 outer 끝은 폴리곤 코너(roofLine 꼭짓점)
// 라 인접 면경계선과 공유 → outerShared 가 true 가 돼 확장이 스킵됐다(처마변경 실패: hip 이 옛 코너에 멈춤).
// 골짜기 내부 hip 의 outer 는 폴리곤 경계에 없는 skeleton 교점이다. 따라서 outerOld 가 (이동 전) 폴리곤
// 꼭짓점이면 게이블 코너 hip 으로 보고 확장을 강제, 공유여도 스킵하지 않는다.
const VERT_TOL = 2.0
// outerOld 가 (이동 전) 폴리곤 꼭짓점이면 그 인덱스를 잡는다. surgical 은 점 순서·개수를 보존하므로
// 같은 인덱스의 새 roof.points 가 곧 이동 후 코너 = 게이블 hip 의 새 outer 끝점.
let vtxIdx = -1
for (let vi = 0; vi < oldPolyPoints.length; vi++) {
const p = oldPolyPoints[vi]
if (p && Math.hypot(p.x - outerOld.x, p.y - outerOld.y) < VERT_TOL) {
vtxIdx = vi
break
}
}
const outerOnVertex = vtxIdx >= 0
if (outerShared && !outerOnVertex) {
logger.log(
'[KERAB-REVERT-EXTEND-45] skip (interior hip — outer shared) ' +
JSON.stringify({ lineName: il.lineName, which, side, outerOld }),
)
delete il.__kerabRevertOuterWhich
delete il.__kerabRevertOuterSide
continue
}
let hit
// [KERAB-REVERT-VERTEX-EXTEND 2026-06-11] 게이블 코너 hip: 45° ray-cast 는 출폭이 클 때 인접
// 변에 먼저 닿아(예: 출폭 80 → -803 에서 멈춤) 코너(-839)에 못 미친다 → rLine 겹침/틈.
// surgical 후 동일 인덱스 코너로 직접 스냅해 항상 새 roofLine 꼭짓점까지 도달시킨다.
if (outerOnVertex && rps.length === oldPolyPoints.length && rps[vtxIdx]) {
hit = { x: rps[vtxIdx].x, y: rps[vtxIdx].y }
} else {
// hip 방향 = inner → outer (snapshot 좌표 기반 = 45°). 동일 직선이면 wL 코너도 위에 있음.
const dx = outerOld.x - innerEnd.x
const dy = outerOld.y - innerEnd.y
const dlen = Math.hypot(dx, dy)
if (dlen < 1e-6) {
delete il.__kerabRevertOuterWhich
delete il.__kerabRevertOuterSide
continue
}
const ux = dx / dlen
const uy = dy / dlen
// wL 코너에서 45° 방향으로 ray → 새 rL 변 (roof.points 는 surgical 후 새 출폭) 의 첫 hit.
let bestT = Infinity
for (let k = 0; k < M; k++) {
const A = rps[k]
const B = rps[(k + 1) % M]
const t = rayHit(wCorner, { x: ux, y: uy }, A, B)
if (t < bestT) bestT = t
}
if (!isFinite(bestT) || bestT < 0.5) {
logger.log(
'[KERAB-REVERT-EXTEND-45] no-hit ' +
JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } }),
)
delete il.__kerabRevertOuterWhich
delete il.__kerabRevertOuterSide
continue
}
hit = { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT }
}
const oldLen = Math.hypot(outerOld.x - innerEnd.x, outerOld.y - innerEnd.y)
const newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y)
const ratio = oldLen > 0 ? newLen / oldLen : 1
if (which === 1) il.set({ x1: hit.x, y1: hit.y })
else il.set({ x2: hit.x, y2: hit.y })
if (il.attributes) {
const oldPlane = il.attributes.planeSize ?? 0
const oldActual = il.attributes.actualSize ?? 0
il.attributes.planeSize = Math.round(oldPlane * ratio * 100) / 100
il.attributes.actualSize = Math.round(oldActual * ratio * 100) / 100
il.attributes.extended = true
}
il.__extended = true
if (typeof il.setCoords === 'function') il.setCoords()
if (typeof il.setLength === 'function') il.setLength()
if (typeof il.addLengthText === 'function') il.addLengthText()
logger.log(
'[KERAB-REVERT-EXTEND-45] ' +
JSON.stringify({
lineName: il.lineName,
which,
side,
hit,
ratio: Number(ratio.toFixed(3)),
x1: il.x1,
y1: il.y1,
x2: il.x2,
y2: il.y2,
}),
)
delete il.__kerabRevertOuterWhich
delete il.__kerabRevertOuterSide
marks.push({ il, which: il.__kerabRevertOuterWhich, side: il.__kerabRevertOuterSide })
}
extendKerabHipsTo45(target, roof, marks, { useRevertGuards: true, oldPolyPoints })
for (const m of marks) {
delete m.il.__kerabRevertOuterWhich
delete m.il.__kerabRevertOuterSide
}
runKerabRuleCheck(roof, 'revert', kerabRevertBeforeSnap)
}
@ -3403,6 +3386,31 @@ export function useEavesGableEdit(id) {
removeLine(eh)
roof.innerLines = roof.innerLines.filter((il) => il !== eh)
}
// [KERAB-EXTHIP-COORD-CLEAN 2026-06-15] __extHipsCreated 는 객체 참조라, 양쪽 박공→한 변씩 처마
// 복원 시퀀스에서 중간 인접변 작업이 ext-hip 을 파괴·재생성하면 죽은 객체를 가리켜 위 루프가
// no-op 이 된다 → 재생성된 ext-hip 이 orphan 으로 잔류(apex 에서 끝나는 1881 V, "라인은 roofLine
// 까지" 규칙 위반). 이 kLine 의 apex(=roofLine 반대쪽 안쪽 끝점) 를 공유하는 ext 라인을 좌표로
// 한 번 더 제거해 재생성돼도 잡는다. orphan 없으면 0개 제거 → 정상 케이스 무영향.
{
const _r = ridgeAtMid.ridge
const _tMid = { x: (target.x1 + target.x2) / 2, y: (target.y1 + target.y2) / 2 }
const _apexPt =
Math.hypot(_r.x1 - _tMid.x, _r.y1 - _tMid.y) >= Math.hypot(_r.x2 - _tMid.x, _r.y2 - _tMid.y)
? { x: _r.x1, y: _r.y1 }
: { x: _r.x2, y: _r.y2 }
const _EXT_TOL = 1.0
const _orphans = roof.innerLines.filter(
(il) =>
il &&
(il.lineName === 'kerabPatternExtHip' || il.lineName === 'kerabPatternExtRidge') &&
(Math.hypot(il.x1 - _apexPt.x, il.y1 - _apexPt.y) < _EXT_TOL || Math.hypot(il.x2 - _apexPt.x, il.y2 - _apexPt.y) < _EXT_TOL),
)
for (const eh of _orphans) {
removeLine(eh)
roof.innerLines = roof.innerLines.filter((il) => il !== eh)
}
if (_orphans.length) logger.log('[KERAB-EXTHIP-COORD-CLEAN] removed ' + JSON.stringify({ apex: _apexPt, count: _orphans.length }))
}
removeLine(ridgeAtMid.ridge)
roof.innerLines = roof.innerLines.filter((il) => il !== ridgeAtMid.ridge)
for (const snap of removedHips) {
@ -3555,10 +3563,37 @@ export function useEavesGableEdit(id) {
ix = -ix
iy = -iy
}
const apex = { x: mid.x + ix * (L / 2), y: mid.y + iy * (L / 2) }
// [KERAB-TYPE-EAVES-APEX-CLAMP 2026-06-15] apex 깊이 = L/2 (45° 이등변). 단 변이 폴리곤 inward
// 두께보다 길면(가로로 납작한 폴리곤) mid+inward*(L/2) 가 반대편 roofLine 을 넘어 폭주
// (R3-outside). "라인은 roofLine 까지" 규칙 → inward ray 가 반대편 변에 닿는 거리로 상한.
// 정상 切妻(세로로 긴 폴리곤)은 oppDist > L/2 라 min=L/2, 기존과 동치 → 무영향.
const oppRayDist = (P, dx, dy) => {
let best = Infinity
const M = cps.length
for (let i = 0; i < M; i++) {
const A = cps[i]
const B = cps[(i + 1) % M]
const ax = B.x - A.x
const ay = B.y - A.y
const den = dx * ay - dy * ax
if (Math.abs(den) < 1e-9) continue
const t = ((A.x - P.x) * ay - (A.y - P.y) * ax) / den
const u = ((A.x - P.x) * dy - (A.y - P.y) * dx) / den
if (t > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6 && t < best) best = t
}
return best
}
const oppDist = oppRayDist(mid, ix, iy)
const apexDepth = Math.min(L / 2, Number.isFinite(oppDist) ? oppDist : L / 2)
const apex = { x: mid.x + ix * apexDepth, y: mid.y + iy * apexDepth }
// 마루 단축: 케라바변쪽 끝점 → apex.
if (midIsA) ridge.set({ x1: apex.x, y1: apex.y })
else ridge.set({ x2: apex.x, y2: apex.y })
// [KERAB-TYPE-EAVES-RIDGE-ZERO 2026-06-15] apex 가 반대편 roofLine 에 클램프되면 native 마루가
// 거기로 단축돼 zero-length 가 될 수 있다(R2 위반). 객체는 revert 위해 보존하되 invisible 처리
// (R2 체크는 visible 만 검사). 마루 형상 재구성은 완전 우진각 시 reconstruct 가 담당.
const ridgeLen = Math.hypot(ridge.x2 - ridge.x1, ridge.y2 - ridge.y1)
if (ridgeLen < 1) ridge.visible = false
const rsz = calcLinePlaneSize({ x1: ridge.x1, y1: ridge.y1, x2: ridge.x2, y2: ridge.y2 })
if (ridge.attributes) {
ridge.attributes.planeSize = rsz
@ -3748,6 +3783,11 @@ export function useEavesGableEdit(id) {
// 힙 = apex → hit (단일 직선 45°).
if (apexIsE1) il.set({ x1: apex.x, y1: apex.y, x2: hit.x, y2: hit.y })
else il.set({ x1: hit.x, y1: hit.y, x2: apex.x, y2: apex.y })
// [KERAB-TYPE-EAVES-STARTPOINT-SYNC 2026-06-12] .set 은 'modified' 를 안 띄워 startPoint/endPoint
// 가 옛 좌표로 남는다. 할당 graph(splitPolygonWithLines)는 startPoint/endPoint 로 면을 닫으므로
// 동기화 누락 시 절삭된 힙이 옛 좌표로 연결돼 인접 roofLine 이 면에 포함되지 않아 미할당된다.
il.startPoint = { x: il.x1, y: il.y1 }
il.endPoint = { x: il.x2, y: il.y2 }
const sz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
if (il.attributes) {
il.attributes.planeSize = sz
@ -3839,6 +3879,10 @@ export function useEavesGableEdit(id) {
const d2 = Math.hypot(h.il.x2 - cenX, h.il.y2 - cenY)
if (d1 >= d2) h.il.set({ x2: rp.x, y2: rp.y })
else h.il.set({ x1: rp.x, y1: rp.y })
// [KERAB-TYPE-EAVES-STARTPOINT-SYNC 2026-06-12] 절삭 후 startPoint/endPoint 동기화.
// 누락 시 할당 graph 가 옛 inner 끝점으로 면을 닫으려다 실패 → 인접 roofLine 미할당.
h.il.startPoint = { x: h.il.x1, y: h.il.y1 }
h.il.endPoint = { x: h.il.x2, y: h.il.y2 }
const sz = calcLinePlaneSize({ x1: h.il.x1, y1: h.il.y1, x2: h.il.x2, y2: h.il.y2 })
if (h.il.attributes) {
h.il.attributes.planeSize = sz
@ -3936,6 +3980,296 @@ export function useEavesGableEdit(id) {
if (lbl) lbl.set({ visible: !hide })
}
// [KERAB-RECT-SOLVER 2026-06-15] 사각형(axis-aligned 4코너) 결정론적 케라바 솔버.
// 토글 이력과 무관하게 「최종 4변 타입」만으로 내부선(힙/마루/케라바 줄기)을 매번 처음부터
// 재생성한다 = 출폭/변타입의 순수 함수. forward/revert/type 미로를 통째로 우회.
// 구조적으로 보장되는 두 불변식:
// 불변식1(Y): 줄기(마루)는 박공으로 바뀐 변(slope 없음) 방향으로만 뻗는다. \|/, /|\ 불가.
// 불변식2: 힙은 apex(45° 교점)에서 멈춘다 → wallbase 교점 초과(폴리곤 밖) 힙 없음.
// 지원: 박공 0개(우진각)·1개(Y, apex 내부일 때)·2개 마주보기(맞배). 그 외(인접 2박공/3·4박공/
// 비사각형/회전사각형/radio2 타입)는 false 반환 → 기존 경로 폴백.
const solveRectangleKerab = (roof, target, newAttributes) => {
try {
if (!roof || !Array.isArray(roof.points) || roof.points.length !== 4) return false
if (!Array.isArray(roof.innerLines)) roof.innerLines = []
const outerLines = canvas.getObjects().filter((o) => o.name === 'outerLine' && o.attributes?.roofId === roof.id)
// [KERAB-RECT-PROBE 2026-06-15] outerLine 이 wallbase(출폭0) 좌표인지 roofLine 좌표인지
// 실제 데이터로 확정하기 위한 진입 덤프. solver 가 좌표 불일치로 false 폴백해도 이건 먼저 찍힘.
logger.log(
'[KERAB-RECT-PROBE] entry ' +
JSON.stringify({
roofPoints: roof.points.map((p) => ({ x: Math.round(p.x * 10) / 10, y: Math.round(p.y * 10) / 10 })),
outerLineCount: outerLines.length,
outerLines: outerLines.map((o) => ({
type: o.attributes?.type,
offset: o.attributes?.offset,
x1: Math.round(o.x1 * 10) / 10,
y1: Math.round(o.y1 * 10) / 10,
x2: Math.round(o.x2 * 10) / 10,
y2: Math.round(o.y2 * 10) / 10,
})),
newType: newAttributes?.type,
newOffset: newAttributes?.offset,
}),
)
if (outerLines.length !== 4) return false
const newType = newAttributes?.type
const TOL = 0.6
const xs = roof.points.map((p) => p.x)
const ys = roof.points.map((p) => p.y)
let minX = Math.min(...xs)
let maxX = Math.max(...xs)
let minY = Math.min(...ys)
let maxY = Math.max(...ys)
let W = maxX - minX
let H = maxY - minY
if (W < 2 || H < 2) return false
// 네 코너가 실제 직사각형 코너와 일치해야 함 (axis-aligned).
const corners = [
{ x: minX, y: minY },
{ x: maxX, y: minY },
{ x: maxX, y: maxY },
{ x: minX, y: maxY },
]
for (const c of corners) {
if (!roof.points.some((p) => Math.abs(p.x - c.x) < TOL && Math.abs(p.y - c.y) < TOL)) return false
}
// [KERAB-RECT-SIDE-FIX 2026-06-15] outerLine 은 roof.points(roofLine)에서 出幅만큼 안쪽으로
// 들어온 wallbase 좌표라, roof.points bbox 와 직접 비교하면 出幅(예 50)만큼 어긋나 TOL 초과 →
// sideOf 가 null → 솔버 전체 false 폴백(증상: 토글 시 KERAB-SIMPLE 미로로 빠져 그림 깨짐).
// 변 분류는 outerLine 자체 bbox 기준으로, 지오메트리(힙/마루)는 그대로 roof.points 기준으로 푼다.
const oxs = outerLines.flatMap((o) => [o.x1, o.x2])
const oys = outerLines.flatMap((o) => [o.y1, o.y2])
const oMinX = Math.min(...oxs)
const oMaxX = Math.max(...oxs)
const oMinY = Math.min(...oys)
const oMaxY = Math.max(...oys)
// 각 외곽선 → 변(top/bottom/left/right) 분류 + 유효 타입(EAVES/GABLE) 확정.
const sideOf = (o) => {
const horiz = Math.abs(o.y1 - o.y2) < TOL
const vert = Math.abs(o.x1 - o.x2) < TOL
if (horiz && Math.abs((o.y1 + o.y2) / 2 - oMinY) < TOL) return 'top'
if (horiz && Math.abs((o.y1 + o.y2) / 2 - oMaxY) < TOL) return 'bottom'
if (vert && Math.abs((o.x1 + o.x2) / 2 - oMinX) < TOL) return 'left'
if (vert && Math.abs((o.x1 + o.x2) / 2 - oMaxX) < TOL) return 'right'
return null
}
const sides = {}
for (const o of outerLines) {
const s = sideOf(o)
if (!s || sides[s]) return false
const eff = o === target ? newType : o.attributes?.type
if (eff !== LINE_TYPE.WALLLINE.EAVES && eff !== LINE_TYPE.WALLLINE.GABLE) return false
sides[s] = { o, gable: eff === LINE_TYPE.WALLLINE.GABLE }
}
if (!sides.top || !sides.bottom || !sides.left || !sides.right) return false
const gTop = sides.top.gable
const gBottom = sides.bottom.gable
const gLeft = sides.left.gable
const gRight = sides.right.gable
const nGable = [gTop, gBottom, gLeft, gRight].filter(Boolean).length
// [KERAB-RECT-GATE 2026-06-15] 出幅을 경계에 반영하기 전에 지원 형상인지 먼저 판정한다.
// 미지원(인접 2박공/3·4박공/얕은 Y)인데 offset 을 먼저 옮기면 폴백된 미로가 offset 을 한 번 더
// 적용해 이중 出幅이 된다 → 반드시 무변형 상태에서 게이트하고, 미지원이면 boundary 손대지 않고 false.
{
let ok = false
if (nGable === 0) ok = true
// [KERAB-RECT-GATE-1 2026-06-15] 단일 박공은 항상 지원: depth<=span 이면 Y(apex)모델,
// long-edge(케라바, depth>span)면 CASE B(반대코너 45° 힙 2개 + 변 위 마루 1개). 둘 다 정의됨.
else if (nGable === 1) ok = true
else if (nGable === 2) {
ok = (gTop && gBottom) || (gLeft && gRight)
}
if (!ok) return false
}
// [KERAB-RECT-OFFSET 2026-06-15] 지원 형상 확정 후, 토글되는 변의 出幅 변경분을 경계(roofLine)에
// 먼저 반영한다(inner line 은 안 건드림). 이후 갱신된 roof.points 로 내부 지오메트리를 푼다.
// 出幅 입력이 기존과 같으면 delta 0 → 무동작. 다르면 maze 의 offset-surgical 과 동일 경로.
if (newAttributes && newAttributes.offset != null) {
applyTargetOffsetSurgical(target, newAttributes.offset, { skipInnerLines: true })
const nxs = roof.points.map((p) => p.x)
const nys = roof.points.map((p) => p.y)
minX = Math.min(...nxs)
maxX = Math.max(...nxs)
minY = Math.min(...nys)
maxY = Math.max(...nys)
W = maxX - minX
H = maxY - minY
}
const cx = (minX + maxX) / 2
const cy = (minY + maxY) / 2
const out = []
const addRidge = (x1, y1, x2, y2) => {
if (Math.hypot(x2 - x1, y2 - y1) >= 1) out.push({ x1, y1, x2, y2, kind: 'ridge' })
}
const addHip = (x1, y1, x2, y2) => {
if (Math.hypot(x2 - x1, y2 - y1) >= 1) out.push({ x1, y1, x2, y2, kind: 'hip' })
}
if (nGable === 0) {
// 우진각: 마루는 긴 축, apex 는 짧은 변/2 안쪽. 힙 4개.
if (W >= H) {
const inset = H / 2
const lA = { x: minX + inset, y: cy }
const rA = { x: maxX - inset, y: cy }
addRidge(lA.x, lA.y, rA.x, rA.y)
addHip(minX, minY, lA.x, lA.y)
addHip(minX, maxY, lA.x, lA.y)
addHip(maxX, minY, rA.x, rA.y)
addHip(maxX, maxY, rA.x, rA.y)
} else {
const inset = W / 2
const tA = { x: cx, y: minY + inset }
const bA = { x: cx, y: maxY - inset }
addRidge(tA.x, tA.y, bA.x, bA.y)
addHip(minX, minY, tA.x, tA.y)
addHip(maxX, minY, tA.x, tA.y)
addHip(minX, maxY, bA.x, bA.y)
addHip(maxX, maxY, bA.x, bA.y)
}
} else if (nGable === 1) {
// 박공변이 짧은/중간 변(맞은편 apex 가 폴리곤 안)이면 Y(apex+마루).
// 긴 변(케라바=마루와 평행, W>2H or H>2W)이면 맞은편 두 코너에서 45° 힙이 박공변까지
// 직진하고 박공변 위에 마루가 놓인다(CASE B). 어느 쪽이든 단일 박공은 항상 유효.
if (gBottom || gTop) {
const depth = W / 2 // apex 깊이 = 박공변(가로) 길이/2
if (depth <= H - 0.5) {
// Y: apex 가 맞은편 변 안쪽
if (gBottom) {
const apex = { x: cx, y: minY + depth }
addHip(minX, minY, apex.x, apex.y)
addHip(maxX, minY, apex.x, apex.y)
addRidge(apex.x, apex.y, cx, maxY)
} else {
const apex = { x: cx, y: maxY - depth }
addHip(minX, maxY, apex.x, apex.y)
addHip(maxX, maxY, apex.x, apex.y)
addRidge(apex.x, apex.y, cx, minY)
}
} else if (gBottom) {
// 케라바(긴 가로 변): 위쪽 두 코너 → 박공변(아래)까지 45° 직진 + 박공변 위 마루
addHip(minX, minY, minX + H, maxY)
addHip(maxX, minY, maxX - H, maxY)
addRidge(minX + H, maxY, maxX - H, maxY)
} else {
addHip(minX, maxY, minX + H, minY)
addHip(maxX, maxY, maxX - H, minY)
addRidge(minX + H, minY, maxX - H, minY)
}
} else {
const depth = H / 2
if (depth <= W - 0.5) {
if (gLeft) {
const apex = { x: maxX - depth, y: cy }
addHip(maxX, minY, apex.x, apex.y)
addHip(maxX, maxY, apex.x, apex.y)
addRidge(apex.x, apex.y, minX, cy)
} else {
const apex = { x: minX + depth, y: cy }
addHip(minX, minY, apex.x, apex.y)
addHip(minX, maxY, apex.x, apex.y)
addRidge(apex.x, apex.y, maxX, cy)
}
} else if (gLeft) {
// 케라바(긴 세로 변): 오른쪽 두 코너 → 박공변(왼쪽)까지 45° 직진 + 박공변 위 마루
addHip(maxX, minY, minX, minY + W)
addHip(maxX, maxY, minX, maxY - W)
addRidge(minX, minY + W, minX, maxY - W)
} else {
addHip(minX, minY, maxX, minY + W)
addHip(minX, maxY, maxX, maxY - W)
addRidge(maxX, minY + W, maxX, maxY - W)
}
}
} else if (nGable === 2) {
// 맞배: 마주보는 두 박공 사이를 잇는 단일 마루, 힙 없음.
if (gTop && gBottom) {
addRidge(cx, minY, cx, maxY)
} else if (gLeft && gRight) {
addRidge(minX, cy, maxX, cy)
} else {
return false // 인접 2박공 = 굽은 마루, 미지원 → 폴백
}
} else {
return false // 3·4 박공 → 폴백
}
if (!out.length) return false
const beforeSnap = snapshotKerabState(roof)
// 기존 내부 HIP/RIDGE(네이티브 + kerabPattern) 전부 제거 후 재생성.
for (const il of [...roof.innerLines]) {
if (!il) continue
if (il.name === LINE_TYPE.SUBLINE.HIP || il.name === LINE_TYPE.SUBLINE.RIDGE) {
canvas.remove(il)
const idx = roof.innerLines.indexOf(il)
if (idx >= 0) roof.innerLines.splice(idx, 1)
}
}
for (const o of outerLines) removeKerabHalfLabels(o.id)
for (const ln of out) {
const sz = calcLinePlaneSize({ x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 })
const isRidge = ln.kind === 'ridge'
const q = new QLine([ln.x1, ln.y1, ln.x2, ln.y2], {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: isRidge ? 4 : 3,
name: isRidge ? LINE_TYPE.SUBLINE.RIDGE : LINE_TYPE.SUBLINE.HIP,
textMode: roof.textMode,
attributes: {
roofId: roof.id,
type: isRidge ? LINE_TYPE.SUBLINE.RIDGE : LINE_TYPE.SUBLINE.HIP,
planeSize: sz,
actualSize: sz,
// [KERAB-RECT-EXTENDED 2026-06-15] 솔버 hip 은 roofLine 코너(=출폭 끝점)까지 이미 닿아 있다.
// allocation 의 integrateExtensionLines 가 ext+hip 머지로 hip 을 wall 코너까지 단축시키면
// split graph 가 polygon 코너와 끊겨 1면만 잡힌다(native hip 은 extended 플래그로 머지 스킵).
// 동일하게 extended=true 부여 → INTEGRATE 가 merge 스킵, ext 는 lines 에서만 제거.
...(isRidge ? {} : { extended: true }),
},
})
q.lineName = ln.kind
if (!isRidge) q.__extended = true
// 할당 graph(splitPolygonWithLines)는 startPoint/endPoint 로 면을 닫으므로 동기화 필수.
q.startPoint = { x: ln.x1, y: ln.y1 }
q.endPoint = { x: ln.x2, y: ln.y2 }
canvas.add(q)
q.bringToFront()
roof.innerLines.push(q)
if (typeof q.setCoords === 'function') q.setCoords()
if (typeof q.setLength === 'function') q.setLength()
if (typeof q.addLengthText === 'function') q.addLengthText()
}
// 박공 변엔 절반 라벨 + 원본 길이 숨김, 처마 변엔 원본 길이 노출.
for (const key of ['top', 'bottom', 'left', 'right']) {
const s = sides[key]
if (s.gable) {
addKerabHalfLabels(s.o, { x: s.o.x1, y: s.o.y1 }, { x: s.o.x2, y: s.o.y2 })
hideOriginalLengthText(s.o.id, true)
} else {
hideOriginalLengthText(s.o.id, false)
}
}
target.set({ attributes: newAttributes })
canvas.renderAll()
const phase = newType === LINE_TYPE.WALLLINE.GABLE ? 'forward' : 'revert'
runKerabRuleCheck(roof, phase, beforeSnap)
try {
reattachDebugLabels(canvas, roof.id)
} catch (e) {}
logger.log('[KERAB-RECT-SOLVER] built ' + JSON.stringify({ nGable, lines: out.length, W: Math.round(W), H: Math.round(H) }))
return true
} catch (err) {
logger.warn('[KERAB-RECT-SOLVER] error → fallback', err)
return false
}
}
// [KERAB-PATTERN-EXT-CLEAN 2026-05-19] forward 시 제거되는 hip 과 짝이었던 orphan
// extensionLine 정리. 한 끝점이 hip 의 한 끝점과 같고 다른 끝점이 hip 의 진행
// 방향과 동일선상에 있으면 짝으로 판정. 잔류 시 allocation 의 integrateExtensionLines

View File

@ -1228,6 +1228,17 @@ export function useRoofAllocationSetting(id) {
roofBase.innerLines.push(...made)
}
// [KERAB-TYPE-EAVES-SK-HELPER-PURGE 2026-06-12] kerabPatternHip 이 있을 때
// name='hip', lineName 미설정 인 SK 격자 반-엣지를 innerLines 에서 제거한다.
// 이 선들은 외곽 gable 변과 동일직선상에서 더 짧은 경로를 제공하여 Dijkstra 가
// degenerate collinear face 를 먼저 선택 → 진짜 top/bottom 사다리꼴 면의 start 가
// 소진돼 미생성된다. kerab 케이스에서만 purge — 일반 SK hip 에서는 건드리지 않음.
if (roofBase.innerLines.some((l) => l?.lineName === 'kerabPatternHip')) {
const skHelpers = roofBase.innerLines.filter((l) => !l?.lineName && l?.name === 'hip')
skHelpers.forEach((l) => canvas.remove(l))
roofBase.innerLines = roofBase.innerLines.filter((l) => !(!l?.lineName && l?.name === 'hip'))
}
if (roofBase.separatePolygon.length > 0) {
splitPolygonWithSeparate(roofBase.separatePolygon)
} else {

View File

@ -10,6 +10,7 @@
"header.menus.community.notice": "お知らせ",
"header.menus.community.faq": "FAQ",
"header.menus.community.archive": "各種資料ダウンロード",
"header.menus.economic.simulation": "経済シミュレーション管理",
"header.logout": "ログアウト",
"header.go": "移動",
"header.online.warranty.system": "オンライン保証システム",
@ -589,6 +590,7 @@
"common.message.no.edit.save": "このドキュメントは変更できません。",
"common.message.ref.file.change": "ファイルを変更しますか?",
"common.message.ref.file.noImage": "画像ではありません。",
"common.message.ref.file.convertFail": "図面の変換に失敗しました。時間をおいて再度お試しください。",
"common.load": "読込",
"common.input.file": "ファイルを読み込む",
"common.input.file.load": "読込",

View File

@ -10,6 +10,7 @@
"header.menus.community.notice": "공지사항",
"header.menus.community.faq": "FAQ",
"header.menus.community.archive": "각종 자료 다운로드",
"header.menus.economic.simulation": "경제시뮬레이션 관리",
"header.logout": "로그아웃",
"header.go": "이동",
"header.online.warranty.system": "온라인보증시스템",
@ -328,7 +329,7 @@
"modal.canvas.setting.first.option.display": "화면 표시",
"modal.canvas.setting.first.option.border": "테두리만",
"modal.canvas.setting.first.option.line": "라인해치",
"modal.canvas.setting.first.option.all": "All painted",
"modal.canvas.setting.first.option.all": "전체 칠하기",
"modal.canvas.setting.wallline.properties.setting": "외벽선 속성 설정",
"modal.canvas.setting.wallline.properties.setting.info": "※ 속성을 변경할 외벽선을 선택하고 처마로 설정 또는 케라바로 설정\n 버튼을 클릭하여 설정값을 적용하십시오.\n",
"modal.canvas.setting.wallline.properties.setting.eaves": "처마로 설정",
@ -534,12 +535,12 @@
"common.message.file.template.validation03": "Non-registerable extension",
"common.message.file.template.validation04": "Exceed capacity \n Uploadable capacity ",
"common.message.file.template.validation05": "업로드 파일을 선택해주세요. {0} MB",
"common.message.multi.insert": "Total {0} cases ({1} successes {2} failures {3})",
"common.message.multi.insert": "총 {0}건 (성공 {1} 실패 {2} {3})",
"common.message.error": "Error occurred please contact site administrator.",
"common.message.data.save": "Do you want to save it?",
"common.message.data.delete": "정말로 삭제하시겠습니까?",
"common.message.data.exists": "{0} is data that already exists.",
"common.message.data.no.exists": "{0} is data that does not exist.",
"common.message.data.exists": "{0}은(는) 이미 존재하는 데이터입니다.",
"common.message.data.no.exists": "{0}은(는) 존재하지 않는 데이터입니다.",
"common.message.all": "All",
"common.message.tab.close.all": "Close all tabs?",
"common.message.transfer.save": "Want to {0} transfer it?",
@ -570,18 +571,18 @@
"common.message.menu.validation01": "There is no menu to save the order.",
"common.message.menu.validation02": "The same sort order exists.",
"common.message.menuCode.check01": "Registerable",
"common.message.menuCode.check02": "Unable to register",
"common.message.pleaseSelect": "Please Select {0}",
"common.message.pleaseInput": "Please Input a {0}.",
"common.message.pleaseInputOr": "Please Input a {0} or {1}.",
"common.message.menuCode.check02": "등록할 수 없습니다.",
"common.message.pleaseSelect": "{0}을(를) 선택해 주세요.",
"common.message.pleaseInput": "{0}을(를) 입력해 주세요.",
"common.message.pleaseInputOr": "{0} 또는 {1}을(를) 입력해 주세요.",
"common.message.please.input.over": "{0} 이상 값을 입력해주세요.",
"common.message.approved ": "Approved.",
"common.message.errorFieldExist": "Error Field Exist",
"common.message.storeIdExist ": "이미 사용하고 있는 판매점 ID 입니다.",
"common.message.userIdExist ": "이미 사용하고 있는 사용자 ID 입니다.",
"common.message.noExists ": "삭제된 게시물 입니다.",
"common.message.emailReqTo": "Email To is required",
"common.message.downloadPeriod": "Please select the download search period within {0} days.",
"common.message.emailReqTo": "받는 사람(Email To)은 필수입니다.",
"common.message.downloadPeriod": "다운로드 조회 기간을 {0}일 이내로 선택해 주세요.",
"common.message.backToSubmit": "판매점 블록 해제를 실행하시겠습니까?",
"common.message.backToG3": "Back to G3 처리를 실행하시겠습니까?",
"common.message.writeToConfirm": "작성 해제를 실행하시겠습니까?",
@ -589,6 +590,7 @@
"common.message.no.edit.save": "This document cannot be changed.",
"common.message.ref.file.change": "파일을 변경하시겠습니까?",
"common.message.ref.file.noImage": "이미지가 아닙니다.",
"common.message.ref.file.convertFail": "도면 변환에 실패했습니다. 잠시 후 다시 시도해 주세요.",
"common.load": "불러오기",
"common.input.file": "파일 불러오기",
"common.input.file.load": "불러오기",
@ -911,7 +913,7 @@
"offset": "출폭",
"width": "폭",
"size": "치수",
"size.angle": "寸(度)",
"size.angle": "치(도)",
"eaves": "처마",
"eaves.line": "처마선",
"gable": "케라바",
@ -999,10 +1001,10 @@
"estimate.detail.objectName": "안건명",
"estimate.detail.objectRemarks": "메모",
"estimate.detail.estimateType": "주문분류",
"estimate.detail.estimateType.yjss": "住宅PKG",
"estimate.detail.estimateType.yjod": "積上げ( YJOD )",
"estimate.detail.estimateType.yjss": "주택PKG",
"estimate.detail.estimateType.yjod": "적산(YJOD)",
"estimate.detail.agency": "2차점명",
"estimate.detail.roofCns": "지붕재・사양시공",
"estimate.detail.roofCns": "지붕재·시공구분",
"estimate.detail.remarks": "비고",
"estimate.detail.fileFlg": "후일자료제출",
"estimate.detail.dragFileGuide": "(※ 북면설치인 경우, 파일 첨부가 필수입니다.)",

View File

@ -1,8 +1,11 @@
import { atom } from 'recoil'
// localhost(yarn dev, RUN_MODE=local) 에서만 기본 한국어, 그 외(dev/prd)는 일본어
const DEFAULT_LOCALE = process.env.NEXT_PUBLIC_RUN_MODE === 'local' ? 'ko' : 'ja'
export const globalLocaleStore = atom({
key: 'globalLocaleState',
default: 'ja',
default: DEFAULT_LOCALE,
})
export const appMessageStore = atom({

View File

@ -29,7 +29,7 @@ const lineLineIntersection = (p1, p2, p3, p4) => {
* @returns true=적용됨 / false=조건 미달 또는 변경 없음
*/
export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}) => {
const { skipInnerLines = false } = options
const { skipInnerLines = false, skipKerabHips = false } = options
const roof = canvas
.getObjects()
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
@ -363,6 +363,10 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
// 골짜기(케라바 패턴) 라인과 일반 라인은 각자 독립 핸들러로 처리되어 서로 간섭하지 않는다.
for (const il of roof.innerLines || []) {
if (!il) continue
// [KERAB-HIP-45-PROTECT 2026-06-15] 케라바 hip(확장라인)은 CORNER-SNAP 금지 —
// 호출자(reclick 45° 블록)가 wall 코너에서 45° ray-cast 로 roofLine 까지 뻗는다.
// 여기서 recomputeKerabPatternLine 의 t-ratio 매핑을 타면 roofLine 코너로 스냅돼 룰 위반.
if (skipKerabHips && il.lineName === 'kerabPatternHip') continue
if (isKerabPatternLine(il)) recomputeKerabPatternLine(il)
else recomputeNormalLine(il)
}