diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index 1da5d325..7dc90dc5 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -2769,6 +2769,12 @@ export function useEavesGableEdit(id) { moveStaleEdgeInnerLines(roof, c1, c2, _edgeIdx, apexRes) // [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] apex 는 벽기준 고정. 힙을 벽교점 방향 45° 그대로 roofLine 까지 직선 연장. extendTypeGableHipsStraightToRoofLine(roof, target, apexRes) + // [KERAB-TYPE-EAVES-HIPROOF 2026-06-12] 양쪽 박공이 모두 처마가 되어 완전 寄棟이면, + // 세로 apex 2개·X자 교차를 버리고 힙·마루 규칙(R1/R2/R3)으로 가로 마루 + 절삭 힙 4개로 재구성. + reconstructHipRoofRidgeIfComplete(roof) + // [KERAB-RULE-CHECK 2026-06-12] 모든 라인변경(재구성 포함)이 끝난 최종 형상을 규칙으로 검증. + // surgicalAfter 내부 검사는 재구성 전 상태라, 최종 상태는 여기서 한 번 더 본다. + runKerabRuleCheck(roof, 'type-eaves', kerabRevertBeforeSnap) } logger.log('[KERAB-TYPE-EAVES] branch ' + JSON.stringify({ ok: !!apexRes, c1, c2, edgeIdx: _edgeIdx })) if (apexRes) return @@ -3524,16 +3530,31 @@ export function useEavesGableEdit(id) { const L = Math.hypot(wB.x - wA.x, wB.y - wA.y) if (L < 1) return null const mid = { x: (wA.x + wB.x) / 2, y: (wA.y + wB.y) / 2 } - // 마루의 케라바변쪽 끝점 식별 + 내부(inward) 방향 도출. + // 마루의 케라바변쪽 끝점 식별 (단축 대상). const rA = { x: ridge.x1, y: ridge.y1 } const rB = { x: ridge.x2, y: ridge.y2 } const midIsA = Math.hypot(rA.x - mid.x, rA.y - mid.y) <= Math.hypot(rB.x - mid.x, rB.y - mid.y) - const ridgeInner = midIsA ? rB : rA - let ix = ridgeInner.x - mid.x - let iy = ridgeInner.y - mid.y - const ilen = Math.hypot(ix, iy) || 1 - ix /= ilen - iy /= ilen + // [KERAB-TYPE-EAVES-INWARD 2026-06-12] inward 방향을 변의 "폴리곤 안쪽 법선"으로 도출. + // 기존: ridge stub 의 far-end - mid. 마루가 이미 짧아진 stub(2회차 리버트)이면 far-end 가 + // 처마변 위로 와서 방향이 바깥으로 뒤집힘 → apex 폴리곤 밖 폭주(R3-outside, roofLine 절삭 불변식 위반). + // 변⟂마루인 정상 切妻에서는 안쪽 법선 = 마루방향이라 45°/L/2 모델과 동치이고, stub 퇴화에 불변. + const cps = Array.isArray(roof.points) ? roof.points : [] + let cenX = 0 + let cenY = 0 + for (const p of cps) { + cenX += p.x + cenY += p.y + } + cenX /= cps.length || 1 + cenY /= cps.length || 1 + const ex = (wB.x - wA.x) / L + const ey = (wB.y - wA.y) / L + let ix = -ey + let iy = ex + if (ix * (cenX - mid.x) + iy * (cenY - mid.y) < 0) { + ix = -ix + iy = -iy + } const apex = { x: mid.x + ix * (L / 2), y: mid.y + iy * (L / 2) } // 마루 단축: 케라바변쪽 끝점 → apex. if (midIsA) ridge.set({ x1: apex.x, y1: apex.y }) @@ -3602,8 +3623,8 @@ export function useEavesGableEdit(id) { const newA = c1ToP ? nP : nQ const newB = c1ToP ? nQ : nP // 出幅 평행이동 delta (양 코너 동일, 안전하게 평균). - const dxE = ((newA.x - oldC1.x) + (newB.x - oldC2.x)) / 2 - const dyE = ((newA.y - oldC1.y) + (newB.y - oldC2.y)) / 2 + const dxE = (newA.x - oldC1.x + (newB.x - oldC2.x)) / 2 + const dyE = (newA.y - oldC1.y + (newB.y - oldC2.y)) / 2 if (Math.hypot(dxE, dyE) < 0.01) return null const segDx = oldC2.x - oldC1.x const segDy = oldC2.y - oldC1.y @@ -3704,10 +3725,7 @@ export function useEavesGableEdit(id) { const apexIsE1 = d1 <= d2 const outerEnd = apexIsE1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 } // outerEnd 에 가까운 벽교점(wLine 끝점) 선택. - const wc = - Math.hypot(outerEnd.x - wcs[0].x, outerEnd.y - wcs[0].y) <= Math.hypot(outerEnd.x - wcs[1].x, outerEnd.y - wcs[1].y) - ? wcs[0] - : wcs[1] + const wc = Math.hypot(outerEnd.x - wcs[0].x, outerEnd.y - wcs[0].y) <= Math.hypot(outerEnd.x - wcs[1].x, outerEnd.y - wcs[1].y) ? wcs[0] : wcs[1] // 방향 = apex→wc (45°). 이 방향 그대로 roofLine 까지 ray. const dx = wc.x - apex.x const dy = wc.y - apex.y @@ -3746,6 +3764,129 @@ export function useEavesGableEdit(id) { logger.log('[KERAB-TYPE-EAVES-STRAIGHT] ' + JSON.stringify({ extended, apex })) } + // [KERAB-TYPE-EAVES-HIPROOF 2026-06-12] 양쪽 박공이 모두 처마로 바뀌어 완전 寄棟(우진각)이 되는 순간 호출. + // per-edge L/2 apex 모델은 마루를 짧은 축(세로)으로 만들어 두 힙쌍이 X자로 교차하는 틀린 형상을 남긴다. + // 힙·마루 규칙으로 재구성: R2(라인은 교점에서 멈춤)·R3(교점 초과분 절삭)·R1(중앙선=마루). + // 각 힙이 "가장 먼저 만나는" 다른 힙과의 교점이 진짜 마루 끝점(짧은변 공유쌍). 두 끝점을 잇는 선이 마루. + // apex 기반 세로 마루 스텁은 제거하고 절삭된 힙 4개 + 가로 마루 1개(표준 우진각)로 교체. + const reconstructHipRoofRidgeIfComplete = (roof) => { + if (!roof || !Array.isArray(roof.innerLines) || !Array.isArray(roof.points)) return false + // 완전 寄棟 = 외곽선에 박공(GABLE)이 하나도 안 남음. + const outerLines = canvas.getObjects().filter((o) => o.name === 'outerLine' && o.attributes?.roofId === roof.id) + if (!outerLines.length) return false + if (outerLines.some((o) => o.attributes?.type === LINE_TYPE.WALLLINE.GABLE)) return false + // kerabPatternHip 정확히 4개일 때만 표준 우진각으로 간주. + const hips = roof.innerLines.filter((il) => il && il.lineName === 'kerabPatternHip' && il.visible !== false) + if (hips.length !== 4) return false + const pts = roof.points + let cenX = 0 + let cenY = 0 + for (const p of pts) { + cenX += p.x + cenY += p.y + } + cenX /= pts.length || 1 + cenY /= pts.length || 1 + // 각 힙의 outer(roofLine 코너쪽) / inner 끝점 + 방향(outer→inner, 45°). + const info = hips.map((il) => { + const d1 = Math.hypot(il.x1 - cenX, il.y1 - cenY) + const d2 = Math.hypot(il.x2 - cenX, il.y2 - cenY) + const outer = d1 >= d2 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 } + const inner = d1 >= d2 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 } + return { il, outer, dir: { x: inner.x - outer.x, y: inner.y - outer.y } } + }) + // 두 힙 직선 교점 (a.outer + t·a.dir). + const intersect = (a, b) => { + const den = a.dir.x * b.dir.y - a.dir.y * b.dir.x + if (Math.abs(den) < 1e-9) return null + const t = ((b.outer.x - a.outer.x) * b.dir.y - (b.outer.y - a.outer.y) * b.dir.x) / den + const u = ((b.outer.x - a.outer.x) * a.dir.y - (b.outer.y - a.outer.y) * a.dir.x) / den + if (t <= 1e-6 || u <= 1e-6) return null + return { x: a.outer.x + a.dir.x * t, y: a.outer.y + a.dir.y * t, t } + } + // 각 힙이 "가장 먼저(최소 t)" 만나는 힙 = 마루 끝점 파트너. + for (let i = 0; i < info.length; i++) { + let best = null + let bestJ = -1 + for (let j = 0; j < info.length; j++) { + if (i === j) continue + const ip = intersect(info[i], info[j]) + if (!ip) continue + if (!best || ip.t < best.t) { + best = ip + bestJ = j + } + } + info[i].stop = best + info[i].partner = bestJ + } + if (info.some((h) => !h.stop)) return false + // 마루 끝점 클러스터링 (2개 기대). + const ridgePts = [] + for (const h of info) { + const found = ridgePts.find((rp) => Math.hypot(rp.x - h.stop.x, rp.y - h.stop.y) < 1.0) + if (found) found.count++ + else ridgePts.push({ x: h.stop.x, y: h.stop.y, count: 1 }) + } + if (ridgePts.length !== 2 || ridgePts.some((rp) => rp.count !== 2)) { + logger.log('[KERAB-TYPE-EAVES-HIPROOF] skip ' + JSON.stringify({ ridgePts: ridgePts.length, counts: ridgePts.map((r) => r.count) })) + return false + } + // R2/R3: 각 힙을 outer → 마루 끝점으로 절삭. + for (const h of info) { + const rp = ridgePts.find((p) => Math.hypot(p.x - h.stop.x, p.y - h.stop.y) < 1.0) + const d1 = Math.hypot(h.il.x1 - cenX, h.il.y1 - cenY) + const d2 = Math.hypot(h.il.x2 - cenX, h.il.y2 - cenY) + if (d1 >= d2) h.il.set({ x2: rp.x, y2: rp.y }) + else h.il.set({ x1: rp.x, y1: rp.y }) + const sz = calcLinePlaneSize({ x1: h.il.x1, y1: h.il.y1, x2: h.il.x2, y2: h.il.y2 }) + if (h.il.attributes) { + h.il.attributes.planeSize = sz + h.il.attributes.actualSize = sz + } + if (typeof h.il.setCoords === 'function') h.il.setCoords() + if (typeof h.il.setLength === 'function') h.il.setLength() + if (typeof h.il.addLengthText === 'function') h.il.addLengthText() + } + // 기존 마루(세로 apex 스텁 등) 제거 — 완전 우진각엔 가로 마루 1개만. + const staleRidges = roof.innerLines.filter((il) => il && il.name === LINE_TYPE.SUBLINE.RIDGE) + for (const r of staleRidges) { + canvas.remove(r) + const idx = roof.innerLines.indexOf(r) + if (idx >= 0) roof.innerLines.splice(idx, 1) + } + // R1: 두 마루 끝점을 잇는 가로 마루 신규 생성. + const rp1 = ridgePts[0] + const rp2 = ridgePts[1] + const rpts = [rp1.x, rp1.y, rp2.x, rp2.y] + const rsz = calcLinePlaneSize({ x1: rpts[0], y1: rpts[1], x2: rpts[2], y2: rpts[3] }) + const ridge = new QLine(rpts, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 3, + name: LINE_TYPE.SUBLINE.RIDGE, + textMode: roof.textMode, + attributes: { roofId: roof.id, type: LINE_TYPE.SUBLINE.RIDGE, planeSize: rsz, actualSize: rsz }, + }) + ridge.lineName = 'ridge' + canvas.add(ridge) + ridge.bringToFront() + roof.innerLines.push(ridge) + if (typeof ridge.setCoords === 'function') ridge.setCoords() + if (typeof ridge.setLength === 'function') ridge.setLength() + if (typeof ridge.addLengthText === 'function') ridge.addLengthText() + canvas.renderAll() + logger.log( + '[KERAB-TYPE-EAVES-HIPROOF] reconstructed ' + + JSON.stringify({ + ridge: { x1: Math.round(rp1.x), y1: Math.round(rp1.y), x2: Math.round(rp2.x), y2: Math.round(rp2.y) }, + removedRidges: staleRidges.length, + }), + ) + return true + } + // [KERAB-HALF-LABEL 2026-05-19] 케라바 외곽선의 좌우/상하 절반 길이 라벨 // 그림 그릴 단계에서는 외곽선은 1개로 유지하고 라벨만 2개 노출. // 할당 시 splitPolygonWithLines 가 자연 분할하므로 그때는 사라지고 분할 라인 라벨이 대체.