From 26b583e2f5712cc3a2efeb98d6b35828e1f055b2 Mon Sep 17 00:00:00 2001 From: ysCha Date: Fri, 24 Apr 2026 20:02:39 +0900 Subject: [PATCH] =?UTF-8?q?=EB=9D=BC=EC=9D=B8=EC=98=A4=EB=B2=84=ED=95=98?= =?UTF-8?q?=EB=A9=B4=20=ED=8F=89=ED=96=89=EB=9D=BC=EC=9D=B8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/fabric/QPolygon.js | 45 +++++--- src/hooks/roofcover/useMovementSetting.js | 59 ++++++++++ src/util/skeleton-utils.js | 130 +++++++++++++++++++--- 3 files changed, 204 insertions(+), 30 deletions(-) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index 8e898d37..c3b330c2 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -6,7 +6,7 @@ import { calculateAngle, drawGableRoof, drawRoofByAttribute, drawShedRoof, toGeo import * as turf from '@turf/turf' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import Big from 'big.js' -import { drawSkeletonRidgeRoof, verifyMoveBoundary } from '@/util/skeleton-utils' +import { drawSkeletonRidgeRoof, drawSkeletonRidgeRoofFromBaseLines, verifyMoveBoundary } from '@/util/skeleton-utils' export const QPolygon = fabric.util.createClass(fabric.Polygon, { type: 'QPolygon', @@ -380,21 +380,23 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { /** * 보조선 그리기 * @param settingModalFirstOptions + * @param forceRedraw - true 면 verifyMoveBoundary 'crossed' 가드를 우회하고 강제로 재빌드. + * (예: 오버 이동 상태에서 wall.baseLines/roof.lines 는 그대로 두고 SK 만 재생성하고 싶을 때) */ - drawHelpLine(settingModalFirstOptions) { - // [보호 가드] 이번 이동이 골짜기 경계를 넘어가는 과한 이동이면 - // 아예 초기화/재빌드 자체를 수행하지 않고 현재 상태 그대로 유지한다. - // ※ 기존 drawHelpLine 로직은 건드리지 않고, 진입부에 가드 한 블록만 추가. - // ※ 최초 그리기·offset 변경 등 moveUpDown=0 / moveFlowLine=0 케이스는 - // verifyMoveBoundary 가 'ok' 를 돌려주므로 영향 없음. + drawHelpLine(settingModalFirstOptions, forceRedraw = false) { + // [정책] 오버 이동(verifyMoveBoundary='crossed') 처리: + // - roofLine 은 그대로, wall.baseLines 는 이미 오버된 좌표로 mutate 된 상태. + // - 보조선 싹 지우고 '오버된 wall.baseLines 좌표 그대로' SK 재빌드. + // - 일반 경로(drawSkeletonRidgeRoof) 는 45도 대각 확장/roof 경계 클램핑이 들어가 + // 오버 polygon 이 왜곡 → 용마루 지붕 케이스는 전용 함수(drawSkeletonRidgeRoofFromBaseLines)로 분기. + // - forceRedraw 인자는 명시적 수동 호출 의도 표현 용도로만 유지. + let __verdict = 'unknown' try { - const __verdict = verifyMoveBoundary(this.id, this.canvas) + __verdict = verifyMoveBoundary(this.id, this.canvas) if (__verdict === 'crossed') { - console.warn('[drawHelpLine] 경계 넘음(crossed) → 초기화/재빌드 스킵 (기존 innerLines/SK 유지)') - return + console.warn(`[drawHelpLine] 경계 넘음(crossed) → 오버 전용 경로(drawSkeletonRidgeRoofFromBaseLines) 사용 (forceRedraw=${forceRedraw})`) } } catch (e) { - // 판정기 자체 실패는 무시하고 기존 흐름 진행 (안전한 no-op) console.warn('[drawHelpLine] verifyMoveBoundary 예외 → 기존 흐름 진행:', e) } @@ -495,8 +497,12 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { if (types.every((type) => type === LINE_TYPE.WALLLINE.EAVES)) { // 용마루 -- straight-skeleton - // console.log('용마루 지붕') - drawSkeletonRidgeRoof(this.id, this.canvas, textMode) + // 오버(crossed) 이동 시 wall.baseLines 좌표 그대로 사용하는 전용 경로로 분기. + if (__verdict === 'crossed') { + drawSkeletonRidgeRoofFromBaseLines(this.id, this.canvas, textMode) + } else { + drawSkeletonRidgeRoof(this.id, this.canvas, textMode) + } } else if (isGableRoof(types)) { // A형, B형 박공 지붕 // console.log('패턴 지붕') @@ -510,6 +516,19 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { } }, + /** + * 오버 이동(crossed) 상태여도 wall.baseLines / roof.lines / outerLine / lengthText 는 그대로 두고 + * 보조선(innerLines: eaveHelpLine, HIP, extensionLine, SK ridge 등) 만 제거 후 SK 재빌드. + * + * drawHelpLine 의 진입부 verifyMoveBoundary 가드만 우회하는 wrapper. 이후 흐름은 동일. + * - 초기화 필터(line 405-411) 가 이미 wall/roof/baseLine/outerLine/lengthText 를 제외하고 있어 + * 해당 객체들은 보존됨. + * - 사용자가 오버된 상태에서 "SK 만 다시 그려보기" 의도로 명시적으로 호출할 때 사용. + */ + redrawHelpLineForced(settingModalFirstOptions) { + return this.drawHelpLine(settingModalFirstOptions, true) + }, + addLengthText() { if ([POLYGON_TYPE.MODULE, 'arrow', POLYGON_TYPE.MODULE_SETUP_SURFACE, POLYGON_TYPE.OBJECT_SURFACE].includes(this.name)) { return diff --git a/src/hooks/roofcover/useMovementSetting.js b/src/hooks/roofcover/useMovementSetting.js index 92f2e2c0..d4f9fc74 100644 --- a/src/hooks/roofcover/useMovementSetting.js +++ b/src/hooks/roofcover/useMovementSetting.js @@ -11,6 +11,14 @@ import { calcLinePlaneSize } from '@/util/qpolygon-utils' import { getSelectLinePosition } from '@/util/skeleton-utils' import { useMouse } from '@/hooks/useMouse' +// [정책 flag] 오버 이동(인접 라인 반전 = polygon self-cross) 입력 처리 방법. +// 'clamp' : (기본/2026-04-24 최종) 평행 위치-OVER_EPS 까지 클램핑. wall.baseLines 는 항상 정상 polygon, SK 확장 정상. +// 'reject' : (2026-04-22 정책) silent skip — 오버면 해당 target 의 mutation 자체를 건너뜀. 토스트 없음. +// 'passthrough' : 가드 전면 스킵 — 사용자가 입력한 raw 값을 그대로 적용. (과거 crossed 허용 동작; polygon 왜곡 위험) +// 교체 방법: 이 상수만 변경. OVER_GUARD 분기가 자동 반응. +const OVER_MOVE_POLICY = 'clamp' +const OVER_EPS = 0.5 + //동선이동 형 올림 내림 export function useMovementSetting(id) { const TYPE = { @@ -746,6 +754,57 @@ export function useMovementSetting(id) { deltaX = value.toNumber() } + // [OVER_GUARD] 오버 이동(인접 라인 반전) 처리. 정책은 파일 상단 OVER_MOVE_POLICY 로 전환. + // 한계 계산 (공통): + // nLimit = (nextLine 의 free 끝점 좌표) - (currentLine 의 해당 끝점 좌표) + // pLimit = (prevLine 의 free 끝점 좌표) - (currentLine 의 해당 시작점 좌표) + // delta 와 같은 부호인 한계만 후보(반대 부호 한계는 인접 라인이 길어지는 방향). + // 정책별 동작: + // 'passthrough' → 가드 전체 스킵 + // 'reject' → |delta| > |한계-EPS| 이면 해당 target 은 mutation 없이 건너뜀 (silent skip) + // 'clamp' → |delta| 를 |한계-EPS| 로 잘라 평행 직전까지 이동. roof.moveUpDown 도 동일 비율 동기화. + if (OVER_MOVE_POLICY !== 'passthrough') { + const __isHoriz = currentLine.y1 === currentLine.y2 + const __raw = __isHoriz ? deltaY : deltaX + const __sgn = Math.sign(__raw) + if (__sgn !== 0) { + const __nLimit = __isHoriz ? (nextLine.y2 - currentLine.y2) : (nextLine.x2 - currentLine.x2) + const __pLimit = __isHoriz ? (prevLine.y1 - currentLine.y1) : (prevLine.x1 - currentLine.x1) + let __absLimit = Infinity + if (Math.sign(__nLimit) === __sgn) __absLimit = Math.min(__absLimit, Math.abs(__nLimit)) + if (Math.sign(__pLimit) === __sgn) __absLimit = Math.min(__absLimit, Math.abs(__pLimit)) + // [EPS] 평행 정확히 도달 시 인접 baseLine zero-length → polygon 중복 꼭짓점 → SkeletonBuilder 헛선 발생. + // OVER_EPS 여유 두어 인접 baseLine 길이 OVER_EPS(=0.5) 로 남기고 SHOULDER_ABSORBED(planeSize<1) 가 자연 흡수. + const __safeAbsLimit = Math.max(0, __absLimit - OVER_EPS) + const __isOver = Math.abs(__raw) > __safeAbsLimit + 0.001 + + if (__isOver && OVER_MOVE_POLICY === 'reject') { + // silent skip: mutation 자체 건너뜀. movingLineFromSkeleton 의 roof.points 확장도 막으려면 moveUpDown=0. + console.warn( + `[handleSave] OVER 거부(reject): ${__isHoriz ? 'deltaY' : 'deltaX'}=${__raw.toFixed(1)} > limit=${__safeAbsLimit.toFixed(1)} → target skip` + ) + roof.moveUpDown = 0 + return + } + + if (OVER_MOVE_POLICY === 'clamp') { + const __limited = __sgn * Math.min(Math.abs(__raw), __safeAbsLimit) + if (Math.abs(__limited - __raw) > 0.001) { + console.warn( + `[handleSave] OVER 클램핑: ${__isHoriz ? 'deltaY' : 'deltaX'} ${__raw.toFixed(1)} → ${__limited.toFixed(1)} (nLimit=${__nLimit.toFixed(1)} pLimit=${__pLimit.toFixed(1)}, EPS=${OVER_EPS})` + ) + if (__isHoriz) deltaY = __limited + else deltaX = __limited + // [SYNC roof.moveUpDown] movingLineFromSkeleton 은 roof.moveUpDown(mm) 로 roof.points 확장. + // delta 만 줄이고 moveUpDown 방치 시 wall 은 평행까지, roof.points 는 원본 양만큼 이동 → + // polygon 자기교차 → removeNonOrthogonalPoints 가 꼭짓점 축소 → "SK 가 wall 안에서 멈춤". + // __limited(cm) × 10 = mm. + roof.moveUpDown = Math.round(Math.abs(__limited) * 10) + } + } + } + } + currentLine.set({ x1: currentLine.x1 + deltaX, y1: currentLine.y1 + deltaY, diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index 88096345..fdf4ddb7 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -783,14 +783,17 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { // 6. 마루이동(용마루 위치 수동 조정)이 설정된 경우 movingLineFromSkeleton 결과로 대체 if (moveFlowLine !== 0 || moveUpDown !== 0) { - // [보호 가드] 이동이 골짜기 경계를 넘어가는지 사전 판정 - // 'crossed' → 외곽선을 뒤집을 만큼 과한 이동 → 기존 SK/innerLines/skeletonLines 전부 보존 후 조용히 종료 - // 'ok' | 'on-boundary' | 'unknown' → 기존 흐름 그대로 진행 - // ※ 기존 로직은 전혀 수정하지 않고, 진입부에 한 블록만 추가 - const __moveVerdict = verifyMoveBoundary(roofId, canvas) - if (__moveVerdict === 'crossed') { - console.warn('[skeleton] 경계 넘음(crossed) → 재빌드 스킵 (기존 SK/innerLines/skeletonLines 유지)') - return + // [정책] 오버 이동(verifyMoveBoundary='crossed') 처리: + // - 과거: 'crossed' 시 재빌드 스킵하여 기존 SK 유지 (silent skip). + // - 현재: 사용자가 재작도 회피용 단축 수단으로 오버를 의도 사용 → 오버된 wall.baseLines 좌표 기반 재빌드 수행. + // - 경고 로그만 남기고 기존 흐름 그대로 진행. + try { + const __moveVerdict = verifyMoveBoundary(roofId, canvas) + if (__moveVerdict === 'crossed') { + console.warn('[skeleton] 경계 넘음(crossed) → 오버된 wall.baseLine 좌표 기반 재빌드 진행') + } + } catch (e) { + console.warn('[skeleton] verifyMoveBoundary 예외 → 기존 흐름 진행:', e) } const movedPoints = safeMovedPointsWithFallback(roofId, canvas) @@ -901,18 +904,16 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { // ────────────────────────────────────────────────────────────────── // [분기] 스켈레톤 입력 포인트 결정 - // 정상: changRoofLinePoints 그대로 사용 (기존 로직 변경 없음) - // 오버: calcOverCorrectedPoints() 별도 함수로 보정 포인트 생성 - // ※ changRoofLinePoints 자체는 절대 수정하지 않음 - // ※ 오버 보정 실패 시 changRoofLinePoints 그대로 fallback + // 정상: changRoofLinePoints 그대로 사용 + // 오버(일반 경로로 들어온 에지 케이스): calcOverCorrectedPoints() 로 보정 포인트 생성 + // ※ verifyMoveBoundary === 'crossed' 케이스는 drawHelpLine 에서 drawSkeletonRidgeRoofFromBaseLines + // 로 사전 분기하므로, 여기 isOverDetected 분기는 일반 경로의 안전망 용도로만 작동. + // ※ changRoofLinePoints 자체는 절대 수정하지 않음. 실패 시 그대로 fallback. // ────────────────────────────────────────────────────────────────── - let skeletonInputPoints = changRoofLinePoints // 정상 경로 + let skeletonInputPoints = changRoofLinePoints if (isOverDetected) { - // [예외] 오버 감지 시 → 별도 함수에서 스켈레톤 전용 보정 포인트 계산 try { - // 누적 이동 보호: 이전 SK 확정 상태(lastPoints)를 전달하여 - // 1차 이동에서 이미 확정된 점은 보정 대상에서 제외한다. const corrected = calcOverCorrectedPointsSafe( changRoofLinePoints, origPoints, @@ -923,7 +924,6 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { console.log('[SK_OVER] 보정 포인트 적용:', skeletonInputPoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`)) } } catch (e) { - // 보정 실패 시 기존 changRoofLinePoints로 fallback → 기존 동작 보장 console.error('[SK_OVER] 보정 실패 → fallback:', e) } } @@ -1006,6 +1006,102 @@ export const skeletonBuilder = (roofId, canvas, textMode) => { } } +/** + * 오버 이동(verifyMoveBoundary='crossed') 전용 경로. + * + * 일반 경로(drawSkeletonRidgeRoof) 는 wall.baseLines → 45도 대각 확장 → roof 경계까지 늘린 polygon 을 + * SkeletonBuilder 에 입력한다. 오버 이동 상태에선 이 확장/클램핑이 폴리곤 자가교차나 roof 경계로의 + * 되돌림을 야기해 엉뚱한 SK 를 만든다. + * + * 본 함수는 **wall.baseLines 의 끝점을 확장/offset 없이 그대로** polygon 으로 넘겨 SK 를 빌드한다. + * - roof.points / roof.lines / outerLine / lengthText 는 건드리지 않음. + * - SHOULDER_ABSORBED (planeSize < 1) baseLines 는 스킵. + * - innerLines 는 기존 createInnerLinesFromSkeleton 으로 생성 (isOverDetected=true 마킹). + * - canvas.skeleton.lastPoints 는 기존 값을 보존 (오버 상태에서 새로 계산하지 않음). + */ +export const drawSkeletonRidgeRoofFromBaseLines = (roofId, canvas, textMode) => { + const roof = canvas?.getObjects().find((o) => o.id === roofId) + if (!roof) { + console.warn('[SK_OVER_FN] roof 없음 → 중단') + return + } + const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL && obj.attributes?.roofId === roofId) + if (!wall || !wall.baseLines?.length) { + console.warn('[SK_OVER_FN] wall/baseLines 없음 → 중단') + return + } + + // wall.baseLines 순차 끝점(x1,y1) 수집. zero-length(SHOULDER_ABSORBED) 항목 스킵. + const rawPoints = [] + for (let i = 0; i < wall.baseLines.length; i++) { + const bl = wall.baseLines[i] + const planeSize = bl?.attributes?.planeSize ?? Infinity + if (planeSize < 1) { + console.log(`[SK_OVER_FN] baseLines[${i}] SHOULDER_ABSORBED skip (planeSize=${planeSize})`) + continue + } + if (bl == null || !isFinite(bl.x1) || !isFinite(bl.y1)) { + console.warn(`[SK_OVER_FN] baseLines[${i}] invalid coord → skip`) + continue + } + rawPoints.push({ x: bl.x1, y: bl.y1 }) + } + + if (rawPoints.length < 3) { + console.warn(`[SK_OVER_FN] 유효 baseLines 끝점 ${rawPoints.length} < 3 → 중단`) + return + } + + console.log('[SK_OVER_FN] wall.baseLines polygon:', rawPoints.map((p, i) => `[${i}](${Math.round(p.x)},${Math.round(p.y)})`)) + + // skeletonPoints 마킹 (오버 좌표 그대로) + roof.skeletonPoints = rawPoints.map((p) => ({ x: p.x, y: p.y })) + + const perturbed = perturbPolygonPoints(rawPoints) + const geoJSONPolygon = toGeoJSON(perturbed) + + try { + geoJSONPolygon.pop() + console.log('[SK_OVER_FN] geoJSONPolygon:', JSON.stringify(geoJSONPolygon, null, 2)) + console.log('[SK_OVER_FN] 꼭짓점 수:', geoJSONPolygon.length) + const skeleton = SkeletonBuilder.BuildFromGeoJSON([[geoJSONPolygon]]) + + roof.innerLines = roof.innerLines || [] + roof.innerLines = createInnerLinesFromSkeleton(roofId, canvas, skeleton, textMode, /* isOverDetected */ true) + + if (!canvas.skeletonStates) { + canvas.skeletonStates = {} + canvas.skeletonLines = [] + } + canvas.skeletonStates[roofId] = true + canvas.skeletonLines = [] + canvas.skeletonLines.push(...roof.innerLines) + roof.skeletonLines = canvas.skeletonLines + + const cleanSkeleton = { + Edges: skeleton.Edges.map((edge) => ({ + X1: edge.Edge.Begin.X, + Y1: edge.Edge.Begin.Y, + X2: edge.Edge.End.X, + Y2: edge.Edge.End.Y, + Polygon: edge.Polygon, + })), + roofId: roofId, + } + // lastPoints 는 기존 값 유지 (오버 상태에서 새 누적 기준점을 만들지 않음) + const __preservedLastPoints = canvas.skeleton?.lastPoints ?? null + canvas.skeleton = cleanSkeleton + if (__preservedLastPoints) canvas.skeleton.lastPoints = __preservedLastPoints + + canvas.set('skeleton', cleanSkeleton) + canvas.renderAll() + } catch (e) { + console.error('[SK_OVER_FN] 스켈레톤 생성 오류:', e) + console.error('[SK_OVER_FN] 입력 좌표:', rawPoints.map((p, i) => `[${i}] (${p.x.toFixed(2)},${p.y.toFixed(2)})`)) + if (canvas.skeletonStates) canvas.skeletonStates[roofId] = false + } +} + /** * 스켈레톤 결과와 외벽선 정보를 바탕으로 내부선(용마루, 추녀)을 생성합니다. * @param {object} skeleton - SkeletonBuilder로부터 반환된 스켈레톤 객체 -- 2.47.2