From a8d59873b7e9259161014263591d6048d8ce1b38 Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 4 Jun 2026 17:01:11 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[1961]=20=ED=8F=89=ED=96=89=20=EC=B2=98?= =?UTF-8?q?=EB=A7=88=20=EC=8C=8D=20=EB=9D=BC=EB=B2=A8=20=ED=86=B5=EC=9D=BC?= =?UTF-8?q?=20=E2=80=94=20OVER=5FEPS=201mm=20=ED=81=B4=EB=9E=A8=ED=94=84?= =?UTF-8?q?=20=EB=B3=B4=EC=A0=95=20equalizeParallelEaveLabels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4 --- src/components/fabric/QPolygon.js | 23 +++++++- src/util/qpolygon-utils.js | 87 +++++++++++++++++++++++++++++++ src/util/skeleton-utils.js | 11 ---- 3 files changed, 109 insertions(+), 12 deletions(-) diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index fb12e181..fc2bb168 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -2,7 +2,16 @@ import { fabric } from 'fabric' import { v4 as uuidv4 } from 'uuid' import { QLine } from '@/components/fabric/QLine' import { distanceBetweenPoints, findTopTwoIndexesByDistance, getDirectionByPoint, sortedPointLessEightPoint, sortedPoints } from '@/util/canvas-util' -import { calculateAngle, drawGableRoof, drawRoofByAttribute, drawShedRoof, equalizeSymmetricHips, snapNearAxisEdges, toGeoJSON } from '@/util/qpolygon-utils' +import { + calculateAngle, + drawGableRoof, + drawRoofByAttribute, + drawShedRoof, + equalizeParallelEaveLabels, + equalizeSymmetricHips, + snapNearAxisEdges, + toGeoJSON, +} from '@/util/qpolygon-utils' import * as turf from '@turf/turf' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import Big from 'big.js' @@ -786,6 +795,18 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, { if (this.innerLines && this.innerLines.length > 0) { const hips = this.innerLines.filter((l) => l && l.name === LINE_TYPE.SUBLINE.HIP) equalizeSymmetricHips(hips, this.canvas) + + // 평행 처마 쌍(고정 outerLine ↔ 이동 eaveHelpLine) 라벨 통일. + // OVER_EPS 클램프로 이동 변이 1mm 짧아져 평행인데 라벨이 1 차이 나는 케이스 보정. + // eaveHelpLine 은 이 지붕 소속(parentId)만, outerLine 은 좌표가 겹치는 것만 그룹핑되므로 cross-roof 오머지 없음. + const eaveCandidates = (this.canvas?.getObjects() || []).filter( + (o) => + (o.name === 'outerLine' || (o.name === LINE_TYPE.WALLLINE.EAVE_HELP_LINE && o.parentId === this.id)) && + typeof o.x1 === 'number' && + typeof o.y1 === 'number', + ) + equalizeParallelEaveLabels(eaveCandidates, this.canvas) + this.canvas?.renderAll() } diff --git a/src/util/qpolygon-utils.js b/src/util/qpolygon-utils.js index 1471186d..7a8ddda7 100644 --- a/src/util/qpolygon-utils.js +++ b/src/util/qpolygon-utils.js @@ -6346,6 +6346,93 @@ export const equalizeSymmetricHips = (hipLines, canvas) => { }) } +/** + * 평행한 처마 변 쌍(고정 outerLine ↔ 이동 eaveHelpLine)의 라벨을 통일. + * + * 배경: WallBaseLine 을 옆 변과 평행이 되도록 옮기면 OVER_EPS(0.1px=1mm) 클램프 때문에 + * 이동 변이 평행 직전에서 멈춰 좌표 길이가 고정 변보다 정확히 1mm 짧아진다 + * (예: 고정 2805 ↔ 이동 2804). 기하/SK 안전을 위해 OVER_EPS 는 유지해야 하므로 + * 좌표는 건드리지 않고 '표시 라벨' 만 맞춘다. 고객이 1mm 차이도 거부하기 때문. + * + * 좌표 길이(calcLinePlaneSize 와 동일식)로 그룹핑한다. outerLine 의 attributes.planeSize 는 + * stale 일 수 있어 신뢰하지 않는다. 클램프는 항상 '짧게' 만들므로 그룹 최대값이 의도한 값. + * 기준(최대) 변은 손대지 않고, 짧아진 변만 최대값으로 끌어올린다. + * + * @param {Array} lines - outerLine + eaveHelpLine 등 후보 라인 배열 (x1,y1,x2,y2 필수) + * @param {object} canvas - fabric canvas (lengthText 동기화용; null 이면 attribute 만 갱신) + */ +export const equalizeParallelEaveLabels = (lines, canvas) => { + if (!lines || lines.length < 2) return + + const _coordLen = (l) => Math.round(Math.sqrt((l.x2 - l.x1) ** 2 + (l.y2 - l.y1) ** 2) * 10) + const _orient = (l) => { + const adx = Math.abs(l.x2 - l.x1) + const ady = Math.abs(l.y2 - l.y1) + if (adx < 0.5 && ady >= 0.5) return 'V' + if (ady < 0.5 && adx >= 0.5) return 'H' + return null // 대각선(hip 등) 은 equalizeSymmetricHips 담당 → 제외 + } + // 평행 두 변이 마주보는 처마 쌍인지: 평행 방향(변화 축) 의 범위가 겹쳐야 한다. + const _overlap = (a, b, orient) => { + const [ak1, ak2, bk1, bk2] = + orient === 'V' + ? [Math.min(a.y1, a.y2), Math.max(a.y1, a.y2), Math.min(b.y1, b.y2), Math.max(b.y1, b.y2)] + : [Math.min(a.x1, a.x2), Math.max(a.x1, a.x2), Math.min(b.x1, b.x2), Math.max(b.x1, b.x2)] + return Math.min(ak2, bk2) - Math.max(ak1, bk1) > 0.5 + } + const _apply = (line, plane, actual) => { + const text = canvas ? canvas.getObjects().find((o) => o.name === 'lengthText' && o.parentId === line.id) : null + // 표시 모드(평면/실측) 는 갱신 전 기준으로 판정해야 잘못된 모드 전환을 막는다. + const wasPlane = text ? !text.actualSize || text.text === String(text.planeSize) : true + if (line.attributes) { + line.attributes.planeSize = plane + line.attributes.actualSize = actual + } + if (text) { + text.set({ planeSize: plane, actualSize: actual }) + const __raw = wasPlane ? plane : actual + text.set({ text: Number(__raw).toFixed(1).replace(/\.0$/, '') }) + } + } + + const used = new Set() + lines.forEach((a, i) => { + if (used.has(i)) return + const oa = _orient(a) + if (!oa) { + used.add(i) + return + } + const baseLen = _coordLen(a) + const group = [{ idx: i, line: a, len: baseLen }] + lines.forEach((b, j) => { + if (j <= i || used.has(j)) return + if (_orient(b) !== oa) return + const lenB = _coordLen(b) + if (Math.abs(lenB - baseLen) > 5) return + if (!_overlap(a, b, oa)) return + group.push({ idx: j, line: b, len: lenB }) + }) + if (group.length >= 2) { + let ref = group[0] + group.forEach((g) => { + if (g.len > ref.len) ref = g + }) + const plane = ref.len + group.forEach(({ idx, line, len }) => { + used.add(idx) + if (len >= plane) return // 기준(최대) 변은 손대지 않음 + const oldPlane = line.attributes?.planeSize ?? len + const oldActual = line.attributes?.actualSize ?? oldPlane + const actual = oldPlane > 0 ? Math.round((oldActual * plane) / oldPlane) : plane + _apply(line, plane, actual) + }) + } else { + used.add(i) + } + }) +} + /** * 포인트와 기울기를 기준으로 선의 길이를 구한다. * @param lineLength diff --git a/src/util/skeleton-utils.js b/src/util/skeleton-utils.js index eb0a0306..79b75182 100644 --- a/src/util/skeleton-utils.js +++ b/src/util/skeleton-utils.js @@ -2111,17 +2111,6 @@ const createInnerLinesFromSkeleton = (roofId, canvas, skeleton, textMode, isOver const getAddLine = (p1, p2, stroke = '', lineType = 'eaveHelpLine') => { movedLines.push({ index, p1, p2 }) - // [진단] eaveHelpLine 생성 추적: 어느 index/어느 wallBaseLine-wallLine 쌍에서 만들어지는지 - // logger.log('🎯 [getAddLine]', { - // index, - // lineType, - // p1: { x: Math.round(p1.x), y: Math.round(p1.y) }, - // p2: { x: Math.round(p2.x), y: Math.round(p2.y) }, - // wallLine: `(${Math.round(wallLine.x1)},${Math.round(wallLine.y1)})→(${Math.round(wallLine.x2)},${Math.round(wallLine.y2)})`, - // wallBaseLine: `(${Math.round(wallBaseLine.x1)},${Math.round(wallBaseLine.y1)})→(${Math.round(wallBaseLine.x2)},${Math.round(wallBaseLine.y2)})`, - // wallBaseLen: Math.round(Math.hypot(wallBaseLine.x2 - wallBaseLine.x1, wallBaseLine.y2 - wallBaseLine.y1)), - // }) - const dx = Math.abs(p2.x - p1.x); const dy = Math.abs(p2.y - p1.y); const isDiagonal = dx > 0.5 && dy > 0.5; // x, y 변화가 모두 있으면 대각선 From 956576491264ff13c29fe94e0e54d6feed8f2840 Mon Sep 17 00:00:00 2001 From: ysCha Date: Thu, 4 Jun 2026 17:19:53 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[0000]=20=EC=BC=80=EB=9D=BC=EB=B0=94=20kLin?= =?UTF-8?q?e=20=E2=80=94=20wLine=20=EC=9D=B4=EB=8F=99=20=ED=9B=84=20baseLi?= =?UTF-8?q?ne=20=EB=81=9D=EC=A0=90(t1/t2)=20=EA=B8=B0=EC=A4=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20foot=20=EA=B3=84=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wLine 이동 후 케라바 변경 시 선택된 target 은 이동 전 wallLine 좌표를 가짐. wall.baseLines 에서 wallId 매칭으로 이동된 실제 끝점(t1/t2)을 찾아 kLine foot 계산 기준으로 사용 → 이상한 대각선 수정. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/roofcover/useEavesGableEdit.js | 26 +++++++++++++++--------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index 7b5b5f40..eb0938d7 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -364,8 +364,12 @@ export function useEavesGableEdit(id) { } } const labelOf = (line) => (line ? labelByLine.get(line) || '?' : null) - const t1 = { x: target.x1, y: target.y1 } - const t2 = { x: target.x2, y: target.y2 } + // [KERAB-WLINE-T1T2 2026-06-04] wLine 이동 후 선택된 target 은 이동 전 wallLine. + // 이동된 실제 위치는 wall.baseLines 중 wallId 가 일치하는 baseLine 에 있다. + const _wall = canvas.getObjects().find((o) => o.name === POLYGON_TYPE.WALL && o.attributes?.roofId === target.attributes?.roofId) + const _matchedBase = _wall?.baseLines?.find((bl) => bl.attributes?.wallId === target.attributes?.wallId) + const t1 = _matchedBase ? { x: _matchedBase.x1, y: _matchedBase.y1 } : { x: target.x1, y: target.y1 } + const t2 = _matchedBase ? { x: _matchedBase.x2, y: _matchedBase.y2 } : { x: target.x2, y: target.y2 } const h1Match = findHipAtEndpoint(roof, t1) const h2Match = findHipAtEndpoint(roof, t2) logger.log( @@ -734,11 +738,13 @@ export function useEavesGableEdit(id) { return Math.abs((vax * vbx + vay * vby) / (ma * mb)) < PERP_EPS } const computePendingKLine = (apex) => { - const ax = h2Match.near.x - h1Match.near.x - const ay = h2Match.near.y - h1Match.near.y + // [KERAB-WLINE-T1T2 2026-06-04] wLine 이동 후 hip.near 가 target 끝점에서 벗어나므로 + // 이동한 처마라인 끝점(t1/t2) 기준으로 foot 계산 + const ax = t2.x - t1.x + const ay = t2.y - t1.y const aSq = ax * ax + ay * ay || 1 - const tFoot = ((apex.x - h1Match.near.x) * ax + (apex.y - h1Match.near.y) * ay) / aSq - const foot = { x: h1Match.near.x + tFoot * ax, y: h1Match.near.y + tFoot * ay } + const tFoot = ((apex.x - t1.x) * ax + (apex.y - t1.y) * ay) / aSq + const foot = { x: t1.x + tFoot * ax, y: t1.y + tFoot * ay } return { x1: apex.x, y1: apex.y, x2: foot.x, y2: foot.y } } const pushApexIfNew = (point, callerTag = '?') => { @@ -1492,8 +1498,8 @@ export function useEavesGableEdit(id) { roof, target, markerApex, - h1Match.near, - h2Match.near, + t1, + t2, [h1Match.hip, h2Match.hip, ...pathHips], pathRidges, extLines, @@ -2160,8 +2166,8 @@ export function useEavesGableEdit(id) { roof, target, apex, - h1Match.near, - h2Match.near, + t1, + t2, [h1Match.hip, h2Match.hip], null, null,