[2294] 체크포인트 — 타입 박공→처마 라인변경 RG-1 실삭제 + H-8 박스막음
[작업내용] : - 마루(RG-1) hide 대신 canvas.remove + innerLines splice 로 실제 삭제 (LABEL-MAP·디버그 라벨이 visible 무시 → hide 는 검증에서 잔존) - H-8 박스막음: 과길게 뻗은 끝점을 boxCorner 로 내려 H-7↔H-2 사이만 닫는 짧은 세로 roofLine 으로 보정 (\|/ 수렴형상 금지) Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
parent
591f467548
commit
5279f35b7b
@ -17,6 +17,7 @@ import { reattachDebugLabels } from '@/components/fabric/QPolygon'
|
|||||||
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
|
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
|
||||||
import { findInteriorPoint } from '@/util/skeleton-utils'
|
import { findInteriorPoint } from '@/util/skeleton-utils'
|
||||||
import { logger } from '@/util/logger'
|
import { logger } from '@/util/logger'
|
||||||
|
import { checkKerabRules } from '@/util/kerab-rule-checker'
|
||||||
import { debugCapture } from '@/util/debugCapture'
|
import { debugCapture } from '@/util/debugCapture'
|
||||||
import { applyKerabOffsetSurgical } from '@/util/kerab-offset-surgical'
|
import { applyKerabOffsetSurgical } from '@/util/kerab-offset-surgical'
|
||||||
|
|
||||||
@ -459,6 +460,8 @@ export function useEavesGableEdit(id) {
|
|||||||
} else {
|
} else {
|
||||||
logger.log('[KERAB-RULE-CHECK] ' + phase + ' PASS')
|
logger.log('[KERAB-RULE-CHECK] ' + phase + ' PASS')
|
||||||
}
|
}
|
||||||
|
// 기하 불변식 체커(R-45HIP/R-AXISRIDGE/R-CROSS/R-WEDGE). 위 R1~R4 와 상호보완.
|
||||||
|
checkKerabRules(roof.innerLines, { roofId: roof.id, label: phase })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('[KERAB-RULE-CHECK] error', err)
|
logger.warn('[KERAB-RULE-CHECK] error', err)
|
||||||
}
|
}
|
||||||
@ -2749,6 +2752,8 @@ export function useEavesGableEdit(id) {
|
|||||||
// 변은 出幅만큼 평행이동(delta)했으므로, 옛 변 위 끝점만 동일 delta 로 평행이동(= NORMAL-ABS 동치).
|
// 변은 出幅만큼 평행이동(delta)했으므로, 옛 변 위 끝점만 동일 delta 로 평행이동(= NORMAL-ABS 동치).
|
||||||
// 단, cMid(변 중점)에 걸린 비-collinear 선의 끝점은 apex 로 수렴해야 한다(힙 사이 마루 금지).
|
// 단, cMid(변 중점)에 걸린 비-collinear 선의 끝점은 apex 로 수렴해야 한다(힙 사이 마루 금지).
|
||||||
// 케라바패턴 라인(내 hip)은 surgicalAfter 의 ray-cast 가 이미 처리 → 제외.
|
// 케라바패턴 라인(내 hip)은 surgicalAfter 의 ray-cast 가 이미 처리 → 제외.
|
||||||
|
// [KERAB-TYPE-EAVES-RIDGE-VANISH-FLAG 2026-06-23] apexRes 를 항상 넘긴다. relocate 가 apexRes.ridgeHidden
|
||||||
|
// 플래그로 골짜기 세로 힙(H-8) 끝점을 apex 로 끌어올리지 않고 제자리 앵커(위로 반전 방지)하기 때문.
|
||||||
moveStaleEdgeInnerLines(roof, c1, c2, _edgeIdx, apexRes)
|
moveStaleEdgeInnerLines(roof, c1, c2, _edgeIdx, apexRes)
|
||||||
// [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] apex 는 벽기준 고정. 힙을 벽교점 방향 45° 그대로 roofLine 까지 직선 연장.
|
// [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] apex 는 벽기준 고정. 힙을 벽교점 방향 45° 그대로 roofLine 까지 직선 연장.
|
||||||
extendTypeGableHipsStraightToRoofLine(roof, target, apexRes)
|
extendTypeGableHipsStraightToRoofLine(roof, target, apexRes)
|
||||||
@ -3146,12 +3151,325 @@ export function useEavesGableEdit(id) {
|
|||||||
}
|
}
|
||||||
return touched
|
return touched
|
||||||
}
|
}
|
||||||
const ridgeOps = resolveRidgeEndpoints()
|
// 5a') 골짜기 마름모 생성 — A,B타입 골짜기 라인은 케라바+처마 조합이라
|
||||||
|
// 라인변경(케라바→처마) 시 두 평행 힙( / / ) + 잇는 마루 = 마름모(힙—마루—힙)가 되어야 한다.
|
||||||
|
// 두 힙은 *같은 방향*(평행)이고 *같은 깊이*면 끝점을 잇는 마루가 골짜기 라인과 평행
|
||||||
|
// = 항상 수평/수직(마루 비대각 불변식 충족). 깊이 = 두 raycast hit 중 *짧은 쪽(min)*.
|
||||||
|
// 짧은 쪽 = 먼저 닫히는 인접면 — 긴 쪽에 맞추면 짧은 면을 넘어 ><(R3 위반)이 되므로 min.
|
||||||
|
// (사용자 확정 2026-06-22: 가로·세로 골짜기 변경 시 양쪽 다 마름모 생성.)
|
||||||
|
// 마름모 깊이 산출 진단(로그/검증용).
|
||||||
|
let adjHipDbg = null
|
||||||
|
const buildValleyDiamond = () => {
|
||||||
|
if (!resA || !resA.hip || !resA.hit || !resB || !resB.hip || !resB.hit) return null
|
||||||
|
// 두 힙이 평행해야 마름모(잇는 마루 = 골짜기 라인과 평행). 방향이 다르면 마름모 아님 → 폴백.
|
||||||
|
if (Math.hypot(resA.dir.x - resB.dir.x, resA.dir.y - resB.dir.y) > 0.05) return null
|
||||||
|
// [KERAB-VALLEY-EAVES-RULE1 MIDLINE 2026-06-22] 마름모 깊이 = *결정적 중앙선 공식*.
|
||||||
|
// 잇는 마루(수평/수직 불변식)는 골짜기 처마 roofLine 과 그 맞은편 평행 건물변의 *정확히 중앙*에 놓인다.
|
||||||
|
// (straight-skeleton: 두 평행 처마변 사이 ridge 는 중앙선). 인접 힙 매칭·내부 ridge clamp(min raycast)는
|
||||||
|
// 값이 매 실행마다 흔들려 폐기(사용자 확정 2026-06-22: "힙길이가 자꾸 바뀐다", 2820/3930 결정값 기대).
|
||||||
|
// depth(축) = |중앙선축 − offset 코너축|, 힙길이 = depth × √2 (45° 불변식).
|
||||||
|
// 중앙선축 = (원래 골짜기 roofLine 축 + 맞은편 평행변 축)/2.
|
||||||
|
// · 원래 골짜기 roofLine 축: c1(=surgical offset 이전 nearestRoofPoint) — 가로 골짜기는 c1.y, 세로는 c1.x.
|
||||||
|
// · 맞은편 평행변 축: 골짜기 중점에서 *안쪽(힙 방향 부호)* 으로 축정렬 ray 를 쏴 처음 만나는 *외곽 폴리곤 변*.
|
||||||
|
// 내부선(transient HIP/RIDGE)은 제외 — 그게 값 흔들림의 근본원인이었음.
|
||||||
|
const valleyHorizontal =
|
||||||
|
Math.abs(pts[(edgeIdx + 1) % NP].x - pts[edgeIdx].x) >= Math.abs(pts[(edgeIdx + 1) % NP].y - pts[edgeIdx].y)
|
||||||
|
// 원래 골짜기 roofLine 의 축값 + 중점(c1/c2 = surgical offset 이전 코너).
|
||||||
|
const origValleyAxis = valleyHorizontal ? c1.y : c1.x
|
||||||
|
const midX = (c1.x + c2.x) / 2
|
||||||
|
const midY = (c1.y + c2.y) / 2
|
||||||
|
// 안쪽 방향 부호 = 힙 진행 방향(가로 골짜기→y, 세로 골짜기→x).
|
||||||
|
const sgn = valleyHorizontal ? Math.sign(resA.dir.y) || 1 : Math.sign(resA.dir.x) || 1
|
||||||
|
// 골짜기 중점에서 축정렬 ray 를 외곽 폴리곤 변에만 쏴 맞은편 평행변 축값을 구함.
|
||||||
|
const EPS = 0.01
|
||||||
|
let oppAxis = null
|
||||||
|
for (let i = 0; i < NP; i++) {
|
||||||
|
const a = pts[i]
|
||||||
|
const b = pts[(i + 1) % NP]
|
||||||
|
if (valleyHorizontal) {
|
||||||
|
// 변이 x=midX 를 가로지르나? 가로지른다면 그 지점의 y.
|
||||||
|
if ((a.x - midX) * (b.x - midX) > EPS) continue
|
||||||
|
if (Math.abs(a.x - b.x) < EPS) continue
|
||||||
|
const t = (midX - a.x) / (b.x - a.x)
|
||||||
|
if (t < -EPS || t > 1 + EPS) continue
|
||||||
|
const y = a.y + t * (b.y - a.y)
|
||||||
|
if (sgn * (y - midY) <= EPS) continue // 안쪽(힙 방향)만
|
||||||
|
if (oppAxis === null || sgn * (y - oppAxis) < 0) oppAxis = y
|
||||||
|
} else {
|
||||||
|
if ((a.y - midY) * (b.y - midY) > EPS) continue
|
||||||
|
if (Math.abs(a.y - b.y) < EPS) continue
|
||||||
|
const t = (midY - a.y) / (b.y - a.y)
|
||||||
|
if (t < -EPS || t > 1 + EPS) continue
|
||||||
|
const x = a.x + t * (b.x - a.x)
|
||||||
|
if (sgn * (x - midX) <= EPS) continue
|
||||||
|
if (oppAxis === null || sgn * (x - oppAxis) < 0) oppAxis = x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cornerAxis = valleyHorizontal ? newC1.y : newC1.x
|
||||||
|
// raycast 폴백(중앙선 못 구한 경우의 null-safety 만): 두 raycast hit 중 짧은 쪽.
|
||||||
|
const depthA = Math.hypot(resA.hit.x - newC1.x, resA.hit.y - newC1.y)
|
||||||
|
const depthB = Math.hypot(resB.hit.x - newC2.x, resB.hit.y - newC2.y)
|
||||||
|
const fallbackD = Math.min(depthA, depthB)
|
||||||
|
// [KERAB-VALLEY-EAVES-RULE1 CLAMP 2026-06-22] 두 힙은 *각자 독립적으로* 중앙선까지 확장하되,
|
||||||
|
// 가는 길에 *마루/힙* 을 먼저 만나면 *그 힙만* 거기서 멈춤(사용자 확정: "만나는 그 힙만 멈추고,
|
||||||
|
// 다른 힙은 계속 중앙까지"). 넘어가면 R3(교차/오버슈트) 위반.
|
||||||
|
// ★ roofLine 히트는 막는 대상 아님(중앙선이 지붕 안이면 정상) → 마루/힙 hit 만 obstruction.
|
||||||
|
// ★ 두 힙 길이가 *다를 수 있음* → 끝을 잇는 게 축정렬이면 마루, 아니면 힙으로 연결(아래).
|
||||||
|
const isObstr = (kind) => kind === LINE_TYPE.SUBLINE.HIP || kind === LINE_TYPE.SUBLINE.RIDGE
|
||||||
|
let dA = depthA
|
||||||
|
let dB = depthB
|
||||||
|
let source = 'fallback-min-raycast'
|
||||||
|
let midlineAxis = null
|
||||||
|
let dDet = null
|
||||||
|
if (oppAxis !== null) {
|
||||||
|
midlineAxis = (origValleyAxis + oppAxis) / 2
|
||||||
|
const dM = Math.abs(midlineAxis - cornerAxis) * Math.SQRT2
|
||||||
|
if (dM >= 1) {
|
||||||
|
dDet = dM
|
||||||
|
dA = resA.hit && isObstr(resA.hitKind) ? Math.min(dM, depthA) : dM
|
||||||
|
dB = resB.hit && isObstr(resB.hitKind) ? Math.min(dM, depthB) : dM
|
||||||
|
source = dA < dM - 1e-6 || dB < dM - 1e-6 ? 'midline-clamped' : 'midline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adjHipDbg = {
|
||||||
|
valleyHorizontal,
|
||||||
|
origValleyAxis: Math.round(origValleyAxis * 10) / 10,
|
||||||
|
oppAxis: oppAxis === null ? null : Math.round(oppAxis * 10) / 10,
|
||||||
|
midlineAxis: midlineAxis === null ? null : Math.round(midlineAxis * 10) / 10,
|
||||||
|
cornerAxis: Math.round(cornerAxis * 10) / 10,
|
||||||
|
dDet: dDet === null ? null : Math.round(dDet),
|
||||||
|
depthA: Math.round(depthA),
|
||||||
|
depthB: Math.round(depthB),
|
||||||
|
obstr: { a: resA.hitKind, b: resB.hitKind },
|
||||||
|
usedDA: Math.round(dA),
|
||||||
|
usedDB: Math.round(dB),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
if (dA < 1 || dB < 1) return null
|
||||||
|
// [KERAB-VALLEY-EAVES-RULE1 CASCADE 2026-06-22] 힙이 *중앙선 전에* 마루/힙을 만나는 경우
|
||||||
|
// (depth < 중앙선 depth): 마름모(중앙 clamp + connector) 가 아니라 *순차 절삭 연쇄*.
|
||||||
|
// 사용자 확정: CCW 로 먼저 그린 힙이 그 마루를 *흡수측 절삭*(--< : 경계=박공 출발측 보존,
|
||||||
|
// 노치측 흡수) → 그 절삭으로 다른 힙의 장애물(마루 근측)이 사라져 다른 힙은 *다음 라인까지 확장*.
|
||||||
|
// ★ 2820 대칭 마름모 케이스(장애물이 중앙선 *너머*)는 depth ≥ dDet 라 여기 안 걸림 → 기존 경로 보존.
|
||||||
|
const cascadeA = !!(resA.hit && isObstr(resA.hitKind) && dDet !== null && depthA < dDet - 1e-6)
|
||||||
|
const cascadeB = !!(resB.hit && isObstr(resB.hitKind) && dDet !== null && depthB < dDet - 1e-6)
|
||||||
|
if (cascadeA || cascadeB) {
|
||||||
|
const cops = []
|
||||||
|
const rptsC = roof.points || []
|
||||||
|
const TOLC = 2.0
|
||||||
|
const d2s = (p, a, b) => {
|
||||||
|
const ex = b.x - a.x
|
||||||
|
const ey = b.y - a.y
|
||||||
|
const l2 = ex * ex + ey * ey || 1
|
||||||
|
let t = ((p.x - a.x) * ex + (p.y - a.y) * ey) / l2
|
||||||
|
t = Math.max(0, Math.min(1, t))
|
||||||
|
return Math.hypot(p.x - (a.x + t * ex), p.y - (a.y + t * ey))
|
||||||
|
}
|
||||||
|
const minEd = (pt) => {
|
||||||
|
let m = Infinity
|
||||||
|
for (let i = 0, j = rptsC.length - 1; i < rptsC.length; j = i++) {
|
||||||
|
const d = d2s(pt, rptsC[j], rptsC[i])
|
||||||
|
if (d < m) m = d
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
const hipsC = [resA, resB].filter((r) => r && r.hip)
|
||||||
|
// 0) ★ 내부확장박스(kerabValleyOverlapLine) 삭제 — 케라바 적용 때 그려진 골짜기 겹침 사각형은
|
||||||
|
// 처마로 되돌릴 때 사라져야 한다(사용자 확정 2026-06-22: "내부확장박스를 삭제하지?").
|
||||||
|
// 삭제 후, 이 박스를 *지탱하던 마루* 는 따로 확장(아래 [지탱마루확장]) 한다.
|
||||||
|
const boxLines = (canvas?.getObjects() || []).filter(
|
||||||
|
(o) => o && o.parentId === roof.id && (o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine'),
|
||||||
|
)
|
||||||
|
for (const bl of boxLines) {
|
||||||
|
cops.push({ act: 'box-remove', ln: bl.lineName || '-', p1: { x: Math.round(bl.x1), y: Math.round(bl.y1) }, p2: { x: Math.round(bl.x2), y: Math.round(bl.y2) } })
|
||||||
|
removeLine(bl)
|
||||||
|
if (Array.isArray(roof.innerLines)) roof.innerLines = roof.innerLines.filter((l) => l !== bl)
|
||||||
|
}
|
||||||
|
// 코너에서 dir 로 가는 무한 ray ↔ 마루 선분 교점(마루 내부 u∈[0,1], ray 앞쪽 t>0).
|
||||||
|
const rayRidgeCross = (corner, dir, rg) => {
|
||||||
|
const ax = rg.x1
|
||||||
|
const ay = rg.y1
|
||||||
|
const ex = rg.x2 - rg.x1
|
||||||
|
const ey = rg.y2 - rg.y1
|
||||||
|
const den = dir.x * ey - dir.y * ex
|
||||||
|
if (Math.abs(den) < 1e-9) return null
|
||||||
|
const t = ((ax - corner.x) * ey - (ay - corner.y) * ex) / den
|
||||||
|
const u = ((ax - corner.x) * dir.y - (ay - corner.y) * dir.x) / den
|
||||||
|
if (t <= 1e-6 || u < -1e-6 || u > 1 + 1e-6) return null
|
||||||
|
return { x: corner.x + dir.x * t, y: corner.y + dir.y * t }
|
||||||
|
}
|
||||||
|
// 1) ★ 마루확장 + 흡수측 절삭(사용자 확정 2026-06-22: "박스삭제→RG-1·RG-2 확장→힙생성 절삭").
|
||||||
|
// 각 마루의 *비경계(free)* 끝을 먼저 roof bbox 까지 바깥확장 → 힙 교점들이 세그먼트 안에
|
||||||
|
// 들어오게 한 뒤, *free 끝에서 가장 먼* 교점까지 당겨 절삭(--<). 보존측 = 경계(박공 출발측).
|
||||||
|
// ★ RG-1 이 짧아 H-8 교점(611)을 못 잡던 문제 해소: 확장하면 farthest-from-free 가 611 을 준다.
|
||||||
|
// free/keep 판정은 *확장 전* 좌표로 1회 고정(확장 후 bbox 끝이 경계로 재인식되는 것 방지).
|
||||||
|
const ridgesC = (roof.innerLines || []).filter((l) => l && l.visible !== false && l.name === LINE_TYPE.SUBLINE.RIDGE)
|
||||||
|
let bx0 = Infinity
|
||||||
|
let bx1 = -Infinity
|
||||||
|
let by0 = Infinity
|
||||||
|
let by1 = -Infinity
|
||||||
|
for (const p of rptsC) {
|
||||||
|
if (p.x < bx0) bx0 = p.x
|
||||||
|
if (p.x > bx1) bx1 = p.x
|
||||||
|
if (p.y < by0) by0 = p.y
|
||||||
|
if (p.y > by1) by1 = p.y
|
||||||
|
}
|
||||||
|
for (const rg of ridgesC) {
|
||||||
|
const E1 = { x: rg.x1, y: rg.y1 }
|
||||||
|
const E2 = { x: rg.x2, y: rg.y2 }
|
||||||
|
const b1 = minEd(E1) <= TOLC
|
||||||
|
const b2 = minEd(E2) <= TOLC
|
||||||
|
if (b1 === b2) continue // 보존/흡수측 판별 불가(둘 다/둘 다 아님) → 절삭 보류
|
||||||
|
const keep = b1 ? E1 : E2 // 경계측 보존
|
||||||
|
const free = b1 ? E2 : E1 // 비경계측 = 흡수(절삭) 대상
|
||||||
|
// free 끝을 ridge 방향(keep→free) 으로 bbox 경계까지 확장.
|
||||||
|
const dxR = free.x - keep.x
|
||||||
|
const dyR = free.y - keep.y
|
||||||
|
const lenR = Math.hypot(dxR, dyR) || 1
|
||||||
|
const ux = dxR / lenR
|
||||||
|
const uy = dyR / lenR
|
||||||
|
let tMax = Infinity
|
||||||
|
if (ux > 1e-9) tMax = Math.min(tMax, (bx1 - keep.x) / ux)
|
||||||
|
else if (ux < -1e-9) tMax = Math.min(tMax, (bx0 - keep.x) / ux)
|
||||||
|
if (uy > 1e-9) tMax = Math.min(tMax, (by1 - keep.y) / uy)
|
||||||
|
else if (uy < -1e-9) tMax = Math.min(tMax, (by0 - keep.y) / uy)
|
||||||
|
const absorbed = isFinite(tMax) && tMax > lenR ? { x: keep.x + ux * tMax, y: keep.y + uy * tMax } : free
|
||||||
|
const setFree = b1 ? (p) => rg.set({ x2: p.x, y2: p.y }) : (p) => rg.set({ x1: p.x, y1: p.y })
|
||||||
|
setFree(absorbed) // 임시 확장(아래에서 교점까지 다시 당김)
|
||||||
|
const crossings = []
|
||||||
|
for (const r of hipsC) {
|
||||||
|
const corner = r === resA ? newC1 : newC2
|
||||||
|
const cx = rayRidgeCross(corner, r.dir, rg)
|
||||||
|
if (cx) crossings.push(cx)
|
||||||
|
}
|
||||||
|
if (!crossings.length) {
|
||||||
|
// 교점 없으면 확장 취소(원복) — 잘못된 bbox 확장 잔재 방지.
|
||||||
|
setFree(free)
|
||||||
|
rg.setLength?.()
|
||||||
|
rg.setCoords?.()
|
||||||
|
rg.addLengthText?.()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 흡수측(확장된 free) 에서 *가장 먼* 교점까지 절삭 = 노치측 세그먼트 최대 제거.
|
||||||
|
let far = null
|
||||||
|
let farD = -1
|
||||||
|
for (const c of crossings) {
|
||||||
|
const d = Math.hypot(c.x - absorbed.x, c.y - absorbed.y)
|
||||||
|
if (d > farD) {
|
||||||
|
farD = d
|
||||||
|
far = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFree(far)
|
||||||
|
if (b1) rg.endPoint = { x: far.x, y: far.y }
|
||||||
|
else rg.startPoint = { x: far.x, y: far.y }
|
||||||
|
const nsz = calcLinePlaneSize({ x1: rg.x1, y1: rg.y1, x2: rg.x2, y2: rg.y2 })
|
||||||
|
rg.attributes = { ...(rg.attributes || {}), planeSize: nsz, actualSize: nsz }
|
||||||
|
rg.setLength?.()
|
||||||
|
rg.setCoords?.()
|
||||||
|
rg.addLengthText?.()
|
||||||
|
cops.push({ act: 'extend-trim', ln: rg.lineName || '-', to: { x: Math.round(far.x), y: Math.round(far.y) }, sz: nsz })
|
||||||
|
}
|
||||||
|
// 2) ★ 삭제 금지(사용자 확정 2026-06-22: "확장하라고 했지 삭제하니?"). 절삭으로 anchor 잃은
|
||||||
|
// 라인은 *제거하지 않고* 확장해 형상을 닫는다. (확장 타깃 확정 전까지 보류 — 삭제만 차단.)
|
||||||
|
const orphans = []
|
||||||
|
// 3) 갱신된 형상으로 두 힙을 *재-raycast* 후 다시 그림(앞 힙 절삭이 뒤 힙 장애물을 없앤 결과 반영).
|
||||||
|
for (const r of hipsC) {
|
||||||
|
const corner = r === resA ? newC1 : newC2
|
||||||
|
const hit2 = rayFirstHit(corner, r.dir)
|
||||||
|
if (!hit2) continue
|
||||||
|
const sz = calcLinePlaneSize({ x1: corner.x, y1: corner.y, x2: hit2.x, y2: hit2.y })
|
||||||
|
r.hip.set({ x1: corner.x, y1: corner.y, x2: hit2.x, y2: hit2.y })
|
||||||
|
r.hip.startPoint = { x: corner.x, y: corner.y }
|
||||||
|
r.hip.endPoint = { x: hit2.x, y: hit2.y }
|
||||||
|
r.hip.attributes = { ...(r.hip.attributes || {}), planeSize: sz, actualSize: sz }
|
||||||
|
r.hip.setLength?.()
|
||||||
|
r.hip.setCoords?.()
|
||||||
|
r.hip.addLengthText?.()
|
||||||
|
r.hit = hit2
|
||||||
|
r.hitLine = hit2.line
|
||||||
|
r.hitKind = hit2.kind
|
||||||
|
cops.push({ act: 'hip-recast', ln: r.hip.lineName || '-', to: { x: Math.round(hit2.x), y: Math.round(hit2.y) }, kind: hit2.kind, sz })
|
||||||
|
}
|
||||||
|
// 4) ★ 마지막: 서로 다른 길의 두 힙 끝점(마루 안착점)을 잇는 45° 연결 힙
|
||||||
|
// (사용자 확정 2026-06-22: "다른 길의 힙과 힙을 이어주는 라인은 힙이 된다, 45도" = 반삭합).
|
||||||
|
// 각 힙이 마루에 안착한 뒤 두 안착점을 연결해 노치 형상을 닫는다.
|
||||||
|
const eA = resA && resA.hit ? { x: resA.hit.x, y: resA.hit.y } : null
|
||||||
|
const eB = resB && resB.hit ? { x: resB.hit.x, y: resB.hit.y } : null
|
||||||
|
if (eA && eB) {
|
||||||
|
const cdx = Math.abs(eB.x - eA.x)
|
||||||
|
const cdy = Math.abs(eB.y - eA.y)
|
||||||
|
const dist = Math.hypot(eB.x - eA.x, eB.y - eA.y)
|
||||||
|
if (dist > 1 && Math.abs(cdx - cdy) <= 1.0) {
|
||||||
|
const conn = mkValleyHip(eA, eB)
|
||||||
|
canvas.add(conn)
|
||||||
|
conn.bringToFront()
|
||||||
|
roof.innerLines.push(conn)
|
||||||
|
cops.push({ act: 'connector-hip', from: { x: Math.round(eA.x), y: Math.round(eA.y) }, to: { x: Math.round(eB.x), y: Math.round(eB.y) }, sz: conn.attributes?.planeSize })
|
||||||
|
} else {
|
||||||
|
cops.push({ act: 'connector-skip', reason: Math.abs(cdx - cdy) > 1.0 ? 'not-45' : 'too-short', from: { x: Math.round(eA.x), y: Math.round(eA.y) }, to: { x: Math.round(eB.x), y: Math.round(eB.y) } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adjHipDbg.cascade = { cascadeA, cascadeB, orphans: orphans.length, ops: cops }
|
||||||
|
return { ops: cops, cascade: true }
|
||||||
|
}
|
||||||
|
const ops = []
|
||||||
|
const setLen = (res, corner, len) => {
|
||||||
|
const ex = corner.x + res.dir.x * len
|
||||||
|
const ey = corner.y + res.dir.y * len
|
||||||
|
const sz = calcLinePlaneSize({ x1: corner.x, y1: corner.y, x2: ex, y2: ey })
|
||||||
|
res.hip.set({ x1: corner.x, y1: corner.y, x2: ex, y2: ey })
|
||||||
|
res.hip.startPoint = { x: corner.x, y: corner.y }
|
||||||
|
res.hip.endPoint = { x: ex, y: ey }
|
||||||
|
res.hip.attributes = { ...(res.hip.attributes || {}), planeSize: sz, actualSize: sz }
|
||||||
|
res.hip.setLength?.()
|
||||||
|
if (typeof res.hip.setCoords === 'function') res.hip.setCoords()
|
||||||
|
res.hip.addLengthText?.()
|
||||||
|
ops.push({ ln: res.hip.lineName || '-', to: { x: Math.round(ex), y: Math.round(ey) } })
|
||||||
|
return { x: ex, y: ey }
|
||||||
|
}
|
||||||
|
const e1 = setLen(resA, newC1, dA)
|
||||||
|
const e2 = setLen(resB, newC2, dB)
|
||||||
|
const rlen = Math.hypot(e2.x - e1.x, e2.y - e1.y)
|
||||||
|
if (rlen >= 1) {
|
||||||
|
// 두 힙 끝을 잇는 선: 축정렬(수평/수직)이면 마루, 아니면 힙으로 연결(두 힙 길이가 달라진 경우).
|
||||||
|
const CTOL = 1.0
|
||||||
|
const axisAligned = Math.abs(e1.x - e2.x) < CTOL || Math.abs(e1.y - e2.y) < CTOL
|
||||||
|
const rpts = [e1.x, e1.y, e2.x, e2.y]
|
||||||
|
const rsz = calcLinePlaneSize({ x1: rpts[0], y1: rpts[1], x2: rpts[2], y2: rpts[3] })
|
||||||
|
const conn = new QLine(rpts, {
|
||||||
|
parentId: roof.id,
|
||||||
|
fontSize: roof.fontSize,
|
||||||
|
stroke: '#1083E3',
|
||||||
|
strokeWidth: axisAligned ? 4 : 3,
|
||||||
|
name: axisAligned ? LINE_TYPE.SUBLINE.RIDGE : LINE_TYPE.SUBLINE.HIP,
|
||||||
|
textMode: roof.textMode,
|
||||||
|
attributes: { roofId: roof.id, ...(axisAligned ? {} : { type: LINE_TYPE.SUBLINE.HIP }), planeSize: rsz, actualSize: rsz },
|
||||||
|
})
|
||||||
|
conn.lineName = axisAligned ? 'kerabPatternRidge' : 'kerabPatternHip'
|
||||||
|
conn.startPoint = { x: conn.x1, y: conn.y1 }
|
||||||
|
conn.endPoint = { x: conn.x2, y: conn.y2 }
|
||||||
|
canvas.add(conn)
|
||||||
|
conn.bringToFront()
|
||||||
|
roof.innerLines.push(conn)
|
||||||
|
ops.push({ [axisAligned ? 'ridge' : 'connHip']: { from: { x: Math.round(e1.x), y: Math.round(e1.y) }, to: { x: Math.round(e2.x), y: Math.round(e2.y) } } })
|
||||||
|
}
|
||||||
|
return { ops, e1, e2 }
|
||||||
|
}
|
||||||
|
const diamond = buildValleyDiamond()
|
||||||
|
// 깊이 산출 진단 — 큰 applied 로그에 묻혀 잘리지 않도록 별도 짧은 줄로 먼저 출력.
|
||||||
|
logger.log('[KERAB-VALLEY-EAVES-RULE1 MIDLINE] ' + JSON.stringify(adjHipDbg))
|
||||||
|
// 마름모를 만든 경우에도 resolveRidgeEndpoints 를 *이어서* 실행한다 — 새 힙·마루로
|
||||||
|
// anchor 잃은 기존 세로/가로 마루를 절삭(또는 확장)해 dangling(R1) 을 해소(사용자 확정 2026-06-22:
|
||||||
|
// "세로도 그려야지" = 가로 마름모만 만들고 세로 마루를 떠 있게 두면 안 됨).
|
||||||
|
// cascade 경로는 마루 절삭/힙 재-raycast 를 자체 처리했으므로 resolveRidgeEndpoints 를 건너뛴다(이중 절삭 방지).
|
||||||
|
const ridgeOps = diamond ? (diamond.cascade ? diamond.ops : [...diamond.ops, ...resolveRidgeEndpoints()]) : resolveRidgeEndpoints()
|
||||||
// 5c) Y 반사 힙 — 새 힙이 *기존 마루(RIDGE)에 직접 부딪힌* 경우만(=raycast hit 가 RIDGE),
|
// 5c) Y 반사 힙 — 새 힙이 *기존 마루(RIDGE)에 직접 부딪힌* 경우만(=raycast hit 가 RIDGE),
|
||||||
// 그 마루를 대칭축으로 반대편 면에 반사 힙 1개 생성(R7: 마루+hip 교점 반사).
|
// 그 마루를 대칭축으로 반대편 면에 반사 힙 1개 생성(R7: 마루+hip 교점 반사).
|
||||||
// ★ 마루를 *확장*해서 만난 힙(hit 가 roofLine 이던 resA)은 Y 아님 → 반사 안 함(사용자 확정 2026-06-16).
|
// ★ 마루를 *확장*해서 만난 힙(hit 가 roofLine 이던 resA)은 Y 아님 → 반사 안 함(사용자 확정 2026-06-16).
|
||||||
const reflectOps = []
|
const reflectOps = []
|
||||||
for (const res of [resA, resB]) {
|
for (const res of [resA, resB]) {
|
||||||
|
if (diamond) break // 마름모를 만든 경우 힙을 인접 처마 힙 길이로 재설정했으므로 반사 불필요
|
||||||
if (!res || !res.hip || res.hitKind !== LINE_TYPE.SUBLINE.RIDGE || !res.hitLine) continue
|
if (!res || !res.hip || res.hitKind !== LINE_TYPE.SUBLINE.RIDGE || !res.hitLine) continue
|
||||||
const M = { x: res.hit.x, y: res.hit.y }
|
const M = { x: res.hit.x, y: res.hit.y }
|
||||||
const rg = res.hitLine
|
const rg = res.hitLine
|
||||||
@ -3174,8 +3492,42 @@ export function useEavesGableEdit(id) {
|
|||||||
runKerabRuleCheck(roof, 'valley-eaves-rule1', kerabRevertBeforeSnap)
|
runKerabRuleCheck(roof, 'valley-eaves-rule1', kerabRevertBeforeSnap)
|
||||||
logger.log(
|
logger.log(
|
||||||
'[KERAB-VALLEY-EAVES-RULE1] applied ' +
|
'[KERAB-VALLEY-EAVES-RULE1] applied ' +
|
||||||
JSON.stringify({ edgeIdx, removed: gableWallLines.length, cleaned, newC1, newC2, resA, resB, ridgeOps, reflectOps }),
|
JSON.stringify({ edgeIdx, removed: gableWallLines.length, cleaned, newC1, newC2, resA, resB, adjHipDbg, ridgeOps, reflectOps }),
|
||||||
)
|
)
|
||||||
|
// [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 = []
|
||||||
|
for (const o of canvas.getObjects()) {
|
||||||
|
if (!o || o.parentId !== roof.id || o.name === '__debugLabel') continue
|
||||||
|
const pfx = classify(o)
|
||||||
|
if (!pfx) continue
|
||||||
|
ctr[pfx] = (ctr[pfx] || 0) + 1
|
||||||
|
lmap.push({
|
||||||
|
label: `${pfx}-${ctr[pfx]}`,
|
||||||
|
p1: { x: Math.round(o.x1), y: Math.round(o.y1) },
|
||||||
|
p2: { x: Math.round(o.x2), y: Math.round(o.y2) },
|
||||||
|
sz: o.attributes?.planeSize ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
logger.log('[KERAB-LABEL-MAP] ' + JSON.stringify(lmap))
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('[KERAB-LABEL-MAP] failed ' + (e?.message || e))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3991,11 +4343,89 @@ export function useEavesGableEdit(id) {
|
|||||||
const u = ((A.x - P.x) * dy - (A.y - P.y) * dx) / den
|
const u = ((A.x - P.x) * dy - (A.y - P.y) * dx) / den
|
||||||
if (t > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6 && t < best) best = t
|
if (t > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6 && t < best) best = t
|
||||||
}
|
}
|
||||||
|
// [KERAB-VALLEY-BOX-RELATIVE-ROOFLINE 2026-06-23] 골짜기 박스(kerabValleyOverlapLine)는 인접
|
||||||
|
// 두 sub-roof 의 *겹침(union)* 이라, 각 sub-roof 입장에서 박스 변이 곧 자기 roofLine 경계다
|
||||||
|
// (상대 roofLine). 따라서 절대 roofLine 뿐 아니라 더 가까운 박스 변에서도 apex/힙이 멈춰야
|
||||||
|
// 한다(사용자 확정 2026-06-23: "roofLine 이 상대적"). roofLine 변과 동급으로 ray 교차에 포함.
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
for (const o of boxSegs) {
|
||||||
|
const ax = o.x2 - o.x1
|
||||||
|
const ay = o.y2 - o.y1
|
||||||
|
const den = dx * ay - dy * ax
|
||||||
|
if (Math.abs(den) < 1e-9) continue
|
||||||
|
const t = ((o.x1 - P.x) * ay - (o.y1 - P.y) * ax) / den
|
||||||
|
const u = ((o.x1 - P.x) * dy - (o.y1 - P.y) * dx) / den
|
||||||
|
if (t > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6 && t < best) best = t
|
||||||
|
}
|
||||||
return best
|
return best
|
||||||
}
|
}
|
||||||
const oppDist = oppRayDist(mid, ix, iy)
|
const oppDist = oppRayDist(mid, ix, iy)
|
||||||
const apexDepth = Math.min(L / 2, Number.isFinite(oppDist) ? oppDist : L / 2)
|
const apexDepth = Math.min(L / 2, Number.isFinite(oppDist) ? oppDist : L / 2)
|
||||||
const apex = { x: mid.x + ix * apexDepth, y: mid.y + iy * apexDepth }
|
const apex = { x: mid.x + ix * apexDepth, y: mid.y + iy * apexDepth }
|
||||||
|
// [KERAB-TYPE-EAVES-BOX-CLIP 2026-06-23] 골짜기 박스(지붕의 겹침)는 인접 면 입장에서 자기
|
||||||
|
// roofLine 경계 = "상대 roofLine"(사용자 확정 2026-06-23). apex 로 향하는 힙이 박스 경계를
|
||||||
|
// 만나면 거기서 멈춰야 하고 뚫고 지나가면 안 된다. apex 자체는 45° 이등변 유지를 위해 보존하되,
|
||||||
|
// 각 힙을 corner→apex 로 쏴 박스 경계 교차점으로 절삭한다.
|
||||||
|
// 박스 경계 식별: 마루(ridge)의 박스측 고정 끝점 Bp(= apex 로 단축되지 않는 끝)을 공유하는
|
||||||
|
// inner HIP/RIDGE 변(H-6/H-7 류). RG-2(415)가 이 Bp 에서 박스 안으로 올라가므로 Bp 가 곧
|
||||||
|
// 박스 코너다. 일반 내부선(H-2 등, Bp 미공유)은 힙이 가로질러야 하므로 제외 → 정상 切妻
|
||||||
|
// (Bp=roofLine 코너, 공유 inner 없음)은 무영향. ridge(단축 대상)·invisible 제외.
|
||||||
|
const boxCorner = midIsA ? rB : rA
|
||||||
|
const clipHipToBox = (corner) => {
|
||||||
|
const dx0 = apex.x - corner.x
|
||||||
|
const dy0 = apex.y - corner.y
|
||||||
|
const dlen = Math.hypot(dx0, dy0)
|
||||||
|
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
|
||||||
|
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) {
|
||||||
|
best = t
|
||||||
|
bestPt = { x: corner.x + dx * t, y: corner.y + dy * t }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestPt
|
||||||
|
}
|
||||||
|
// 박스 절삭점 선계산 → 마루 처리 분기 판단에 사용.
|
||||||
|
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) {
|
||||||
|
// [KERAB-TYPE-EAVES-RIDGE-VANISH 2026-06-23] 박스(겹침)가 상대 roofLine 이라 신규 힙이 박스/직전 힙에서
|
||||||
|
// 멈추면, 가운데 마루(RG-1)는 양쪽 코너 힙이 이미 생성됐으므로 "삭제 대상"이다(사용자 규칙 2026-06-23:
|
||||||
|
// "RG-1 은 마루 = 삭제, 양쪽에 힙 생성됨"). visible=false 숨기기는 객체가 innerLines 에 남아
|
||||||
|
// 디버그 라벨 재부착/LABEL-MAP 이 visible 무시하고 RG-1 을 다시 노출 → "삭제 안 됨" 으로 보였다.
|
||||||
|
// revert 스냅샷은 좌표 값만 복사하고 revert 경로가 라인을 재생성하므로 진짜 삭제해도 무해하다.
|
||||||
|
// → reconstruct(4820) 와 동일하게 canvas.remove + innerLines splice 로 실제 삭제한다.
|
||||||
|
canvas.remove(ridge)
|
||||||
|
const _ridx = roof.innerLines.indexOf(ridge)
|
||||||
|
if (_ridx >= 0) roof.innerLines.splice(_ridx, 1)
|
||||||
|
} else {
|
||||||
// 마루 단축: 케라바변쪽 끝점 → apex.
|
// 마루 단축: 케라바변쪽 끝점 → apex.
|
||||||
if (midIsA) ridge.set({ x1: apex.x, y1: apex.y })
|
if (midIsA) ridge.set({ x1: apex.x, y1: apex.y })
|
||||||
else ridge.set({ x2: apex.x, y2: apex.y })
|
else ridge.set({ x2: apex.x, y2: apex.y })
|
||||||
@ -4012,9 +4442,10 @@ export function useEavesGableEdit(id) {
|
|||||||
if (typeof ridge.setCoords === 'function') ridge.setCoords()
|
if (typeof ridge.setCoords === 'function') ridge.setCoords()
|
||||||
if (typeof ridge.setLength === 'function') ridge.setLength()
|
if (typeof ridge.setLength === 'function') ridge.setLength()
|
||||||
if (typeof ridge.addLengthText === 'function') ridge.addLengthText()
|
if (typeof ridge.addLengthText === 'function') ridge.addLengthText()
|
||||||
// hip 2개: wA→apex, wB→apex. pts=[corner, apex] 라 corner 끝(x1,y1)이 outer(=which 1).
|
}
|
||||||
const mkHip = (corner, side) => {
|
// hip 2개: wA→endA, wB→endB. pts=[corner, end] 라 corner 끝(x1,y1)이 outer.
|
||||||
const pts = [corner.x, corner.y, apex.x, apex.y]
|
const mkHip = (corner, side, end) => {
|
||||||
|
const pts = [corner.x, corner.y, end.x, end.y]
|
||||||
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
|
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
|
||||||
const hip = new QLine(pts, {
|
const hip = new QLine(pts, {
|
||||||
parentId: roof.id,
|
parentId: roof.id,
|
||||||
@ -4032,8 +4463,8 @@ export function useEavesGableEdit(id) {
|
|||||||
hip.__kerabRevertOuterSide = side
|
hip.__kerabRevertOuterSide = side
|
||||||
return hip
|
return hip
|
||||||
}
|
}
|
||||||
const hip1 = mkHip(wA, 'A')
|
const hip1 = mkHip(wA, 'A', endA)
|
||||||
const hip2 = mkHip(wB, 'B')
|
const hip2 = mkHip(wB, 'B', endB)
|
||||||
canvas.add(hip1)
|
canvas.add(hip1)
|
||||||
canvas.add(hip2)
|
canvas.add(hip2)
|
||||||
hip1.bringToFront()
|
hip1.bringToFront()
|
||||||
@ -4044,8 +4475,91 @@ export function useEavesGableEdit(id) {
|
|||||||
canvas.renderAll()
|
canvas.renderAll()
|
||||||
logger.log(
|
logger.log(
|
||||||
'[KERAB-TYPE-EAVES] applied ' +
|
'[KERAB-TYPE-EAVES] applied ' +
|
||||||
JSON.stringify({ L: Math.round(L), mid, apex, inward: { ix: Number(ix.toFixed(3)), iy: Number(iy.toFixed(3)) } }),
|
JSON.stringify({
|
||||||
|
L: Math.round(L),
|
||||||
|
mid,
|
||||||
|
apex: { x: Math.round(apex.x), y: Math.round(apex.y) },
|
||||||
|
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,
|
||||||
|
ridgeHidden: ridge.visible === false,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
// [KERAB-LABEL-MAP 2026-06-23] 경로 A(일반 라인→처마) 진단 — 화면 라벨(H-n/RG-n/V-n…)↔좌표 맵.
|
||||||
|
// 골짜기 박스(kerabValleyOverlapLine)는 V 로 분류되어 V-n 으로 노출 → 힙이 박스를 통과/멈추는지
|
||||||
|
// 라벨로 대조하기 위함(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 = []
|
||||||
|
for (const o of canvas.getObjects()) {
|
||||||
|
if (!o || o.parentId !== roof.id || o.name === '__debugLabel') continue
|
||||||
|
const pfx = classify(o)
|
||||||
|
if (!pfx) continue
|
||||||
|
ctr[pfx] = (ctr[pfx] || 0) + 1
|
||||||
|
lmap.push({
|
||||||
|
label: `${pfx}-${ctr[pfx]}`,
|
||||||
|
p1: { x: Math.round(o.x1), y: Math.round(o.y1) },
|
||||||
|
p2: { x: Math.round(o.x2), y: Math.round(o.y2) },
|
||||||
|
ln: o.lineName || null,
|
||||||
|
sz: o.attributes?.planeSize ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
logger.log('[KERAB-LABEL-MAP] ' + JSON.stringify(lmap))
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('[KERAB-LABEL-MAP] failed ' + (e?.message || e))
|
||||||
|
}
|
||||||
|
// [KERAB-BOX-DUMP 2026-06-23] 진단 — canvas 전체의 골짜기 박스(kerabValleyOverlapLine) + 모든 ROOF
|
||||||
|
// 폴리곤 외곽 bbox 를 roofId 와 함께 덤프. 힙이 뚫은 "박스"가 현재 roof 소속인지, 다른 roof/겹침
|
||||||
|
// 소속이라 oppRayDist 필터에서 빠졌는지 판별하기 위함(local 전용, 로깅만).
|
||||||
|
try {
|
||||||
|
const allBox = (canvas.getObjects() || [])
|
||||||
|
.filter((o) => o && (o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine'))
|
||||||
|
.map((o) => ({
|
||||||
|
p1: { x: Math.round(o.x1), y: Math.round(o.y1) },
|
||||||
|
p2: { x: Math.round(o.x2), y: Math.round(o.y2) },
|
||||||
|
parentId: o.parentId || null,
|
||||||
|
roofId: o.roofId || o.attributes?.roofId || null,
|
||||||
|
name: o.name || null,
|
||||||
|
}))
|
||||||
|
const allRoofs = (canvas.getObjects() || [])
|
||||||
|
.filter((o) => o && o.name === POLYGON_TYPE.ROOF)
|
||||||
|
.map((o) => {
|
||||||
|
const ps = o.points || []
|
||||||
|
let x0 = Infinity
|
||||||
|
let x1 = -Infinity
|
||||||
|
let y0 = Infinity
|
||||||
|
let y1 = -Infinity
|
||||||
|
for (const p of ps) {
|
||||||
|
if (p.x < x0) x0 = p.x
|
||||||
|
if (p.x > x1) x1 = p.x
|
||||||
|
if (p.y < y0) y0 = p.y
|
||||||
|
if (p.y > y1) y1 = p.y
|
||||||
|
}
|
||||||
|
return { id: o.id, isFixed: !!o.isFixed, bbox: { x0: Math.round(x0), y0: Math.round(y0), x1: Math.round(x1), y1: Math.round(y1) }, n: ps.length }
|
||||||
|
})
|
||||||
|
logger.log('[KERAB-BOX-DUMP] ' + JSON.stringify({ curRoof: roof.id, box: allBox, roofs: allRoofs }))
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('[KERAB-BOX-DUMP] 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
|
||||||
return apex
|
return apex
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4102,9 +4616,17 @@ export function useEavesGableEdit(id) {
|
|||||||
let movedCnt = 0
|
let movedCnt = 0
|
||||||
for (const il of roof.innerLines || []) {
|
for (const il of roof.innerLines || []) {
|
||||||
if (!il || isKerabPattern(il.lineName)) continue
|
if (!il || isKerabPattern(il.lineName)) continue
|
||||||
|
// [KERAB-TYPE-EAVES-EDGEMOVE-SKIPHIDDEN 2026-06-23] 숨긴 마루(hipClipped 시 visible=false)는
|
||||||
|
// 옮기지 않는다. 옮기면 cMid→apex 수렴 후 addLengthText 가 박스 위에 "415" 라벨을 되살린다.
|
||||||
|
if (il.visible === false) continue
|
||||||
const collinear = isCollinearWithEdge(il.x1, il.y1, il.x2, il.y2)
|
const collinear = isCollinearWithEdge(il.x1, il.y1, il.x2, il.y2)
|
||||||
// 非평행 라인의 cMid 끝점은 apex 로 수렴, 그 외는 出幅 delta 로 평행이동.
|
// 非평행 라인의 cMid 끝점은 apex 로 수렴, 그 외는 出幅 delta 로 평행이동.
|
||||||
const relocate = (px, py) => {
|
const relocate = (px, py) => {
|
||||||
|
// [KERAB-TYPE-EAVES-EDGEMOVE-BOXCLOSE 2026-06-23] 마루(RG-1) 삭제 케이스(apex.ridgeHidden)는
|
||||||
|
// 박스 세로선(H-8)이 옛 처마변까지 과길게 뻗어 있다(에러). 그 과길게 뻗은 끝점(cMid 측)을
|
||||||
|
// apex 로 올리지도 평행이동하지도 않고, 박스 바닥(boxCorner=RG-1 박스측 끝점)으로 내려
|
||||||
|
// H-7↔H-2 사이만 막는 짧은 세로선으로 만든다.
|
||||||
|
if (apex && apex.ridgeHidden && apex.boxCorner && !collinear && nearMid(px, py)) return { x: apex.boxCorner.x, y: apex.boxCorner.y }
|
||||||
if (apex && !collinear && nearMid(px, py)) return { x: apex.x, y: apex.y }
|
if (apex && !collinear && nearMid(px, py)) return { x: apex.x, y: apex.y }
|
||||||
return { x: px + dxE, y: py + dyE }
|
return { x: px + dxE, y: py + dyE }
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user