From 5ba5623d4616e50b0f331864471dbe2f98a56075 Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 27 May 2026 13:58:26 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[2173]=20=EA=B3=A8=EC=A7=9C=EA=B8=B0?= =?UTF-8?q?=ED=99=95=EC=9E=A5=EB=9D=BC=EC=9D=B8=20self-extension=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20+=20TRIM/FIXPOINT-cuts=20off=20=E2=80=94?= =?UTF-8?q?=20=ED=99=95=EC=9E=A5=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/roofcover/useEavesGableEdit.js | 281 +++++++++++------------ 1 file changed, 128 insertions(+), 153 deletions(-) diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index f805c5e7..2af181ab 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -383,10 +383,11 @@ export function useEavesGableEdit(id) { : null, }), ) - // [KERAB-VALLEY-DIAG 2026-05-27] 평행(apex=null) 케라바: polygonPath 라인들의 valley vertex 식별. - // 사용자 전제: 평행 케라바 조건 = polygonPath 내부에 valley 가 2개 존재. 그 중 한쪽이 변환 대상. - // 진단 단계 — 실제 데이터에서 valley 가 어떤 vertex 로 잡히는지 확인. - if (!apex && polygonPath) { + // [KERAB-VALLEY-DIAG 2026-05-27] polygonPath 라인들의 valley vertex 식별 (진단). + // apex 유무 무관 — valley 가 polygonPath 에 존재하면 valleyExt 후보 (gate 완화 2026-05-27). + // surgical 출폭 변경으로 H-3·H-2 평행성이 살짝 깨져 apex 가 폴리곤 밖 멀리 잡히는 케이스(거의 평행) + // 에서도 처마확장이 그려져야 함. 내부의 `h1FarIsValley || h2FarIsValley` 가드가 자동 skip 보장. + if (polygonPath) { const valleyPool = [h1Match.hip, h2Match.hip, target, ...polygonPath.map((p) => p.line)] const valleyInfo = [] for (const line of [h1Match.hip, h2Match.hip, ...polygonPath.map((p) => p.line)]) { @@ -406,63 +407,48 @@ export function useEavesGableEdit(id) { } logger.log('[KERAB-VALLEY-DIAG] ' + JSON.stringify(valleyInfo)) } - // [KERAB-VALLEY-EXT 2026-05-27] valley vertex 검출 + target/roofLine 의 valley 측 endpoint 만 미리 결정. - // 실제 raycast 는 applyKerabKLinePattern 호출 후 (extLines, 새 kLine 이 roof.innerLines 에 추가된 뒤) - // 에 수행해야 hip 자체+확장, ridge 자체+확장이 모두 정지 대상으로 잡힌다. + // [KERAB-VALLEY-EXT 2026-05-27] 모델 교체: skeleton valley → 외곽 polygon concave corner. + // "확장" = roofLine 의 한쪽 끝점에서 self-extension (반대 끝점 → 그 끝점 방향) 으로 연장. + // concave corner 옆 끝점 self-extension 은 polygon 내부로 향함 → 첫 hip/ridge 와 hit. + // convex 측 끝점 self-extension 은 polygon 외부로 새서 hit 없음 → 자동 skip. + // 양 끝점 둘 다 후보로 push → raycast 가 valley 측을 자동 결정. const valleyPlannedEndpoints = [] - if (!apex && polygonPath) { - const valleyPool = [h1Match.hip, h2Match.hip, target, ...polygonPath.map((p) => p.line)] - const h1Info = findInteriorPoint(h1Match.hip, valleyPool) - const h2Info = findInteriorPoint(h2Match.hip, valleyPool) - const h1FarIsEnd = - Math.hypot(h1Match.far.x - h1Match.hip.x2, h1Match.far.y - h1Match.hip.y2) < 1 - const h2FarIsEnd = - Math.hypot(h2Match.far.x - h2Match.hip.x2, h2Match.far.y - h2Match.hip.y2) < 1 - const h1FarIsValley = h1FarIsEnd ? h1Info.end : h1Info.start - const h2FarIsValley = h2FarIsEnd ? h2Info.end : h2Info.start + if (polygonPath) { + const matchingRoofLine = Array.isArray(roof.lines) + ? roof.lines.find((rl) => rl && rl.attributes?.wallLine === target.id) + : null logger.log( - '[KERAB-VALLEY-EXT] gate ' + - JSON.stringify({ h1FarIsValley, h2FarIsValley }), + '[KERAB-VALLEY-EXT] roofLine-match ' + + JSON.stringify({ + targetId: target.id, + targetIdx: target.idx, + found: !!matchingRoofLine, + rl: matchingRoofLine + ? { x1: matchingRoofLine.x1, y1: matchingRoofLine.y1, x2: matchingRoofLine.x2, y2: matchingRoofLine.y2 } + : null, + }), ) - if (h1FarIsValley || h2FarIsValley) { - // target 의 valley 측 endpoint = SK valley vertex (h1/h2.far 중 valley 인 쪽) 와 더 가까운 쪽. - const skValley = h1FarIsValley ? h1Match.far : h2Match.far - const dTo1 = Math.hypot(target.x1 - skValley.x, target.y1 - skValley.y) - const dTo2 = Math.hypot(target.x2 - skValley.x, target.y2 - skValley.y) - const valleyAtStart = dTo1 < dTo2 - const tSX = valleyAtStart ? target.x1 : target.x2 - const tSY = valleyAtStart ? target.y1 : target.y2 - const tOX = valleyAtStart ? target.x2 : target.x1 - const tOY = valleyAtStart ? target.y2 : target.y1 - logger.log( - '[KERAB-VALLEY-EXT] valleyEndpoint ' + - JSON.stringify({ skValley, dTo1, dTo2, valleyAtStart, sx: tSX, sy: tSY }), + if (matchingRoofLine) { + valleyPlannedEndpoints.push( + { + sx: matchingRoofLine.x1, + sy: matchingRoofLine.y1, + ox: matchingRoofLine.x2, + oy: matchingRoofLine.y2, + label: 'roofBase-s', + parent: matchingRoofLine, + }, + { + sx: matchingRoofLine.x2, + sy: matchingRoofLine.y2, + ox: matchingRoofLine.x1, + oy: matchingRoofLine.y1, + label: 'roofBase-e', + parent: matchingRoofLine, + }, ) - valleyPlannedEndpoints.push({ sx: tSX, sy: tSY, ox: tOX, oy: tOY, label: 'wallBase', parent: target }) - // roofLine 짝 = roof.lines 중 attributes.wallLine === target.id 인 것 (drawRoofPolygon CCW 동순서). - const matchingRoofLine = Array.isArray(roof.lines) - ? roof.lines.find((rl) => rl && rl.attributes?.wallLine === target.id) - : null - logger.log( - '[KERAB-VALLEY-EXT] roofLine-match ' + - JSON.stringify({ - targetId: target.id, - targetIdx: target.idx, - found: !!matchingRoofLine, - rl: matchingRoofLine - ? { x1: matchingRoofLine.x1, y1: matchingRoofLine.y1, x2: matchingRoofLine.x2, y2: matchingRoofLine.y2 } - : null, - }), - ) - if (matchingRoofLine) { - const rSX = valleyAtStart ? matchingRoofLine.x1 : matchingRoofLine.x2 - const rSY = valleyAtStart ? matchingRoofLine.y1 : matchingRoofLine.y2 - const rOX = valleyAtStart ? matchingRoofLine.x2 : matchingRoofLine.x1 - const rOY = valleyAtStart ? matchingRoofLine.y2 : matchingRoofLine.y1 - valleyPlannedEndpoints.push({ sx: rSX, sy: rSY, ox: rOX, oy: rOY, label: 'roofBase', parent: matchingRoofLine }) - } else { - logger.log('[KERAB-VALLEY-EXT] no matching roof.lines for target.id=' + target.id) - } + } else { + logger.log('[KERAB-VALLEY-EXT] no matching roof.lines for target.id=' + target.id) } } if (polygonPath === null) { @@ -992,7 +978,17 @@ export function useEavesGableEdit(id) { // (d) re-resolve: stop 점이 죽은 segment(잘려나간 stub 또는 purge 된 확장) // 위에 있던 다른 resolved entry 는 unresolved 로 되돌리고, 새 상태로 // wall + barrier 까지 재확장한다 (Phase A의 "다음 행위"). - if (cuts.length > 0) { + // [KERAB-VALLEY-EXT 2026-05-27] 골짜기확장 케이스에선 cuts 적용 skip. + // 사용자 요구: "골짜기 라인은 확장만 하라" — 처마확장만 그리고 + // 다른 hip/ridge(H-2 등) 은 일체 손대지 말 것. + // staticMeets/cuts 는 일반 케라바 알고리즘의 ext hip pattern 정리용인데, + // 골짜기 케이스에선 polygonPath 외부의 H-2 같은 라인까지 dir=junction 으로 + // 단축시키는 부작용이 있음. + const isValleyExtCase = valleyPlannedEndpoints.length > 0 + if (cuts.length > 0 && isValleyExtCase) { + logger.log('[KERAB-FIXPOINT-PHASE-A] cuts SKIPPED (valley extension case)') + } + if (cuts.length > 0 && !isValleyExtCase) { logger.log( '[KERAB-FIXPOINT-PHASE-A] applying cuts(initial) ' + JSON.stringify( @@ -1300,6 +1296,9 @@ export function useEavesGableEdit(id) { 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 @@ -1312,9 +1311,9 @@ export function useEavesGableEdit(id) { let bestHit = null for (const il of roof.innerLines || []) { if (!il) continue - // [KERAB-VALLEY-EXT 2026-05-27] 처마확장은 자기(kerabPatternValleyExt) 외 모든 inner line 에서 정지. - // "처마확장은 자기와 다른 속성의 라인을 만나면 멈춘다" — cascade/cut 없이 그리기만. + // [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 } @@ -1348,72 +1347,65 @@ export function useEavesGableEdit(id) { logger.log('[KERAB-VALLEY-EXT] no ridge/hip hit for ' + ep.label) } } - // [KERAB-VALLEY-EXT-SPLIT 2026-05-27] hit innerLine 을 hitPoint 에서 분할. - // splitPolygonWithLines 의 graph 가 line 양 endpoint 만 노드로 잡으므로, - // ext 끝점이 기존 innerLine 중간에 있으면 T-junction 이 graph 에 안 잡혀 face 할당이 끊긴다. - // revert 시 복원 위해 target.__valleyExtSplits 에 (halves, original snapshot) 보관. - // wallBase 확장은 wall layer 라 roof face 할당에 참여하면 안 되므로 split 대상에서 제외. - const splitRecords = [] - // 같은 hit line 을 여러 ext 가 공유할 수 있으므로 endpoint set 으로 누적 후 분할. - const splitMap = new Map() // hitLine -> [hitPoints...] - for (const vr of valleyExtensions) { - if (vr.source !== 'roofBase') continue + // [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 + const trimRecords = [] + const TOL_DOMINO = 1.0 + if (ENABLE_VALLEY_EXT_TRIM) for (const vr of valleyExtensions) { + if (!vr.source || !vr.source.startsWith('roofBase')) continue if (!vr.hitLine || !vr.hitPoint) continue - if (!splitMap.has(vr.hitLine)) splitMap.set(vr.hitLine, []) - splitMap.get(vr.hitLine).push(vr.hitPoint) - } - for (const [hitLine, hitPoints] of splitMap.entries()) { - const origPts = [hitLine.x1, hitLine.y1, hitLine.x2, hitLine.y2] - const origAttrs = { ...(hitLine.attributes || {}) } - const origName = hitLine.name - const origLineName = hitLine.lineName - const origStroke = hitLine.stroke - const origStrokeWidth = hitLine.strokeWidth - // start 에서 거리 순 정렬. - const sx = hitLine.x1 - const sy = hitLine.y1 - hitPoints.sort((a, b) => Math.hypot(a.x - sx, a.y - sy) - Math.hypot(b.x - sx, b.y - sy)) - const splitSegs = [] - let cur = { x: hitLine.x1, y: hitLine.y1 } - for (const hp of hitPoints) { - splitSegs.push([cur.x, cur.y, hp.x, hp.y]) - cur = hp - } - splitSegs.push([cur.x, cur.y, hitLine.x2, hitLine.y2]) - const halves = [] - for (const segPts of splitSegs) { - const segSz = calcLinePlaneSize({ x1: segPts[0], y1: segPts[1], x2: segPts[2], y2: segPts[3] }) - const half = new QLine(segPts, { - parentId: hitLine.parentId, - fontSize: hitLine.fontSize, - stroke: origStroke, - strokeWidth: origStrokeWidth, - name: origName, - textMode: hitLine.textMode, - attributes: { ...origAttrs, planeSize: segSz, actualSize: segSz }, - }) - if (origLineName) half.lineName = origLineName - half.__valleyExtSplitHalf = true - half.__valleyExtSplitTargetId = target.id - canvas.add(half) - half.bringToFront() - roof.innerLines.push(half) - halves.push(half) - } - // 원본 제거. - removeLine(hitLine) - roof.innerLines = roof.innerLines.filter((il) => il !== hitLine) - splitRecords.push({ - halves, - originalPts: origPts, - originalAttrs: origAttrs, - originalName: origName, - originalLineName: origLineName, - originalStroke: origStroke, - originalStrokeWidth: origStrokeWidth, + 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 + trimRecords.push({ + line: other, + end: m1 ? 1 : 2, + oldPt: { x: oldX, y: oldY }, + newPt: { x: hp.x, y: hp.y }, + originalAttrs: { ...(other.attributes || {}) }, + domino: true, + }) + 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 } + logger.log( + '[KERAB-VALLEY-EXT-TRIM] domino ' + JSON.stringify({ name: other.name, lineName: other.lineName, hp }), + ) + } } - if (splitRecords.length) target.__valleyExtSplits = splitRecords + if (trimRecords.length) target.__valleyExtTrims = trimRecords for (const vr of valleyExtensions) { const pts = [vr.x1, vr.y1, vr.x2, vr.y2] @@ -1421,7 +1413,7 @@ export function useEavesGableEdit(id) { // [KERAB-VALLEY-EXT 2026-05-27] 지붕면 할당 통합 (option a): // roofBase 확장은 처마의 연장 → attributes.type=EAVES + parentLine + innerLines push (split 참여). // wallBase 확장은 wall layer (roof 폴리곤 밖) → 시각만, innerLines 비추가, type/parent 부여 안 함. - const isRoofBase = vr.source === 'roofBase' + const isRoofBase = !!(vr.source && vr.source.startsWith('roofBase')) const baseAttrs = { roofId: roof.id, planeSize: sz, actualSize: sz } if (isRoofBase) { // [KERAB-VALLEY-EXT 2026-05-27] splitPolygonWithLines 의 innerLineMapping 은 @@ -2048,36 +2040,19 @@ export function useEavesGableEdit(id) { if (valleyExtsToRemove.length) { logger.log('[KERAB-VALLEY-EXT] revert removed ' + valleyExtsToRemove.length) } - // [KERAB-VALLEY-EXT-SPLIT 2026-05-27] forward 에서 분할했던 hit innerLine 복원. - // halves 제거 + 원본 snapshot 으로 1개 재생성. - if (Array.isArray(target.__valleyExtSplits) && target.__valleyExtSplits.length) { - for (const rec of target.__valleyExtSplits) { - for (const half of rec.halves || []) { - removeLine(half) - roof.innerLines = roof.innerLines.filter((il) => il !== half) - } - const sz = calcLinePlaneSize({ - x1: rec.originalPts[0], - y1: rec.originalPts[1], - x2: rec.originalPts[2], - y2: rec.originalPts[3], - }) - const restored = new QLine(rec.originalPts, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: rec.originalStroke, - strokeWidth: rec.originalStrokeWidth, - name: rec.originalName, - textMode: roof.textMode, - attributes: { ...rec.originalAttrs, planeSize: rec.originalAttrs?.planeSize ?? sz, actualSize: rec.originalAttrs?.actualSize ?? sz }, - }) - if (rec.originalLineName) restored.lineName = rec.originalLineName - canvas.add(restored) - restored.bringToFront() - roof.innerLines.push(restored) + // [KERAB-VALLEY-EXT-TRIM 2026-05-27] forward 에서 절삭/도미노로 단축한 hip/ridge 끝점을 옛 좌표로 복원. + // trimRecords push 순서: 원본 trim → 도미노 cascade. 복원은 역순. + if (Array.isArray(target.__valleyExtTrims) && target.__valleyExtTrims.length) { + for (let i = target.__valleyExtTrims.length - 1; i >= 0; i--) { + const rec = target.__valleyExtTrims[i] + const il = rec.line + if (!il) continue + if (rec.end === 1) il.set({ x1: rec.oldPt.x, y1: rec.oldPt.y }) + else il.set({ x2: rec.oldPt.x, y2: rec.oldPt.y }) + if (rec.originalAttrs) il.attributes = rec.originalAttrs } - logger.log('[KERAB-VALLEY-EXT-SPLIT] revert restored ' + target.__valleyExtSplits.length + ' hit-line(s)') - delete target.__valleyExtSplits + logger.log('[KERAB-VALLEY-EXT-TRIM] revert restored ' + target.__valleyExtTrims.length + ' trim(s)') + delete target.__valleyExtTrims } const buildHipFromSnapshot = (snap) => { const pts = [snap.x1, snap.y1, snap.x2, snap.y2] -- 2.47.2 From e9f5ceab69f7fc27327f7ca56d8507a35e64db64 Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 27 May 2026 15:20:55 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[2173]=20=EA=B3=A8=EC=A7=9C=EA=B8=B0?= =?UTF-8?q?=ED=99=95=EC=9E=A5=EB=9D=BC=EC=9D=B8=20TRIM=20=EC=9E=AC?= =?UTF-8?q?=ED=99=9C=EC=84=B1=20+=20Y=20junction=20=EB=B3=B4=EC=A1=B4=20+?= =?UTF-8?q?=20=EB=81=9D=EC=A0=90=EC=9D=BC=EC=B9=98=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/roofcover/useEavesGableEdit.js | 325 +++++++++++++++++------ 1 file changed, 244 insertions(+), 81 deletions(-) diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index 2af181ab..77d3cd3e 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -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 }), ) } } -- 2.47.2 From a4b7f519bb8de6e116ad4228fcaff47d69fe7ab3 Mon Sep 17 00:00:00 2001 From: ysCha Date: Wed, 27 May 2026 16:18:33 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[2173]=20valleyExt=20TRIM=20=E2=80=94=20hip?= =?UTF-8?q?-presence=20=EC=9B=90=EB=9E=98hip-only=20+=20cascade=20hide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/roofcover/useEavesGableEdit.js | 140 ++++++++++++++++++----- 1 file changed, 110 insertions(+), 30 deletions(-) diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index 77d3cd3e..d4fc2b7e 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -1507,48 +1507,78 @@ export function useEavesGableEdit(id) { 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 - if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) 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 } - 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) => { + 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.name !== LINE_TYPE.SUBLINE.HIP && other.name !== LINE_TYPE.SUBLINE.RIDGE) 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) < TOL_J) return true - if (Math.hypot(other.x2 - px, other.y2 - py) < TOL_J) return true + 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 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) + const hip1 = isOriginalHipEndAt(il.x1, il.y1) + const hip2 = isOriginalHipEndAt(il.x2, il.y2) let trimEnd - if (end1J && !end2J) trimEnd = 2 - else if (!end1J && end2J) trimEnd = 1 - else trimEnd = d1 < d2 ? 1 : 2 + 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({ @@ -1560,6 +1590,7 @@ export function useEavesGableEdit(id) { }) 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( @@ -1568,6 +1599,50 @@ export function useEavesGableEdit(id) { ) } } + // 사용자 규칙: "원 라인이 절삭되었으면 그 밑으로 확장된 라인도 삭제." + // 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) { @@ -2210,9 +2285,14 @@ export function useEavesGableEdit(id) { const rec = target.__valleyExtTrims[i] const il = rec.line if (!il) continue - if (rec.end === 1) il.set({ x1: rec.oldPt.x, y1: rec.oldPt.y }) - else il.set({ x2: rec.oldPt.x, y2: rec.oldPt.y }) + if (rec.hide) { + il.visible = rec.originalVisible !== false + } else { + if (rec.end === 1) il.set({ x1: rec.oldPt.x, y1: rec.oldPt.y }) + else il.set({ x2: rec.oldPt.x, y2: rec.oldPt.y }) + } if (rec.originalAttrs) il.attributes = rec.originalAttrs + if (typeof il.setCoords === 'function') il.setCoords() } logger.log('[KERAB-VALLEY-EXT-TRIM] revert restored ' + target.__valleyExtTrims.length + ' trim(s)') delete target.__valleyExtTrims -- 2.47.2