- 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>
262 lines
9.8 KiB
JavaScript
262 lines
9.8 KiB
JavaScript
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 }
|
||
}
|