diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index d4fc2b7e..97ad0616 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -277,6 +277,25 @@ export function useEavesGableEdit(id) { .getObjects() .find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId) logger.log('[KERAB-SIMPLE] roof check ' + JSON.stringify({ roofId: target.attributes?.roofId, 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)) + } + 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) @@ -608,9 +627,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 @@ -625,12 +644,35 @@ 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 + for (const il of roof.innerLines || []) { + if (!il || il.visible === false) continue + if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + if (il.lineName === 'kerabPatternValleyExt') 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=' + ep.label + - ' wallHit={' + Math.round(wallHit.x * 100) / 100 + ',' + Math.round(wallHit.y * 100) / 100 + '}' + - ' mid={' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + '}', + '[KERAB-VALLEY-EXT-RIDGE-STOP] pre label=' + ep.label + + ' ridgeT=' + Math.round(ridgeT * 100) / 100 + + ' stop={' + Math.round(bestPt.x * 100) / 100 + ',' + 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 + '}', ) } if (bestPt) { @@ -1431,6 +1473,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 @@ -1443,209 +1492,87 @@ 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 - } - } - 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, - } - 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 - } - 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 { - // 양쪽 다 원래 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 }), - ) - } - } - // 사용자 규칙: "원 라인이 절삭되었으면 그 밑으로 확장된 라인도 삭제." - // 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) { + // ── 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 + for (const il of roof.innerLines || []) { + if (!il || il.visible === false) continue + if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + if (il.lineName === 'kerabPatternValleyExt') 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) + } + } + 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): @@ -1680,14 +1607,252 @@ export function useEavesGableEdit(id) { 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 + for (const il of roof.innerLines || []) { + if (!il || il.visible === false) continue + if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + if (il.lineName === 'kerabPatternValleyExt') 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) { + const wallPts = [wStart.x, wStart.y, wEnd.x, wEnd.y] + const wallSz = calcLinePlaneSize({ x1: wallPts[0], y1: wallPts[1], x2: wallPts[2], y2: wallPts[3] }) + const wallExt = new QLine(wallPts, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 3, + name: LINE_TYPE.SUBLINE.VALLEY, + textMode: roof.textMode, + attributes: { roofId: roof.id, planeSize: wallSz, actualSize: wallSz }, + }) + wallExt.lineName = 'kerabPatternValleyExt' + wallExt.__targetId = target.id + wallExt.__valleyExtSource = vr.source + '-wall' + canvas.add(wallExt) + wallExt.bringToFront() + logger.log( + '[KERAB-VALLEY-EXT-WALL] drawn ' + + JSON.stringify({ + src: vr.source, + stop: wRidgeStop ? 'ridge' : 'wall', + from: { x: Math.round(wStart.x * 100) / 100, y: Math.round(wStart.y * 100) / 100 }, + to: { x: Math.round(wEnd.x * 100) / 100, y: Math.round(wEnd.y * 100) / 100 }, + }), + ) + } else { + logger.log('[KERAB-VALLEY-EXT-WALL] no hit for ' + vr.source) + } + } } + } + + // ── 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 + } + 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] trim ' + + JSON.stringify({ name: il.name, lineName: il.lineName, trimEnd, oldPt: { x: oldX, y: oldY }, ip }), + ) + } + } + return trimRecords + } + + // ── 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 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 }) + } + } + } + + // === Phase 1 + Phase 2 실행 === + const valleyExtensions = computeValleyExtensions() + drawValleyExtensions(valleyExtensions) if (valleyExtensions.length) { logger.log('[KERAB-VALLEY-EXT] drawn ' + valleyExtensions.length) } + const trimRecords = trimByValleyExtensions(valleyExtensions) + cascadeHideByValleyExtensions(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() @@ -1705,6 +1870,7 @@ export function useEavesGableEdit(id) { null, null, ) + dumpInnerLineSnapshot('AFTER') logger.log('[KERAB-SIMPLE] applied (kLine, natural-apex)') return }