diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index 0df6c176..7810851d 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -48,10 +48,21 @@ function __classifyLineForLabel(obj) { return null } +export function reattachDebugLabels(canvas, parentId) { + __attachDebugLabels(canvas, parentId) +} + function __attachDebugLabels(canvas, parentId) { if (!__isDebugLabelsEnabled()) return if (!canvas || !parentId) return + // [KERAB-LABEL-REATTACH 2026-05-29] 케라바 토글 등으로 라인이 추가/변경된 후 재호출 가능하도록 + // 같은 parentId 의 기존 __debugLabel 먼저 제거 → 카운트 reset 후 새로 부여. + canvas + .getObjects() + .filter((o) => o.name === DEBUG_LABEL_NAME && o.parentId === parentId) + .forEach((o) => canvas.remove(o)) + const counters = {} const objects = canvas.getObjects().filter((o) => o.parentId === parentId && o.name !== DEBUG_LABEL_NAME) diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index db3dbb0f..e4373713 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -14,6 +14,7 @@ import { settingModalFirstOptionsState } from '@/store/settingAtom' import { useUndoRedo } from '@/hooks/useUndoRedo' import { fabric } from 'fabric' import { QLine } from '@/components/fabric/QLine' +import { reattachDebugLabels } from '@/components/fabric/QPolygon' import { calcLinePlaneSize } from '@/util/qpolygon-utils' import { findInteriorPoint } from '@/util/skeleton-utils' import { logger } from '@/util/logger' @@ -153,10 +154,26 @@ export function useEavesGableEdit(id) { } const mouseDownEvent = (e) => { - canvas.discardActiveObject() - if (!e.target || (e.target && e.target.name !== 'outerLine')) { + // [KERAB-MOUSEDOWN-GUARD 2026-05-29] outerLine 아닌 target(ridge/lengthText 등) 클릭 시 + // discardActiveObject·로그·후속 처리 모두 skip — 다른 hook 의 active 흐름 보호. + if (!e.target || e.target.name !== 'outerLine') { return } + logger.log( + '[KERAB-MOUSEDOWN] fired ' + + JSON.stringify({ + hasTarget: !!e.target, + name: e.target?.name, + type: typeRef.current, + radio: radioTypeRef.current, + targetType: e.target?.attributes?.type, + x1: e.target?.x1, + y1: e.target?.y1, + x2: e.target?.x2, + y2: e.target?.y2, + }), + ) + canvas.discardActiveObject() const target = e.target @@ -265,6 +282,35 @@ export function useEavesGableEdit(id) { roofFound: !!roof, }), ) + // [KERAB-INNERLINES-DUMP 2026-05-27] 토글 진입/종료 시점 innerLines ridge/hip 스냅샷. + // - cascade hide / trim 으로 어떤 라인이 어디서 끊겼는지 추적용 임시 진단 로그. + const dumpInnerLineSnapshot = (label) => { + if (!roof || !Array.isArray(roof.innerLines)) return + const rows = roof.innerLines + .filter((l) => l && (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE)) + .map((l) => ({ + lab: l.label || '?', + n: l.name, + ln: l.lineName || '-', + v: l.visible !== false, + x1: Math.round(l.x1 * 10) / 10, + y1: Math.round(l.y1 * 10) / 10, + x2: Math.round(l.x2 * 10) / 10, + y2: Math.round(l.y2 * 10) / 10, + })) + logger.log('[KERAB-INNERLINES-' + label + '] ' + JSON.stringify(rows)) + // [KERAB-LABEL-REATTACH 2026-05-29] AFTER 시점에서 라벨 재부착 (local 모드 한정). + // 케라바 토글로 추가/변경된 kerabPatternRidge/ExtRidge/Hip 등에도 H-/RG- 라벨 부여. + if (label === 'AFTER') { + try { + reattachDebugLabels(canvas, roof.id) + } catch (e) { + logger.warn('[KERAB-LABEL-REATTACH] failed', e) + } + } + } + dumpInnerLineSnapshot('BEFORE') + // [KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 직전 target.attributes.offset 을 roofLine 에 surgical 반영. // SK 재실행 없이 외곽 corner / inner-line endpoint 만 이동 → kLine 등 layered custom 라인 보존. if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0) @@ -605,9 +651,9 @@ export function useEavesGableEdit(id) { const FAR_RAY = 1e5 const start = { x: ep.sx, y: ep.sy } const rayEnd = { x: ep.sx + ux * FAR_RAY, y: ep.sy + uy * FAR_RAY } - // [KERAB-VALLEY-EXT-ALWAYS-MID 2026-05-27] 사용자 규칙: "골짜기 세로라인이 더 확장되어야 한다." - // RIDGE stopper 제거 — 무조건 polygon-wall 까지 거리의 절반 = polygon width 의 대칭중앙. - // ridge·hip 은 모두 통과 (경로 위 교차 시 trim 단계에서 절삭). + // [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-27] 새 규칙: + // 1) ridge(마루) 만나면 그 점에서 정지. hip 은 통과. + // 2) ridge 못 만나면 맞은편 polygon-wall(roofLine 너머) 까지 끝까지 확장 (절반 아님). let bestPt = null let wallT = Infinity let wallHit = null @@ -622,17 +668,45 @@ export function useEavesGableEdit(id) { wallHit = ip } } - if (wallHit) { - bestPt = { x: (start.x + wallHit.x) / 2, y: (start.y + wallHit.y) / 2 } + let ridgeStop = null + let ridgeT = Infinity + // [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 골짜기 확장라인이 "원래 있던" ridge(마루) 만나면 그 점에서 정지. + // 확장으로 생긴 ridge(kerabPatternRidge/kerabPatternExtRidge 등) 는 stop 대상 아님. + // 화이트리스트: lineName === 'ridge' 만 매칭. + for (const il of roof.innerLines || []) { + if (!il || il.visible === false) continue + if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + if (il.lineName !== 'ridge') continue + const ip = lineLineIntersection(start, rayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) + if (!ip) continue + if (!isPointOnSegment(ip, il.x1, il.y1, il.x2, il.y2)) continue + const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy + if (t < 0.5) continue + if (wallT !== Infinity && t > wallT + 0.5) continue + if (t < ridgeT) { + ridgeT = t + ridgeStop = ip + } + } + if (ridgeStop) { + bestPt = ridgeStop logger.log( - '[KERAB-VALLEY-EXT-MID] pre label=' + + '[KERAB-VALLEY-EXT-RIDGE-STOP] pre label=' + ep.label + - ' wallHit={' + - Math.round(wallHit.x * 100) / 100 + + ' ridgeT=' + + Math.round(ridgeT * 100) / 100 + + ' stop={' + + Math.round(bestPt.x * 100) / 100 + ',' + - Math.round(wallHit.y * 100) / 100 + - '}' + - ' mid={' + + Math.round(bestPt.y * 100) / 100 + + '}', + ) + } else if (wallHit) { + bestPt = wallHit + logger.log( + '[KERAB-VALLEY-EXT-WALL] pre label=' + + ep.label + + ' end={' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + @@ -1332,7 +1406,13 @@ export function useEavesGableEdit(id) { // (d) re-resolve: 죽은 segment 들 위에 stop 이 있던 resolved entry → 재확장. // killed = static line 의 잘린 stub (cut.point → removedEnd) + // 방금 purge 한 drawn segment 들(source → stop). - const killedSegments = [{ a: { x: cut.point.x, y: cut.point.y }, b: { x: removedEnd.x, y: removedEnd.y } }, ...purgedSegments] + const killedSegments = [ + { + a: { x: cut.point.x, y: cut.point.y }, + b: { x: removedEnd.x, y: removedEnd.y }, + }, + ...purgedSegments, + ] const staleExts = [] for (const [ext, stop] of Array.from(resolved.entries())) { const onKilled = killedSegments.some((seg) => isPointOnSegment(stop, seg.a.x, seg.a.y, seg.b.x, seg.b.y, 0.5)) @@ -1488,7 +1568,12 @@ export function useEavesGableEdit(id) { isHip: e.isHip, })), drawKLine, - markerApex: markerApex ? { x: Math.round(markerApex.x * 100) / 100, y: Math.round(markerApex.y * 100) / 100 } : null, + markerApex: markerApex + ? { + x: Math.round(markerApex.x * 100) / 100, + y: Math.round(markerApex.y * 100) / 100, + } + : null, apexList: apexList.map((ap) => ({ x: Math.round(ap.point.x * 100) / 100, y: Math.round(ap.point.y * 100) / 100, @@ -1515,6 +1600,13 @@ export function useEavesGableEdit(id) { // raycast 대상 = 현재 roof.innerLines 중 HIP/RIDGE (= 살아남은 hip·ridge + 새 extLines·kLine 모두 포함). // 사라진 polygonPath hip/ridge 는 이미 제거되어 자동 제외. valleyExt 자신은 push 후이므로 1mm 필터로 보호. if (valleyPlannedEndpoints.length) { + // ==================================================================== + // [KERAB-VALLEY-EXT 2026-05-28] valleyExt helper 4종 (Step B 추출). + // Phase 1 = computeValleyExtensions + drawValleyExtensions + // Phase 2 = trimByValleyExtensions + cascadeHideByValleyExtensions + // 모든 helper 는 closure 로 roof/target/canvas/roofPolygonWalls/valleyPlannedEndpoints 캡처. + // revert 계약 (lineName='kerabPatternValleyExt', __targetId, target.__valleyExtTrims) 그대로 유지. + // ==================================================================== const isOnSegV = (pt, ax, ay, bx, by, tol = 0.5) => { const sdx = bx - ax const sdy = by - ay @@ -1527,279 +1619,595 @@ export function useEavesGableEdit(id) { const py = ay + tt * sdy return Math.hypot(px - pt.x, py - pt.y) <= tol } - const valleyExtensions = [] - for (const ep of valleyPlannedEndpoints) { - const start = { x: ep.sx, y: ep.sy } - // [KERAB-VALLEY-EXT 2026-05-27] self-extension 방향만 사용 (양 끝점 둘 다 시도). - // concave corner 측 끝점 → polygon 내부로 향해 hip/ridge hit → 그려짐. - // convex 측 끝점 → polygon 외부로 새서 hit 없음 → 자동 skip. - const dx = ep.sx - ep.ox - const dy = ep.sy - ep.oy - const len = Math.hypot(dx, dy) || 1 - const ux = dx / len - const uy = dy / len - const FAR_RAY = 1e5 - const rayEnd = { x: start.x + ux * FAR_RAY, y: start.y + uy * FAR_RAY } - // [KERAB-VALLEY-EXT-ALWAYS-MID 2026-05-27] 사용자 규칙: "골짜기 세로라인이 더 확장되어야 한다." - // RIDGE stopper 제거 — 무조건 polygon-wall midpoint(대칭중앙)까지. - // 경로 위 교차 hip/ridge 는 trim 단계에서 절삭 (KERAB-VALLEY-EXT-TRIM). - let bestStop = null - let wallT = Infinity - let wallHit = null - for (const w of roofPolygonWalls) { - const ip = lineLineIntersection(start, rayEnd, w.a, w.b) - if (!ip) continue - if (!isOnSegV(ip, w.a.x, w.a.y, w.b.x, w.b.y)) continue - const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy - if (t < 0.5) continue - if (t < wallT) { - wallT = t - wallHit = ip + + // ── Phase 1-a: valleyExt ray 계산 ── + // self-extension 방향만 사용 (양 끝점 둘 다 시도, concave 측만 hit). + // ridge meet first, 못 만나면 wallhit 끝까지. hip 통과. + const computeValleyExtensions = () => { + const exts = [] + for (const ep of valleyPlannedEndpoints) { + const start = { x: ep.sx, y: ep.sy } + const dx = ep.sx - ep.ox + const dy = ep.sy - ep.oy + const len = Math.hypot(dx, dy) || 1 + const ux = dx / len + const uy = dy / len + const FAR_RAY = 1e5 + const rayEnd = { x: start.x + ux * FAR_RAY, y: start.y + uy * FAR_RAY } + let bestStop = null + let wallT = Infinity + let wallHit = null + for (const w of roofPolygonWalls) { + const ip = lineLineIntersection(start, rayEnd, w.a, w.b) + if (!ip) continue + if (!isOnSegV(ip, w.a.x, w.a.y, w.b.x, w.b.y)) continue + const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy + if (t < 0.5) continue + if (t < wallT) { + wallT = t + wallHit = ip + } + } + let ridgeStop = null + let ridgeT = Infinity + // [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 원래 ridge 만 stop (roof 측 post 경로). + for (const il of roof.innerLines || []) { + if (!il || il.visible === false) continue + if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + if (il.lineName !== 'ridge') continue + const ip = lineLineIntersection(start, rayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) + if (!ip) continue + if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue + const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy + if (t < 0.5) continue + if (wallT !== Infinity && t > wallT + 0.5) continue + if (t < ridgeT) { + ridgeT = t + ridgeStop = ip + } + } + if (ridgeStop) { + bestStop = ridgeStop + logger.log( + '[KERAB-VALLEY-EXT-RIDGE-STOP] post label=' + + ep.label + + ' ridgeT=' + + Math.round(ridgeT * 100) / 100 + + ' stop={' + + Math.round(bestStop.x * 100) / 100 + + ',' + + Math.round(bestStop.y * 100) / 100 + + '}', + ) + } else if (wallHit) { + bestStop = wallHit + logger.log( + '[KERAB-VALLEY-EXT-WALL] post label=' + + ep.label + + ' end={' + + Math.round(bestStop.x * 100) / 100 + + ',' + + Math.round(bestStop.y * 100) / 100 + + '}', + ) + } + if (bestStop) { + const seg = { + x1: start.x, + y1: start.y, + x2: bestStop.x, + y2: bestStop.y, + source: ep.label, + parent: ep.parent || null, + } + exts.push(seg) + logger.log('[KERAB-VALLEY-EXT] generated ' + JSON.stringify(seg)) + } else { + logger.log('[KERAB-VALLEY-EXT] no ridge/hip hit for ' + ep.label) } } - if (wallHit) { - bestStop = { x: (start.x + wallHit.x) / 2, y: (start.y + wallHit.y) / 2 } - logger.log( - '[KERAB-VALLEY-EXT-MID] post label=' + - ep.label + - ' wallHit={' + - Math.round(wallHit.x * 100) / 100 + - ',' + - Math.round(wallHit.y * 100) / 100 + - '}' + - ' mid={' + - Math.round(bestStop.x * 100) / 100 + - ',' + - Math.round(bestStop.y * 100) / 100 + - '}', - ) - } - if (bestStop) { - const seg = { - x1: start.x, - y1: start.y, - x2: bestStop.x, - y2: bestStop.y, - source: ep.label, - parent: ep.parent || null, + return exts + } + + // ── Phase 1-b: roof + wall QLine 생성 (순수 그리기, trim/hide 없음) ── + const drawValleyExtensions = (valleyExtensions) => { + for (const vr of valleyExtensions) { + const pts = [vr.x1, vr.y1, vr.x2, vr.y2] + const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) + // [KERAB-VALLEY-EXT 2026-05-27] 지붕면 할당 통합 (option a): + // roofBase 확장은 처마의 연장 → attributes.type=EAVES + parentLine + innerLines push (split 참여). + // wallBase 확장은 wall layer (roof 폴리곤 밖) → 시각만, innerLines 비추가, type/parent 부여 안 함. + const isRoofBase = !!(vr.source && vr.source.startsWith('roofBase')) + const baseAttrs = { roofId: roof.id, planeSize: sz, actualSize: sz } + if (isRoofBase) { + // [KERAB-VALLEY-EXT 2026-05-27] splitPolygonWithLines 의 innerLineMapping 은 + // EAVES polygonLine 을 skip 하므로(usePolygon.js:822) 처마확장은 자동 매핑이 안 된다. + // 처마 라인 매핑과 동일하게 type/isStart 를 직접 부여. + baseAttrs.type = LINE_TYPE.WALLLINE.EAVES + baseAttrs.isStart = true + } + const vExt = new QLine(pts, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 3, + name: LINE_TYPE.SUBLINE.VALLEY, + textMode: roof.textMode, + attributes: baseAttrs, + }) + vExt.lineName = 'kerabPatternValleyExt' + vExt.__targetId = target.id + vExt.__valleyExtSource = vr.source + if (isRoofBase && vr.parent) { + vExt.parentLine = vr.parent + // [KERAB-VALLEY-EXT 2026-05-27] direction 도 부모 roofLine 에서 상속 — flow/pitch 방향 일관. + if (vr.parent.direction) vExt.direction = vr.parent.direction + } + canvas.add(vExt) + vExt.bringToFront() + if (isRoofBase) roof.innerLines.push(vExt) + + // [KERAB-VALLEY-EXT-WALL 2026-05-28] wallBase 레벨 확장 라인 — 독립 ray. + // 사용자 룰: "맞은편 roofLine 까지" — wallBase 측도 자체 시작점에서 ray 쏴서 polygon-wall hit 까지. + // roof 측 끝점과 공유 X (그건 만나라는 의미). roof 측과 동일 방향, ridge-stop 룰 동일. + // wallBase 측은 시각만 — innerLines push X, type=EAVES X, parentLine X. + if (isRoofBase && vr.parent && target) { + const rl = vr.parent + const dxR = rl.x2 - rl.x1 + const dyR = rl.y2 - rl.y1 + const lenR = Math.hypot(dxR, dyR) || 1 + const uxR2 = dxR / lenR + const uyR2 = dyR / lenR + const sT1 = (target.x1 - rl.x1) * uxR2 + (target.y1 - rl.y1) * uyR2 + const sT2 = (target.x2 - rl.x1) * uxR2 + (target.y2 - rl.y1) * uyR2 + const tStart = sT1 < sT2 ? { x: target.x1, y: target.y1 } : { x: target.x2, y: target.y2 } + const tEnd = sT1 < sT2 ? { x: target.x2, y: target.y2 } : { x: target.x1, y: target.y1 } + const wallCorner = vr.source === 'roofBase-s' ? tStart : tEnd + // 방향: vr 방향 그대로 (vr.x1,y1 → vr.x2,y2) + const vdx = vr.x2 - vr.x1 + const vdy = vr.y2 - vr.y1 + const vlen = Math.hypot(vdx, vdy) || 1 + const wUx = vdx / vlen + const wUy = vdy / vlen + const wStart = { x: wallCorner.x, y: wallCorner.y } + const wRayEnd = { x: wStart.x + wUx * 1e5, y: wStart.y + wUy * 1e5 } + // polygon-wall hit + let wWallT = Infinity + let wWallHit = null + for (const w of roofPolygonWalls) { + const ip = lineLineIntersection(wStart, wRayEnd, w.a, w.b) + if (!ip) continue + if (!isOnSegV(ip, w.a.x, w.a.y, w.b.x, w.b.y)) continue + const t = (ip.x - wStart.x) * wUx + (ip.y - wStart.y) * wUy + if (t < 0.5) continue + if (t < wWallT) { + wWallT = t + wWallHit = ip + } + } + // ridge stop (roof 측과 동일 룰) + let wRidgeT = Infinity + let wRidgeStop = null + // [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 원래 ridge 만 stop (wallBase 측 ray). + for (const il of roof.innerLines || []) { + if (!il || il.visible === false) continue + if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + if (il.lineName !== 'ridge') continue + const ip = lineLineIntersection( + wStart, + wRayEnd, + { x: il.x1, y: il.y1 }, + { + x: il.x2, + y: il.y2, + }, + ) + if (!ip) continue + if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue + const t = (ip.x - wStart.x) * wUx + (ip.y - wStart.y) * wUy + if (t < 0.5) continue + if (wWallT !== Infinity && t > wWallT + 0.5) continue + if (t < wRidgeT) { + wRidgeT = t + wRidgeStop = ip + } + } + const wEnd = wRidgeStop || wWallHit + if (wEnd) { + // [KERAB-VALLEY-OVERLAP 2026-05-28] roof 1개에 split 결과 sub-roof 가 나뉜다. + // 라인은 roof.id 로만 생성 → split 후 직사각형 sub-roof 가 분리되어 나옴. + // apply() 후처리에서 직사각형 sub-roof 를 인접 두 sub-roof 에 union 머지하여 "겹침" 표현. + const buildOverlapLine = (p1, p2, suffix) => { + const lpts = [p1.x, p1.y, p2.x, p2.y] + const lsz = calcLinePlaneSize({ x1: lpts[0], y1: lpts[1], x2: lpts[2], y2: lpts[3] }) + const ln = new QLine(lpts, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 3, + name: LINE_TYPE.SUBLINE.VALLEY, + textMode: roof.textMode, + attributes: { + roofId: roof.id, + type: 'kerabValleyOverlapLine', + isStart: true, + pitch: roof.pitch, + planeSize: lsz, + actualSize: lsz, + }, + }) + ln.lineName = 'kerabValleyOverlapLine' + ln.roofId = roof.id + ln.__targetId = target.id + ln.__valleyExtSource = vr.source + suffix + canvas.add(ln) + ln.bringToFront() + return ln + } + // [KERAB-VALLEY-OVERLAP 2026-05-28] 평행 사각형으로 b 외곽 닫기 + (선택) 그 너머 ray 연장. + // 사용자 룰: "vStart 에서 wLine 까지 평행선 → 그 교점에서부터 wallExt 확장". + // 출발: vStart 의 wallLine(target) 위 수선의 발 = newWStart (90° 보조선). 원래 wStart(target corner) 는 사용 안 함. + // 도착: vEnd 의 동일 offset 평행이동 = wEndProj (90° 보조선, 이전과 동일). + // 사각형 4변: vStart-newWStart, newWStart-wEndProj(wallExt 본체), wEndProj-vEnd, vEnd-vStart(=vExt 이미 그려짐). + // wEnd(원 ray hit) 가 wEndProj 보다 더 멀면 wEndProj→wEnd 확장 라인 추가. + const vStart = { x: pts[0], y: pts[1] } + const vEnd = { x: pts[2], y: pts[3] } + const dxT = target.x2 - target.x1 + const dyT = target.y2 - target.y1 + const lenTSq = dxT * dxT + dyT * dyT + const tFoot = ((vStart.x - target.x1) * dxT + (vStart.y - target.y1) * dyT) / Math.max(lenTSq, 1e-9) + const newWStart = { x: target.x1 + tFoot * dxT, y: target.y1 + tFoot * dyT } + const offX = newWStart.x - vStart.x + const offY = newWStart.y - vStart.y + const wEndProj = { x: vEnd.x + offX, y: vEnd.y + offY } + // wEnd(원 ray hit) 가 wEndProj 보다 ray 방향으로 더 멀리 있는지 — newWStart 기준 투영 비교. + const tProj = (wEndProj.x - newWStart.x) * wUx + (wEndProj.y - newWStart.y) * wUy + const tEndR = (wEnd.x - newWStart.x) * wUx + (wEnd.y - newWStart.y) * wUy + buildOverlapLine(newWStart, wEndProj, '-wall') + buildOverlapLine(vStart, newWStart, '-bridge-start') + buildOverlapLine(vEnd, wEndProj, '-bridge-end') + if (tEndR > tProj + 0.5) { + buildOverlapLine(wEndProj, wEnd, '-extend') + } + logger.log( + '[KERAB-VALLEY-OVERLAP] drawn ' + + JSON.stringify({ + src: vr.source, + stop: wRidgeStop ? 'ridge' : 'wall', + newWStart: { + x: Math.round(newWStart.x * 100) / 100, + y: Math.round(newWStart.y * 100) / 100, + }, + wEndProj: { + x: Math.round(wEndProj.x * 100) / 100, + y: Math.round(wEndProj.y * 100) / 100, + }, + wEnd: { x: Math.round(wEnd.x * 100) / 100, y: Math.round(wEnd.y * 100) / 100 }, + vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 }, + vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 }, + extended: tEndR > tProj + 0.5, + }), + ) + + // [KERAB-VALLEY-EXT-TRIM 2026-05-29] 룰 2: vExt 가 ridge 와 교차하면 + // wallExt(`:`) 측(=vExt 와 가까운 영역) ridge 부분 절삭. + // ridge 의 V apex 는 vExt 와 먼 끝점 (Y 출발점). 살아남는 쪽 = V apex 쪽. + // 방향 판정: 절삭 방향 = wallExt 중점 - vExt 중점 (wallExt 측). dot 양수면 절삭 대상. + const veMid = { x: (vStart.x + vEnd.x) / 2, y: (vStart.y + vEnd.y) / 2 } + const wsMid = { x: (newWStart.x + wEndProj.x) / 2, y: (newWStart.y + wEndProj.y) / 2 } + const bDirX = wsMid.x - veMid.x + const bDirY = wsMid.y - veMid.y + const isBSide = (px, py) => (px - veMid.x) * bDirX + (py - veMid.y) * bDirY > 0 + const trimCascadePts = [] + const newTrimRecords = [] + for (const il of roof.innerLines || []) { + if (!il || il.visible === false) continue + // [KERAB-VALLEY-EXT-TRIM 2026-05-29] trim 대상 = 원래 ridge/hip + 마루 확장(ExtRidge) + kLine(kerabPatternRidge). + // stop 룰(룰 1) 은 ridge 만, trim 룰(룰 2) 은 hip 도 포함 — vExt 는 hip 통과 후 절삭. + // 절삭 방향은 V apex 우선 룰 (아래) 로 결정. + const isRidge = + il.name === LINE_TYPE.SUBLINE.RIDGE && + (il.lineName === 'ridge' || il.lineName === 'kerabPatternRidge' || il.lineName === 'kerabPatternExtRidge') + const isHip = + il.name === LINE_TYPE.SUBLINE.HIP && + (il.lineName === 'hip' || il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip') + if (!isRidge && !isHip) continue + const ip = lineLineIntersection( + vStart, + vEnd, + { x: il.x1, y: il.y1 }, + { + x: il.x2, + y: il.y2, + }, + ) + if (!ip) continue + if (!isOnSegV(ip, vStart.x, vStart.y, vEnd.x, vEnd.y)) continue + if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue + // [KERAB-VALLEY-EXT-TRIM 2026-05-29] 절삭 방향 — V apex 우선 룰. + // V apex = 케라바 확장 hip(kerabPatternHip/kerabPatternExtHip) 두 개의 교점. + // 원래 hip(lineName='hip') 모임은 V apex 아님 — Y 출발점 룰은 케라바 패턴 한정. + // 1) 한 끝만 V apex → 그 V apex 측 살림, 반대 끝 절삭. + // 2) 양 끝 모두 V apex → vExt 와 먼 끝 살림 (가까운 끝 절삭). + // 3) 둘 다 V apex 아님 → 기존 fallback (B 측 = wallExt 측 절삭). + const isVApex = (px, py) => { + let cnt = 0 + for (const other of roof.innerLines || []) { + if (!other || other === il) continue + if (other.visible === false) continue + if (other.name !== LINE_TYPE.SUBLINE.HIP) continue + if (other.lineName !== 'kerabPatternHip' && other.lineName !== 'kerabPatternExtHip') continue + if (Math.hypot(other.x1 - px, other.y1 - py) < 1.0) cnt++ + else if (Math.hypot(other.x2 - px, other.y2 - py) < 1.0) cnt++ + if (cnt >= 2) return true + } + return false + } + const e1V = isVApex(il.x1, il.y1) + const e2V = isVApex(il.x2, il.y2) + const e1B = isBSide(il.x1, il.y1) + const e2B = isBSide(il.x2, il.y2) + let trimEnd = 0 + if (e1V && !e2V) + trimEnd = 2 // e1 V apex 살림, e2 절삭 + else if (!e1V && e2V) trimEnd = 1 + else if (e1V && e2V) { + // 양 끝 모두 V apex → vExt 와 가까운 끝 절삭 + const d1 = Math.hypot(il.x1 - veMid.x, il.y1 - veMid.y) + const d2 = Math.hypot(il.x2 - veMid.x, il.y2 - veMid.y) + trimEnd = d1 < d2 ? 1 : 2 + } else { + // 둘 다 V apex 아님 → 기존 B 측 fallback + if (e1B && !e2B) trimEnd = 1 + else if (!e1B && e2B) trimEnd = 2 + else continue + } + const oldPt = trimEnd === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 } + const originalAttrs = { ...(il.attributes || {}) } + if (trimEnd === 1) il.set({ x1: ip.x, y1: ip.y }) + else il.set({ x2: ip.x, y2: ip.y }) + if (typeof il.setCoords === 'function') il.setCoords() + const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) + il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz } + trimCascadePts.push(oldPt) + newTrimRecords.push({ + line: il, + end: trimEnd, + oldPt, + newPt: { x: ip.x, y: ip.y }, + originalAttrs, + }) + logger.log( + '[KERAB-VALLEY-EXT-TRIM] ridge trim ' + + JSON.stringify({ + lineName: il.lineName, + trimEnd, + oldPt, + ip: { x: Math.round(ip.x * 100) / 100, y: Math.round(ip.y * 100) / 100 }, + }), + ) + } + // cascade — 절삭된 끝점에 붙은 ridge 확장(kerabPatternExtRidge) 만 BFS hide + const cascadeHidden = new Set() + let cascadeGuard = 0 + while (trimCascadePts.length && cascadeGuard++ < 200) { + const pt = trimCascadePts.shift() + if (!pt) continue + for (const il of roof.innerLines || []) { + if (!il || il.visible === false) continue + if (cascadeHidden.has(il)) continue + if (il.lineName !== 'kerabPatternExtRidge') continue + const m1 = Math.hypot(il.x1 - pt.x, il.y1 - pt.y) < 1.0 + const m2 = Math.hypot(il.x2 - pt.x, il.y2 - pt.y) < 1.0 + if (!m1 && !m2) continue + cascadeHidden.add(il) + const originalVisible = il.visible !== false + const originalAttrs = { ...(il.attributes || {}) } + il.visible = false + if (typeof il.setCoords === 'function') il.setCoords() + newTrimRecords.push({ + line: il, + hide: true, + originalVisible, + originalAttrs, + }) + logger.log( + '[KERAB-VALLEY-EXT-TRIM] cascade hide ' + + JSON.stringify({ lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }), + ) + trimCascadePts.push(m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }) + } + } + // [KERAB-VALLEY-EXT-TRIM 2026-05-29] revert 위해 target.__valleyExtTrims 에 누적. + // 기존 revert 로직(2566~) 이 hide/end/oldPt/originalAttrs 그대로 처리. + // 한 mousedown 에 여러 vExt 가 호출될 수 있어 concat. + if (newTrimRecords.length) { + target.__valleyExtTrims = (target.__valleyExtTrims || []).concat(newTrimRecords) + } + } else { + logger.log('[KERAB-VALLEY-EXT-WALL] no hit for ' + vr.source) + } } - valleyExtensions.push(seg) - logger.log('[KERAB-VALLEY-EXT] generated ' + JSON.stringify(seg)) - } else { - logger.log('[KERAB-VALLEY-EXT] no ridge/hip hit for ' + ep.label) } } - // [KERAB-VALLEY-EXT-TRIM 2026-05-27] valleyExt 경로상 교차 hip/ridge 절삭. - // 각 valleyExt segment 에 대해 모든 hip/ridge 와 교차 검사 → 교차하면 valleyExt 시작점에 - // 가까운 끝점을 hitPoint 로 단축 (= corner 쪽 부분이 잘리고 너머 쪽이 보존). - // 사용자 규칙: "대칭중앙까지 가는 도중에 힙 또는 마루를 만나면 힙·마루는 절삭한다." - // cascade 도미노는 비활성 (junction 공유 hip 의도 외 절삭 부작용). - // revert 시 trimRecords 역순 복원. - const trimRecords = [] - for (const vr of valleyExtensions) { - if (!vr.source || !vr.source.startsWith('roofBase')) continue - const segA = { x: vr.x1, y: vr.y1 } - const segB = { x: vr.x2, y: vr.y2 } - // 사용자 규칙: "조건 다 필요 없고 확장하는 라인 절삭." - // trim 검사 ray = segA(corner) → wallEnd(polygon-wall). segB(대칭중앙) 은 segA-wallEnd 의 중점. - // ray 위에서 만나는 hip/ridge 의 진행방향 쪽 끝점(= u 투영값 큰 쪽) 을 ip 로 단축. - // collinear (valleyExt 와 평행+동일직선) 라인은 별도 처리 — segB 너머 끝점을 segB 로 단축. - const wallEnd = { x: 2 * segB.x - segA.x, y: 2 * segB.y - segA.y } - const dxV = wallEnd.x - segA.x - const dyV = wallEnd.y - segA.y - const lenV = Math.hypot(dxV, dyV) || 1 - const ux = dxV / lenV - const uy = dyV / lenV - const tB = (segB.x - segA.x) * ux + (segB.y - segA.y) * uy - const COLLINEAR_TOL = 1.0 - for (const il of roof.innerLines || []) { - if (!il) continue - if (il.lineName === 'kerabPatternValleyExt') continue - // 사용자 규칙: "Y 모양일때 hip 있는 쪽은 살리고 마루쪽을 절삭." + "1522 도 삭제." - // trim 대상 = ridge(마루) + 케라바 토글 생성 hip(kerabPatternHip / kerabPatternExtHip). - // 원래 지붕 hip(H-5/H-6/H-7/H-1) 은 보존. hip-presence 룰이 자기 자신 제외하므로 안전. - const isTrimCandidate = - il.name === LINE_TYPE.SUBLINE.RIDGE || - (il.name === LINE_TYPE.SUBLINE.HIP && (il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip')) - if (!isTrimCandidate) continue - if (il.visible === false) continue - const a = { x: il.x1, y: il.y1 } - const b = { x: il.x2, y: il.y2 } - let ip = lineLineIntersection(segA, wallEnd, a, b) - if (ip) { - if (!isOnSegV(ip, segA.x, segA.y, wallEnd.x, wallEnd.y)) ip = null - else if (!isOnSegV(ip, a.x, a.y, b.x, b.y)) ip = null - } - if (!ip) { - const perp1 = Math.abs((il.x1 - segA.x) * uy - (il.y1 - segA.y) * ux) - const perp2 = Math.abs((il.x2 - segA.x) * uy - (il.y2 - segA.y) * ux) - if (perp1 > COLLINEAR_TOL || perp2 > COLLINEAR_TOL) continue - const tc1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy - const tc2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy - if (Math.max(tc1, tc2) <= tB + 0.5) continue - ip = { x: segB.x, y: segB.y } - } - // 사용자 규칙: "Y 모양일때 hip 있는 쪽은 살리고 마루(ridge) 쪽을 절삭." - // ridge 의 각 끝점에 *원래 지붕* hip 끝점이 붙어있는지 검사. - // 케라바 토글 생성 hip(kerabPatternHip/ExtHip) 은 골짜기 부속물이므로 hip-presence 카운트 제외. - // hip 있는 쪽 보존, 없는 쪽 단축. 양쪽 다 원래 hip → 통째 보존(continue). - // 양쪽 다 원래 hip 없음 → Y-junction 아닌 골짜기 부속물 → 통째 hide. - const isOriginalHipEndAt = (px, py) => { - for (const other of roof.innerLines || []) { - if (!other || other === il) continue - if (other.lineName === 'kerabPatternValleyExt') continue - if (other.lineName === 'kerabPatternHip' || other.lineName === 'kerabPatternExtHip') continue - if (other.name !== LINE_TYPE.SUBLINE.HIP) continue - if (other.visible === false) continue - if (Math.hypot(other.x1 - px, other.y1 - py) < 1.0) return true - if (Math.hypot(other.x2 - px, other.y2 - py) < 1.0) return true + + // ── Phase 2-a: valleyExt 경로상 교차 hip/ridge 절삭 ── + // trim 대상 = ridge + 케라바 토글 생성 hip(kerabPatternHip/ExtHip). 원래 지붕 hip 보존. + // Y 룰: hip-presence 검사 → hip 있는 쪽 보존, 없는 쪽 단축. + const trimByValleyExtensions = (valleyExtensions) => { + const trimRecords = [] + for (const vr of valleyExtensions) { + if (!vr.source || !vr.source.startsWith('roofBase')) continue + const segA = { x: vr.x1, y: vr.y1 } + const segB = { x: vr.x2, y: vr.y2 } + const wallEnd = { x: 2 * segB.x - segA.x, y: 2 * segB.y - segA.y } + const dxV = wallEnd.x - segA.x + const dyV = wallEnd.y - segA.y + const lenV = Math.hypot(dxV, dyV) || 1 + const ux = dxV / lenV + const uy = dyV / lenV + const tB = (segB.x - segA.x) * ux + (segB.y - segA.y) * uy + const COLLINEAR_TOL = 1.0 + for (const il of roof.innerLines || []) { + if (!il) continue + if (il.lineName === 'kerabPatternValleyExt') continue + // trim 대상 = ridge(마루) + 케라바 토글 생성 hip(kerabPatternHip / kerabPatternExtHip). + // 원래 지붕 hip 은 보존. + const isTrimCandidate = + il.name === LINE_TYPE.SUBLINE.RIDGE || + (il.name === LINE_TYPE.SUBLINE.HIP && (il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip')) + if (!isTrimCandidate) continue + if (il.visible === false) continue + const a = { x: il.x1, y: il.y1 } + const b = { x: il.x2, y: il.y2 } + let ip = lineLineIntersection(segA, wallEnd, a, b) + if (ip) { + if (!isOnSegV(ip, segA.x, segA.y, wallEnd.x, wallEnd.y)) ip = null + else if (!isOnSegV(ip, a.x, a.y, b.x, b.y)) ip = null } - return false - } - const hip1 = isOriginalHipEndAt(il.x1, il.y1) - const hip2 = isOriginalHipEndAt(il.x2, il.y2) - let trimEnd - if (hip1 && hip2) { + if (!ip) { + const perp1 = Math.abs((il.x1 - segA.x) * uy - (il.y1 - segA.y) * ux) + const perp2 = Math.abs((il.x2 - segA.x) * uy - (il.y2 - segA.y) * ux) + if (perp1 > COLLINEAR_TOL || perp2 > COLLINEAR_TOL) continue + const tc1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy + const tc2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy + if (Math.max(tc1, tc2) <= tB + 0.5) continue + ip = { x: segB.x, y: segB.y } + } + // Y 룰: hip-presence 검사 → hip 있는 쪽 보존, 없는 쪽 단축. + // 양쪽 다 원래 hip 있음 → 통째 보존. 양쪽 다 없음 → 진행벡터 fallback (cascade pass 에서 정리). + const isOriginalHipEndAt = (px, py) => { + for (const other of roof.innerLines || []) { + if (!other || other === il) continue + if (other.lineName === 'kerabPatternValleyExt') continue + if (other.lineName === 'kerabPatternHip' || other.lineName === 'kerabPatternExtHip') continue + if (other.name !== LINE_TYPE.SUBLINE.HIP) continue + if (other.visible === false) continue + if (Math.hypot(other.x1 - px, other.y1 - py) < 1.0) return true + if (Math.hypot(other.x2 - px, other.y2 - py) < 1.0) return true + } + return false + } + const hip1 = isOriginalHipEndAt(il.x1, il.y1) + const hip2 = isOriginalHipEndAt(il.x2, il.y2) + let trimEnd + if (hip1 && hip2) { + logger.log( + '[KERAB-VALLEY-EXT-TRIM] both-hip preserve ' + + JSON.stringify({ + name: il.name, + lineName: il.lineName, + }), + ) + continue + } else if (hip1 && !hip2) { + trimEnd = 2 + } else if (!hip1 && hip2) { + trimEnd = 1 + } else { + const t1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy + const t2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy + trimEnd = t1 > t2 ? 1 : 2 + } + const oldX = trimEnd === 1 ? il.x1 : il.x2 + const oldY = trimEnd === 1 ? il.y1 : il.y2 + trimRecords.push({ + line: il, + end: trimEnd, + oldPt: { x: oldX, y: oldY }, + newPt: { x: ip.x, y: ip.y }, + originalAttrs: { ...(il.attributes || {}) }, + }) + if (trimEnd === 1) il.set({ x1: ip.x, y1: ip.y }) + else il.set({ x2: ip.x, y2: ip.y }) + if (typeof il.setCoords === 'function') il.setCoords() + const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) + il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz } logger.log( - '[KERAB-VALLEY-EXT-TRIM] both-hip preserve ' + + '[KERAB-VALLEY-EXT-TRIM] trim ' + JSON.stringify({ name: il.name, lineName: il.lineName, + trimEnd, + oldPt: { x: oldX, y: oldY }, + ip, }), ) - continue - } else if (hip1 && !hip2) { - trimEnd = 2 - } else if (!hip1 && hip2) { - trimEnd = 1 - } else { - // 양쪽 다 원래 hip 없음 — 진행벡터 fallback (cascade pass 에서 부속물 정리) - const t1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy - const t2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy - trimEnd = t1 > t2 ? 1 : 2 } - const oldX = trimEnd === 1 ? il.x1 : il.x2 - const oldY = trimEnd === 1 ? il.y1 : il.y2 - trimRecords.push({ - line: il, - end: trimEnd, - oldPt: { x: oldX, y: oldY }, - newPt: { x: ip.x, y: ip.y }, - originalAttrs: { ...(il.attributes || {}) }, - }) - if (trimEnd === 1) il.set({ x1: ip.x, y1: ip.y }) - else il.set({ x2: ip.x, y2: ip.y }) - if (typeof il.setCoords === 'function') il.setCoords() - const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) - il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz } - logger.log( - '[KERAB-VALLEY-EXT-TRIM] trim ' + - JSON.stringify({ - name: il.name, - lineName: il.lineName, - trimEnd, - oldPt: { x: oldX, y: oldY }, - ip, - }), - ) } + return trimRecords } - // 사용자 규칙: "원 라인이 절삭되었으면 그 밑으로 확장된 라인도 삭제." - // cascade pass — trim 결과 옛 끝점(oldPt) 에 끝점이 일치하는 케라바산 라인(kerabPatternHip/ExtHip/Ridge) 을 hide. - // 가드: 원래 지붕 ridge/hip(lineName 없거나 kerabPattern* 아닌 것) 은 cascade 대상 외 — junction 공유 의도 외 절삭 방지. - const isKerabSynthetic = (line) => { - if (!line || !line.lineName) return false - return ( - line.lineName === 'kerabPatternHip' || - line.lineName === 'kerabPatternExtHip' || - line.lineName === 'kerabPatternRidge' || - line.lineName === 'kerabPatternExtRidge' - ) - } - const cascadeHidden = new Set() - // BFS — cascade hide 된 라인의 다른 끝점에 붙은 케라바산 라인도 재귀 hide. - const cascadeQueue = trimRecords.map((r) => r.oldPt).filter(Boolean) - let cascadeGuard = 0 - while (cascadeQueue.length && cascadeGuard++ < 200) { - const pt = cascadeQueue.shift() - if (!pt) continue - for (const il of roof.innerLines || []) { - if (!il || il.visible === false) continue - if (cascadeHidden.has(il)) continue - if (il.lineName === 'kerabPatternValleyExt') continue - if (!isKerabSynthetic(il)) continue - const m1 = Math.hypot(il.x1 - pt.x, il.y1 - pt.y) < 1.0 - const m2 = Math.hypot(il.x2 - pt.x, il.y2 - pt.y) < 1.0 - if (!m1 && !m2) continue - cascadeHidden.add(il) - trimRecords.push({ - line: il, - hide: true, - originalAttrs: { ...(il.attributes || {}) }, - originalVisible: il.visible !== false, - }) - il.visible = false - if (typeof il.setCoords === 'function') il.setCoords() - logger.log( - '[KERAB-VALLEY-EXT-TRIM] cascade hide ' + - JSON.stringify({ - name: il.name, - lineName: il.lineName, - x1: il.x1, - y1: il.y1, - x2: il.x2, - y2: il.y2, - }), - ) - // 다른 끝점도 cascade 시드에 추가 - cascadeQueue.push(m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }) - } - } - if (trimRecords.length) target.__valleyExtTrims = trimRecords - for (const vr of valleyExtensions) { - const pts = [vr.x1, vr.y1, vr.x2, vr.y2] - const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) - // [KERAB-VALLEY-EXT 2026-05-27] 지붕면 할당 통합 (option a): - // roofBase 확장은 처마의 연장 → attributes.type=EAVES + parentLine + innerLines push (split 참여). - // wallBase 확장은 wall layer (roof 폴리곤 밖) → 시각만, innerLines 비추가, type/parent 부여 안 함. - const isRoofBase = !!(vr.source && vr.source.startsWith('roofBase')) - const baseAttrs = { roofId: roof.id, planeSize: sz, actualSize: sz } - if (isRoofBase) { - // [KERAB-VALLEY-EXT 2026-05-27] splitPolygonWithLines 의 innerLineMapping 은 - // EAVES polygonLine 을 skip 하므로(usePolygon.js:822) 처마확장은 자동 매핑이 안 된다. - // 처마 라인 매핑과 동일하게 type/isStart 를 직접 부여. - baseAttrs.type = LINE_TYPE.WALLLINE.EAVES - baseAttrs.isStart = true + // ── Phase 2-b: cascade hide ── + // trim oldPt 에 끝점 일치 케라바산 라인(kerabPatternHip/ExtHip/Ridge/ExtRidge) BFS hide. + // 원래 지붕 ridge/hip 은 cascade 대상 외 — junction 공유 의도 외 절삭 방지. + const cascadeHideByValleyExtensions = (trimRecords) => { + const isKerabSynthetic = (line) => { + if (!line || !line.lineName) return false + return ( + line.lineName === 'kerabPatternHip' || + line.lineName === 'kerabPatternExtHip' || + line.lineName === 'kerabPatternRidge' || + line.lineName === 'kerabPatternExtRidge' + ) } - const vExt = new QLine(pts, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 3, - name: LINE_TYPE.SUBLINE.VALLEY, - textMode: roof.textMode, - attributes: baseAttrs, - }) - vExt.lineName = 'kerabPatternValleyExt' - vExt.__targetId = target.id - vExt.__valleyExtSource = vr.source - if (isRoofBase && vr.parent) { - vExt.parentLine = vr.parent - // [KERAB-VALLEY-EXT 2026-05-27] direction 도 부모 roofLine 에서 상속 — flow/pitch 방향 일관. - if (vr.parent.direction) vExt.direction = vr.parent.direction + const cascadeHidden = new Set() + const cascadeQueue = trimRecords.map((r) => r.oldPt).filter(Boolean) + let cascadeGuard = 0 + while (cascadeQueue.length && cascadeGuard++ < 200) { + const pt = cascadeQueue.shift() + if (!pt) continue + for (const il of roof.innerLines || []) { + if (!il || il.visible === false) continue + if (cascadeHidden.has(il)) continue + if (il.lineName === 'kerabPatternValleyExt') continue + if (!isKerabSynthetic(il)) continue + const m1 = Math.hypot(il.x1 - pt.x, il.y1 - pt.y) < 1.0 + const m2 = Math.hypot(il.x2 - pt.x, il.y2 - pt.y) < 1.0 + if (!m1 && !m2) continue + cascadeHidden.add(il) + trimRecords.push({ + line: il, + hide: true, + originalAttrs: { ...(il.attributes || {}) }, + originalVisible: il.visible !== false, + }) + il.visible = false + if (typeof il.setCoords === 'function') il.setCoords() + logger.log( + '[KERAB-VALLEY-EXT-TRIM] cascade hide ' + + JSON.stringify({ + name: il.name, + lineName: il.lineName, + x1: il.x1, + y1: il.y1, + x2: il.x2, + y2: il.y2, + }), + ) + cascadeQueue.push(m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }) + } } - canvas.add(vExt) - vExt.bringToFront() - if (isRoofBase) roof.innerLines.push(vExt) } + + // === Phase 1 + Phase 2 실행 === + const valleyExtensions = computeValleyExtensions() + drawValleyExtensions(valleyExtensions) if (valleyExtensions.length) { logger.log('[KERAB-VALLEY-EXT] drawn ' + valleyExtensions.length) } + // [KERAB-VALLEY-EXT 2026-05-28] 사용자 지시: 골짜기 라인이동(trim)·라인절삭(cascade) 다 비활성, 확장만. + // 골짜기 케라바 라인 별도 룰 후속에서 재활성 예정. + // const trimRecords = trimByValleyExtensions(valleyExtensions) + // cascadeHideByValleyExtensions(trimRecords) + const trimRecords = [] + if (trimRecords.length) target.__valleyExtTrims = trimRecords } + dumpInnerLineSnapshot('AFTER') logger.log('[KERAB-SIMPLE] applied (sequential, drawKLine=' + drawKLine + ', extraApexes=' + extraApexes.length + ')') return } + dumpInnerLineSnapshot('AFTER') logger.log('[KERAB-SIMPLE] no resolution possible — fallback attr-only') target.set({ attributes }) applyKerabAttributeOnlyPattern() @@ -1807,7 +2215,10 @@ export function useEavesGableEdit(id) { } // condition 1: 자연 만남 — ext 없이 hip 제거 + kLine target.set({ attributes }) + applyKerabKLinePattern(roof, target, apex, h1Match.near, h2Match.near, [h1Match.hip, h2Match.hip], null, null) + dumpInnerLineSnapshot('AFTER') + logger.log('[KERAB-SIMPLE] applied (kLine, natural-apex)') return } @@ -2383,8 +2794,11 @@ export function useEavesGableEdit(id) { // [KERAB-VALLEY-EXT 2026-05-27] forward 에서 추가됐던 처마확장(kerabPatternValleyExt) 제거. // __targetId === target.id 매칭으로 해당 target 의 처마확장만 정확히 정리. 모든 패턴 분기 공통. // roofBase 확장은 roof.innerLines 에 있고 wallBase 확장은 canvas 에만 있으므로 canvas 전체 스캔. + // [KERAB-VALLEY-OVERLAP 2026-05-28] wallBase 측은 lineName='kerabValleyOverlapLine' (본체 + 90도 보조선 2개). + // b polygon 의 lines[] 에는 apply() 단계에서 들어가므로, 여기서는 canvas 객체와 roof.innerLines 만 정리. + // b 의 lines[] 에서 제거하는 책임은 apply() 다음 사이클에서 자동 (canvas 에서 사라지므로). const valleyExtsToRemove = (canvas.getObjects() || []).filter( - (il) => il && il.lineName === 'kerabPatternValleyExt' && il.__targetId === target.id, + (il) => il && (il.lineName === 'kerabPatternValleyExt' || il.lineName === 'kerabValleyOverlapLine') && il.__targetId === target.id, ) for (const v of valleyExtsToRemove) { removeLine(v) diff --git a/src/hooks/roofcover/useRoofAllocationSetting.js b/src/hooks/roofcover/useRoofAllocationSetting.js index a53677a4..976c9a8f 100644 --- a/src/hooks/roofcover/useRoofAllocationSetting.js +++ b/src/hooks/roofcover/useRoofAllocationSetting.js @@ -30,6 +30,7 @@ import { QcastContext } from '@/app/QcastProvider' import { usePlan } from '@/hooks/usePlan' import { roofsState } from '@/store/roofAtom' import { useText } from '@/hooks/useText' +import { fabric } from 'fabric' import { QLine } from '@/components/fabric/QLine' import { useUndoRedo } from '@/hooks/useUndoRedo' import { calcLineActualSize2 } from '@/util/qpolygon-utils' @@ -658,6 +659,247 @@ export function useRoofAllocationSetting(id) { ) } + /** + * [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] 골짜기 케라바 출폭 띠 — 직사각형 sub-roof 를 인접 sub-roof 들에 union 머지. + * + * 배경: 케라바 토글 시 두 지붕면이 出幅만큼 서로 물려 겹친다는 표시. useEavesGableEdit.js wallExt 단계에서 + * 평행 사각형 4 라인(kerabValleyOverlapLine) 으로 출폭 띠를 닫고 split 단계로 진입. + * split 후 이 사각형은 별도 sub-roof X 가 됨. 의도는 X 영역이 인접 두 sub-roof 모두에 속해 겹치는 것. + * 따라서 X 의 공유 변을 가진 인접 sub-roof 들에 X 의 나머지 변(detour)을 끼워넣어 X 영역을 흡수. + * 흡수 완료 후 X 자체는 캔버스에서 제거. + * + * 식별: + * - 직사각형 X: sub.lines 의 attributes.type === 'kerabValleyOverlapLine' 가 N-1 개 이상 (4변 중 3~4) + * - 인접 sub-roof: X 의 변과 같은 두 끝점을 공유하는 다른 sub-roof + * + * 머지: 사각형 X 의 공유 변 1개를 detour 변 N-1개로 대체. 방향은 면적 증가로 검증. + */ + const mergeValleyOverlapSubRoofs = () => { + const newSubRoofs = canvas.getObjects().filter((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed) + if (newSubRoofs.length === 0) return + + const eq = (p, q) => Math.abs(p.x - q.x) < 0.5 && Math.abs(p.y - q.y) < 0.5 + + const rects = newSubRoofs.filter((sub) => { + if (!sub.lines || sub.lines.length < 3) return false + const cnt = sub.lines.filter((l) => l?.attributes?.type === 'kerabValleyOverlapLine').length + return cnt >= Math.max(3, sub.lines.length - 1) + }) + + if (rects.length === 0) return + + logger.log(`[KERAB-VALLEY-OVERLAP-MERGE] 직사각형 sub-roof 후보=${rects.length}`) + + const signedArea = (pts) => { + let s = 0 + for (let i = 0; i < pts.length; i++) { + const a = pts[i] + const b = pts[(i + 1) % pts.length] + s += a.x * b.y - b.x * a.y + } + return s / 2 + } + + rects.forEach((X) => { + const Xpts = (X.points || []).map((p) => ({ x: p.x, y: p.y })) + const Xlines = X.lines || [] + if (Xpts.length < 3 || Xlines.length < Xpts.length) { + logger.log(`[KERAB-VALLEY-OVERLAP-MERGE] X.id=${X.id} skip — pts=${Xpts.length} lines=${Xlines.length}`) + return + } + + const Xedges = [] + for (let i = 0; i < Xpts.length; i++) { + Xedges.push({ a: Xpts[i], b: Xpts[(i + 1) % Xpts.length], idx: i }) + } + + // 인접 sub-roof — X 와 공유 변 모두 수집 (다중 공유 검출용) + const adjacencyMap = new Map() // sub → [{ subEdgeStart, xEdgeIdx }, ...] + newSubRoofs.forEach((sub) => { + if (sub === X) return + const pts = sub.points || [] + if (pts.length < 3) return + const shares = [] + for (let i = 0; i < pts.length; i++) { + const p1 = pts[i] + const p2 = pts[(i + 1) % pts.length] + for (const e of Xedges) { + if ((eq(p1, e.a) && eq(p2, e.b)) || (eq(p1, e.b) && eq(p2, e.a))) { + shares.push({ subEdgeStart: i, xEdgeIdx: e.idx }) + break + } + } + } + if (shares.length > 0) adjacencyMap.set(sub, shares) + }) + + logger.log( + `[KERAB-VALLEY-OVERLAP-MERGE] X.id=${X.id} pts=${Xpts.length} adjacents=${adjacencyMap.size} ` + + `shares=[${[...adjacencyMap.values()].map((s) => s.length).join(',')}]`, + ) + + if (adjacencyMap.size === 0) { + canvas.remove(X) + return + } + + adjacencyMap.forEach((shares, sub) => { + // 다중 공유 → bowtie 위험, 머지 skip + if (shares.length !== 1) { + logger.log(`[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} skip — sharedEdges=${shares.length} (≠1, bowtie 위험)`) + return + } + const { subEdgeStart, xEdgeIdx } = shares[0] + const pts = (sub.points || []).map((p) => ({ x: p.x, y: p.y })) + const oldLines = [...(sub.lines || [])] + const N = Xpts.length + + // [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] detour 방향은 sub.pts[subEdgeStart] 가 + // X.xEdge.a (=Xpts[xEdgeIdx]) 인지 X.xEdge.b (=Xpts[xEdgeIdx+1]) 인지로 결정. + // sub 외곽선 진행 방향이 xEdge 의 a→b 와 같으면 detour 는 X 의 a 쪽 이웃부터 (역방향) + // 반대면 detour 는 X 의 b 쪽 이웃부터 (정방향). + const subA = pts[subEdgeStart] + const xA = Xpts[xEdgeIdx] + const sameDir = eq(subA, xA) + + const detour = [] + const detourEdgeIdx = [] + if (sameDir) { + // a → Xpts[i-1] → Xpts[i-2] → ... → Xpts[i+2] → b + // detour vertices: N-2 개 (a, b 사이) + for (let k = 1; k <= N - 2; k++) { + detour.push(Xpts[(xEdgeIdx - k + N) % N]) + } + // detour edges: N-1 개. a→Xpts[i-1] 는 X 의 (i-1)%N 변 (반대방향). + for (let k = 1; k <= N - 1; k++) { + detourEdgeIdx.push((xEdgeIdx - k + N) % N) + } + } else { + // b → Xpts[i+2] → Xpts[i+3] → ... → Xpts[i+N-1] → a 의 a→b 표기: + // a → Xpts[i+2] → Xpts[i+3] → ... → Xpts[i+N-1] → b (subA=b 인 관점에서 보면 반대) + // 정확히는 subA=X.xEdge.b → detour 시작 = Xpts[(i+2)%N] (b 의 X 내 이웃) + for (let k = 2; k <= N - 1; k++) { + detour.push(Xpts[(xEdgeIdx + k) % N]) + } + for (let k = 1; k <= N - 1; k++) { + detourEdgeIdx.push((xEdgeIdx + k) % N) + } + } + + const buildCand = (verts) => { + const c = [...pts] + c.splice(subEdgeStart + 1, 0, ...verts) + return c + } + + const candA = buildCand(detour) + const oldSigned = signedArea(pts) + const aSigned = signedArea(candA) + const sameSign = (s) => oldSigned === 0 || Math.sign(s) === Math.sign(oldSigned) + const okA = sameSign(aSigned) && Math.abs(aSigned) > Math.abs(oldSigned) + 0.5 + + let finalPts = null + let finalEdges = null + if (okA) { + finalPts = candA + finalEdges = detourEdgeIdx + } else { + // fallback — 반대 방향 시도 (X 가 CW 로 들어온 경우 대비) + const detourRev = [...detour].reverse() + const detourEdgeRev = [...detourEdgeIdx].reverse() + const candB = buildCand(detourRev) + const bSigned = signedArea(candB) + const okB = sameSign(bSigned) && Math.abs(bSigned) > Math.abs(oldSigned) + 0.5 + if (okB) { + finalPts = candB + finalEdges = detourEdgeRev + logger.log( + `[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} fallback reverse detour 사용 ` + + `oldSigned=${oldSigned.toFixed(0)} → ${bSigned.toFixed(0)}`, + ) + } else { + logger.log( + `[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} skip — detour 양방향 실패 ` + + `sameDir=${sameDir} oldSigned=${oldSigned.toFixed(0)} ` + + `aSigned=${aSigned.toFixed(0)} bSigned=${bSigned.toFixed(0)} ` + + `subA=(${subA.x.toFixed(1)},${subA.y.toFixed(1)}) ` + + `xA=(${xA.x.toFixed(1)},${xA.y.toFixed(1)}) ` + + `xB=(${Xpts[(xEdgeIdx + 1) % N].x.toFixed(1)},${Xpts[(xEdgeIdx + 1) % N].y.toFixed(1)})`, + ) + return + } + } + + // sub.lines 재구성 — 옛 공유 변 1개 → detour 변 N-1개 + const newSubLines = [] + for (let i = 0; i < oldLines.length; i++) { + if (i === subEdgeStart) { + for (let k = 0; k < finalEdges.length; k++) { + const xLine = Xlines[finalEdges[k]] + const p1 = finalPts[(subEdgeStart + k) % finalPts.length] + const p2 = finalPts[(subEdgeStart + k + 1) % finalPts.length] + const attrs = xLine?.attributes ? { ...xLine.attributes } : { type: 'kerabValleyOverlapLine', offset: 0 } + const ln = new QLine([p1.x, p1.y, p2.x, p2.y], { + stroke: sub.stroke, + strokeWidth: sub.strokeWidth, + fontSize: sub.fontSize, + attributes: attrs, + textVisible: false, + parent: sub, + parentId: sub.id, + idx: newSubLines.length + 1, + }) + ln.startPoint = p1 + ln.endPoint = p2 + newSubLines.push(ln) + } + } else { + const ln = oldLines[i] + if (ln) { + ln.idx = newSubLines.length + 1 + newSubLines.push(ln) + } + } + } + + // [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] fabric.Polygon 정식 points 갱신 패턴. + // src/util/canvas-util.js#anchorWrapper 패턴 그대로 적용 — + // _setPositionDimensions 가 width/height/pathOffset/left/top 을 재계산하므로 + // 변경 전 앵커(pts[0]) 절대좌표를 캡쳐 → 변경 후 setPositionByOrigin 으로 복원. + // 그래야 polygon 의 path 가 새 bbox 기준으로 다시 그려지면서 시각 위치도 유지됨. + const anchorIdx = 0 + const oldLocal = { + x: sub.points[anchorIdx].x - sub.pathOffset.x, + y: sub.points[anchorIdx].y - sub.pathOffset.y, + } + const absolutePoint = fabric.util.transformPoint(oldLocal, sub.calcTransformMatrix()) + + sub.points = finalPts + sub.lines = newSubLines + sub._setPositionDimensions({}) + + const strokeW = sub.strokeUniform ? sub.strokeWidth / sub.scaleX : sub.strokeWidth + const baseW = sub.width + strokeW + const baseH = sub.height + (sub.strokeUniform ? sub.strokeWidth / sub.scaleY : sub.strokeWidth) + const newX = (sub.points[anchorIdx].x - sub.pathOffset.x) / Math.max(baseW, 1e-9) + const newY = (sub.points[anchorIdx].y - sub.pathOffset.y) / Math.max(baseH, 1e-9) + sub.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5) + sub.setCoords?.() + sub.dirty = true + + logger.log( + `[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} 머지 완료 ` + + `oldSigned=${oldSigned.toFixed(0)} → newSigned=${signedArea(finalPts).toFixed(0)} ` + + `pts=${finalPts.length} lines=${newSubLines.length}`, + ) + }) + + canvas.remove(X) + }) + + canvas.renderAll?.() + } + /** * 지붕면 할당 */ @@ -669,9 +911,16 @@ export function useRoofAllocationSetting(id) { roofBases.forEach((roofBase) => { try { // 지붕 할당 로직에 extensionLine 추가 + + // [KERAB-VALLEY-OVERLAP 2026-05-28] 골짜기 케라바 출폭 띠 외곽 라인(kerabValleyOverlapLine) 도 할당 대상. + // b polygon 의 lines[] 에 추가되어 b 의 면이 출폭 띠 영역까지 확장 → a 의 sub-면과 겹침 표현. const roofEaveHelpLines = canvas .getObjects() - .filter((obj) => (obj.lineName === 'eaveHelpLine' || obj.lineName === 'extensionLine') && obj.roofId === roofBase.id) + .filter( + (obj) => + (obj.lineName === 'eaveHelpLine' || obj.lineName === 'extensionLine' || obj.lineName === 'kerabValleyOverlapLine') && + obj.roofId === roofBase.id, + ) // logger.log('roofBase.id:', roofBase.id) // logger.log('roofEaveHelpLines:', roofEaveHelpLines) // logger.log('extensionLines found:', roofEaveHelpLines.filter(l => l.lineName === 'extensionLine')) @@ -686,6 +935,8 @@ export function useRoofAllocationSetting(id) { const extensionLines = newEaveLines.filter((line) => line.lineName === 'extensionLine') const normalEaveLines = newEaveLines.filter((line) => line.lineName === 'eaveHelpLine') + // [KERAB-VALLEY-OVERLAP 2026-05-28] 골짜기 케라바 겹침 라인 — overlap 판단 제외, 단순 결합 + const overlapLines = newEaveLines.filter((line) => line.lineName === 'kerabValleyOverlapLine') // logger.log('extensionLines count:', extensionLines.length) // logger.log('normalEaveLines count:', normalEaveLines.length) @@ -743,12 +994,13 @@ export function useRoofAllocationSetting(id) { } } } + return false // 끝점만 닿아 있거나 직각인 경우는 살림 }) return !shouldRemove }) // Combine remaining lines with newEaveLines - roofBase.lines = [...linesToKeep, ...normalEaveLines, ...extensionLines] + roofBase.lines = [...linesToKeep, ...normalEaveLines, ...extensionLines, ...overlapLines] } else { roofBase.lines = [...roofEaveHelpLines] } @@ -816,6 +1068,12 @@ export function useRoofAllocationSetting(id) { const __newRoofs = canvas.getObjects().filter((o) => o.name === 'roof' && !o.isFixed) logger.log(`[KERAB-PATTERN-DIAG] 분할 후 새 sub-roof=${__newRoofs.length}`) } + + // [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] 직사각형 sub-roof 를 인접 두 sub-roof 에 union 머지. + // 골짜기 출폭 띠 영역이 두 면 모두에 속하도록 (겹침 표현). + // 직사각형 식별: 외곽 라인의 attributes.type === 'kerabValleyOverlapLine' 가 다수인 sub-roof. + // 인접 두 sub-roof = 직사각형의 변 좌표를 공유하는 다른 sub-roof. + mergeValleyOverlapSubRoofs() } catch (e) { logger.log(e) canvas.discardActiveObject() @@ -827,6 +1085,18 @@ export function useRoofAllocationSetting(id) { canvas.remove(line) }) + // [KERAB-VALLEY-OVERLAP 2026-05-28] roofBase.lines 로 추가된 보조 라인(kerabValleyOverlapLine) 및 + // wallBase 변형 kerabPatternValleyExt(innerLines 미포함) 는 split 이후 더 이상 필요 없으므로 정리. + // 미정리 시 canvas 잔류 → sub-roof 위에 솔리드/점선으로 남는다. + const overlapLeftovers = canvas + .getObjects() + .filter( + (obj) => + (obj.lineName === 'kerabValleyOverlapLine' || obj.lineName === 'kerabPatternValleyExt') && + (obj.roofId === roofBase.id || obj?.attributes?.roofId === roofBase.id), + ) + overlapLeftovers.forEach((line) => canvas.remove(line)) + canvas.remove(roofBase) }) diff --git a/src/util/kerab-offset-surgical.js b/src/util/kerab-offset-surgical.js index 480e11be..c6619ffa 100644 --- a/src/util/kerab-offset-surgical.js +++ b/src/util/kerab-offset-surgical.js @@ -5,6 +5,7 @@ // 고객 회귀 검증을 위해 본 로직 전체를 useEavesGableEdit.js 에서 분리. // 호출 ON/OFF 는 useEavesGableEdit.js 상단 `ENABLE_KERAB_OFFSET_SURGICAL` 상수로 토글한다. +import { fabric } from 'fabric' import { POLYGON_TYPE } from '@/common/common' import { calcLinePlaneSize } from '@/util/qpolygon-utils' import { logger } from '@/util/logger' @@ -123,16 +124,52 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => { } // points 는 새 배열로 set 해야 fabric 의 dirty 감지가 동작. + // [KERAB-OFFSET-SURGICAL 2026-05-29] 출폭 증가 시 새 corner 가 polygon bbox 밖에 있으면 + // 외곽선이 안 그려짐. _setPositionDimensions 로 width/height/pathOffset 재계산 + 앵커 + // 절대좌표 보존(setPositionByOrigin) 으로 polygon path 가 새 영역까지 다시 그려지게 강제. const newPoints = roof.points.map((p, i) => { if (i === idx) return { x: newCorner1.x, y: newCorner1.y } if (i === (idx + 1) % N) return { x: newCorner2.x, y: newCorner2.y } return { x: p.x, y: p.y } }) + let absolutePoint = null + let anchorIdx = 0 + // 변경 대상이 아닌 첫 인덱스를 앵커로 — pathOffset 갱신 후 그 점 절대 좌표 보존. + for (let i = 0; i < roof.points.length; i++) { + if (i !== idx && i !== (idx + 1) % N) { + anchorIdx = i + break + } + } + try { + if (typeof roof.calcTransformMatrix === 'function' && roof.pathOffset) { + const oldLocal = { + x: roof.points[anchorIdx].x - roof.pathOffset.x, + y: roof.points[anchorIdx].y - roof.pathOffset.y, + } + absolutePoint = fabric.util.transformPoint(oldLocal, roof.calcTransformMatrix()) + } + } catch (e) { + absolutePoint = null + } + roof.points = newPoints roof.set({ points: newPoints, dirty: true }) + if (typeof roof._setPositionDimensions === 'function') roof._setPositionDimensions({}) + if (absolutePoint && typeof roof.setPositionByOrigin === 'function') { + const strokeW = roof.strokeUniform ? roof.strokeWidth / Math.max(roof.scaleX || 1, 1e-9) : roof.strokeWidth + const baseW = (roof.width || 0) + strokeW + const baseH = (roof.height || 0) + (roof.strokeUniform ? roof.strokeWidth / Math.max(roof.scaleY || 1, 1e-9) : roof.strokeWidth) + const newX = (roof.points[anchorIdx].x - roof.pathOffset.x) / Math.max(baseW, 1e-9) + const newY = (roof.points[anchorIdx].y - roof.pathOffset.y) / Math.max(baseH, 1e-9) + roof.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5) + } if (typeof roof.setCoords === 'function') roof.setCoords() // canvas 에 add 된 동일 wallLine 매칭의 eaves(외곽 roofLine) fabric 객체도 같이 갱신. + // [KERAB-OFFSET-SURGICAL 2026-05-29] outerLine 은 wall 좌표(출폭 0 기준) 유지가 원칙. + // corner 좌표(=wall + offset*normal) 로 set 하면 출폭 증가 시 외곽 처마라인이 통째로 이동해 + // 화면상 roofLine 이 안 그려진 듯 보임. outerLine 은 attributes 만 갱신, 좌표는 보존. const canvasEdgeObjs = canvas .getObjects() .filter( @@ -142,7 +179,9 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => { o.attributes?.wallLine === target.id, ) for (const eo of canvasEdgeObjs) { - eo.set({ x1: newCorner1.x, y1: newCorner1.y, x2: newCorner2.x, y2: newCorner2.y, dirty: true }) + if (eo.name !== 'outerLine') { + eo.set({ x1: newCorner1.x, y1: newCorner1.y, x2: newCorner2.x, y2: newCorner2.y, dirty: true }) + } eo.attributes = { ...eo.attributes, offset: newOffset, planeSize: newSize, actualSize: newSize } if (typeof eo.setCoords === 'function') eo.setCoords() }