[1961] hip 라벨 정합성 — SK↔할당 일치 + 0.5 step 표시 통일

This commit is contained in:
ysCha 2026-05-18 12:41:55 +09:00
parent 116953ee2f
commit eaae0f4a84
8 changed files with 140 additions and 63 deletions

View File

@ -93,7 +93,12 @@ export const QLine = fabric.util.createClass(fabric.Line, {
thisText.set({ actualSize: this.attributes.actualSize }) thisText.set({ actualSize: this.attributes.actualSize })
} }
if (this.attributes?.planeSize) { if (this.attributes?.planeSize) {
thisText.set({ planeSize: this.attributes.planeSize, text: this.attributes.planeSize.toString() }) // [2026-05-18 라벨 저장값 통일] planeSize 저장값 그대로 표시.
// 정수(calcLinePlaneSize) 또는 0.5 step(hip equalize 후) 모두 노출.
thisText.set({
planeSize: this.attributes.planeSize,
text: Number(this.attributes.planeSize).toFixed(1).replace(/\.0$/, ''),
})
} }
// 라인이 이동했을 수 있으므로 위치도 재계산해서 업데이트한다. // 라인이 이동했을 수 있으므로 위치도 재계산해서 업데이트한다.
const sX = this.scaleX const sX = this.scaleX
@ -147,8 +152,9 @@ export const QLine = fabric.util.createClass(fabric.Line, {
const maxY = this.top + this.length const maxY = this.top + this.length
const degree = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI const degree = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI
// [2026-05-18 라벨 저장값 통일] planeSize 저장값(정수 또는 0.5 step) 그대로.
const displayValue = this.attributes?.planeSize ?? this.getLength() const displayValue = this.attributes?.planeSize ?? this.getLength()
const text = new fabric.Textbox(displayValue.toString(), { const text = new fabric.Textbox(Number(displayValue).toFixed(1).replace(/\.0$/, ''), {
actualSize: this.attributes?.actualSize, actualSize: this.attributes?.actualSize,
planeSize: this.attributes?.planeSize, planeSize: this.attributes?.planeSize,
left: left, left: left,

View File

@ -2,7 +2,7 @@ import { fabric } from 'fabric'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { QLine } from '@/components/fabric/QLine' import { QLine } from '@/components/fabric/QLine'
import { distanceBetweenPoints, findTopTwoIndexesByDistance, getDirectionByPoint, sortedPointLessEightPoint, sortedPoints } from '@/util/canvas-util' import { distanceBetweenPoints, findTopTwoIndexesByDistance, getDirectionByPoint, sortedPointLessEightPoint, sortedPoints } from '@/util/canvas-util'
import { calculateAngle, drawGableRoof, drawRoofByAttribute, drawShedRoof, snapNearAxisEdges, toGeoJSON } from '@/util/qpolygon-utils' import { calculateAngle, drawGableRoof, drawRoofByAttribute, drawShedRoof, equalizeSymmetricHips, snapNearAxisEdges, toGeoJSON } from '@/util/qpolygon-utils'
import * as turf from '@turf/turf' import * as turf from '@turf/turf'
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import Big from 'big.js' import Big from 'big.js'
@ -610,49 +610,11 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
drawRoofByAttribute(this.id, this.canvas, textMode) drawRoofByAttribute(this.id, this.canvas, textMode)
} }
// 대칭 hip line 쌍의 planeSize/actualSize 를 평균값으로 통일 (예: 1637 / 1638 → 1637.5) // 대칭 hip line 쌍의 planeSize/actualSize 통일 (±5mm 그룹 평균, 0.5 step)
// hip 라인은 좌우 대칭이지만 부동소수점 round-off 로 1mm 차이가 발생함 // 부동소수점 round-off 로 좌우 hip 이 1mm 차이 나는 케이스 보정.
if (this.innerLines && this.innerLines.length > 0) { if (this.innerLines && this.innerLines.length > 0) {
const hips = this.innerLines.filter((l) => l && l.name === LINE_TYPE.SUBLINE.HIP) const hips = this.innerLines.filter((l) => l && l.name === LINE_TYPE.SUBLINE.HIP)
const _segLen = (l) => Math.round(Math.sqrt((l.x2 - l.x1) ** 2 + (l.y2 - l.y1) ** 2) * 10) equalizeSymmetricHips(hips, this.canvas)
const _equalizeText = (line, plane, actual) => {
if (line.attributes) {
line.attributes.planeSize = plane
line.attributes.actualSize = actual
}
// canvas 위 lengthText 도 즉시 갱신
const text = this.canvas.getObjects().find((o) => o.name === 'lengthText' && o.parentId === line.id)
if (text) {
text.set({ planeSize: plane, actualSize: actual })
// 현재 표시 모드에 맞춰 text 갱신
const isPlane = !text.actualSize || text.text === String(text.planeSize)
text.set({ text: isPlane ? String(plane) : String(actual) })
}
}
// 길이 그룹화: ±5mm 이내 길이끼리 묶고, 그 그룹 안에서 평균값으로 통일
const used = new Set()
hips.forEach((h, i) => {
if (used.has(i)) return
const group = [{ idx: i, line: h }]
const baseLen = _segLen(h)
hips.forEach((h2, j) => {
if (j <= i || used.has(j)) return
if (Math.abs(_segLen(h2) - baseLen) <= 5) group.push({ idx: j, line: h2 })
})
if (group.length >= 2) {
const avgPlane = group.reduce((s, g) => s + (g.line.attributes?.planeSize || 0), 0) / group.length
const avgActual = group.reduce((s, g) => s + (g.line.attributes?.actualSize || 0), 0) / group.length
// 0.5 단위 정밀도 유지
const plane = Math.round(avgPlane * 2) / 2
const actual = Math.round(avgActual * 2) / 2
group.forEach(({ idx, line }) => {
used.add(idx)
_equalizeText(line, plane, actual)
})
} else {
used.add(i)
}
})
this.canvas?.renderAll() this.canvas?.renderAll()
} }
@ -697,6 +659,9 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
const dx = Big(end.x).minus(Big(start.x)) const dx = Big(end.x).minus(Big(start.x))
const dy = Big(end.y).minus(Big(start.y)) const dy = Big(end.y).minus(Big(start.y))
const length = dx.pow(2).plus(dy.pow(2)).sqrt().times(10).round().toNumber() const length = dx.pow(2).plus(dy.pow(2)).sqrt().times(10).round().toNumber()
// [2026-05-18 라벨 저장값 통일] planeSize(정수 또는 0.5 step) 그대로.
const __displayRaw = this.lines[i]?.attributes?.planeSize ?? length
const __display = Number(__displayRaw).toFixed(1).replace(/\.0$/, '')
const direction = getDirectionByPoint(start, end) const direction = getDirectionByPoint(start, end)
@ -723,7 +688,7 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
const degree = Big(Math.atan2(dy.toNumber(), dx.toNumber())).times(180).div(Math.PI).toNumber() const degree = Big(Math.atan2(dy.toNumber(), dx.toNumber())).times(180).div(Math.PI).toNumber()
// Create a new text object if it doesn't exist // Create a new text object if it doesn't exist
const text = new fabric.Text(length.toString(), { const text = new fabric.Text(__display, {
left: midPoint.x, left: midPoint.x,
top: midPoint.y, top: midPoint.y,
fontSize: this.fontSize, fontSize: this.fontSize,

View File

@ -17,7 +17,8 @@ import { useMouse } from '@/hooks/useMouse'
// 'passthrough' : 가드 전면 스킵 — 사용자가 입력한 raw 값을 그대로 적용. (과거 crossed 허용 동작; polygon 왜곡 위험) // 'passthrough' : 가드 전면 스킵 — 사용자가 입력한 raw 값을 그대로 적용. (과거 crossed 허용 동작; polygon 왜곡 위험)
// 교체 방법: 이 상수만 변경. OVER_GUARD 분기가 자동 반응. // 교체 방법: 이 상수만 변경. OVER_GUARD 분기가 자동 반응.
const OVER_MOVE_POLICY = 'clamp' const OVER_MOVE_POLICY = 'clamp'
const OVER_EPS = 0.5 // [2026-05-18 라벨 5mm 단축 fix 1차] 0.5 → 0.1. 인접 edge 잔여 1mm → SHOULDER_ABSORBED(<10) 자연 흡수, 라벨 1mm 차는 round-trip 인정 범위.
const OVER_EPS = 0.1
//동선이동 형 올림 내림 //동선이동 형 올림 내림
export function useMovementSetting(id) { export function useMovementSetting(id) {

View File

@ -147,7 +147,9 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
} }
} }
const displayValue = (+roofSizeSet === 1 ? actualSize : planeSize).toString() // [2026-05-18 라벨 저장값 통일] planeSize/actualSize 저장값 그대로.
const __raw = +roofSizeSet === 1 ? actualSize : planeSize
const displayValue = Number(__raw).toFixed(1).replace(/\.0$/, '')
let tx = (start.x + end.x) / 2 let tx = (start.x + end.x) / 2
let ty = (start.y + end.y) / 2 let ty = (start.y + end.y) / 2
if (lineDir === 'right') ty += 15 if (lineDir === 'right') ty += 15

View File

@ -3,7 +3,7 @@ import { useRecoilValue } from 'recoil'
import { fabric } from 'fabric' import { fabric } from 'fabric'
import { calculateIntersection, findAndRemoveClosestPoint, getDegreeByChon, isPointOnLine } from '@/util/canvas-util' import { calculateIntersection, findAndRemoveClosestPoint, getDegreeByChon, isPointOnLine } from '@/util/canvas-util'
import { QPolygon } from '@/components/fabric/QPolygon' import { QPolygon } from '@/components/fabric/QPolygon'
import { calcLinePlaneSize, isSamePoint, removeDuplicatePolygons } from '@/util/qpolygon-utils' import { calcLinePlaneSize, equalizeSymmetricHips, isSamePoint, removeDuplicatePolygons } from '@/util/qpolygon-utils'
import { basicSettingState, flowDisplaySelector } from '@/store/settingAtom' import { basicSettingState, flowDisplaySelector } from '@/store/settingAtom'
import { fontSelector } from '@/store/fontAtom' import { fontSelector } from '@/store/fontAtom'
import { QLine } from '@/components/fabric/QLine' import { QLine } from '@/components/fabric/QLine'
@ -89,8 +89,10 @@ export const usePolygon = () => {
const maxY = line.top + line.length const maxY = line.top + line.length
const degree = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI const degree = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI
// [2026-05-18 라벨 저장값 통일] planeSize(정수 또는 0.5 step) 그대로.
const __display = planeSize ?? length
const text = new fabric.Textbox( const text = new fabric.Textbox(
+roofSizeSet === 1 ? (actualSize ? actualSize.toString() : length.toString()) : planeSize ? planeSize.toString() : length.toString(), Number(__display).toFixed(1).replace(/\.0$/, ''),
{ {
left: left, left: left,
top: top, top: top,
@ -2139,6 +2141,26 @@ export const usePolygon = () => {
setActualSize(line, polygon.direction, +polygon.roofMaterial?.pitch, forceUpdate) setActualSize(line, polygon.direction, +polygon.roofMaterial?.pitch, forceUpdate)
}) })
// [hip 대칭 통일 2026-05-18] 할당 단계도 SK 빌드와 동일하게 hip 좌우대칭 라인을
// 평균 0.5 step 으로 통일. 좌표 float drift 로 hip 4개가 3832.5/3831.5/3833.5
// 처럼 갈리던 문제 차단.
// canvas 전체 ROOF 폴리곤의 hip 라인을 모아 cross-polygon 그룹화 (한 hip 이 두 face 에
// 공유되는 경우도 같은 그룹).
const allHips = []
const seenIds = new Set()
canvas?.getObjects()?.forEach((obj) => {
if (obj?.name !== POLYGON_TYPE.ROOF || !obj.lines) return
obj.lines.forEach((l) => {
if (l?.attributes?.type === LINE_TYPE.SUBLINE.HIP && !seenIds.has(l.id)) {
seenIds.add(l.id)
allHips.push(l)
}
})
})
if (allHips.length >= 2) {
equalizeSymmetricHips(allHips, canvas)
}
addLengthText(polygon) addLengthText(polygon)
} }

View File

@ -96,12 +96,15 @@ export function useText() {
return newActual return newActual
} }
// [2026-05-18 라벨 통일] planeSize/actualSize 모두 저장값 그대로 표시.
// planeSize 는 정수(calcLinePlaneSize) 가 기본, hip equalize 후 0.5 step 가능.
// actualSize 는 0.5 step. 둘 다 .toFixed(1) 후 trailing .0 제거.
switch (column) { switch (column) {
case 'corridorDimension': case 'corridorDimension':
lengthTexts.forEach((obj) => { lengthTexts.forEach((obj) => {
const v = resolveSize(obj, 'planeSize') const v = resolveSize(obj, 'planeSize')
if (v) { if (v) {
obj.set({ text: v.toString() }) obj.set({ text: Number(v).toFixed(1).replace(/\.0$/, '') })
} }
}) })
break break
@ -111,7 +114,7 @@ export function useText() {
recomputeHipActualIfFlat(obj) recomputeHipActualIfFlat(obj)
const v = resolveSize(obj, 'actualSize') const v = resolveSize(obj, 'actualSize')
if (v) { if (v) {
obj.set({ text: v.toString() }) obj.set({ text: Number(v).toFixed(1).replace(/\.0$/, '') })
} }
}) })
break break
@ -207,7 +210,9 @@ export function useText() {
for (const group of groups) { for (const group of groups) {
if (group.items.length < 2) continue if (group.items.length < 2) continue
const avg = group.items.reduce((s, i) => s + i.value, 0) / group.items.length const avg = group.items.reduce((s, i) => s + i.value, 0) / group.items.length
const unified = Math.round(avg).toString() // [2026-05-18 라벨 0.5 step 통일] corridor/real 모두 0.5 step.
// planeSize 도 hip equalize 후 0.5 step 가능하므로 정수 round 하지 않음.
const unified = (Math.round(avg * 2) / 2).toFixed(1).replace(/\.0$/, '')
for (const item of group.items) { for (const item of group.items) {
// text 만 set. obj.actualSize / obj.planeSize 는 그대로. // text 만 set. obj.actualSize / obj.planeSize 는 그대로.
item.obj.set({ text: unified }) item.obj.set({ text: unified })

View File

@ -6276,14 +6276,74 @@ export const calcLineActualSize = (points, degree = 0) => {
export const calcLineActualSize2 = (points, degree = 0) => { export const calcLineActualSize2 = (points, degree = 0) => {
const planeSize = calcLinePlaneSize(points) const planeSize = calcLinePlaneSize(points)
// [공식 통일 2026-05-18] setActualSize(useLine.js) 와 동일하게 axis 평균 사용.
// 기존: hx = xLength * tan(deg) → X 축만 → south/north hip 과 west/east hip 에서
// symmetric 케이스에도 다른 actualSize 가 산출되어 SK 빌드와 할당 단계 라벨 불일치.
// 변경: axisLength = (xLength + yLength) / 2 → 양 face 대칭값 산출.
const xLength = Math.abs(points.x2 - points.x1) * 10 const xLength = Math.abs(points.x2 - points.x1) * 10
const yLength = Math.abs(points.y2 - points.y1) * 10 const yLength = Math.abs(points.y2 - points.y1) * 10
const hx = xLength * Math.tan(degree * (Math.PI / 180)) const axisLength = (xLength + yLength) / 2
const h = axisLength * Math.tan(degree * (Math.PI / 180))
const actualSize = Math.sqrt(h ** 2 + planeSize ** 2)
// setActualSize 와 동일하게 0.5 step 으로 정합.
return Number((Math.round(actualSize * 2) / 2).toFixed(1))
}
const hy = yLength * Math.tan(degree * (Math.PI / 180)) /**
const actualSize = Number(Math.sqrt(hx ** 2 + planeSize ** 2)).toFixed(0) * 대칭 hip 라인들의 planeSize/actualSize ±5mm 그룹 평균(0.5 step)으로 통일.
const actualSize2 = Number(Math.sqrt(hy ** 2 + planeSize ** 2)).toFixed(0) * SK 빌드(QPolygon.drawHelpLine innerLines) 지붕재 할당(setPolygonLinesActualSize 결과) 양쪽에서 재사용.
return actualSize *
* @param {Array} hipLines - hip 라인 객체 배열 (x1,y1,x2,y2 + attributes 필수)
* @param {object} canvas - fabric canvas (lengthText 동기화용; null 이면 attribute 갱신)
*/
export const equalizeSymmetricHips = (hipLines, canvas) => {
if (!hipLines || hipLines.length < 2) return
// [그룹 키 안정화 2026-05-18]
// 기존 rawLen(Math.hypot * 10) 기준은 같은 hip 의 face별 좌표 미세 drift
// (예: dx=290,dy=289 vs 290,290 → 7mm 차) 로 ±5mm 그룹을 벗어남.
// planeSize 는 SK 빌드시 이미 ±5mm 그룹으로 통일된 안정 정수 키.
// planeSize 없을 때만 rawLen 폴백.
const _segLen = (l) =>
l?.attributes?.planeSize != null
? Math.round(l.attributes.planeSize)
: Math.round(Math.sqrt((l.x2 - l.x1) ** 2 + (l.y2 - l.y1) ** 2) * 10)
const _apply = (line, plane, actual) => {
if (line.attributes) {
line.attributes.planeSize = plane
line.attributes.actualSize = actual
}
if (!canvas) return
const text = canvas.getObjects().find((o) => o.name === 'lengthText' && o.parentId === line.id)
if (text) {
text.set({ planeSize: plane, actualSize: actual })
const isPlane = !text.actualSize || text.text === String(text.planeSize)
// planeSize/actualSize 모두 저장값(정수 또는 0.5 step) 그대로 표시.
const __raw = isPlane ? plane : actual
text.set({ text: Number(__raw).toFixed(1).replace(/\.0$/, '') })
}
}
const used = new Set()
hipLines.forEach((h, i) => {
if (used.has(i)) return
const group = [{ idx: i, line: h }]
const baseLen = _segLen(h)
hipLines.forEach((h2, j) => {
if (j <= i || used.has(j)) return
if (Math.abs(_segLen(h2) - baseLen) <= 5) group.push({ idx: j, line: h2 })
})
if (group.length >= 2) {
const avgPlane = group.reduce((s, g) => s + (g.line.attributes?.planeSize || 0), 0) / group.length
const avgActual = group.reduce((s, g) => s + (g.line.attributes?.actualSize || 0), 0) / group.length
const plane = Math.round(avgPlane * 2) / 2
const actual = Math.round(avgActual * 2) / 2
group.forEach(({ idx, line }) => {
used.add(idx)
_apply(line, plane, actual)
})
} else {
used.add(i)
}
})
} }
/** /**

View File

@ -2918,12 +2918,28 @@ function processEavesEdge(roofId, canvas, skeleton, edgeResult, skeletonLines) {
// logger.log('Has matching line:', outerLine); // logger.log('Has matching line:', outerLine);
//if(outerLine === null) return //if(outerLine === null) return
} }
// [hip pitch fallback 2026-04-30] // [hip pitch fallback 2026-04-30 / 강화 2026-05-18]
// outerLine.attributes.pitch 가 없으면 0 → calcLineActualSize2(deg=0) 가 // 1) outerLine.attributes.pitch — 매칭된 eaves 라인의 pitch
// calcLinePlaneSize 와 동치(tan0=0) → 모든 hip 의 planeSize === actualSize. // 2) roof.pitch — setRoofPitch 에서 stamp (allocation 후)
// 결과: 伏せ図(plane) ↔ 配置面(actual) 메뉴 토글해도 hip 라벨 변화 없음. // 3) roof.lines 중 pitch 가 있는 eaves 라인 — 초기 SK 빌드 시 매칭 실패 시
// roof.pitch 폴백(usePolygon.js:1649 패턴) 으로 진짜 pitch 확보. // 4) canvas 의 wall outerLine 중 pitch 가 있는 라인 — 최후 폴백
let pitch = outerLine?.attributes?.pitch ?? roof?.pitch ?? 0 // 5) 0
// 기존엔 1~2 폴백만 있어 SK 빌드시 pitch=0 → hip actualSize=planeSize 로 잘못 계산되고,
// 이후 display recompute / 할당 단계에서 다른 pitch 로 재계산되어 라벨이 변하는 문제.
let pitch = outerLine?.attributes?.pitch ?? roof?.pitch
if (pitch === undefined || pitch === null) {
const fallbackFromRoofLines = roof?.lines?.find((l) => l?.attributes?.pitch != null)?.attributes?.pitch
pitch = fallbackFromRoofLines
}
if (pitch === undefined || pitch === null) {
const fallbackOuter = canvas
?.getObjects()
?.find((o) => o?.name === 'outerLine' && o?.attributes?.pitch != null)
pitch = fallbackOuter?.attributes?.pitch
}
if (pitch === undefined || pitch === null) {
pitch = 0
}
const convertedPolygon = edgeResult.Polygon?.map(point => ({ const convertedPolygon = edgeResult.Polygon?.map(point => ({