|
|
|
|
@ -14,7 +14,7 @@ import { settingModalFirstOptionsState } from '@/store/settingAtom'
|
|
|
|
|
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
|
|
|
|
import { fabric } from 'fabric'
|
|
|
|
|
import { QLine } from '@/components/fabric/QLine'
|
|
|
|
|
import { reattachDebugLabels } from '@/components/fabric/QPolygon'
|
|
|
|
|
import { reattachDebugLabels, classifyLineForLabel, getRoofBoundaryEdges } from '@/components/fabric/QPolygon'
|
|
|
|
|
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
|
|
|
|
|
import { findInteriorPoint } from '@/util/skeleton-utils'
|
|
|
|
|
import { logger } from '@/util/logger'
|
|
|
|
|
@ -763,32 +763,10 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
const labelByLine = new Map()
|
|
|
|
|
{
|
|
|
|
|
const counters = {}
|
|
|
|
|
const boundaryEdges = getRoofBoundaryEdges(canvas, roof.id)
|
|
|
|
|
const objs = canvas.getObjects().filter((o) => o.parentId === roof.id && o.name !== '__debugLabel')
|
|
|
|
|
for (const obj of objs) {
|
|
|
|
|
let prefix = null
|
|
|
|
|
const nm = obj.name
|
|
|
|
|
const ln = obj.lineName
|
|
|
|
|
const tp = obj.attributes?.type
|
|
|
|
|
if (nm === 'baseLine') prefix = 'B'
|
|
|
|
|
else if (nm === 'outerLine' || nm === 'eaves' || ln === 'roofLine') prefix = 'R'
|
|
|
|
|
else if (nm === LINE_TYPE.SUBLINE.HIP || ln === LINE_TYPE.SUBLINE.HIP) prefix = 'H'
|
|
|
|
|
else if (nm === LINE_TYPE.SUBLINE.RIDGE || ln === LINE_TYPE.SUBLINE.RIDGE) prefix = 'RG'
|
|
|
|
|
else if (nm === LINE_TYPE.SUBLINE.VALLEY || ln === LINE_TYPE.SUBLINE.VALLEY) prefix = 'V'
|
|
|
|
|
else if (nm === LINE_TYPE.SUBLINE.GABLE || ln === LINE_TYPE.SUBLINE.GABLE) prefix = 'G'
|
|
|
|
|
else if (nm === LINE_TYPE.SUBLINE.VERGE || ln === LINE_TYPE.SUBLINE.VERGE) prefix = 'VG'
|
|
|
|
|
else if (
|
|
|
|
|
tp === LINE_TYPE.WALLLINE.EAVE_HELP_LINE ||
|
|
|
|
|
ln === LINE_TYPE.WALLLINE.EAVE_HELP_LINE ||
|
|
|
|
|
nm === LINE_TYPE.WALLLINE.EAVE_HELP_LINE
|
|
|
|
|
)
|
|
|
|
|
prefix = 'E'
|
|
|
|
|
else if (
|
|
|
|
|
typeof obj.x1 === 'number' &&
|
|
|
|
|
typeof obj.y1 === 'number' &&
|
|
|
|
|
typeof obj.x2 === 'number' &&
|
|
|
|
|
typeof obj.y2 === 'number'
|
|
|
|
|
)
|
|
|
|
|
prefix = 'SK'
|
|
|
|
|
const prefix = classifyLineForLabel(obj, boundaryEdges)
|
|
|
|
|
if (!prefix) continue
|
|
|
|
|
counters[prefix] = (counters[prefix] || 0) + 1
|
|
|
|
|
labelByLine.set(obj, `${prefix}-${counters[prefix]}`)
|
|
|
|
|
@ -2762,10 +2740,22 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
extendTypeGableHipsStraightToRoofLine(roof, target, apexRes)
|
|
|
|
|
// [KERAB-TYPE-EAVES-HIPROOF 2026-06-12] 양쪽 박공이 모두 처마가 되어 완전 寄棟이면,
|
|
|
|
|
// 세로 apex 2개·X자 교차를 버리고 힙·마루 규칙(R1/R2/R3)으로 가로 마루 + 절삭 힙 4개로 재구성.
|
|
|
|
|
reconstructHipRoofRidgeIfComplete(roof)
|
|
|
|
|
const _hipRoofDone = reconstructHipRoofRidgeIfComplete(roof)
|
|
|
|
|
// [KERAB-TYPE-EAVES-HIP-CROSS-CLIP 2026-06-24 Step1] 완전우진각 재구성이 안 된(상단 박공 잔존 등)
|
|
|
|
|
// 부분 변환에서, 신규 힙이 기존 대각 힙과 *박스 밖*에서 교차하면 그 교점에서 절삭한다.
|
|
|
|
|
// 체크함수(R-CROSS)와 동일한 segCross 내부교차 + 박스(겹침=상대 roofLine) 제외 판정을 재사용 →
|
|
|
|
|
// 박스가 막는 노치측 교차(HE-2/HE-3)는 자연히 빠지고 박스 밖 교점 하나만 절삭된다. 마루 생성은 후속.
|
|
|
|
|
if (!_hipRoofDone) clipCrossedHipsAtIntersection(roof)
|
|
|
|
|
// [KERAB-RULE-CHECK 2026-06-12] 모든 라인변경(재구성 포함)이 끝난 최종 형상을 규칙으로 검증.
|
|
|
|
|
// surgicalAfter 내부 검사는 재구성 전 상태라, 최종 상태는 여기서 한 번 더 본다.
|
|
|
|
|
runKerabRuleCheck(roof, 'type-eaves', kerabRevertBeforeSnap)
|
|
|
|
|
// [KERAB-LABEL-REATTACH 2026-06-24] 라인변경으로 새로 생성된 힙(HE)·마루(R)에도 캔버스 라벨
|
|
|
|
|
// 부여(사용자 확정). 이 분기는 dumpInnerLineSnapshot('AFTER') 경로를 안 타므로 직접 호출.
|
|
|
|
|
try {
|
|
|
|
|
reattachDebugLabels(canvas, roof.id)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
logger.warn('[KERAB-LABEL-REATTACH] type-eaves failed', e)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
logger.log('[KERAB-TYPE-EAVES] branch ' + JSON.stringify({ ok: !!apexRes, c1, c2, edgeIdx: _edgeIdx }))
|
|
|
|
|
if (apexRes) return
|
|
|
|
|
@ -2775,7 +2765,15 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
const ok = applyKerabRevertPattern(roof, target, c1, c2, ridgeAtMid)
|
|
|
|
|
if (ok) surgicalAfter()
|
|
|
|
|
logger.log('[KERAB-REVERT] applied ' + JSON.stringify({ ok, c1, c2, apex: ridgeAtMid.apex }))
|
|
|
|
|
if (ok) return
|
|
|
|
|
if (ok) {
|
|
|
|
|
// [KERAB-LABEL-REATTACH 2026-06-24] 케라바→처마 revert 로 생성/변경된 힙·마루에도 라벨 부여.
|
|
|
|
|
try {
|
|
|
|
|
reattachDebugLabels(canvas, roof.id)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
logger.warn('[KERAB-LABEL-REATTACH] revert failed', e)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// [KERAB-VALLEY-EAVES-RULE1 2026-06-16] A/B타입 native 케라바 변(스냅샷·native마루·kLine 전부 없음)의
|
|
|
|
|
// 첫 처마 변환 "씨앗". 양 코너에서 폴리곤 안쪽 45° 힙 2개를 그려 각자 첫 교차(다른 inner line 또는
|
|
|
|
|
@ -2818,9 +2816,15 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
const tB = { x: target.x2, y: target.y2 }
|
|
|
|
|
// 게이블벽(HIP)만 직접 제거. native 마루(RIDGE)는 여기서 보존하되, anchor 가 끊겨 dangling 이
|
|
|
|
|
// 되는 ridge(RG-2)는 뒤의 cleanupDangling 이 연쇄 제거한다(사용자 확정 2026-06-16).
|
|
|
|
|
// [KERAB-VALLEY-EAVES-ROOFLINE-KEEP 2026-06-24] 골짜기 라인을 lineName:'roofLine' 으로 마킹(A/B타입 설정).
|
|
|
|
|
// name='hip' 이라 collinear 게이블벽으로 휩쓸려 삭제되면 외곽 roofLine 이 사라진다 → lineName==='roofLine' 은 삭제 제외.
|
|
|
|
|
const gableWallLines = (roof.innerLines || []).filter(
|
|
|
|
|
(l) =>
|
|
|
|
|
l && l.visible !== false && l.name === LINE_TYPE.SUBLINE.HIP && (isCollinearWith(l, c1, c2) || isCollinearWith(l, tA, tB)),
|
|
|
|
|
l &&
|
|
|
|
|
l.visible !== false &&
|
|
|
|
|
l.name === LINE_TYPE.SUBLINE.HIP &&
|
|
|
|
|
l.lineName !== 'roofLine' &&
|
|
|
|
|
(isCollinearWith(l, c1, c2) || isCollinearWith(l, tA, tB)),
|
|
|
|
|
)
|
|
|
|
|
if (edgeIdx >= 0 && gableWallLines.length > 0) {
|
|
|
|
|
target.set({ attributes })
|
|
|
|
|
@ -2841,6 +2845,31 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
const idxB = newC1IsA ? (edgeIdx + 1) % NP : edgeIdx
|
|
|
|
|
const newC1 = pts[idxA]
|
|
|
|
|
const newC2 = pts[idxB]
|
|
|
|
|
// [KERAB-VALLEY-EAVES-ROOFLINE-SNAP 2026-06-25] 出幅으로 코너가 이동(c1→newC1, c2→newC2)했는데
|
|
|
|
|
// surgical(skipInnerLines) 이 boundary roofLine innerLine 은 안 건드려, 인접 세로 roofLine(L-1/L-3)의
|
|
|
|
|
// 코너쪽 끝점이 옛 위치(또는 wallLine 까지 overshoot)에 stale 로 남는다
|
|
|
|
|
// (사용자: "L1 끝점이 w6 라인에 가있고", "L3 도 출폭만큼 길어져야"). → 옛 코너 근처 끝점을 새 코너로 스냅.
|
|
|
|
|
// SNAP_TOL 은 wallLine overshoot(출폭 크기) 흡수용 — 반대편 진짜 코너(보통 수백 단위)는 안 잡히게 넉넉히.
|
|
|
|
|
const snapRoofLineCorner = (oldC, newC) => {
|
|
|
|
|
const SNAP_TOL = 80
|
|
|
|
|
for (const l of roof.innerLines || []) {
|
|
|
|
|
if (!l || l.visible === false || l.lineName !== 'roofLine') continue
|
|
|
|
|
const d1 = Math.hypot(l.x1 - oldC.x, l.y1 - oldC.y)
|
|
|
|
|
const d2 = Math.hypot(l.x2 - oldC.x, l.y2 - oldC.y)
|
|
|
|
|
if (Math.min(d1, d2) > SNAP_TOL) continue
|
|
|
|
|
if (d1 <= d2) l.set({ x1: newC.x, y1: newC.y })
|
|
|
|
|
else l.set({ x2: newC.x, y2: newC.y })
|
|
|
|
|
const sz = calcLinePlaneSize({ x1: l.x1, y1: l.y1, x2: l.x2, y2: l.y2 })
|
|
|
|
|
l.attributes = { ...(l.attributes || {}), planeSize: sz, actualSize: sz }
|
|
|
|
|
l.startPoint = { x: l.x1, y: l.y1 }
|
|
|
|
|
l.endPoint = { x: l.x2, y: l.y2 }
|
|
|
|
|
l.setCoords?.()
|
|
|
|
|
l.setLength?.()
|
|
|
|
|
l.addLengthText?.()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
snapRoofLineCorner(c1, newC1)
|
|
|
|
|
snapRoofLineCorner(c2, newC2)
|
|
|
|
|
// 폴리곤 중심 (inward 판정용).
|
|
|
|
|
let cenX = 0
|
|
|
|
|
let cenY = 0
|
|
|
|
|
@ -2984,8 +3013,11 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
ends.push({ x: l.x2, y: l.y2, line: l })
|
|
|
|
|
}
|
|
|
|
|
// 마루(RIDGE)·VALLEY 는 dangling 이어도 제거 금지 — HIP 만 제거 후보.
|
|
|
|
|
// [KERAB-VALLEY-EAVES-ROOFLINE-KEEP 2026-06-24] lineName:'roofLine'(name='hip') 은 외곽 골짜기 라인 —
|
|
|
|
|
// dangling 이어도 제거 금지(삭제 시 화면에서 골짜기 라인 사라짐).
|
|
|
|
|
const dangler = surv.find((l) => {
|
|
|
|
|
if (l.name !== LINE_TYPE.SUBLINE.HIP) return false
|
|
|
|
|
if (l.lineName === 'roofLine') return false
|
|
|
|
|
for (const p of [
|
|
|
|
|
{ x: l.x1, y: l.y1 },
|
|
|
|
|
{ x: l.x2, y: l.y2 },
|
|
|
|
|
@ -3007,6 +3039,50 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
const cleaned = cleanupDangling()
|
|
|
|
|
const resA = addHipFrom(newC1, idxA)
|
|
|
|
|
const resB = addHipFrom(newC2, idxB)
|
|
|
|
|
// [KERAB-VALLEY-EAVES-R8 2026-06-24] R8: 골짜기 변(케라바) 양 코너에 인접한 처마(일반라인) 코너에서도 힙 생성 → > 완성.
|
|
|
|
|
// CCW 기준 idxA 쪽 prev(idxA-1), idxB 쪽 next(idxB+1) 각각 시도.
|
|
|
|
|
// hit 가 innerLine(HIP/RIDGE) 일 때만 유효 — roofLine 직격은 제거.
|
|
|
|
|
;(() => {
|
|
|
|
|
const adjCandidates = [
|
|
|
|
|
(idxA - 1 + NP) % NP,
|
|
|
|
|
(idxB + 1) % NP,
|
|
|
|
|
].filter((ai, _, arr) => ai !== idxA && ai !== idxB && arr.indexOf(ai) === arr.lastIndexOf(ai))
|
|
|
|
|
for (const ai of adjCandidates) {
|
|
|
|
|
const r = addHipFrom(pts[ai], ai)
|
|
|
|
|
if (!r.hip) continue
|
|
|
|
|
if (!r.hit || r.hit.kind === 'roofLine') {
|
|
|
|
|
canvas.remove(r.hip)
|
|
|
|
|
roof.innerLines = roof.innerLines.filter((l) => l !== r.hip)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})()
|
|
|
|
|
// [KERAB-VALLEY-EAVES-ROOFLINE-H 2026-06-25] 변환된 변(=처마)의 수평 roofLine 생성.
|
|
|
|
|
// surgical 은 skipInnerLines 로 boundary innerLine 을 안 만들어 세로 roofLine 만 남고 가로가 빈다
|
|
|
|
|
// (사용자: "세로만 나오면 어떻게하니.. 가로도 나와야지"). newC1↔newC2 구간에 collinear roofLine 이
|
|
|
|
|
// 이미 있으면(중복) 건너뛰고, 없을 때만 drawRoofLine 패턴(strokeWidth:2, lineName='roofLine')으로 1개 생성.
|
|
|
|
|
;(() => {
|
|
|
|
|
const exists = (roof.innerLines || []).some(
|
|
|
|
|
(l) => l && l.visible !== false && l.lineName === 'roofLine' && isCollinearWith(l, newC1, newC2),
|
|
|
|
|
)
|
|
|
|
|
if (exists) return
|
|
|
|
|
const arr = [newC2.x, newC2.y, newC1.x, newC1.y]
|
|
|
|
|
const sz = calcLinePlaneSize({ x1: arr[0], y1: arr[1], x2: arr[2], y2: arr[3] })
|
|
|
|
|
const rl = new QLine(arr, {
|
|
|
|
|
parentId: roof.id,
|
|
|
|
|
fontSize: roof.fontSize,
|
|
|
|
|
stroke: '#1083E3',
|
|
|
|
|
strokeWidth: 2,
|
|
|
|
|
name: LINE_TYPE.SUBLINE.HIP,
|
|
|
|
|
textMode: roof.textMode,
|
|
|
|
|
attributes: { roofId: roof.id, planeSize: sz, actualSize: sz },
|
|
|
|
|
})
|
|
|
|
|
rl.lineName = 'roofLine'
|
|
|
|
|
rl.startPoint = { x: rl.x1, y: rl.y1 }
|
|
|
|
|
rl.endPoint = { x: rl.x2, y: rl.y2 }
|
|
|
|
|
canvas.add(rl)
|
|
|
|
|
rl.bringToFront()
|
|
|
|
|
roof.innerLines.push(rl)
|
|
|
|
|
})()
|
|
|
|
|
// 5b) 마루(RIDGE) 끝점 상호작용 — anchor 잃은 끝점 해소(사용자 확정 2026-06-16: "RG1(h8) 확장, RG2(b5) 절삭").
|
|
|
|
|
// 규칙: 마루는 절대 삭제 안 함. anchor 잃은(=경계 미접촉 + 다른 선과 미공유) 끝점은
|
|
|
|
|
// · 새 골짜기 힙이 그 마루를 가로지르면 → 교점에서 절삭(trim, 끝점을 교점으로 당김)
|
|
|
|
|
@ -3500,24 +3576,12 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
// [KERAB-LABEL-MAP 2026-06-22] 화면 라벨(H-n/RG-n/B-n…)↔좌표 맵 — QPolygon.__attachDebugLabels 와
|
|
|
|
|
// *동일 분류·동일 카운터 순서*(canvas 오브젝트 순서)로 재현. 좌표 대신 라벨로 대화하기 위함(local 전용).
|
|
|
|
|
try {
|
|
|
|
|
const classify = (o) => {
|
|
|
|
|
const nm = o.name
|
|
|
|
|
const ln = o.lineName
|
|
|
|
|
const ty = o.attributes?.type
|
|
|
|
|
if (nm === 'baseLine') return 'B'
|
|
|
|
|
if (nm === 'outerLine' || nm === 'eaves' || ln === 'roofLine') return 'R'
|
|
|
|
|
if (nm === LINE_TYPE.SUBLINE.HIP || ln === LINE_TYPE.SUBLINE.HIP) return 'H'
|
|
|
|
|
if (nm === LINE_TYPE.SUBLINE.RIDGE || ln === LINE_TYPE.SUBLINE.RIDGE) return 'RG'
|
|
|
|
|
if (nm === LINE_TYPE.SUBLINE.VALLEY || ln === LINE_TYPE.SUBLINE.VALLEY) return 'V'
|
|
|
|
|
if (nm === LINE_TYPE.SUBLINE.GABLE || ln === LINE_TYPE.SUBLINE.GABLE) return 'G'
|
|
|
|
|
if (typeof o.x1 === 'number' && typeof o.y1 === 'number' && typeof o.x2 === 'number' && typeof o.y2 === 'number') return 'SK'
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
const ctr = {}
|
|
|
|
|
const lmap = []
|
|
|
|
|
const bEdges = getRoofBoundaryEdges(canvas, roof.id)
|
|
|
|
|
for (const o of canvas.getObjects()) {
|
|
|
|
|
if (!o || o.parentId !== roof.id || o.name === '__debugLabel') continue
|
|
|
|
|
const pfx = classify(o)
|
|
|
|
|
const pfx = classifyLineForLabel(o, bEdges)
|
|
|
|
|
if (!pfx) continue
|
|
|
|
|
ctr[pfx] = (ctr[pfx] || 0) + 1
|
|
|
|
|
lmap.push({
|
|
|
|
|
@ -3531,6 +3595,12 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
} catch (e) {
|
|
|
|
|
logger.warn('[KERAB-LABEL-MAP] failed ' + (e?.message || e))
|
|
|
|
|
}
|
|
|
|
|
// [KERAB-LABEL-REATTACH 2026-06-24] RULE1 씨앗(첫 처마 변환)으로 생성된 힙·마루에도 라벨 부여.
|
|
|
|
|
try {
|
|
|
|
|
reattachDebugLabels(canvas, roof.id)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
logger.warn('[KERAB-LABEL-REATTACH] rule1 failed', e)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@ -4367,6 +4437,157 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
}
|
|
|
|
|
return best
|
|
|
|
|
}
|
|
|
|
|
// [KERAB-TYPE-EAVES-VALLEY-BOX 2026-06-24] A/B타입 변환은 골짜기에 겹침 박스를 만든다(사용자 확정).
|
|
|
|
|
// 일반 케라바 경로(buildOverlapLine)와 달리 TYPE 경로엔 vExt 가 없어 박스가 kerabValleyOverlapLine
|
|
|
|
|
// 객체로 안 만들어졌다 → boxSegs=[] → ① 박스 변이 B 라벨이 아니라 H/R 로 표시 ② oppRayDist/
|
|
|
|
|
// clipHipToBox/extendStraight 가 박스를 못 잡아 힙이 박스를 관통(HE 가 H 를 뚫음). 여기서 박스를
|
|
|
|
|
// "roof.points 의 凹(reflex) 꼭짓점을 한 코너로 하고 주변 축정렬 SK 내부선으로 둘러싸인 축정렬
|
|
|
|
|
// 직사각형"으로 기하 탐지해 실제 kerabValleyOverlapLine 세그먼트로 생성한다. 그러면 기존 boxSegs
|
|
|
|
|
// 기반 절삭이 그대로 동작하고 라벨도 B 가 된다. reflex 없는 정상 切妻은 박스 미생성(무영향).
|
|
|
|
|
const ensureTypeValleyBox = () => {
|
|
|
|
|
const pp = Array.isArray(roof.points) ? roof.points : []
|
|
|
|
|
const N = pp.length
|
|
|
|
|
if (N < 4) return
|
|
|
|
|
// 폴리곤 winding 부호.
|
|
|
|
|
let area2 = 0
|
|
|
|
|
for (let i = 0; i < N; i++) {
|
|
|
|
|
const a = pp[i]
|
|
|
|
|
const b = pp[(i + 1) % N]
|
|
|
|
|
area2 += a.x * b.y - b.x * a.y
|
|
|
|
|
}
|
|
|
|
|
const ccw = area2 > 0
|
|
|
|
|
const AX_EPS = 0.5
|
|
|
|
|
const isAxis = (ax, ay) => Math.abs(ax) < AX_EPS || Math.abs(ay) < AX_EPS
|
|
|
|
|
const existsBoxSeg = (x1, y1, x2, y2) =>
|
|
|
|
|
(canvas.getObjects() || []).some(
|
|
|
|
|
(o) =>
|
|
|
|
|
o &&
|
|
|
|
|
(o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine') &&
|
|
|
|
|
((Math.hypot(o.x1 - x1, o.y1 - y1) < 1 && Math.hypot(o.x2 - x2, o.y2 - y2) < 1) ||
|
|
|
|
|
(Math.hypot(o.x1 - x2, o.y1 - y2) < 1 && Math.hypot(o.x2 - x1, o.y2 - y1) < 1)),
|
|
|
|
|
)
|
|
|
|
|
const mkBoxEdge = (p1, p2, suffix) => {
|
|
|
|
|
if (Math.hypot(p2.x - p1.x, p2.y - p1.y) < 1) return
|
|
|
|
|
if (existsBoxSeg(p1.x, p1.y, p2.x, p2.y)) return
|
|
|
|
|
const lpts = [p1.x, p1.y, p2.x, p2.y]
|
|
|
|
|
const lsz = calcLinePlaneSize({ x1: lpts[0], y1: lpts[1], x2: lpts[2], y2: lpts[3] })
|
|
|
|
|
const ln = new QLine(lpts, {
|
|
|
|
|
parentId: roof.id,
|
|
|
|
|
fontSize: roof.fontSize,
|
|
|
|
|
stroke: '#1083E3',
|
|
|
|
|
strokeWidth: 3,
|
|
|
|
|
name: LINE_TYPE.SUBLINE.VALLEY,
|
|
|
|
|
textMode: roof.textMode,
|
|
|
|
|
attributes: { roofId: roof.id, type: 'kerabValleyOverlapLine', isStart: true, pitch: roof.pitch, planeSize: lsz, actualSize: lsz },
|
|
|
|
|
})
|
|
|
|
|
ln.lineName = 'kerabValleyOverlapLine'
|
|
|
|
|
ln.roofId = roof.id
|
|
|
|
|
ln.__targetId = target.id
|
|
|
|
|
ln.__valleyExtSource = 'type-box' + suffix
|
|
|
|
|
canvas.add(ln)
|
|
|
|
|
ln.bringToFront()
|
|
|
|
|
}
|
|
|
|
|
for (let i = 0; i < N; i++) {
|
|
|
|
|
const prev = pp[(i - 1 + N) % N]
|
|
|
|
|
const V = pp[i]
|
|
|
|
|
const next = pp[(i + 1) % N]
|
|
|
|
|
const cz = (V.x - prev.x) * (next.y - V.y) - (V.y - prev.y) * (next.x - V.x)
|
|
|
|
|
const reflex = (ccw && cz < 0) || (!ccw && cz > 0)
|
|
|
|
|
if (!reflex) continue
|
|
|
|
|
// V 의 두 이웃 변이 모두 축정렬일 때만(노치형 박스) 처리.
|
|
|
|
|
if (!isAxis(V.x - prev.x, V.y - prev.y) || !isAxis(next.x - V.x, next.y - V.y)) continue
|
|
|
|
|
// 폴리곤 안쪽(centroid) 방향으로 축 부호 결정.
|
|
|
|
|
const dirX = cenX - V.x >= 0 ? 1 : -1
|
|
|
|
|
const dirY = cenY - V.y >= 0 ? 1 : -1
|
|
|
|
|
// V 에서 +dirX 가로 ray → 첫 세로 내부선 x.
|
|
|
|
|
let xEdge = null
|
|
|
|
|
let xBest = Infinity
|
|
|
|
|
// V 에서 +dirY 세로 ray → 첫 가로 내부선 y.
|
|
|
|
|
let yEdge = null
|
|
|
|
|
let yBest = Infinity
|
|
|
|
|
for (const il of roof.innerLines || []) {
|
|
|
|
|
if (!il || il.visible === false) continue
|
|
|
|
|
const ax = il.x2 - il.x1
|
|
|
|
|
const ay = il.y2 - il.y1
|
|
|
|
|
const vertical = Math.abs(ax) < AX_EPS && Math.abs(ay) > AX_EPS
|
|
|
|
|
const horizontal = Math.abs(ay) < AX_EPS && Math.abs(ax) > AX_EPS
|
|
|
|
|
if (vertical) {
|
|
|
|
|
const lx = il.x1
|
|
|
|
|
const d = (lx - V.x) * dirX
|
|
|
|
|
const yLo = Math.min(il.y1, il.y2) - 0.5
|
|
|
|
|
const yHi = Math.max(il.y1, il.y2) + 0.5
|
|
|
|
|
if (d > 0.5 && d < xBest && V.y >= yLo && V.y <= yHi) {
|
|
|
|
|
xBest = d
|
|
|
|
|
xEdge = lx
|
|
|
|
|
}
|
|
|
|
|
} else if (horizontal) {
|
|
|
|
|
const ly = il.y1
|
|
|
|
|
const d = (ly - V.y) * dirY
|
|
|
|
|
const xLo = Math.min(il.x1, il.x2) - 0.5
|
|
|
|
|
const xHi = Math.max(il.x1, il.x2) + 0.5
|
|
|
|
|
if (d > 0.5 && d < yBest && V.x >= xLo && V.x <= xHi) {
|
|
|
|
|
yBest = d
|
|
|
|
|
yEdge = ly
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// [KERAB-TYPE-EAVES-VALLEY-CAP-PREFER 2026-06-24] 골짜기 캡(kerabValleyOverlapLine)은 지붕형상설정
|
|
|
|
|
// 단계에서 이미 생성된다(B-1 류 = 우측 캡). 이 기존 캡이 박스의 진짜 경계다 — +x/+y ray 가 중간
|
|
|
|
|
// 내부선(R-1 기둥 x=662 등)에 먼저 걸려 박스를 짧게 끊으면 캡(747) 안쪽에 중복 우측변(B-3)이
|
|
|
|
|
// 생긴다. 같은 방향에 기존 valley 캡이 있으면 그 좌표를 xEdge/yEdge 로 우선 채택해 중복 제거.
|
|
|
|
|
const capScan = (wantVertical) => {
|
|
|
|
|
let cap = null
|
|
|
|
|
let capBest = Infinity
|
|
|
|
|
for (const o of canvas.getObjects() || []) {
|
|
|
|
|
if (!o || o.visible === false) continue
|
|
|
|
|
if (o.lineName !== 'kerabValleyOverlapLine' && o.attributes?.type !== 'kerabValleyOverlapLine') continue
|
|
|
|
|
if (!(o.parentId === roof.id || o.roofId === roof.id || o.attributes?.roofId === roof.id)) continue
|
|
|
|
|
const ax = o.x2 - o.x1
|
|
|
|
|
const ay = o.y2 - o.y1
|
|
|
|
|
const vertical = Math.abs(ax) < AX_EPS && Math.abs(ay) > AX_EPS
|
|
|
|
|
const horizontal = Math.abs(ay) < AX_EPS && Math.abs(ax) > AX_EPS
|
|
|
|
|
if (wantVertical && vertical) {
|
|
|
|
|
const d = (o.x1 - V.x) * dirX
|
|
|
|
|
const yLo = Math.min(o.y1, o.y2) - 0.5
|
|
|
|
|
const yHi = Math.max(o.y1, o.y2) + 0.5
|
|
|
|
|
if (d > 0.5 && d < capBest && V.y >= yLo && V.y <= yHi) {
|
|
|
|
|
capBest = d
|
|
|
|
|
cap = o.x1
|
|
|
|
|
}
|
|
|
|
|
} else if (!wantVertical && horizontal) {
|
|
|
|
|
const d = (o.y1 - V.y) * dirY
|
|
|
|
|
const xLo = Math.min(o.x1, o.x2) - 0.5
|
|
|
|
|
const xHi = Math.max(o.x1, o.x2) + 0.5
|
|
|
|
|
if (d > 0.5 && d < capBest && V.x >= xLo && V.x <= xHi) {
|
|
|
|
|
capBest = d
|
|
|
|
|
cap = o.y1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return cap
|
|
|
|
|
}
|
|
|
|
|
const xCap = capScan(true)
|
|
|
|
|
if (xEdge == null || yEdge == null) continue
|
|
|
|
|
// [KERAB-TYPE-EAVES-VALLEY-CAP-PREFER 2026-06-24] xEdge/yEdge 는 그대로 유지(=apex·힙 불변).
|
|
|
|
|
// 단지 박스의 우측변(B-3) 위치 이상으로 바깥(출구 쪽)에 지붕형상설정 단계 캡(B-1, kerabValleyOverlapLine)이
|
|
|
|
|
// 이미 존재하면, 그 캡이 끝점을 잇는 진짜 우측 경계이므로 중복 우측변(B-3)을 새로 만들지 않는다.
|
|
|
|
|
// 힙 기하는 전혀 건드리지 않고 중복 변(B-3)만 억제한다(사용자 확정 2026-06-24).
|
|
|
|
|
const hasOuterXCap = xCap != null && Math.abs(xCap - V.x) > Math.abs(xEdge - V.x) - 0.5
|
|
|
|
|
const c00 = { x: V.x, y: V.y }
|
|
|
|
|
const cX0 = { x: xEdge, y: V.y }
|
|
|
|
|
const cXY = { x: xEdge, y: yEdge }
|
|
|
|
|
const c0Y = { x: V.x, y: yEdge }
|
|
|
|
|
mkBoxEdge(c00, cX0, '-top')
|
|
|
|
|
if (!hasOuterXCap) mkBoxEdge(cX0, cXY, '-right')
|
|
|
|
|
mkBoxEdge(cXY, c0Y, '-bottom')
|
|
|
|
|
mkBoxEdge(c0Y, c00, '-left')
|
|
|
|
|
logger.log(
|
|
|
|
|
'[KERAB-TYPE-EAVES-VALLEY-BOX] ' +
|
|
|
|
|
JSON.stringify({ V: { x: Math.round(V.x), y: Math.round(V.y) }, xEdge: Math.round(xEdge), yEdge: Math.round(yEdge) }),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ensureTypeValleyBox()
|
|
|
|
|
const oppDist = oppRayDist(mid, ix, iy)
|
|
|
|
|
const apexDepth = Math.min(L / 2, Number.isFinite(oppDist) ? oppDist : L / 2)
|
|
|
|
|
const apex = { x: mid.x + ix * apexDepth, y: mid.y + iy * apexDepth }
|
|
|
|
|
@ -4379,6 +4600,18 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
// 박스 코너다. 일반 내부선(H-2 등, Bp 미공유)은 힙이 가로질러야 하므로 제외 → 정상 切妻
|
|
|
|
|
// (Bp=roofLine 코너, 공유 inner 없음)은 무영향. ridge(단축 대상)·invisible 제외.
|
|
|
|
|
const boxCorner = midIsA ? rB : rA
|
|
|
|
|
// [KERAB-TYPE-EAVES-RIDGE-VANISH-BOXGATE 2026-06-23] RG-1 삭제·박스닫기 후처리는 박스(겹침) 존재 케이스
|
|
|
|
|
// 전용. 박스 없는 일반 L자(box:[]) 에서 힙이 마루에 막혀도 RG-1 은 우진각 마루로 남아야 한다.
|
|
|
|
|
// (clipHipToBox 의 apex 클램프 분기도 이 값을 참조하므로 호출 전에 선계산한다.)
|
|
|
|
|
const boxExists = (canvas.getObjects() || []).some(
|
|
|
|
|
(o) =>
|
|
|
|
|
o &&
|
|
|
|
|
(o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine') &&
|
|
|
|
|
(o.parentId === roof.id || o.roofId === roof.id || o.attributes?.roofId === roof.id),
|
|
|
|
|
)
|
|
|
|
|
// [KERAB-TYPE-EAVES-CROSS-HIP-STOP-RIDGE 2026-06-23] 사용자 확정: 힙은 수평 hip(H-2/H-6 등)을
|
|
|
|
|
// *가로질러* 계속 뻗고, 세로 마루(RIDGE, RG-2 류)에서 멈춘다 — 마루가 상대 roofLine 역할.
|
|
|
|
|
// blocker = roofLine 변 · 마루(RIDGE)만 · 박스 변(상대 roofLine) · 형제 힙 meet(apex).
|
|
|
|
|
const clipHipToBox = (corner) => {
|
|
|
|
|
const dx0 = apex.x - corner.x
|
|
|
|
|
const dy0 = apex.y - corner.y
|
|
|
|
|
@ -4386,39 +4619,74 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
if (dlen < 1e-6) return { x: apex.x, y: apex.y }
|
|
|
|
|
const dx = dx0 / dlen
|
|
|
|
|
const dy = dy0 / dlen
|
|
|
|
|
let best = dlen
|
|
|
|
|
let bestPt = { x: apex.x, y: apex.y }
|
|
|
|
|
for (const il of roof.innerLines || []) {
|
|
|
|
|
if (!il || il === ridge || il.visible === false) continue
|
|
|
|
|
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
|
|
|
|
|
const sharesBp =
|
|
|
|
|
(Math.abs(il.x1 - boxCorner.x) < 0.5 && Math.abs(il.y1 - boxCorner.y) < 0.5) ||
|
|
|
|
|
(Math.abs(il.x2 - boxCorner.x) < 0.5 && Math.abs(il.y2 - boxCorner.y) < 0.5)
|
|
|
|
|
// [KERAB-TYPE-EAVES-HIPMEET 2026-06-23] 신규 힙은 (1) 박스 변(Bp 공유) 또는 (2) 직전 변경에서
|
|
|
|
|
// 생성된 기존 케라바 힙(kerabPatternHip)과의 최근접 교점에서 멈춘다 → ">" 모양. 둘 다 방향 무관
|
|
|
|
|
// 후보로 같은 best(t 최소) 누산기에 경쟁시켜 가장 가까운 교점이 절삭점이 된다. 1차 변경 때는
|
|
|
|
|
// 기존 힙이 없어 박스 변만 후보 → 동작 불변(무영향).
|
|
|
|
|
const isPriorHip = il.lineName === 'kerabPatternHip'
|
|
|
|
|
if (!sharesBp && !isPriorHip) continue
|
|
|
|
|
const ax = il.x2 - il.x1
|
|
|
|
|
const ay = il.y2 - il.y1
|
|
|
|
|
if (Math.hypot(ax, ay) < 1e-6) continue
|
|
|
|
|
let best = Infinity
|
|
|
|
|
let bestPt = null
|
|
|
|
|
const consider = (sx1, sy1, sx2, sy2) => {
|
|
|
|
|
const ax = sx2 - sx1
|
|
|
|
|
const ay = sy2 - sy1
|
|
|
|
|
if (Math.hypot(ax, ay) < 1e-6) return
|
|
|
|
|
const den = dx * ay - dy * ax
|
|
|
|
|
if (Math.abs(den) < 1e-9) continue
|
|
|
|
|
const t = ((il.x1 - corner.x) * ay - (il.y1 - corner.y) * ax) / den
|
|
|
|
|
const u = ((il.x1 - corner.x) * dy - (il.y1 - corner.y) * dx) / den
|
|
|
|
|
if (t > 1.0 && t < best - 1e-6 && u >= -1e-6 && u <= 1 + 1e-6) {
|
|
|
|
|
if (Math.abs(den) < 1e-9) return
|
|
|
|
|
const t = ((sx1 - corner.x) * ay - (sy1 - corner.y) * ax) / den
|
|
|
|
|
const u = ((sx1 - corner.x) * dy - (sy1 - corner.y) * dx) / den
|
|
|
|
|
if (t > 1.0 && u >= -1e-6 && u <= 1 + 1e-6 && t < best - 1e-6) {
|
|
|
|
|
best = t
|
|
|
|
|
bestPt = { x: corner.x + dx * t, y: corner.y + dy * t }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return bestPt
|
|
|
|
|
for (let i = 0; i < cps.length; i++) {
|
|
|
|
|
consider(cps[i].x, cps[i].y, cps[(i + 1) % cps.length].x, cps[(i + 1) % cps.length].y)
|
|
|
|
|
}
|
|
|
|
|
// 세로 마루(RIDGE)만 blocker — 수평 hip 은 가로질러 통과.
|
|
|
|
|
for (const il of roof.innerLines || []) {
|
|
|
|
|
if (!il || il === ridge || il.visible === false) continue
|
|
|
|
|
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
|
|
|
|
|
consider(il.x1, il.y1, il.x2, il.y2)
|
|
|
|
|
}
|
|
|
|
|
const boxSegs = (canvas.getObjects() || []).filter(
|
|
|
|
|
(o) =>
|
|
|
|
|
o &&
|
|
|
|
|
(o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine') &&
|
|
|
|
|
(o.parentId === roof.id || o.roofId === roof.id || o.attributes?.roofId === roof.id),
|
|
|
|
|
)
|
|
|
|
|
// [KERAB-TYPE-EAVES-VALLEY-BOX-EXIT 2026-06-24] 박스=상대 roofLine. 힙은 박스 진입변에서 멈추지 않고
|
|
|
|
|
// 관통해서 진출변(ray 방향 먼 교점=B-3, 실제 힙 H-2 와 일치)에서 끝난다. 진입변에서 자르면 HE-2 가
|
|
|
|
|
// H-2 못미쳐 dangling. 단 박스 진출 t 가 하드 blocker(roofLine/마루 best)보다 멀면 하드가 우선.
|
|
|
|
|
const boxTs = []
|
|
|
|
|
for (const o of boxSegs) {
|
|
|
|
|
const ax = o.x2 - o.x1
|
|
|
|
|
const ay = o.y2 - o.y1
|
|
|
|
|
if (Math.hypot(ax, ay) < 1e-6) continue
|
|
|
|
|
const den = dx * ay - dy * ax
|
|
|
|
|
if (Math.abs(den) < 1e-9) continue
|
|
|
|
|
const t = ((o.x1 - corner.x) * ay - (o.y1 - corner.y) * ax) / den
|
|
|
|
|
const u = ((o.x1 - corner.x) * dy - (o.y1 - corner.y) * dx) / den
|
|
|
|
|
if (t > 1.0 && u >= -1e-6 && u <= 1 + 1e-6) boxTs.push(t)
|
|
|
|
|
}
|
|
|
|
|
if (boxTs.length) {
|
|
|
|
|
boxTs.sort((a, b) => a - b)
|
|
|
|
|
const tExit = boxTs[boxTs.length - 1]
|
|
|
|
|
if (tExit < best - 1e-6) {
|
|
|
|
|
best = tExit
|
|
|
|
|
bestPt = { x: corner.x + dx * tExit, y: corner.y + dy * tExit }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// [KERAB-TYPE-EAVES-APEX-CLAMP-BOXGATE 2026-06-24] apex 는 "x=mid 마루의 바닥(L/2)" 으로 설계된 점.
|
|
|
|
|
// 박스(겹침) 케이스는 그 마루를 삭제(ridgeDeleted)하므로 apex 가 허공의 유령점이 된다. 이때
|
|
|
|
|
// 방해선 없는 힙(HE-1)을 apex 에 클램프하면 마루도 짝힙도 없는 곳에 끝점이 떠 버린다(공중 dangling).
|
|
|
|
|
// → 박스 케이스에서는 apex 클램프를 풀어 실제 blocker(살아있는 마루 R-1 등)까지 뻗게 한다.
|
|
|
|
|
// 정상 대칭 切妻(box 없음)은 종전대로 apex 에서 짝힙과 만나야 하므로 클램프 유지.
|
|
|
|
|
if (!boxExists && dlen < best - 1e-6) {
|
|
|
|
|
best = dlen
|
|
|
|
|
bestPt = { x: apex.x, y: apex.y }
|
|
|
|
|
}
|
|
|
|
|
return bestPt || { x: apex.x, y: apex.y }
|
|
|
|
|
}
|
|
|
|
|
// 박스 절삭점 선계산 → 마루 처리 분기 판단에 사용.
|
|
|
|
|
const endA = clipHipToBox(wA)
|
|
|
|
|
const endB = clipHipToBox(wB)
|
|
|
|
|
const hipClipped = Math.hypot(endA.x - apex.x, endA.y - apex.y) > 1 || Math.hypot(endB.x - apex.x, endB.y - apex.y) > 1
|
|
|
|
|
if (hipClipped) {
|
|
|
|
|
if (hipClipped && boxExists) {
|
|
|
|
|
// [KERAB-TYPE-EAVES-RIDGE-VANISH 2026-06-23] 박스(겹침)가 상대 roofLine 이라 신규 힙이 박스/직전 힙에서
|
|
|
|
|
// 멈추면, 가운데 마루(RG-1)는 양쪽 코너 힙이 이미 생성됐으므로 "삭제 대상"이다(사용자 규칙 2026-06-23:
|
|
|
|
|
// "RG-1 은 마루 = 삭제, 양쪽에 힙 생성됨"). visible=false 숨기기는 객체가 innerLines 에 남아
|
|
|
|
|
@ -4473,6 +4741,54 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
hip1.bringToFront()
|
|
|
|
|
hip2.bringToFront()
|
|
|
|
|
roof.innerLines.push(hip1, hip2)
|
|
|
|
|
// [KERAB-TYPE-EAVES-RIDGE-CUT 2026-06-24] 신규 힙(HE-1)이 살아있는 마루(RIDGE)에 T자로 착지하면,
|
|
|
|
|
// 표준 寄棟 규칙상 마루는 그 교점에서 종료한다. 교점 위쪽(닫히는 쪽=apex 측) 구간은 힙 삼각면에
|
|
|
|
|
// 먹히므로 잘라낸다(끝점을 교점으로 단축). 박스 안을 지나던 마루 상단부가 제거돼 R-CROSS 도 동시 해소.
|
|
|
|
|
// 박스(겹침) 케이스 전용 — 정상 대칭 切妻(box 없음)은 힙이 apex 에서 만나 마루 중간 착지가 없다.
|
|
|
|
|
const truncateRidgeAtHipJunction = (end) => {
|
|
|
|
|
const ON_TOL = 1.5
|
|
|
|
|
for (const il of roof.innerLines || []) {
|
|
|
|
|
if (!il || il === ridge || il.visible === false) continue
|
|
|
|
|
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
|
|
|
|
|
const dx = il.x2 - il.x1
|
|
|
|
|
const dy = il.y2 - il.y1
|
|
|
|
|
const len2 = dx * dx + dy * dy
|
|
|
|
|
if (len2 < 1e-6) continue
|
|
|
|
|
const t = ((end.x - il.x1) * dx + (end.y - il.y1) * dy) / len2
|
|
|
|
|
const prx = il.x1 + t * dx
|
|
|
|
|
const pry = il.y1 + t * dy
|
|
|
|
|
if (Math.hypot(end.x - prx, end.y - pry) > ON_TOL) continue
|
|
|
|
|
// 교점이 마루 끝점이면(이미 끝에서 만남) 단축 불필요.
|
|
|
|
|
if (t <= 0.02 || t >= 0.98) continue
|
|
|
|
|
// 두 끝점 중 apex(닫히는 쪽)에 가까운 끝을 교점으로 단축 → 위쪽 구간 제거.
|
|
|
|
|
const d1 = Math.hypot(il.x1 - apex.x, il.y1 - apex.y)
|
|
|
|
|
const d2 = Math.hypot(il.x2 - apex.x, il.y2 - apex.y)
|
|
|
|
|
if (d1 <= d2) il.set({ x1: end.x, y1: end.y })
|
|
|
|
|
else il.set({ x2: end.x, y2: end.y })
|
|
|
|
|
il.startPoint = { x: il.x1, y: il.y1 }
|
|
|
|
|
il.endPoint = { x: il.x2, y: il.y2 }
|
|
|
|
|
const rs = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
|
|
|
|
|
if (il.attributes) {
|
|
|
|
|
il.attributes.planeSize = rs
|
|
|
|
|
il.attributes.actualSize = rs
|
|
|
|
|
}
|
|
|
|
|
if (typeof il.setCoords === 'function') il.setCoords()
|
|
|
|
|
if (typeof il.setLength === 'function') il.setLength()
|
|
|
|
|
if (typeof il.addLengthText === 'function') il.addLengthText()
|
|
|
|
|
logger.log(
|
|
|
|
|
'[KERAB-TYPE-EAVES-RIDGE-CUT] ' +
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
at: { x: Math.round(end.x), y: Math.round(end.y) },
|
|
|
|
|
ridge: { x1: Math.round(il.x1), y1: Math.round(il.y1), x2: Math.round(il.x2), y2: Math.round(il.y2) },
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (boxExists) {
|
|
|
|
|
truncateRidgeAtHipJunction(endA)
|
|
|
|
|
truncateRidgeAtHipJunction(endB)
|
|
|
|
|
}
|
|
|
|
|
removeKerabHalfLabels(target.id)
|
|
|
|
|
hideOriginalLengthText(target.id, false)
|
|
|
|
|
canvas.renderAll()
|
|
|
|
|
@ -4485,31 +4801,19 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
inward: { ix: Number(ix.toFixed(3)), iy: Number(iy.toFixed(3)) },
|
|
|
|
|
hipA: { x: Math.round(hip1.x2), y: Math.round(hip1.y2), clipped: Math.hypot(hip1.x2 - apex.x, hip1.y2 - apex.y) > 1 },
|
|
|
|
|
hipB: { x: Math.round(hip2.x2), y: Math.round(hip2.y2), clipped: Math.hypot(hip2.x2 - apex.x, hip2.y2 - apex.y) > 1 },
|
|
|
|
|
ridgeDeleted: hipClipped,
|
|
|
|
|
ridgeDeleted: hipClipped && boxExists,
|
|
|
|
|
ridgeHidden: ridge.visible === false,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
// [KERAB-LABEL-MAP 2026-06-23] 경로 A(일반 라인→처마) 진단 — 화면 라벨(H-n/RG-n/V-n…)↔좌표 맵.
|
|
|
|
|
// 골짜기 박스(kerabValleyOverlapLine)는 V 로 분류되어 V-n 으로 노출 → 힙이 박스를 통과/멈추는지
|
|
|
|
|
// 라벨로 대조하기 위함(local 전용, 로깅만).
|
|
|
|
|
// [KERAB-LABEL-MAP 2026-06-23] 경로 A(일반 라인→처마) 진단 — 화면 라벨(L/H/W/R/RE/HE/B…)↔좌표 맵.
|
|
|
|
|
// 골짜기 박스(kerabValleyOverlapLine)는 B 로 분류 → 힙이 박스를 통과/멈추는지 라벨로 대조(local 전용, 로깅만).
|
|
|
|
|
try {
|
|
|
|
|
const classify = (o) => {
|
|
|
|
|
const nm = o.name
|
|
|
|
|
const ln = o.lineName
|
|
|
|
|
if (nm === 'baseLine') return 'B'
|
|
|
|
|
if (nm === 'outerLine' || nm === 'eaves' || ln === 'roofLine') return 'R'
|
|
|
|
|
if (nm === LINE_TYPE.SUBLINE.HIP || ln === LINE_TYPE.SUBLINE.HIP) return 'H'
|
|
|
|
|
if (nm === LINE_TYPE.SUBLINE.RIDGE || ln === LINE_TYPE.SUBLINE.RIDGE) return 'RG'
|
|
|
|
|
if (nm === LINE_TYPE.SUBLINE.VALLEY || ln === LINE_TYPE.SUBLINE.VALLEY) return 'V'
|
|
|
|
|
if (nm === LINE_TYPE.SUBLINE.GABLE || ln === LINE_TYPE.SUBLINE.GABLE) return 'G'
|
|
|
|
|
if (typeof o.x1 === 'number' && typeof o.y1 === 'number' && typeof o.x2 === 'number' && typeof o.y2 === 'number') return 'SK'
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
const ctr = {}
|
|
|
|
|
const lmap = []
|
|
|
|
|
const bEdges = getRoofBoundaryEdges(canvas, roof.id)
|
|
|
|
|
for (const o of canvas.getObjects()) {
|
|
|
|
|
if (!o || o.parentId !== roof.id || o.name === '__debugLabel') continue
|
|
|
|
|
const pfx = classify(o)
|
|
|
|
|
const pfx = classifyLineForLabel(o, bEdges)
|
|
|
|
|
if (!pfx) continue
|
|
|
|
|
ctr[pfx] = (ctr[pfx] || 0) + 1
|
|
|
|
|
lmap.push({
|
|
|
|
|
@ -4557,12 +4861,62 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
} catch (e) {
|
|
|
|
|
logger.warn('[KERAB-BOX-DUMP] failed ' + (e?.message || e))
|
|
|
|
|
}
|
|
|
|
|
// [KERAB-HIP-RAY 2026-06-23] 진단 — 각 신규 힙의 corner→apex 45° ray 가 *모든* 내부선과 어디서
|
|
|
|
|
// 교차하는지(거리순)를 라인 라벨·좌표와 함께 덤프. 사용자: "왼쪽 힙이 박스 통과" ↔ 현재 좌표모델:
|
|
|
|
|
// ray 가 박스(662,236)-(747,266) 안으로 안 들어감. 모순을 좌표로 검증하기 위함. 결정적 사실:
|
|
|
|
|
// 박스가 kerabValleyOverlapLine 객체가 아니라 일반 SK 내부선(H-7/RG-2/H-2 + 결손 우변)으로
|
|
|
|
|
// 구성돼 boxSegs=[] → 박스 절삭 로직이 발동 안 함. local 전용, 로깅만.
|
|
|
|
|
try {
|
|
|
|
|
const rayCrossings = (corner) => {
|
|
|
|
|
const dx0 = apex.x - corner.x
|
|
|
|
|
const dy0 = apex.y - corner.y
|
|
|
|
|
const dlen = Math.hypot(dx0, dy0)
|
|
|
|
|
if (dlen < 1e-6) return []
|
|
|
|
|
const dx = dx0 / dlen
|
|
|
|
|
const dy = dy0 / dlen
|
|
|
|
|
const hits = []
|
|
|
|
|
let idx = 0
|
|
|
|
|
for (const il of roof.innerLines || []) {
|
|
|
|
|
idx++
|
|
|
|
|
if (!il || il === ridge) continue
|
|
|
|
|
const ax = il.x2 - il.x1
|
|
|
|
|
const ay = il.y2 - il.y1
|
|
|
|
|
if (Math.hypot(ax, ay) < 1e-6) continue
|
|
|
|
|
const den = dx * ay - dy * ax
|
|
|
|
|
if (Math.abs(den) < 1e-9) continue
|
|
|
|
|
const t = ((il.x1 - corner.x) * ay - (il.y1 - corner.y) * ax) / den
|
|
|
|
|
const u = ((il.x1 - corner.x) * dy - (il.y1 - corner.y) * dx) / den
|
|
|
|
|
if (t > 1.0 && t < dlen + 1 && u >= -1e-6 && u <= 1 + 1e-6) {
|
|
|
|
|
hits.push({
|
|
|
|
|
at: { x: Math.round(corner.x + dx * t), y: Math.round(corner.y + dy * t) },
|
|
|
|
|
d: Math.round(t),
|
|
|
|
|
name: il.name || null,
|
|
|
|
|
ln: il.lineName || null,
|
|
|
|
|
vis: il.visible !== false,
|
|
|
|
|
seg: { x1: Math.round(il.x1), y1: Math.round(il.y1), x2: Math.round(il.x2), y2: Math.round(il.y2) },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
hits.sort((a, b) => a.d - b.d)
|
|
|
|
|
return hits
|
|
|
|
|
}
|
|
|
|
|
logger.log(
|
|
|
|
|
'[KERAB-HIP-RAY] ' +
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
apex: { x: Math.round(apex.x), y: Math.round(apex.y) },
|
|
|
|
|
wA: { x: Math.round(wA.x), y: Math.round(wA.y), crossings: rayCrossings(wA) },
|
|
|
|
|
wB: { x: Math.round(wB.x), y: Math.round(wB.y), crossings: rayCrossings(wB) },
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
logger.warn('[KERAB-HIP-RAY] failed ' + (e?.message || e))
|
|
|
|
|
}
|
|
|
|
|
// [KERAB-TYPE-EAVES-RIDGE-VANISH-FLAG 2026-06-23] 마루(RG-1)를 삭제한 케이스. moveStaleEdgeInnerLines
|
|
|
|
|
// 의 relocate 가 이 플래그로 박스 세로선(H-8)의 과길게 뻗은 끝점을 박스 바닥(boxCorner=RG-1 의
|
|
|
|
|
// 박스측 끝점, 예 (747,266))으로 내려 H-7↔H-2 사이만 막도록 만든다(사용자 규칙 2026-06-23:
|
|
|
|
|
// "H-8 은 힙이 아니라 박스를 막는 세로 roofLine, H-2 와 H-7 사이만").
|
|
|
|
|
apex.ridgeHidden = hipClipped
|
|
|
|
|
apex.boxCorner = hipClipped ? boxCorner : null
|
|
|
|
|
apex.ridgeHidden = hipClipped && boxExists
|
|
|
|
|
apex.boxCorner = hipClipped && boxExists ? boxCorner : null
|
|
|
|
|
return apex
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -4714,6 +5068,28 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
hit = { x: P.x + dir.x * t, y: P.y + dir.y * t }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// [KERAB-TYPE-EAVES-VALLEY-BOX 2026-06-24] 골짜기 박스(kerabValleyOverlapLine)는 상대 roofLine →
|
|
|
|
|
// 힙은 박스 변에서 멈춰야 한다. roof.points 변보다 박스 변이 더 가까우면 거기서 절삭(관통 방지).
|
|
|
|
|
const boxSegs = (canvas.getObjects() || []).filter(
|
|
|
|
|
(o) =>
|
|
|
|
|
o &&
|
|
|
|
|
(o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine') &&
|
|
|
|
|
(o.parentId === roof.id || o.roofId === roof.id || o.attributes?.roofId === roof.id),
|
|
|
|
|
)
|
|
|
|
|
// [KERAB-TYPE-EAVES-VALLEY-BOX-EXIT 2026-06-24] 박스=상대 roofLine → 힙은 관통 후 진출변(먼 교점)에서 종료.
|
|
|
|
|
const boxTs = []
|
|
|
|
|
for (const o of boxSegs) {
|
|
|
|
|
const t = rayHit(P, dir, { x: o.x1, y: o.y1 }, { x: o.x2, y: o.y2 })
|
|
|
|
|
if (t < Infinity) boxTs.push(t)
|
|
|
|
|
}
|
|
|
|
|
if (boxTs.length) {
|
|
|
|
|
boxTs.sort((a, b) => a - b)
|
|
|
|
|
const tExit = boxTs[boxTs.length - 1]
|
|
|
|
|
if (tExit < best) {
|
|
|
|
|
best = tExit
|
|
|
|
|
hit = { x: P.x + dir.x * tExit, y: P.y + dir.y * tExit }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!hit) continue
|
|
|
|
|
// 힙 = apex → hit (단일 직선 45°).
|
|
|
|
|
if (apexIsE1) il.set({ x1: apex.x, y1: apex.y, x2: hit.x, y2: hit.y })
|
|
|
|
|
@ -4866,6 +5242,165 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [KERAB-TYPE-EAVES-HIP-CROSS-CLIP 2026-06-24 Step1] 교차하는 두 힙을 교점에서 절삭.
|
|
|
|
|
// 체크함수(kerab-rule-checker R-CROSS)와 동일한 원리: 보이는 kerabPatternHip 쌍을 따라가며 *내부*
|
|
|
|
|
// 교차(둘 다 끝점 제외)를 segCross 로 찾고, 골짜기 박스(겹침=상대 roofLine) 안 교점은 제외한다.
|
|
|
|
|
// 교점에서 각 힙의 inner(폴리곤 중심쪽) 끝을 교점으로 단축 = "교점 이하 절삭". 마루 생성은 후속 단계.
|
|
|
|
|
const clipCrossedHipsAtIntersection = (roof) => {
|
|
|
|
|
if (!roof || !Array.isArray(roof.innerLines) || !Array.isArray(roof.points)) return false
|
|
|
|
|
const hips = roof.innerLines.filter((il) => il && il.lineName === 'kerabPatternHip' && il.visible !== false)
|
|
|
|
|
if (hips.length < 2) return false
|
|
|
|
|
// 박스(겹침) bbox + 패딩 — 박스 안 교차는 규칙상 제외(상대 roofLine 이 둘을 막음).
|
|
|
|
|
const PAD = 1.0
|
|
|
|
|
const boxLines = (canvas.getObjects() || []).filter(
|
|
|
|
|
(o) =>
|
|
|
|
|
o &&
|
|
|
|
|
(o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine') &&
|
|
|
|
|
(o.parentId === roof.id || o.roofId === roof.id || o.attributes?.roofId === roof.id),
|
|
|
|
|
)
|
|
|
|
|
let bx0 = Infinity
|
|
|
|
|
let by0 = Infinity
|
|
|
|
|
let bx1 = -Infinity
|
|
|
|
|
let by1 = -Infinity
|
|
|
|
|
for (const o of boxLines) {
|
|
|
|
|
bx0 = Math.min(bx0, o.x1, o.x2)
|
|
|
|
|
by0 = Math.min(by0, o.y1, o.y2)
|
|
|
|
|
bx1 = Math.max(bx1, o.x1, o.x2)
|
|
|
|
|
by1 = Math.max(by1, o.y1, o.y2)
|
|
|
|
|
}
|
|
|
|
|
const hasBox = boxLines.length > 0
|
|
|
|
|
const inBox = (p) => hasBox && p.x >= bx0 - PAD && p.x <= bx1 + PAD && p.y >= by0 - PAD && p.y <= by1 + PAD
|
|
|
|
|
// 폴리곤 중심(코너 outer 판별).
|
|
|
|
|
let cx = 0
|
|
|
|
|
let cy = 0
|
|
|
|
|
for (const p of roof.points) {
|
|
|
|
|
cx += p.x
|
|
|
|
|
cy += p.y
|
|
|
|
|
}
|
|
|
|
|
cx /= roof.points.length || 1
|
|
|
|
|
cy /= roof.points.length || 1
|
|
|
|
|
// 내부 교차(끝점 접합 제외) — 체크함수 segCross 와 동치.
|
|
|
|
|
const segCross = (a, b) => {
|
|
|
|
|
const rx = a.x2 - a.x1
|
|
|
|
|
const ry = a.y2 - a.y1
|
|
|
|
|
const sx = b.x2 - b.x1
|
|
|
|
|
const sy = b.y2 - b.y1
|
|
|
|
|
const den = rx * sy - ry * sx
|
|
|
|
|
if (Math.abs(den) < 1e-9) return null
|
|
|
|
|
const t = ((b.x1 - a.x1) * sy - (b.y1 - a.y1) * sx) / den
|
|
|
|
|
const u = ((b.x1 - a.x1) * ry - (b.y1 - a.y1) * rx) / den
|
|
|
|
|
const m = 1e-3
|
|
|
|
|
if (t > m && t < 1 - m && u > m && u < 1 - m) return { x: a.x1 + t * rx, y: a.y1 + t * ry }
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
const clipInnerEnd = (hip, p) => {
|
|
|
|
|
const d1 = Math.hypot(hip.x1 - cx, hip.y1 - cy)
|
|
|
|
|
const d2 = Math.hypot(hip.x2 - cx, hip.y2 - cy)
|
|
|
|
|
if (d1 >= d2) hip.set({ x2: p.x, y2: p.y })
|
|
|
|
|
else hip.set({ x1: p.x, y1: p.y })
|
|
|
|
|
hip.startPoint = { x: hip.x1, y: hip.y1 }
|
|
|
|
|
hip.endPoint = { x: hip.x2, y: hip.y2 }
|
|
|
|
|
const sz = calcLinePlaneSize({ x1: hip.x1, y1: hip.y1, x2: hip.x2, y2: hip.y2 })
|
|
|
|
|
if (hip.attributes) {
|
|
|
|
|
hip.attributes.planeSize = sz
|
|
|
|
|
hip.attributes.actualSize = sz
|
|
|
|
|
}
|
|
|
|
|
if (typeof hip.setCoords === 'function') hip.setCoords()
|
|
|
|
|
if (typeof hip.setLength === 'function') hip.setLength()
|
|
|
|
|
if (typeof hip.addLengthText === 'function') hip.addLengthText()
|
|
|
|
|
}
|
|
|
|
|
let clipped = 0
|
|
|
|
|
const clipEvents = []
|
|
|
|
|
for (let i = 0; i < hips.length; i++) {
|
|
|
|
|
for (let k = i + 1; k < hips.length; k++) {
|
|
|
|
|
const ca = { x1: hips[i].x1, y1: hips[i].y1, x2: hips[i].x2, y2: hips[i].y2 }
|
|
|
|
|
const cb = { x1: hips[k].x1, y1: hips[k].y1, x2: hips[k].x2, y2: hips[k].y2 }
|
|
|
|
|
const ip = segCross(ca, cb)
|
|
|
|
|
if (!ip || inBox(ip)) continue
|
|
|
|
|
clipInnerEnd(hips[i], ip)
|
|
|
|
|
clipInnerEnd(hips[k], ip)
|
|
|
|
|
clipped++
|
|
|
|
|
clipEvents.push({ p: ip, a: hips[i], b: hips[k] })
|
|
|
|
|
logger.log('[KERAB-TYPE-EAVES-HIP-CROSS-CLIP] ' + JSON.stringify({ at: { x: Math.round(ip.x), y: Math.round(ip.y) } }))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// [KERAB-TYPE-EAVES-HIP-CROSS-CLIP 2026-06-24 Step2] `<` 꼭지점(교점)에서 마루 생성.
|
|
|
|
|
// 두 절삭 힙의 바깥방향 이등분선을 축에 스냅(마루는 수평/수직) → 그 반대(폴리곤 안쪽)로 마루를 긋고,
|
|
|
|
|
// 안쪽 ray 가 가장 먼저 만나는 다른 kerabPatternHip 에서 멈춰 그 힙을 교점에서 절삭 = "--<" 표준 형상.
|
|
|
|
|
for (const ev of clipEvents) {
|
|
|
|
|
const otherEnd = (hip) => {
|
|
|
|
|
const d1 = Math.hypot(hip.x1 - ev.p.x, hip.y1 - ev.p.y)
|
|
|
|
|
const d2 = Math.hypot(hip.x2 - ev.p.x, hip.y2 - ev.p.y)
|
|
|
|
|
return d1 >= d2 ? { x: hip.x1, y: hip.y1 } : { x: hip.x2, y: hip.y2 }
|
|
|
|
|
}
|
|
|
|
|
const norm = (q) => {
|
|
|
|
|
const dx = q.x - ev.p.x
|
|
|
|
|
const dy = q.y - ev.p.y
|
|
|
|
|
const L = Math.hypot(dx, dy) || 1
|
|
|
|
|
return { x: dx / L, y: dy / L }
|
|
|
|
|
}
|
|
|
|
|
const na = norm(otherEnd(ev.a))
|
|
|
|
|
const nb = norm(otherEnd(ev.b))
|
|
|
|
|
const bisX = na.x + nb.x // `<` 바깥쪽 이등분선
|
|
|
|
|
const bisY = na.y + nb.y
|
|
|
|
|
// 마루 방향 = 이등분선 반대(안쪽) + 축 스냅.
|
|
|
|
|
const dir = Math.abs(bisX) >= Math.abs(bisY) ? { x: bisX > 0 ? -1 : 1, y: 0 } : { x: 0, y: bisY > 0 ? -1 : 1 }
|
|
|
|
|
const raySeg = (s1, s2) => {
|
|
|
|
|
const sx = s2.x - s1.x
|
|
|
|
|
const sy = s2.y - s1.y
|
|
|
|
|
const den = dir.x * sy - dir.y * sx
|
|
|
|
|
if (Math.abs(den) < 1e-9) return null
|
|
|
|
|
const t = ((s1.x - ev.p.x) * sy - (s1.y - ev.p.y) * sx) / den
|
|
|
|
|
const u = ((s1.x - ev.p.x) * dir.y - (s1.y - ev.p.y) * dir.x) / den
|
|
|
|
|
if (t > 0.5 && u > 1e-3 && u < 1 - 1e-3) return { x: ev.p.x + dir.x * t, y: ev.p.y + dir.y * t, t }
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
let best = null
|
|
|
|
|
let bestHip = null
|
|
|
|
|
for (const h of hips) {
|
|
|
|
|
if (h === ev.a || h === ev.b || h.visible === false) continue
|
|
|
|
|
const q = raySeg({ x: h.x1, y: h.y1 }, { x: h.x2, y: h.y2 })
|
|
|
|
|
if (!q) continue
|
|
|
|
|
if (!best || q.t < best.t) {
|
|
|
|
|
best = q
|
|
|
|
|
bestHip = h
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!best) {
|
|
|
|
|
logger.log('[KERAB-TYPE-EAVES-HIP-CROSS-CLIP] Step2 no-hip-met ' + JSON.stringify({ from: { x: Math.round(ev.p.x), y: Math.round(ev.p.y) }, dir }))
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
const q = { x: best.x, y: best.y }
|
|
|
|
|
clipInnerEnd(bestHip, q)
|
|
|
|
|
const rpts = [ev.p.x, ev.p.y, q.x, q.y]
|
|
|
|
|
const rsz = calcLinePlaneSize({ x1: rpts[0], y1: rpts[1], x2: rpts[2], y2: rpts[3] })
|
|
|
|
|
const ridge = new QLine(rpts, {
|
|
|
|
|
parentId: roof.id,
|
|
|
|
|
fontSize: roof.fontSize,
|
|
|
|
|
stroke: '#1083E3',
|
|
|
|
|
strokeWidth: 3,
|
|
|
|
|
name: LINE_TYPE.SUBLINE.RIDGE,
|
|
|
|
|
textMode: roof.textMode,
|
|
|
|
|
attributes: { roofId: roof.id, type: LINE_TYPE.SUBLINE.RIDGE, planeSize: rsz, actualSize: rsz },
|
|
|
|
|
})
|
|
|
|
|
ridge.lineName = 'ridge'
|
|
|
|
|
ridge.startPoint = { x: rpts[0], y: rpts[1] }
|
|
|
|
|
ridge.endPoint = { x: rpts[2], y: rpts[3] }
|
|
|
|
|
canvas.add(ridge)
|
|
|
|
|
ridge.bringToFront()
|
|
|
|
|
roof.innerLines.push(ridge)
|
|
|
|
|
if (typeof ridge.setCoords === 'function') ridge.setCoords()
|
|
|
|
|
if (typeof ridge.setLength === 'function') ridge.setLength()
|
|
|
|
|
if (typeof ridge.addLengthText === 'function') ridge.addLengthText()
|
|
|
|
|
logger.log(
|
|
|
|
|
'[KERAB-TYPE-EAVES-HIP-CROSS-CLIP] Step2 ridge ' +
|
|
|
|
|
JSON.stringify({ from: { x: Math.round(ev.p.x), y: Math.round(ev.p.y) }, to: { x: Math.round(q.x), y: Math.round(q.y) }, dir }),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
if (clipped) canvas.renderAll()
|
|
|
|
|
return clipped > 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [KERAB-HALF-LABEL 2026-05-19] 케라바 외곽선의 좌우/상하 절반 길이 라벨
|
|
|
|
|
// 그림 그릴 단계에서는 외곽선은 1개로 유지하고 라벨만 2개 노출.
|
|
|
|
|
// 할당 시 splitPolygonWithLines 가 자연 분할하므로 그때는 사라지고 분할 라인 라벨이 대체.
|
|
|
|
|
|