From c7757fabdafee69d81b4633095abf67510119095 Mon Sep 17 00:00:00 2001 From: sangwook yoo Date: Thu, 11 Jun 2026 10:05:09 +0900 Subject: [PATCH] =?UTF-8?q?fix(pdf-import):=20PDF=20=EC=99=B8=EA=B3=BD?= =?UTF-8?q?=EC=84=A0=EC=9D=84=20=EC=88=98=EB=8F=99=20=ED=99=95=EC=A0=95=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=99=80=20=EB=8F=99=EC=9D=BC=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EC=83=9D=EC=84=B1=20=E2=80=94=20=EC=A7=80=EB=B6=95?= =?UTF-8?q?=ED=98=95=EC=83=81=20=EC=84=A4=EC=A0=95=20=EC=B0=A8=EB=8B=A8=20?= =?UTF-8?q?=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - useLine.addLine 경유로 outerLine 생성 — attributes/planeSize/fontSize 부여(지붕형상 설정·처마/케라바 편집·재로드 복원에 필수) - canvas.outerLineFix + outerLineFixState 설정 — 미설정 시 지붕형상 설정 진입이 wall.line.not.found 로 즉시 차단되던 문제 해소 - skeletonBuilder 가 소비하는 outerLinePoint 정점 circle 생성(수동 플로우와 동일) - 조기 WALL 폴리곤 생성 제거 — 수동 플로우와 동일하게 지붕형상 설정 적용 시점에 WALL/ROOF 생성 - 짧은엣지 dedup 을 '직전 유지 정점' 기준으로 수정하고 닫힘 엣지(Gemini 닫힌 링 반환 포함)까지 검사, 임계값을 addLine 의 1unit 과 일치 - PDF 적용 후 외벽선 그리기 메뉴(진입 시 재드로잉 강등) 대신 지붕형상 설정 메뉴로 직행 Co-Authored-By: Claude Fable 5 --- .../placementShape/PlacementShapeSetting.jsx | 7 +- src/hooks/pdf-import/usePdfImport.js | 84 ++++++++++++++----- 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx index ab628a98..3022c5a1 100644 --- a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx +++ b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx @@ -439,7 +439,12 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla const hasRoofs = canvas.getObjects().some((obj) => obj.name === POLYGON_TYPE.ROOF) // roofSizeSet에 따라 메뉴 설정 - if (currentRoof?.roofSizeSet === '2') { + if (inputMode === INPUT_MODE.PDF) { + // PDF 임포트는 외곽선이 이미 확정(outerLineFix) 상태 — 외벽선 그리기 메뉴로 보내면 + // useOuterLineWall 이 라인을 재드로잉 모드로 강등시키므로 지붕형상 설정으로 직행한다. + setSelectedMenu('outline') + setCurrentMenu(MENU.ROOF_COVERING.ROOF_SHAPE_SETTINGS) + } else if (currentRoof?.roofSizeSet === '2') { setSelectedMenu('surface') setCurrentMenu(MENU.BATCH_CANVAS.BATCH_DRAWING) } else if (currentRoof?.roofSizeSet === '1') { diff --git a/src/hooks/pdf-import/usePdfImport.js b/src/hooks/pdf-import/usePdfImport.js index 5fc67f6f..e9ecb484 100644 --- a/src/hooks/pdf-import/usePdfImport.js +++ b/src/hooks/pdf-import/usePdfImport.js @@ -1,9 +1,10 @@ import { useSetRecoilState, useRecoilValue } from 'recoil' +import { fabric } from 'fabric' import { canvasState } from '@/store/canvasAtom' -import { outerLinePointsState } from '@/store/outerLineAtom' +import { outerLineFixState, outerLinePointsState } from '@/store/outerLineAtom' +import { outlineDisplaySelector } from '@/store/settingAtom' import { POLYGON_TYPE } from '@/common/common' -import { QLine } from '@/components/fabric/QLine' -import { usePolygon } from '@/hooks/usePolygon' +import { useLine } from '@/hooks/useLine' import { useSwal } from '@/hooks/useSwal' import { useMessage } from '@/hooks/useMessage' import { logger } from '@/util/logger' @@ -12,7 +13,8 @@ import * as turf from '@turf/turf' const MM_PER_CANVAS_UNIT = 10 const MAX_DIM = 1500 -const MIN_CANVAS_UNIT_LENGTH = 0.5 +// useLine.addLine 이 length < 1(=10mm) 라인을 생성하지 않으므로 동일 임계값으로 사전 제거한다. +const MIN_CANVAS_UNIT_LENGTH = 1 const SIMPLIFY_TOLERANCE = 0.5 // canvas unit ≈ 5mm. 직선 위 잉여 정점/미세 엣지 제거(보수적) const SNAP_ANGLE_TOL_DEG = 2 // 거의 수평/수직 엣지를 정확히 축정렬로 스냅 @@ -33,6 +35,21 @@ const ensureCounterClockwise = (points) => { return polygonArea(points) > 0 ? [...points].reverse() : points } +// 직전 '유지된' 정점과의 거리로 짧은 엣지를 제거한다. 닫힘 엣지(마지막→첫)도 동일 기준으로 검사 — +// Gemini 가 닫힌 링(첫점=끝점 중복)을 반환하는 경우가 여기서 함께 정리된다. +const dedupShortEdges = (points) => { + const out = [] + points.forEach((p) => { + const prev = out[out.length - 1] + if (prev && Math.hypot(p.x - prev.x, p.y - prev.y) < MIN_CANVAS_UNIT_LENGTH) return + out.push(p) + }) + while (out.length >= 3 && Math.hypot(out[out.length - 1].x - out[0].x, out[out.length - 1].y - out[0].y) < MIN_CANVAS_UNIT_LENGTH) { + out.pop() + } + return out +} + const fitAndCenter = (points, canvas) => { const xs = points.map((p) => p.x) const ys = points.map((p) => p.y) @@ -88,7 +105,9 @@ const normalizeGeometry = (points) => { export function usePdfImport() { const canvas = useRecoilValue(canvasState) const setOuterLinePoints = useSetRecoilState(outerLinePointsState) - const { addPolygonByLines } = usePolygon() + const setOuterLineFix = useSetRecoilState(outerLineFixState) + const isOutlineDisplay = useRecoilValue(outlineDisplaySelector) + const { addLine } = useLine() const { swalFire } = useSwal() const { getMessage } = useMessage() @@ -114,18 +133,38 @@ export function usePdfImport() { removable.forEach((obj) => canvas.remove(obj)) } + // 수동 그리기(useOuterLineWall.drawLine)와 동일하게 useLine.addLine 경유로 생성한다 — + // attributes/planeSize/fontSize 가 없으면 지붕형상 설정·처마/케라바 편집·재로드 복원이 깨진다. const drawOuterLines = (points) => { points.forEach((point, idx) => { if (idx === 0) return const prev = points[idx - 1] - const line = new QLine([prev.x, prev.y, point.x, point.y], { - stroke: '#000000', + addLine([prev.x, prev.y, point.x, point.y], { + stroke: 'black', strokeWidth: 3, idx, selectable: true, name: 'outerLine', + x1: prev.x, + y1: prev.y, + x2: point.x, + y2: point.y, + visible: isOutlineDisplay, }) - canvas.add(line) + }) + } + + // skeletonBuilder 등 지붕 생성 로직이 정점 위치를 'outerLinePoint' circle 에서 읽는다 — 수동 플로우와 동일하게 생성 + const drawOuterLinePoints = (points) => { + points.forEach((point) => { + canvas.add( + new fabric.Circle({ + left: point.x, + top: point.y, + visible: false, + name: 'outerLinePoint', + }), + ) }) } @@ -148,13 +187,7 @@ export function usePdfImport() { } let canvasPoints = toCanvasUnits(mmPoints) - canvasPoints = canvasPoints.filter((p, idx, arr) => { - if (idx === 0) return true - const prev = arr[idx - 1] - const dx = p.x - prev.x - const dy = p.y - prev.y - return Math.hypot(dx, dy) >= MIN_CANVAS_UNIT_LENGTH - }) + canvasPoints = dedupShortEdges(canvasPoints) if (canvasPoints.length < 3) { swalFire({ text: getMessage('pdf.import.error.too.few.vertices'), type: 'alert' }) @@ -162,6 +195,13 @@ export function usePdfImport() { } canvasPoints = normalizeGeometry(canvasPoints) + canvasPoints = dedupShortEdges(canvasPoints) // 정규화 과정에서 생긴 미세 엣지 제거 + + if (canvasPoints.length < 3) { + swalFire({ text: getMessage('pdf.import.error.too.few.vertices'), type: 'alert' }) + return false + } + canvasPoints = fitAndCenter(canvasPoints, canvas) canvasPoints = ensureCounterClockwise(canvasPoints) @@ -170,15 +210,13 @@ export function usePdfImport() { const closedPoints = [...canvasPoints, canvasPoints[0]] setOuterLinePoints(closedPoints) drawOuterLines(closedPoints) + drawOuterLinePoints(closedPoints) - try { - const lines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') - if (lines.length >= 3) { - addPolygonByLines(lines, { name: POLYGON_TYPE.WALL, fill: 'transparent', stroke: 'black' }) - } - } catch (e) { - logger.warn('[usePdfImport] wall polygon 생성 실패', e?.message || e) - } + // 수동 확정(useOuterLineWall.handleFix) 직후와 동일한 상태로 만든다 — 이 플래그가 없으면 + // 지붕형상 설정(useRoofShapeSetting/useRoofShapePassivitySetting) 진입이 'wall.line.not.found' 로 차단된다. + // WALL/ROOF 폴리곤은 수동 플로우와 동일하게 지붕형상 설정 적용 시점에 생성된다. + canvas.outerLineFix = true + setOuterLineFix(true) canvas.renderAll() }