From 35ba41a104ec5dee8dbc3cd54e3a627740164dfd Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 19 May 2026 12:32:48 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=ED=83=AD=EC=97=90=20path=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/PageTracker.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/common/PageTracker.jsx b/src/components/common/PageTracker.jsx index 19eb99d9..157dab9d 100644 --- a/src/components/common/PageTracker.jsx +++ b/src/components/common/PageTracker.jsx @@ -19,9 +19,9 @@ export default function PageTracker() { 'background:#1e88e5;color:#fff;padding:2px 8px;border-radius:3px;font-weight:bold', ) - if (typeof document !== 'undefined') { - document.title = `${pathname} · HANASYS DESIGN` - } + // if (typeof document !== 'undefined') { + // document.title = `${pathname} · HANASYS DESIGN` + // } }, [pathname]) return null -- 2.47.2 From eb12b2413825d70d8e584e052244075bbf923d52 Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 19 May 2026 17:12:48 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[2173]=20=EC=B2=98=EB=A7=88=E2=86=92?= =?UTF-8?q?=EC=BC=80=EB=9D=BC=EB=B0=94=20junction-extended=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20+=20RG-1=20=EC=9E=90=EB=8F=99=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - findHipPairViaJunction: H-7/H-1 의 junction 너머 inner hip(H-4/H-2) 무한확장 교점을 apex 채택 - applyKerabJunctionExtendedPattern (순수 추가): · ext hip 2개(junction→apex) + 중앙 ridge(mid→apex) 추가 · ridgeEndpointsOnBothExtensions 로 RG-1 식별 → 스냅샷 첨부 후 제거 (RG-2 보존) - applyKerabRevertPattern: __removedRidgesSnapshot(배열) 반복 복원으로 forward 와 대칭 - useRoofAllocationSetting: 분할 직전/후 패턴 라인 진단 로그(KERAB-PATTERN-DIAG) --- src/hooks/roofcover/useEavesGableEdit.js | 619 ++++++++++++++++++ .../roofcover/useRoofAllocationSetting.js | 25 + 2 files changed, 644 insertions(+) diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index 995203f1..14fb825d 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -11,6 +11,10 @@ import { useSwal } from '@/hooks/useSwal' import { usePopup } from '@/hooks/usePopup' import { getChonByDegree } from '@/util/canvas-util' import { settingModalFirstOptionsState } from '@/store/settingAtom' +import { fabric } from 'fabric' +import { QLine } from '@/components/fabric/QLine' +import { calcLinePlaneSize } from '@/util/qpolygon-utils' +import { logger } from '@/util/logger' // 처마.케라바 변경 export function useEavesGableEdit(id) { @@ -141,6 +145,7 @@ export function useEavesGableEdit(id) { } const mouseDownEvent = (e) => { + logger.log('[KERAB-MOUSEDOWN] fired', { hasTarget: !!e.target, name: e.target?.name, type: typeRef.current, radio: radioTypeRef.current }) canvas.discardActiveObject() if (!e.target || (e.target && e.target.name !== 'outerLine')) { return @@ -210,6 +215,107 @@ export function useEavesGableEdit(id) { break } + // [2240 KERAB-NOOP-REKLICK 2026-05-19] 같은 type 으로의 재클릭은 무동작. + // - 케라바→케라바, 처마→처마 등. 기존 rebuild 흐름이 다시 돌면 패턴 상태 + // (ridge/half-label/orphan ext 정리)를 망가뜨림. + // - radio 1 의 단순 변환에만 적용. JERKINHEAD/HIPANDGABLE 등 width 가 들어가는 + // radio 2 변환은 파라미터 갱신 가능성이 있어 그대로 진행. + if (radioTypeRef.current === '1' && target.attributes?.type === attributes?.type) { + logger.log(`[KERAB-NOOP] 이미 ${attributes.type} → 재변환 무시`) + return + } + + // [2240 KERAB-SINGLE-RIDGE 2026-05-19] 대전제 1: 처마→케라바 변환 시 양 끝점에 동일 apex 의 hip 페어가 존재하면 + // hip 2개 제거 + 처마 중점→apex ridge 1개 생성으로 처리하고 기존 rebuild 흐름은 건너뜀. + // 그 외 모든 케이스(hip 0/1개, 평행, 다중 변환 후 hip 소진 등)는 attribute-only 로 처리하고 rebuild 진입 차단. + if (typeRef.current === TYPES.GABLE && radioTypeRef.current === '1') { + const roof = canvas + .getObjects() + .find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId) + logger.log('[KERAB-GUARD] roof check', { + roofId: target.attributes?.roofId, + roofFound: !!roof, + innerLinesArray: Array.isArray(roof?.innerLines), + pointsArray: Array.isArray(roof?.points), + innerLinesLen: roof?.innerLines?.length, + pointsLen: roof?.points?.length, + }) + if (roof && Array.isArray(roof.innerLines) && Array.isArray(roof.points)) { + // outerLine 끝점(벽 좌표)을 가장 가까운 roof 폴리곤 코너(offset 적용 좌표)로 스냅 + const c1 = nearestRoofPoint(roof, { x: target.x1, y: target.y1 }) + const c2 = nearestRoofPoint(roof, { x: target.x2, y: target.y2 }) + logger.log('[KERAB-GUARD] c1/c2', { targetXY: { x1: target.x1, y1: target.y1, x2: target.x2, y2: target.y2 }, c1, c2 }) + if (c1 && c2) { + const hipsAtC1 = findHipsAtPoint(roof, c1) + const hipsAtC2 = findHipsAtPoint(roof, c2) + logger.log('[KERAB-GUARD] hips at corners', { c1n: hipsAtC1.length, c2n: hipsAtC2.length }) + const pair = + hipsAtC1.length > 0 && hipsAtC2.length > 0 ? findHipPairWithSharedApex(hipsAtC1, c1, hipsAtC2, c2) : null + logger.log('[KERAB-GUARD] shared-apex pair', { found: !!pair }) + if (pair) { + target.set({ attributes }) + const ok = applyKerabSingleRidgePattern(roof, target, c1, c2, pair) + logger.log('[KERAB-PATTERN] applied', { ok, c1, c2, apex: pair.apex }) + if (ok) return + } + // [2240 KERAB-JUNCTION-EXTENDED 2026-05-19] shared-apex 가 없으면 H-7/H-1 의 junction 너머 + // H-4/H-2(inner hip) 를 무한확장한 교점을 apex 로. 채택 시 outer(H-7,H-1) 제거 + + // inner(H-4,H-2) 끝을 apex 로 늘림 + mid→apex ridge. H-7/H-1 직접 확장보다 안쪽까지 중앙선이 연장됨. + const jp = findHipPairViaJunction(roof, c1, c2) + logger.log('[KERAB-GUARD] junction-extended pair', { found: !!jp, apex: jp?.apex }) + if (jp) { + target.set({ attributes }) + const ok = applyKerabJunctionExtendedPattern(roof, target, c1, c2, jp) + logger.log('[KERAB-PATTERN] applied (junction-extended)', { ok }) + if (ok) return + } + // [2240 KERAB-EXTENDED-APEX 2026-05-19] junction-extended 도 안 되면 H-7/H-1 직접 무한확장한 교점을 apex 로. + // apex 가 처마 중점 수선 위(가운데) 일 때만 채택. 채택 시 단일-ridge 패턴과 동일 처리. + const pairExt = + hipsAtC1.length > 0 && hipsAtC2.length > 0 ? findHipPairWithExtendedApex(hipsAtC1, c1, hipsAtC2, c2) : null + logger.log('[KERAB-GUARD] extended-apex pair', { found: !!pairExt, apex: pairExt?.apex }) + if (pairExt) { + target.set({ attributes }) + const ok = applyKerabSingleRidgePattern(roof, target, c1, c2, pairExt) + logger.log('[KERAB-PATTERN] applied (extended)', { ok }) + if (ok) return + } + // [2240 KERAB-ATTR-ONLY 2026-05-19] hip 페어 없음(0/1개·평행·다중변환 소진·apex 가 중앙 아님 등) → 속성만 GABLE 로 변경 + target.set({ attributes }) + applyKerabAttributeOnlyPattern() + logger.log('[KERAB-ATTR-ONLY] applied (forward)', { c1, c2, hipsC1: hipsAtC1.length, hipsC2: hipsAtC2.length }) + return + } + } + } + + // [2240 KERAB-REVERT 2026-05-19] 대전제 1 역방향: 케라바→처마 변환 시 케라바 중점→apex ridge 가 존재하면 + // ridge 1개 제거 + apex→c1, apex→c2 hip 2개 생성으로 처리하고 기존 rebuild 흐름은 건너뜀. + // ridge-at-mid 없으면 forward 가 attribute-only 였던 케이스 → 속성만 EAVES 로 토글. + if (typeRef.current === TYPES.EAVES && radioTypeRef.current === '1' && target.attributes?.type === LINE_TYPE.WALLLINE.GABLE) { + const roof = canvas + .getObjects() + .find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId) + if (roof && Array.isArray(roof.innerLines) && Array.isArray(roof.points)) { + const c1 = nearestRoofPoint(roof, { x: target.x1, y: target.y1 }) + const c2 = nearestRoofPoint(roof, { x: target.x2, y: target.y2 }) + if (c1 && c2) { + const ridgeAtMid = findRidgeAtMidpoint(roof, c1, c2) + if (ridgeAtMid) { + target.set({ attributes }) + const ok = applyKerabRevertPattern(roof, target, c1, c2, ridgeAtMid) + logger.log('[KERAB-REVERT] applied', { ok, c1, c2, apex: ridgeAtMid.apex }) + if (ok) return + } + // [2240 KERAB-ATTR-ONLY 2026-05-19] ridge-at-mid 없음 → forward 가 attribute-only 였음 → 동일 대칭으로 속성만 토글 + target.set({ attributes }) + applyKerabAttributeOnlyPattern() + logger.log('[KERAB-ATTR-ONLY] applied (revert)', { c1, c2 }) + return + } + } + } + target.set({ attributes, }) @@ -321,5 +427,518 @@ export function useEavesGableEdit(id) { canvas?.renderAll() } + // [2240 KERAB-SINGLE-RIDGE 2026-05-19] 헬퍼 — 양 끝점 hip 페어 식별 및 ridge 단일화 적용 + const KERAB_TOL = 0.5 + const isSamePt = (a, b) => Math.abs(a.x - b.x) < KERAB_TOL && Math.abs(a.y - b.y) < KERAB_TOL + + // outerLine 끝점(벽 좌표)을 가장 가까운 roof.points 코너(offset 적용 좌표)로 스냅 + const nearestRoofPoint = (roof, point) => { + if (!Array.isArray(roof.points) || roof.points.length === 0) return null + let best = roof.points[0] + let bestD = (best.x - point.x) ** 2 + (best.y - point.y) ** 2 + for (let i = 1; i < roof.points.length; i++) { + const p = roof.points[i] + const d = (p.x - point.x) ** 2 + (p.y - point.y) ** 2 + if (d < bestD) { + bestD = d + best = p + } + } + return { x: best.x, y: best.y } + } + + const findHipsAtPoint = (roof, point) => { + return roof.innerLines.filter((il) => { + if (!il || il.name !== LINE_TYPE.SUBLINE.HIP) return false + return isSamePt({ x: il.x1, y: il.y1 }, point) || isSamePt({ x: il.x2, y: il.y2 }, point) + }) + } + + const otherEndOfHip = (hip, corner) => { + const a = { x: hip.x1, y: hip.y1 } + const b = { x: hip.x2, y: hip.y2 } + return isSamePt(a, corner) ? b : a + } + + const findHipPairWithSharedApex = (hipsAtP1, p1, hipsAtP2, p2) => { + for (const hA of hipsAtP1) { + const apexA = otherEndOfHip(hA, p1) + for (const hB of hipsAtP2) { + if (hB === hA) continue + const apexB = otherEndOfHip(hB, p2) + if (isSamePt(apexA, apexB)) return { hA, hB, apex: apexA } + } + } + return null + } + + // [2240 KERAB-EXTENDED-APEX 2026-05-19] B-5 끝점 hip(H-7, H-1) 을 무한 확장한 교점이 + // 처마 중점 수선 위에 있으면 apex 채택. 채택 시 단일-ridge 패턴과 동일하게 hip 2개 제거 + mid→apex ridge. + const findHipPairWithExtendedApex = (hipsAtP1, p1, hipsAtP2, p2) => { + const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 } + const eVx = p2.x - p1.x + const eVy = p2.y - p1.y + const eMag = Math.hypot(eVx, eVy) || 1 + for (const hA of hipsAtP1) { + const oA = otherEndOfHip(hA, p1) + // hA 의 진행방향: p1 → oA 의 반대로(oA 너머 확장) 이 아닌, hip 자체를 무한 직선으로 본 교점 계산. + // 직선 파라미터: p1 + t*(oA - p1). t>1 이면 oA 너머(폴리곤 내부 방향). + const dAx = oA.x - p1.x + const dAy = oA.y - p1.y + for (const hB of hipsAtP2) { + if (hB === hA) continue + const oB = otherEndOfHip(hB, p2) + const dBx = oB.x - p2.x + const dBy = oB.y - p2.y + const denom = dAx * dBy - dAy * dBx + if (Math.abs(denom) < 1e-6) continue // 평행 → 영원히 못 만남 + const px = p2.x - p1.x + const py = p2.y - p1.y + const t = (px * dBy - py * dBx) / denom + const s = (px * dAy - py * dAx) / denom + // hip 의 진행방향(끝점 너머) 으로 만나야 함. t,s > 1 (oA, oB 너머 확장) + if (t <= 1 + 1e-3 || s <= 1 + 1e-3) continue + const apex = { x: p1.x + t * dAx, y: p1.y + t * dAy } + // 가운데 검증: (apex - mid) 이 처마 방향과 수직(내적 ≈ 0) + const dotEaves = ((apex.x - mid.x) * eVx + (apex.y - mid.y) * eVy) / eMag + if (Math.abs(dotEaves) > KERAB_TOL) continue + return { hA, hB, apex } + } + } + return null + } + + // [2240 KERAB-JUNCTION-EXTENDED 2026-05-19] H-7/H-1 의 junction 너머 inner hip(H-4/H-2) 을 무한직선으로 + // 확장한 교점이 처마 중점 수선 위에 있으면 apex 채택. H-7/H-1 직접 확장이 가운데가 아닌 + // L-노치 등 복합 형상에서 사용. apex 검증 후 outer hip 제거 + inner hip 확장 + ridge. + const findHipPairViaJunction = (roof, c1, c2) => { + const hipsAtC1 = findHipsAtPoint(roof, c1) + const hipsAtC2 = findHipsAtPoint(roof, c2) + logger.log('[JUNCTION-DIAG] c1/c2 hips', { c1, c2, c1n: hipsAtC1.length, c2n: hipsAtC2.length }) + if (hipsAtC1.length === 0 || hipsAtC2.length === 0) return null + const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 } + const eVx = c2.x - c1.x + const eVy = c2.y - c1.y + const eMag = Math.hypot(eVx, eVy) || 1 + for (const outer1 of hipsAtC1) { + const junction1 = otherEndOfHip(outer1, c1) + const innersAtJ1 = findHipsAtPoint(roof, junction1).filter((h) => h !== outer1) + logger.log('[JUNCTION-DIAG] j1', { junction1, innersAtJ1n: innersAtJ1.length }) + if (innersAtJ1.length === 0) continue + for (const outer2 of hipsAtC2) { + if (outer2 === outer1) continue + const junction2 = otherEndOfHip(outer2, c2) + if (isSamePt(junction1, junction2)) { + logger.log('[JUNCTION-DIAG] junction shared → skip (shared-apex case)') + continue + } + const innersAtJ2 = findHipsAtPoint(roof, junction2).filter((h) => h !== outer2) + logger.log('[JUNCTION-DIAG] j2', { junction2, innersAtJ2n: innersAtJ2.length }) + if (innersAtJ2.length === 0) continue + for (const inner1 of innersAtJ1) { + const innerFar1 = otherEndOfHip(inner1, junction1) + const dAx = innerFar1.x - junction1.x + const dAy = innerFar1.y - junction1.y + for (const inner2 of innersAtJ2) { + if (inner2 === inner1) continue + const innerFar2 = otherEndOfHip(inner2, junction2) + const dBx = innerFar2.x - junction2.x + const dBy = innerFar2.y - junction2.y + const denom = dAx * dBy - dAy * dBx + if (Math.abs(denom) < 1e-6) { + logger.log('[JUNCTION-DIAG] parallel inner hips') + continue + } + const px = junction2.x - junction1.x + const py = junction2.y - junction1.y + const t = (px * dBy - py * dBx) / denom + const apex = { x: junction1.x + t * dAx, y: junction1.y + t * dAy } + const dotEaves = ((apex.x - mid.x) * eVx + (apex.y - mid.y) * eVy) / eMag + logger.log('[JUNCTION-DIAG] candidate', { apex, dotEaves, threshold: KERAB_TOL }) + if (Math.abs(dotEaves) > KERAB_TOL) continue + logger.log('[JUNCTION-DIAG] MATCH') + return { outer1, outer2, inner1, inner2, junction1, junction2, apex, innerFar1, innerFar2 } + } + } + } + } + return null + } + + // 재사용 가능한 검증 헬퍼 — ridge 의 한 끝점이 주어진 line 의 중점과 만나는지 + const midpointOfLine = (line) => ({ x: (line.x1 + line.x2) / 2, y: (line.y1 + line.y2) / 2 }) + const ridgeMeetsMidpointOf = (ridge, line) => { + const mid = midpointOfLine(line) + return isSamePt({ x: ridge.x1, y: ridge.y1 }, mid) || isSamePt({ x: ridge.x2, y: ridge.y2 }, mid) + } + + // [2240 KERAB-CENTRAL-RIDGE 2026-05-19] 특정 좌표 점에서 끝나는 ridge 1개 찾기. + // 케라바 패턴 ridge(kerabPatternRidge) 는 제외. + const findRidgeEndingAt = (roof, point) => { + for (const il of roof.innerLines) { + if (!il || il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + if (il.lineName === 'kerabPatternRidge') continue + if (isSamePt({ x: il.x1, y: il.y1 }, point)) return il + if (isSamePt({ x: il.x2, y: il.y2 }, point)) return il + } + return null + } + + // [2240 KERAB-CENTRAL-RIDGE 2026-05-19] 점이 선분 위에 있는지 (tol 안 거리). + const isPointOnSegment = (p, seg, tol = KERAB_TOL) => { + const dx = seg.x2 - seg.x1 + const dy = seg.y2 - seg.y1 + const len2 = dx * dx + dy * dy + if (len2 < 1e-9) return Math.hypot(p.x - seg.x1, p.y - seg.y1) <= tol + let t = ((p.x - seg.x1) * dx + (p.y - seg.y1) * dy) / len2 + t = Math.max(0, Math.min(1, t)) + const px = seg.x1 + t * dx + const py = seg.y1 + t * dy + return Math.hypot(p.x - px, p.y - py) <= tol + } + + // [2240 KERAB-CENTRAL-RIDGE 2026-05-19] ridge 의 양 끝점이 ext1·ext2 각각의 segment 위에 있는지. + // RG-1 은 H-4/H-2 확장선 사이에 놓인 ridge — 두 확장선이 RG-1 의 양 끝점을 각각 통과한다. + const ridgeEndpointsOnBothExtensions = (ridge, ext1, ext2) => { + const a = { x: ridge.x1, y: ridge.y1 } + const b = { x: ridge.x2, y: ridge.y2 } + const seg1 = { x1: ext1.x1, y1: ext1.y1, x2: ext1.x2, y2: ext1.y2 } + const seg2 = { x1: ext2.x1, y1: ext2.y1, x2: ext2.x2, y2: ext2.y2 } + const aOn1 = isPointOnSegment(a, seg1) + const aOn2 = isPointOnSegment(a, seg2) + const bOn1 = isPointOnSegment(b, seg1) + const bOn2 = isPointOnSegment(b, seg2) + return (aOn1 && bOn2) || (aOn2 && bOn1) + } + + // 케라바 중점→apex ridge 1개 찾기 (역방향 패턴의 전제) + const findRidgeAtMidpoint = (roof, c1, c2) => { + const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 } + for (const il of roof.innerLines) { + if (!il || il.name !== LINE_TYPE.SUBLINE.RIDGE) continue + const a = { x: il.x1, y: il.y1 } + const b = { x: il.x2, y: il.y2 } + if (isSamePt(a, mid)) return { ridge: il, apex: b } + if (isSamePt(b, mid)) return { ridge: il, apex: a } + } + return null + } + + // 케라바→처마 역변환 패턴: ridge 1개 제거 + hip 복원 + // - junctionExtended: inner hip 좌표 원복 + outer hip 2개 신규 복원 (snapshot 기반) + // - 일반 single-ridge (__originalHips): outer hip 2개를 스냅샷 좌표 그대로 복원 + // - 둘 다 없으면 폴백: c1↔apex, c2↔apex 신규 hip (이전 패턴 호환) + const applyKerabRevertPattern = (roof, target, c1, c2, ridgeAtMid) => { + const apex = ridgeAtMid.apex + const buildHipFromSnapshot = (snap) => { + const pts = [snap.x1, snap.y1, snap.x2, snap.y2] + const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) + const hip = new QLine(pts, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 2, + name: LINE_TYPE.SUBLINE.HIP, + textMode: roof.textMode, + attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...snap.attributes }, + }) + if (snap.lineName) hip.lineName = snap.lineName + if (snap.__extended) hip.__extended = snap.__extended + return hip + } + const buildHipToApex = (cornerPt) => { + const pts = [cornerPt.x, cornerPt.y, apex.x, apex.y] + const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) + const hip = new QLine(pts, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 2, + name: LINE_TYPE.SUBLINE.HIP, + textMode: roof.textMode, + attributes: { roofId: roof.id, planeSize: sz, actualSize: sz }, + }) + hip.lineName = 'kerabPatternHip' + return hip + } + const restoreInnerHipFromSnapshot = (hip, snap) => { + hip.set({ x1: snap.x1, y1: snap.y1, x2: snap.x2, y2: snap.y2 }) + const ns = calcLinePlaneSize({ x1: snap.x1, y1: snap.y1, x2: snap.x2, y2: snap.y2 }) + hip.attributes = { ...hip.attributes, ...snap.attributes, planeSize: ns, actualSize: ns } + } + + // junctionExtended: 추가만 했으므로 추가된 ext hip 2개 + ridge 1개만 제거 + 제거됐던 RG-1 복원 + if (ridgeAtMid.ridge.__patternKind === 'junctionExtended') { + const extHips = Array.isArray(ridgeAtMid.ridge.__patternExtHips) ? ridgeAtMid.ridge.__patternExtHips : [] + extHips.forEach((h) => { + if (!h) return + removeLine(h) + roof.innerLines = roof.innerLines.filter((il) => il !== h) + }) + const removedSnaps = Array.isArray(ridgeAtMid.ridge.__removedRidgesSnapshot) ? ridgeAtMid.ridge.__removedRidgesSnapshot : [] + removeLine(ridgeAtMid.ridge) + roof.innerLines = roof.innerLines.filter((il) => il !== ridgeAtMid.ridge) + removedSnaps.forEach((snap) => { + if (!snap) return + const pts = [snap.x1, snap.y1, snap.x2, snap.y2] + const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) + const restored = new QLine(pts, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 2, + name: LINE_TYPE.SUBLINE.RIDGE, + textMode: roof.textMode, + attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...snap.attributes }, + }) + if (snap.lineName) restored.lineName = snap.lineName + canvas.add(restored) + restored.bringToFront() + roof.innerLines.push(restored) + }) + removeKerabHalfLabels(target.id) + hideOriginalLengthText(target.id, false) + canvas.renderAll() + return true + } + + // single-ridge / 이전 패턴: outer hip 2개 신규 생성 + const snaps = Array.isArray(ridgeAtMid.ridge.__originalHips) ? ridgeAtMid.ridge.__originalHips : null + const hip1 = snaps ? buildHipFromSnapshot(snaps[0]) : buildHipToApex(c1) + const hip2 = snaps ? buildHipFromSnapshot(snaps[1]) : buildHipToApex(c2) + removeLine(ridgeAtMid.ridge) + roof.innerLines = roof.innerLines.filter((il) => il !== ridgeAtMid.ridge) + canvas.add(hip1) + canvas.add(hip2) + hip1.bringToFront() + hip2.bringToFront() + roof.innerLines.push(hip1, hip2) + removeKerabHalfLabels(target.id) + hideOriginalLengthText(target.id, false) + canvas.renderAll() + return true + } + + // [KERAB-HALF-LABEL 2026-05-19] 케라바 외곽선의 좌우/상하 절반 길이 라벨 + // 그림 그릴 단계에서는 외곽선은 1개로 유지하고 라벨만 2개 노출. + // 할당 시 splitPolygonWithLines 가 자연 분할하므로 그때는 사라지고 분할 라인 라벨이 대체. + const KERAB_HALF_LABEL_NAME = 'kerabHalfLabel' + + const removeKerabHalfLabels = (parentId) => { + const labels = canvas + .getObjects() + .filter((obj) => obj.name === KERAB_HALF_LABEL_NAME && obj.parentId === parentId) + labels.forEach((obj) => canvas.remove(obj)) + } + + const addKerabHalfLabels = (target, c1, c2) => { + const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 } + const fullPlane = calcLinePlaneSize({ x1: c1.x, y1: c1.y, x2: c2.x, y2: c2.y }) + const halfPlane = Math.round(fullPlane / 2) + const text = String(halfPlane).replace(/\.0$/, '') + const mkPos = (a, b) => { + const cx = (a.x + b.x) / 2 + const cy = (a.y + b.y) / 2 + // 수평/수직 라인에 한해 라벨을 라인 옆으로 살짝 띄움 (QLine.addLengthText 와 동일한 룰) + if (target.direction === 'left' || target.direction === 'right') return { left: cx, top: cy + 10 } + if (target.direction === 'top' || target.direction === 'bottom') return { left: cx + 10, top: cy } + return { left: cx, top: cy } + } + const buildLabel = (pos) => + new fabric.Textbox(text, { + left: pos.left, + top: pos.top, + fontSize: target.fontSize, + parentId: target.id, + name: KERAB_HALF_LABEL_NAME, + editable: false, + selectable: true, + lockRotation: true, + lockScalingX: true, + lockScalingY: true, + planeSize: halfPlane, + actualSize: halfPlane, + }) + canvas.add(buildLabel(mkPos(c1, mid))) + canvas.add(buildLabel(mkPos(mid, c2))) + } + + const hideOriginalLengthText = (parentId, hide) => { + const lbl = canvas.getObjects().find((obj) => obj.name === 'lengthText' && obj.parentId === parentId) + if (lbl) lbl.set({ visible: !hide }) + } + + // [KERAB-PATTERN-EXT-CLEAN 2026-05-19] forward 시 제거되는 hip 과 짝이었던 orphan + // extensionLine 정리. 한 끝점이 hip 의 한 끝점과 같고 다른 끝점이 hip 의 진행 + // 방향과 동일선상에 있으면 짝으로 판정. 잔류 시 allocation 의 integrateExtensionLines + // 가 "짝없음" 으로 처리하고, ext 끝점이 외곽 polygon 을 잘못 자르는 잉여 분할 발생. + const removeOrphanExtensionsForHip = (hip) => { + const TOL = 1.0 + const hipP1 = { x: hip.x1, y: hip.y1 } + const hipP2 = { x: hip.x2, y: hip.y2 } + const hipVx = hip.x2 - hip.x1 + const hipVy = hip.y2 - hip.y1 + const hipMag = Math.hypot(hipVx, hipVy) || 1 + const same = (a, b) => Math.hypot(a.x - b.x, a.y - b.y) < TOL + const exts = canvas.getObjects().filter( + (o) => o?.lineName === 'extensionLine' && o.roofId === hip.parentId, + ) + exts.forEach((ext) => { + const eP1 = { x: ext.x1, y: ext.y1 } + const eP2 = { x: ext.x2, y: ext.y2 } + const sharesP1 = same(eP1, hipP1) || same(eP1, hipP2) + const sharesP2 = same(eP2, hipP1) || same(eP2, hipP2) + if ((sharesP1 ? 1 : 0) + (sharesP2 ? 1 : 0) !== 1) return + const eVx = ext.x2 - ext.x1 + const eVy = ext.y2 - ext.y1 + const eMag = Math.hypot(eVx, eVy) || 1 + const cross = (hipVx * eVy - hipVy * eVx) / (hipMag * eMag) + if (Math.abs(cross) >= 0.02) return // 동일선상 아님 + canvas.remove(ext) + }) + } + + // [2240 KERAB-HIP-SNAPSHOT 2026-05-19] forward 시 제거되는 hip 의 원본 상태 캡처. + // revert 시 c1↔apex 로 새 hip 만들지 않고 이 스냅샷으로 원본 좌표/속성 그대로 복원. + const snapshotHip = (hip) => ({ + x1: hip.x1, + y1: hip.y1, + x2: hip.x2, + y2: hip.y2, + attributes: hip.attributes ? { ...hip.attributes } : {}, + lineName: hip.lineName, + __extended: hip.__extended, + }) + + const applyKerabSingleRidgePattern = (roof, target, c1, c2, pair) => { + // 케라바 외곽선 중점은 roof 폴리곤 코너 c1↔c2 의 중점 + const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 } + const points = [mid.x, mid.y, pair.apex.x, pair.apex.y] + const sz = calcLinePlaneSize({ x1: points[0], y1: points[1], x2: points[2], y2: points[3] }) + const ridge = new QLine(points, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 2, + name: LINE_TYPE.SUBLINE.RIDGE, + textMode: roof.textMode, + attributes: { roofId: roof.id, planeSize: sz, actualSize: sz }, + }) + // [KERAB-PATTERN-MARKER 2026-05-19] 할당 등 후속 흐름이 패턴 생성 라인을 식별할 수 있도록 + ridge.lineName = 'kerabPatternRidge' + // [2240 KERAB-HIP-SNAPSHOT 2026-05-19] 원본 hip 스냅샷 ridge 에 첨부 (revert 시 원본 그대로 복원) + ridge.__originalHips = [snapshotHip(pair.hA), snapshotHip(pair.hB)] + // 불변식: ridge 한 끝점이 케라바 중점과 일치 + if (!ridgeMeetsMidpointOf(ridge, { x1: c1.x, y1: c1.y, x2: c2.x, y2: c2.y })) { + return false + } + // 패턴이 직접 hip 페어 제거 + 짝 ext 동반 정리 (rebuild 흐름에 의존하지 않음) + removeOrphanExtensionsForHip(pair.hA) + removeOrphanExtensionsForHip(pair.hB) + removeLine(pair.hA) + removeLine(pair.hB) + roof.innerLines = roof.innerLines.filter((il) => il !== pair.hA && il !== pair.hB) + canvas.add(ridge) + ridge.bringToFront() + roof.innerLines.push(ridge) + // 외곽선 라벨: 원본 1개 숨김 + 좌우/상하 절반 라벨 2개 추가 + hideOriginalLengthText(target.id, true) + removeKerabHalfLabels(target.id) + addKerabHalfLabels(target, c1, c2) + canvas.renderAll() + return true + } + + // [2240 KERAB-JUNCTION-EXTENDED 2026-05-19] H-4/H-2 확장 패턴 (추가만, 기존 라인 무손상). + // 1) H-4 확장선(junction1 → apex) hip 추가 + // 2) H-2 확장선(junction2 → apex) hip 추가 + // 3) mid(B-5) → apex ridge 추가 + // 기존 outer(H-7/H-1)·inner(H-4/H-2)·RG-2 모두 그대로 유지. revert 시 추가된 3개만 제거. + const applyKerabJunctionExtendedPattern = (roof, target, c1, c2, jp) => { + const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 } + const ridgePoints = [mid.x, mid.y, jp.apex.x, jp.apex.y] + const ridgeSz = calcLinePlaneSize({ x1: ridgePoints[0], y1: ridgePoints[1], x2: ridgePoints[2], y2: ridgePoints[3] }) + const ridge = new QLine(ridgePoints, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 2, + name: LINE_TYPE.SUBLINE.RIDGE, + textMode: roof.textMode, + attributes: { roofId: roof.id, planeSize: ridgeSz, actualSize: ridgeSz }, + }) + ridge.lineName = 'kerabPatternRidge' + ridge.__patternKind = 'junctionExtended' + if (!ridgeMeetsMidpointOf(ridge, { x1: c1.x, y1: c1.y, x2: c2.x, y2: c2.y })) return false + + const buildExtensionHip = (from, to) => { + const pts = [from.x, from.y, to.x, to.y] + const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) + const hip = new QLine(pts, { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 2, + name: LINE_TYPE.SUBLINE.HIP, + textMode: roof.textMode, + attributes: { roofId: roof.id, planeSize: sz, actualSize: sz }, + }) + hip.lineName = 'kerabPatternHip' + hip.__patternKind = 'junctionExtended' + return hip + } + const ext1 = buildExtensionHip(jp.junction1, jp.apex) + const ext2 = buildExtensionHip(jp.junction2, jp.apex) + ridge.__patternExtHips = [ext1, ext2] + + // [2240 KERAB-CENTRAL-RIDGE 2026-05-19] RG-1(주 마루) 제거. + // 양 끝점이 ext1·ext2 segment 위에 각각 놓인 ridge 가 RG-1. + // (H-4/H-2 확장선이 RG-1 끝점을 통과 → RG-2 등 다른 ridge 는 두 확장선과 무관) + const ridgesToRemove = roof.innerLines.filter( + (il) => + il && + il.name === LINE_TYPE.SUBLINE.RIDGE && + il.lineName !== 'kerabPatternRidge' && + il !== ridge && + ridgeEndpointsOnBothExtensions(il, ext1, ext2), + ) + logger.log('[KERAB-CENTRAL-RIDGE] lookup', { + ext1: `(${Math.round(ext1.x1)},${Math.round(ext1.y1)})→(${Math.round(ext1.x2)},${Math.round(ext1.y2)})`, + ext2: `(${Math.round(ext2.x1)},${Math.round(ext2.y1)})→(${Math.round(ext2.x2)},${Math.round(ext2.y2)})`, + removeCount: ridgesToRemove.length, + removeList: ridgesToRemove.map( + (il) => `(${Math.round(il.x1)},${Math.round(il.y1)})→(${Math.round(il.x2)},${Math.round(il.y2)})`, + ), + }) + if (ridgesToRemove.length > 0) { + ridge.__removedRidgesSnapshot = ridgesToRemove.map(snapshotHip) + for (const il of ridgesToRemove) removeLine(il) + roof.innerLines = roof.innerLines.filter((il) => !ridgesToRemove.includes(il)) + } + + canvas.add(ext1) + canvas.add(ext2) + canvas.add(ridge) + ext1.bringToFront() + ext2.bringToFront() + ridge.bringToFront() + roof.innerLines.push(ext1, ext2, ridge) + + hideOriginalLengthText(target.id, true) + removeKerabHalfLabels(target.id) + addKerabHalfLabels(target, c1, c2) + canvas.renderAll() + return true + } + + // [2240 KERAB-ATTR-ONLY 2026-05-19] 평행 hip 케이스: 외곽선 attributes.type 만 토글. + // - hip 라인/ridge/라벨 등 SK 산출물 전부 무변경 + // - 호출자가 이미 target.set({ attributes }) 적용한 상태로 들어옴 → 렌더만 갱신 + // - forward/revert 양방향 동일 처리 (대칭) + const applyKerabAttributeOnlyPattern = () => { + canvas.renderAll() + return true + } + return { type, setType, buttonMenu, TYPES, pitchRef, offsetRef, widthRef, radioTypeRef, pitchText } } diff --git a/src/hooks/roofcover/useRoofAllocationSetting.js b/src/hooks/roofcover/useRoofAllocationSetting.js index 87c6ae06..33b512b2 100644 --- a/src/hooks/roofcover/useRoofAllocationSetting.js +++ b/src/hooks/roofcover/useRoofAllocationSetting.js @@ -777,11 +777,36 @@ export function useRoofAllocationSetting(id) { // extensionLine + 동일직선 SK 1:1 통합 (대각선 단일길이/각도 면적 산출) integrateExtensionLines(roofBase) + // [KERAB-PATTERN-DIAG 2026-05-19] 패턴 라인 상태 진단 — 분할 직전 스냅샷 + const __patternLines = roofBase.innerLines.filter( + (l) => l?.lineName === 'kerabPatternHip' || l?.lineName === 'kerabPatternRidge', + ) + if (__patternLines.length > 0) { + logger.log( + `[KERAB-PATTERN-DIAG] roof=${roofBase.id} patternLines=${__patternLines.length} ` + + `roof.points=${roofBase.points?.length ?? 0} ` + + `lines=${roofBase.lines?.length ?? 0} innerLines=${roofBase.innerLines.length}`, + ) + __patternLines.forEach((l) => + logger.log( + ` [PATTERN] ${l.lineName} ${l.name} (${l.x1.toFixed(1)},${l.y1.toFixed(1)})` + + `→(${l.x2.toFixed(1)},${l.y2.toFixed(1)}) ` + + `type=${l.attributes?.type ?? 'none'} __extended=${l.__extended} attrExtended=${l.attributes?.extended}`, + ), + ) + } + if (roofBase.separatePolygon.length > 0) { splitPolygonWithSeparate(roofBase.separatePolygon) } else { splitPolygonWithLines(roofBase) } + + // [KERAB-PATTERN-DIAG 2026-05-19] 분할 후 결과 — 새 sub-roof 개수 + if (__patternLines.length > 0) { + const __newRoofs = canvas.getObjects().filter((o) => o.name === 'roof' && !o.isFixed) + logger.log(`[KERAB-PATTERN-DIAG] 분할 후 새 sub-roof=${__newRoofs.length}`) + } } catch (e) { logger.log(e) canvas.discardActiveObject() -- 2.47.2