From e17af8f4b4734f3887e3cbae701a66c1bb2f785f Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Wed, 10 Jun 2026 13:22:44 +0900 Subject: [PATCH 1/9] =?UTF-8?q?fix:=20=EC=A7=80=EB=B6=95=ED=98=95=EC=83=81?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95/=EC=88=98=EB=8F=99=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=99=B8=EB=B2=BD=EC=84=A0=20=ED=91=9C=EC=8B=9C=C2=B7=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=83=81=ED=83=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - 지붕형상 저장 후 색칠된 외벽선 선택·드래그 차단, 변별로 설정 재진입 시 선택 복원 - 수동설정 저장 시에도 자동설정과 동일하게 외벽선을 타입별 색상으로 표시 - 두 모달을 진입 시 스냅샷 → 저장 시 커밋 / 저장 없이 닫으면 롤백 구조로 변경 (팝업을 열었다 닫기만 해도 외벽선이 숨겨지고 지붕면이 삭제되던 문제 수정) - 롤백 시 WALL 폴리곤은 원본 객체를 원래 z-순서에 재삽입 (재생성 시 최상단에 올라가 보조선 클릭을 가로채던 문제 방지) Co-Authored-By: Claude Fable 5 --- .../roofcover/useRoofShapePassivitySetting.js | 69 +++++++++++-------- src/hooks/roofcover/useRoofShapeSetting.js | 54 +++++++++++++-- 2 files changed, 91 insertions(+), 32 deletions(-) diff --git a/src/hooks/roofcover/useRoofShapePassivitySetting.js b/src/hooks/roofcover/useRoofShapePassivitySetting.js index e0223139..14c648f0 100644 --- a/src/hooks/roofcover/useRoofShapePassivitySetting.js +++ b/src/hooks/roofcover/useRoofShapePassivitySetting.js @@ -36,6 +36,7 @@ export function useRoofShapePassivitySetting(id) { const [type, setType] = useState(TYPES.EAVES) const isFix = useRef(false) const initLines = useRef([]) + const removedWalls = useRef([]) const [isLoading, setIsLoading] = useState(false) const { closePopup } = usePopup() @@ -60,6 +61,7 @@ export function useRoofShapePassivitySetting(id) { addCanvasMouseEventListener('mouse:down', mouseDown) const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL) + removedWalls.current = wallLines.map((wall) => ({ wall, index: canvas.getObjects().indexOf(wall) })) canvas?.remove(...wallLines) const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') @@ -198,10 +200,32 @@ export function useRoofShapePassivitySetting(id) { } const handleLineToPolygon = () => { - const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) - const exceptObjs = canvas.getObjects().filter((obj) => obj.name !== 'outerLine' && obj.parent?.name !== 'outerLine') const lines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') + if (!isFix.current) { + // 저장 없이 닫을 경우 진입 시점 스냅샷으로 롤백 — 캔버스를 진입 전 상태 그대로 되돌린다 + lines.forEach((line, idx) => { + const init = initLines.current[idx] + if (!init) { + return + } + init.visible ? showLine(line) : hideLine(line) + line.set({ stroke: init.stroke, strokeWidth: init.strokeWidth, selectable: init.selectable }) + line.attributes = init.attributes + }) + + // 진입 시 삭제했던 WALL 폴리곤 원본을 원래 z-순서 그대로 재삽입 + removedWalls.current.forEach(({ wall, index }) => { + canvas.insertAt(wall, index) + }) + removedWalls.current = [] + + canvas.renderAll() + return + } + + const exceptObjs = canvas.getObjects().filter((obj) => obj.name !== 'outerLine' && obj.parent?.name !== 'outerLine') + let checkedAllSetting = true lines.forEach((line) => { @@ -219,36 +243,27 @@ export function useRoofShapePassivitySetting(id) { canvas.remove(obj) }) + // 저장 시에는 자동 설정과 동일하게 외벽선을 타입별 색상으로 표시 lines.forEach((line) => { - hideLine(line) + let stroke + if (line.attributes?.type === LINE_TYPE.WALLLINE.EAVES || line.attributes?.type === LINE_TYPE.WALLLINE.HIPANDGABLE) { + stroke = '#45CD7D' + } else if (line.attributes?.type === LINE_TYPE.WALLLINE.GABLE || line.attributes?.type === LINE_TYPE.WALLLINE.JERKINHEAD) { + stroke = '#3FBAE6' + } else { + stroke = '#000000' + } + line.set({ stroke, strokeWidth: 4, selectable: false, visible: true }) }) - let wall - - if (isFix.current) { - wall = addPolygonByLines(lines, { name: POLYGON_TYPE.WALL, fill: 'transparent', stroke: 'black' }) - } else { - // 그냥 닫을 경우 처리 - wall = addPolygonByLines([...initLines.current], { - name: POLYGON_TYPE.WALL, - fill: 'transparent', - stroke: 'black', - }) - lines.forEach((line, idx) => { - line.attributes = initLines.current[idx].attributes - }) - } - + const wall = addPolygonByLines(lines, { name: POLYGON_TYPE.WALL, fill: 'transparent', stroke: 'black' }) wall.lines = [...lines] - // 기존 그려진 지붕이 없다면 - - if (isFix.current) { - // 완료 한 경우에는 지붕까지 그려줌 - addPitchTextsByOuterLines() - const roof = drawRoofPolygon(wall) - addLengthText(roof) - } + // 완료 한 경우에는 지붕까지 그려줌 + addPitchTextsByOuterLines() + const roof = drawRoofPolygon(wall) + addLengthText(roof) + lines.forEach((line) => line.bringToFront()) canvas.renderAll() } diff --git a/src/hooks/roofcover/useRoofShapeSetting.js b/src/hooks/roofcover/useRoofShapeSetting.js index f5c84f5b..58fec3df 100644 --- a/src/hooks/roofcover/useRoofShapeSetting.js +++ b/src/hooks/roofcover/useRoofShapeSetting.js @@ -52,6 +52,11 @@ export function useRoofShapeSetting(id) { const isFixRef = useRef(false) + // 저장 없이 닫을 때 롤백할 진입 시점 스냅샷 + const initLinesRef = useRef([]) + const removedWallsRef = useRef([]) + const pitchTextLineIdsRef = useRef(new Set()) + const pitchRef = useRef(null) const shedPitchRef = useRef(null) const jerkinHeadPitchRef = useRef(null) @@ -87,14 +92,46 @@ export function useRoofShapeSetting(id) { closePopup(id) return } - return () => { - if (!isFixRef.current) { - return - } + initLinesRef.current = outerLines.map((line) => ({ ...line })) + pitchTextLineIdsRef.current = new Set( + canvas + .getObjects() + .filter((obj) => obj.name === 'pitchText') + .map((obj) => obj.parentId), + ) + + return () => { const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') const pitchTexts = canvas.getObjects().filter((obj) => obj.name === 'pitchText') + if (!isFixRef.current) { + // 저장 없이 닫을 경우 진입 시점 스냅샷으로 롤백 — 캔버스를 진입 전 상태 그대로 되돌린다 + canvas.remove(...pitchTexts) + outerLines.forEach((line, idx) => { + const init = initLinesRef.current[idx] + if (!init) { + return + } + init.visible ? showLine(line) : hideLine(line) + line.set({ stroke: init.stroke, strokeWidth: init.strokeWidth, selectable: init.selectable }) + line.attributes = init.attributes + if (pitchTextLineIdsRef.current.has(line.id)) { + addPitchText(line) + } + }) + + // 변별로 설정 진입 시 삭제했던 WALL 폴리곤 원본을 원래 z-순서 그대로 재삽입 + removedWallsRef.current.forEach(({ wall, index }) => { + canvas.insertAt(wall, index) + }) + removedWallsRef.current = [] + + canvas.discardActiveObject() + canvas.renderAll() + return + } + canvas.remove(...pitchTexts) outerLines.forEach((line) => { let stroke, strokeWidth @@ -113,6 +150,8 @@ export function useRoofShapeSetting(id) { line.set({ stroke, strokeWidth, + selectable: false, + visible: true, }) addPitchText(line) @@ -144,10 +183,15 @@ export function useRoofShapeSetting(id) { useEffect(() => { if (shapeNum === 4) { - canvas?.remove(canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL)) + const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL) + if (wall && removedWallsRef.current.length === 0) { + removedWallsRef.current = [{ wall, index: canvas.getObjects().indexOf(wall) }] + } + canvas?.remove(wall) const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') outerLines.forEach((line) => { showLine(line) + line.set({ selectable: true }) line.bringToFront() }) From 7900b2e67b160f124b327494aab3581c81bd1265 Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Thu, 11 Jun 2026 14:52:19 +0900 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20removePitchText=EA=B0=80=20=EA=B2=BD?= =?UTF-8?q?=EC=82=AC=20=ED=85=8D=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=ED=95=98=EC=A7=80=20=EB=AA=BB=ED=95=98=EB=8D=98=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - canvas.remove()에 배열을 통째로 넘겨 Fabric이 무시(silent no-op)하던 것을 spread로 개별 전달하도록 수정 - addPitchText의 중복 방지가 무력화되어 같은 자리에 경사/출폭 텍스트가 겹겹이 쌓이던 문제 해결 Co-Authored-By: Claude Fable 5 --- src/hooks/useLine.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useLine.js b/src/hooks/useLine.js index fcca1bbc..b9a3eb05 100644 --- a/src/hooks/useLine.js +++ b/src/hooks/useLine.js @@ -148,7 +148,7 @@ export const useLine = () => { // 기존 pitchText가 있으면 삭제 if (pitchText.length > 0) { - canvas.remove(pitchText) + canvas.remove(...pitchText) } } From 18abbeb902a298898062c20ea3a28fd19f206ec3 Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Thu, 11 Jun 2026 18:09:31 +0900 Subject: [PATCH 3/9] =?UTF-8?q?fix:=20offset=200=EC=9D=B8=20=EB=B3=80?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=83=89=EC=B9=A0=EB=90=9C=20=EC=99=B8?= =?UTF-8?q?=EB=B2=BD=EC=84=A0=EC=9D=B4=20=EB=B3=B4=EC=A1=B0=EC=84=A0=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=9D=84=20=EA=B0=80=EB=A1=9C=EC=B1=84?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - 지붕형상 저장 시 외벽선을 bringToFront(맨 앞) 대신 WALL/ROOF 폴리곤 바로 위 인덱스에 삽입 - offset 0이면 보조선이 외벽선과 같은 좌표에 그려지는데, 외벽선이 맨 위에 있으면 클릭을 가로채(selectable:false라도 evented는 유지됨) 보조선 선택이 불가능했음 - z-order를 폴리곤 < 외벽선 < 보조선·텍스트로 유지해 색 표시와 보조선 클릭을 양립 Co-Authored-By: Claude Fable 5 --- src/hooks/roofcover/useRoofShapePassivitySetting.js | 8 +++++++- src/hooks/roofcover/useRoofShapeSetting.js | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/hooks/roofcover/useRoofShapePassivitySetting.js b/src/hooks/roofcover/useRoofShapePassivitySetting.js index 14c648f0..cabcbe90 100644 --- a/src/hooks/roofcover/useRoofShapePassivitySetting.js +++ b/src/hooks/roofcover/useRoofShapePassivitySetting.js @@ -263,7 +263,13 @@ export function useRoofShapePassivitySetting(id) { addPitchTextsByOuterLines() const roof = drawRoofPolygon(wall) addLengthText(roof) - lines.forEach((line) => line.bringToFront()) + + // 외벽선은 WALL/ROOF 폴리곤 바로 위에 배치 — 맨 앞(bringToFront)으로 올리면 + // offset 0인 변에서 같은 좌표에 그려진 보조선의 클릭을 가로채 선택을 막는다 + const polygonTopIdx = canvas + .getObjects() + .reduce((top, obj, idx) => (obj.name === POLYGON_TYPE.WALL || obj.name === POLYGON_TYPE.ROOF ? idx : top), -1) + lines.forEach((line) => canvas.moveTo(line, polygonTopIdx)) canvas.renderAll() } diff --git a/src/hooks/roofcover/useRoofShapeSetting.js b/src/hooks/roofcover/useRoofShapeSetting.js index 58fec3df..be939b1b 100644 --- a/src/hooks/roofcover/useRoofShapeSetting.js +++ b/src/hooks/roofcover/useRoofShapeSetting.js @@ -155,9 +155,15 @@ export function useRoofShapeSetting(id) { }) addPitchText(line) - line.bringToFront() } }) + + // 외벽선은 WALL/ROOF 폴리곤 바로 위에 배치 — 맨 앞(bringToFront)으로 올리면 + // offset 0인 변에서 같은 좌표에 그려진 보조선의 클릭을 가로채 선택을 막는다 + const polygonTopIdx = canvas + .getObjects() + .reduce((top, obj, idx) => (obj.name === POLYGON_TYPE.WALL || obj.name === POLYGON_TYPE.ROOF ? idx : top), -1) + outerLines.forEach((line) => canvas.moveTo(line, polygonTopIdx)) canvas.renderAll() } }, []) From 1bf5f45b635fb436a129008dd986a25303e5f659 Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Fri, 12 Jun 2026 13:58:08 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20=EC=B2=98=EB=A7=88=EB=B3=80=20s?= =?UTF-8?q?plit=20=EB=A1=9C=EC=A7=81=20splitRoofLinesToInnerLines=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=ED=95=A8=EC=88=98=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - drawRoofByAttribute 의 인라인 split 블록을 splitRoofLinesToInnerLines 공용 함수로 추출 (변별/용마루 경로 공용) - 분할점 거리순 정렬 버그 수정 (a[1] → a.distance — 분할점 2개 이상에서 정렬이 no-op 이던 문제) - 분할점 끝점 판정을 almostEqual(1e-10) → 끝점과의 유클리드 거리 1px 이내 제외로 변경 (SK 추녀 연장의 부동소수 오차 흡수, 0 길이 조각 생성 방지) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/util/qpolygon-utils.js | 53 ++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/src/util/qpolygon-utils.js b/src/util/qpolygon-utils.js index 7a8ddda7..fb23d06c 100644 --- a/src/util/qpolygon-utils.js +++ b/src/util/qpolygon-utils.js @@ -5429,7 +5429,35 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => { //추가된 하단 지붕 라인 innerLines에 추가. innerLines.push(...downRoofLines) - //지붕선에 따라 라인추가 작업 처리. + //지붕선에 따라 라인추가 작업 처리. (split 공용 함수 — docs §3-1) + splitRoofLinesToInnerLines(roof, innerLines, canvas, textMode) + + //지붕 innerLines에 추가. + roof.innerLines = innerLines + + canvas + .getObjects() + .filter((object) => object.name === 'check') + .forEach((object) => canvas.remove(object)) + canvas.renderAll() +} + +/** + * roof.lines(처마 외곽선)를 innerLine 끝점 기준으로 split 해 편집 가능한 innerLine(hip/roofLine)으로 만든다. + * 변별/용마루 경로 공용. innerLines 에 결과를 in-place push 하고 반환한다. + * (용마루 경로는 추녀 연장 후 호출해야 종점이 처마변 위에 있어 분할점이 잡힌다 — docs §9) + * @param {fabric.Object} roof - 대상 지붕 (roof.lines 사용) + * @param {Array} innerLines - 내부선 배열 (split 결과가 여기에 in-place 추가됨) + * @param {fabric.Canvas} canvas + * @param {string} textMode + * @returns {Array} innerLines + */ +export const splitRoofLinesToInnerLines = (roof, innerLines, canvas, textMode) => { + // 분할점이 처마변 끝점(코너)과 이 거리(px) 이내면 분할에서 제외. + // SK 추녀 연장이 처마 코너에 닿을 때 ray cast 부동소수 오차(관측 0.02px)로 코너와 미세하게 + // 어긋나면, 기존 almostEqual(1e-10)은 "끝점 아님"으로 오판 → 0 길이 조각 생성. END_EPS 로 흡수. + // 변별 경로의 진짜 분할점은 끝점에서 수백 px 떨어져 있어 영향 없음. + const END_EPS = 1.0 const innerLinesPoints = [] innerLines.forEach((line) => { const hasCoord1 = innerLinesPoints.find((p) => p.x === line.x1 && p.y === line.y1) @@ -5464,17 +5492,15 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => { if (hasOverlapLine) return innerLinesPoints.forEach((point) => { - if ( - isPointOnLineNew(currentLine, point) && - !(almostEqual(currentLine.x1, point.x) && almostEqual(currentLine.y1, point.y)) && - !(almostEqual(currentLine.x2, point.x) && almostEqual(currentLine.y2, point.y)) - ) { - const distance = Math.sqrt((point.x - currentLine.x1) ** 2 + (point.y - currentLine.y1) ** 2) - splitPoint.push({ point, distance }) + const distToStart = Math.hypot(point.x - currentLine.x1, point.y - currentLine.y1) + const distToEnd = Math.hypot(point.x - currentLine.x2, point.y - currentLine.y2) + // 변 위 점이면서 양 끝점(코너)에서 END_EPS 보다 멀 때만 진짜 분할점으로 채택. + if (isPointOnLineNew(currentLine, point) && distToStart > END_EPS && distToEnd > END_EPS) { + splitPoint.push({ point, distance: distToStart }) } }) if (splitPoint.length > 0) { - splitPoint.sort((a, b) => a[1] - b[1]) + splitPoint.sort((a, b) => a.distance - b.distance) let startPoint = { x: currentLine.x1, y: currentLine.y1 } for (let i = 0; i < splitPoint.length; i++) { const point = splitPoint[i].point @@ -5495,14 +5521,7 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => { } }) - //지붕 innerLines에 추가. - roof.innerLines = innerLines - - canvas - .getObjects() - .filter((object) => object.name === 'check') - .forEach((object) => canvas.remove(object)) - canvas.renderAll() + return innerLines } /** From 87545e5df17ca5188d15768dec47079917584daf Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Fri, 12 Jun 2026 13:58:15 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=EC=9A=A9=EB=A7=88=EB=A3=A8(skeleto?= =?UTF-8?q?n)=20=EC=A7=80=EB=B6=95=20=EC=99=B8=EA=B3=BD=20=EC=B2=98?= =?UTF-8?q?=EB=A7=88=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - 추녀 연장(drawBaselineToRooflineHelpers) 후 splitRoofLinesToInnerLines 호출 → 처마변을 편집 가능한 innerLine(hip/roofLine)으로 등록 - 처마선 innerLine 이 외곽선을 전담하므로 roof 원본 외곽(stroke) 숨김 처리 - 설계 문서 정정: 대상 브랜치 roofline-edit, §4 리스크1 검증 완료, §9 측정 시점 정정 (추녀 연장 후 종점이 처마변 위에 정합 → split 은 연장 다음에 호출) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-06-01-skeleton-roofline-edit-design.md | 46 +++++++++++++++++-- src/util/skeleton-utils.js | 9 +++- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/docs/plans/2026-06-01-skeleton-roofline-edit-design.md b/docs/plans/2026-06-01-skeleton-roofline-edit-design.md index 82414cae..9e97a2d2 100644 --- a/docs/plans/2026-06-01-skeleton-roofline-edit-design.md +++ b/docs/plans/2026-06-01-skeleton-roofline-edit-design.md @@ -1,7 +1,7 @@ # 용마루(skeleton) 경로 외곽 지붕선 편집 기능 — 설계 - 작성일: 2026-06-01 -- 대상 브랜치: `feature/redo-undo` +- 대상 브랜치: `feature/roofline-edit` - 관련 파일: `src/util/skeleton-utils.js`, `src/util/qpolygon-utils.js`, `src/hooks/useContextMenu.js` ## 1. 배경 / 문제 @@ -66,13 +66,13 @@ splitRoofLinesToInnerLines(roof, canvas, textMode) → QLine[] - **`drawRoofByAttribute` 교체**: 인라인 블록을 함수 호출로 교체. 순수 추출이므로 변별 경로 동작 불변. ### 3-2. skeleton 경로 변경 -- `createInnerLinesFromSkeleton` 의 `isOuterEdge` 숨김 처리(`:1636-1643` 의 `visible:false`, `:1624` 의 `selectable:false`) 제거. -- 내부선(용마루/추녀) 생성 완료 후 `splitRoofLinesToInnerLines(roof, canvas, textMode)` 호출 → 외곽 지붕선이 변별 경로와 동일한 형태로 `roof.innerLines` 에 합류. +- `createInnerLinesFromSkeleton` 의 `isOuterEdge`(SK eaves) 숨김 처리는 **유지**. (§9 정정: SK eaves 라인은 baseLine 기준이라, 노출하면 처마변 split 결과와 offset 만큼 어긋나 이중 표시됨. 처마 외곽선은 아래 split 으로 별도 innerLine 화.) +- **추녀 연장(`drawBaselineToRooflineHelpers`, `:1285`) 완료 후** `splitRoofLinesToInnerLines(roof, canvas, textMode)` 호출 → 추녀 종점이 처마변 위에 있는 상태라 split 정합 성립, 외곽 지붕선이 변별 경로와 동일한 형태로 `roof.innerLines` 에 합류. (⚠️ 연장 **전**에 호출하면 종점이 baseLine 위라 분할점을 못 잡음 — §9 참조) - `drawHelpLine` 재호출 시마다 자동 재실행되어 편집 후 재그리기에도 외곽 지붕선 유지. ## 4. 리스크 & 구현 전 검증 -1. **SK 추녀 끝점 ↔ `roof.lines` 정합성**: split 은 "innerLine 끝점이 `roof.lines` 위에 있다"(`isPointOnLineNew`, tol 0.1)를 전제로 한다. SK 가 만든 hip 끝점이 처마 외곽선 위에 실제로 떨어지는지 **실측 확인 필요**. 안 맞으면 split 이 헛돌아 외곽선이 통째로 roofLine 1개로만 그려진다. → 구현 첫 단계에서 검증. +1. **SK 추녀 끝점 ↔ `roof.lines` 정합성** — ✅ **검증 완료 (2026-06-12, §9 참조)**: **연장 후 정합 OK**. SK 골조는 `wall.baseLines` 기준이라 추녀 끝점이 처음엔 baseLine 코너에 있으나(연장 전 `onRoof=false`), `drawBaselineToRooflineHelpers`(`:1285`)가 추녀를 처마변까지 **연장**해 종점을 처마변 위로 옮긴다(연장 후 4코너 전부 `diffRoof=0`, `onRoof=true`). → **split 을 연장 다음에 호출하면 정합 자동 성립**. (초기 측정이 연장 전에 찍혀 "구조적 불일치"로 오판했음 — §9-0 참조) 2. **순환 import**: `qpolygon-utils ↔ skeleton-utils` 상호 import 여부 점검. 순환이면 split 함수를 제3의 모듈(예: canvas-util)로 추출하거나 의존성 정리. 3. **기존 `isOuterEdge` roofLine 제거 영향**: 숨긴 라인을 참조하는 곳(치수 계산, 플랜 저장/복원 등)이 있는지 확인 후 제거. @@ -159,3 +159,41 @@ opts.degreeOf?: (parentLine) => number // 부모 외곽변 → 경사각(도). ### 8-5. 리스크 - **분할점 부재**: 한쪽흐름은 내부 보조선이 없어 외곽변이 1:1 통짜 라인으로 그려진다 → 부모 변 type 매칭이 단순(분할 시에도 조각은 부모 type 상속). split 의 끝점 정합성(`isPointOnLineNew`, tol 0.1) 자체는 분할점이 없으면 무의미하므로 `else` 통짜 경로(`:5493`)로 안전하게 떨어진다. - **`isGable` 판정원**: `drawShedRoof` 의 `gables` 분류(`:1653`)와 동일 기준(`attributes.type === GABLE`)을 써야 보정 대상이 일치. + +--- + +## 9. SK 끝점 ↔ 처마변 정합 — 실측 결과 (2026-06-12, 측정 시점 정정) + +§4 리스크1 을 임시 계측(`skeleton-utils.js` 의 `logSkRoofLineAlignment`)으로 추녀 연장 **전/후** 2단계 실측한 결과. + +### 9-0. ⚠️ 측정 시점 정정 (중요) +1차 측정은 `createInnerLinesFromSkeleton` **직후**(`:1279`, 추녀 연장 전)에 찍혀, 추녀 종점이 baseLine 코너에 있는 상태(`onRoof=false`, `perpRoof=offset`)만 보고 **"구조적 불일치 → 투영 보정 필요"로 오판**했다. 실제로는 그 직후 `drawBaselineToRooflineHelpers`(`:1285`)가 추녀를 처마변까지 연장한다. 연장 **후**(`afterExt`) 측정에서 정합이 성립함을 확인 → 아래는 정정된 결론. **(투영 보정·SK 입력 교체·연장 보정 설계는 모두 폐기. 불필요했음.)** + +### 9-1. 실측 결과 (용마루 우진각, 변별 offset 50px) +처마변 x∈[357.5,1147.6]·y∈[115.2,636.7] vs baseLine x∈[407.5,1097.6]·y∈[165.2,586.7] (전 변 50px 안쪽). + +| 추녀 끝점 | beforeExt (`:1279`, 연장 전) | afterExt (`:1285`, 연장 후) | +|---|---|---| +| **바깥** 끝점(4코너) | baseLine 코너 — `onRoof=false`, `diffRoof=22.4`, `onBase=true` | **처마 코너로 연장 — `onRoof=true`, `diffRoof=0`** | +| 안쪽·용마루(내부점) | `onRoof=false` (260.7) | `onRoof=false` (260.7) — 내부점이라 정상 | + +예: 추녀 바깥 끝 `(407.5,165.2)`(baseLine) → `(357.5,115.2)`(처마 코너) 로 연장. 4코너 전부 `diffRoof=0`, `onRoof=true`. + +### 9-2. 추녀 연장 메커니즘 (이미 구현됨) +`drawBaselineToRooflineHelpers`(`:685`, 플래그 `DRAW_BASELINE_TO_ROOFLINE_HELPER=true`, `:25`): +- `hipLines` 추출(`:734`) → baseLine 코너에 닿는 hip 만(`CORNER_EPS`, `:773`) → 추녀 진행 방향으로 ray cast(`rayHitSegment`, `:693`) → 처마변 교점까지 hip 좌표를 **연장**(`:861-865`). +- `actualSize` 비율 보정(`:871`) + `__extended`/`attributes.extended` 플래그로 save/load 영속(`:879`). +- 즉 "추녀는 roof.lines 까지 연결되는 선" 이 이미 코드에 있다. SK 골조 자체는 baseLine 기준(동기화 유지) + 추녀 종점만 처마변까지 연장. + +### 9-3. 결론 — split 은 추녀 연장 **다음**에 호출 +- 추녀 종점은 연장 후 처마변 위(`onRoof=true`, `diffRoof=0`)에 정확히 앉으므로, **split/외곽선 innerLine화의 정합은 깨지지 않는다.** +- **유일한 요건**: `splitRoofLinesToInnerLines` 호출을 `createInnerLinesFromSkeleton` 직후가 아니라 **`drawBaselineToRooflineHelpers`(`:1285`) 다음에** 둘 것. §3-2 가 호출 위치를 연장 전으로 잡은 것이 유일한 함정이었음. +- **단순 우진각**: 추녀가 처마 **코너**(변 끝점)에 닿으므로 split 분할점 없음 → 처마 4변 통짜 innerLine(정상). +- **복합 형상**(L자·凸자): 추녀가 처마변 **중간**에 닿으면 그 점에서 분할. 연장 후 종점이 처마변 위이므로 분할점으로 정상 인식. + +### 9-4. 검증 기준 (구현 시) +- split 을 연장 후 호출 → 처마 4변이 innerLine 으로 등록, select+우클릭 편집 가능. +- 복합 형상: 처마변 중간 접점에서 분할, 각 조각 독립 편집. +- offset=0 / offset≠0 양쪽 동작. +- 마루이동/벽이동 후 재그리기에도 유지(연장은 `__extended`/`attributes.extended` 로 영속). +- **임시 계측(`logSkRoofLineAlignment` 정의 + 호출부 3곳 `:1279`/`:1285`/`:1413`) 제거** 후 커밋. diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index e283c472..66ea9afb 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -1,6 +1,6 @@ import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import { SkeletonBuilder } from '@/lib/skeletons' -import { calcLineActualSize, calcLineActualSize2, calcLinePlaneSize, toGeoJSON } from '@/util/qpolygon-utils' +import { calcLineActualSize, calcLineActualSize2, calcLinePlaneSize, splitRoofLinesToInnerLines, toGeoJSON } from '@/util/qpolygon-utils' import { QLine } from '@/components/fabric/QLine' import { getDegreeByChon } from '@/util/canvas-util' import Big from 'big.js' @@ -1283,6 +1283,13 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { // gap 을 시각화. innerLines 와 별개 collection. 기존 working code (processInBoth 등) 무영향. drawBaselineToRooflineHelpers(roof, canvas, changRoofLinePoints, roofLinePoints) + // [docs §9-3] 추녀 연장 후 처마변(roof.lines)을 split → 편집 가능한 innerLine(hip/roofLine)으로 등록. + // 단순 우진각: 추녀가 처마 코너(변 끝점)에 닿아 분할점 없음 → 처마 4변 통짜. 복합 형상: 중간 접점에서 분할. + splitRoofLinesToInnerLines(roof, roof.innerLines, canvas, textMode) + // 처마선 innerLine 이 외곽선을 전담하므로 roof 원본 외곽(stroke)은 숨긴다. + // (안 숨기면 처마선 삭제 시 roof 의 black stroke 가 노출됨 — usePolygon.js 의 roof 기본 stroke:'black') + roof.set({ stroke: 'transparent' }) + // 캔버스에 스켈레톤 상태 저장 if (!canvas.skeletonStates) { canvas.skeletonStates = {} From 53ecbcca0f7d6f5b0db47a98902dadc7c3dc86c4 Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Fri, 12 Jun 2026 14:45:12 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20=EC=B2=98=EB=A7=88=EC=84=A0=20split?= =?UTF-8?q?=20=EC=8B=9C=20roof=20=EC=9B=90=EB=B3=B8=20=EC=99=B8=EA=B3=BD?= =?UTF-8?q?=EC=84=A0=20=EC=88=A8=EA=B9=80=EC=9D=84=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - splitRoofLinesToInnerLines 가 처마변 innerLine화 후 roof 원본 외곽(stroke)을 transparent 로 숨기도록 통합 - 변별/용마루 양 경로 공통 적용 — 처마선 삭제 시 roof 기본 stroke:'black' 이 노출되던 문제 해결 - 용마루 호출부(skeletonBuilder)의 개별 roof.set 제거 (split 함수가 담당) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/util/qpolygon-utils.js | 7 ++++++- src/util/skeleton-utils.js | 3 --- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/util/qpolygon-utils.js b/src/util/qpolygon-utils.js index fb23d06c..03d72c6a 100644 --- a/src/util/qpolygon-utils.js +++ b/src/util/qpolygon-utils.js @@ -5443,7 +5443,8 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => { } /** - * roof.lines(처마 외곽선)를 innerLine 끝점 기준으로 split 해 편집 가능한 innerLine(hip/roofLine)으로 만든다. + * roof.lines(처마 외곽선)를 innerLine 끝점 기준으로 split 해 편집 가능한 innerLine(hip/roofLine)으로 만들고, + * 처마선 innerLine 이 외곽선을 전담하므로 roof 원본 외곽선(stroke)은 숨긴다(transparent). * 변별/용마루 경로 공용. innerLines 에 결과를 in-place push 하고 반환한다. * (용마루 경로는 추녀 연장 후 호출해야 종점이 처마변 위에 있어 분할점이 잡힌다 — docs §9) * @param {fabric.Object} roof - 대상 지붕 (roof.lines 사용) @@ -5521,6 +5522,10 @@ export const splitRoofLinesToInnerLines = (roof, innerLines, canvas, textMode) = } }) + // 처마선 innerLine 이 외곽선을 전담하므로 roof 원본 외곽(stroke)은 숨긴다. + // (안 숨기면 처마선 삭제 시 roof 의 기본 stroke:'black'(usePolygon.js) 이 노출됨) + roof.set({ stroke: 'transparent' }) + return innerLines } diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 66ea9afb..06ad38ff 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -1286,9 +1286,6 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { // [docs §9-3] 추녀 연장 후 처마변(roof.lines)을 split → 편집 가능한 innerLine(hip/roofLine)으로 등록. // 단순 우진각: 추녀가 처마 코너(변 끝점)에 닿아 분할점 없음 → 처마 4변 통짜. 복합 형상: 중간 접점에서 분할. splitRoofLinesToInnerLines(roof, roof.innerLines, canvas, textMode) - // 처마선 innerLine 이 외곽선을 전담하므로 roof 원본 외곽(stroke)은 숨긴다. - // (안 숨기면 처마선 삭제 시 roof 의 black stroke 가 노출됨 — usePolygon.js 의 roof 기본 stroke:'black') - roof.set({ stroke: 'transparent' }) // 캔버스에 스켈레톤 상태 저장 if (!canvas.skeletonStates) { From fd83fa3aa010d703ba7620ac11dd08f87e9fc67c Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Fri, 12 Jun 2026 14:57:38 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=EB=B0=95=EA=B3=B5(gable)=20?= =?UTF-8?q?=EC=A7=80=EB=B6=95=EB=8F=84=20=EC=B2=98=EB=A7=88=EC=84=A0=20?= =?UTF-8?q?=EC=99=B8=EA=B3=BD=20=EC=A0=84=EB=8B=B4=20=EC=8B=9C=20roof=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=20=EC=99=B8=EA=B3=BD=EC=84=A0=20=EC=88=A8?= =?UTF-8?q?=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - drawGableRoof 가 처마선 innerLine 생성(roof.innerLines.push) 후 roof 원본 외곽(stroke)을 transparent 로 숨김 - 박공은 split 공용 함수를 거치지 않으므로 drawGableRoof 내에서 직접 처리 - 처마선 삭제 시 roof 기본 stroke:'black' 이 노출되던 문제 해결 (변별/용마루와 동일 동작) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/util/qpolygon-utils.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/util/qpolygon-utils.js b/src/util/qpolygon-utils.js index 03d72c6a..40f7e695 100644 --- a/src/util/qpolygon-utils.js +++ b/src/util/qpolygon-utils.js @@ -1628,6 +1628,9 @@ export const drawGableRoof = (roofId, canvas, textMode) => { drawRoofPlane(backward) }) roof.innerLines.push(...ridgeLines, ...innerLines) + // 처마선 innerLine 이 외곽선을 전담하므로 roof 원본 외곽(stroke)은 숨긴다. + // (박공은 split 공용 함수를 안 거치므로 여기서 직접 처리 — usePolygon.js 의 roof 기본 stroke:'black') + roof.set({ stroke: 'transparent' }) canvas .getObjects() .filter((obj) => obj.name === 'check') From 0944aa3a8c8d645782dc2ccaca1c814cc1890344 Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Mon, 15 Jun 2026 13:09:56 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20ErrorBoundary=20fallback=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=20=EC=8B=9C=20errorInfo=20null=20=ED=81=AC=EB=9E=98?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - getDerivedStateFromError 가 errorInfo 를 채우지 않아 첫 fallback 렌더에서 this.state.errorInfo 가 null - componentStack 접근에 옵셔널 체이닝(?.) 추가 — componentDidCatch 의 setState 후 정상 표시 - ErrorBoundary 가 자기 자신을 크래시시켜 원본 에러를 가리던 이중 에러 해소 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/common/ErrorBoundary.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/common/ErrorBoundary.jsx b/src/components/common/ErrorBoundary.jsx index 677a67fa..dad497a0 100644 --- a/src/components/common/ErrorBoundary.jsx +++ b/src/components/common/ErrorBoundary.jsx @@ -40,7 +40,7 @@ class ErrorBoundary extends React.Component {

Timestamp: {new Date().toISOString()}

Error: {this.state.error && this.state.error.toString()}

Stack Trace:

-
{this.state.errorInfo.componentStack}
+
{this.state.errorInfo?.componentStack}