qcast-front/src/hooks/pdf-import/usePdfImport.js
sangwook yoo dbaebf9e2b feat(pdf-import): 외벽선 자동 확정 토글 동작 연결 — ON 확정·지붕형상 직행 / OFF 미확정 유지
- usePdfImport.applyOuterline 에 autoFix 옵션 추가 — outerLineFix 확정 여부를 토글에 따라 분기
- PlacementShapeSetting.handleSaveBtn 에서 토글값으로 메뉴 분기(ON→ROOF_SHAPE_SETTINGS / OFF→EXTERIOR_WALL_LINE); 모달은 MenuDepth01 이 currentMenu 변화로 자동 오픈하므로 직접 addPopup 제거
- fix: basicSettingSave 의 setMenuByRoofSize 를 skipSideEffects 가드 안으로 이동 — ON 인데도 async 저장(await post) 직후 메뉴가 EXTERIOR_WALL_LINE 로 덮어써져 외벽선 작성 팝업으로 되돌아가며 확정이 풀리던 문제 수정
- docs(claude): PDF 임포트 절에 자동 확정 분기·MenuDepth01 메뉴 연동·skipSideEffects 주의 반영

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:53:25 +09:00

262 lines
9.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useSetRecoilState, useRecoilValue } from 'recoil'
import { fabric } from 'fabric'
import { canvasState, canvasZoomState } from '@/store/canvasAtom'
import { outerLineFixState, outerLinePointsState } from '@/store/outerLineAtom'
import { outlineDisplaySelector } from '@/store/settingAtom'
import { POLYGON_TYPE } from '@/common/common'
import { useLine } from '@/hooks/useLine'
import { useSwal } from '@/hooks/useSwal'
import { useMessage } from '@/hooks/useMessage'
import { logger } from '@/util/logger'
import { snapNearAxisEdges, cleanSelfIntersectingPolygon } from '@/util/qpolygon-utils'
import * as turf from '@turf/turf'
const MM_PER_CANVAS_UNIT = 10
// useLine.addLine 이 length < 1(=10mm) 라인을 생성하지 않으므로 동일 임계값으로 사전 제거한다.
const MIN_CANVAS_UNIT_LENGTH = 1
const VIEWPORT_FIT_RATIO = 0.6 // 화면맞춤 시 캔버스 대비 도면 최대 비율
const SIMPLIFY_TOLERANCE = 0.5 // canvas unit ≈ 5mm. 직선 위 잉여 정점/미세 엣지 제거(보수적)
const SNAP_ANGLE_TOL_DEG = 2 // 거의 수평/수직 엣지를 정확히 축정렬로 스냅
const toCanvasUnits = (mmPoints) => mmPoints.map((p) => ({ x: p.x / MM_PER_CANVAS_UNIT, y: p.y / MM_PER_CANVAS_UNIT }))
const polygonArea = (points) => {
let area = 0
for (let i = 0; i < points.length; i++) {
const cur = points[i]
const next = points[(i + 1) % points.length]
area += cur.x * next.y - next.x * cur.y
}
return area
}
const ensureCounterClockwise = (points) => {
// canvas 좌표계 (y 가 아래로 증가) 에서 CCW 는 signed area 가 음수가 된다.
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
}
// 좌표에 축소 scale 을 곱하면 'QLine 길이×10 = 표시 mm' 불변식이 깨져 치수 라벨이 실측과 어긋난다.
// 좌표는 실측 그대로 평행이동만 하고, 화면맞춤은 fitViewport 의 zoom(viewportTransform)으로 분리 처리한다.
const centerOnCanvas = (points, canvas) => {
const xs = points.map((p) => p.x)
const ys = points.map((p) => p.y)
const minX = Math.min(...xs)
const minY = Math.min(...ys)
const width = Math.max(...xs) - minX
const height = Math.max(...ys) - minY
const cx = (canvas?.getWidth?.() ?? 1000) / 2
const cy = (canvas?.getHeight?.() ?? 1000) / 2
return points.map((p) => ({
x: cx + (p.x - (minX + width / 2)),
y: cy + (p.y - (minY + height / 2)),
}))
}
const simplifyRing = (points, tolerance) => {
if (points.length < 4) return points
try {
const coords = points.map((p) => [p.x, p.y])
coords.push([points[0].x, points[0].y])
const simplified = turf.simplify(turf.polygon([coords]), { tolerance, highQuality: true })
const ring = simplified.geometry.coordinates[0]
const out = ring.slice(0, -1).map((c) => ({ x: c[0], y: c[1] }))
return out.length >= 3 ? out : points
} catch (e) {
logger.warn('[usePdfImport] simplifyRing 실패', e?.message || e)
return points
}
}
// AI 추출 좌표의 결정적 정규화: 축정렬 스냅 → 과다정점 단순화 → 자기교차 복구.
// 순서 주의 — 스냅(정점 이동) 후 단순화(정점 제거)해야 축정렬이 보존된다.
const normalizeGeometry = (points) => {
let pts = snapNearAxisEdges(points, SNAP_ANGLE_TOL_DEG)
pts = simplifyRing(pts, SIMPLIFY_TOLERANCE)
pts = cleanSelfIntersectingPolygon(pts)
return pts.length >= 3 ? pts : points
}
export function usePdfImport() {
const canvas = useRecoilValue(canvasState)
const setOuterLinePoints = useSetRecoilState(outerLinePointsState)
const setOuterLineFix = useSetRecoilState(outerLineFixState)
const setCanvasZoom = useSetRecoilState(canvasZoomState)
const isOutlineDisplay = useRecoilValue(outlineDisplaySelector)
const { addLine } = useLine()
const { swalFire } = useSwal()
const { getMessage } = useMessage()
const hasExistingOuterLine = () => {
if (!canvas) return false
return canvas.getObjects().some((obj) => obj.name === 'outerLine' || obj.name === POLYGON_TYPE.WALL || obj.name === POLYGON_TYPE.ROOF)
}
const clearExistingCanvasState = () => {
if (!canvas) return
const removable = canvas.getObjects().filter((obj) => {
return (
obj.name === 'outerLine' ||
obj.name === 'outerLinePoint' ||
obj.name === 'startPoint' ||
obj.name === 'helpGuideLine' ||
obj.name === 'lengthText' ||
obj.name === 'pitchText' ||
obj.name === POLYGON_TYPE.WALL ||
obj.name === POLYGON_TYPE.ROOF
)
})
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]
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,
})
})
}
// skeletonBuilder 등 지붕 생성 로직이 정점 위치를 'outerLinePoint' circle 에서 읽는다 — 수동 플로우와 동일하게 생성
const drawOuterLinePoints = (points) => {
points.forEach((point) => {
canvas.add(
new fabric.Circle({
left: point.x,
top: point.y,
visible: false,
name: 'outerLinePoint',
}),
)
})
}
// 도면이 화면보다 크면 캔버스 중앙 고정 줌으로 화면맞춤한다. canvasZoomState 를 함께 동기화하면
// useCanvasEvent 의 setZoom(동일 zoom 값) 은 translation 을 보존하므로 중앙 정렬이 유지된다.
const fitViewport = (points) => {
const xs = points.map((p) => p.x)
const ys = points.map((p) => p.y)
const width = Math.max(...xs) - Math.min(...xs)
const height = Math.max(...ys) - Math.min(...ys)
const targetWidth = canvas.getWidth() * VIEWPORT_FIT_RATIO
const targetHeight = canvas.getHeight() * VIEWPORT_FIT_RATIO
let zoomPercent = 100
if (width > targetWidth || height > targetHeight) {
zoomPercent = Math.max(1, Math.floor(Math.min(targetWidth / width, targetHeight / height) * 100))
}
const zoom = zoomPercent / 100
const cx = canvas.getWidth() / 2
const cy = canvas.getHeight() / 2
canvas.setViewportTransform([zoom, 0, 0, zoom, cx * (1 - zoom), cy * (1 - zoom)])
setCanvasZoom(zoomPercent)
}
const applyOuterline = async (mmPoints, options = {}) => {
if (!canvas) {
logger.warn('[usePdfImport] canvas 가 준비되지 않았습니다.')
return false
}
const { unit = 'mm', autoFix = true } = options
if (unit !== 'mm') {
logger.warn('[usePdfImport] 지원하지 않는 단위:', unit)
swalFire({ text: getMessage('pdf.import.error.unsupported.unit'), type: 'alert' })
return false
}
if (!Array.isArray(mmPoints) || mmPoints.length < 3) {
swalFire({ text: getMessage('pdf.import.error.too.few.vertices'), type: 'alert' })
return false
}
let canvasPoints = toCanvasUnits(mmPoints)
canvasPoints = dedupShortEdges(canvasPoints)
if (canvasPoints.length < 3) {
swalFire({ text: getMessage('pdf.import.error.too.few.vertices'), type: 'alert' })
return false
}
canvasPoints = normalizeGeometry(canvasPoints)
canvasPoints = dedupShortEdges(canvasPoints) // 정규화 과정에서 생긴 미세 엣지 제거
if (canvasPoints.length < 3) {
swalFire({ text: getMessage('pdf.import.error.too.few.vertices'), type: 'alert' })
return false
}
canvasPoints = centerOnCanvas(canvasPoints, canvas)
canvasPoints = ensureCounterClockwise(canvasPoints)
const proceed = async () => {
clearExistingCanvasState()
const closedPoints = [...canvasPoints, canvasPoints[0]]
setOuterLinePoints(closedPoints)
drawOuterLines(closedPoints)
drawOuterLinePoints(closedPoints)
// 외벽선 자동 확정(autoFix) 옵션에 따라 확정 여부를 분기한다.
// - ON: 수동 확정(useOuterLineWall.handleFix) 직후와 동일하게 outerLineFix 를 켠다. 이 플래그가 없으면
// 지붕형상 설정(useRoofShapeSetting/useRoofShapePassivitySetting) 진입이 'wall.line.not.found' 로 차단된다.
// WALL/ROOF 폴리곤은 수동 플로우와 동일하게 지붕형상 설정 적용 시점에 생성된다.
// - OFF: 외벽선만 그려둔 미확정 상태로 두어, 사용자가 외벽선 그리기 단계에서 직접 확정하도록 한다.
canvas.outerLineFix = autoFix
setOuterLineFix(autoFix)
fitViewport(closedPoints)
canvas.renderAll()
}
if (hasExistingOuterLine()) {
return await new Promise((resolve) => {
swalFire({
text: getMessage('pdf.import.confirm.overwrite'),
type: 'confirm',
confirmFn: async () => {
await proceed()
resolve(true)
},
denyFn: () => resolve(false),
})
})
}
await proceed()
return true
}
return { applyOuterline, hasExistingOuterLine }
}