Compare commits
6 Commits
ef55c819d5
...
f46dc788cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f46dc788cc | ||
|
|
8768182fa8 | ||
|
|
08ef577260 | ||
|
|
7b15b76054 | ||
|
|
29fd4cbd59 | ||
|
|
c7757fabda |
@ -56,6 +56,8 @@ A modal under `components/floor-plan/modal/<feature>` pairs with a hook under `h
|
|||||||
|
|
||||||
**Plan persistence** (serialize/restore) is in `src/lib/canvas.js` and `src/util/canvas-util.js`. Recent commits (`a5e794b3`, `2aef32e0`) added a three-layer guard (entry / cache / save) and `SizeSetting` input guards against 0-area apertures/shadows/dormers — when touching plan load/save or size inputs, do not weaken or remove these guards without understanding the failure mode (`InvalidStateError` from corrupted plans).
|
**Plan persistence** (serialize/restore) is in `src/lib/canvas.js` and `src/util/canvas-util.js`. Recent commits (`a5e794b3`, `2aef32e0`) added a three-layer guard (entry / cache / save) and `SizeSetting` input guards against 0-area apertures/shadows/dormers — when touching plan load/save or size inputs, do not weaken or remove these guards without understanding the failure mode (`InvalidStateError` from corrupted plans).
|
||||||
|
|
||||||
|
**PDF 도면 임포트** — 배치면 초기설정 모달의 PDF 입력 모드 → `src/app/api/gemini/floor-plan/route.js`(Gemini 분석, 세션 인증 + 사용자별 분당 5회 레이트리밋) → `src/hooks/pdf-import/usePdfImport.js`. 임포트는 수동 외벽선 확정(`useOuterLineWall.handleFix`) 직후와 동일한 상태를 만들어야 한다: `useLine.addLine` 경유 outerLine 생성(attributes/planeSize/fontSize), `outerLinePoint` 정점 circle, `canvas.outerLineFix = true` + `outerLineFixState`, 이후 지붕형상 설정 메뉴 직행. **좌표에 축소 scale 을 곱하지 말 것** — 'QLine 길이×10 = 표시 mm' 불변식이 깨진다. 화면맞춤은 `viewportTransform` 줌 + `canvasZoomState` 동기화로만 처리한다.
|
||||||
|
|
||||||
### State — Recoil, split by domain
|
### State — Recoil, split by domain
|
||||||
|
|
||||||
Atoms live under `src/store/` and are split by domain (`canvasAtom`, `roofAtom`, `estimateAtom`, `modalAtom`, `menuAtom`, `settingAtom`, …). When adding state, find the existing domain atom rather than introducing a new global. `RecoilWrapper.js` mounts the root; `QcastProvider.js` composes the rest of the global providers (session, global data, global loading) for the App Router root layout.
|
Atoms live under `src/store/` and are split by domain (`canvasAtom`, `roofAtom`, `estimateAtom`, `modalAtom`, `menuAtom`, `settingAtom`, …). When adding state, find the existing domain atom rather than introducing a new global. `RecoilWrapper.js` mounts the root; `QcastProvider.js` composes the rest of the global providers (session, global data, global loading) for the App Router root layout.
|
||||||
|
|||||||
@ -1,13 +1,33 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { getIronSession } from 'iron-session'
|
||||||
import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai'
|
import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai'
|
||||||
import { GoogleAIFileManager, FileState } from '@google/generative-ai/server'
|
import { GoogleAIFileManager, FileState } from '@google/generative-ai/server'
|
||||||
|
|
||||||
|
import { sessionOptions } from '@/lib/session'
|
||||||
import { logger } from '@/util/logger'
|
import { logger } from '@/util/logger'
|
||||||
|
|
||||||
const MAX_FILE_BYTES = 20 * 1024 * 1024 // 20MB
|
const MAX_FILE_BYTES = 20 * 1024 * 1024 // 20MB
|
||||||
const MAX_VERTICES = 500
|
const MAX_VERTICES = 500
|
||||||
const DEFAULT_MODEL = 'gemini-3.1-pro-preview'
|
const DEFAULT_MODEL = 'gemini-3.1-pro-preview'
|
||||||
|
|
||||||
|
// Gemini 호출은 건당 과금이므로 사용자별 호출 빈도를 제한한다(프로세스 단위 in-memory).
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 60 * 1000
|
||||||
|
const RATE_LIMIT_MAX_REQUESTS = 5
|
||||||
|
const rateLimitMap = new Map()
|
||||||
|
|
||||||
|
const isRateLimited = (key) => {
|
||||||
|
const now = Date.now()
|
||||||
|
const recent = (rateLimitMap.get(key) || []).filter((t) => now - t < RATE_LIMIT_WINDOW_MS)
|
||||||
|
if (recent.length >= RATE_LIMIT_MAX_REQUESTS) {
|
||||||
|
rateLimitMap.set(key, recent)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
recent.push(now)
|
||||||
|
rateLimitMap.set(key, recent)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const FLOOR_PLAN_SCHEMA = {
|
const FLOOR_PLAN_SCHEMA = {
|
||||||
type: SchemaType.OBJECT,
|
type: SchemaType.OBJECT,
|
||||||
properties: {
|
properties: {
|
||||||
@ -133,6 +153,18 @@ export async function POST(req) {
|
|||||||
const apiKey = process.env.GEMINI_API_KEY
|
const apiKey = process.env.GEMINI_API_KEY
|
||||||
const modelName = process.env.GEMINI_MODEL || DEFAULT_MODEL
|
const modelName = process.env.GEMINI_MODEL || DEFAULT_MODEL
|
||||||
|
|
||||||
|
// 페이지 인증(layout redirect)은 API 라우트에 적용되지 않으므로 세션을 직접 검사한다 — 익명 Gemini 과금 차단.
|
||||||
|
const session = await getIronSession(cookies(), sessionOptions)
|
||||||
|
if (!session?.isLoggedIn) {
|
||||||
|
return NextResponse.json({ error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, { status: 401 })
|
||||||
|
}
|
||||||
|
if (isRateLimited(session.userId || 'unknown')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: 'RATE_LIMITED', message: '분석 요청이 너무 잦습니다. 잠시 후 다시 시도해주세요.' } },
|
||||||
|
{ status: 429 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return NextResponse.json({ error: { code: 'NO_API_KEY', message: 'GEMINI_API_KEY 가 설정되지 않았습니다.' } }, { status: 500 })
|
return NextResponse.json({ error: { code: 'NO_API_KEY', message: 'GEMINI_API_KEY 가 설정되지 않았습니다.' } }, { status: 500 })
|
||||||
}
|
}
|
||||||
@ -163,6 +195,7 @@ export async function POST(req) {
|
|||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer())
|
const buffer = Buffer.from(await file.arrayBuffer())
|
||||||
|
|
||||||
|
const uploadStarted = Date.now()
|
||||||
const uploadResult = await fileManager.uploadFile(buffer, {
|
const uploadResult = await fileManager.uploadFile(buffer, {
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
displayName: file.name || 'floor-plan.pdf',
|
displayName: file.name || 'floor-plan.pdf',
|
||||||
@ -170,6 +203,7 @@ export async function POST(req) {
|
|||||||
uploadedFileName = uploadResult.file.name
|
uploadedFileName = uploadResult.file.name
|
||||||
|
|
||||||
await waitForFileActive(fileManager, uploadedFileName)
|
await waitForFileActive(fileManager, uploadedFileName)
|
||||||
|
const fileReadyMs = Date.now() - uploadStarted
|
||||||
|
|
||||||
const genAI = new GoogleGenerativeAI(apiKey)
|
const genAI = new GoogleGenerativeAI(apiKey)
|
||||||
const model = genAI.getGenerativeModel({
|
const model = genAI.getGenerativeModel({
|
||||||
@ -185,6 +219,7 @@ export async function POST(req) {
|
|||||||
|
|
||||||
const prompt = buildPrompt({ pageMode, facadePages, floorPages })
|
const prompt = buildPrompt({ pageMode, facadePages, floorPages })
|
||||||
|
|
||||||
|
const generateStarted = Date.now()
|
||||||
const result = await model.generateContent([
|
const result = await model.generateContent([
|
||||||
{
|
{
|
||||||
fileData: {
|
fileData: {
|
||||||
@ -196,6 +231,7 @@ export async function POST(req) {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const raw = result.response.text()
|
const raw = result.response.text()
|
||||||
|
logger.debug('[gemini/floor-plan] timing(ms)', { fileReady: fileReadyMs, generate: Date.now() - generateStarted })
|
||||||
logger.debug('[gemini/floor-plan] raw response', raw)
|
logger.debug('[gemini/floor-plan] raw response', raw)
|
||||||
logger.debug('[gemini/floor-plan] usageMetadata', result.response.usageMetadata)
|
logger.debug('[gemini/floor-plan] usageMetadata', result.response.usageMetadata)
|
||||||
|
|
||||||
|
|||||||
@ -40,7 +40,9 @@ const PDF_MAX_FILE_BYTES = 20 * 1024 * 1024
|
|||||||
|
|
||||||
// 분석 요청 타임아웃(ms). 서버 라우트는 파일 처리(최대 60s)+generateContent 로 길어질 수 있어
|
// 분석 요청 타임아웃(ms). 서버 라우트는 파일 처리(최대 60s)+generateContent 로 길어질 수 있어
|
||||||
// 클라이언트에서 backstop 을 둔다. 이 시간을 넘기면 abort 하여 스피너가 영구 멈추지 않도록 한다.
|
// 클라이언트에서 backstop 을 둔다. 이 시간을 넘기면 abort 하여 스피너가 영구 멈추지 않도록 한다.
|
||||||
const PDF_ANALYZE_TIMEOUT_MS = 120 * 1000
|
// gemini-3.1-pro-preview 는 thinking 비활성화가 불가능 + MEDIA_RESOLUTION_HIGH PDF 분석이라
|
||||||
|
// 정상 응답도 2분을 넘길 수 있다(실측 120s). 서버 완료 직전에 클라이언트가 끊지 않도록 여유를 둔다.
|
||||||
|
const PDF_ANALYZE_TIMEOUT_MS = 300 * 1000
|
||||||
|
|
||||||
const PDF_PAGE_MODE = {
|
const PDF_PAGE_MODE = {
|
||||||
ALL: 'all',
|
ALL: 'all',
|
||||||
@ -107,6 +109,16 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
|
|||||||
const [pdfAnalyzing, setPdfAnalyzing] = useState(false)
|
const [pdfAnalyzing, setPdfAnalyzing] = useState(false)
|
||||||
const { setIsGlobalLoading } = useContext(QcastContext)
|
const { setIsGlobalLoading } = useContext(QcastContext)
|
||||||
const pdfInputRef = useRef(null)
|
const pdfInputRef = useRef(null)
|
||||||
|
// 진행 중인 PDF 분석 요청 추적 — 언마운트(라우팅 이탈) 시 abort 하고, 늦게 도착한 결과가
|
||||||
|
// 이미 비워진 canvas 에 적용되거나 이전 플랜으로 저장되는 것을 차단한다.
|
||||||
|
const pdfRunRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
pdfRunRef.current?.controller?.abort()
|
||||||
|
pdfRunRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
/**
|
/**
|
||||||
* 치수 입력방법(복시도입력/실측값입력/도면 파일 업로드)
|
* 치수 입력방법(복시도입력/실측값입력/도면 파일 업로드)
|
||||||
*/
|
*/
|
||||||
@ -165,6 +177,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
|
|||||||
setPdfAnalyzing(true)
|
setPdfAnalyzing(true)
|
||||||
setIsGlobalLoading(true)
|
setIsGlobalLoading(true)
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
pdfRunRef.current = { controller }
|
||||||
const timeoutId = setTimeout(() => controller.abort(), PDF_ANALYZE_TIMEOUT_MS)
|
const timeoutId = setTimeout(() => controller.abort(), PDF_ANALYZE_TIMEOUT_MS)
|
||||||
|
|
||||||
let response = null
|
let response = null
|
||||||
@ -188,6 +201,11 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
|
|||||||
setIsGlobalLoading(false)
|
setIsGlobalLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 언마운트로 abort 됐거나 다른 실행으로 대체된 경우 — 늦은 결과를 적용하거나 알림을 띄우지 않는다.
|
||||||
|
if (pdfRunRef.current?.controller !== controller) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// alert/confirm 은 스피너 해제 후 표시한다(스피너 z-index 가 alert 보다 높아 가려지는 것 방지).
|
// alert/confirm 은 스피너 해제 후 표시한다(스피너 z-index 가 alert 보다 높아 가려지는 것 방지).
|
||||||
if (errorKey) {
|
if (errorKey) {
|
||||||
swalFire({ text: getMessage(errorKey), type: 'alert' })
|
swalFire({ text: getMessage(errorKey), type: 'alert' })
|
||||||
@ -216,6 +234,11 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
|
|||||||
if (!proceed) return false
|
if (!proceed) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 저신뢰 confirm 대기 중 언마운트된 경우까지 커버하는 최종 가드
|
||||||
|
if (pdfRunRef.current?.controller !== controller) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const applied = await applyOuterline(payload.outerline, { unit: payload.unit || 'mm' })
|
const applied = await applyOuterline(payload.outerline, { unit: payload.unit || 'mm' })
|
||||||
if (!applied) {
|
if (!applied) {
|
||||||
swalFire({ text: getMessage('pdf.import.error.apply'), type: 'alert' })
|
swalFire({ text: getMessage('pdf.import.error.apply'), type: 'alert' })
|
||||||
@ -439,7 +462,12 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
|
|||||||
const hasRoofs = canvas.getObjects().some((obj) => obj.name === POLYGON_TYPE.ROOF)
|
const hasRoofs = canvas.getObjects().some((obj) => obj.name === POLYGON_TYPE.ROOF)
|
||||||
|
|
||||||
// roofSizeSet에 따라 메뉴 설정
|
// 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')
|
setSelectedMenu('surface')
|
||||||
setCurrentMenu(MENU.BATCH_CANVAS.BATCH_DRAWING)
|
setCurrentMenu(MENU.BATCH_CANVAS.BATCH_DRAWING)
|
||||||
} else if (currentRoof?.roofSizeSet === '1') {
|
} else if (currentRoof?.roofSizeSet === '1') {
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { useSetRecoilState, useRecoilValue } from 'recoil'
|
import { useSetRecoilState, useRecoilValue } from 'recoil'
|
||||||
import { canvasState } from '@/store/canvasAtom'
|
import { fabric } from 'fabric'
|
||||||
import { outerLinePointsState } from '@/store/outerLineAtom'
|
import { canvasState, canvasZoomState } from '@/store/canvasAtom'
|
||||||
|
import { outerLineFixState, outerLinePointsState } from '@/store/outerLineAtom'
|
||||||
|
import { outlineDisplaySelector } from '@/store/settingAtom'
|
||||||
import { POLYGON_TYPE } from '@/common/common'
|
import { POLYGON_TYPE } from '@/common/common'
|
||||||
import { QLine } from '@/components/fabric/QLine'
|
import { useLine } from '@/hooks/useLine'
|
||||||
import { usePolygon } from '@/hooks/usePolygon'
|
|
||||||
import { useSwal } from '@/hooks/useSwal'
|
import { useSwal } from '@/hooks/useSwal'
|
||||||
import { useMessage } from '@/hooks/useMessage'
|
import { useMessage } from '@/hooks/useMessage'
|
||||||
import { logger } from '@/util/logger'
|
import { logger } from '@/util/logger'
|
||||||
@ -11,8 +12,9 @@ import { snapNearAxisEdges, cleanSelfIntersectingPolygon } from '@/util/qpolygon
|
|||||||
import * as turf from '@turf/turf'
|
import * as turf from '@turf/turf'
|
||||||
|
|
||||||
const MM_PER_CANVAS_UNIT = 10
|
const MM_PER_CANVAS_UNIT = 10
|
||||||
const MAX_DIM = 1500
|
// useLine.addLine 이 length < 1(=10mm) 라인을 생성하지 않으므로 동일 임계값으로 사전 제거한다.
|
||||||
const MIN_CANVAS_UNIT_LENGTH = 0.5
|
const MIN_CANVAS_UNIT_LENGTH = 1
|
||||||
|
const VIEWPORT_FIT_RATIO = 0.6 // 화면맞춤 시 캔버스 대비 도면 최대 비율
|
||||||
const SIMPLIFY_TOLERANCE = 0.5 // canvas unit ≈ 5mm. 직선 위 잉여 정점/미세 엣지 제거(보수적)
|
const SIMPLIFY_TOLERANCE = 0.5 // canvas unit ≈ 5mm. 직선 위 잉여 정점/미세 엣지 제거(보수적)
|
||||||
const SNAP_ANGLE_TOL_DEG = 2 // 거의 수평/수직 엣지를 정확히 축정렬로 스냅
|
const SNAP_ANGLE_TOL_DEG = 2 // 거의 수평/수직 엣지를 정확히 축정렬로 스냅
|
||||||
|
|
||||||
@ -33,31 +35,37 @@ const ensureCounterClockwise = (points) => {
|
|||||||
return polygonArea(points) > 0 ? [...points].reverse() : points
|
return polygonArea(points) > 0 ? [...points].reverse() : points
|
||||||
}
|
}
|
||||||
|
|
||||||
const fitAndCenter = (points, canvas) => {
|
// 직전 '유지된' 정점과의 거리로 짧은 엣지를 제거한다. 닫힘 엣지(마지막→첫)도 동일 기준으로 검사 —
|
||||||
|
// 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 xs = points.map((p) => p.x)
|
||||||
const ys = points.map((p) => p.y)
|
const ys = points.map((p) => p.y)
|
||||||
const minX = Math.min(...xs)
|
const minX = Math.min(...xs)
|
||||||
const minY = Math.min(...ys)
|
const minY = Math.min(...ys)
|
||||||
const maxX = Math.max(...xs)
|
const width = Math.max(...xs) - minX
|
||||||
const maxY = Math.max(...ys)
|
const height = Math.max(...ys) - minY
|
||||||
const width = maxX - minX
|
|
||||||
const height = maxY - minY
|
|
||||||
|
|
||||||
const targetWidth = (canvas?.getWidth?.() ?? 1000) * 0.6
|
|
||||||
const targetHeight = (canvas?.getHeight?.() ?? 1000) * 0.6
|
|
||||||
const maxAllowed = Math.min(MAX_DIM, Math.max(targetWidth, targetHeight))
|
|
||||||
|
|
||||||
let scale = 1
|
|
||||||
if (width > maxAllowed || height > maxAllowed) {
|
|
||||||
scale = Math.min(maxAllowed / width, maxAllowed / height)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cx = (canvas?.getWidth?.() ?? 1000) / 2
|
const cx = (canvas?.getWidth?.() ?? 1000) / 2
|
||||||
const cy = (canvas?.getHeight?.() ?? 1000) / 2
|
const cy = (canvas?.getHeight?.() ?? 1000) / 2
|
||||||
|
|
||||||
return points.map((p) => ({
|
return points.map((p) => ({
|
||||||
x: cx + (p.x - (minX + width / 2)) * scale,
|
x: cx + (p.x - (minX + width / 2)),
|
||||||
y: cy + (p.y - (minY + height / 2)) * scale,
|
y: cy + (p.y - (minY + height / 2)),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,7 +96,10 @@ const normalizeGeometry = (points) => {
|
|||||||
export function usePdfImport() {
|
export function usePdfImport() {
|
||||||
const canvas = useRecoilValue(canvasState)
|
const canvas = useRecoilValue(canvasState)
|
||||||
const setOuterLinePoints = useSetRecoilState(outerLinePointsState)
|
const setOuterLinePoints = useSetRecoilState(outerLinePointsState)
|
||||||
const { addPolygonByLines } = usePolygon()
|
const setOuterLineFix = useSetRecoilState(outerLineFixState)
|
||||||
|
const setCanvasZoom = useSetRecoilState(canvasZoomState)
|
||||||
|
const isOutlineDisplay = useRecoilValue(outlineDisplaySelector)
|
||||||
|
const { addLine } = useLine()
|
||||||
const { swalFire } = useSwal()
|
const { swalFire } = useSwal()
|
||||||
const { getMessage } = useMessage()
|
const { getMessage } = useMessage()
|
||||||
|
|
||||||
@ -114,21 +125,64 @@ export function usePdfImport() {
|
|||||||
removable.forEach((obj) => canvas.remove(obj))
|
removable.forEach((obj) => canvas.remove(obj))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 수동 그리기(useOuterLineWall.drawLine)와 동일하게 useLine.addLine 경유로 생성한다 —
|
||||||
|
// attributes/planeSize/fontSize 가 없으면 지붕형상 설정·처마/케라바 편집·재로드 복원이 깨진다.
|
||||||
const drawOuterLines = (points) => {
|
const drawOuterLines = (points) => {
|
||||||
points.forEach((point, idx) => {
|
points.forEach((point, idx) => {
|
||||||
if (idx === 0) return
|
if (idx === 0) return
|
||||||
const prev = points[idx - 1]
|
const prev = points[idx - 1]
|
||||||
const line = new QLine([prev.x, prev.y, point.x, point.y], {
|
addLine([prev.x, prev.y, point.x, point.y], {
|
||||||
stroke: '#000000',
|
stroke: 'black',
|
||||||
strokeWidth: 3,
|
strokeWidth: 3,
|
||||||
idx,
|
idx,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
name: 'outerLine',
|
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',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 도면이 화면보다 크면 캔버스 중앙 고정 줌으로 화면맞춤한다. 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 = {}) => {
|
const applyOuterline = async (mmPoints, options = {}) => {
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
logger.warn('[usePdfImport] canvas 가 준비되지 않았습니다.')
|
logger.warn('[usePdfImport] canvas 가 준비되지 않았습니다.')
|
||||||
@ -148,13 +202,7 @@ export function usePdfImport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let canvasPoints = toCanvasUnits(mmPoints)
|
let canvasPoints = toCanvasUnits(mmPoints)
|
||||||
canvasPoints = canvasPoints.filter((p, idx, arr) => {
|
canvasPoints = dedupShortEdges(canvasPoints)
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
if (canvasPoints.length < 3) {
|
if (canvasPoints.length < 3) {
|
||||||
swalFire({ text: getMessage('pdf.import.error.too.few.vertices'), type: 'alert' })
|
swalFire({ text: getMessage('pdf.import.error.too.few.vertices'), type: 'alert' })
|
||||||
@ -162,7 +210,14 @@ export function usePdfImport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
canvasPoints = normalizeGeometry(canvasPoints)
|
canvasPoints = normalizeGeometry(canvasPoints)
|
||||||
canvasPoints = fitAndCenter(canvasPoints, canvas)
|
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)
|
canvasPoints = ensureCounterClockwise(canvasPoints)
|
||||||
|
|
||||||
const proceed = async () => {
|
const proceed = async () => {
|
||||||
@ -170,16 +225,15 @@ export function usePdfImport() {
|
|||||||
const closedPoints = [...canvasPoints, canvasPoints[0]]
|
const closedPoints = [...canvasPoints, canvasPoints[0]]
|
||||||
setOuterLinePoints(closedPoints)
|
setOuterLinePoints(closedPoints)
|
||||||
drawOuterLines(closedPoints)
|
drawOuterLines(closedPoints)
|
||||||
|
drawOuterLinePoints(closedPoints)
|
||||||
|
|
||||||
try {
|
// 수동 확정(useOuterLineWall.handleFix) 직후와 동일한 상태로 만든다 — 이 플래그가 없으면
|
||||||
const lines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
|
// 지붕형상 설정(useRoofShapeSetting/useRoofShapePassivitySetting) 진입이 'wall.line.not.found' 로 차단된다.
|
||||||
if (lines.length >= 3) {
|
// WALL/ROOF 폴리곤은 수동 플로우와 동일하게 지붕형상 설정 적용 시점에 생성된다.
|
||||||
addPolygonByLines(lines, { name: POLYGON_TYPE.WALL, fill: 'transparent', stroke: 'black' })
|
canvas.outerLineFix = true
|
||||||
}
|
setOuterLineFix(true)
|
||||||
} catch (e) {
|
|
||||||
logger.warn('[usePdfImport] wall polygon 생성 실패', e?.message || e)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
fitViewport(closedPoints)
|
||||||
canvas.renderAll()
|
canvas.renderAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user