[2173] valleyExt Phase 1/2 helper 추출 + wallBase 독립 ray + ridge-stop 룰
This commit is contained in:
parent
a4b7f519bb
commit
036c283e0c
@ -277,6 +277,25 @@ export function useEavesGableEdit(id) {
|
|||||||
.getObjects()
|
.getObjects()
|
||||||
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
|
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
|
||||||
logger.log('[KERAB-SIMPLE] roof check ' + JSON.stringify({ roofId: target.attributes?.roofId, roofFound: !!roof }))
|
logger.log('[KERAB-SIMPLE] roof check ' + JSON.stringify({ roofId: target.attributes?.roofId, roofFound: !!roof }))
|
||||||
|
// [KERAB-INNERLINES-DUMP 2026-05-27] 토글 진입/종료 시점 innerLines ridge/hip 스냅샷.
|
||||||
|
// - cascade hide / trim 으로 어떤 라인이 어디서 끊겼는지 추적용 임시 진단 로그.
|
||||||
|
const dumpInnerLineSnapshot = (label) => {
|
||||||
|
if (!roof || !Array.isArray(roof.innerLines)) return
|
||||||
|
const rows = roof.innerLines
|
||||||
|
.filter((l) => l && (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE))
|
||||||
|
.map((l) => ({
|
||||||
|
lab: l.label || '?',
|
||||||
|
n: l.name,
|
||||||
|
ln: l.lineName || '-',
|
||||||
|
v: l.visible !== false,
|
||||||
|
x1: Math.round(l.x1 * 10) / 10,
|
||||||
|
y1: Math.round(l.y1 * 10) / 10,
|
||||||
|
x2: Math.round(l.x2 * 10) / 10,
|
||||||
|
y2: Math.round(l.y2 * 10) / 10,
|
||||||
|
}))
|
||||||
|
logger.log('[KERAB-INNERLINES-' + label + '] ' + JSON.stringify(rows))
|
||||||
|
}
|
||||||
|
dumpInnerLineSnapshot('BEFORE')
|
||||||
// [KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 직전 target.attributes.offset 을 roofLine 에 surgical 반영.
|
// [KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 직전 target.attributes.offset 을 roofLine 에 surgical 반영.
|
||||||
// SK 재실행 없이 외곽 corner / inner-line endpoint 만 이동 → kLine 등 layered custom 라인 보존.
|
// SK 재실행 없이 외곽 corner / inner-line endpoint 만 이동 → kLine 등 layered custom 라인 보존.
|
||||||
if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0)
|
if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0)
|
||||||
@ -608,9 +627,9 @@ export function useEavesGableEdit(id) {
|
|||||||
const FAR_RAY = 1e5
|
const FAR_RAY = 1e5
|
||||||
const start = { x: ep.sx, y: ep.sy }
|
const start = { x: ep.sx, y: ep.sy }
|
||||||
const rayEnd = { x: ep.sx + ux * FAR_RAY, y: ep.sy + uy * FAR_RAY }
|
const rayEnd = { x: ep.sx + ux * FAR_RAY, y: ep.sy + uy * FAR_RAY }
|
||||||
// [KERAB-VALLEY-EXT-ALWAYS-MID 2026-05-27] 사용자 규칙: "골짜기 세로라인이 더 확장되어야 한다."
|
// [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-27] 새 규칙:
|
||||||
// RIDGE stopper 제거 — 무조건 polygon-wall 까지 거리의 절반 = polygon width 의 대칭중앙.
|
// 1) ridge(마루) 만나면 그 점에서 정지. hip 은 통과.
|
||||||
// ridge·hip 은 모두 통과 (경로 위 교차 시 trim 단계에서 절삭).
|
// 2) ridge 못 만나면 맞은편 polygon-wall(roofLine 너머) 까지 끝까지 확장 (절반 아님).
|
||||||
let bestPt = null
|
let bestPt = null
|
||||||
let wallT = Infinity
|
let wallT = Infinity
|
||||||
let wallHit = null
|
let wallHit = null
|
||||||
@ -625,12 +644,35 @@ export function useEavesGableEdit(id) {
|
|||||||
wallHit = ip
|
wallHit = ip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (wallHit) {
|
let ridgeStop = null
|
||||||
bestPt = { x: (start.x + wallHit.x) / 2, y: (start.y + wallHit.y) / 2 }
|
let ridgeT = Infinity
|
||||||
|
for (const il of roof.innerLines || []) {
|
||||||
|
if (!il || il.visible === false) continue
|
||||||
|
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
|
||||||
|
if (il.lineName === 'kerabPatternValleyExt') continue
|
||||||
|
const ip = lineLineIntersection(start, rayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
|
||||||
|
if (!ip) continue
|
||||||
|
if (!isPointOnSegment(ip, il.x1, il.y1, il.x2, il.y2)) continue
|
||||||
|
const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy
|
||||||
|
if (t < 0.5) continue
|
||||||
|
if (wallT !== Infinity && t > wallT + 0.5) continue
|
||||||
|
if (t < ridgeT) {
|
||||||
|
ridgeT = t
|
||||||
|
ridgeStop = ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ridgeStop) {
|
||||||
|
bestPt = ridgeStop
|
||||||
logger.log(
|
logger.log(
|
||||||
'[KERAB-VALLEY-EXT-MID] pre label=' + ep.label +
|
'[KERAB-VALLEY-EXT-RIDGE-STOP] pre label=' + ep.label +
|
||||||
' wallHit={' + Math.round(wallHit.x * 100) / 100 + ',' + Math.round(wallHit.y * 100) / 100 + '}' +
|
' ridgeT=' + Math.round(ridgeT * 100) / 100 +
|
||||||
' mid={' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + '}',
|
' stop={' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + '}',
|
||||||
|
)
|
||||||
|
} else if (wallHit) {
|
||||||
|
bestPt = wallHit
|
||||||
|
logger.log(
|
||||||
|
'[KERAB-VALLEY-EXT-WALL] pre label=' + ep.label +
|
||||||
|
' end={' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + '}',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (bestPt) {
|
if (bestPt) {
|
||||||
@ -1431,6 +1473,13 @@ export function useEavesGableEdit(id) {
|
|||||||
// raycast 대상 = 현재 roof.innerLines 중 HIP/RIDGE (= 살아남은 hip·ridge + 새 extLines·kLine 모두 포함).
|
// raycast 대상 = 현재 roof.innerLines 중 HIP/RIDGE (= 살아남은 hip·ridge + 새 extLines·kLine 모두 포함).
|
||||||
// 사라진 polygonPath hip/ridge 는 이미 제거되어 자동 제외. valleyExt 자신은 push 후이므로 1mm 필터로 보호.
|
// 사라진 polygonPath hip/ridge 는 이미 제거되어 자동 제외. valleyExt 자신은 push 후이므로 1mm 필터로 보호.
|
||||||
if (valleyPlannedEndpoints.length) {
|
if (valleyPlannedEndpoints.length) {
|
||||||
|
// ====================================================================
|
||||||
|
// [KERAB-VALLEY-EXT 2026-05-28] valleyExt helper 4종 (Step B 추출).
|
||||||
|
// Phase 1 = computeValleyExtensions + drawValleyExtensions
|
||||||
|
// Phase 2 = trimByValleyExtensions + cascadeHideByValleyExtensions
|
||||||
|
// 모든 helper 는 closure 로 roof/target/canvas/roofPolygonWalls/valleyPlannedEndpoints 캡처.
|
||||||
|
// revert 계약 (lineName='kerabPatternValleyExt', __targetId, target.__valleyExtTrims) 그대로 유지.
|
||||||
|
// ====================================================================
|
||||||
const isOnSegV = (pt, ax, ay, bx, by, tol = 0.5) => {
|
const isOnSegV = (pt, ax, ay, bx, by, tol = 0.5) => {
|
||||||
const sdx = bx - ax
|
const sdx = bx - ax
|
||||||
const sdy = by - ay
|
const sdy = by - ay
|
||||||
@ -1443,209 +1492,87 @@ export function useEavesGableEdit(id) {
|
|||||||
const py = ay + tt * sdy
|
const py = ay + tt * sdy
|
||||||
return Math.hypot(px - pt.x, py - pt.y) <= tol
|
return Math.hypot(px - pt.x, py - pt.y) <= tol
|
||||||
}
|
}
|
||||||
const valleyExtensions = []
|
|
||||||
for (const ep of valleyPlannedEndpoints) {
|
|
||||||
const start = { x: ep.sx, y: ep.sy }
|
|
||||||
// [KERAB-VALLEY-EXT 2026-05-27] self-extension 방향만 사용 (양 끝점 둘 다 시도).
|
|
||||||
// concave corner 측 끝점 → polygon 내부로 향해 hip/ridge hit → 그려짐.
|
|
||||||
// convex 측 끝점 → polygon 외부로 새서 hit 없음 → 자동 skip.
|
|
||||||
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 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 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, 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) {
|
|
||||||
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,
|
|
||||||
y1: start.y,
|
|
||||||
x2: bestStop.x,
|
|
||||||
y2: bestStop.y,
|
|
||||||
source: ep.label,
|
|
||||||
parent: ep.parent || null,
|
|
||||||
}
|
|
||||||
valleyExtensions.push(seg)
|
|
||||||
logger.log('[KERAB-VALLEY-EXT] generated ' + JSON.stringify(seg))
|
|
||||||
} else {
|
|
||||||
logger.log('[KERAB-VALLEY-EXT] no ridge/hip hit for ' + ep.label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// [KERAB-VALLEY-EXT-TRIM 2026-05-27] valleyExt 경로상 교차 hip/ridge 절삭.
|
|
||||||
// 각 valleyExt segment 에 대해 모든 hip/ridge 와 교차 검사 → 교차하면 valleyExt 시작점에
|
|
||||||
// 가까운 끝점을 hitPoint 로 단축 (= corner 쪽 부분이 잘리고 너머 쪽이 보존).
|
|
||||||
// 사용자 규칙: "대칭중앙까지 가는 도중에 힙 또는 마루를 만나면 힙·마루는 절삭한다."
|
|
||||||
// cascade 도미노는 비활성 (junction 공유 hip 의도 외 절삭 부작용).
|
|
||||||
// revert 시 trimRecords 역순 복원.
|
|
||||||
const trimRecords = []
|
|
||||||
for (const vr of valleyExtensions) {
|
|
||||||
if (!vr.source || !vr.source.startsWith('roofBase')) continue
|
|
||||||
const segA = { x: vr.x1, y: vr.y1 }
|
|
||||||
const segB = { x: vr.x2, y: vr.y2 }
|
|
||||||
// 사용자 규칙: "조건 다 필요 없고 확장하는 라인 절삭."
|
|
||||||
// trim 검사 ray = segA(corner) → wallEnd(polygon-wall). segB(대칭중앙) 은 segA-wallEnd 의 중점.
|
|
||||||
// ray 위에서 만나는 hip/ridge 의 진행방향 쪽 끝점(= u 투영값 큰 쪽) 을 ip 로 단축.
|
|
||||||
// collinear (valleyExt 와 평행+동일직선) 라인은 별도 처리 — segB 너머 끝점을 segB 로 단축.
|
|
||||||
const wallEnd = { x: 2 * segB.x - segA.x, y: 2 * segB.y - segA.y }
|
|
||||||
const dxV = wallEnd.x - segA.x
|
|
||||||
const dyV = wallEnd.y - segA.y
|
|
||||||
const lenV = Math.hypot(dxV, dyV) || 1
|
|
||||||
const ux = dxV / lenV
|
|
||||||
const uy = dyV / lenV
|
|
||||||
const tB = (segB.x - segA.x) * ux + (segB.y - segA.y) * uy
|
|
||||||
const COLLINEAR_TOL = 1.0
|
|
||||||
for (const il of roof.innerLines || []) {
|
|
||||||
if (!il) continue
|
|
||||||
if (il.lineName === 'kerabPatternValleyExt') continue
|
|
||||||
// 사용자 규칙: "Y 모양일때 hip 있는 쪽은 살리고 마루쪽을 절삭." + "1522 도 삭제."
|
|
||||||
// trim 대상 = ridge(마루) + 케라바 토글 생성 hip(kerabPatternHip / kerabPatternExtHip).
|
|
||||||
// 원래 지붕 hip(H-5/H-6/H-7/H-1) 은 보존. hip-presence 룰이 자기 자신 제외하므로 안전.
|
|
||||||
const isTrimCandidate =
|
|
||||||
il.name === LINE_TYPE.SUBLINE.RIDGE ||
|
|
||||||
(il.name === LINE_TYPE.SUBLINE.HIP && (il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip'))
|
|
||||||
if (!isTrimCandidate) continue
|
|
||||||
if (il.visible === false) continue
|
|
||||||
const a = { x: il.x1, y: il.y1 }
|
|
||||||
const b = { x: il.x2, y: il.y2 }
|
|
||||||
let ip = lineLineIntersection(segA, wallEnd, a, b)
|
|
||||||
if (ip) {
|
|
||||||
if (!isOnSegV(ip, segA.x, segA.y, wallEnd.x, wallEnd.y)) ip = null
|
|
||||||
else if (!isOnSegV(ip, a.x, a.y, b.x, b.y)) ip = null
|
|
||||||
}
|
|
||||||
if (!ip) {
|
|
||||||
const perp1 = Math.abs((il.x1 - segA.x) * uy - (il.y1 - segA.y) * ux)
|
|
||||||
const perp2 = Math.abs((il.x2 - segA.x) * uy - (il.y2 - segA.y) * ux)
|
|
||||||
if (perp1 > COLLINEAR_TOL || perp2 > COLLINEAR_TOL) continue
|
|
||||||
const tc1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy
|
|
||||||
const tc2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy
|
|
||||||
if (Math.max(tc1, tc2) <= tB + 0.5) continue
|
|
||||||
ip = { x: segB.x, y: segB.y }
|
|
||||||
}
|
|
||||||
// 사용자 규칙: "Y 모양일때 hip 있는 쪽은 살리고 마루(ridge) 쪽을 절삭."
|
|
||||||
// ridge 의 각 끝점에 *원래 지붕* hip 끝점이 붙어있는지 검사.
|
|
||||||
// 케라바 토글 생성 hip(kerabPatternHip/ExtHip) 은 골짜기 부속물이므로 hip-presence 카운트 제외.
|
|
||||||
// hip 있는 쪽 보존, 없는 쪽 단축. 양쪽 다 원래 hip → 통째 보존(continue).
|
|
||||||
// 양쪽 다 원래 hip 없음 → Y-junction 아닌 골짜기 부속물 → 통째 hide.
|
|
||||||
const isOriginalHipEndAt = (px, py) => {
|
|
||||||
for (const other of roof.innerLines || []) {
|
|
||||||
if (!other || other === il) continue
|
|
||||||
if (other.lineName === 'kerabPatternValleyExt') continue
|
|
||||||
if (other.lineName === 'kerabPatternHip' || other.lineName === 'kerabPatternExtHip') continue
|
|
||||||
if (other.name !== LINE_TYPE.SUBLINE.HIP) continue
|
|
||||||
if (other.visible === false) continue
|
|
||||||
if (Math.hypot(other.x1 - px, other.y1 - py) < 1.0) return true
|
|
||||||
if (Math.hypot(other.x2 - px, other.y2 - py) < 1.0) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const hip1 = isOriginalHipEndAt(il.x1, il.y1)
|
|
||||||
const hip2 = isOriginalHipEndAt(il.x2, il.y2)
|
|
||||||
let trimEnd
|
|
||||||
if (hip1 && hip2) {
|
|
||||||
logger.log('[KERAB-VALLEY-EXT-TRIM] both-hip preserve ' + JSON.stringify({ name: il.name, lineName: il.lineName }))
|
|
||||||
continue
|
|
||||||
} else if (hip1 && !hip2) {
|
|
||||||
trimEnd = 2
|
|
||||||
} else if (!hip1 && hip2) {
|
|
||||||
trimEnd = 1
|
|
||||||
} else {
|
|
||||||
// 양쪽 다 원래 hip 없음 — 진행벡터 fallback (cascade pass 에서 부속물 정리)
|
|
||||||
const t1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy
|
|
||||||
const t2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy
|
|
||||||
trimEnd = t1 > t2 ? 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: ip.x, y: ip.y },
|
|
||||||
originalAttrs: { ...(il.attributes || {}) },
|
|
||||||
})
|
|
||||||
if (trimEnd === 1) il.set({ x1: ip.x, y1: ip.y })
|
|
||||||
else il.set({ x2: ip.x, y2: ip.y })
|
|
||||||
if (typeof il.setCoords === 'function') il.setCoords()
|
|
||||||
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 }, ip }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 사용자 규칙: "원 라인이 절삭되었으면 그 밑으로 확장된 라인도 삭제."
|
|
||||||
// cascade pass — trim 결과 옛 끝점(oldPt) 에 끝점이 일치하는 케라바산 라인(kerabPatternHip/ExtHip/Ridge) 을 hide.
|
|
||||||
// 가드: 원래 지붕 ridge/hip(lineName 없거나 kerabPattern* 아닌 것) 은 cascade 대상 외 — junction 공유 의도 외 절삭 방지.
|
|
||||||
const isKerabSynthetic = (line) => {
|
|
||||||
if (!line || !line.lineName) return false
|
|
||||||
return (
|
|
||||||
line.lineName === 'kerabPatternHip' ||
|
|
||||||
line.lineName === 'kerabPatternExtHip' ||
|
|
||||||
line.lineName === 'kerabPatternRidge' ||
|
|
||||||
line.lineName === 'kerabPatternExtRidge'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const cascadeHidden = new Set()
|
|
||||||
// BFS — cascade hide 된 라인의 다른 끝점에 붙은 케라바산 라인도 재귀 hide.
|
|
||||||
const cascadeQueue = trimRecords.map((r) => r.oldPt).filter(Boolean)
|
|
||||||
let cascadeGuard = 0
|
|
||||||
while (cascadeQueue.length && cascadeGuard++ < 200) {
|
|
||||||
const pt = cascadeQueue.shift()
|
|
||||||
if (!pt) continue
|
|
||||||
for (const il of roof.innerLines || []) {
|
|
||||||
if (!il || il.visible === false) continue
|
|
||||||
if (cascadeHidden.has(il)) continue
|
|
||||||
if (il.lineName === 'kerabPatternValleyExt') continue
|
|
||||||
if (!isKerabSynthetic(il)) continue
|
|
||||||
const m1 = Math.hypot(il.x1 - pt.x, il.y1 - pt.y) < 1.0
|
|
||||||
const m2 = Math.hypot(il.x2 - pt.x, il.y2 - pt.y) < 1.0
|
|
||||||
if (!m1 && !m2) continue
|
|
||||||
cascadeHidden.add(il)
|
|
||||||
trimRecords.push({
|
|
||||||
line: il,
|
|
||||||
hide: true,
|
|
||||||
originalAttrs: { ...(il.attributes || {}) },
|
|
||||||
originalVisible: il.visible !== false,
|
|
||||||
})
|
|
||||||
il.visible = false
|
|
||||||
if (typeof il.setCoords === 'function') il.setCoords()
|
|
||||||
logger.log(
|
|
||||||
'[KERAB-VALLEY-EXT-TRIM] cascade hide ' +
|
|
||||||
JSON.stringify({ name: il.name, lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }),
|
|
||||||
)
|
|
||||||
// 다른 끝점도 cascade 시드에 추가
|
|
||||||
cascadeQueue.push(m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (trimRecords.length) target.__valleyExtTrims = trimRecords
|
|
||||||
|
|
||||||
for (const vr of valleyExtensions) {
|
// ── Phase 1-a: valleyExt ray 계산 ──
|
||||||
|
// self-extension 방향만 사용 (양 끝점 둘 다 시도, concave 측만 hit).
|
||||||
|
// ridge meet first, 못 만나면 wallhit 끝까지. hip 통과.
|
||||||
|
const computeValleyExtensions = () => {
|
||||||
|
const exts = []
|
||||||
|
for (const ep of valleyPlannedEndpoints) {
|
||||||
|
const start = { x: ep.sx, y: ep.sy }
|
||||||
|
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 rayEnd = { x: start.x + ux * FAR_RAY, y: start.y + uy * FAR_RAY }
|
||||||
|
let bestStop = 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 (!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 < 0.5) continue
|
||||||
|
if (t < wallT) {
|
||||||
|
wallT = t
|
||||||
|
wallHit = ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ridgeStop = null
|
||||||
|
let ridgeT = Infinity
|
||||||
|
for (const il of roof.innerLines || []) {
|
||||||
|
if (!il || il.visible === false) continue
|
||||||
|
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
|
||||||
|
if (il.lineName === 'kerabPatternValleyExt') continue
|
||||||
|
const ip = lineLineIntersection(start, rayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
|
||||||
|
if (!ip) continue
|
||||||
|
if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue
|
||||||
|
const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy
|
||||||
|
if (t < 0.5) continue
|
||||||
|
if (wallT !== Infinity && t > wallT + 0.5) continue
|
||||||
|
if (t < ridgeT) {
|
||||||
|
ridgeT = t
|
||||||
|
ridgeStop = ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ridgeStop) {
|
||||||
|
bestStop = ridgeStop
|
||||||
|
logger.log(
|
||||||
|
'[KERAB-VALLEY-EXT-RIDGE-STOP] post label=' + ep.label +
|
||||||
|
' ridgeT=' + Math.round(ridgeT * 100) / 100 +
|
||||||
|
' stop={' + Math.round(bestStop.x * 100) / 100 + ',' + Math.round(bestStop.y * 100) / 100 + '}',
|
||||||
|
)
|
||||||
|
} else if (wallHit) {
|
||||||
|
bestStop = wallHit
|
||||||
|
logger.log(
|
||||||
|
'[KERAB-VALLEY-EXT-WALL] post label=' + ep.label +
|
||||||
|
' end={' + Math.round(bestStop.x * 100) / 100 + ',' + Math.round(bestStop.y * 100) / 100 + '}',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (bestStop) {
|
||||||
|
const seg = {
|
||||||
|
x1: start.x,
|
||||||
|
y1: start.y,
|
||||||
|
x2: bestStop.x,
|
||||||
|
y2: bestStop.y,
|
||||||
|
source: ep.label,
|
||||||
|
parent: ep.parent || null,
|
||||||
|
}
|
||||||
|
exts.push(seg)
|
||||||
|
logger.log('[KERAB-VALLEY-EXT] generated ' + JSON.stringify(seg))
|
||||||
|
} else {
|
||||||
|
logger.log('[KERAB-VALLEY-EXT] no ridge/hip hit for ' + ep.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return exts
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 1-b: roof + wall QLine 생성 (순수 그리기, trim/hide 없음) ──
|
||||||
|
const drawValleyExtensions = (valleyExtensions) => {
|
||||||
|
for (const vr of valleyExtensions) {
|
||||||
const pts = [vr.x1, vr.y1, vr.x2, vr.y2]
|
const pts = [vr.x1, vr.y1, vr.x2, vr.y2]
|
||||||
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] })
|
||||||
// [KERAB-VALLEY-EXT 2026-05-27] 지붕면 할당 통합 (option a):
|
// [KERAB-VALLEY-EXT 2026-05-27] 지붕면 할당 통합 (option a):
|
||||||
@ -1680,14 +1607,252 @@ export function useEavesGableEdit(id) {
|
|||||||
canvas.add(vExt)
|
canvas.add(vExt)
|
||||||
vExt.bringToFront()
|
vExt.bringToFront()
|
||||||
if (isRoofBase) roof.innerLines.push(vExt)
|
if (isRoofBase) roof.innerLines.push(vExt)
|
||||||
|
|
||||||
|
// [KERAB-VALLEY-EXT-WALL 2026-05-28] wallBase 레벨 확장 라인 — 독립 ray.
|
||||||
|
// 사용자 룰: "맞은편 roofLine 까지" — wallBase 측도 자체 시작점에서 ray 쏴서 polygon-wall hit 까지.
|
||||||
|
// roof 측 끝점과 공유 X (그건 만나라는 의미). roof 측과 동일 방향, ridge-stop 룰 동일.
|
||||||
|
// wallBase 측은 시각만 — innerLines push X, type=EAVES X, parentLine X.
|
||||||
|
if (isRoofBase && vr.parent && target) {
|
||||||
|
const rl = vr.parent
|
||||||
|
const dxR = rl.x2 - rl.x1
|
||||||
|
const dyR = rl.y2 - rl.y1
|
||||||
|
const lenR = Math.hypot(dxR, dyR) || 1
|
||||||
|
const uxR2 = dxR / lenR
|
||||||
|
const uyR2 = dyR / lenR
|
||||||
|
const sT1 = (target.x1 - rl.x1) * uxR2 + (target.y1 - rl.y1) * uyR2
|
||||||
|
const sT2 = (target.x2 - rl.x1) * uxR2 + (target.y2 - rl.y1) * uyR2
|
||||||
|
const tStart = sT1 < sT2 ? { x: target.x1, y: target.y1 } : { x: target.x2, y: target.y2 }
|
||||||
|
const tEnd = sT1 < sT2 ? { x: target.x2, y: target.y2 } : { x: target.x1, y: target.y1 }
|
||||||
|
const wallCorner = vr.source === 'roofBase-s' ? tStart : tEnd
|
||||||
|
// 방향: vr 방향 그대로 (vr.x1,y1 → vr.x2,y2)
|
||||||
|
const vdx = vr.x2 - vr.x1
|
||||||
|
const vdy = vr.y2 - vr.y1
|
||||||
|
const vlen = Math.hypot(vdx, vdy) || 1
|
||||||
|
const wUx = vdx / vlen
|
||||||
|
const wUy = vdy / vlen
|
||||||
|
const wStart = { x: wallCorner.x, y: wallCorner.y }
|
||||||
|
const wRayEnd = { x: wStart.x + wUx * 1e5, y: wStart.y + wUy * 1e5 }
|
||||||
|
// polygon-wall hit
|
||||||
|
let wWallT = Infinity
|
||||||
|
let wWallHit = null
|
||||||
|
for (const w of roofPolygonWalls) {
|
||||||
|
const ip = lineLineIntersection(wStart, wRayEnd, w.a, w.b)
|
||||||
|
if (!ip) continue
|
||||||
|
if (!isOnSegV(ip, w.a.x, w.a.y, w.b.x, w.b.y)) continue
|
||||||
|
const t = (ip.x - wStart.x) * wUx + (ip.y - wStart.y) * wUy
|
||||||
|
if (t < 0.5) continue
|
||||||
|
if (t < wWallT) {
|
||||||
|
wWallT = t
|
||||||
|
wWallHit = ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ridge stop (roof 측과 동일 룰)
|
||||||
|
let wRidgeT = Infinity
|
||||||
|
let wRidgeStop = null
|
||||||
|
for (const il of roof.innerLines || []) {
|
||||||
|
if (!il || il.visible === false) continue
|
||||||
|
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
|
||||||
|
if (il.lineName === 'kerabPatternValleyExt') continue
|
||||||
|
const ip = lineLineIntersection(wStart, wRayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
|
||||||
|
if (!ip) continue
|
||||||
|
if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue
|
||||||
|
const t = (ip.x - wStart.x) * wUx + (ip.y - wStart.y) * wUy
|
||||||
|
if (t < 0.5) continue
|
||||||
|
if (wWallT !== Infinity && t > wWallT + 0.5) continue
|
||||||
|
if (t < wRidgeT) {
|
||||||
|
wRidgeT = t
|
||||||
|
wRidgeStop = ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const wEnd = wRidgeStop || wWallHit
|
||||||
|
if (wEnd) {
|
||||||
|
const wallPts = [wStart.x, wStart.y, wEnd.x, wEnd.y]
|
||||||
|
const wallSz = calcLinePlaneSize({ x1: wallPts[0], y1: wallPts[1], x2: wallPts[2], y2: wallPts[3] })
|
||||||
|
const wallExt = new QLine(wallPts, {
|
||||||
|
parentId: roof.id,
|
||||||
|
fontSize: roof.fontSize,
|
||||||
|
stroke: '#1083E3',
|
||||||
|
strokeWidth: 3,
|
||||||
|
name: LINE_TYPE.SUBLINE.VALLEY,
|
||||||
|
textMode: roof.textMode,
|
||||||
|
attributes: { roofId: roof.id, planeSize: wallSz, actualSize: wallSz },
|
||||||
|
})
|
||||||
|
wallExt.lineName = 'kerabPatternValleyExt'
|
||||||
|
wallExt.__targetId = target.id
|
||||||
|
wallExt.__valleyExtSource = vr.source + '-wall'
|
||||||
|
canvas.add(wallExt)
|
||||||
|
wallExt.bringToFront()
|
||||||
|
logger.log(
|
||||||
|
'[KERAB-VALLEY-EXT-WALL] drawn ' +
|
||||||
|
JSON.stringify({
|
||||||
|
src: vr.source,
|
||||||
|
stop: wRidgeStop ? 'ridge' : 'wall',
|
||||||
|
from: { x: Math.round(wStart.x * 100) / 100, y: Math.round(wStart.y * 100) / 100 },
|
||||||
|
to: { x: Math.round(wEnd.x * 100) / 100, y: Math.round(wEnd.y * 100) / 100 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.log('[KERAB-VALLEY-EXT-WALL] no hit for ' + vr.source)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 2-a: valleyExt 경로상 교차 hip/ridge 절삭 ──
|
||||||
|
// trim 대상 = ridge + 케라바 토글 생성 hip(kerabPatternHip/ExtHip). 원래 지붕 hip 보존.
|
||||||
|
// Y 룰: hip-presence 검사 → hip 있는 쪽 보존, 없는 쪽 단축.
|
||||||
|
const trimByValleyExtensions = (valleyExtensions) => {
|
||||||
|
const trimRecords = []
|
||||||
|
for (const vr of valleyExtensions) {
|
||||||
|
if (!vr.source || !vr.source.startsWith('roofBase')) continue
|
||||||
|
const segA = { x: vr.x1, y: vr.y1 }
|
||||||
|
const segB = { x: vr.x2, y: vr.y2 }
|
||||||
|
const wallEnd = { x: 2 * segB.x - segA.x, y: 2 * segB.y - segA.y }
|
||||||
|
const dxV = wallEnd.x - segA.x
|
||||||
|
const dyV = wallEnd.y - segA.y
|
||||||
|
const lenV = Math.hypot(dxV, dyV) || 1
|
||||||
|
const ux = dxV / lenV
|
||||||
|
const uy = dyV / lenV
|
||||||
|
const tB = (segB.x - segA.x) * ux + (segB.y - segA.y) * uy
|
||||||
|
const COLLINEAR_TOL = 1.0
|
||||||
|
for (const il of roof.innerLines || []) {
|
||||||
|
if (!il) continue
|
||||||
|
if (il.lineName === 'kerabPatternValleyExt') continue
|
||||||
|
// trim 대상 = ridge(마루) + 케라바 토글 생성 hip(kerabPatternHip / kerabPatternExtHip).
|
||||||
|
// 원래 지붕 hip 은 보존.
|
||||||
|
const isTrimCandidate =
|
||||||
|
il.name === LINE_TYPE.SUBLINE.RIDGE ||
|
||||||
|
(il.name === LINE_TYPE.SUBLINE.HIP && (il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip'))
|
||||||
|
if (!isTrimCandidate) continue
|
||||||
|
if (il.visible === false) continue
|
||||||
|
const a = { x: il.x1, y: il.y1 }
|
||||||
|
const b = { x: il.x2, y: il.y2 }
|
||||||
|
let ip = lineLineIntersection(segA, wallEnd, a, b)
|
||||||
|
if (ip) {
|
||||||
|
if (!isOnSegV(ip, segA.x, segA.y, wallEnd.x, wallEnd.y)) ip = null
|
||||||
|
else if (!isOnSegV(ip, a.x, a.y, b.x, b.y)) ip = null
|
||||||
|
}
|
||||||
|
if (!ip) {
|
||||||
|
const perp1 = Math.abs((il.x1 - segA.x) * uy - (il.y1 - segA.y) * ux)
|
||||||
|
const perp2 = Math.abs((il.x2 - segA.x) * uy - (il.y2 - segA.y) * ux)
|
||||||
|
if (perp1 > COLLINEAR_TOL || perp2 > COLLINEAR_TOL) continue
|
||||||
|
const tc1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy
|
||||||
|
const tc2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy
|
||||||
|
if (Math.max(tc1, tc2) <= tB + 0.5) continue
|
||||||
|
ip = { x: segB.x, y: segB.y }
|
||||||
|
}
|
||||||
|
// Y 룰: hip-presence 검사 → hip 있는 쪽 보존, 없는 쪽 단축.
|
||||||
|
// 양쪽 다 원래 hip 있음 → 통째 보존. 양쪽 다 없음 → 진행벡터 fallback (cascade pass 에서 정리).
|
||||||
|
const isOriginalHipEndAt = (px, py) => {
|
||||||
|
for (const other of roof.innerLines || []) {
|
||||||
|
if (!other || other === il) continue
|
||||||
|
if (other.lineName === 'kerabPatternValleyExt') continue
|
||||||
|
if (other.lineName === 'kerabPatternHip' || other.lineName === 'kerabPatternExtHip') continue
|
||||||
|
if (other.name !== LINE_TYPE.SUBLINE.HIP) continue
|
||||||
|
if (other.visible === false) continue
|
||||||
|
if (Math.hypot(other.x1 - px, other.y1 - py) < 1.0) return true
|
||||||
|
if (Math.hypot(other.x2 - px, other.y2 - py) < 1.0) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const hip1 = isOriginalHipEndAt(il.x1, il.y1)
|
||||||
|
const hip2 = isOriginalHipEndAt(il.x2, il.y2)
|
||||||
|
let trimEnd
|
||||||
|
if (hip1 && hip2) {
|
||||||
|
logger.log('[KERAB-VALLEY-EXT-TRIM] both-hip preserve ' + JSON.stringify({ name: il.name, lineName: il.lineName }))
|
||||||
|
continue
|
||||||
|
} else if (hip1 && !hip2) {
|
||||||
|
trimEnd = 2
|
||||||
|
} else if (!hip1 && hip2) {
|
||||||
|
trimEnd = 1
|
||||||
|
} else {
|
||||||
|
const t1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy
|
||||||
|
const t2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy
|
||||||
|
trimEnd = t1 > t2 ? 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: ip.x, y: ip.y },
|
||||||
|
originalAttrs: { ...(il.attributes || {}) },
|
||||||
|
})
|
||||||
|
if (trimEnd === 1) il.set({ x1: ip.x, y1: ip.y })
|
||||||
|
else il.set({ x2: ip.x, y2: ip.y })
|
||||||
|
if (typeof il.setCoords === 'function') il.setCoords()
|
||||||
|
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 }, ip }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimRecords
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 2-b: cascade hide ──
|
||||||
|
// trim oldPt 에 끝점 일치 케라바산 라인(kerabPatternHip/ExtHip/Ridge/ExtRidge) BFS hide.
|
||||||
|
// 원래 지붕 ridge/hip 은 cascade 대상 외 — junction 공유 의도 외 절삭 방지.
|
||||||
|
const cascadeHideByValleyExtensions = (trimRecords) => {
|
||||||
|
const isKerabSynthetic = (line) => {
|
||||||
|
if (!line || !line.lineName) return false
|
||||||
|
return (
|
||||||
|
line.lineName === 'kerabPatternHip' ||
|
||||||
|
line.lineName === 'kerabPatternExtHip' ||
|
||||||
|
line.lineName === 'kerabPatternRidge' ||
|
||||||
|
line.lineName === 'kerabPatternExtRidge'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const cascadeHidden = new Set()
|
||||||
|
const cascadeQueue = trimRecords.map((r) => r.oldPt).filter(Boolean)
|
||||||
|
let cascadeGuard = 0
|
||||||
|
while (cascadeQueue.length && cascadeGuard++ < 200) {
|
||||||
|
const pt = cascadeQueue.shift()
|
||||||
|
if (!pt) continue
|
||||||
|
for (const il of roof.innerLines || []) {
|
||||||
|
if (!il || il.visible === false) continue
|
||||||
|
if (cascadeHidden.has(il)) continue
|
||||||
|
if (il.lineName === 'kerabPatternValleyExt') continue
|
||||||
|
if (!isKerabSynthetic(il)) continue
|
||||||
|
const m1 = Math.hypot(il.x1 - pt.x, il.y1 - pt.y) < 1.0
|
||||||
|
const m2 = Math.hypot(il.x2 - pt.x, il.y2 - pt.y) < 1.0
|
||||||
|
if (!m1 && !m2) continue
|
||||||
|
cascadeHidden.add(il)
|
||||||
|
trimRecords.push({
|
||||||
|
line: il,
|
||||||
|
hide: true,
|
||||||
|
originalAttrs: { ...(il.attributes || {}) },
|
||||||
|
originalVisible: il.visible !== false,
|
||||||
|
})
|
||||||
|
il.visible = false
|
||||||
|
if (typeof il.setCoords === 'function') il.setCoords()
|
||||||
|
logger.log(
|
||||||
|
'[KERAB-VALLEY-EXT-TRIM] cascade hide ' +
|
||||||
|
JSON.stringify({ name: il.name, lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }),
|
||||||
|
)
|
||||||
|
cascadeQueue.push(m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Phase 1 + Phase 2 실행 ===
|
||||||
|
const valleyExtensions = computeValleyExtensions()
|
||||||
|
drawValleyExtensions(valleyExtensions)
|
||||||
if (valleyExtensions.length) {
|
if (valleyExtensions.length) {
|
||||||
logger.log('[KERAB-VALLEY-EXT] drawn ' + valleyExtensions.length)
|
logger.log('[KERAB-VALLEY-EXT] drawn ' + valleyExtensions.length)
|
||||||
}
|
}
|
||||||
|
const trimRecords = trimByValleyExtensions(valleyExtensions)
|
||||||
|
cascadeHideByValleyExtensions(trimRecords)
|
||||||
|
if (trimRecords.length) target.__valleyExtTrims = trimRecords
|
||||||
}
|
}
|
||||||
|
dumpInnerLineSnapshot('AFTER')
|
||||||
logger.log('[KERAB-SIMPLE] applied (sequential, drawKLine=' + drawKLine + ', extraApexes=' + extraApexes.length + ')')
|
logger.log('[KERAB-SIMPLE] applied (sequential, drawKLine=' + drawKLine + ', extraApexes=' + extraApexes.length + ')')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
dumpInnerLineSnapshot('AFTER')
|
||||||
logger.log('[KERAB-SIMPLE] no resolution possible — fallback attr-only')
|
logger.log('[KERAB-SIMPLE] no resolution possible — fallback attr-only')
|
||||||
target.set({ attributes })
|
target.set({ attributes })
|
||||||
applyKerabAttributeOnlyPattern()
|
applyKerabAttributeOnlyPattern()
|
||||||
@ -1705,6 +1870,7 @@ export function useEavesGableEdit(id) {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
|
dumpInnerLineSnapshot('AFTER')
|
||||||
logger.log('[KERAB-SIMPLE] applied (kLine, natural-apex)')
|
logger.log('[KERAB-SIMPLE] applied (kLine, natural-apex)')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user