dev #870

Merged
ysCha merged 3 commits from dev into dev-deploy 2026-05-27 16:21:05 +09:00
Showing only changes of commit e9f5ceab69 - Show all commits

View File

@ -564,6 +564,90 @@ export function useEavesGableEdit(id) {
const py = ay + t * dy
return Math.hypot(px - pt.x, py - pt.y) <= tol
}
// [KERAB-ROOF-MAX-INWARD 2026-05-27] roof polygon wall 정의를 wave 시작 전으로 이동 (이전 L896).
// 사용자 규칙: "확장을 해도 roofLine 까지이다. 절대 통과 못한다."
// wave 의 모든 meet 후보 거리를 ext.maxInwardDist 로 제한 → roofLine 너머 meet 거부.
const roofPolygonWalls = []
if (Array.isArray(roof.points) && roof.points.length >= 2) {
for (let i = 0; i < roof.points.length; i++) {
roofPolygonWalls.push({
a: roof.points[i],
b: roof.points[(i + 1) % roof.points.length],
})
}
}
const computeMaxInwardDist = (ext) => {
let best = Infinity
for (const wall of roofPolygonWalls) {
const ip = lineLineIntersection(ext.near, ext.far, wall.a, wall.b)
if (!ip) continue
if (!isInward(ext, ip)) continue
if (!isPointOnSegment(ip, wall.a.x, wall.a.y, wall.b.x, wall.b.y)) continue
const d = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y)
if (d < 1e-3) continue
if (d < best) best = d
}
return best
}
const MAX_INWARD_TOL = 0.5
for (const ext of allExtenders) {
ext.maxInwardDist = computeMaxInwardDist(ext)
}
// [KERAB-VALLEY-EXT-BARRIER 2026-05-27] 골짜기확장라인 사전 계산 — hip/ridge wave 의 barrier 로 사용.
// 사용자 규칙: "힙/마루 라인은 골짜기 확장라인 및 roofLine 까지. 절대 통과 못한다."
// pre-wave 상태(polygonPath 라인 미삭제) 의 roof.innerLines 로 raycast. polygonLines 는 곧 삭제될 라인이라 stopper 제외.
// 최종 valleyExt 좌표는 L1280+ 에서 post-wave 상태로 다시 raycast → 약간의 차이 있을 수 있음 (수용).
const valleyExtPreSegs = []
if (valleyPlannedEndpoints.length) {
for (const ep of valleyPlannedEndpoints) {
const dx = ep.sx - ep.ox
const dy = ep.sy - ep.oy
const len = Math.hypot(dx, dy) || 1
const ux = dx / len
const uy = dy / len
const FAR_RAY = 1e5
const start = { x: ep.sx, y: ep.sy }
const rayEnd = { x: ep.sx + ux * FAR_RAY, y: ep.sy + uy * FAR_RAY }
// [KERAB-VALLEY-EXT-ALWAYS-MID 2026-05-27] 사용자 규칙: "골짜기 세로라인이 더 확장되어야 한다."
// RIDGE stopper 제거 — 무조건 polygon-wall 까지 거리의 절반 = polygon width 의 대칭중앙.
// ridge·hip 은 모두 통과 (경로 위 교차 시 trim 단계에서 절삭).
let bestPt = null
let wallT = Infinity
let wallHit = null
for (const w of roofPolygonWalls) {
const ip = lineLineIntersection(start, rayEnd, w.a, w.b)
if (!ip) continue
if (!isPointOnSegment(ip, w.a.x, w.a.y, w.b.x, w.b.y)) continue
const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy
if (t < 0.5) continue
if (t < wallT) {
wallT = t
wallHit = ip
}
}
if (wallHit) {
bestPt = { x: (start.x + wallHit.x) / 2, y: (start.y + wallHit.y) / 2 }
logger.log(
'[KERAB-VALLEY-EXT-MID] pre label=' + ep.label +
' wallHit={' + Math.round(wallHit.x * 100) / 100 + ',' + Math.round(wallHit.y * 100) / 100 + '}' +
' mid={' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + '}',
)
}
if (bestPt) {
valleyExtPreSegs.push({ x1: start.x, y1: start.y, x2: bestPt.x, y2: bestPt.y, label: ep.label })
}
}
logger.log(
'[KERAB-VALLEY-EXT-PRE] ' +
JSON.stringify(
valleyExtPreSegs.map((s) => ({
label: s.label,
from: { x: Math.round(s.x1 * 100) / 100, y: Math.round(s.y1 * 100) / 100 },
to: { x: Math.round(s.x2 * 100) / 100, y: Math.round(s.y2 * 100) / 100 },
})),
),
)
}
const extenderPool = [...allExtenders]
const resolved = new Map()
// [KERAB-MULTI-APEX 2026-05-22] hip+hip 이 90° 로 만나는 모든 apex 수집 → 각 apex 마다 kLine 1개.
@ -642,6 +726,32 @@ export function useEavesGableEdit(id) {
kLineMeets.push({ ext, point: ip, dist, kLine: kl })
}
}
// [KERAB-VALLEY-EXT-BARRIER 2026-05-27] 골짜기확장라인 segment 와의 meet — hip/ridge 가 그 점에서 정지.
// mirror/reflection 없음 (단순 정지). 거리 가장 짧은 후보면 우선 처리되어 그 너머로 못 감.
const valleyExtMeets = []
for (const ext of unresolved) {
for (const vs of valleyExtPreSegs) {
const ip = lineLineIntersection(ext.near, ext.far, { x: vs.x1, y: vs.y1 }, { x: vs.x2, y: vs.y2 })
if (!ip) continue
if (!isInward(ext, ip)) continue
if (!isPointOnSegment(ip, vs.x1, vs.y1, vs.x2, vs.y2)) continue
const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y)
if (dist < 1e-3) continue
valleyExtMeets.push({ ext, point: ip, dist, valleyExtSeg: vs })
}
}
if (valleyExtMeets.length > 0) {
logger.log(
'[KERAB-VALLEY-EXT-BARRIER] meets iter=' + iter + ' ' +
JSON.stringify(
valleyExtMeets.map((v) => ({
ext: labelOf(v.ext.line) || (v.ext.isHip ? 'H' : 'R'),
point: { x: Math.round(v.point.x * 100) / 100, y: Math.round(v.point.y * 100) / 100 },
dist: Math.round(v.dist * 100) / 100,
})),
),
)
}
// [KERAB-STATIC-RIDGE 2026-05-21] 정적 ridge(RG-1 등, 폴리곤 삭제대상 제외) 도 hip extender 의 거울.
const staticRidges = (roof.innerLines || []).filter(
(il) =>
@ -742,6 +852,16 @@ export function useEavesGableEdit(id) {
for (const km of kLineMeets) {
candidates.push({ kind: 'kline', extenders: [km.ext], point: km.point, minDist: km.dist, mirrorLine: km.kLine })
}
// [KERAB-VALLEY-EXT-BARRIER 2026-05-27] valleyExt 와 만나는 hip/ridge 는 그 점에서 정지.
// mirror 없음 → L859 의 (kline/ridge/drawn) 분기에 valleyExt 추가하지 않아 reflected 생성 안 됨.
for (const vm of valleyExtMeets) {
candidates.push({
kind: 'valleyExt',
extenders: [vm.ext],
point: vm.point,
minDist: vm.dist,
})
}
for (const rm of ridgeMeets) {
candidates.push({
kind: 'ridge',
@ -770,6 +890,37 @@ export function useEavesGableEdit(id) {
staticLine: sm.staticLine,
})
}
// [KERAB-ROOF-MAX-INWARD 2026-05-27] roofLine 너머 meet 후보 제거.
// 사용자 규칙: "확장을 해도 roofLine 까지이다. 절대 통과 못한다."
// ext.maxInwardDist (inward 방향 roof wall 까지 최단거리) 를 초과하는 점은 폐기.
const candidatesBeforeCap = candidates.length
const capFilteredOut = []
for (let ci = candidates.length - 1; ci >= 0; ci--) {
const c = candidates[ci]
let over = false
for (const e of c.extenders) {
const cap = e.maxInwardDist
if (cap === undefined || !Number.isFinite(cap)) continue
const d = Math.hypot(c.point.x - e.near.x, c.point.y - e.near.y)
if (d > cap + MAX_INWARD_TOL) {
over = true
capFilteredOut.push({
kind: c.kind,
ext: labelOf(e.line) || (e.isHip ? 'H' : 'R'),
d: Math.round(d * 100) / 100,
cap: Math.round(cap * 100) / 100,
})
break
}
}
if (over) candidates.splice(ci, 1)
}
if (capFilteredOut.length > 0) {
logger.log(
'[KERAB-ROOF-MAX-INWARD] iter=' + iter + ' filtered=' + capFilteredOut.length +
'/' + candidatesBeforeCap + ' ' + JSON.stringify(capFilteredOut),
)
}
candidates.sort((a, b) => a.minDist - b.minDist)
const newReflected = []
let pendingKLineCreated = false
@ -875,7 +1026,7 @@ export function useEavesGableEdit(id) {
(c.kind === 'kline' && pendingKLines.some(
(pk) => pk.x1 === mirrorLine.x1 && pk.y1 === mirrorLine.y1 && pk.x2 === mirrorLine.x2 && pk.y2 === mirrorLine.y2,
))
newReflected.push({
const reflected = {
line: hipExt.line,
near: { x: c.point.x, y: c.point.y },
far: { x: c.point.x - rdx, y: c.point.y - rdy },
@ -883,7 +1034,10 @@ export function useEavesGableEdit(id) {
sourcePoint: { x: c.point.x, y: c.point.y },
__reflected: true,
__reflectedFromPending: fromPending,
})
}
// [KERAB-ROOF-MAX-INWARD 2026-05-27] reflected ext 도 roof wall 까지 캡 계산.
reflected.maxInwardDist = computeMaxInwardDist(reflected)
newReflected.push(reflected)
}
break
}
@ -893,15 +1047,7 @@ export function useEavesGableEdit(id) {
// [KERAB-ROOF-FALLBACK-ANYWALL 2026-05-21] 미해소 extender 를 roof 폴리곤의 어떤 wall 이라도
// inward 방향의 가장 가까운 wall 교점까지 확장. 반사 hip 이 target wall 과 반대쪽으로
// 향해도 다른 wall 에서 정지.
const roofPolygonWalls = []
if (Array.isArray(roof.points) && roof.points.length >= 2) {
for (let i = 0; i < roof.points.length; i++) {
roofPolygonWalls.push({
a: roof.points[i],
b: roof.points[(i + 1) % roof.points.length],
})
}
}
// (roofPolygonWalls 정의는 wave 시작 전 KERAB-ROOF-MAX-INWARD 블록으로 이동됨)
// [KERAB-NO-PIERCE 2026-05-21] fallback 직진 중에도 정적 inner line(이 polygon 의 처리대상 제외)
// + 이번 wave 에 이미 그려질 ext 라인을 barrier 로 검사. 가장 가까운 hit 에서 정지하여
// 다른 라인을 관통해 내부로 침입하는 케이스를 차단.
@ -922,6 +1068,8 @@ export function useEavesGableEdit(id) {
})
}
for (const pk of pendingKLines) barrierLines.push(pk)
// [KERAB-VALLEY-EXT-BARRIER 2026-05-27] 골짜기확장라인 — fallback 단계에서도 통과 금지.
for (const vs of valleyExtPreSegs) barrierLines.push(vs)
for (const ext of extenderPool) {
if (resolved.has(ext)) continue
let bestPt = null
@ -1160,6 +1308,8 @@ export function useEavesGableEdit(id) {
})
}
for (const pk of pendingKLines) barriers2.push(pk)
// [KERAB-VALLEY-EXT-BARRIER 2026-05-27] re-resolve 단계에서도 골짜기확장라인 통과 금지.
for (const vs of valleyExtPreSegs) barriers2.push(vs)
for (const ext of staleExts) {
let bestPt = null
let bestDist = Infinity
@ -1306,28 +1456,31 @@ export function useEavesGableEdit(id) {
const uy = dy / len
const FAR_RAY = 1e5
const rayEnd = { x: start.x + ux * FAR_RAY, y: start.y + uy * FAR_RAY }
// [KERAB-VALLEY-EXT-ALWAYS-MID 2026-05-27] 사용자 규칙: "골짜기 세로라인이 더 확장되어야 한다."
// RIDGE stopper 제거 — 무조건 polygon-wall midpoint(대칭중앙)까지.
// 경로 위 교차 hip/ridge 는 trim 단계에서 절삭 (KERAB-VALLEY-EXT-TRIM).
let bestStop = null
let bestT = Infinity
let bestHit = null
for (const il of roof.innerLines || []) {
if (!il) continue
// [KERAB-VALLEY-EXT 2026-05-27] stopper = HIP / RIDGE 만.
if (il.lineName === 'kerabPatternValleyExt') continue
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
if (il.visible === false) continue
const a = { x: il.x1, y: il.y1 }
const b = { x: il.x2, y: il.y2 }
const ip = lineLineIntersection(start, rayEnd, a, b)
let wallT = Infinity
let wallHit = null
for (const w of roofPolygonWalls) {
const ip = lineLineIntersection(start, rayEnd, w.a, w.b)
if (!ip) continue
if (!isOnSegV(ip, a.x, a.y, b.x, b.y)) continue
if (!isOnSegV(ip, w.a.x, w.a.y, w.b.x, w.b.y)) continue
const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy
if (t < 1.0) continue
if (t < bestT) {
bestT = t
bestStop = ip
bestHit = il
if (t < 0.5) continue
if (t < wallT) {
wallT = t
wallHit = ip
}
}
if (wallHit) {
bestStop = { x: (start.x + wallHit.x) / 2, y: (start.y + wallHit.y) / 2 }
logger.log(
'[KERAB-VALLEY-EXT-MID] post label=' + ep.label +
' wallHit={' + Math.round(wallHit.x * 100) / 100 + ',' + Math.round(wallHit.y * 100) / 100 + '}' +
' mid={' + Math.round(bestStop.x * 100) / 100 + ',' + Math.round(bestStop.y * 100) / 100 + '}',
)
}
if (bestStop) {
const seg = {
x1: start.x,
@ -1336,10 +1489,6 @@ export function useEavesGableEdit(id) {
y2: bestStop.y,
source: ep.label,
parent: ep.parent || null,
hitLine: bestHit,
hitPoint: { x: bestStop.x, y: bestStop.y },
hitLineName: bestHit && bestHit.lineName,
hitName: bestHit && bestHit.name,
}
valleyExtensions.push(seg)
logger.log('[KERAB-VALLEY-EXT] generated ' + JSON.stringify(seg))
@ -1347,61 +1496,75 @@ export function useEavesGableEdit(id) {
logger.log('[KERAB-VALLEY-EXT] no ridge/hip hit for ' + ep.label)
}
}
// [KERAB-VALLEY-EXT-TRIM 2026-05-27] 처마확장 segment 는 1순위로 보존 (위에서 그려진 hitPoint 까지 그대로).
// 다음 단계: 처마확장이 만난 hitLine(hip/ridge) 의 외곽측 끝점을 hitPoint 로 단축(절삭).
// 도미노: 단축된 hitLine 의 옛 외곽 끝점에 닿아있던 다른 hip/ridge 의 그 끝점도 hitPoint 로 cascade 이동.
// revert 시 trimRecords 역순 복원 (도미노 → 원본 trim 순).
// [2026-05-27 비활성] flag=false. junction 공유 hip 까지 cascade 되는 부작용 (예: H-2 의도 외 절삭)
// 확인 — 절삭 모델은 다음 페이즈에서 concave-side-only 판정 추가 후 재활성.
const ENABLE_VALLEY_EXT_TRIM = false
// [KERAB-VALLEY-EXT-TRIM 2026-05-27] valleyExt 경로상 교차 hip/ridge 절삭.
// 각 valleyExt segment 에 대해 모든 hip/ridge 와 교차 검사 → 교차하면 valleyExt 시작점에
// 가까운 끝점을 hitPoint 로 단축 (= corner 쪽 부분이 잘리고 너머 쪽이 보존).
// 사용자 규칙: "대칭중앙까지 가는 도중에 힙 또는 마루를 만나면 힙·마루는 절삭한다."
// cascade 도미노는 비활성 (junction 공유 hip 의도 외 절삭 부작용).
// revert 시 trimRecords 역순 복원.
const trimRecords = []
const TOL_DOMINO = 1.0
if (ENABLE_VALLEY_EXT_TRIM) for (const vr of valleyExtensions) {
for (const vr of valleyExtensions) {
if (!vr.source || !vr.source.startsWith('roofBase')) continue
if (!vr.hitLine || !vr.hitPoint) continue
const il = vr.hitLine
const hp = vr.hitPoint
const d1 = Math.hypot(il.x1 - vr.x1, il.y1 - vr.y1)
const d2 = Math.hypot(il.x2 - vr.x1, il.y2 - vr.y1)
const trimEnd = d1 < d2 ? 1 : 2
const oldX = trimEnd === 1 ? il.x1 : il.x2
const oldY = trimEnd === 1 ? il.y1 : il.y2
trimRecords.push({
line: il,
end: trimEnd,
oldPt: { x: oldX, y: oldY },
newPt: { x: hp.x, y: hp.y },
originalAttrs: { ...(il.attributes || {}) },
})
if (trimEnd === 1) il.set({ x1: hp.x, y1: hp.y })
else il.set({ x2: hp.x, y2: hp.y })
const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz }
logger.log(
'[KERAB-VALLEY-EXT-TRIM] trim ' +
JSON.stringify({ name: il.name, lineName: il.lineName, trimEnd, oldPt: { x: oldX, y: oldY }, hp }),
)
for (const other of roof.innerLines || []) {
if (!other || other === il) continue
if (other.lineName === 'kerabPatternValleyExt') continue
if (other.name !== LINE_TYPE.SUBLINE.HIP && other.name !== LINE_TYPE.SUBLINE.RIDGE) continue
const m1 = Math.hypot(other.x1 - oldX, other.y1 - oldY) <= TOL_DOMINO
const m2 = Math.hypot(other.x2 - oldX, other.y2 - oldY) <= TOL_DOMINO
if (!m1 && !m2) continue
const segA = { x: vr.x1, y: vr.y1 }
const segB = { x: vr.x2, y: vr.y2 }
for (const il of roof.innerLines || []) {
if (!il) continue
if (il.lineName === 'kerabPatternValleyExt') continue
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
if (il.visible === false) continue
const a = { x: il.x1, y: il.y1 }
const b = { x: il.x2, y: il.y2 }
const ip = lineLineIntersection(segA, segB, a, b)
if (!ip) continue
if (!isOnSegV(ip, segA.x, segA.y, segB.x, segB.y)) continue
if (!isOnSegV(ip, a.x, a.y, b.x, b.y)) continue
// [KERAB-VALLEY-EXT-TRIM 2026-05-27] 끝점 연결 보존: hip/ridge 의 어느 한쪽 끝점이라도
// 교차점과 일치(= 끝점에서 valleyExt 와 만남) → 살림.
const TOL_EP = 1.0
if (Math.hypot(il.x1 - ip.x, il.y1 - ip.y) < TOL_EP) continue
if (Math.hypot(il.x2 - ip.x, il.y2 - ip.y) < TOL_EP) continue
// [KERAB-VALLEY-EXT-TRIM-Y-GUARD 2026-05-27] Y junction 보존:
// 사용자 규칙: "Y 모양이 있는 쪽은 살리고 그 반대쪽은 절삭."
// hip/ridge 의 양 끝점이 모두 다른 inner line 의 끝점과 일치(= 양쪽 다 Y junction)
// → 절삭 skip (= 정상 polygon 구조 보존).
// 한쪽만 junction → 자유 끝점(non-Y) 쪽 단축.
const TOL_J = 1.0
const isOtherInnerEndAt = (px, py) => {
for (const other of roof.innerLines || []) {
if (!other || other === il) continue
if (other.lineName === 'kerabPatternValleyExt') continue
if (other.name !== LINE_TYPE.SUBLINE.HIP && other.name !== LINE_TYPE.SUBLINE.RIDGE) continue
if (other.visible === false) continue
if (Math.hypot(other.x1 - px, other.y1 - py) < TOL_J) return true
if (Math.hypot(other.x2 - px, other.y2 - py) < TOL_J) return true
}
return false
}
const end1J = isOtherInnerEndAt(il.x1, il.y1)
const end2J = isOtherInnerEndAt(il.x2, il.y2)
if (end1J && end2J) continue
const d1 = Math.hypot(il.x1 - segA.x, il.y1 - segA.y)
const d2 = Math.hypot(il.x2 - segA.x, il.y2 - segA.y)
let trimEnd
if (end1J && !end2J) trimEnd = 2
else if (!end1J && end2J) trimEnd = 1
else trimEnd = d1 < d2 ? 1 : 2
const oldX = trimEnd === 1 ? il.x1 : il.x2
const oldY = trimEnd === 1 ? il.y1 : il.y2
trimRecords.push({
line: other,
end: m1 ? 1 : 2,
line: il,
end: trimEnd,
oldPt: { x: oldX, y: oldY },
newPt: { x: hp.x, y: hp.y },
originalAttrs: { ...(other.attributes || {}) },
domino: true,
newPt: { x: ip.x, y: ip.y },
originalAttrs: { ...(il.attributes || {}) },
})
if (m1) other.set({ x1: hp.x, y1: hp.y })
else other.set({ x2: hp.x, y2: hp.y })
const oSz = calcLinePlaneSize({ x1: other.x1, y1: other.y1, x2: other.x2, y2: other.y2 })
other.attributes = { ...other.attributes, planeSize: oSz, actualSize: oSz }
if (trimEnd === 1) il.set({ x1: ip.x, y1: ip.y })
else il.set({ x2: ip.x, y2: ip.y })
const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz }
logger.log(
'[KERAB-VALLEY-EXT-TRIM] domino ' + JSON.stringify({ name: other.name, lineName: other.lineName, hp }),
'[KERAB-VALLEY-EXT-TRIM] trim ' +
JSON.stringify({ name: il.name, lineName: il.lineName, trimEnd, oldPt: { x: oldX, y: oldY }, ip }),
)
}
}