diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index 35221da3..bb641ef4 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -17,6 +17,7 @@ import { reattachDebugLabels } from '@/components/fabric/QPolygon' import { calcLinePlaneSize } from '@/util/qpolygon-utils' import { findInteriorPoint } from '@/util/skeleton-utils' import { logger } from '@/util/logger' +import { checkKerabRules } from '@/util/kerab-rule-checker' import { debugCapture } from '@/util/debugCapture' import { applyKerabOffsetSurgical } from '@/util/kerab-offset-surgical' @@ -459,6 +460,8 @@ export function useEavesGableEdit(id) { } else { logger.log('[KERAB-RULE-CHECK] ' + phase + ' PASS') } + // 기하 불변식 체커(R-45HIP/R-AXISRIDGE/R-CROSS/R-WEDGE). 위 R1~R4 와 상호보완. + checkKerabRules(roof.innerLines, { roofId: roof.id, label: phase }) } catch (err) { logger.warn('[KERAB-RULE-CHECK] error', err) } @@ -2749,6 +2752,8 @@ export function useEavesGableEdit(id) { // 변은 出幅만큼 평행이동(delta)했으므로, 옛 변 위 끝점만 동일 delta 로 평행이동(= NORMAL-ABS 동치). // 단, cMid(변 중점)에 걸린 비-collinear 선의 끝점은 apex 로 수렴해야 한다(힙 사이 마루 금지). // 케라바패턴 라인(내 hip)은 surgicalAfter 의 ray-cast 가 이미 처리 → 제외. + // [KERAB-TYPE-EAVES-RIDGE-VANISH-FLAG 2026-06-23] apexRes 를 항상 넘긴다. relocate 가 apexRes.ridgeHidden + // 플래그로 골짜기 세로 힙(H-8) 끝점을 apex 로 끌어올리지 않고 제자리 앵커(위로 반전 방지)하기 때문. moveStaleEdgeInnerLines(roof, c1, c2, _edgeIdx, apexRes) // [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] apex 는 벽기준 고정. 힙을 벽교점 방향 45° 그대로 roofLine 까지 직선 연장. extendTypeGableHipsStraightToRoofLine(roof, target, apexRes) @@ -3146,12 +3151,325 @@ export function useEavesGableEdit(id) { } return touched } - const ridgeOps = resolveRidgeEndpoints() + // 5a') 골짜기 마름모 생성 — A,B타입 골짜기 라인은 케라바+처마 조합이라 + // 라인변경(케라바→처마) 시 두 평행 힙( / / ) + 잇는 마루 = 마름모(힙—마루—힙)가 되어야 한다. + // 두 힙은 *같은 방향*(평행)이고 *같은 깊이*면 끝점을 잇는 마루가 골짜기 라인과 평행 + // = 항상 수평/수직(마루 비대각 불변식 충족). 깊이 = 두 raycast hit 중 *짧은 쪽(min)*. + // 짧은 쪽 = 먼저 닫히는 인접면 — 긴 쪽에 맞추면 짧은 면을 넘어 ><(R3 위반)이 되므로 min. + // (사용자 확정 2026-06-22: 가로·세로 골짜기 변경 시 양쪽 다 마름모 생성.) + // 마름모 깊이 산출 진단(로그/검증용). + let adjHipDbg = null + const buildValleyDiamond = () => { + if (!resA || !resA.hip || !resA.hit || !resB || !resB.hip || !resB.hit) return null + // 두 힙이 평행해야 마름모(잇는 마루 = 골짜기 라인과 평행). 방향이 다르면 마름모 아님 → 폴백. + if (Math.hypot(resA.dir.x - resB.dir.x, resA.dir.y - resB.dir.y) > 0.05) return null + // [KERAB-VALLEY-EAVES-RULE1 MIDLINE 2026-06-22] 마름모 깊이 = *결정적 중앙선 공식*. + // 잇는 마루(수평/수직 불변식)는 골짜기 처마 roofLine 과 그 맞은편 평행 건물변의 *정확히 중앙*에 놓인다. + // (straight-skeleton: 두 평행 처마변 사이 ridge 는 중앙선). 인접 힙 매칭·내부 ridge clamp(min raycast)는 + // 값이 매 실행마다 흔들려 폐기(사용자 확정 2026-06-22: "힙길이가 자꾸 바뀐다", 2820/3930 결정값 기대). + // depth(축) = |중앙선축 − offset 코너축|, 힙길이 = depth × √2 (45° 불변식). + // 중앙선축 = (원래 골짜기 roofLine 축 + 맞은편 평행변 축)/2. + // · 원래 골짜기 roofLine 축: c1(=surgical offset 이전 nearestRoofPoint) — 가로 골짜기는 c1.y, 세로는 c1.x. + // · 맞은편 평행변 축: 골짜기 중점에서 *안쪽(힙 방향 부호)* 으로 축정렬 ray 를 쏴 처음 만나는 *외곽 폴리곤 변*. + // 내부선(transient HIP/RIDGE)은 제외 — 그게 값 흔들림의 근본원인이었음. + const valleyHorizontal = + Math.abs(pts[(edgeIdx + 1) % NP].x - pts[edgeIdx].x) >= Math.abs(pts[(edgeIdx + 1) % NP].y - pts[edgeIdx].y) + // 원래 골짜기 roofLine 의 축값 + 중점(c1/c2 = surgical offset 이전 코너). + const origValleyAxis = valleyHorizontal ? c1.y : c1.x + const midX = (c1.x + c2.x) / 2 + const midY = (c1.y + c2.y) / 2 + // 안쪽 방향 부호 = 힙 진행 방향(가로 골짜기→y, 세로 골짜기→x). + const sgn = valleyHorizontal ? Math.sign(resA.dir.y) || 1 : Math.sign(resA.dir.x) || 1 + // 골짜기 중점에서 축정렬 ray 를 외곽 폴리곤 변에만 쏴 맞은편 평행변 축값을 구함. + const EPS = 0.01 + let oppAxis = null + for (let i = 0; i < NP; i++) { + const a = pts[i] + const b = pts[(i + 1) % NP] + if (valleyHorizontal) { + // 변이 x=midX 를 가로지르나? 가로지른다면 그 지점의 y. + if ((a.x - midX) * (b.x - midX) > EPS) continue + if (Math.abs(a.x - b.x) < EPS) continue + const t = (midX - a.x) / (b.x - a.x) + if (t < -EPS || t > 1 + EPS) continue + const y = a.y + t * (b.y - a.y) + if (sgn * (y - midY) <= EPS) continue // 안쪽(힙 방향)만 + if (oppAxis === null || sgn * (y - oppAxis) < 0) oppAxis = y + } else { + if ((a.y - midY) * (b.y - midY) > EPS) continue + if (Math.abs(a.y - b.y) < EPS) continue + const t = (midY - a.y) / (b.y - a.y) + if (t < -EPS || t > 1 + EPS) continue + const x = a.x + t * (b.x - a.x) + if (sgn * (x - midX) <= EPS) continue + if (oppAxis === null || sgn * (x - oppAxis) < 0) oppAxis = x + } + } + const cornerAxis = valleyHorizontal ? newC1.y : newC1.x + // raycast 폴백(중앙선 못 구한 경우의 null-safety 만): 두 raycast hit 중 짧은 쪽. + const depthA = Math.hypot(resA.hit.x - newC1.x, resA.hit.y - newC1.y) + const depthB = Math.hypot(resB.hit.x - newC2.x, resB.hit.y - newC2.y) + const fallbackD = Math.min(depthA, depthB) + // [KERAB-VALLEY-EAVES-RULE1 CLAMP 2026-06-22] 두 힙은 *각자 독립적으로* 중앙선까지 확장하되, + // 가는 길에 *마루/힙* 을 먼저 만나면 *그 힙만* 거기서 멈춤(사용자 확정: "만나는 그 힙만 멈추고, + // 다른 힙은 계속 중앙까지"). 넘어가면 R3(교차/오버슈트) 위반. + // ★ roofLine 히트는 막는 대상 아님(중앙선이 지붕 안이면 정상) → 마루/힙 hit 만 obstruction. + // ★ 두 힙 길이가 *다를 수 있음* → 끝을 잇는 게 축정렬이면 마루, 아니면 힙으로 연결(아래). + const isObstr = (kind) => kind === LINE_TYPE.SUBLINE.HIP || kind === LINE_TYPE.SUBLINE.RIDGE + let dA = depthA + let dB = depthB + let source = 'fallback-min-raycast' + let midlineAxis = null + let dDet = null + if (oppAxis !== null) { + midlineAxis = (origValleyAxis + oppAxis) / 2 + const dM = Math.abs(midlineAxis - cornerAxis) * Math.SQRT2 + if (dM >= 1) { + dDet = dM + dA = resA.hit && isObstr(resA.hitKind) ? Math.min(dM, depthA) : dM + dB = resB.hit && isObstr(resB.hitKind) ? Math.min(dM, depthB) : dM + source = dA < dM - 1e-6 || dB < dM - 1e-6 ? 'midline-clamped' : 'midline' + } + } + adjHipDbg = { + valleyHorizontal, + origValleyAxis: Math.round(origValleyAxis * 10) / 10, + oppAxis: oppAxis === null ? null : Math.round(oppAxis * 10) / 10, + midlineAxis: midlineAxis === null ? null : Math.round(midlineAxis * 10) / 10, + cornerAxis: Math.round(cornerAxis * 10) / 10, + dDet: dDet === null ? null : Math.round(dDet), + depthA: Math.round(depthA), + depthB: Math.round(depthB), + obstr: { a: resA.hitKind, b: resB.hitKind }, + usedDA: Math.round(dA), + usedDB: Math.round(dB), + source, + } + if (dA < 1 || dB < 1) return null + // [KERAB-VALLEY-EAVES-RULE1 CASCADE 2026-06-22] 힙이 *중앙선 전에* 마루/힙을 만나는 경우 + // (depth < 중앙선 depth): 마름모(중앙 clamp + connector) 가 아니라 *순차 절삭 연쇄*. + // 사용자 확정: CCW 로 먼저 그린 힙이 그 마루를 *흡수측 절삭*(--< : 경계=박공 출발측 보존, + // 노치측 흡수) → 그 절삭으로 다른 힙의 장애물(마루 근측)이 사라져 다른 힙은 *다음 라인까지 확장*. + // ★ 2820 대칭 마름모 케이스(장애물이 중앙선 *너머*)는 depth ≥ dDet 라 여기 안 걸림 → 기존 경로 보존. + const cascadeA = !!(resA.hit && isObstr(resA.hitKind) && dDet !== null && depthA < dDet - 1e-6) + const cascadeB = !!(resB.hit && isObstr(resB.hitKind) && dDet !== null && depthB < dDet - 1e-6) + if (cascadeA || cascadeB) { + const cops = [] + const rptsC = roof.points || [] + const TOLC = 2.0 + const d2s = (p, a, b) => { + const ex = b.x - a.x + const ey = b.y - a.y + const l2 = ex * ex + ey * ey || 1 + let t = ((p.x - a.x) * ex + (p.y - a.y) * ey) / l2 + t = Math.max(0, Math.min(1, t)) + return Math.hypot(p.x - (a.x + t * ex), p.y - (a.y + t * ey)) + } + const minEd = (pt) => { + let m = Infinity + for (let i = 0, j = rptsC.length - 1; i < rptsC.length; j = i++) { + const d = d2s(pt, rptsC[j], rptsC[i]) + if (d < m) m = d + } + return m + } + const hipsC = [resA, resB].filter((r) => r && r.hip) + // 0) ★ 내부확장박스(kerabValleyOverlapLine) 삭제 — 케라바 적용 때 그려진 골짜기 겹침 사각형은 + // 처마로 되돌릴 때 사라져야 한다(사용자 확정 2026-06-22: "내부확장박스를 삭제하지?"). + // 삭제 후, 이 박스를 *지탱하던 마루* 는 따로 확장(아래 [지탱마루확장]) 한다. + const boxLines = (canvas?.getObjects() || []).filter( + (o) => o && o.parentId === roof.id && (o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine'), + ) + for (const bl of boxLines) { + cops.push({ act: 'box-remove', ln: bl.lineName || '-', p1: { x: Math.round(bl.x1), y: Math.round(bl.y1) }, p2: { x: Math.round(bl.x2), y: Math.round(bl.y2) } }) + removeLine(bl) + if (Array.isArray(roof.innerLines)) roof.innerLines = roof.innerLines.filter((l) => l !== bl) + } + // 코너에서 dir 로 가는 무한 ray ↔ 마루 선분 교점(마루 내부 u∈[0,1], ray 앞쪽 t>0). + const rayRidgeCross = (corner, dir, rg) => { + const ax = rg.x1 + const ay = rg.y1 + const ex = rg.x2 - rg.x1 + const ey = rg.y2 - rg.y1 + const den = dir.x * ey - dir.y * ex + if (Math.abs(den) < 1e-9) return null + const t = ((ax - corner.x) * ey - (ay - corner.y) * ex) / den + const u = ((ax - corner.x) * dir.y - (ay - corner.y) * dir.x) / den + if (t <= 1e-6 || u < -1e-6 || u > 1 + 1e-6) return null + return { x: corner.x + dir.x * t, y: corner.y + dir.y * t } + } + // 1) ★ 마루확장 + 흡수측 절삭(사용자 확정 2026-06-22: "박스삭제→RG-1·RG-2 확장→힙생성 절삭"). + // 각 마루의 *비경계(free)* 끝을 먼저 roof bbox 까지 바깥확장 → 힙 교점들이 세그먼트 안에 + // 들어오게 한 뒤, *free 끝에서 가장 먼* 교점까지 당겨 절삭(--<). 보존측 = 경계(박공 출발측). + // ★ RG-1 이 짧아 H-8 교점(611)을 못 잡던 문제 해소: 확장하면 farthest-from-free 가 611 을 준다. + // free/keep 판정은 *확장 전* 좌표로 1회 고정(확장 후 bbox 끝이 경계로 재인식되는 것 방지). + const ridgesC = (roof.innerLines || []).filter((l) => l && l.visible !== false && l.name === LINE_TYPE.SUBLINE.RIDGE) + let bx0 = Infinity + let bx1 = -Infinity + let by0 = Infinity + let by1 = -Infinity + for (const p of rptsC) { + if (p.x < bx0) bx0 = p.x + if (p.x > bx1) bx1 = p.x + if (p.y < by0) by0 = p.y + if (p.y > by1) by1 = p.y + } + for (const rg of ridgesC) { + const E1 = { x: rg.x1, y: rg.y1 } + const E2 = { x: rg.x2, y: rg.y2 } + const b1 = minEd(E1) <= TOLC + const b2 = minEd(E2) <= TOLC + if (b1 === b2) continue // 보존/흡수측 판별 불가(둘 다/둘 다 아님) → 절삭 보류 + const keep = b1 ? E1 : E2 // 경계측 보존 + const free = b1 ? E2 : E1 // 비경계측 = 흡수(절삭) 대상 + // free 끝을 ridge 방향(keep→free) 으로 bbox 경계까지 확장. + const dxR = free.x - keep.x + const dyR = free.y - keep.y + const lenR = Math.hypot(dxR, dyR) || 1 + const ux = dxR / lenR + const uy = dyR / lenR + let tMax = Infinity + if (ux > 1e-9) tMax = Math.min(tMax, (bx1 - keep.x) / ux) + else if (ux < -1e-9) tMax = Math.min(tMax, (bx0 - keep.x) / ux) + if (uy > 1e-9) tMax = Math.min(tMax, (by1 - keep.y) / uy) + else if (uy < -1e-9) tMax = Math.min(tMax, (by0 - keep.y) / uy) + const absorbed = isFinite(tMax) && tMax > lenR ? { x: keep.x + ux * tMax, y: keep.y + uy * tMax } : free + const setFree = b1 ? (p) => rg.set({ x2: p.x, y2: p.y }) : (p) => rg.set({ x1: p.x, y1: p.y }) + setFree(absorbed) // 임시 확장(아래에서 교점까지 다시 당김) + const crossings = [] + for (const r of hipsC) { + const corner = r === resA ? newC1 : newC2 + const cx = rayRidgeCross(corner, r.dir, rg) + if (cx) crossings.push(cx) + } + if (!crossings.length) { + // 교점 없으면 확장 취소(원복) — 잘못된 bbox 확장 잔재 방지. + setFree(free) + rg.setLength?.() + rg.setCoords?.() + rg.addLengthText?.() + continue + } + // 흡수측(확장된 free) 에서 *가장 먼* 교점까지 절삭 = 노치측 세그먼트 최대 제거. + let far = null + let farD = -1 + for (const c of crossings) { + const d = Math.hypot(c.x - absorbed.x, c.y - absorbed.y) + if (d > farD) { + farD = d + far = c + } + } + setFree(far) + if (b1) rg.endPoint = { x: far.x, y: far.y } + else rg.startPoint = { x: far.x, y: far.y } + const nsz = calcLinePlaneSize({ x1: rg.x1, y1: rg.y1, x2: rg.x2, y2: rg.y2 }) + rg.attributes = { ...(rg.attributes || {}), planeSize: nsz, actualSize: nsz } + rg.setLength?.() + rg.setCoords?.() + rg.addLengthText?.() + cops.push({ act: 'extend-trim', ln: rg.lineName || '-', to: { x: Math.round(far.x), y: Math.round(far.y) }, sz: nsz }) + } + // 2) ★ 삭제 금지(사용자 확정 2026-06-22: "확장하라고 했지 삭제하니?"). 절삭으로 anchor 잃은 + // 라인은 *제거하지 않고* 확장해 형상을 닫는다. (확장 타깃 확정 전까지 보류 — 삭제만 차단.) + const orphans = [] + // 3) 갱신된 형상으로 두 힙을 *재-raycast* 후 다시 그림(앞 힙 절삭이 뒤 힙 장애물을 없앤 결과 반영). + for (const r of hipsC) { + const corner = r === resA ? newC1 : newC2 + const hit2 = rayFirstHit(corner, r.dir) + if (!hit2) continue + const sz = calcLinePlaneSize({ x1: corner.x, y1: corner.y, x2: hit2.x, y2: hit2.y }) + r.hip.set({ x1: corner.x, y1: corner.y, x2: hit2.x, y2: hit2.y }) + r.hip.startPoint = { x: corner.x, y: corner.y } + r.hip.endPoint = { x: hit2.x, y: hit2.y } + r.hip.attributes = { ...(r.hip.attributes || {}), planeSize: sz, actualSize: sz } + r.hip.setLength?.() + r.hip.setCoords?.() + r.hip.addLengthText?.() + r.hit = hit2 + r.hitLine = hit2.line + r.hitKind = hit2.kind + cops.push({ act: 'hip-recast', ln: r.hip.lineName || '-', to: { x: Math.round(hit2.x), y: Math.round(hit2.y) }, kind: hit2.kind, sz }) + } + // 4) ★ 마지막: 서로 다른 길의 두 힙 끝점(마루 안착점)을 잇는 45° 연결 힙 + // (사용자 확정 2026-06-22: "다른 길의 힙과 힙을 이어주는 라인은 힙이 된다, 45도" = 반삭합). + // 각 힙이 마루에 안착한 뒤 두 안착점을 연결해 노치 형상을 닫는다. + const eA = resA && resA.hit ? { x: resA.hit.x, y: resA.hit.y } : null + const eB = resB && resB.hit ? { x: resB.hit.x, y: resB.hit.y } : null + if (eA && eB) { + const cdx = Math.abs(eB.x - eA.x) + const cdy = Math.abs(eB.y - eA.y) + const dist = Math.hypot(eB.x - eA.x, eB.y - eA.y) + if (dist > 1 && Math.abs(cdx - cdy) <= 1.0) { + const conn = mkValleyHip(eA, eB) + canvas.add(conn) + conn.bringToFront() + roof.innerLines.push(conn) + cops.push({ act: 'connector-hip', from: { x: Math.round(eA.x), y: Math.round(eA.y) }, to: { x: Math.round(eB.x), y: Math.round(eB.y) }, sz: conn.attributes?.planeSize }) + } else { + cops.push({ act: 'connector-skip', reason: Math.abs(cdx - cdy) > 1.0 ? 'not-45' : 'too-short', from: { x: Math.round(eA.x), y: Math.round(eA.y) }, to: { x: Math.round(eB.x), y: Math.round(eB.y) } }) + } + } + adjHipDbg.cascade = { cascadeA, cascadeB, orphans: orphans.length, ops: cops } + return { ops: cops, cascade: true } + } + const ops = [] + const setLen = (res, corner, len) => { + const ex = corner.x + res.dir.x * len + const ey = corner.y + res.dir.y * len + const sz = calcLinePlaneSize({ x1: corner.x, y1: corner.y, x2: ex, y2: ey }) + res.hip.set({ x1: corner.x, y1: corner.y, x2: ex, y2: ey }) + res.hip.startPoint = { x: corner.x, y: corner.y } + res.hip.endPoint = { x: ex, y: ey } + res.hip.attributes = { ...(res.hip.attributes || {}), planeSize: sz, actualSize: sz } + res.hip.setLength?.() + if (typeof res.hip.setCoords === 'function') res.hip.setCoords() + res.hip.addLengthText?.() + ops.push({ ln: res.hip.lineName || '-', to: { x: Math.round(ex), y: Math.round(ey) } }) + return { x: ex, y: ey } + } + const e1 = setLen(resA, newC1, dA) + const e2 = setLen(resB, newC2, dB) + const rlen = Math.hypot(e2.x - e1.x, e2.y - e1.y) + if (rlen >= 1) { + // 두 힙 끝을 잇는 선: 축정렬(수평/수직)이면 마루, 아니면 힙으로 연결(두 힙 길이가 달라진 경우). + const CTOL = 1.0 + const axisAligned = Math.abs(e1.x - e2.x) < CTOL || Math.abs(e1.y - e2.y) < CTOL + const rpts = [e1.x, e1.y, e2.x, e2.y] + const rsz = calcLinePlaneSize({ x1: rpts[0], y1: rpts[1], x2: rpts[2], y2: rpts[3] }) + const conn = new QLine(rpts, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: axisAligned ? 4 : 3, + name: axisAligned ? LINE_TYPE.SUBLINE.RIDGE : LINE_TYPE.SUBLINE.HIP, + textMode: roof.textMode, + attributes: { roofId: roof.id, ...(axisAligned ? {} : { type: LINE_TYPE.SUBLINE.HIP }), planeSize: rsz, actualSize: rsz }, + }) + conn.lineName = axisAligned ? 'kerabPatternRidge' : 'kerabPatternHip' + conn.startPoint = { x: conn.x1, y: conn.y1 } + conn.endPoint = { x: conn.x2, y: conn.y2 } + canvas.add(conn) + conn.bringToFront() + roof.innerLines.push(conn) + ops.push({ [axisAligned ? 'ridge' : 'connHip']: { from: { x: Math.round(e1.x), y: Math.round(e1.y) }, to: { x: Math.round(e2.x), y: Math.round(e2.y) } } }) + } + return { ops, e1, e2 } + } + const diamond = buildValleyDiamond() + // 깊이 산출 진단 — 큰 applied 로그에 묻혀 잘리지 않도록 별도 짧은 줄로 먼저 출력. + logger.log('[KERAB-VALLEY-EAVES-RULE1 MIDLINE] ' + JSON.stringify(adjHipDbg)) + // 마름모를 만든 경우에도 resolveRidgeEndpoints 를 *이어서* 실행한다 — 새 힙·마루로 + // anchor 잃은 기존 세로/가로 마루를 절삭(또는 확장)해 dangling(R1) 을 해소(사용자 확정 2026-06-22: + // "세로도 그려야지" = 가로 마름모만 만들고 세로 마루를 떠 있게 두면 안 됨). + // cascade 경로는 마루 절삭/힙 재-raycast 를 자체 처리했으므로 resolveRidgeEndpoints 를 건너뛴다(이중 절삭 방지). + const ridgeOps = diamond ? (diamond.cascade ? diamond.ops : [...diamond.ops, ...resolveRidgeEndpoints()]) : resolveRidgeEndpoints() // 5c) Y 반사 힙 — 새 힙이 *기존 마루(RIDGE)에 직접 부딪힌* 경우만(=raycast hit 가 RIDGE), // 그 마루를 대칭축으로 반대편 면에 반사 힙 1개 생성(R7: 마루+hip 교점 반사). // ★ 마루를 *확장*해서 만난 힙(hit 가 roofLine 이던 resA)은 Y 아님 → 반사 안 함(사용자 확정 2026-06-16). const reflectOps = [] for (const res of [resA, resB]) { + if (diamond) break // 마름모를 만든 경우 힙을 인접 처마 힙 길이로 재설정했으므로 반사 불필요 if (!res || !res.hip || res.hitKind !== LINE_TYPE.SUBLINE.RIDGE || !res.hitLine) continue const M = { x: res.hit.x, y: res.hit.y } const rg = res.hitLine @@ -3174,8 +3492,42 @@ export function useEavesGableEdit(id) { runKerabRuleCheck(roof, 'valley-eaves-rule1', kerabRevertBeforeSnap) logger.log( '[KERAB-VALLEY-EAVES-RULE1] applied ' + - JSON.stringify({ edgeIdx, removed: gableWallLines.length, cleaned, newC1, newC2, resA, resB, ridgeOps, reflectOps }), + JSON.stringify({ edgeIdx, removed: gableWallLines.length, cleaned, newC1, newC2, resA, resB, adjHipDbg, ridgeOps, reflectOps }), ) + // [KERAB-LABEL-MAP 2026-06-22] 화면 라벨(H-n/RG-n/B-n…)↔좌표 맵 — QPolygon.__attachDebugLabels 와 + // *동일 분류·동일 카운터 순서*(canvas 오브젝트 순서)로 재현. 좌표 대신 라벨로 대화하기 위함(local 전용). + try { + const classify = (o) => { + const nm = o.name + const ln = o.lineName + const ty = o.attributes?.type + if (nm === 'baseLine') return 'B' + if (nm === 'outerLine' || nm === 'eaves' || ln === 'roofLine') return 'R' + if (nm === LINE_TYPE.SUBLINE.HIP || ln === LINE_TYPE.SUBLINE.HIP) return 'H' + if (nm === LINE_TYPE.SUBLINE.RIDGE || ln === LINE_TYPE.SUBLINE.RIDGE) return 'RG' + if (nm === LINE_TYPE.SUBLINE.VALLEY || ln === LINE_TYPE.SUBLINE.VALLEY) return 'V' + if (nm === LINE_TYPE.SUBLINE.GABLE || ln === LINE_TYPE.SUBLINE.GABLE) return 'G' + if (typeof o.x1 === 'number' && typeof o.y1 === 'number' && typeof o.x2 === 'number' && typeof o.y2 === 'number') return 'SK' + return null + } + const ctr = {} + const lmap = [] + for (const o of canvas.getObjects()) { + if (!o || o.parentId !== roof.id || o.name === '__debugLabel') continue + const pfx = classify(o) + if (!pfx) continue + ctr[pfx] = (ctr[pfx] || 0) + 1 + lmap.push({ + label: `${pfx}-${ctr[pfx]}`, + p1: { x: Math.round(o.x1), y: Math.round(o.y1) }, + p2: { x: Math.round(o.x2), y: Math.round(o.y2) }, + sz: o.attributes?.planeSize ?? null, + }) + } + logger.log('[KERAB-LABEL-MAP] ' + JSON.stringify(lmap)) + } catch (e) { + logger.warn('[KERAB-LABEL-MAP] failed ' + (e?.message || e)) + } return } } @@ -3991,30 +4343,109 @@ export function useEavesGableEdit(id) { 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 } + // [KERAB-VALLEY-BOX-RELATIVE-ROOFLINE 2026-06-23] 골짜기 박스(kerabValleyOverlapLine)는 인접 + // 두 sub-roof 의 *겹침(union)* 이라, 각 sub-roof 입장에서 박스 변이 곧 자기 roofLine 경계다 + // (상대 roofLine). 따라서 절대 roofLine 뿐 아니라 더 가까운 박스 변에서도 apex/힙이 멈춰야 + // 한다(사용자 확정 2026-06-23: "roofLine 이 상대적"). roofLine 변과 동급으로 ray 교차에 포함. + const boxSegs = (canvas.getObjects() || []).filter( + (o) => + o && + (o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine') && + (o.parentId === roof.id || o.roofId === roof.id || o.attributes?.roofId === roof.id), + ) + for (const o of boxSegs) { + const ax = o.x2 - o.x1 + const ay = o.y2 - o.y1 + const den = dx * ay - dy * ax + if (Math.abs(den) < 1e-9) continue + const t = ((o.x1 - P.x) * ay - (o.y1 - P.y) * ax) / den + const u = ((o.x1 - P.x) * dy - (o.y1 - 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 - ridge.attributes.actualSize = rsz + // [KERAB-TYPE-EAVES-BOX-CLIP 2026-06-23] 골짜기 박스(지붕의 겹침)는 인접 면 입장에서 자기 + // roofLine 경계 = "상대 roofLine"(사용자 확정 2026-06-23). apex 로 향하는 힙이 박스 경계를 + // 만나면 거기서 멈춰야 하고 뚫고 지나가면 안 된다. apex 자체는 45° 이등변 유지를 위해 보존하되, + // 각 힙을 corner→apex 로 쏴 박스 경계 교차점으로 절삭한다. + // 박스 경계 식별: 마루(ridge)의 박스측 고정 끝점 Bp(= apex 로 단축되지 않는 끝)을 공유하는 + // inner HIP/RIDGE 변(H-6/H-7 류). RG-2(415)가 이 Bp 에서 박스 안으로 올라가므로 Bp 가 곧 + // 박스 코너다. 일반 내부선(H-2 등, Bp 미공유)은 힙이 가로질러야 하므로 제외 → 정상 切妻 + // (Bp=roofLine 코너, 공유 inner 없음)은 무영향. ridge(단축 대상)·invisible 제외. + const boxCorner = midIsA ? rB : rA + const clipHipToBox = (corner) => { + const dx0 = apex.x - corner.x + const dy0 = apex.y - corner.y + const dlen = Math.hypot(dx0, dy0) + if (dlen < 1e-6) return { x: apex.x, y: apex.y } + const dx = dx0 / dlen + const dy = dy0 / dlen + let best = dlen + let bestPt = { x: apex.x, y: apex.y } + for (const il of roof.innerLines || []) { + if (!il || il === ridge || il.visible === false) continue + if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + const sharesBp = + (Math.abs(il.x1 - boxCorner.x) < 0.5 && Math.abs(il.y1 - boxCorner.y) < 0.5) || + (Math.abs(il.x2 - boxCorner.x) < 0.5 && Math.abs(il.y2 - boxCorner.y) < 0.5) + // [KERAB-TYPE-EAVES-HIPMEET 2026-06-23] 신규 힙은 (1) 박스 변(Bp 공유) 또는 (2) 직전 변경에서 + // 생성된 기존 케라바 힙(kerabPatternHip)과의 최근접 교점에서 멈춘다 → ">" 모양. 둘 다 방향 무관 + // 후보로 같은 best(t 최소) 누산기에 경쟁시켜 가장 가까운 교점이 절삭점이 된다. 1차 변경 때는 + // 기존 힙이 없어 박스 변만 후보 → 동작 불변(무영향). + const isPriorHip = il.lineName === 'kerabPatternHip' + if (!sharesBp && !isPriorHip) continue + const ax = il.x2 - il.x1 + const ay = il.y2 - il.y1 + if (Math.hypot(ax, ay) < 1e-6) continue + const den = dx * ay - dy * ax + if (Math.abs(den) < 1e-9) continue + const t = ((il.x1 - corner.x) * ay - (il.y1 - corner.y) * ax) / den + const u = ((il.x1 - corner.x) * dy - (il.y1 - corner.y) * dx) / den + if (t > 1.0 && t < best - 1e-6 && u >= -1e-6 && u <= 1 + 1e-6) { + best = t + bestPt = { x: corner.x + dx * t, y: corner.y + dy * t } + } + } + return bestPt } - if (typeof ridge.setCoords === 'function') ridge.setCoords() - if (typeof ridge.setLength === 'function') ridge.setLength() - if (typeof ridge.addLengthText === 'function') ridge.addLengthText() - // hip 2개: wA→apex, wB→apex. pts=[corner, apex] 라 corner 끝(x1,y1)이 outer(=which 1). - const mkHip = (corner, side) => { - const pts = [corner.x, corner.y, apex.x, apex.y] + // 박스 절삭점 선계산 → 마루 처리 분기 판단에 사용. + const endA = clipHipToBox(wA) + const endB = clipHipToBox(wB) + const hipClipped = Math.hypot(endA.x - apex.x, endA.y - apex.y) > 1 || Math.hypot(endB.x - apex.x, endB.y - apex.y) > 1 + if (hipClipped) { + // [KERAB-TYPE-EAVES-RIDGE-VANISH 2026-06-23] 박스(겹침)가 상대 roofLine 이라 신규 힙이 박스/직전 힙에서 + // 멈추면, 가운데 마루(RG-1)는 양쪽 코너 힙이 이미 생성됐으므로 "삭제 대상"이다(사용자 규칙 2026-06-23: + // "RG-1 은 마루 = 삭제, 양쪽에 힙 생성됨"). visible=false 숨기기는 객체가 innerLines 에 남아 + // 디버그 라벨 재부착/LABEL-MAP 이 visible 무시하고 RG-1 을 다시 노출 → "삭제 안 됨" 으로 보였다. + // revert 스냅샷은 좌표 값만 복사하고 revert 경로가 라인을 재생성하므로 진짜 삭제해도 무해하다. + // → reconstruct(4820) 와 동일하게 canvas.remove + innerLines splice 로 실제 삭제한다. + canvas.remove(ridge) + const _ridx = roof.innerLines.indexOf(ridge) + if (_ridx >= 0) roof.innerLines.splice(_ridx, 1) + } else { + // 마루 단축: 케라바변쪽 끝점 → 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 + ridge.attributes.actualSize = rsz + } + if (typeof ridge.setCoords === 'function') ridge.setCoords() + if (typeof ridge.setLength === 'function') ridge.setLength() + if (typeof ridge.addLengthText === 'function') ridge.addLengthText() + } + // hip 2개: wA→endA, wB→endB. pts=[corner, end] 라 corner 끝(x1,y1)이 outer. + const mkHip = (corner, side, end) => { + const pts = [corner.x, corner.y, end.x, end.y] const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) const hip = new QLine(pts, { parentId: roof.id, @@ -4032,8 +4463,8 @@ export function useEavesGableEdit(id) { hip.__kerabRevertOuterSide = side return hip } - const hip1 = mkHip(wA, 'A') - const hip2 = mkHip(wB, 'B') + const hip1 = mkHip(wA, 'A', endA) + const hip2 = mkHip(wB, 'B', endB) canvas.add(hip1) canvas.add(hip2) hip1.bringToFront() @@ -4044,8 +4475,91 @@ export function useEavesGableEdit(id) { canvas.renderAll() logger.log( '[KERAB-TYPE-EAVES] applied ' + - JSON.stringify({ L: Math.round(L), mid, apex, inward: { ix: Number(ix.toFixed(3)), iy: Number(iy.toFixed(3)) } }), + JSON.stringify({ + L: Math.round(L), + mid, + apex: { x: Math.round(apex.x), y: Math.round(apex.y) }, + inward: { ix: Number(ix.toFixed(3)), iy: Number(iy.toFixed(3)) }, + hipA: { x: Math.round(hip1.x2), y: Math.round(hip1.y2), clipped: Math.hypot(hip1.x2 - apex.x, hip1.y2 - apex.y) > 1 }, + hipB: { x: Math.round(hip2.x2), y: Math.round(hip2.y2), clipped: Math.hypot(hip2.x2 - apex.x, hip2.y2 - apex.y) > 1 }, + ridgeDeleted: hipClipped, + ridgeHidden: ridge.visible === false, + }), ) + // [KERAB-LABEL-MAP 2026-06-23] 경로 A(일반 라인→처마) 진단 — 화면 라벨(H-n/RG-n/V-n…)↔좌표 맵. + // 골짜기 박스(kerabValleyOverlapLine)는 V 로 분류되어 V-n 으로 노출 → 힙이 박스를 통과/멈추는지 + // 라벨로 대조하기 위함(local 전용, 로깅만). + try { + const classify = (o) => { + const nm = o.name + const ln = o.lineName + if (nm === 'baseLine') return 'B' + if (nm === 'outerLine' || nm === 'eaves' || ln === 'roofLine') return 'R' + if (nm === LINE_TYPE.SUBLINE.HIP || ln === LINE_TYPE.SUBLINE.HIP) return 'H' + if (nm === LINE_TYPE.SUBLINE.RIDGE || ln === LINE_TYPE.SUBLINE.RIDGE) return 'RG' + if (nm === LINE_TYPE.SUBLINE.VALLEY || ln === LINE_TYPE.SUBLINE.VALLEY) return 'V' + if (nm === LINE_TYPE.SUBLINE.GABLE || ln === LINE_TYPE.SUBLINE.GABLE) return 'G' + if (typeof o.x1 === 'number' && typeof o.y1 === 'number' && typeof o.x2 === 'number' && typeof o.y2 === 'number') return 'SK' + return null + } + const ctr = {} + const lmap = [] + for (const o of canvas.getObjects()) { + if (!o || o.parentId !== roof.id || o.name === '__debugLabel') continue + const pfx = classify(o) + if (!pfx) continue + ctr[pfx] = (ctr[pfx] || 0) + 1 + lmap.push({ + label: `${pfx}-${ctr[pfx]}`, + p1: { x: Math.round(o.x1), y: Math.round(o.y1) }, + p2: { x: Math.round(o.x2), y: Math.round(o.y2) }, + ln: o.lineName || null, + sz: o.attributes?.planeSize ?? null, + }) + } + logger.log('[KERAB-LABEL-MAP] ' + JSON.stringify(lmap)) + } catch (e) { + logger.warn('[KERAB-LABEL-MAP] failed ' + (e?.message || e)) + } + // [KERAB-BOX-DUMP 2026-06-23] 진단 — canvas 전체의 골짜기 박스(kerabValleyOverlapLine) + 모든 ROOF + // 폴리곤 외곽 bbox 를 roofId 와 함께 덤프. 힙이 뚫은 "박스"가 현재 roof 소속인지, 다른 roof/겹침 + // 소속이라 oppRayDist 필터에서 빠졌는지 판별하기 위함(local 전용, 로깅만). + try { + const allBox = (canvas.getObjects() || []) + .filter((o) => o && (o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine')) + .map((o) => ({ + p1: { x: Math.round(o.x1), y: Math.round(o.y1) }, + p2: { x: Math.round(o.x2), y: Math.round(o.y2) }, + parentId: o.parentId || null, + roofId: o.roofId || o.attributes?.roofId || null, + name: o.name || null, + })) + const allRoofs = (canvas.getObjects() || []) + .filter((o) => o && o.name === POLYGON_TYPE.ROOF) + .map((o) => { + const ps = o.points || [] + let x0 = Infinity + let x1 = -Infinity + let y0 = Infinity + let y1 = -Infinity + for (const p of ps) { + if (p.x < x0) x0 = p.x + if (p.x > x1) x1 = p.x + if (p.y < y0) y0 = p.y + if (p.y > y1) y1 = p.y + } + return { id: o.id, isFixed: !!o.isFixed, bbox: { x0: Math.round(x0), y0: Math.round(y0), x1: Math.round(x1), y1: Math.round(y1) }, n: ps.length } + }) + logger.log('[KERAB-BOX-DUMP] ' + JSON.stringify({ curRoof: roof.id, box: allBox, roofs: allRoofs })) + } catch (e) { + logger.warn('[KERAB-BOX-DUMP] failed ' + (e?.message || e)) + } + // [KERAB-TYPE-EAVES-RIDGE-VANISH-FLAG 2026-06-23] 마루(RG-1)를 삭제한 케이스. moveStaleEdgeInnerLines + // 의 relocate 가 이 플래그로 박스 세로선(H-8)의 과길게 뻗은 끝점을 박스 바닥(boxCorner=RG-1 의 + // 박스측 끝점, 예 (747,266))으로 내려 H-7↔H-2 사이만 막도록 만든다(사용자 규칙 2026-06-23: + // "H-8 은 힙이 아니라 박스를 막는 세로 roofLine, H-2 와 H-7 사이만"). + apex.ridgeHidden = hipClipped + apex.boxCorner = hipClipped ? boxCorner : null return apex } @@ -4102,9 +4616,17 @@ export function useEavesGableEdit(id) { let movedCnt = 0 for (const il of roof.innerLines || []) { if (!il || isKerabPattern(il.lineName)) continue + // [KERAB-TYPE-EAVES-EDGEMOVE-SKIPHIDDEN 2026-06-23] 숨긴 마루(hipClipped 시 visible=false)는 + // 옮기지 않는다. 옮기면 cMid→apex 수렴 후 addLengthText 가 박스 위에 "415" 라벨을 되살린다. + if (il.visible === false) continue const collinear = isCollinearWithEdge(il.x1, il.y1, il.x2, il.y2) // 非평행 라인의 cMid 끝점은 apex 로 수렴, 그 외는 出幅 delta 로 평행이동. const relocate = (px, py) => { + // [KERAB-TYPE-EAVES-EDGEMOVE-BOXCLOSE 2026-06-23] 마루(RG-1) 삭제 케이스(apex.ridgeHidden)는 + // 박스 세로선(H-8)이 옛 처마변까지 과길게 뻗어 있다(에러). 그 과길게 뻗은 끝점(cMid 측)을 + // apex 로 올리지도 평행이동하지도 않고, 박스 바닥(boxCorner=RG-1 박스측 끝점)으로 내려 + // H-7↔H-2 사이만 막는 짧은 세로선으로 만든다. + if (apex && apex.ridgeHidden && apex.boxCorner && !collinear && nearMid(px, py)) return { x: apex.boxCorner.x, y: apex.boxCorner.y } if (apex && !collinear && nearMid(px, py)) return { x: apex.x, y: apex.y } return { x: px + dxE, y: py + dyE } }