From 562bc79beb6719eed3f5bfbe6510298ea53b968e Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 2 Jun 2026 16:52:42 +0900 Subject: [PATCH 1/8] =?UTF-8?q?[=EB=B0=B0=EC=B9=98=EB=A9=B4=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=EC=84=A4=EC=A0=95]=20=EC=A0=80=EC=9E=A5=EA=B0=92=20?= =?UTF-8?q?=EC=9A=B0=EC=84=A0=20=EB=B0=98=EC=98=81=20=E2=80=94=20basicSett?= =?UTF-8?q?ing=20=EB=8D=AE=EC=96=B4=EC=93=B0=EA=B8=B0=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20+=20inputMode=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modal/placementShape/PlacementShapeSetting.jsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx index 8c442b22..58ffcea5 100644 --- a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx +++ b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx @@ -261,15 +261,19 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla if (addedRoofs.length > 0) { const raftCodeList = findCommonCode('203800') setRaftCodes(raftCodeList) + // [2026-06-02] addedRoofs[0]에서 받은 roofSizeSet을 그대로 사용 (basicSetting으로 덮어씌우지 않기) setCurrentRoof({ ...addedRoofs[0], planNo: currentCanvasPlan?.planNo || planNo, - roofSizeSet: String(basicSetting.roofSizeSet), - roofAngleSet: basicSetting.roofAngleSet, + roofSizeSet: String(addedRoofs[0].roofSizeSet), + roofAngleSet: addedRoofs[0].roofAngleSet, }) + // 입력모드도 함께 동기화 + setInputMode(addedRoofs[0].roofSizeSet) } else { /** 데이터 설정 확인 후 데이터가 없으면 기본 데이터 설정 */ setCurrentRoof({ ...DEFAULT_ROOF_SETTINGS }) + setInputMode(DEFAULT_ROOF_SETTINGS.roofSizeSet) } }, [addedRoofs]) From 8ef0810058e8a8fbe93f39b3344bcc65d148869a Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 2 Jun 2026 16:58:54 +0900 Subject: [PATCH 2/8] =?UTF-8?q?[2294]=20=EC=BC=80=EB=9D=BC=EB=B0=94=20?= =?UTF-8?q?=EC=B6=9C=ED=8F=AD=20surgical=20=E2=80=94=20vExt=20cascade=20+?= =?UTF-8?q?=20corner=20snap=20+=20ExtRidge=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/roofcover/useEavesGableEdit.js | 11 ++- src/util/kerab-offset-surgical.js | 114 ++++++++++++++++++++++- 2 files changed, 117 insertions(+), 8 deletions(-) diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index 3faee18b..f926a8ef 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -1839,7 +1839,9 @@ export function useEavesGableEdit(id) { if (typeof il.setCoords === 'function') il.setCoords() const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz } - trimCascadePts.push(oldPt) + // [KERAB-EXTRIDGE-CONNECT 2026-06-01] cascade hide 시 ExtRidge 끝점을 vExt 끝점(=ip)로 연장. + // ExtRidge 와 vExt 끝점이 분리돼 split graph 의 sub-roof 외곽이 안 닫히는 문제 해소. + trimCascadePts.push({ pt: oldPt, newPt: { x: ip.x, y: ip.y } }) newTrimRecords.push({ line: il, end: trimEnd, @@ -1856,8 +1858,9 @@ export function useEavesGableEdit(id) { const cascadeHidden = new Set() let cascadeGuard = 0 while (trimCascadePts.length && cascadeGuard++ < 200) { - const pt = trimCascadePts.shift() - if (!pt) continue + const entry = trimCascadePts.shift() + if (!entry) continue + const pt = entry.pt || entry for (const il of roof.innerLines || []) { if (!il || il.visible === false) continue if (cascadeHidden.has(il)) continue @@ -1880,7 +1883,7 @@ export function useEavesGableEdit(id) { '[KERAB-VALLEY-EXT-TRIM] cascade hide ' + JSON.stringify({ lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }), ) - trimCascadePts.push(m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }) + trimCascadePts.push({ pt: m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 } }) } } // [KERAB-VALLEY-EXT-TRIM 2026-05-29] revert 위해 target.__valleyExtTrims 에 누적. diff --git a/src/util/kerab-offset-surgical.js b/src/util/kerab-offset-surgical.js index ed437bea..5d9e3f59 100644 --- a/src/util/kerab-offset-surgical.js +++ b/src/util/kerab-offset-surgical.js @@ -129,7 +129,9 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => { (il.lineName === 'kerabPatternRidge' || il.lineName === 'kerabPatternExtRidge' || il.lineName === 'kerabPatternHip' || - il.lineName === 'kerabPatternExtHip') + il.lineName === 'kerabPatternExtHip' || + il.lineName === 'kerabPatternValleyExt' || + il.lineName === 'kerabValleyOverlapLine') const oldSegDx = oldCorner2.x - oldCorner1.x const oldSegDy = oldCorner2.y - oldCorner1.y const oldSegLen2 = oldSegDx * oldSegDx + oldSegDy * oldSegDy || 1 @@ -150,18 +152,87 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => { if (!il) continue // 케라바 패턴 라인: 옛 roofLine segment 위 끝점 → 새 segment 의 동일 t 점. - // 절삭/복원 흐름 skip. + // 한 끝만 segment 위(vExt 등 self-extension)일 때는 다른 끝도 같은 변위로 평행 이동 → + // 직각/형상 보존. 절삭/복원 흐름 skip. if (isKerabPatternLine(il)) { + const oldX1 = il.x1 + const oldY1 = il.y1 + const oldX2 = il.x2 + const oldY2 = il.y2 const np1 = mapToNewSeg({ x: il.x1, y: il.y1 }) - if (np1) il.set({ x1: np1.x, y1: np1.y }) const np2 = mapToNewSeg({ x: il.x2, y: il.y2 }) - if (np2) il.set({ x2: np2.x, y2: np2.y }) + if (np1 && np2) { + il.set({ x1: np1.x, y1: np1.y, x2: np2.x, y2: np2.y }) + } else if (np1 && !np2) { + const dx = np1.x - il.x1 + const dy = np1.y - il.y1 + il.set({ x1: np1.x, y1: np1.y, x2: il.x2 + dx, y2: il.y2 + dy }) + } else if (!np1 && np2) { + const dx = np2.x - il.x2 + const dy = np2.y - il.y2 + il.set({ x1: il.x1 + dx, y1: il.y1 + dy, x2: np2.x, y2: np2.y }) + } if (typeof il.setCoords === 'function') il.setCoords() if (np1 || np2) { logger.log( '[KERAB-PATTERN-CORNER-SNAP] mapped ' + JSON.stringify({ lineName: il.lineName, np1, np2, newPts: { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 } }), ) + // [KERAB-PATTERN-CASCADE 2026-06-01] vExt 등 평행 이동 시 옛 끝점에 닿아있던 + // 다른 innerLine 끝점도 같은 변위로 평행 이동. RG-1 의 valley-trim 결과 끝점이 + // vExt 끝점과 분리되어 split graph 의 closed path 안 만들어지는 문제 해소. + // cascade: vExt 옛 segment 위에 끝점이 있는 다른 innerLine 도 같은 변위로 평행 이동. + // 끝점-끝점 일치 외에 segment 위 중간 점(예: kLine 끝점이 vExt segment 위)도 매칭. + // 케라바 패턴 라인 가드 제거 — 자체 매핑된 라인은 이미 새 좌표라 옛 segment 위 X → 자동 skip. + const dxVExt = il.x1 - oldX1 + const dyVExt = il.y1 - oldY1 + const pointOnOldSeg = (px, py) => { + const sdx = oldX2 - oldX1 + const sdy = oldY2 - oldY1 + const slen2 = sdx * sdx + sdy * sdy + if (slen2 < 1e-6) return Math.hypot(px - oldX1, py - oldY1) < 1.0 + const t = ((px - oldX1) * sdx + (py - oldY1) * sdy) / slen2 + if (t < -0.02 || t > 1.02) return false + const projX = oldX1 + t * sdx + const projY = oldY1 + t * sdy + return Math.hypot(px - projX, py - projY) < 1.0 + } + if (Math.hypot(dxVExt, dyVExt) > 0.01) { + // cascade 대상: roof.innerLines + canvas 의 kerabValleyOverlapLine (innerLines 미포함). + // overlap 보조선들은 vExt 의 끝점과 90도로 만나므로 vExt 이동에 같이 따라가야 정합. + const overlapInCanvas = (canvas.getObjects() || []).filter( + (o) => + o && + o.lineName === 'kerabValleyOverlapLine' && + (o.roofId === roof.id || o.attributes?.roofId === roof.id), + ) + const cascadeTargets = [...(roof.innerLines || []), ...overlapInCanvas] + for (const other of cascadeTargets) { + if (!other || other === il) continue + let moved = false + if (pointOnOldSeg(other.x1, other.y1)) { + other.set({ x1: other.x1 + dxVExt, y1: other.y1 + dyVExt }) + moved = true + } + if (pointOnOldSeg(other.x2, other.y2)) { + other.set({ x2: other.x2 + dxVExt, y2: other.y2 + dyVExt }) + moved = true + } + if (moved) { + if (typeof other.setCoords === 'function') other.setCoords() + logger.log( + '[KERAB-PATTERN-CASCADE] moved ' + + JSON.stringify({ + lineName: other.lineName, + name: other.name, + dx: dxVExt, + dy: dyVExt, + newPts: { x1: other.x1, y1: other.y1, x2: other.x2, y2: other.y2 }, + }), + ) + } + } + } } continue } @@ -169,6 +240,41 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => { const orig = il.__shrinkOrig || { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 } const orig1 = { x: orig.x1, y: orig.y1 } const orig2 = { x: orig.x2, y: orig.y2 } + + // [KERAB-OFFSET-CORNER-SHORTCUT 2026-06-01] orig 끝점이 옛 corner 와 일치하면 새 corner 로 직접 snap. + // 일반 절삭 흐름은 il segment 와 새 roofLine 변의 lineLineIntersection 으로 ip 계산하는데, + // 끝점이 옛 corner 위에 있으면 ip 가 새 corner segment 밖으로 떨어져 segOk 가 reject → 절삭 실패. + // 그 결과 c1·c2 비대칭으로 kLine 대각선 변형됨. + const CORNER_SNAP_TOL_TRIM = 0.5 + let cornerSnapped = false + const trySnap = (epx, epy, which) => { + if (Math.hypot(epx - oldCorner1.x, epy - oldCorner1.y) < CORNER_SNAP_TOL_TRIM) { + if (!il.__shrinkOrig) il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 } + if (which === 1) il.set({ x1: newCorner1.x, y1: newCorner1.y }) + else il.set({ x2: newCorner1.x, y2: newCorner1.y }) + cornerSnapped = true + return true + } + if (Math.hypot(epx - oldCorner2.x, epy - oldCorner2.y) < CORNER_SNAP_TOL_TRIM) { + if (!il.__shrinkOrig) il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 } + if (which === 1) il.set({ x1: newCorner2.x, y1: newCorner2.y }) + else il.set({ x2: newCorner2.x, y2: newCorner2.y }) + cornerSnapped = true + return true + } + return false + } + trySnap(orig.x1, orig.y1, 1) + trySnap(orig.x2, orig.y2, 2) + if (cornerSnapped) { + if (typeof il.setCoords === 'function') il.setCoords() + logger.log( + '[KERAB-OFFSET-CORNER-SHORTCUT] snapped ' + + JSON.stringify({ lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }), + ) + continue + } + const d1 = (orig1.x - newAxisMid.x) * nx + (orig1.y - newAxisMid.y) * ny const d2 = (orig2.x - newAxisMid.x) * nx + (orig2.y - newAxisMid.y) * ny From c2d8aeb5726734787191eeaab0237c86a61a831b Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 4 Jun 2026 09:46:47 +0900 Subject: [PATCH 3/8] =?UTF-8?q?[2089]=20useSwal=20alert=20html=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20+=20=EC=A0=80=EA=B5=AC=EB=B0=B0=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=20=EC=A4=84=EB=B0=94=EA=BF=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useSwal alert 분기에 html 옵션 지원 추가 (html 있으면 html, 없으면 text) - roof-pitch-warning: text → html + \n을
로 치환하여 줄바꿈 렌더링 - ja.json: 構造物 → 架台 용어 수정 + \n 추가 - ko.json: 문구 다듬기 + \n 추가 --- src/hooks/useSwal.js | 2 +- src/locales/ja.json | 2 +- src/locales/ko.json | 2 +- src/util/roof-pitch-warning.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/hooks/useSwal.js b/src/hooks/useSwal.js index f904091d..227f96ee 100644 --- a/src/hooks/useSwal.js +++ b/src/hooks/useSwal.js @@ -30,7 +30,7 @@ export const useSwal = () => { if (type === 'alert') { MySwal.fire({ title, - text, + ...(html ? { html } : { text }), icon: icon === '' ? 'success' : icon, confirmButtonText: getMessage('common.ok'), }).then(() => { diff --git a/src/locales/ja.json b/src/locales/ja.json index 88e88a2a..e6092ae9 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -65,7 +65,7 @@ "modal.roof.shape.setting.patten.a": "Aパターン", "modal.roof.shape.setting.patten.b": "Bパターン", "modal.roof.shape.setting.side": "個別に設定", - "modal.roof.material.low.pitch.warning": "2寸以上2.5寸未満の瓦を使用する場合、適用可能な瓦製品および構造物に制限があります。必ず施工マニュアルをご確認ください。", + "modal.roof.material.low.pitch.warning": "2寸以上2.5寸未満の瓦を使用する場合、適用可能な瓦製品および架台に制限があります。\n必ず施工マニュアルをご確認ください。", "plan.menu.roof.cover": "伏せ図入力", "plan.menu.roof.cover.outline.drawing": "外壁線を描く", "plan.menu.roof.cover.roof.shape.setting": "屋根形状の設定", diff --git a/src/locales/ko.json b/src/locales/ko.json index 956d552d..b000c736 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -65,7 +65,7 @@ "modal.roof.shape.setting.patten.a": "A 패턴", "modal.roof.shape.setting.patten.b": "B 패턴", "modal.roof.shape.setting.side": "변별로 설정", - "modal.roof.material.low.pitch.warning": "2寸 이상 2.5寸 미만의 기와를 사용하는 경우, 적용 가능한 기와 제품 및 구조물에 제한이 있습니다. 반드시 시공 매뉴얼을 확인해 주세요.", + "modal.roof.material.low.pitch.warning": "2寸 이상 2.5寸 미만의 기와를 사용하는 경우, 적용 가능한 기와 제품 및 구조물 에 제한이 있습니다. \n반드시 시공 매뉴얼을 확인해 주시기 바랍니다.", "plan.menu.roof.cover": "지붕덮개", "plan.menu.roof.cover.outline.drawing": "외벽선 그리기", "plan.menu.roof.cover.roof.shape.setting": "지붕형상 설정", diff --git a/src/util/roof-pitch-warning.js b/src/util/roof-pitch-warning.js index 17099f91..377e9eda 100644 --- a/src/util/roof-pitch-warning.js +++ b/src/util/roof-pitch-warning.js @@ -72,7 +72,7 @@ export const notifyLowPitchRestrictionForRoofs = async (roofs, swalFire, getMess swalFire({ type: 'alert', icon: 'warning', - text: getMessage('modal.roof.material.low.pitch.warning'), + html: getMessage('modal.roof.material.low.pitch.warning').replace(/\n/g, '
'), confirmFn: () => resolve(), }) }) From df1a2d6fe6b207182385db772800f95512e3ae49 Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 4 Jun 2026 15:37:36 +0900 Subject: [PATCH 4/8] =?UTF-8?q?[2294]=20=EC=BC=80=EB=9D=BC=EB=B0=94=20?= =?UTF-8?q?=EA=B3=A8=EC=A7=9C=EA=B8=B0=20=ED=95=A0=EB=8B=B9=20=E2=80=94=20?= =?UTF-8?q?=EB=B0=B4=EB=93=9C=20dedup=20+=20fold=20=EB=8C=80=EA=B0=81?= =?UTF-8?q?=EC=84=A0=20=EC=A0=9C=EA=B1=B0=20(=EB=B3=80=20=EC=97=B0?= =?UTF-8?q?=EC=9E=A5=20=ED=9D=A1=EC=88=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 작업내용 : - 밴드 이중 생성 제거: buildOverlapLine(케라바 토글) + RECALC 중복 → RECALC 전 기존 kerabValleyOverlapLine 제거로 단일 소스 통일 - ladder → 단일 quad 밴드: V1+3변(connector/wall)만 생성, 내부 분할 제거 - 밴드 식별 ov≥2: split 후 V1 edge가 lineName 유실 → overlap-typed 변 2개 이상인 sub-roof를 밴드로 식별 - fold 방식 교체: detour 우회 → V1 전체를 가진 면(F-3)에 OV 외곽경로로 변 연장 흡수. 대각선 제거, 면적 보존 검증 추가 - vExt split(Option A): ridge 교차점에서 vExt를 분할해 RG-2를 할당 그래프에 연결 - QPolygon: ROOF-FACE-DIAG 디버그 라벨 + debugCapture 덤프 추가 (local 가드) Co-Authored-By: Claude Opus 4 --- src/components/fabric/QPolygon.js | 132 ++++++ src/hooks/roofcover/useEavesGableEdit.js | 92 ++++ .../roofcover/useRoofAllocationSetting.js | 407 +++++++++++------- src/hooks/usePolygon.js | 18 + 4 files changed, 487 insertions(+), 162 deletions(-) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index 89c4448d..fb12e181 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -8,6 +8,7 @@ import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import Big from 'big.js' import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils' import { logger } from '@/util/logger' +import { debugCapture } from '@/util/debugCapture' // ======================================================================== // [DEBUG] 캔버스 라인 라벨 — 로컬 전용 (NEXT_PUBLIC_RUN_MODE === 'local') @@ -100,6 +101,137 @@ function __attachDebugLabels(canvas, parentId) { canvas.renderAll() } +// ======================================================================== +// [ROOF-FACE-DIAG 2026-06-04] 지붕면 할당 디버그 — 로컬 전용. +// 할당된 각 지붕면(name === POLYGON_TYPE.ROOF)에 +// - 캔버스: 면 식별자 + 평면면적 라벨(F-n) (사람이 "F-3" 지목용) +// - 로거: 면적(평면/실제)·구배·방향·꼭짓점 절대좌표 전체 (대화/디버그 공유용 — 본체) +// 캔버스 라벨만으론 Claude 가 못 읽으므로 logger 덤프가 핵심. +// ======================================================================== +const ROOF_FACE_LABEL_NAME = '__roofFaceDebugLabel' + +// fabric Polygon 의 points 는 pathOffset 기준 상대좌표 → 변환행렬로 절대(canvas)좌표 복원. +function __roofFaceAbsPoints(roof) { + const m = roof.calcTransformMatrix() + const ox = roof.pathOffset?.x ?? 0 + const oy = roof.pathOffset?.y ?? 0 + return (roof.points || []).map((p) => fabric.util.transformPoint({ x: p.x - ox, y: p.y - oy }, m)) +} + +function __shoelaceAreaPx(pts) { + let a = 0 + for (let i = 0; i < pts.length; i++) { + const cur = pts[i] + const nxt = pts[(i + 1) % pts.length] + a += cur.x * nxt.y - nxt.x * cur.y + } + return Math.abs(a) / 2 +} + +export function reattachRoofFaceDebugLabels(canvas) { + if (!__isDebugLabelsEnabled()) return + if (!canvas) return + + // 재할당/재진입 시 기존 면 라벨 제거 → 카운트 reset 후 새로 부여. + canvas + .getObjects() + .filter((o) => o.name === ROOF_FACE_LABEL_NAME) + .forEach((o) => canvas.remove(o)) + + const roofs = canvas.getObjects().filter((o) => o.name === POLYGON_TYPE.ROOF) + logger.log(`[ROOF-FACE-DIAG] 할당 지붕면 ${roofs.length}개`) + + // [ROOF-FACE-DIAG 2026-06-04] logger.log 는 브라우저 콘솔뿐이라 Claude 가 못 읽음. + // 좌표/면적을 debugCapture 로 debug/debug.log 에 영속화 → log-check 로 읽는 본체. + const records = [] + + roofs.forEach((roof, i) => { + const label = `F-${i + 1}` + const pts = __roofFaceAbsPoints(roof) + if (pts.length < 3) { + logger.log(`[ROOF-FACE-DIAG] ${label} 좌표 부족(pts=${pts.length}) skip`) + records.push({ face: label, skip: `pts=${pts.length}` }) + return + } + // 좌표 단위 1px = 10mm (planeSize = hypot(px)*10) → 평면면적 ㎡ = pxArea * 100 / 1e6 = pxArea / 1e4 + const planeM2 = __shoelaceAreaPx(pts) / 10000 + // 구배(寸) → 경사각. 실제 지붕면적 = 평면 / cos(angle). 구배 없으면 실제 = 평면. + const pitch = Number(roof.pitch ?? roof.roofMaterial?.pitch ?? 0) || 0 + const angle = Math.atan(pitch / 10) + const actualM2 = angle ? planeM2 / Math.cos(angle) : planeM2 + + // 캔버스 라벨: 면 중심에 식별자 + 평면면적만 (좌표는 로그에만). + const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length + const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length + const text = new fabric.Text(`${label} ${planeM2.toFixed(2)}㎡`, { + left: cx, + top: cy, + originX: 'center', + originY: 'center', + fontSize: 12, + fill: '#000', + fontFamily: 'monospace', + fontWeight: 'bold', + backgroundColor: 'rgba(0,255,255,0.85)', + selectable: false, + evented: false, + hasControls: false, + hasBorders: false, + name: ROOF_FACE_LABEL_NAME, + parentId: roof.id, + excludeFromExport: true, + }) + canvas.add(text) + text.bringToFront() + + // 꼭짓점별 좌표 라벨 (화면 표시 — local 전용). 같은 name 으로 cleanup 에 함께 제거됨. + pts.forEach((p) => { + const coord = new fabric.Text(`${Math.round(p.x)},${Math.round(p.y)}`, { + left: p.x, + top: p.y, + originX: 'center', + originY: 'bottom', + fontSize: 9, + fill: '#0a0', + fontFamily: 'monospace', + backgroundColor: 'rgba(255,255,255,0.7)', + selectable: false, + evented: false, + hasControls: false, + hasBorders: false, + name: ROOF_FACE_LABEL_NAME, + parentId: roof.id, + excludeFromExport: true, + }) + canvas.add(coord) + coord.bringToFront() + }) + + // 로거 덤프(본체): 평면/실제 면적 + 구배 + 방향 + 변 개수 + 꼭짓점 절대좌표 전체. + const ptsStr = pts.map((p) => `(${p.x.toFixed(1)},${p.y.toFixed(1)})`).join(' ') + logger.log( + `[ROOF-FACE-DIAG] ${label} id=${roof.id?.slice?.(0, 8) ?? 'none'} ` + + `plane=${planeM2.toFixed(2)}㎡ actual=${actualM2.toFixed(2)}㎡ pitch=${pitch} ` + + `dir=${roof.direction ?? 'none'} lines=${roof.lines?.length ?? 0} pts=${pts.length} ${ptsStr}`, + ) + records.push({ + face: label, + id: roof.id?.slice?.(0, 8) ?? null, + planeM2: Number(planeM2.toFixed(2)), + actualM2: Number(actualM2.toFixed(2)), + pitch, + dir: roof.direction ?? null, + lines: roof.lines?.length ?? 0, + points: pts.map((p) => ({ x: Number(p.x.toFixed(1)), y: Number(p.y.toFixed(1)) })), + }) + }) + + // Claude 가 읽는 본체 — debug/debug.log 에 [ROOF-FACE-DIAG] 라벨로 영속화. + debugCapture.log('ROOF-FACE-DIAG', { count: roofs.length, faces: records }) + + canvas.renderAll() +} + export const QPolygon = fabric.util.createClass(fabric.Polygon, { type: 'QPolygon', diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index f926a8ef..7b5b5f40 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 { debugCapture } from '@/util/debugCapture' import { applyKerabOffsetSurgical } from '@/util/kerab-offset-surgical' // [2240 KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 시 出幅 변경분 surgical 반영 기능 토글. @@ -451,6 +452,7 @@ export function useEavesGableEdit(id) { }) } logger.log('[KERAB-VALLEY-DIAG] ' + JSON.stringify(valleyInfo)) + debugCapture.log('KERAB-VALLEY-DIAG', { targetId: target.id, count: valleyInfo.length, valleys: valleyInfo }) } // [KERAB-VALLEY-EXT 2026-05-27] 모델 교체: skeleton valley → 외곽 polygon concave corner. // "확장" = roofLine 의 한쪽 끝점에서 self-extension (반대 끝점 → 그 끝점 방향) 으로 연장. @@ -1886,12 +1888,102 @@ export function useEavesGableEdit(id) { trimCascadePts.push({ pt: m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 } }) } } + // [KERAB-VALLEY-EXT-SPLIT 2026-06-04] Option A: vExt(내부 roofLine)가 ridge/hip 와 + // *내부* 교점에서 만나면 그 교점에서 vExt 자체를 분할한다. + // 이유: getSplitRoofsPoints(usePolygon.js)는 라인 *끝점*만 그래프 노드로 만든다 + // (교차분할 없음). 절삭된 ridge(예: RG-2)가 vExt 의 끝점이 아닌 중간(internal)에 + // 닿으면 공유 노드가 없어 dead-end → 지붕면 할당에서 고립 폐기되고 인접 면이 안 + // 쪼개진다(예: F-2 비대). vExt 를 교점에서 분할해 끝점=노드를 만들어 ridge 가 + // 할당 그래프에 연결되게 한다. 절삭 규칙/방향은 건드리지 않고 vExt 분할만 추가. + // revert: 분할 세그먼트도 lineName='kerabPatternValleyExt' + __targetId 이므로 + // applyKerabRevertPattern 의 valleyExt 제거 스캔에서 자동 정리된다. + { + const SPLIT_TOL = 1.0 + const interiorPts = [] + for (const r of newTrimRecords) { + if (!r || r.hide || !r.newPt) continue + const ip = r.newPt + if (!isOnSegV(ip, vStart.x, vStart.y, vEnd.x, vEnd.y)) continue + const dS = Math.hypot(ip.x - vStart.x, ip.y - vStart.y) + const dE = Math.hypot(ip.x - vEnd.x, ip.y - vEnd.y) + if (dS < SPLIT_TOL || dE < SPLIT_TOL) continue // 끝점은 이미 노드 + if (interiorPts.some((p) => Math.hypot(p.x - ip.x, p.y - ip.y) < SPLIT_TOL)) continue + interiorPts.push({ x: ip.x, y: ip.y }) + } + if (interiorPts.length) { + const vdx2 = vEnd.x - vStart.x + const vdy2 = vEnd.y - vStart.y + const tOf = (p) => (p.x - vStart.x) * vdx2 + (p.y - vStart.y) * vdy2 + interiorPts.sort((a, b) => tOf(a) - tOf(b)) + const bps = [vStart, ...interiorPts, vEnd] + // 첫 구간은 기존 vExt 재사용(끝점 단축), 나머지는 새 QLine 세그먼트. + vExt.set({ x2: bps[1].x, y2: bps[1].y }) + if (typeof vExt.setCoords === 'function') vExt.setCoords() + const sz0 = calcLinePlaneSize({ x1: vExt.x1, y1: vExt.y1, x2: vExt.x2, y2: vExt.y2 }) + vExt.attributes = { ...vExt.attributes, planeSize: sz0, actualSize: sz0 } + const newSegs = [] + for (let bi = 1; bi < bps.length - 1; bi++) { + const a = bps[bi] + const b = bps[bi + 1] + const segSz = calcLinePlaneSize({ x1: a.x, y1: a.y, x2: b.x, y2: b.y }) + const seg = new QLine([a.x, a.y, b.x, b.y], { + parentId: roof.id, + fontSize: roof.fontSize, + stroke: '#1083E3', + strokeWidth: 3, + name: LINE_TYPE.SUBLINE.VALLEY, + textMode: roof.textMode, + attributes: { ...(vExt.attributes || {}), planeSize: segSz, actualSize: segSz }, + }) + seg.lineName = 'kerabPatternValleyExt' + seg.__targetId = target.id + seg.__valleyExtSource = vExt.__valleyExtSource + if (vExt.parentLine) seg.parentLine = vExt.parentLine + if (vExt.direction) seg.direction = vExt.direction + canvas.add(seg) + seg.bringToFront() + roof.innerLines.push(seg) + newSegs.push(seg) + } + logger.log( + '[KERAB-VALLEY-EXT-SPLIT] vExt split ' + + JSON.stringify({ + source: vr.source, + breaks: interiorPts.map((p) => ({ x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 })), + segs: newSegs.length + 1, + }), + ) + debugCapture.log('KERAB-VALLEY-EXT-SPLIT', { + source: vr.source, + vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 }, + vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 }, + breaks: interiorPts.map((p) => ({ x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 })), + segCount: newSegs.length + 1, + }) + } + } // [KERAB-VALLEY-EXT-TRIM 2026-05-29] revert 위해 target.__valleyExtTrims 에 누적. // 기존 revert 로직(2566~) 이 hide/end/oldPt/originalAttrs 그대로 처리. // 한 mousedown 에 여러 vExt 가 호출될 수 있어 concat. if (newTrimRecords.length) { target.__valleyExtTrims = (target.__valleyExtTrims || []).concat(newTrimRecords) } + debugCapture.log('KERAB-VALLEY-EXT-TRIM', { + source: vr.source, + vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 }, + vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 }, + count: newTrimRecords.length, + trims: newTrimRecords.map((r) => ({ + lineName: r.line?.lineName, + name: r.line?.name, + label: typeof labelOf === 'function' ? labelOf(r.line) : undefined, + hide: r.hide ?? false, + end: r.end, + oldPt: r.oldPt ? { x: Math.round(r.oldPt.x * 100) / 100, y: Math.round(r.oldPt.y * 100) / 100 } : null, + newPt: r.newPt ? { x: Math.round(r.newPt.x * 100) / 100, y: Math.round(r.newPt.y * 100) / 100 } : null, + now: r.line ? { x1: Math.round(r.line.x1 * 100) / 100, y1: Math.round(r.line.y1 * 100) / 100, x2: Math.round(r.line.x2 * 100) / 100, y2: Math.round(r.line.y2 * 100) / 100 } : null, + })), + }) } else { logger.log('[KERAB-VALLEY-EXT-WALL] no hit for ' + vr.source) } diff --git a/src/hooks/roofcover/useRoofAllocationSetting.js b/src/hooks/roofcover/useRoofAllocationSetting.js index 33fe7793..94688b98 100644 --- a/src/hooks/roofcover/useRoofAllocationSetting.js +++ b/src/hooks/roofcover/useRoofAllocationSetting.js @@ -32,6 +32,7 @@ import { roofsState } from '@/store/roofAtom' import { useText } from '@/hooks/useText' import { fabric } from 'fabric' import { QLine } from '@/components/fabric/QLine' +import { reattachRoofFaceDebugLabels } from '@/components/fabric/QPolygon' import { calcLineActualSize2 } from '@/util/qpolygon-utils' // [LOW-PITCH-WARN 2026-05-06] 저구배 + 특정 기와 사용 시 시공 매뉴얼 안내 alert import { notifyLowPitchRestrictionForRoofs, isLowPitchRestricted, LOW_PITCH_RESTRICTED_ROOF_IDS } from '@/util/roof-pitch-warning' @@ -678,10 +679,15 @@ export function useRoofAllocationSetting(id) { const eq = (p, q) => Math.abs(p.x - q.x) < 0.5 && Math.abs(p.y - q.y) < 0.5 + // [KERAB-VALLEY-OVERLAP-BAND-ID 2026-06-04] 밴드 sub-roof 식별. + // split 후 sub-roof 의 V1(vExt) edge 는 lineName 을 잃어 'L' 로 나오므로(BAND-PROBE 확인) + // vExt 기반 식별은 불가. 대신 밴드만 overlap-typed(kerabValleyOverlapLine) 변을 보유한다는 + // 사실(인접 실제 면은 split 후 ov:0)을 이용 — overlap 변 2개 이상이면 밴드로 인정. + // 밴드는 항상 V2/V3/V4 3변이 overlap → ov>=3 이지만, 안전 여유로 2 로 둔다. const rects = newSubRoofs.filter((sub) => { if (!sub.lines || sub.lines.length < 3) return false - const cnt = sub.lines.filter((l) => l?.attributes?.type === 'kerabValleyOverlapLine').length - return cnt >= Math.max(3, sub.lines.length - 1) + const ovCnt = sub.lines.filter((l) => l?.attributes?.type === 'kerabValleyOverlapLine').length + return ovCnt >= 2 }) if (rects.length === 0) return @@ -736,165 +742,188 @@ export function useRoofAllocationSetting(id) { `shares=[${[...adjacencyMap.values()].map((s) => s.length).join(',')}]`, ) + // [KERAB-VALLEY-OVERLAP-FOLD-DIAG 2026-06-04] fold 직전 — 띠(X) + 인접 면 전체 좌표 덤프 (대각선 원인 추적) + { + const rnd = (p) => ({ x: Math.round(p.x), y: Math.round(p.y) }) + const adjDump = [] + adjacencyMap.forEach((shares, sub) => { + adjDump.push({ + subId: String(sub.id).slice(0, 8), + shares: shares.length, + shareEdges: shares.map((s) => ({ subEdgeStart: s.subEdgeStart, xEdgeIdx: s.xEdgeIdx })), + pts: (sub.points || []).map(rnd), + }) + }) + debugCapture.log('KERAB-VALLEY-OVERLAP-FOLD-DIAG', { + XId: String(X.id).slice(0, 8), + Xpts: Xpts.map(rnd), + XlineTypes: Xlines.map((l) => (l?.attributes?.type === 'kerabValleyOverlapLine' ? 'OV' : l?.lineName || 'L')), + adjacents: adjDump, + }) + } + if (adjacencyMap.size === 0) { canvas.remove(X) return } + // [KERAB-VALLEY-OVERLAP-FOLD 2026-06-04] 밴드를 "변 연장" 으로 한 면에 흡수 (대각선 제거). + // 할당 = 이어진 라인을 따라 면을 만드는 것. 밴드의 V1(내부 변)을 전부 가진 인접 면이 + // V1 을 밴드 OV 외곽경로(V3→V2→V4, x 를 출폭만큼 바깥)로 연장해 밴드를 흡수한다. + // 부분만 닿은 면(이전 detour 우회 대상)은 흡수 안 함 — 그 우회가 대각선의 근본원인이었다. + const N = Xpts.length + const isOVedge = (i) => Xlines[i]?.attributes?.type === 'kerabValleyOverlapLine' + + // 밴드의 V1(비 OV) 변 run — 밴드는 V1 이 한 덩어리이므로 연속 가정, 아니면 보존(흡수 skip). + let lStart = -1 + let lLen = 0 + for (let i = 0; i < N; i++) { + if (!isOVedge(i)) { + if (lStart === -1) lStart = i + lLen++ + } + } + let lContig = lStart !== -1 && lLen < N + for (let k = 0; k < lLen && lContig; k++) { + if (isOVedge((lStart + k) % N)) lContig = false + } + if (!lContig) { + logger.log(`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — V1 run 비연속 (lStart=${lStart} lLen=${lLen})`) + return + } + const vStartVtx = Xpts[lStart] + const vFinVtx = Xpts[(lStart + lLen) % N] + // OV 외곽 중간점들 — vFin 에서 vStart 로 도는 순서 + const ovFromFin = [] + for (let k = 1; k <= N - (lLen + 1); k++) { + ovFromFin.push(Xpts[(lStart + lLen + k) % N]) + } + + // 흡수 면 = V1 을 가장 많이(전부) 공유하는 인접 면 + let absorber = null + let absorberShares = null adjacencyMap.forEach((shares, sub) => { - // 다중 공유 → bowtie 위험, 머지 skip - if (shares.length !== 1) { - logger.log( - `[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} skip — sharedEdges=${shares.length} (≠1, bowtie 위험)`, - ) - return + if (!absorber || shares.length > absorberShares.length) { + absorber = sub + absorberShares = shares } - const { subEdgeStart, xEdgeIdx } = shares[0] - const pts = (sub.points || []).map((p) => ({ x: p.x, y: p.y })) - const oldLines = [...(sub.lines || [])] - const N = Xpts.length - - // [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] detour 방향은 sub.pts[subEdgeStart] 가 - // X.xEdge.a (=Xpts[xEdgeIdx]) 인지 X.xEdge.b (=Xpts[xEdgeIdx+1]) 인지로 결정. - // sub 외곽선 진행 방향이 xEdge 의 a→b 와 같으면 detour 는 X 의 a 쪽 이웃부터 (역방향) - // 반대면 detour 는 X 의 b 쪽 이웃부터 (정방향). - const subA = pts[subEdgeStart] - const xA = Xpts[xEdgeIdx] - const sameDir = eq(subA, xA) - - const detour = [] - const detourEdgeIdx = [] - if (sameDir) { - // a → Xpts[i-1] → Xpts[i-2] → ... → Xpts[i+2] → b - // detour vertices: N-2 개 (a, b 사이) - for (let k = 1; k <= N - 2; k++) { - detour.push(Xpts[(xEdgeIdx - k + N) % N]) - } - // detour edges: N-1 개. a→Xpts[i-1] 는 X 의 (i-1)%N 변 (반대방향). - for (let k = 1; k <= N - 1; k++) { - detourEdgeIdx.push((xEdgeIdx - k + N) % N) - } - } else { - // b → Xpts[i+2] → Xpts[i+3] → ... → Xpts[i+N-1] → a 의 a→b 표기: - // a → Xpts[i+2] → Xpts[i+3] → ... → Xpts[i+N-1] → b (subA=b 인 관점에서 보면 반대) - // 정확히는 subA=X.xEdge.b → detour 시작 = Xpts[(i+2)%N] (b 의 X 내 이웃) - for (let k = 2; k <= N - 1; k++) { - detour.push(Xpts[(xEdgeIdx + k) % N]) - } - for (let k = 1; k <= N - 1; k++) { - detourEdgeIdx.push((xEdgeIdx + k) % N) - } - } - - const buildCand = (verts) => { - const c = [...pts] - c.splice(subEdgeStart + 1, 0, ...verts) - return c - } - - const candA = buildCand(detour) - const oldSigned = signedArea(pts) - const aSigned = signedArea(candA) - const sameSign = (s) => oldSigned === 0 || Math.sign(s) === Math.sign(oldSigned) - const okA = sameSign(aSigned) && Math.abs(aSigned) > Math.abs(oldSigned) + 0.5 - - let finalPts = null - let finalEdges = null - if (okA) { - finalPts = candA - finalEdges = detourEdgeIdx - } else { - // fallback — 반대 방향 시도 (X 가 CW 로 들어온 경우 대비) - const detourRev = [...detour].reverse() - const detourEdgeRev = [...detourEdgeIdx].reverse() - const candB = buildCand(detourRev) - const bSigned = signedArea(candB) - const okB = sameSign(bSigned) && Math.abs(bSigned) > Math.abs(oldSigned) + 0.5 - if (okB) { - finalPts = candB - finalEdges = detourEdgeRev - logger.log( - `[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} fallback reverse detour 사용 ` + - `oldSigned=${oldSigned.toFixed(0)} → ${bSigned.toFixed(0)}`, - ) - } else { - logger.log( - `[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} skip — detour 양방향 실패 ` + - `sameDir=${sameDir} oldSigned=${oldSigned.toFixed(0)} ` + - `aSigned=${aSigned.toFixed(0)} bSigned=${bSigned.toFixed(0)} ` + - `subA=(${subA.x.toFixed(1)},${subA.y.toFixed(1)}) ` + - `xA=(${xA.x.toFixed(1)},${xA.y.toFixed(1)}) ` + - `xB=(${Xpts[(xEdgeIdx + 1) % N].x.toFixed(1)},${Xpts[(xEdgeIdx + 1) % N].y.toFixed(1)})`, - ) - return - } - } - - // sub.lines 재구성 — 옛 공유 변 1개 → detour 변 N-1개 - const newSubLines = [] - for (let i = 0; i < oldLines.length; i++) { - if (i === subEdgeStart) { - for (let k = 0; k < finalEdges.length; k++) { - const xLine = Xlines[finalEdges[k]] - const p1 = finalPts[(subEdgeStart + k) % finalPts.length] - const p2 = finalPts[(subEdgeStart + k + 1) % finalPts.length] - const attrs = xLine?.attributes - ? { ...xLine.attributes } - : { type: 'kerabValleyOverlapLine', offset: 0 } - const ln = new QLine([p1.x, p1.y, p2.x, p2.y], { - stroke: sub.stroke, - strokeWidth: sub.strokeWidth, - fontSize: sub.fontSize, - attributes: attrs, - textVisible: false, - parent: sub, - parentId: sub.id, - idx: newSubLines.length + 1, - }) - ln.startPoint = p1 - ln.endPoint = p2 - newSubLines.push(ln) - } - } else { - const ln = oldLines[i] - if (ln) { - ln.idx = newSubLines.length + 1 - newSubLines.push(ln) - } - } - } - - // [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] fabric.Polygon 정식 points 갱신 패턴. - // src/util/canvas-util.js#anchorWrapper 패턴 그대로 적용 — - // _setPositionDimensions 가 width/height/pathOffset/left/top 을 재계산하므로 - // 변경 전 앵커(pts[0]) 절대좌표를 캡쳐 → 변경 후 setPositionByOrigin 으로 복원. - // 그래야 polygon 의 path 가 새 bbox 기준으로 다시 그려지면서 시각 위치도 유지됨. - const anchorIdx = 0 - const oldLocal = { - x: sub.points[anchorIdx].x - sub.pathOffset.x, - y: sub.points[anchorIdx].y - sub.pathOffset.y, - } - const absolutePoint = fabric.util.transformPoint(oldLocal, sub.calcTransformMatrix()) - - sub.points = finalPts - sub.lines = newSubLines - sub._setPositionDimensions({}) - - const strokeW = sub.strokeUniform ? sub.strokeWidth / sub.scaleX : sub.strokeWidth - const baseW = sub.width + strokeW - const baseH = sub.height + (sub.strokeUniform ? sub.strokeWidth / sub.scaleY : sub.strokeWidth) - const newX = (sub.points[anchorIdx].x - sub.pathOffset.x) / Math.max(baseW, 1e-9) - const newY = (sub.points[anchorIdx].y - sub.pathOffset.y) / Math.max(baseH, 1e-9) - sub.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5) - sub.setCoords?.() - sub.dirty = true - - logger.log( - `[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} 머지 완료 ` + - `oldSigned=${oldSigned.toFixed(0)} → newSigned=${signedArea(finalPts).toFixed(0)} ` + - `pts=${finalPts.length} lines=${newSubLines.length}`, - ) }) + if (!absorber || absorberShares.length < lLen) { + logger.log( + `[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — V1 전체를 가진 흡수 면 없음 ` + + `(maxShares=${absorberShares?.length ?? 0}, lLen=${lLen})`, + ) + return + } + + const aPts = (absorber.points || []).map((p) => ({ x: p.x, y: p.y })) + const aLines = [...(absorber.lines || [])] + const aN = aPts.length + const absShared = absorberShares.map((s) => s.subEdgeStart).sort((a, b) => a - b) + let aContig = true + for (let i = 1; i < absShared.length; i++) { + if (absShared[i] !== absShared[i - 1] + 1) aContig = false + } + if (!aContig || absShared[absShared.length - 1] + 1 > aN - 1) { + logger.log(`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — 흡수 면 공유 변 비연속/wrap [${absShared.join(',')}]`) + return + } + const runStart = absShared[0] + const runEnd = absShared[absShared.length - 1] + 1 + // 삽입 방향: 흡수 면 run 시작점이 vStart 면 ovFromFin 역순, vFin 이면 그대로 + const insert = eq(aPts[runStart], vStartVtx) ? [...ovFromFin].reverse() : [...ovFromFin] + + const newPts = [] + for (let i = 0; i <= runStart; i++) newPts.push(aPts[i]) + for (const p of insert) newPts.push(p) + for (let i = runEnd; i < aN; i++) newPts.push(aPts[i]) + + // 면적 부호 보존 + 확장(절댓값 증가) 검증 — 실패 시 밴드 보존(흡수 skip) + const oldSigned = signedArea(aPts) + const newSigned = signedArea(newPts) + if ( + (oldSigned !== 0 && Math.sign(newSigned) !== Math.sign(oldSigned)) || + Math.abs(newSigned) <= Math.abs(oldSigned) + 0.5 + ) { + logger.log( + `[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — 확장 검증 실패 ` + + `oldSigned=${oldSigned.toFixed(0)} newSigned=${newSigned.toFixed(0)}`, + ) + return + } + + // 새 라인 재구성 — 각 변을 기존 흡수 면 라인 또는 밴드 OV 라인에서 매핑, 없으면 생성 + const findLine = (p1, p2) => { + for (const l of aLines) { + if (!l) continue + if ( + (eq({ x: l.x1, y: l.y1 }, p1) && eq({ x: l.x2, y: l.y2 }, p2)) || + (eq({ x: l.x1, y: l.y1 }, p2) && eq({ x: l.x2, y: l.y2 }, p1)) + ) + return { src: l, reuse: true } + } + for (const l of Xlines) { + if (!l) continue + if ( + (eq({ x: l.x1, y: l.y1 }, p1) && eq({ x: l.x2, y: l.y2 }, p2)) || + (eq({ x: l.x1, y: l.y1 }, p2) && eq({ x: l.x2, y: l.y2 }, p1)) + ) + return { src: l, reuse: false } + } + return null + } + const newLines = [] + for (let i = 0; i < newPts.length; i++) { + const p1 = newPts[i] + const p2 = newPts[(i + 1) % newPts.length] + const found = findLine(p1, p2) + if (found && found.reuse) { + found.src.idx = newLines.length + 1 + newLines.push(found.src) + } else { + const attrs = found?.src?.attributes ? { ...found.src.attributes } : { type: 'kerabValleyOverlapLine', offset: 0 } + const ln = new QLine([p1.x, p1.y, p2.x, p2.y], { + stroke: absorber.stroke, + strokeWidth: absorber.strokeWidth, + fontSize: absorber.fontSize, + attributes: attrs, + textVisible: false, + parent: absorber, + parentId: absorber.id, + idx: newLines.length + 1, + }) + ln.startPoint = p1 + ln.endPoint = p2 + newLines.push(ln) + } + } + + // [KERAB-VALLEY-OVERLAP-FOLD 2026-06-04] fabric.Polygon points 갱신 (canvas-util#anchorWrapper 패턴). + // _setPositionDimensions 가 bbox 재계산하므로 변경 전 앵커(pts[0]) 절대좌표 캡쳐 후 복원. + const anchorIdx = 0 + const oldLocal = { + x: absorber.points[anchorIdx].x - absorber.pathOffset.x, + y: absorber.points[anchorIdx].y - absorber.pathOffset.y, + } + const absolutePoint = fabric.util.transformPoint(oldLocal, absorber.calcTransformMatrix()) + + absorber.points = newPts + absorber.lines = newLines + absorber._setPositionDimensions({}) + + const strokeW = absorber.strokeUniform ? absorber.strokeWidth / absorber.scaleX : absorber.strokeWidth + const baseW = absorber.width + strokeW + const baseH = absorber.height + (absorber.strokeUniform ? absorber.strokeWidth / absorber.scaleY : absorber.strokeWidth) + const newX = (absorber.points[anchorIdx].x - absorber.pathOffset.x) / Math.max(baseW, 1e-9) + const newY = (absorber.points[anchorIdx].y - absorber.pathOffset.y) / Math.max(baseH, 1e-9) + absorber.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5) + absorber.setCoords?.() + absorber.dirty = true + + logger.log( + `[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} → absorber=${absorber.id} 흡수 완료 ` + + `pts=${newPts.length} lines=${newLines.length} oldSigned=${oldSigned.toFixed(0)} newSigned=${newSigned.toFixed(0)}`, + ) canvas.remove(X) }) @@ -1061,7 +1090,55 @@ export function useRoofAllocationSetting(id) { const vExtsForOverlap = (roofBase.innerLines || []).filter( (l) => l && l.lineName === 'kerabPatternValleyExt' && l.visible !== false, ) - for (const vExt of vExtsForOverlap) { + // [KERAB-VALLEY-OVERLAP-DEDUP 2026-06-04] buildOverlapLine(useEavesGableEdit)가 케라바 토글 시 + // 생성한 밴드와 이 RECALC 가 이중 생성 → split 가 중복 면 → 머지 모호 → 대각선. + // 단일 소스로 통일: 재계산 전 기존 kerabValleyOverlapLine 을 roofBase/canvas 에서 제거. + // vExt 가 있는 roof 만 (= RECALC 가 다시 생성할 roof) 대상 — 아니면 기존 밴드 유실. + if (vExtsForOverlap.length > 0) { + const isBandLine = (l) => + l && (l.lineName === 'kerabValleyOverlapLine' || l.attributes?.type === 'kerabValleyOverlapLine') + roofBase.lines = (roofBase.lines || []).filter((l) => !isBandLine(l)) + roofBase.innerLines = (roofBase.innerLines || []).filter((l) => !isBandLine(l)) + canvas + .getObjects() + .filter( + (o) => + isBandLine(o) && (o.roofId === roofBase.id || o.parentId === roofBase.id || o.attributes?.roofId === roofBase.id), + ) + .forEach((o) => canvas.remove(o)) + } + // [KERAB-VALLEY-EXT-SPLIT 2026-06-04] vExt 가 split 되면 같은 V1 이 여러 collinear 세그먼트로 쪼개진다. + // overlap 밴드는 V1(벽) 당 한 번만 그려야 한다 — 세그먼트마다 make() 하면 분할점에서 중복 변이 2개 생긴다. + // 같은 wall(__targetId/wallLine/source)로 묶고, 세그먼트 양 끝의 최외곽 두 점을 vStart/vEnd 로 써서 한 번만 재계산. + const overlapGroups = new Map() + for (const seg of vExtsForOverlap) { + const gkey = seg.__targetId || seg.attributes?.wallLine || seg.__valleyExtSource || seg.parentLine?.id || seg.id + if (!overlapGroups.has(gkey)) overlapGroups.set(gkey, []) + overlapGroups.get(gkey).push(seg) + } + for (const segs of overlapGroups.values()) { + // 대표 세그먼트 — 속성/wallLine 해석용 + const vExt = segs[0] + // 그룹 내 모든 끝점 중 가장 먼 두 점 = 합쳐진 V1 전체 span + let combStart = { x: vExt.x1, y: vExt.y1 } + let combEnd = { x: vExt.x2, y: vExt.y2 } + if (segs.length > 1) { + const pts = [] + for (const s of segs) { + pts.push({ x: s.x1, y: s.y1 }, { x: s.x2, y: s.y2 }) + } + let maxD = -1 + for (let i = 0; i < pts.length; i++) { + for (let j = i + 1; j < pts.length; j++) { + const d = Math.hypot(pts[i].x - pts[j].x, pts[i].y - pts[j].y) + if (d > maxD) { + maxD = d + combStart = pts[i] + combEnd = pts[j] + } + } + } + } // [KERAB-VALLEY-OVERLAP-RECALC 2026-05-29] wallLineId 보장 fallback 체인. // 1) vExt.attributes.wallLine (정상 케이스) // 2) vExt.__targetId (useEavesGableEdit.js drawValleyExtensions 에서 백업) @@ -1072,7 +1149,7 @@ export function useRoofAllocationSetting(id) { if (!wallLineId && vExt.parentLine) wallLineId = vExt.parentLine.attributes?.wallLine if (!wallLineId) { // vStart 가 끝점인 roof.lines 변에서 wallLine 추출 - const vStartP = { x: vExt.x1, y: vExt.y1 } + const vStartP = { x: combStart.x, y: combStart.y } for (const rl of roofBase.lines || []) { if (!rl?.attributes?.wallLine) continue const m1 = Math.hypot(rl.x1 - vStartP.x, rl.y1 - vStartP.y) < 1 @@ -1096,8 +1173,8 @@ export function useRoofAllocationSetting(id) { continue } } - const vStart = { x: vExt.x1, y: vExt.y1 } - const vEnd = { x: vExt.x2, y: vExt.y2 } + const vStart = { x: combStart.x, y: combStart.y } + const vEnd = { x: combEnd.x, y: combEnd.y } const dxT = target.x2 - target.x1 const dyT = target.y2 - target.y1 const lenTSq = dxT * dxT + dyT * dyT @@ -1141,11 +1218,14 @@ export function useRoofAllocationSetting(id) { return p } const vStartSnap = snapToCorner(vStart) - const wallLn = make(newWStart, wEndProj) - const bsLn = make(vStartSnap, newWStart) - const beLn = make(vEnd, wEndProj) - roofBase.lines.push(wallLn, bsLn, beLn) - roofBase.innerLines.push(wallLn, bsLn, beLn) + // [KERAB-VALLEY-OVERLAP-SINGLE-BAND 2026-06-04] 밴드(V1·V2·V3·V4 사각형)는 새 지붕면이 아니라 + // "겹침" 영역 — 단일 면으로 두고 mergeValleyOverlapSubRoofs 가 인접 면(F3)에 접어 넣는다. + // 따라서 벽/connector 를 분할하지 않고 quad 4변만 만든다. + // V1(vExt)은 이미 innerLines 에 존재 → 나머지 3변(start connector, wall, end connector)만 추가. + // V1 의 RG-2 교차 노드(Option A split)는 RG-2 를 그래프에 연결하되 밴드는 한 면으로 유지. + const made = [make(vStartSnap, newWStart), make(newWStart, wEndProj), make(wEndProj, vEnd)] + roofBase.lines.push(...made) + roofBase.innerLines.push(...made) } if (roofBase.separatePolygon.length > 0) { @@ -1217,6 +1297,9 @@ export function useRoofAllocationSetting(id) { drawDirectionArrow(roof) }) + // [ROOF-FACE-DIAG 2026-06-04] 할당된 지붕면별 면적/좌표 디버그 라벨+로그 (로컬 전용). + reattachRoofFaceDebugLabels(canvas) + setRoofMaterials(newRoofList) setRoofsStore(newRoofList) /** 외곽선 삭제 */ diff --git a/src/hooks/usePolygon.js b/src/hooks/usePolygon.js index 5fefa914..16d548ce 100644 --- a/src/hooks/usePolygon.js +++ b/src/hooks/usePolygon.js @@ -10,6 +10,7 @@ import { QLine } from '@/components/fabric/QLine' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import { useLine } from '@/hooks/useLine' import { logger } from '@/util/logger' +import { debugCapture } from '@/util/debugCapture' export const usePolygon = () => { const canvas = useRecoilValue(canvasState) @@ -763,6 +764,23 @@ export const usePolygon = () => { `(${l.x1?.toFixed(1)},${l.y1?.toFixed(1)})→(${l.x2?.toFixed(1)},${l.y2?.toFixed(1)})`, ) }) + const __splitLineRec = (l) => ({ + lineName: l.lineName ?? 'none', + name: l.name, + type: l.attributes?.type ?? 'none', + isStart: l.attributes?.isStart ?? false, + x1: Math.round(l.x1 * 100) / 100, + y1: Math.round(l.y1 * 100) / 100, + x2: Math.round(l.x2 * 100) / 100, + y2: Math.round(l.y2 * 100) / 100, + }) + debugCapture.log('SPLIT-ALLOC-DIAG', { + polygonId: polygon.id?.slice(0, 8), + visibleInner: innerLines.length, + polygonLines: polygon.lines?.length ?? 0, + inner: innerLines.map(__splitLineRec), + outer: (polygon.lines || []).map(__splitLineRec), + }) /*// innerLine이 세팅이 안되어있는경우 찾아서 세팅한다. if (!innerLines || innerLines.length === 0) { From a8d59873b7e9259161014263591d6048d8ce1b38 Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 4 Jun 2026 17:01:11 +0900 Subject: [PATCH 5/8] =?UTF-8?q?[1961]=20=ED=8F=89=ED=96=89=20=EC=B2=98?= =?UTF-8?q?=EB=A7=88=20=EC=8C=8D=20=EB=9D=BC=EB=B2=A8=20=ED=86=B5=EC=9D=BC?= =?UTF-8?q?=20=E2=80=94=20OVER=5FEPS=201mm=20=ED=81=B4=EB=9E=A8=ED=94=84?= =?UTF-8?q?=20=EB=B3=B4=EC=A0=95=20equalizeParallelEaveLabels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4 --- src/components/fabric/QPolygon.js | 23 +++++++- src/util/qpolygon-utils.js | 87 +++++++++++++++++++++++++++++++ src/util/skeleton-utils.js | 11 ---- 3 files changed, 109 insertions(+), 12 deletions(-) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index fb12e181..fc2bb168 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -2,7 +2,16 @@ import { fabric } from 'fabric' import { v4 as uuidv4 } from 'uuid' import { QLine } from '@/components/fabric/QLine' import { distanceBetweenPoints, findTopTwoIndexesByDistance, getDirectionByPoint, sortedPointLessEightPoint, sortedPoints } from '@/util/canvas-util' -import { calculateAngle, drawGableRoof, drawRoofByAttribute, drawShedRoof, equalizeSymmetricHips, snapNearAxisEdges, toGeoJSON } from '@/util/qpolygon-utils' +import { + calculateAngle, + drawGableRoof, + drawRoofByAttribute, + drawShedRoof, + equalizeParallelEaveLabels, + equalizeSymmetricHips, + snapNearAxisEdges, + toGeoJSON, +} from '@/util/qpolygon-utils' import * as turf from '@turf/turf' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import Big from 'big.js' @@ -786,6 +795,18 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { if (this.innerLines && this.innerLines.length > 0) { const hips = this.innerLines.filter((l) => l && l.name === LINE_TYPE.SUBLINE.HIP) equalizeSymmetricHips(hips, this.canvas) + + // 평행 처마 쌍(고정 outerLine ↔ 이동 eaveHelpLine) 라벨 통일. + // OVER_EPS 클램프로 이동 변이 1mm 짧아져 평행인데 라벨이 1 차이 나는 케이스 보정. + // eaveHelpLine 은 이 지붕 소속(parentId)만, outerLine 은 좌표가 겹치는 것만 그룹핑되므로 cross-roof 오머지 없음. + const eaveCandidates = (this.canvas?.getObjects() || []).filter( + (o) => + (o.name === 'outerLine' || (o.name === LINE_TYPE.WALLLINE.EAVE_HELP_LINE && o.parentId === this.id)) && + typeof o.x1 === 'number' && + typeof o.y1 === 'number', + ) + equalizeParallelEaveLabels(eaveCandidates, this.canvas) + this.canvas?.renderAll() } diff --git a/src/util/qpolygon-utils.js b/src/util/qpolygon-utils.js index 1471186d..7a8ddda7 100644 --- a/src/util/qpolygon-utils.js +++ b/src/util/qpolygon-utils.js @@ -6346,6 +6346,93 @@ export const equalizeSymmetricHips = (hipLines, canvas) => { }) } +/** + * 평행한 처마 변 쌍(고정 outerLine ↔ 이동 eaveHelpLine)의 라벨을 통일. + * + * 배경: WallBaseLine 을 옆 변과 평행이 되도록 옮기면 OVER_EPS(0.1px=1mm) 클램프 때문에 + * 이동 변이 평행 직전에서 멈춰 좌표 길이가 고정 변보다 정확히 1mm 짧아진다 + * (예: 고정 2805 ↔ 이동 2804). 기하/SK 안전을 위해 OVER_EPS 는 유지해야 하므로 + * 좌표는 건드리지 않고 '표시 라벨' 만 맞춘다. 고객이 1mm 차이도 거부하기 때문. + * + * 좌표 길이(calcLinePlaneSize 와 동일식)로 그룹핑한다. outerLine 의 attributes.planeSize 는 + * stale 일 수 있어 신뢰하지 않는다. 클램프는 항상 '짧게' 만들므로 그룹 최대값이 의도한 값. + * 기준(최대) 변은 손대지 않고, 짧아진 변만 최대값으로 끌어올린다. + * + * @param {Array} lines - outerLine + eaveHelpLine 등 후보 라인 배열 (x1,y1,x2,y2 필수) + * @param {object} canvas - fabric canvas (lengthText 동기화용; null 이면 attribute 만 갱신) + */ +export const equalizeParallelEaveLabels = (lines, canvas) => { + if (!lines || lines.length < 2) return + + const _coordLen = (l) => Math.round(Math.sqrt((l.x2 - l.x1) ** 2 + (l.y2 - l.y1) ** 2) * 10) + const _orient = (l) => { + const adx = Math.abs(l.x2 - l.x1) + const ady = Math.abs(l.y2 - l.y1) + if (adx < 0.5 && ady >= 0.5) return 'V' + if (ady < 0.5 && adx >= 0.5) return 'H' + return null // 대각선(hip 등) 은 equalizeSymmetricHips 담당 → 제외 + } + // 평행 두 변이 마주보는 처마 쌍인지: 평행 방향(변화 축) 의 범위가 겹쳐야 한다. + const _overlap = (a, b, orient) => { + const [ak1, ak2, bk1, bk2] = + orient === 'V' + ? [Math.min(a.y1, a.y2), Math.max(a.y1, a.y2), Math.min(b.y1, b.y2), Math.max(b.y1, b.y2)] + : [Math.min(a.x1, a.x2), Math.max(a.x1, a.x2), Math.min(b.x1, b.x2), Math.max(b.x1, b.x2)] + return Math.min(ak2, bk2) - Math.max(ak1, bk1) > 0.5 + } + const _apply = (line, plane, actual) => { + const text = canvas ? canvas.getObjects().find((o) => o.name === 'lengthText' && o.parentId === line.id) : null + // 표시 모드(평면/실측) 는 갱신 전 기준으로 판정해야 잘못된 모드 전환을 막는다. + const wasPlane = text ? !text.actualSize || text.text === String(text.planeSize) : true + if (line.attributes) { + line.attributes.planeSize = plane + line.attributes.actualSize = actual + } + if (text) { + text.set({ planeSize: plane, actualSize: actual }) + const __raw = wasPlane ? plane : actual + text.set({ text: Number(__raw).toFixed(1).replace(/\.0$/, '') }) + } + } + + const used = new Set() + lines.forEach((a, i) => { + if (used.has(i)) return + const oa = _orient(a) + if (!oa) { + used.add(i) + return + } + const baseLen = _coordLen(a) + const group = [{ idx: i, line: a, len: baseLen }] + lines.forEach((b, j) => { + if (j <= i || used.has(j)) return + if (_orient(b) !== oa) return + const lenB = _coordLen(b) + if (Math.abs(lenB - baseLen) > 5) return + if (!_overlap(a, b, oa)) return + group.push({ idx: j, line: b, len: lenB }) + }) + if (group.length >= 2) { + let ref = group[0] + group.forEach((g) => { + if (g.len > ref.len) ref = g + }) + const plane = ref.len + group.forEach(({ idx, line, len }) => { + used.add(idx) + if (len >= plane) return // 기준(최대) 변은 손대지 않음 + const oldPlane = line.attributes?.planeSize ?? len + const oldActual = line.attributes?.actualSize ?? oldPlane + const actual = oldPlane > 0 ? Math.round((oldActual * plane) / oldPlane) : plane + _apply(line, plane, actual) + }) + } else { + used.add(i) + } + }) +} + /** * 포인트와 기울기를 기준으로 선의 길이를 구한다. * @param lineLength diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index eb0a0306..79b75182 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -2111,17 +2111,6 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver const getAddLine = (p1, p2, stroke = '', lineType = 'eaveHelpLine') => { movedLines.push({ index, p1, p2 }) - // [진단] eaveHelpLine 생성 추적: 어느 index/어느 wallBaseLine-wallLine 쌍에서 만들어지는지 - // logger.log('🎯 [getAddLine]', { - // index, - // lineType, - // p1: { x: Math.round(p1.x), y: Math.round(p1.y) }, - // p2: { x: Math.round(p2.x), y: Math.round(p2.y) }, - // wallLine: `(${Math.round(wallLine.x1)},${Math.round(wallLine.y1)})→(${Math.round(wallLine.x2)},${Math.round(wallLine.y2)})`, - // wallBaseLine: `(${Math.round(wallBaseLine.x1)},${Math.round(wallBaseLine.y1)})→(${Math.round(wallBaseLine.x2)},${Math.round(wallBaseLine.y2)})`, - // wallBaseLen: Math.round(Math.hypot(wallBaseLine.x2 - wallBaseLine.x1, wallBaseLine.y2 - wallBaseLine.y1)), - // }) - const dx = Math.abs(p2.x - p1.x); const dy = Math.abs(p2.y - p1.y); const isDiagonal = dx > 0.5 && dy > 0.5; // x, y 변화가 모두 있으면 대각선 From 956576491264ff13c29fe94e0e54d6feed8f2840 Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 4 Jun 2026 17:19:53 +0900 Subject: [PATCH 6/8] =?UTF-8?q?[0000]=20=EC=BC=80=EB=9D=BC=EB=B0=94=20kLin?= =?UTF-8?q?e=20=E2=80=94=20wLine=20=EC=9D=B4=EB=8F=99=20=ED=9B=84=20baseLi?= =?UTF-8?q?ne=20=EB=81=9D=EC=A0=90(t1/t2)=20=EA=B8=B0=EC=A4=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20foot=20=EA=B3=84=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wLine 이동 후 케라바 변경 시 선택된 target 은 이동 전 wallLine 좌표를 가짐. wall.baseLines 에서 wallId 매칭으로 이동된 실제 끝점(t1/t2)을 찾아 kLine foot 계산 기준으로 사용 → 이상한 대각선 수정. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/roofcover/useEavesGableEdit.js | 26 +++++++++++++++--------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index 7b5b5f40..eb0938d7 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -364,8 +364,12 @@ export function useEavesGableEdit(id) { } } const labelOf = (line) => (line ? labelByLine.get(line) || '?' : null) - const t1 = { x: target.x1, y: target.y1 } - const t2 = { x: target.x2, y: target.y2 } + // [KERAB-WLINE-T1T2 2026-06-04] wLine 이동 후 선택된 target 은 이동 전 wallLine. + // 이동된 실제 위치는 wall.baseLines 중 wallId 가 일치하는 baseLine 에 있다. + const _wall = canvas.getObjects().find((o) => o.name === POLYGON_TYPE.WALL && o.attributes?.roofId === target.attributes?.roofId) + const _matchedBase = _wall?.baseLines?.find((bl) => bl.attributes?.wallId === target.attributes?.wallId) + const t1 = _matchedBase ? { x: _matchedBase.x1, y: _matchedBase.y1 } : { x: target.x1, y: target.y1 } + const t2 = _matchedBase ? { x: _matchedBase.x2, y: _matchedBase.y2 } : { x: target.x2, y: target.y2 } const h1Match = findHipAtEndpoint(roof, t1) const h2Match = findHipAtEndpoint(roof, t2) logger.log( @@ -734,11 +738,13 @@ export function useEavesGableEdit(id) { return Math.abs((vax * vbx + vay * vby) / (ma * mb)) < PERP_EPS } const computePendingKLine = (apex) => { - const ax = h2Match.near.x - h1Match.near.x - const ay = h2Match.near.y - h1Match.near.y + // [KERAB-WLINE-T1T2 2026-06-04] wLine 이동 후 hip.near 가 target 끝점에서 벗어나므로 + // 이동한 처마라인 끝점(t1/t2) 기준으로 foot 계산 + const ax = t2.x - t1.x + const ay = t2.y - t1.y const aSq = ax * ax + ay * ay || 1 - const tFoot = ((apex.x - h1Match.near.x) * ax + (apex.y - h1Match.near.y) * ay) / aSq - const foot = { x: h1Match.near.x + tFoot * ax, y: h1Match.near.y + tFoot * ay } + const tFoot = ((apex.x - t1.x) * ax + (apex.y - t1.y) * ay) / aSq + const foot = { x: t1.x + tFoot * ax, y: t1.y + tFoot * ay } return { x1: apex.x, y1: apex.y, x2: foot.x, y2: foot.y } } const pushApexIfNew = (point, callerTag = '?') => { @@ -1492,8 +1498,8 @@ export function useEavesGableEdit(id) { roof, target, markerApex, - h1Match.near, - h2Match.near, + t1, + t2, [h1Match.hip, h2Match.hip, ...pathHips], pathRidges, extLines, @@ -2160,8 +2166,8 @@ export function useEavesGableEdit(id) { roof, target, apex, - h1Match.near, - h2Match.near, + t1, + t2, [h1Match.hip, h2Match.hip], null, null, From 8192b0b07f21e5f082f1bc9f9288a2375b962d21 Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 4 Jun 2026 17:48:44 +0900 Subject: [PATCH 7/8] =?UTF-8?q?[0000]=20=EC=BC=80=EB=9D=BC=EB=B0=94=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=EC=9D=B4=EB=8F=99=20=ED=9B=84=20=EB=B0=98?= =?UTF-8?q?=EB=8C=80=EC=AA=BD=20=EC=8B=A4=ED=96=89=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=E2=80=94=20baseLine=20=EA=B8=B0=ED=95=98?= =?UTF-8?q?=ED=95=99=EC=A0=81=20=EB=A7=A4=EC=B9=AD=20+=20KERAB-SKIP=20?= =?UTF-8?q?=EA=B0=80=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/roofcover/useEavesGableEdit.js | 28 ++++++++++++++++++++++-- src/util/skeleton-utils.js | 14 ++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index eb0938d7..c2e73567 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -365,9 +365,33 @@ export function useEavesGableEdit(id) { } const labelOf = (line) => (line ? labelByLine.get(line) || '?' : null) // [KERAB-WLINE-T1T2 2026-06-04] wLine 이동 후 선택된 target 은 이동 전 wallLine. - // 이동된 실제 위치는 wall.baseLines 중 wallId 가 일치하는 baseLine 에 있다. + // 이동된 실제 위치는 wall.baseLines 에 있다. + // wallId 매칭은 이동 후 인덱스 재정렬로 잘못된 baseLine 반환 → 기하학적 매칭으로 교체. + // 같은 방향(수직/수평) + target 의 고정 끝점(이동 안 된 쪽) 공유 여부로 식별. const _wall = canvas.getObjects().find((o) => o.name === POLYGON_TYPE.WALL && o.attributes?.roofId === target.attributes?.roofId) - const _matchedBase = _wall?.baseLines?.find((bl) => bl.attributes?.wallId === target.attributes?.wallId) + const _BL_TOL = 5 + const _tIsV = Math.abs(target.x1 - target.x2) < 0.5 + const _tIsH = Math.abs(target.y1 - target.y2) < 0.5 + const _matchedBase = _wall?.baseLines?.find((bl) => { + if (_tIsV) { + if (Math.abs(bl.x1 - bl.x2) >= 0.5) return false // bl 방향 불일치 + if (Math.abs(bl.x1 - target.x1) >= _BL_TOL) return false // 다른 수직선 + return ( + Math.abs(bl.y1 - target.y1) < _BL_TOL || Math.abs(bl.y1 - target.y2) < _BL_TOL || + Math.abs(bl.y2 - target.y1) < _BL_TOL || Math.abs(bl.y2 - target.y2) < _BL_TOL + ) + } + if (_tIsH) { + if (Math.abs(bl.y1 - bl.y2) >= 0.5) return false // bl 방향 불일치 + if (Math.abs(bl.y1 - target.y1) >= _BL_TOL) return false // 다른 수평선 + return ( + Math.abs(bl.x1 - target.x1) < _BL_TOL || Math.abs(bl.x1 - target.x2) < _BL_TOL || + Math.abs(bl.x2 - target.x1) < _BL_TOL || Math.abs(bl.x2 - target.x2) < _BL_TOL + ) + } + // 대각선: wallId fallback + return bl.attributes?.wallId === target.attributes?.wallId + }) const t1 = _matchedBase ? { x: _matchedBase.x1, y: _matchedBase.y1 } : { x: target.x1, y: target.y1 } const t2 = _matchedBase ? { x: _matchedBase.x2, y: _matchedBase.y2 } : { x: target.x2, y: target.y2 } const h1Match = findHipAtEndpoint(roof, t1) diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 79b75182..e283c472 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -2013,6 +2013,16 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver return } + // [KERAB-SKIP 2026-06-04] 순수 케라바 변(GABLE/GABLE_LEFT/GABLE_RIGHT/JERKINHEAD)은 + // 처마처럼 wall→roof 사이에 hip이 없으므로 eaveHelpLine 불필요. + // 적용 시 SK 오염. HIPANDGABLE은 처마+케라바 혼합(hip 있음)이므로 제외하지 않음. + if ([ + LINE_TYPE.WALLLINE.GABLE, + LINE_TYPE.WALLLINE.GABLE_LEFT, + LINE_TYPE.WALLLINE.GABLE_RIGHT, + LINE_TYPE.WALLLINE.JERKINHEAD, + ].includes(wallBaseLine.attributes?.type)) return + // [진단] sortWallLines ↔ sortWallBaseLines 페어링 검증 // collapse 시 ensureCounterClockwiseLines 시작점이 달라지면 인덱스가 어긋남 // 정상: 같은 물리 벽 → 적어도 한쪽 끝점 공유 또는 방향 동일(수직/수평) @@ -3136,8 +3146,8 @@ function findMatchingLine(edgePolygon, roof, roofPoints) { */ function processGableEdge(edgeResult, baseLines, skeletonLines, selectBaseLine, lastSkeletonLines) { const edgePoints = edgeResult.Polygon.map(p => ({ x: p.X, y: p.Y })); - //const polygons = createPolygonsFromSkeletonLines(skeletonLines, selectBaseLine); - //logger.log("edgePoints::::::", edgePoints) + + // 1. Initialize processedLines with a deep copy of lastSkeletonLines let processedLines = [] // 1. 케라바 면과 관련된 불필요한 스켈레톤 선을 제거합니다. From ddf7cb9f13cc12f0d760231cef72b1b90f5f1b90 Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 5 Jun 2026 11:00:29 +0900 Subject: [PATCH 8/8] =?UTF-8?q?[1423]=20=EC=84=A4=EA=B3=84=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EA=B8=B0=20T01=20=EC=A0=84=EC=9A=A9=20+=202?= =?UTF-8?q?=EC=B0=A8=EC=A0=90=20=EA=B0=95=EC=A0=9C=20select=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=E2=80=94=20No=20data/=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EC=97=90=EB=9F=AC=20=EB=B6=84=EB=A6=AC=20+=20vExt?= =?UTF-8?q?=20cascade=20=ED=8F=89=ED=96=89=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/management/StuffDetail.jsx | 88 +++++++++++++---------- src/locales/ja.json | 1 + src/locales/ko.json | 1 + src/util/kerab-offset-surgical.js | 27 +++++-- 4 files changed, 73 insertions(+), 44 deletions(-) diff --git a/src/components/management/StuffDetail.jsx b/src/components/management/StuffDetail.jsx index 874aa8fd..30370d73 100644 --- a/src/components/management/StuffDetail.jsx +++ b/src/components/management/StuffDetail.jsx @@ -1082,39 +1082,49 @@ export default function StuffDetail() { return } - // T01 / 1차 user + 2차 ID: firstAgent 검증 후에만 적용 (실패 시 무반영) - get({ url: `/api/object/saleStore/${info.saleStoreId}/firstAgent` }).then((res) => { - logger.debug('[PLANREQ-DEBUG] firstAgent result', { firstAgentId: res?.firstAgentId }) - if (!res?.firstAgentId) { - swalFire({ - title: getMessage('stuff.detail.planReq.message.notMatch'), - type: 'alert', - icon: 'warning', - }) - return - } + // [PLANREQ-FORCE-SELECT 2026-06-05] T01 / 1차 user + 2차 ID + // 매핑 실패해도 모든 필드 적용 + 2차점을 옵션에 강제 추가 + selected + // 1차점 정보(firstAgent) 가 있으면 1차점도 함께 반영 + // 'No data' 응답(4xx + message)도 매핑 실패와 동일하게 처리, 진짜 네트워크 에러만 알림 + const applyOtherSaleStore = () => { applyFields() + setOtherSaleStoreList((prev) => { + const exists = prev.some((o) => o.saleStoreId === info.saleStoreId) + return exists ? prev : [...prev, { saleStoreId: info.saleStoreId, saleStoreName: info.saleStoreName }] + }) setOtherSelOptions(info.saleStoreId) form.setValue('otherSaleStoreId', info.saleStoreId) form.setValue('otherSaleStoreName', info.saleStoreName) form.setValue('otherSaleStoreLevel', info.saleStoreLevel) - const firstAgent = { saleStoreId: res.firstAgentId, saleStoreName: res.firstAgentName } - setSaleStoreList((prev) => { - const exists = prev.some((s) => s.saleStoreId === res.firstAgentId) - return exists ? prev : [...prev, firstAgent] - }) - setShowSaleStoreList((prev) => { - const exists = prev.some((s) => s.saleStoreId === res.firstAgentId) - return exists ? prev : [...prev, firstAgent] - }) - setSelOptions(res.firstAgentId) - form.setValue('saleStoreId', res.firstAgentId) - form.setValue('saleStoreName', res.firstAgentName) - form.setValue('saleStoreLevel', '1') - }).catch(() => { - // 매핑 실패 — 아무것도 적용 안 함 + 사용자에게 알림 + } + get({ url: `/api/object/saleStore/${info.saleStoreId}/firstAgent` }).then((res) => { + logger.debug('[PLANREQ-DEBUG] firstAgent result', { firstAgentId: res?.firstAgentId }) + applyOtherSaleStore() + if (res?.firstAgentId) { + const firstAgent = { saleStoreId: res.firstAgentId, saleStoreName: res.firstAgentName } + setSaleStoreList((prev) => { + const exists = prev.some((s) => s.saleStoreId === res.firstAgentId) + return exists ? prev : [...prev, firstAgent] + }) + setShowSaleStoreList((prev) => { + const exists = prev.some((s) => s.saleStoreId === res.firstAgentId) + return exists ? prev : [...prev, firstAgent] + }) + setSelOptions(res.firstAgentId) + form.setValue('saleStoreId', res.firstAgentId) + form.setValue('saleStoreName', res.firstAgentName) + form.setValue('saleStoreLevel', '1') + } + }).catch((error) => { + // 'No data' 는 1차점 매핑 정보가 없을 뿐 — 2차점만 옵션 추가+select + const message = error?.response?.data?.message + if (message === 'No data') { + applyOtherSaleStore() + return + } + // 진짜 네트워크/서버 에러 swalFire({ - title: getMessage('stuff.detail.planReq.message.notMatch'), + title: getMessage('stuff.detail.planReq.message.networkError'), type: 'alert', icon: 'warning', }) @@ -1878,16 +1888,18 @@ export default function StuffDetail() { )) || null} - + {session?.storeId === 'T01' && ( + + )} @@ -2468,7 +2480,7 @@ export default function StuffDetail() { > ) : null} - {managementState?.tempFlg === '1' ? ( + {managementState?.tempFlg === '1' && session?.storeId === 'T01' ? ( <>