qcast-front/src/hooks/roofcover/useEavesGableEdit.js

5661 lines
303 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react'
import { useRecoilValue } from 'recoil'
import { ANGLE_TYPE, canvasState, currentAngleTypeSelector, pitchTextSelector } from '@/store/canvasAtom'
import { useMessage } from '@/hooks/useMessage'
import { useEvent } from '@/hooks/useEvent'
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import { useLine } from '@/hooks/useLine'
import { useMode } from '@/hooks/useMode'
import { outerLineFixState } from '@/store/outerLineAtom'
import { useSwal } from '@/hooks/useSwal'
import { usePopup } from '@/hooks/usePopup'
import { getChonByDegree } from '@/util/canvas-util'
import { settingModalFirstOptionsState } from '@/store/settingAtom'
import { useUndoRedo } from '@/hooks/useUndoRedo'
import { fabric } from 'fabric'
import { QLine } from '@/components/fabric/QLine'
import { reattachDebugLabels } from '@/components/fabric/QPolygon'
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
import { findInteriorPoint } from '@/util/skeleton-utils'
import { logger } from '@/util/logger'
import { checkKerabRules } from '@/util/kerab-rule-checker'
import { debugCapture } from '@/util/debugCapture'
import { applyKerabOffsetSurgical } from '@/util/kerab-offset-surgical'
// [2240 KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 시 出幅 변경분 surgical 반영 기능 토글.
// false 로 두면 이번 세션 변경 전 동작 (apply/revert 시 출폭 입력값 무시) 으로 즉시 회귀.
const ENABLE_KERAB_OFFSET_SURGICAL = true
// [KERAB-TYPE-EAVES 2026-06-11] TYPE(A/B 박공) 출신 케라바→처마 전용 변환 토글.
// false 면 기존 applyKerabRevertPattern 폴백(토글 이력 기반) 으로 회귀.
const ENABLE_TYPE_GABLE_TO_EAVES = true
// 처마.케라바 변경
export function useEavesGableEdit(id) {
const canvas = useRecoilValue(canvasState)
const { getMessage } = useMessage()
const { addCanvasMouseEventListener, initEvent } = useEvent()
// const { addCanvasMouseEventListener, initEvent } = useContext(EventContext)
const { closePopup } = usePopup()
const TYPES = {
EAVES: 'eaves',
GABLE: 'gable',
WALL_MERGE: 'wall.merge',
SHED: 'shed',
}
const [type, setType] = useState(TYPES.EAVES)
const typeRef = useRef(TYPES.EAVES)
const { removeLine, addPitchTextsByOuterLines } = useLine()
const { swalFire } = useSwal()
const { saveSnapshot } = useUndoRedo()
const { drawRoofPolygon } = useMode()
const currentAngleType = useRecoilValue(currentAngleTypeSelector)
const pitchText = useRecoilValue(pitchTextSelector)
const pitchRef = useRef(null)
const offsetRef = useRef(null)
const widthRef = useRef(null)
const radioTypeRef = useRef('1') // 각 페이지에서 사용하는 radio type
const outerLineFix = useRecoilValue(outerLineFixState)
const buttonMenu = [
{ id: 1, name: getMessage('eaves'), type: TYPES.EAVES },
{ id: 2, name: getMessage('gable'), type: TYPES.GABLE },
{ id: 3, name: getMessage('wall.merge'), type: TYPES.WALL_MERGE },
{ id: 4, name: getMessage('shed'), type: TYPES.SHED },
]
const settingModalFirstOptions = useRecoilValue(settingModalFirstOptionsState)
useEffect(() => {
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
if (outerLines.length === 0) {
swalFire({ text: getMessage('wall.line.not.found') })
closePopup(id)
}
}, [])
useEffect(() => {
const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL)
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
wallLines.forEach((wallLine) => {
wallLine.lines = outerLines.filter((line) => line.attributes?.wallId === wallLine.id).sort((a, b) => a.idx - b.idx)
})
wallLines.forEach((wallLine) => {
convertPolygonToLines(wallLine)
})
addCanvasMouseEventListener('mouse:over', mouseOverEvent)
addCanvasMouseEventListener('mouse:down', mouseDownEvent)
return () => {
canvas.discardActiveObject()
wallLines.forEach((wallLine) => {
convertLinesToPolygon(wallLine)
})
initEvent()
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
outerLines.forEach((line) => {
let stroke, strokeWidth
if (line.attributes) {
if (line.attributes.type === LINE_TYPE.WALLLINE.EAVES || line.attributes.type === LINE_TYPE.WALLLINE.HIPANDGABLE) {
stroke = '#45CD7D'
strokeWidth = 4
} else if (line.attributes.type === LINE_TYPE.WALLLINE.GABLE || line.attributes.type === LINE_TYPE.WALLLINE.JERKINHEAD) {
stroke = '#3FBAE6'
strokeWidth = 4
} else {
stroke = '#000000'
strokeWidth = 4
}
line.set({
visible: true,
stroke,
strokeWidth,
selectable: false,
})
line.bringToFront()
}
})
const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
roofs.forEach((roof) => {
roof.innerLines.forEach((line) => {
line.set({ selectable: true })
line.bringToFront()
})
})
canvas.renderAll()
}
}, [])
useEffect(() => {
typeRef.current = type
radioTypeRef.current = '1'
}, [type])
const mouseOverEvent = (e) => {
if (e.target && e.target.name === 'outerLine') {
e.target.set({
stroke: 'red',
})
e.target.bringToFront()
canvas.renderAll()
} else {
canvas
?.getObjects()
.filter((obj) => obj.name === 'outerLine')
.forEach((line) => {
line.set({
stroke: 'black',
})
line.bringToFront()
})
}
canvas.renderAll()
}
// [KERAB-RULE-CHECK 2026-06-10] 케라바(처마↔게이블) 토글 종료 시 결과가 도메인 규칙에
// 맞는지 자동 판정하는 진단 단계. forward/revert 양쪽 끝에서 호출. 로컬 전용 — 위반은
// logger.warn 로 위반 라인만 덤프(production 은 DCE 로 제거).
// 규칙:
// R1 dangling: 모든 visible hip/ridge 끝점은 roofLine 코너에 닿거나 다른 inner line 과
// 공유돼야 한다(떠 있는 끝점=벽 교점/코너 이탈). 골짜기 내부 hip 은 양 끝이
// 다른 라인과 공유되므로 자동 통과(예외 불필요).
// R2 zero-length: 길이 0 으로 붕괴된 visible 라인(=라인 소실) 금지.
// R3 outside: 끝점이 roofLine 폴리곤 밖(경계 tol 초과)으로 이탈 금지.
// R4 anchor: 토글 전후로 움직이지 않은 roofLine 코너(stable corner)의 끝점 점유수 불변
// (코너에서 hip 이 떨어지거나 엉뚱한 코너로 횡단하면 점유수 변화).
// [KERAB-OFFSET-HELPER 2026-06-15] 출폭함수 빌딩블록 (1/2): 케라바 hip(확장라인) 식별.
// reclick 검증 코드에서 무손실 추출. surgical 적용 *전*(옛 폴리곤) 에 호출해야 한다 —
// outer endpoint = 옛 roof.points 변 위 끝점, side = hip 직선이 지나는 wall 코너(A/B).
const detectKerabHipMarks = (target, roof) => {
const marks = []
if (!roof || !Array.isArray(roof.innerLines) || !Array.isArray(roof.points)) return marks
const wA = { x: target.x1, y: target.y1 }
const wB = { x: target.x2, y: target.y2 }
const pts = roof.points
const isOnOldPolyEdge = (P) => {
for (let i = 0; i < pts.length; i++) {
const A = pts[i]
const B = pts[(i + 1) % pts.length]
const ddx = B.x - A.x
const ddy = B.y - A.y
const lenSq = ddx * ddx + ddy * ddy
if (lenSq < 1e-6) continue
const t = ((P.x - A.x) * ddx + (P.y - A.y) * ddy) / lenSq
if (t < -0.02 || t > 1.02) continue
const projX = A.x + t * ddx
const projY = A.y + t * ddy
const d = Math.hypot(P.x - projX, P.y - projY)
if (d < 2.0) return true
}
return false
}
for (const il of roof.innerLines) {
if (!il || (il.lineName !== 'hip' && il.lineName !== 'kerabPatternHip')) continue
const e1 = { x: il.x1, y: il.y1 }
const e2 = { x: il.x2, y: il.y2 }
const e1OnEdge = isOnOldPolyEdge(e1)
const e2OnEdge = isOnOldPolyEdge(e2)
let which = null
if (e1OnEdge && !e2OnEdge) which = 1
else if (e2OnEdge && !e1OnEdge) which = 2
if (which === null) continue
const outerEnd = which === 1 ? e1 : e2
const innerEnd = which === 1 ? e2 : e1
const ddx = outerEnd.x - innerEnd.x
const ddy = outerEnd.y - innerEnd.y
const llen = Math.hypot(ddx, ddy) || 1
const distPerp = (P) => Math.abs((ddx * (innerEnd.y - P.y) - ddy * (innerEnd.x - P.x)) / llen)
const dA = distPerp(wA)
const dB = distPerp(wB)
if (Math.min(dA, dB) > 5.0) continue
const side = dA <= dB ? 'A' : 'B'
marks.push({ il, which, side })
}
logger.log(
'[KERAB-OFFSET-ONLY-RECLICK-EAVES] hip marks ' +
JSON.stringify(marks.map((m) => ({ which: m.which, side: m.side, lineName: m.il.lineName }))),
)
return marks
}
// [KERAB-OFFSET-HELPER 2026-06-15] 출폭함수 빌딩블록 (2/2): 케라바 hip 을 45° ray-cast 확장.
// surgical 적용 *후*(새 폴리곤) 에 호출. wall 코너에서 옛 hip 방향(45°)으로 새 roofLine 변까지.
// CORNER-SNAP 금지 룰의 실제 구현부 — roofLine 코너가 아니라 교점까지 뻗는다.
// [45-UNIFY 2026-06-15] reclick·revert 두 경로의 45° 구현을 한 벌로 통합. 가드는 opt-in:
// - useRevertGuards=false(reclick): 순수 45° ray-cast (기존 reclick 동작 그대로).
// - useRevertGuards=true(revert): outerShared(골짜기 내부 hip 제외) + outerOnVertex(게이블 코너
// hip 은 같은 인덱스 새 꼭짓점으로 직접 스냅) 가드 적용. oldPolyPoints = 이동 전 폴리곤 꼭짓점.
const extendKerabHipsTo45 = (target, roof, hipMarks, options = {}) => {
if (!roof || !Array.isArray(roof.points) || !hipMarks || !hipMarks.length) return
const { useRevertGuards = false, oldPolyPoints = null } = options
const wA = { x: target.x1, y: target.y1 }
const wB = { x: target.x2, y: target.y2 }
const rps = roof.points
const M = rps.length
if (!M) return
const baseTag = useRevertGuards ? '[KERAB-REVERT-EXTEND-45]' : '[KERAB-OFFSET-ONLY-RECLICK-EAVES]'
const okTag = useRevertGuards ? '[KERAB-REVERT-EXTEND-45] ' : '[KERAB-OFFSET-ONLY-RECLICK-EAVES] extended '
const rayHit = (P, dir, A, B) => {
const sx = B.x - A.x
const sy = B.y - A.y
const denom = dir.x * sy - dir.y * sx
if (Math.abs(denom) < 1e-9) return Infinity
const ax = A.x - P.x
const ay = A.y - P.y
const t = (ax * sy - ay * sx) / denom
const s = (ax * dir.y - ay * dir.x) / denom
if (t > 1e-6 && s >= -1e-6 && s <= 1 + 1e-6) return t
return Infinity
}
const cast45 = (wCorner, innerEnd, outerOld, il, which, side) => {
const dx = outerOld.x - innerEnd.x
const dy = outerOld.y - innerEnd.y
const dlen = Math.hypot(dx, dy)
if (dlen < 1e-6) return null
const ux = dx / dlen
const uy = dy / dlen
let bestT = Infinity
for (let k = 0; k < M; k++) {
const A = rps[k]
const B = rps[(k + 1) % M]
const t = rayHit(wCorner, { x: ux, y: uy }, A, B)
if (t < bestT) bestT = t
}
if (!isFinite(bestT) || bestT < 0.5) {
logger.log(baseTag + ' no-hit ' + JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } }))
return null
}
return { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT }
}
for (const mark of hipMarks) {
const { il, which, side } = mark
const wCorner = side === 'A' ? wA : wB
const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
let hit
if (useRevertGuards) {
// outerShared: outer 끝이 다른 내부선과 공유 → 골짜기 내부 hip → 확장 스킵.
const SHARE_TOL = 2.0
const outerShared = (roof.innerLines || []).some(
(o) =>
o &&
o !== il &&
o.visible !== false &&
(Math.hypot(o.x1 - outerOld.x, o.y1 - outerOld.y) < SHARE_TOL ||
Math.hypot(o.x2 - outerOld.x, o.y2 - outerOld.y) < SHARE_TOL),
)
// outerOnVertex: outer 가 (이동 전) 폴리곤 꼭짓점이면 게이블 코너 hip → 같은 인덱스 새 꼭짓점 스냅.
const VERT_TOL = 2.0
const opp = Array.isArray(oldPolyPoints) ? oldPolyPoints : []
let vtxIdx = -1
for (let vi = 0; vi < opp.length; vi++) {
const p = opp[vi]
if (p && Math.hypot(p.x - outerOld.x, p.y - outerOld.y) < VERT_TOL) {
vtxIdx = vi
break
}
}
const outerOnVertex = vtxIdx >= 0
if (outerShared && !outerOnVertex) {
logger.log(baseTag + ' skip (interior hip — outer shared) ' + JSON.stringify({ lineName: il.lineName, which, side, outerOld }))
continue
}
if (outerOnVertex && rps.length === opp.length && rps[vtxIdx]) {
hit = { x: rps[vtxIdx].x, y: rps[vtxIdx].y }
} else {
hit = cast45(wCorner, innerEnd, outerOld, il, which, side)
if (!hit) continue
}
} else {
hit = cast45(wCorner, innerEnd, outerOld, il, which, side)
if (!hit) continue
}
const oldLen = Math.hypot(outerOld.x - innerEnd.x, outerOld.y - innerEnd.y)
const newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y)
const ratio = oldLen > 0 ? newLen / oldLen : 1
if (which === 1) il.set({ x1: hit.x, y1: hit.y })
else il.set({ x2: hit.x, y2: hit.y })
if (il.attributes) {
const oldPlane = il.attributes.planeSize ?? 0
const oldActual = il.attributes.actualSize ?? 0
il.attributes.planeSize = Math.round(oldPlane * ratio * 100) / 100
il.attributes.actualSize = Math.round(oldActual * ratio * 100) / 100
il.attributes.extended = true
}
il.__extended = true
if (typeof il.setCoords === 'function') il.setCoords()
if (typeof il.setLength === 'function') il.setLength()
if (typeof il.addLengthText === 'function') il.addLengthText()
logger.log(
okTag +
JSON.stringify({
lineName: il.lineName,
which,
side,
hit,
ratio: Number(ratio.toFixed(3)),
x1: il.x1,
y1: il.y1,
x2: il.x2,
y2: il.y2,
}),
)
}
}
const snapshotKerabState = (roof) => {
if (!roof || !Array.isArray(roof.innerLines)) return null
const lines = roof.innerLines
.filter((l) => l && (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE) && l.visible !== false)
.map((l) => ({ x1: l.x1, y1: l.y1, x2: l.x2, y2: l.y2 }))
const points = (Array.isArray(roof.points) ? roof.points : []).map((p) => ({ x: p.x, y: p.y }))
return { lines, points }
}
const runKerabRuleCheck = (roof, phase, before) => {
try {
if (!roof || !Array.isArray(roof.innerLines)) return
const TOL = 2.0
const OUT_TOL = 3.0
const ZERO = 1.0
const r1 = (n) => Math.round(n * 10) / 10
const fails = []
const rpts = Array.isArray(roof.points) ? roof.points : []
// 검사 대상 = visible 마루/힙. 끝점 공유(접합) 판정에는 골짜기확장(VALLEY)까지 포함 —
// RG-1 확장(kerabPatternExtRidge)은 vExt(VALLEY) 위에서 끝나므로 VALLEY 를 빼면 오탐.
const visLines = roof.innerLines.filter(
(l) => l && (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE) && l.visible !== false,
)
const connLines = roof.innerLines.filter(
(l) =>
l &&
(l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE || l.name === LINE_TYPE.SUBLINE.VALLEY) &&
l.visible !== false,
)
const info = (l) => ({ n: l.name, ln: l.lineName || '-', x1: r1(l.x1), y1: r1(l.y1), x2: r1(l.x2), y2: r1(l.y2) })
const onCorner = (p) => rpts.some((c) => c && Math.hypot(c.x - p.x, c.y - p.y) < TOL)
const ends = []
for (const l of connLines) {
ends.push({ x: l.x1, y: l.y1, line: l })
ends.push({ x: l.x2, y: l.y2, line: l })
}
const sharedWithOther = (p, self) => ends.some((e) => e.line !== self && Math.hypot(e.x - p.x, e.y - p.y) < TOL)
// 폴리곤 내부/경계 판정 (ray-casting + edge 거리 tol)
const pip = (pt) => {
let inside = false
for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) {
const xi = rpts[i].x
const yi = rpts[i].y
const xj = rpts[j].x
const yj = rpts[j].y
const intersect = yi > pt.y !== yj > pt.y && pt.x < ((xj - xi) * (pt.y - yi)) / (yj - yi + 1e-12) + xi
if (intersect) inside = !inside
}
return inside
}
const distToSeg = (p, a, b) => {
const dx = b.x - a.x
const dy = b.y - a.y
const l2 = dx * dx + dy * dy || 1
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2
t = Math.max(0, Math.min(1, t))
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))
}
const minEdgeDist = (pt) => {
let m = Infinity
for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) {
const d = distToSeg(pt, rpts[j], rpts[i])
if (d < m) m = d
}
return m
}
for (const l of visLines) {
const endpts = [
{ x: l.x1, y: l.y1 },
{ x: l.x2, y: l.y2 },
]
// R2 zero-length
if (Math.hypot(l.x2 - l.x1, l.y2 - l.y1) < ZERO) {
fails.push({ rule: 'R2-zero-length', line: info(l) })
}
for (const p of endpts) {
// R1 dangling: 끝점은 roofLine 경계(코너 + 변)에 닿거나 다른 내부선과 공유돼야 한다.
// kLine(중앙 마루)·게이블 hip 은 roofLine '코너'가 아닌 '변' 중간에 닿는 게 정상 →
// 코너만 보면 오탐. minEdgeDist 로 변까지 포함해 경계 도달을 판정한다.
const onBoundary = rpts.length >= 3 ? minEdgeDist(p) <= TOL : onCorner(p)
if (!onBoundary && !sharedWithOther(p, l)) {
fails.push({ rule: 'R1-dangling', line: info(l), at: { x: r1(p.x), y: r1(p.y) } })
}
// R3 outside roofLine
if (rpts.length >= 3 && !pip(p) && minEdgeDist(p) > OUT_TOL) {
fails.push({ rule: 'R3-outside', line: info(l), at: { x: r1(p.x), y: r1(p.y) } })
}
}
}
// R4 anchor: stable roofLine corner 점유수 불변
if (before && Array.isArray(before.points) && Array.isArray(before.lines)) {
const countOn = (lineArr, c) => {
let n = 0
for (const l of lineArr) {
if (Math.hypot(l.x1 - c.x, l.y1 - c.y) < TOL) n++
if (Math.hypot(l.x2 - c.x, l.y2 - c.y) < TOL) n++
}
return n
}
const afterPlain = visLines.map((l) => ({ x1: l.x1, y1: l.y1, x2: l.x2, y2: l.y2 }))
for (const c of rpts) {
const stable = before.points.some((b) => Math.hypot(b.x - c.x, b.y - c.y) < TOL)
if (!stable) continue
const bN = countOn(before.lines, c)
const aN = countOn(afterPlain, c)
if (bN !== aN) {
fails.push({ rule: 'R4-anchor', at: { x: r1(c.x), y: r1(c.y) }, before: bN, after: aN })
}
}
}
if (fails.length) {
logger.warn('[KERAB-RULE-CHECK] ' + phase + ' FAIL(' + fails.length + ') ' + JSON.stringify(fails))
} else {
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) {
logger.warn('[KERAB-RULE-CHECK] error', err)
}
}
const mouseDownEvent = (e) => {
// [KERAB-MOUSEDOWN-GUARD 2026-05-29] outerLine 아닌 target(ridge/lengthText 등) 클릭 시
// discardActiveObject·로그·후속 처리 모두 skip — 다른 hook 의 active 흐름 보호.
if (!e.target || e.target.name !== 'outerLine') {
return
}
logger.log(
'[KERAB-MOUSEDOWN] fired ' +
JSON.stringify({
hasTarget: !!e.target,
name: e.target?.name,
type: typeRef.current,
radio: radioTypeRef.current,
targetType: e.target?.attributes?.type,
x1: e.target?.x1,
y1: e.target?.y1,
x2: e.target?.x2,
y2: e.target?.y2,
}),
)
canvas.discardActiveObject()
const target = e.target
let attributes = target.get('attributes')
switch (typeRef.current) {
case TYPES.EAVES:
if (radioTypeRef.current === '1') {
attributes = {
...attributes,
type: LINE_TYPE.WALLLINE.EAVES,
pitch: currentAngleType === ANGLE_TYPE.SLOPE ? pitchRef.current.value : getChonByDegree(pitchRef.current.value),
offset: offsetRef.current.value / 10,
}
} else {
attributes = {
...attributes,
type: LINE_TYPE.WALLLINE.HIPANDGABLE,
pitch: currentAngleType === ANGLE_TYPE.SLOPE ? pitchRef.current.value : getChonByDegree(pitchRef.current.value),
offset: offsetRef.current.value / 10,
width: widthRef.current.value / 10,
}
}
break
case TYPES.GABLE:
if (radioTypeRef.current === '1') {
attributes = {
...attributes,
type: LINE_TYPE.WALLLINE.GABLE,
offset: offsetRef.current.value / 10,
}
} else {
attributes = {
...attributes,
type: LINE_TYPE.WALLLINE.JERKINHEAD,
pitch: currentAngleType === ANGLE_TYPE.SLOPE ? pitchRef.current.value : getChonByDegree(pitchRef.current.value),
offset: offsetRef.current.value / 10,
width: widthRef.current.value / 10,
}
}
break
case TYPES.WALL_MERGE:
if (radioTypeRef.current === '1') {
attributes = {
...attributes,
type: LINE_TYPE.WALLLINE.WALL,
offset: 0,
}
} else {
attributes = {
...attributes,
type: LINE_TYPE.WALLLINE.WALL,
offset: offsetRef.current.value / 10,
}
}
break
case TYPES.SHED:
attributes = {
...attributes,
type: LINE_TYPE.WALLLINE.SHED,
offset: offsetRef.current.value / 10,
}
break
}
saveSnapshot()
// [KERAB-STATE-DUMP 2026-06-11] A/B타입(가로/세로 케라바) 토글 진입 시점 지붕 상태 진단.
// 벽 type 지정은 있는데 내부 skLine 이 0인 "무에서 유" 케이스 파악용 — 사용자 설명 전 사실 수집.
// 읽기 전용(좌표/visible/type 만 덤프), 동작 변경 없음. logger 게이트(local 만 출력).
{
const _dr = canvas
.getObjects()
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
const _r1 = (v) => (typeof v === 'number' ? Math.round(v * 10) / 10 : v)
logger.log(
'[KERAB-STATE-DUMP] ' +
JSON.stringify({
uiType: typeRef.current,
radio: radioTypeRef.current,
target: {
type: target.attributes?.type,
offset: target.attributes?.offset,
x1: _r1(target.x1),
y1: _r1(target.y1),
x2: _r1(target.x2),
y2: _r1(target.y2),
},
willBecome: attributes?.type,
roofId: target.attributes?.roofId,
roofFound: !!_dr,
points: _dr?.points?.map((p) => ({ x: _r1(p.x), y: _r1(p.y) })),
lines: _dr?.lines?.map((l) => ({
type: l.attributes?.type,
offset: l.attributes?.offset,
x1: _r1(l.x1),
y1: _r1(l.y1),
x2: _r1(l.x2),
y2: _r1(l.y2),
})),
innerCount: (_dr?.innerLines || []).length,
innerLines: (_dr?.innerLines || []).map((il) => ({
lineName: il.lineName,
type: il.attributes?.type,
visible: il.visible !== false,
x1: _r1(il.x1),
y1: _r1(il.y1),
x2: _r1(il.x2),
y2: _r1(il.y2),
})),
}),
)
}
// [2240 KERAB-NOOP-REKLICK 2026-05-19] 같은 type 으로의 재클릭은 무동작.
// - 케라바→케라바, 처마→처마 등. 기존 rebuild 흐름이 다시 돌면 패턴 상태
// (ridge/half-label/orphan ext 정리)를 망가뜨림.
// - radio 1 의 단순 변환에만 적용. JERKINHEAD/HIPANDGABLE 등 width 가 들어가는
// radio 2 변환은 파라미터 갱신 가능성이 있어 그대로 진행.
if (radioTypeRef.current === '1' && target.attributes?.type === attributes?.type) {
// [KERAB-OFFSET-ONLY-RECLICK 2026-06-01] 동일 type 재클릭이지만 出幅(offset) 만 다른 경우,
// surgical 갱신(corner / 인접 inner-line 끝점 snap + attributes.offset 반영) 후 종료.
// 기존 collapse 흐름(applyKerab*Pattern, polygonPath BFS 등) 은 건너뛰어 케라바 패턴 보존.
const oldOffset = target.attributes?.offset ?? 0
const newOffset = attributes?.offset ?? 0
if (Math.abs(newOffset - oldOffset) > 1e-3) {
logger.log(
`[KERAB-OFFSET-ONLY-RECLICK] type=${attributes.type} 동일, offset ${oldOffset}${newOffset} surgical 적용`,
)
// [KERAB-OFFSET-ONLY-RECLICK-EAVES 2026-06-05] 처마 상태 출폭 변경 룰:
// - wallbaseLine 안의 내부라인 본체 끝점(apex) 절대 불변
// - hip outer endpoint 만 wL 코너에서 hip 방향(=skeleton 45°) 으로 새 rL 변까지 ray-cast 확장
// - surgical 의 CORNER-SHORTCUT/SHRINK-TRIM 은 룰 위반 → skipInnerLines:true
// (케라바 ONLY-RECLICK 은 KERAB-PATTERN-CORNER-SNAP 필요하므로 그대로 둠.)
const isEaves = attributes?.type === LINE_TYPE.WALLLINE.EAVES
let reclickRoof = null
let hasKerabPattern = false
let hipMarks = []
if (isEaves) {
reclickRoof = canvas
.getObjects()
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
if (reclickRoof && Array.isArray(reclickRoof.innerLines) && Array.isArray(reclickRoof.points)) {
hasKerabPattern = reclickRoof.innerLines.some(
(il) => il && typeof il.lineName === 'string' && il.lineName.startsWith('kerabPattern'),
)
// surgical 적용 *전*(옛 폴리곤) 에 케라바 hip 식별 — outer endpoint 가 옛 변 위에 있어야 잡힌다.
hipMarks = detectKerabHipMarks(target, reclickRoof)
}
}
// [KERAB-OFFSET-RECLICK-UNIFY 2026-06-15] 出幅 재변경(eaves→eaves)을 skLine 과 동일 경로로 통일.
// 출발 형상(skLine / A/B타입)에 무관하게 동일 규칙(= 출폭함수):
// - 케라바 hip(확장라인)은 surgical CORNER-SNAP 금지 → wall 코너에서 45° ray-cast 로 roofLine 까지.
// - 측면 수직선/변조각(normal line)은 surgical(recomputeNormalLine)이 새 코너로 추종.
// skLine: 케라바 패턴 없음 → skipInnerLines(전부 45°가 처리).
// A/B타입: 케라바 패턴 보유 → skipKerabHips(normal/ridge 는 surgical, hip 만 45°가 처리).
// 출폭 증감(확장/수축) 양방향이 한 경로에서 일관 처리됨.
const reclickBeforeSnap = isEaves && reclickRoof ? snapshotKerabState(reclickRoof) : null
applyTargetOffsetSurgical(
target,
newOffset,
isEaves ? (hasKerabPattern ? { skipKerabHips: true } : { skipInnerLines: true }) : undefined,
)
// surgical 적용 *후*(새 폴리곤) 에 케라바 hip 을 45° ray-cast 로 새 roofLine 변까지 확장.
if (isEaves && reclickRoof) extendKerabHipsTo45(target, reclickRoof, hipMarks)
// [KERAB-OFFSET-RECLICK-UNIFY 2026-06-15] A/B타입은 케라바 hip 외 inner line 좌표가
// surgical 내부(recomputeKerabPatternLine/recomputeNormalLine)에서 갱신됨 →
// 길이/치수 라벨만 새 좌표 기준으로 새로고침한다(hip 은 45° 블록에서 이미 갱신).
if (isEaves && hasKerabPattern && reclickRoof && Array.isArray(reclickRoof.innerLines)) {
for (const il of reclickRoof.innerLines) {
if (!il || il.lineName === 'kerabPatternHip') continue
if (typeof il.setCoords === 'function') il.setCoords()
if (typeof il.setLength === 'function') il.setLength()
if (typeof il.addLengthText === 'function') il.addLengthText()
}
logger.log('[KERAB-OFFSET-RECLICK-UNIFY] surgical label refresh done')
}
// [KERAB-OFFSET-RECLICK-RULECHECK 2026-06-15] 라인변경(출폭 재적용) 후 규칙 검사 — 사용자 요구.
if (isEaves && reclickRoof && reclickBeforeSnap) {
runKerabRuleCheck(reclickRoof, 'reclick', reclickBeforeSnap)
}
target.set({ attributes })
canvas.renderAll()
return
}
logger.log(`[KERAB-NOOP] 이미 ${attributes.type} → 재변환 무시`)
return
}
// [2240 KERAB-NEIGHBOR-GABLE 2026-05-19] 「ケラバの隣にケラバは不可」
// 처마→케라바 변환 시, target 의 끝점을 공유하는 인접 외곽선 중 하나라도 이미 케라바(GABLE) 면
// 모든 패턴 시도 전에 조용히 무동작 (alert 없음).
if (typeRef.current === TYPES.GABLE && radioTypeRef.current === '1') {
const isSameXY = (a, b) => Math.hypot(a.x - b.x, a.y - b.y) <= 0.5
const tP1 = { x: target.x1, y: target.y1 }
const tP2 = { x: target.x2, y: target.y2 }
const neighbors = canvas.getObjects().filter(
(o) =>
o.name === 'outerLine' &&
o !== target &&
o.attributes?.roofId === target.attributes?.roofId,
)
const sharesEndpoint = (o, pt) => isSameXY({ x: o.x1, y: o.y1 }, pt) || isSameXY({ x: o.x2, y: o.y2 }, pt)
const adjGable = neighbors.find(
(o) => o.attributes?.type === LINE_TYPE.WALLLINE.GABLE && (sharesEndpoint(o, tP1) || sharesEndpoint(o, tP2)),
)
if (adjGable) {
logger.log('[KERAB-NEIGHBOR-GABLE] 인접 외곽선이 케라바 → 무동작')
return
}
}
// [KERAB-RECT-SOLVER 2026-06-15] 사각형이면 토글 이력과 무관한 결정론적 솔버로 처리.
// 최종 4변 타입만으로 내부선 전부 재생성(Y 불변식 + apex 멈춤 구조적 보장). 미지원 형상은
// false → 아래 기존 forward/revert/type 경로로 폴백.
if (radioTypeRef.current === '1' && (attributes?.type === LINE_TYPE.WALLLINE.EAVES || attributes?.type === LINE_TYPE.WALLLINE.GABLE)) {
const rectRoof = canvas
.getObjects()
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
if (rectRoof && solveRectangleKerab(rectRoof, target, attributes)) {
logger.log('[KERAB-RECT-SOLVER] handled deterministically → maze 우회')
return
}
}
// [2240 KERAB-SIMPLE 2026-05-20] 사용자 설명 정직 알고리즘:
// 1) target 양 끝점에 직접 끝이 닿은 hip 2개를 찾는다 (nearestRoofPoint 안 씀)
// 2) 두 hip 직선의 무한확장 교점 = apex
// 3) apex 를 통과하는 ridge(RG-1)가 존재하면 케라바 조건 충족
// 4) mid(target 중점) → apex 중앙선만 추가 (기존 라인 무손상)
if (typeRef.current === TYPES.GABLE && radioTypeRef.current === '1') {
const roof = canvas
.getObjects()
.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 }))
// [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))
// [KERAB-LABEL-REATTACH 2026-05-29] AFTER 시점에서 라벨 재부착 (local 모드 한정).
// 케라바 토글로 추가/변경된 kerabPatternRidge/ExtRidge/Hip 등에도 H-/RG- 라벨 부여.
if (label === 'AFTER') {
try {
reattachDebugLabels(canvas, roof.id)
} catch (e) {
logger.warn('[KERAB-LABEL-REATTACH] failed', e)
}
}
}
dumpInnerLineSnapshot('BEFORE')
// [KERAB-RULE-CHECK 2026-06-10] surgical 전(원본 출폭) 상태를 R4 anchor 기준으로 캡처.
const kerabBeforeSnap = snapshotKerabState(roof)
// [KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 직전 target.attributes.offset 을 roofLine 에 surgical 반영.
// SK 재실행 없이 외곽 corner / inner-line endpoint 만 이동 → kLine 등 layered custom 라인 보존.
if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0)
if (roof && Array.isArray(roof.innerLines)) {
// [KERAB-LABEL-LOOKUP 2026-05-21] QPolygon.__attachDebugLabels 와 동일 분류·카운팅 순서로
// 캔버스 객체에 라벨(H-1, RG-2, B-3 등) 매핑. 로그에 라벨을 함께 찍기 위함.
const labelByLine = new Map()
{
const counters = {}
const objs = canvas.getObjects().filter((o) => o.parentId === roof.id && o.name !== '__debugLabel')
for (const obj of objs) {
let prefix = null
const nm = obj.name
const ln = obj.lineName
const tp = obj.attributes?.type
if (nm === 'baseLine') prefix = 'B'
else if (nm === 'outerLine' || nm === 'eaves' || ln === 'roofLine') prefix = 'R'
else if (nm === LINE_TYPE.SUBLINE.HIP || ln === LINE_TYPE.SUBLINE.HIP) prefix = 'H'
else if (nm === LINE_TYPE.SUBLINE.RIDGE || ln === LINE_TYPE.SUBLINE.RIDGE) prefix = 'RG'
else if (nm === LINE_TYPE.SUBLINE.VALLEY || ln === LINE_TYPE.SUBLINE.VALLEY) prefix = 'V'
else if (nm === LINE_TYPE.SUBLINE.GABLE || ln === LINE_TYPE.SUBLINE.GABLE) prefix = 'G'
else if (nm === LINE_TYPE.SUBLINE.VERGE || ln === LINE_TYPE.SUBLINE.VERGE) prefix = 'VG'
else if (
tp === LINE_TYPE.WALLLINE.EAVE_HELP_LINE ||
ln === LINE_TYPE.WALLLINE.EAVE_HELP_LINE ||
nm === LINE_TYPE.WALLLINE.EAVE_HELP_LINE
)
prefix = 'E'
else if (
typeof obj.x1 === 'number' &&
typeof obj.y1 === 'number' &&
typeof obj.x2 === 'number' &&
typeof obj.y2 === 'number'
)
prefix = 'SK'
if (!prefix) continue
counters[prefix] = (counters[prefix] || 0) + 1
labelByLine.set(obj, `${prefix}-${counters[prefix]}`)
}
}
const labelOf = (line) => (line ? labelByLine.get(line) || '?' : null)
// [KERAB-WLINE-T1T2 2026-06-04] wLine 이동 후 선택된 target 은 이동 전 wallLine.
// 이동된 실제 위치는 wall.baseLines 에 있다.
// wallId 매칭은 이동 후 인덱스 재정렬로 잘못된 baseLine 반환 → 기하학적 매칭으로 교체.
// 같은 방향(수직/수평) + target 의 고정 끝점(이동 안 된 쪽) 공유 여부로 식별.
const _wall = canvas.getObjects().find((o) => o.name === POLYGON_TYPE.WALL && o.attributes?.roofId === target.attributes?.roofId)
const _BL_TOL = 5
const _tIsV = Math.abs(target.x1 - target.x2) < 0.5
const _tIsH = Math.abs(target.y1 - target.y2) < 0.5
const _matchedBase = _wall?.baseLines?.find((bl) => {
if (_tIsV) {
if (Math.abs(bl.x1 - bl.x2) >= 0.5) return false // bl 방향 불일치
if (Math.abs(bl.x1 - target.x1) >= _BL_TOL) return false // 다른 수직선
return (
Math.abs(bl.y1 - target.y1) < _BL_TOL || Math.abs(bl.y1 - target.y2) < _BL_TOL ||
Math.abs(bl.y2 - target.y1) < _BL_TOL || Math.abs(bl.y2 - target.y2) < _BL_TOL
)
}
if (_tIsH) {
if (Math.abs(bl.y1 - bl.y2) >= 0.5) return false // bl 방향 불일치
if (Math.abs(bl.y1 - target.y1) >= _BL_TOL) return false // 다른 수평선
return (
Math.abs(bl.x1 - target.x1) < _BL_TOL || Math.abs(bl.x1 - target.x2) < _BL_TOL ||
Math.abs(bl.x2 - target.x1) < _BL_TOL || Math.abs(bl.x2 - target.x2) < _BL_TOL
)
}
// 대각선: wallId fallback
return bl.attributes?.wallId === target.attributes?.wallId
})
const t1 = _matchedBase ? { x: _matchedBase.x1, y: _matchedBase.y1 } : { x: target.x1, y: target.y1 }
const t2 = _matchedBase ? { x: _matchedBase.x2, y: _matchedBase.y2 } : { x: target.x2, y: target.y2 }
const h1Match = findHipAtEndpoint(roof, t1)
const h2Match = findHipAtEndpoint(roof, t2)
logger.log(
'[KERAB-SIMPLE] hip lookup ' +
JSON.stringify({
target: labelOf(target),
t1,
t2,
h1: h1Match
? { label: labelOf(h1Match.hip), near: h1Match.near, far: h1Match.far, dist: Math.round(h1Match.dist * 100) / 100 }
: null,
h2: h2Match
? { label: labelOf(h2Match.hip), near: h2Match.near, far: h2Match.far, dist: Math.round(h2Match.dist * 100) / 100 }
: null,
}),
)
if (h1Match && h2Match) {
// [KERAB-APEX-FAR-AS-PARALLEL 2026-05-21] lineLineIntersection 은 완전 평행(det≈0) 만 null 반환.
// 거의 평행한 두 hip 은 천문학적 좌표의 가짜 apex 를 만들어 markerApex 오염. 좌표 크기로 평행 강제 판정.
let apex = lineLineIntersection(h1Match.near, h1Match.far, h2Match.near, h2Match.far)
if (apex) {
const APEX_FAR_LIMIT = 1e5
if (Math.abs(apex.x) > APEX_FAR_LIMIT || Math.abs(apex.y) > APEX_FAR_LIMIT) {
apex = null
}
}
logger.log(
'[KERAB-SIMPLE] apex ' +
JSON.stringify({
apex: apex ? { x: Math.round(apex.x * 100) / 100, y: Math.round(apex.y * 100) / 100 } : null,
parallel: !apex,
}),
)
// [KERAB-PARALLEL-FULLALGO 2026-05-21] 평행(apex=null) 도 풀 알고리즘으로 처리.
// 폴리곤 경로 + extender 확장 + 반사/meet/apex/kLine — h1·h2 만나지 않더라도
// 내부 라인(path hips/ridges) 은 삭제, 접점 extender 는 인쪽 확장.
// 자연 만남(condition 1) 만 단축: apex 존재 + h*.far ≈ apex.
{
const EXT_TOL = 1.0
const isNatural =
!!apex &&
Math.hypot(h1Match.far.x - apex.x, h1Match.far.y - apex.y) <= EXT_TOL &&
Math.hypot(h2Match.far.x - apex.x, h2Match.far.y - apex.y) <= EXT_TOL
if (!isNatural) {
// [KERAB-POLYGON-BFS 2026-05-21] 사용자 전제 2: 내부 다각형 경계 = BFS 로 추적한
// h1.far → h2.far 경로 + h1 + h2. 경로상 모든 hip/ridge 를 삭제 대상으로 모음.
// 직접 연결(RG-1) 뿐 아니라 비대칭(Ridge→junction→otherHip 체인) 도 한 번에 처리.
const polygonPath = traceInnerPolygonPath(roof, h1Match.far, h2Match.far, [h1Match.hip, h2Match.hip])
logger.log(
'[KERAB-SIMPLE] polygonPath ' +
JSON.stringify({
found: polygonPath !== null,
length: polygonPath ? polygonPath.length : 0,
lines: polygonPath
? polygonPath.map((p) => ({
label: labelOf(p.line),
name: p.line.name,
lineName: p.line.lineName,
x1: p.line.x1, y1: p.line.y1, x2: p.line.x2, y2: p.line.y2,
}))
: null,
}),
)
// [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)]) {
if (!line) continue
const info = findInteriorPoint(line, valleyPool)
valleyInfo.push({
label: labelOf(line),
name: line.name,
lineName: line.lineName,
x1: Math.round(line.x1 * 100) / 100,
y1: Math.round(line.y1 * 100) / 100,
x2: Math.round(line.x2 * 100) / 100,
y2: Math.round(line.y2 * 100) / 100,
startValley: info.start,
endValley: info.end,
})
}
logger.log('[KERAB-VALLEY-DIAG] ' + JSON.stringify(valleyInfo))
debugCapture.log('KERAB-VALLEY-DIAG', { targetId: target.id, count: valleyInfo.length, valleys: valleyInfo })
}
// [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 (polygonPath) {
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) {
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,
},
)
} else {
logger.log('[KERAB-VALLEY-EXT] no matching roof.lines for target.id=' + target.id)
}
}
if (polygonPath === null) {
logger.log('[KERAB-SIMPLE] no polygon path → attr-only fallback')
target.set({ attributes })
applyKerabAttributeOnlyPattern()
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
return
}
const polygonLines = [h1Match.hip, h2Match.hip, ...polygonPath.map((p) => p.line)]
// [KERAB-EXTENDER 2026-05-21] 사용자 전제 3: 접점(h1.far, h2.far, 중간 junction) 에서 polygon path 가 아닌
// inner line(hip OR ridge) 을 extender 로 식별. 경로상 라인은 제외.
const ext1 = findExtenderAtPoint(roof, h1Match.far, polygonLines)
const ext2 = findExtenderAtPoint(roof, h2Match.far, polygonLines)
// [KERAB-JUNCTION-EXT 2026-05-21] 중간 touch point(junction) 의 extender 도 식별 — 모든
// 사용 가능한 extender 를 수집해 가장 가까운 meet 부터 순차 해소(H-7↔RG-2 → H-1↔roofLine 등).
const intermediatePoints = []
for (let i = 0; i < polygonPath.length - 1; i++) {
intermediatePoints.push(polygonPath[i].to)
}
const junctionExtenders = intermediatePoints.map((jp) => {
const allAtJ = []
for (const il of roof.innerLines || []) {
if (!il || polygonLines.includes(il)) continue
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
const a = { x: il.x1, y: il.y1 }
const b = { x: il.x2, y: il.y2 }
const dA = Math.hypot(a.x - jp.x, a.y - jp.y)
const dB = Math.hypot(b.x - jp.x, b.y - jp.y)
if (dA <= 1.0) allAtJ.push({ line: il, near: a, far: b, isHip: il.name === LINE_TYPE.SUBLINE.HIP })
else if (dB <= 1.0) allAtJ.push({ line: il, near: b, far: a, isHip: il.name === LINE_TYPE.SUBLINE.HIP })
}
return { jp, exts: allAtJ }
})
logger.log(
'[KERAB-SIMPLE] extenders ' +
JSON.stringify({
h1Far: h1Match.far,
h2Far: h2Match.far,
e1: ext1 ? { label: labelOf(ext1.line), near: ext1.near, far: ext1.far, isHip: ext1.isHip } : null,
e2: ext2 ? { label: labelOf(ext2.line), near: ext2.near, far: ext2.far, isHip: ext2.isHip } : null,
junctions: junctionExtenders.map((j) => ({
jp: j.jp,
exts: j.exts.map((e) => ({
label: labelOf(e.line),
near: e.near,
far: e.far,
isHip: e.isHip,
lineName: e.line.lineName,
})),
})),
}),
)
// [KERAB-SEQ-RESOLVE 2026-05-21] 사용자 모델: 모든 접점의 extender 를 모아 인쪽 확장.
// 가장 가까운 meet 부터 순차 해소(hip-hip → apex+kLine, hip-ridge → 그 자리 stop).
// 짝 잃은 extender 는 roofLine 까지 확장. parallel 도 자동 처리.
// [KERAB-POLYGON-INSIDE-REVERT 2026-05-21] sub-polygon 내부 필터(goesIntoPolygon) 제거.
// 해당 필터가 평행 케이스의 정상 extender 까지 거름 → 회귀. RG-1 류는 no-pierce(barrier) 로 처리.
const allExtenders = []
if (ext1) allExtenders.push({ ...ext1, sourcePoint: h1Match.far })
if (ext2) allExtenders.push({ ...ext2, sourcePoint: h2Match.far })
for (const j of junctionExtenders) {
for (const e of j.exts) {
allExtenders.push({ ...e, sourcePoint: j.jp })
}
}
logger.log(
'[KERAB-SIMPLE] extenders-filtered ' +
JSON.stringify({
accepted: allExtenders.map((e) => ({
label: labelOf(e.line),
near: { x: Math.round(e.near.x * 100) / 100, y: Math.round(e.near.y * 100) / 100 },
far: { x: Math.round(e.far.x * 100) / 100, y: Math.round(e.far.y * 100) / 100 },
isHip: e.isHip,
})),
}),
)
if (allExtenders.length === 0) {
logger.log('[KERAB-SIMPLE] no extenders — fallback attr-only')
target.set({ attributes })
applyKerabAttributeOnlyPattern()
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
return
}
// 인쪽 방향 검증: extender 자연 방향(near→far)의 반대(near→inward) 와 일치해야 함.
const isInward = (ext, pt) => {
const ix = ext.near.x - ext.far.x
const iy = ext.near.y - ext.far.y
const px = pt.x - ext.near.x
const py = pt.y - ext.near.y
return ix * px + iy * py > 1e-3
}
// [KERAB-ITER-REFLECT 2026-05-21] 반사 hip 을 1급 extender 로 풀에 추가하고 wave 반복.
// wave: 미해소 extender 들의 meet 계산 → 가장 가까운 meet 부터 해소 →
// hip+(ridge/kLine) → 반사 hip 생성하여 풀에 추가 →
// hip+hip → apex + 새 kLine 도 다음 wave 의 barrier (현재는 첫 apex 하나만 추적).
// 미해소는 roofLine fallback.
const existingKLines = (roof.innerLines || []).filter(
(il) =>
il &&
il.lineName === 'kerabPatternRidge' &&
!il.__noKLine &&
il.__targetId !== target.id &&
il.visible !== false,
)
const isPointOnSegment = (pt, ax, ay, bx, by, tol = 0.5) => {
const dx = bx - ax
const dy = by - ay
const lenSq = dx * dx + dy * dy
if (lenSq < 1e-6) return Math.hypot(pt.x - ax, pt.y - ay) <= tol
const t = ((pt.x - ax) * dx + (pt.y - ay) * dy) / lenSq
const margin = tol / Math.sqrt(lenSq)
if (t < -margin || t > 1 + margin) return false
const px = ax + t * dx
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-RIDGE-STOP 2026-05-27] 새 규칙:
// 1) ridge(마루) 만나면 그 점에서 정지. hip 은 통과.
// 2) ridge 못 만나면 맞은편 polygon-wall(roofLine 너머) 까지 끝까지 확장 (절반 아님).
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
}
}
let ridgeStop = null
let ridgeT = Infinity
// [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 골짜기 확장라인이 "원래 있던" ridge(마루) 만나면 그 점에서 정지.
// 확장으로 생긴 ridge(kerabPatternRidge/kerabPatternExtRidge 등) 는 stop 대상 아님.
// 화이트리스트: lineName === 'ridge' 만 매칭.
for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
if (il.lineName !== 'ridge') 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(
'[KERAB-VALLEY-EXT-RIDGE-STOP] pre label=' + ep.label +
' ridgeT=' + Math.round(ridgeT * 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) {
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개.
// 첫 apex 는 primary(applyKerabKLinePattern), 이후 apex 는 보조 ridge.
const apexList = []
const pendingKLines = []
const PERP_EPS = 0.05
const isPerpendicular = (vax, vay, vbx, vby) => {
const ma = Math.hypot(vax, vay) || 1
const mb = Math.hypot(vbx, vby) || 1
return Math.abs((vax * vbx + vay * vby) / (ma * mb)) < PERP_EPS
}
const computePendingKLine = (apex) => {
// [KERAB-WLINE-T1T2 2026-06-04] wLine 이동 후 hip.near 가 target 끝점에서 벗어나므로
// 이동한 처마라인 끝점(t1/t2) 기준으로 foot 계산
const ax = t2.x - t1.x
const ay = t2.y - t1.y
const aSq = ax * ax + ay * ay || 1
const tFoot = ((apex.x - t1.x) * ax + (apex.y - t1.y) * ay) / aSq
const foot = { x: t1.x + tFoot * ax, y: t1.y + tFoot * ay }
return { x1: apex.x, y1: apex.y, x2: foot.x, y2: foot.y }
}
const pushApexIfNew = (point, callerTag = '?') => {
const dup = apexList.some((ap) => Math.hypot(ap.point.x - point.x, ap.point.y - point.y) < 0.5)
if (dup) {
logger.log('[KERAB-APEX-PUSH-DUP]', callerTag, { x: point.x?.toFixed?.(2), y: point.y?.toFixed?.(2) })
return false
}
apexList.push({ point: { x: point.x, y: point.y } })
const pk = computePendingKLine(point)
pendingKLines.push(pk)
logger.log('[KERAB-APEX-PUSH]', callerTag, {
apex: { x: point.x?.toFixed?.(2), y: point.y?.toFixed?.(2) },
kLine: { x1: pk.x1?.toFixed?.(2), y1: pk.y1?.toFixed?.(2), x2: pk.x2?.toFixed?.(2), y2: pk.y2?.toFixed?.(2) },
apexCount: apexList.length,
})
return true
}
// [KERAB-FIXPOINT-PHASE-A 2026-05-22] 정적 inner line 만남 시 절삭 정보 누적.
// wave 종료 후 apply 직전에 fabric line 좌표를 갱신해 그 점 너머 부분을 제거한다.
const cuts = []
const MAX_ITER = 10
for (let iter = 0; iter < MAX_ITER; iter++) {
const unresolved = extenderPool.filter((e) => !resolved.has(e))
if (unresolved.length === 0) break
const meets = []
// [KERAB-MEETS-FAR-GUARD 2026-05-21] 거의 평행한 두 extender(H-2 vs H-3 등) 의 교점은
// 천문학적 좌표(예: ±6e5) 로 돌아오고 isInward 도 통과 → 가짜 meet 후보 등록.
// line 291 APEX_FAR_LIMIT 와 동일 임계 1e5 로 거부, 짝 잃은 ext 는 fallback 경로(roof wall)로.
const MEETS_FAR_LIMIT = 1e5
for (let i = 0; i < unresolved.length; i++) {
for (let k = i + 1; k < unresolved.length; k++) {
const ea = unresolved[i]
const eb = unresolved[k]
const ip = lineLineIntersection(ea.near, ea.far, eb.near, eb.far)
if (!ip) continue
if (Math.abs(ip.x) > MEETS_FAR_LIMIT || Math.abs(ip.y) > MEETS_FAR_LIMIT) continue
if (!isInward(ea, ip) || !isInward(eb, ip)) continue
const dA = Math.hypot(ip.x - ea.near.x, ip.y - ea.near.y)
const dB = Math.hypot(ip.x - eb.near.x, ip.y - eb.near.y)
meets.push({
a: ea, b: eb, point: ip,
minDist: Math.min(dA, dB),
bothHips: ea.isHip && eb.isHip,
})
}
}
const kLineMeets = []
const kLineCandidates = [...existingKLines, ...pendingKLines]
for (const ext of unresolved) {
for (const kl of kLineCandidates) {
const ip = lineLineIntersection(ext.near, ext.far, { x: kl.x1, y: kl.y1 }, { x: kl.x2, y: kl.y2 })
if (!ip) continue
if (!isInward(ext, ip)) continue
if (!isPointOnSegment(ip, kl.x1, kl.y1, kl.x2, kl.y2)) continue
const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y)
if (dist < 1e-3) continue
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) =>
il &&
il.name === LINE_TYPE.SUBLINE.RIDGE &&
il.lineName !== 'kerabPatternRidge' &&
!polygonLines.includes(il),
)
const ridgeMeets = []
for (const ext of unresolved) {
if (!ext.isHip) continue
for (const r of staticRidges) {
const ip = lineLineIntersection(ext.near, ext.far, { x: r.x1, y: r.y1 }, { x: r.x2, y: r.y2 })
if (!ip) continue
if (!isInward(ext, ip)) continue
if (!isPointOnSegment(ip, r.x1, r.y1, r.x2, r.y2)) continue
const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y)
ridgeMeets.push({ ext, point: ip, dist, ridge: r })
}
}
// [KERAB-FIXPOINT-PHASE-A 2026-05-22] 정적 inner hip 만남 — 무너진 sub-polygon 의 경계.
// unresolved ext (hip 또는 ridge) 가 정적 hip segment 와 만나면 그 점에서 멈춤 + 절삭정보 누적.
// polygon path 라인은 제외(이 polygon 의 처리 대상). 자기 라인은 검사 루프에서 개별 제외.
const staticInnerHips = (roof.innerLines || []).filter(
(il) =>
il &&
il.name === LINE_TYPE.SUBLINE.HIP &&
!polygonLines.includes(il) &&
il.visible !== false,
)
const staticMeets = []
for (const ext of unresolved) {
for (const il of staticInnerHips) {
if (ext.line === il) continue
const a = { x: il.x1, y: il.y1 }
const b = { x: il.x2, y: il.y2 }
const ip = lineLineIntersection(ext.near, ext.far, a, b)
if (!ip) continue
if (Math.abs(ip.x) > 1e5 || Math.abs(ip.y) > 1e5) continue
if (!isInward(ext, ip)) continue
if (!isPointOnSegment(ip, a.x, a.y, b.x, b.y)) continue
const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y)
if (dist < 1e-3) continue
staticMeets.push({ ext, point: ip, dist, staticLine: il })
}
}
if (staticMeets.length > 0) {
logger.log(
'[KERAB-FIXPOINT-PHASE-A] staticMeets iter=' + iter + ' ' +
JSON.stringify(
staticMeets.map((s) => ({
ext: labelOf(s.ext.line) || (s.ext.isHip ? 'H' : 'R'),
staticLine: labelOf(s.staticLine) || 'H?',
point: { x: Math.round(s.point.x * 100) / 100, y: Math.round(s.point.y * 100) / 100 },
dist: Math.round(s.dist * 100) / 100,
})),
),
)
}
// [KERAB-FIXPOINT-STEP2 2026-05-21] 이미 그려질 segment(resolved) 도 후보의 거울.
// 뒤늦게 도달한 extender 가 기존 segment 와 만나면 그 점에서 반사 hip 을 만든다.
// (drawn segment 자체의 절단은 Step 3 에서 처리)
// segment 양끝은 그리기 좌표(sourcePoint→stop) 와 일치시킨다.
const drawnMeets = []
for (const ext of unresolved) {
if (!ext.isHip) continue
for (const [drawnExt, stopPt] of resolved) {
if (!drawnExt || !stopPt) continue
if (drawnExt === ext) continue
const segA = drawnExt.sourcePoint || drawnExt.near
const segB = stopPt
const ip = lineLineIntersection(ext.near, ext.far, segA, segB)
if (!ip) continue
if (Math.abs(ip.x) > 1e5 || Math.abs(ip.y) > 1e5) continue
if (!isInward(ext, ip)) continue
if (!isPointOnSegment(ip, segA.x, segA.y, segB.x, segB.y)) continue
const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y)
if (dist < 1e-3) continue
drawnMeets.push({ ext, point: ip, dist, drawnExt, mirrorLine: { x1: segA.x, y1: segA.y, x2: segB.x, y2: segB.y } })
}
}
if (drawnMeets.length > 0) {
logger.log(
'[KERAB-FIXPOINT-STEP2] drawnMeets iter=' + iter + ' ' +
JSON.stringify(
drawnMeets.map((d) => ({
ext: d.ext.line?.attributes?.label || (d.ext.isHip ? 'H' : 'R'),
point: { x: Math.round(d.point.x * 100) / 100, y: Math.round(d.point.y * 100) / 100 },
dist: Math.round(d.dist * 100) / 100,
})),
),
)
}
const candidates = []
for (const m of meets) {
candidates.push({ kind: 'pair', extenders: [m.a, m.b], point: m.point, minDist: m.minDist, bothHips: m.bothHips })
}
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',
extenders: [rm.ext],
point: rm.point,
minDist: rm.dist,
mirrorLine: { x1: rm.ridge.x1, y1: rm.ridge.y1, x2: rm.ridge.x2, y2: rm.ridge.y2 },
})
}
for (const dm of drawnMeets) {
candidates.push({
kind: 'drawn',
extenders: [dm.ext],
point: dm.point,
minDist: dm.dist,
mirrorLine: dm.mirrorLine,
drawnExt: dm.drawnExt,
})
}
for (const sm of staticMeets) {
candidates.push({
kind: 'static',
extenders: [sm.ext],
point: sm.point,
minDist: sm.dist,
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
let processedAny = false
// [KERAB-FIXPOINT-STEP1 2026-05-21] 동시성 모델: 한 iter 에 가장 가까운 후보 1개만 처리.
// 이후 외곽 for(iter) 루프가 meets/kLineMeets/ridgeMeets 를 새 상태로 재계산한다.
// 이렇게 해야 뒤늦은 교점이 이미 그려질 라인을 잘라낼 수 있다 (Step 2~3 에서 확장 예정).
for (const c of candidates) {
if (c.extenders.some((e) => resolved.has(e))) continue
for (const e of c.extenders) resolved.set(e, c.point)
processedAny = true
// [KERAB-FIXPOINT-STEP3 2026-05-21] drawn segment 절단:
// 뒤늦은 교점이 잡힌 기존 segment 의 stop 을 교점으로 갱신.
// 절단 후 길이 < 0.5 이면 resolved 에서 제거하여 라인 자체 제거.
if (c.kind === 'drawn' && c.drawnExt) {
const drawnNear = c.drawnExt.sourcePoint || c.drawnExt.near
const remain = Math.hypot(c.point.x - drawnNear.x, c.point.y - drawnNear.y)
if (remain < 0.5) {
resolved.delete(c.drawnExt)
} else {
resolved.set(c.drawnExt, c.point)
}
// [KERAB-MULTI-APEX 2026-05-22] 규칙 5 Case B: drawn-meet 두 hip 90° → kLine 신규 생성.
// ray-ray 가 drift(t<0) 로 pair 후보 미생성이어도 drawn segment 교점에서 사실상 만나는 케이스.
// H-5/H-6 같이 RG-2 양끝 발산 hip → drawn meet 으로 동일점 stop.
const ea = c.extenders[0]
const eb = c.drawnExt
if (ea?.isHip && eb?.isHip && remain >= 0.5) {
const vax = ea.far.x - ea.near.x
const vay = ea.far.y - ea.near.y
const vbx = eb.far.x - eb.near.x
const vby = eb.far.y - eb.near.y
if (isPerpendicular(vax, vay, vbx, vby)) {
const eaTag = `${labelOf(ea.line) || 'H'}${ea.__reflected ? '*' : ''}${ea.__reflectedFromPending ? '!' : ''}`
const ebTag = `${labelOf(eb.line) || 'H'}${eb.__reflected ? '*' : ''}${eb.__reflectedFromPending ? '!' : ''}`
if (ea.__reflectedFromPending || eb.__reflectedFromPending) {
logger.log('[KERAB-APEX-SKIP-PHANTOM]', `iter${iter}/drawn-meet[${eaTag}+${ebTag}]`, {
x: c.point.x?.toFixed?.(2), y: c.point.y?.toFixed?.(2),
})
} else if (pushApexIfNew(c.point, `iter${iter}/drawn-meet[${eaTag}+${ebTag}]`)) {
pendingKLineCreated = true
}
}
}
}
// [KERAB-FIXPOINT-PHASE-A 2026-05-22] static 만남: ext 멈춤 + 정적 라인 절삭 정보 누적.
if (c.kind === 'static' && c.staticLine) {
cuts.push({ line: c.staticLine, point: c.point })
}
if (c.kind === 'pair' && c.bothHips) {
const [ea, eb] = c.extenders
const vax = ea.far.x - ea.near.x
const vay = ea.far.y - ea.near.y
const vbx = eb.far.x - eb.near.x
const vby = eb.far.y - eb.near.y
if (isPerpendicular(vax, vay, vbx, vby)) {
const eaTag = `${labelOf(ea.line) || (ea.isHip ? 'H' : 'R')}${ea.__reflected ? '*' : ''}${ea.__reflectedFromPending ? '!' : ''}`
const ebTag = `${labelOf(eb.line) || (eb.isHip ? 'H' : 'R')}${eb.__reflected ? '*' : ''}${eb.__reflectedFromPending ? '!' : ''}`
// [KERAB-MULTI-APEX 2026-05-22] pendingKLine 반사 자식이 끼인 pair-meet 은 phantom 이라 skip.
// primary(H-3+H-4*: H-4는 RG-2 ridge 반사로 fromPending 아님) 는 통과,
// phantom(H-16+H-2!: H-2 가 primary kLine 반사) 은 차단.
// 정적 hip 와의 90° 만남 apex 는 re-resolve 단계에서 따로 push.
if (ea.__reflectedFromPending || eb.__reflectedFromPending) {
logger.log('[KERAB-APEX-SKIP-PHANTOM]', `iter${iter}/pair-meet[${eaTag}+${ebTag}]`, {
x: c.point.x?.toFixed?.(2), y: c.point.y?.toFixed?.(2),
})
} else if (pushApexIfNew(c.point, `iter${iter}/pair-meet[${eaTag}+${ebTag}]`)) {
pendingKLineCreated = true
}
}
}
let hipExt = null
let mirrorLine = null
if ((c.kind === 'kline' || c.kind === 'ridge' || c.kind === 'drawn') && c.extenders[0].isHip) {
hipExt = c.extenders[0]
mirrorLine = c.mirrorLine
} else if (c.kind === 'pair') {
const [ea, eb] = c.extenders
if (ea.isHip && !eb.isHip) {
hipExt = ea
mirrorLine = { x1: eb.near.x, y1: eb.near.y, x2: eb.far.x, y2: eb.far.y }
} else if (!ea.isHip && eb.isHip) {
hipExt = eb
mirrorLine = { x1: ea.near.x, y1: ea.near.y, x2: ea.far.x, y2: ea.far.y }
}
}
if (hipExt && mirrorLine) {
const hdx = hipExt.near.x - hipExt.far.x
const hdy = hipExt.near.y - hipExt.far.y
const kdx = mirrorLine.x2 - mirrorLine.x1
const kdy = mirrorLine.y2 - mirrorLine.y1
const klen = Math.hypot(kdx, kdy) || 1
const nx = -kdy / klen
const ny = kdx / klen
const dot = hdx * nx + hdy * ny
const rdx = hdx - 2 * dot * nx
const rdy = hdy - 2 * dot * ny
// [KERAB-MULTI-APEX 2026-05-22] 같은 operation 의 pendingKLine 에 반사된 자식은
// __reflectedFromPending 태그. 이 자식의 pair-meet 으로 추가 apex 생성 차단(phantom 방지).
// 부모(hipExt) 가 이미 pending 에서 반사된 경우도 상속.
const fromPending =
hipExt.__reflectedFromPending ||
(c.kind === 'kline' && pendingKLines.some(
(pk) => pk.x1 === mirrorLine.x1 && pk.y1 === mirrorLine.y1 && pk.x2 === mirrorLine.x2 && pk.y2 === mirrorLine.y2,
))
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 },
isHip: true,
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
}
if (!processedAny && !pendingKLineCreated) break
extenderPool.push(...newReflected)
}
// [KERAB-ROOF-FALLBACK-ANYWALL 2026-05-21] 미해소 extender 를 roof 폴리곤의 어떤 wall 이라도
// inward 방향의 가장 가까운 wall 교점까지 확장. 반사 hip 이 target wall 과 반대쪽으로
// 향해도 다른 wall 에서 정지.
// (roofPolygonWalls 정의는 wave 시작 전 KERAB-ROOF-MAX-INWARD 블록으로 이동됨)
// [KERAB-NO-PIERCE 2026-05-21] fallback 직진 중에도 정적 inner line(이 polygon 의 처리대상 제외)
// + 이번 wave 에 이미 그려질 ext 라인을 barrier 로 검사. 가장 가까운 hit 에서 정지하여
// 다른 라인을 관통해 내부로 침입하는 케이스를 차단.
const barrierLines = []
for (const il of roof.innerLines || []) {
if (!il) continue
if (polygonLines.includes(il)) continue
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
if (il.visible === false) continue
barrierLines.push({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
}
for (const [otherExt, otherStop] of resolved) {
barrierLines.push({
x1: otherExt.sourcePoint.x,
y1: otherExt.sourcePoint.y,
x2: otherStop.x,
y2: otherStop.y,
})
}
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
let bestDist = Infinity
let bestSrc = null
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 dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y)
if (dist < bestDist) {
bestDist = dist
bestPt = ip
bestSrc = { kind: 'wall', a: wall.a, b: wall.b }
}
}
for (const bl of barrierLines) {
const ip = lineLineIntersection(ext.near, ext.far, { x: bl.x1, y: bl.y1 }, { x: bl.x2, y: bl.y2 })
if (!ip) continue
if (!isInward(ext, ip)) continue
if (!isPointOnSegment(ip, bl.x1, bl.y1, bl.x2, bl.y2)) continue
const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y)
if (dist < 1e-3) continue
if (dist < bestDist) {
bestDist = dist
bestPt = ip
bestSrc = { kind: 'barrier', a: { x: bl.x1, y: bl.y1 }, b: { x: bl.x2, y: bl.y2 } }
}
}
logger.log(
'[KERAB-FALLBACK] ext=' + (labelOf(ext.line) || (ext.isHip ? 'H' : 'R')) +
' near=' + JSON.stringify({ x: Math.round(ext.near.x * 100) / 100, y: Math.round(ext.near.y * 100) / 100 }) +
' stop=' + (bestPt ? JSON.stringify({ x: Math.round(bestPt.x * 100) / 100, y: Math.round(bestPt.y * 100) / 100 }) : 'null') +
' src=' + (bestSrc
? bestSrc.kind +
'[' +
Math.round(bestSrc.a.x * 100) / 100 + ',' + Math.round(bestSrc.a.y * 100) / 100 +
'→' +
Math.round(bestSrc.b.x * 100) / 100 + ',' + Math.round(bestSrc.b.y * 100) / 100 +
']'
: 'none'),
)
if (bestPt) resolved.set(ext, bestPt)
}
// [KERAB-FIXPOINT-PHASE-A 2026-05-22] cuts 적용 — 사용자 멘탈모델:
// 절삭 방향 = static line 이 "확장되는 방향" (= inward extension 의 anchor 쪽).
// 거리 기반(만남점에 가까운 쪽) 가 아니다 — junction-extended outer hip 의 경우
// 우연히 일치할 뿐. extension source 끝점 = 확장 anchor = junction stub = 제거.
// 반대편 = dead-end = 유지.
// (a) static cut: 확장 방향 끝점을 만남점까지 잘라낸다.
// (b) 확장 자체 purge: 같은 line 의 wave drawn(inward 확장) 제거.
// (c) cascade: 제거된 끝점을 source/stop 으로 쓰던 다른 drawn segment 도 정리.
// (d) re-resolve: stop 점이 죽은 segment(잘려나간 stub 또는 purge 된 확장)
// 위에 있던 다른 resolved entry 는 unresolved 로 되돌리고, 새 상태로
// wall + barrier 까지 재확장한다 (Phase A의 "다음 행위").
// [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(
cuts.map((c) => ({
line: labelOf(c.line) || 'H?',
point: { x: Math.round(c.point.x * 100) / 100, y: Math.round(c.point.y * 100) / 100 },
})),
),
)
// [KERAB-FIXPOINT-PHASE-A 2026-05-22] cuts 를 while/index 로 처리해 re-resolve
// 결과로 새로 잡힌 static meet 이 cuts 에 추가되면 같은 루프에서 처리.
let cutIdx = 0
while (cutIdx < cuts.length) {
const cut = cuts[cutIdx++]
const line = cut.line
if (!line) continue
const a = { x: line.x1, y: line.y1 }
const b = { x: line.x2, y: line.y2 }
const dA = Math.hypot(a.x - cut.point.x, a.y - cut.point.y)
const dB = Math.hypot(b.x - cut.point.x, b.y - cut.point.y)
// 확장 방향 = 이 line 을 source 로 하는 inward extension 의 sourcePoint 쪽.
// 그 끝점 = junction = 제거 대상. 반대편(dead-end) = 유지.
let extensionAnchor = null
for (const ext of resolved.keys()) {
if (ext.line === line && ext.sourcePoint) {
extensionAnchor = ext.sourcePoint
break
}
}
let keepA
let dirSource
if (extensionAnchor) {
const dAanc = Math.hypot(a.x - extensionAnchor.x, a.y - extensionAnchor.y)
const dBanc = Math.hypot(b.x - extensionAnchor.x, b.y - extensionAnchor.y)
keepA = dAanc > dBanc // anchor 와 먼 쪽(dead-end) 유지
dirSource = 'extension'
} else {
// priority 2: junction-vs-dead-end. 끝점에 다른 inner line 끝점이 모이면
// junction(3점 교점 등) → 그 쪽 유지. dead-end(연결없음) 쪽 절삭.
// 둘 다 같으면 다음 priority.
const countJunction = (p) => {
let count = 0
for (const il of roof.innerLines || []) {
if (!il || il === line) continue
if (il.visible === false) continue
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
if (Math.hypot(il.x1 - p.x, il.y1 - p.y) < 0.5) count += 1
if (Math.hypot(il.x2 - p.x, il.y2 - p.y) < 0.5) count += 1
}
return count
}
const jA = countJunction(a)
const jB = countJunction(b)
if (jA !== jB) {
keepA = jA > jB // junction count 많은 쪽 유지, dead-end 쪽 절삭
dirSource = 'junction'
} else {
// priority 3: 교점에 가까운 쪽 절삭 (먼 쪽 유지).
const TIE_EPS = 1.0
if (Math.abs(dA - dB) > TIE_EPS) {
keepA = dA >= dB
dirSource = 'distance'
} else {
// priority 4: roofLine 에 가까운 끝점을 가진 쪽 절삭.
const pointToSegDist = (p, sa, sb) => {
const vx = sb.x - sa.x
const vy = sb.y - sa.y
const lenSq = vx * vx + vy * vy
if (lenSq < 1e-6) return Math.hypot(p.x - sa.x, p.y - sa.y)
let t = ((p.x - sa.x) * vx + (p.y - sa.y) * vy) / lenSq
if (t < 0) t = 0
else if (t > 1) t = 1
return Math.hypot(p.x - (sa.x + t * vx), p.y - (sa.y + t * vy))
}
const minDistToRoof = (p) => {
let best = Infinity
for (const w of roofPolygonWalls) {
const d = pointToSegDist(p, w.a, w.b)
if (d < best) best = d
}
return best
}
const rA = minDistToRoof(a)
const rB = minDistToRoof(b)
keepA = rA > rB
dirSource = 'roofLine'
}
}
}
const removedEnd = keepA ? b : a
const remain = Math.hypot((keepA ? a : b).x - cut.point.x, (keepA ? a : b).y - cut.point.y)
if (remain < 0.5) {
if (typeof line.set === 'function') line.set({ visible: false })
else line.visible = false
} else if (keepA) {
if (typeof line.set === 'function') line.set({ x2: cut.point.x, y2: cut.point.y })
else { line.x2 = cut.point.x; line.y2 = cut.point.y }
} else {
if (typeof line.set === 'function') line.set({ x1: cut.point.x, y1: cut.point.y })
else { line.x1 = cut.point.x; line.y1 = cut.point.y }
}
// purge ext.line === cut.line BEFORE deletion: capture drawn segments.
const purgedSegments = []
let purgedLine = 0
for (const ext of Array.from(resolved.keys())) {
if (ext.line === line) {
const stop = resolved.get(ext)
if (ext.sourcePoint && stop) {
purgedSegments.push({
a: { x: ext.sourcePoint.x, y: ext.sourcePoint.y },
b: { x: stop.x, y: stop.y },
})
}
resolved.delete(ext)
purgedLine += 1
}
}
// cascade: removedEnd(잘려나간 stub 끝점) 에 src/stop 이 직접 붙은 drawn
// segment 만 단일 pass purge. resolved 의 stop 점은 apex/reflection 일 수도
// 있고 inner polygon junction 일 수도 있으므로 deadPts 로 전파하지 않는다.
// junction 은 살아있는 anchor → 다른 extension(예: H-2) 을 휩쓸어선 안 됨.
let purgedCascade = 0
for (const [ext, stop] of Array.from(resolved.entries())) {
const src = ext.sourcePoint
if (!src || !stop) continue
const srcAtRemoved = Math.hypot(src.x - removedEnd.x, src.y - removedEnd.y) < 0.5
const stopAtRemoved = Math.hypot(stop.x - removedEnd.x, stop.y - removedEnd.y) < 0.5
if (srcAtRemoved || stopAtRemoved) {
resolved.delete(ext)
purgedCascade += 1
}
}
// (d) re-resolve: 죽은 segment 들 위에 stop 이 있던 resolved entry → 재확장.
// killed = static line 의 잘린 stub (cut.point → removedEnd) +
// 방금 purge 한 drawn segment 들(source → stop).
const killedSegments = [
{ a: { x: cut.point.x, y: cut.point.y }, b: { x: removedEnd.x, y: removedEnd.y } },
...purgedSegments,
]
const staleExts = []
for (const [ext, stop] of Array.from(resolved.entries())) {
const onKilled = killedSegments.some((seg) =>
isPointOnSegment(stop, seg.a.x, seg.a.y, seg.b.x, seg.b.y, 0.5),
)
if (onKilled) {
resolved.delete(ext)
staleExts.push(ext)
}
}
// 재resolve: 현재 resolved + roof.innerLines 로 barrier 재구축 후 fallback 처럼
// 가장 가까운 wall/barrier 까지 확장. killed segment 는 barrier 에서 제외됨
// (resolved 에서 빠졌고 fabric line 좌표가 cut 으로 갱신됨).
let reResolved = 0
if (staleExts.length > 0) {
const barriers2 = []
for (const il of roof.innerLines || []) {
if (!il) continue
if (polygonLines.includes(il)) continue
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
if (il.visible === false) continue
barriers2.push({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
}
for (const [otherExt, otherStop] of resolved) {
barriers2.push({
x1: otherExt.sourcePoint.x,
y1: otherExt.sourcePoint.y,
x2: otherStop.x,
y2: otherStop.y,
})
}
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
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 dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y)
if (dist < 1e-3) continue
if (dist < bestDist) { bestDist = dist; bestPt = ip }
}
for (const bl of barriers2) {
const ip = lineLineIntersection(ext.near, ext.far, { x: bl.x1, y: bl.y1 }, { x: bl.x2, y: bl.y2 })
if (!ip) continue
if (!isInward(ext, ip)) continue
if (!isPointOnSegment(ip, bl.x1, bl.y1, bl.x2, bl.y2)) continue
const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y)
if (dist < 1e-3) continue
if (dist < bestDist) { bestDist = dist; bestPt = ip }
}
if (bestPt) {
resolved.set(ext, bestPt)
reResolved += 1
}
logger.log(
'[KERAB-FIXPOINT-PHASE-A] re-resolve ext=' + (labelOf(ext.line) || (ext.isHip ? 'H' : 'R')) +
' near={' + Math.round(ext.near.x * 100) / 100 + ',' + Math.round(ext.near.y * 100) / 100 + '}' +
' newStop=' + (bestPt ? '{' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + '}' : 'null'),
)
}
}
// [KERAB-FIXPOINT-PHASE-A 2026-05-22] re-resolve 후 새 stop 이 정적 inner hip
// 위면 hip+hip 만남(또는 hip+static-hip) 으로 간주 → cuts 추가 + apex/kLine.
// 같은 cuts 루프에서 다음 pass 처리(while/index).
let newCuts = 0
for (const ext of staleExts) {
const newStop = resolved.get(ext)
if (!newStop) continue
for (const il of roof.innerLines || []) {
if (!il) continue
if (polygonLines.includes(il)) continue
if (il.name !== LINE_TYPE.SUBLINE.HIP) continue
if (il.visible === false) continue
const already = cuts.some(
(c) => c.line === il && Math.hypot(c.point.x - newStop.x, c.point.y - newStop.y) < 0.5,
)
if (already) continue
if (!isPointOnSegment(newStop, il.x1, il.y1, il.x2, il.y2, 0.5)) continue
cuts.push({ line: il, point: { x: newStop.x, y: newStop.y } })
newCuts += 1
// [KERAB-MULTI-APEX 2026-05-22] re-resolve 의 정적 hip 만남(H-2반사 + H-14)이 90° 면 kLine 추가.
if (ext.isHip) {
const vax = ext.far.x - ext.near.x
const vay = ext.far.y - ext.near.y
const vbx = il.x2 - il.x1
const vby = il.y2 - il.y1
if (isPerpendicular(vax, vay, vbx, vby)) {
pushApexIfNew(newStop, `reresolve/ext=${labelOf(ext) || '?'}/il=${labelOf(il) || '?'}`)
}
}
break
}
}
logger.log(
'[KERAB-FIXPOINT-PHASE-A] cut applied line=' + (labelOf(line) || 'H?') +
' dir=' + dirSource +
' purgedLine=' + purgedLine + ' purgedCascade=' + purgedCascade +
' stale=' + staleExts.length + ' reResolved=' + reResolved +
' newCuts=' + newCuts +
' removedEnd={' + Math.round(removedEnd.x * 100) / 100 + ',' + Math.round(removedEnd.y * 100) / 100 + '}',
)
}
}
const extLines = []
for (const [ext, stop] of resolved) {
const fromPt = ext.sourcePoint
if (Math.hypot(stop.x - fromPt.x, stop.y - fromPt.y) < 0.5) continue
extLines.push({ from: fromPt, to: stop, isHip: ext.isHip })
}
const drawKLine = apexList.length > 0
const markerApex = apexList.length > 0 ? apexList[0].point : (extLines.length > 0 ? extLines[0].to : null)
const extraApexes = apexList.slice(1).map((ap) => ap.point)
logger.log(
'[KERAB-SIMPLE] sequential resolve ' +
JSON.stringify({
extLines: extLines.map((e) => ({
from: { x: Math.round(e.from.x * 100) / 100, y: Math.round(e.from.y * 100) / 100 },
to: { x: Math.round(e.to.x * 100) / 100, y: Math.round(e.to.y * 100) / 100 },
isHip: e.isHip,
})),
drawKLine,
markerApex: markerApex
? { x: Math.round(markerApex.x * 100) / 100, y: Math.round(markerApex.y * 100) / 100 }
: null,
apexList: apexList.map((ap) => ({
x: Math.round(ap.point.x * 100) / 100,
y: Math.round(ap.point.y * 100) / 100,
})),
}),
)
if (markerApex) {
const pathHips = polygonPath.filter((p) => p.line.name === LINE_TYPE.SUBLINE.HIP).map((p) => p.line)
const pathRidges = polygonPath.filter((p) => p.line.name === LINE_TYPE.SUBLINE.RIDGE).map((p) => p.line)
target.set({ attributes })
applyKerabKLinePattern(
roof,
target,
markerApex,
t1,
t2,
[h1Match.hip, h2Match.hip, ...pathHips],
pathRidges,
extLines,
drawKLine,
extraApexes,
)
// [KERAB-VALLEY-EXT 2026-05-27] valley 처마확장 raycast + drawing — applyKerabKLinePattern 후 실행.
// raycast 대상 = 현재 roof.innerLines 중 HIP/RIDGE (= 살아남은 hip·ridge + 새 extLines·kLine 모두 포함).
// 사라진 polygonPath hip/ridge 는 이미 제거되어 자동 제외. valleyExt 자신은 push 후이므로 1mm 필터로 보호.
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 sdx = bx - ax
const sdy = by - ay
const lenSq = sdx * sdx + sdy * sdy
if (lenSq < 1e-6) return Math.hypot(pt.x - ax, pt.y - ay) <= tol
const tt = ((pt.x - ax) * sdx + (pt.y - ay) * sdy) / lenSq
const margin = tol / Math.sqrt(lenSq)
if (tt < -margin || tt > 1 + margin) return false
const px = ax + tt * sdx
const py = ay + tt * sdy
return Math.hypot(px - pt.x, py - pt.y) <= tol
}
// ── 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
}
}
// [KERAB-VALLEY-HALF 2026-06-11] A/B 타입 표준 규칙(방향 무관):
// 내부확장라인은 맞은편 roofLine 까지 거리의 "절반"에서 멈춘다.
// 기존 ridge-stop / wall-끝까지 휴리스틱은 ridge 가 우연히 중간에 있으면 절반처럼,
// 없으면 끝까지 가버려 방향(세로/가로)에 따라 결과가 들쭉날쭉했다 → 항상 절반으로 통일.
// wallHit = 이 ray 가 만나는 맞은편 roof 외곽선(roofLine). 그 중점이 stop.
if (wallHit) {
bestStop = { x: (start.x + wallHit.x) / 2, y: (start.y + wallHit.y) / 2 }
logger.log(
'[KERAB-VALLEY-HALF] post label=' + ep.label +
' wallHit={' + Math.round(wallHit.x * 100) / 100 + ',' + Math.round(wallHit.y * 100) / 100 + '}' +
' halfStop={' + 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 sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
// [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 && vr.source.startsWith('roofBase'))
// [KERAB-VALLEY-EXT 2026-05-29] wallLine ID 를 attributes 에 명시 저장.
// apply() 의 wallExt 재계산 RECALC 가 vExt.attributes.wallLine 으로 outerLine 매칭 — 보존 필수.
const baseAttrs = { roofId: roof.id, planeSize: sz, actualSize: sz, wallLine: target.id }
if (isRoofBase) {
// [KERAB-VALLEY-EXT 2026-05-27] splitPolygonWithLines 의 innerLineMapping 은
// EAVES polygonLine 을 skip 하므로(usePolygon.js:822) 처마확장은 자동 매핑이 안 된다.
// 처마 라인 매핑과 동일하게 type/isStart 를 직접 부여.
baseAttrs.type = LINE_TYPE.WALLLINE.EAVES
baseAttrs.isStart = true
}
const vExt = new QLine(pts, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: LINE_TYPE.SUBLINE.VALLEY,
textMode: roof.textMode,
attributes: baseAttrs,
})
vExt.lineName = 'kerabPatternValleyExt'
vExt.__targetId = target.id
vExt.__valleyExtSource = vr.source
if (isRoofBase && vr.parent) {
vExt.parentLine = vr.parent
// [KERAB-VALLEY-EXT 2026-05-27] direction 도 부모 roofLine 에서 상속 — flow/pitch 방향 일관.
if (vr.parent.direction) vExt.direction = vr.parent.direction
}
canvas.add(vExt)
vExt.bringToFront()
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
// [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 원래 ridge 만 stop (wallBase 측 ray).
for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
if (il.lineName !== 'ridge') 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) {
// [KERAB-VALLEY-OVERLAP 2026-05-28] roof 1개에 split 결과 sub-roof 가 나뉜다.
// 라인은 roof.id 로만 생성 → split 후 직사각형 sub-roof 가 분리되어 나옴.
// apply() 후처리에서 직사각형 sub-roof 를 인접 두 sub-roof 에 union 머지하여 "겹침" 표현.
const buildOverlapLine = (p1, p2, suffix) => {
const lpts = [p1.x, p1.y, p2.x, p2.y]
const lsz = calcLinePlaneSize({ x1: lpts[0], y1: lpts[1], x2: lpts[2], y2: lpts[3] })
const ln = new QLine(lpts, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: LINE_TYPE.SUBLINE.VALLEY,
textMode: roof.textMode,
attributes: {
roofId: roof.id,
type: 'kerabValleyOverlapLine',
isStart: true,
pitch: roof.pitch,
planeSize: lsz,
actualSize: lsz,
},
})
ln.lineName = 'kerabValleyOverlapLine'
ln.roofId = roof.id
ln.__targetId = target.id
ln.__valleyExtSource = vr.source + suffix
canvas.add(ln)
ln.bringToFront()
return ln
}
// [KERAB-VALLEY-OVERLAP 2026-05-28] 평행 사각형으로 b 외곽 닫기 + (선택) 그 너머 ray 연장.
// 사용자 룰: "vStart 에서 wLine 까지 평행선 → 그 교점에서부터 wallExt 확장".
// 출발: vStart 의 wallLine(target) 위 수선의 발 = newWStart (90° 보조선). 원래 wStart(target corner) 는 사용 안 함.
// 도착: vEnd 의 동일 offset 평행이동 = wEndProj (90° 보조선, 이전과 동일).
// 사각형 4변: vStart-newWStart, newWStart-wEndProj(wallExt 본체), wEndProj-vEnd, vEnd-vStart(=vExt 이미 그려짐).
// wEnd(원 ray hit) 가 wEndProj 보다 더 멀면 wEndProj→wEnd 확장 라인 추가.
const vStart = { x: pts[0], y: pts[1] }
const vEnd = { x: pts[2], y: pts[3] }
const dxT = target.x2 - target.x1
const dyT = target.y2 - target.y1
const lenTSq = dxT * dxT + dyT * dyT
const tFoot = ((vStart.x - target.x1) * dxT + (vStart.y - target.y1) * dyT) / Math.max(lenTSq, 1e-9)
const newWStart = { x: target.x1 + tFoot * dxT, y: target.y1 + tFoot * dyT }
const offX = newWStart.x - vStart.x
const offY = newWStart.y - vStart.y
const wEndProj = { x: vEnd.x + offX, y: vEnd.y + offY }
buildOverlapLine(newWStart, wEndProj, '-wall')
buildOverlapLine(vStart, newWStart, '-bridge-start')
buildOverlapLine(vEnd, wEndProj, '-bridge-end')
// [KERAB-VALLEY-HALF 2026-06-11] 절반 규칙: wLine(wallBase) 도 vEnd(절반) 까지만.
// wEndProj 는 절반인 vEnd 에서 파생되므로, wEnd(원 ray full hit) 너머 확장은 금지.
logger.log(
'[KERAB-VALLEY-OVERLAP] drawn ' +
JSON.stringify({
src: vr.source,
stop: wRidgeStop ? 'ridge' : 'wall',
newWStart: { x: Math.round(newWStart.x * 100) / 100, y: Math.round(newWStart.y * 100) / 100 },
wEndProj: { x: Math.round(wEndProj.x * 100) / 100, y: Math.round(wEndProj.y * 100) / 100 },
wEnd: { x: Math.round(wEnd.x * 100) / 100, y: Math.round(wEnd.y * 100) / 100 },
vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 },
vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 },
extended: false,
}),
)
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] 룰 2: vExt 가 ridge 와 교차하면
// wallExt(`:`) 측(=vExt 와 가까운 영역) ridge 부분 절삭.
// ridge 의 V apex 는 vExt 와 먼 끝점 (Y 출발점). 살아남는 쪽 = V apex 쪽.
// 방향 판정: 절삭 방향 = wallExt 중점 - vExt 중점 (wallExt 측). dot 양수면 절삭 대상.
const veMid = { x: (vStart.x + vEnd.x) / 2, y: (vStart.y + vEnd.y) / 2 }
const wsMid = { x: (newWStart.x + wEndProj.x) / 2, y: (newWStart.y + wEndProj.y) / 2 }
const bDirX = wsMid.x - veMid.x
const bDirY = wsMid.y - veMid.y
const isBSide = (px, py) => (px - veMid.x) * bDirX + (py - veMid.y) * bDirY > 0
const trimCascadePts = []
const newTrimRecords = []
for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] trim 대상 = 원래 ridge/hip + 마루 확장(ExtRidge) + kLine(kerabPatternRidge).
// stop 룰(룰 1) 은 ridge 만, trim 룰(룰 2) 은 hip 도 포함 — vExt 는 hip 통과 후 절삭.
// 절삭 방향은 V apex 우선 룰 (아래) 로 결정.
const isRidge =
il.name === LINE_TYPE.SUBLINE.RIDGE &&
(il.lineName === 'ridge' || il.lineName === 'kerabPatternRidge' || il.lineName === 'kerabPatternExtRidge')
const isHip =
il.name === LINE_TYPE.SUBLINE.HIP &&
(il.lineName === 'hip' || il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip')
if (!isRidge && !isHip) continue
const ip = lineLineIntersection(vStart, vEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
if (!ip) continue
if (!isOnSegV(ip, vStart.x, vStart.y, vEnd.x, vEnd.y)) continue
if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue
// [VALLEY-EXT-TRIM-ENDPOINT-GUARD 2026-06-10] ip 가 il 끝점과 일치하면
// 그 라인은 vExt 를 가로지르는(cross) 게 아니라 vExt 에서 끝나는(terminate)
// 라인(예: RG-1 연장선, 한 끝이 이미 vExt 위). 반대 끝을 ip 로 절삭하면 양 끝이
// 같은 점이 되어 길이 0 붕괴 → 라인 소실. 절삭은 막되, 끝점이 vExt 내부점이면
// split 은 여전히 필요(그래프 노드 공유 → 할당 dead-end 방지)하므로 split 전용
// 레코드만 push 한다(좌표 변경/cascade 없음). revert 는 splitOnly 를 건너뛴다.
if (Math.hypot(ip.x - il.x1, ip.y - il.y1) < 1.0 || Math.hypot(ip.x - il.x2, ip.y - il.y2) < 1.0) {
newTrimRecords.push({ line: il, splitOnly: true, newPt: { x: ip.x, y: ip.y } })
continue
}
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] 절삭 방향 — V apex 우선 룰.
// V apex = 케라바 확장 hip(kerabPatternHip/kerabPatternExtHip) 두 개의 교점.
// 원래 hip(lineName='hip') 모임은 V apex 아님 — Y 출발점 룰은 케라바 패턴 한정.
// 1) 한 끝만 V apex → 그 V apex 측 살림, 반대 끝 절삭.
// 2) 양 끝 모두 V apex → vExt 와 먼 끝 살림 (가까운 끝 절삭).
// 3) 둘 다 V apex 아님 → 기존 fallback (B 측 = wallExt 측 절삭).
const isVApex = (px, py) => {
let cnt = 0
for (const other of roof.innerLines || []) {
if (!other || other === il) continue
if (other.visible === false) continue
if (other.name !== LINE_TYPE.SUBLINE.HIP) continue
if (other.lineName !== 'kerabPatternHip' && other.lineName !== 'kerabPatternExtHip') continue
if (Math.hypot(other.x1 - px, other.y1 - py) < 1.0) cnt++
else if (Math.hypot(other.x2 - px, other.y2 - py) < 1.0) cnt++
if (cnt >= 2) return true
}
return false
}
const e1V = isVApex(il.x1, il.y1)
const e2V = isVApex(il.x2, il.y2)
const e1B = isBSide(il.x1, il.y1)
const e2B = isBSide(il.x2, il.y2)
let trimEnd = 0
if (e1V && !e2V) trimEnd = 2 // e1 V apex 살림, e2 절삭
else if (!e1V && e2V) trimEnd = 1
else if (e1V && e2V) {
// 양 끝 모두 V apex → vExt 와 가까운 끝 절삭
const d1 = Math.hypot(il.x1 - veMid.x, il.y1 - veMid.y)
const d2 = Math.hypot(il.x2 - veMid.x, il.y2 - veMid.y)
trimEnd = d1 < d2 ? 1 : 2
} else {
// 둘 다 V apex 아님 → 기존 B 측 fallback
if (e1B && !e2B) trimEnd = 1
else if (!e1B && e2B) trimEnd = 2
else continue
}
const oldPt = trimEnd === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
const 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 }
// [KERAB-EXTRIDGE-CONNECT 2026-06-01] cascade hide 시 ExtRidge 끝점을 vExt 끝점(=ip)로 연장.
// ExtRidge 와 vExt 끝점이 분리돼 split graph 의 sub-roof 외곽이 안 닫히는 문제 해소.
trimCascadePts.push({ pt: oldPt, newPt: { x: ip.x, y: ip.y } })
newTrimRecords.push({
line: il,
end: trimEnd,
oldPt,
newPt: { x: ip.x, y: ip.y },
originalAttrs,
})
logger.log(
'[KERAB-VALLEY-EXT-TRIM] ridge trim ' +
JSON.stringify({ lineName: il.lineName, trimEnd, oldPt, ip: { x: Math.round(ip.x * 100) / 100, y: Math.round(ip.y * 100) / 100 } }),
)
}
// cascade — 절삭된 끝점에 붙은 ridge 확장(kerabPatternExtRidge) 만 BFS hide
const cascadeHidden = new Set()
let cascadeGuard = 0
while (trimCascadePts.length && cascadeGuard++ < 200) {
const entry = trimCascadePts.shift()
if (!entry) continue
const pt = entry.pt || entry
for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue
if (cascadeHidden.has(il)) continue
if (il.lineName !== 'kerabPatternExtRidge') 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)
const originalVisible = il.visible !== false
const originalAttrs = { ...(il.attributes || {}) }
il.visible = false
if (typeof il.setCoords === 'function') il.setCoords()
newTrimRecords.push({
line: il,
hide: true,
originalVisible,
originalAttrs,
})
logger.log(
'[KERAB-VALLEY-EXT-TRIM] cascade hide ' +
JSON.stringify({ lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }),
)
trimCascadePts.push({ pt: m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 } })
}
}
// [KERAB-VALLEY-EXT-SPLIT 2026-06-04] Option A: vExt(내부 roofLine)가 ridge/hip 와
// *내부* 교점에서 만나면 그 교점에서 vExt 자체를 분할한다.
// 이유: getSplitRoofsPoints(usePolygon.js)는 라인 *끝점*만 그래프 노드로 만든다
// (교차분할 없음). 절삭된 ridge(예: RG-2)가 vExt 의 끝점이 아닌 중간(internal)에
// 닿으면 공유 노드가 없어 dead-end → 지붕면 할당에서 고립 폐기되고 인접 면이 안
// 쪼개진다(예: F-2 비대). vExt 를 교점에서 분할해 끝점=노드를 만들어 ridge 가
// 할당 그래프에 연결되게 한다. 절삭 규칙/방향은 건드리지 않고 vExt 분할만 추가.
// revert: 분할 세그먼트도 lineName='kerabPatternValleyExt' + __targetId 이므로
// applyKerabRevertPattern 의 valleyExt 제거 스캔에서 자동 정리된다.
{
const SPLIT_TOL = 1.0
const interiorPts = []
for (const r of newTrimRecords) {
if (!r || r.hide || !r.newPt) continue
const ip = r.newPt
if (!isOnSegV(ip, vStart.x, vStart.y, vEnd.x, vEnd.y)) continue
const dS = Math.hypot(ip.x - vStart.x, ip.y - vStart.y)
const dE = Math.hypot(ip.x - vEnd.x, ip.y - vEnd.y)
if (dS < SPLIT_TOL || dE < SPLIT_TOL) continue // 끝점은 이미 노드
if (interiorPts.some((p) => Math.hypot(p.x - ip.x, p.y - ip.y) < SPLIT_TOL)) continue
interiorPts.push({ x: ip.x, y: ip.y })
}
if (interiorPts.length) {
const vdx2 = vEnd.x - vStart.x
const vdy2 = vEnd.y - vStart.y
const tOf = (p) => (p.x - vStart.x) * vdx2 + (p.y - vStart.y) * vdy2
interiorPts.sort((a, b) => tOf(a) - tOf(b))
const bps = [vStart, ...interiorPts, vEnd]
// 첫 구간은 기존 vExt 재사용(끝점 단축), 나머지는 새 QLine 세그먼트.
vExt.set({ x2: bps[1].x, y2: bps[1].y })
if (typeof vExt.setCoords === 'function') vExt.setCoords()
const sz0 = calcLinePlaneSize({ x1: vExt.x1, y1: vExt.y1, x2: vExt.x2, y2: vExt.y2 })
vExt.attributes = { ...vExt.attributes, planeSize: sz0, actualSize: sz0 }
const newSegs = []
for (let bi = 1; bi < bps.length - 1; bi++) {
const a = bps[bi]
const b = bps[bi + 1]
const segSz = calcLinePlaneSize({ x1: a.x, y1: a.y, x2: b.x, y2: b.y })
const seg = new QLine([a.x, a.y, b.x, b.y], {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: LINE_TYPE.SUBLINE.VALLEY,
textMode: roof.textMode,
attributes: { ...(vExt.attributes || {}), planeSize: segSz, actualSize: segSz },
})
seg.lineName = 'kerabPatternValleyExt'
seg.__targetId = target.id
seg.__valleyExtSource = vExt.__valleyExtSource
if (vExt.parentLine) seg.parentLine = vExt.parentLine
if (vExt.direction) seg.direction = vExt.direction
canvas.add(seg)
seg.bringToFront()
roof.innerLines.push(seg)
newSegs.push(seg)
}
logger.log(
'[KERAB-VALLEY-EXT-SPLIT] vExt split ' +
JSON.stringify({
source: vr.source,
breaks: interiorPts.map((p) => ({ x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 })),
segs: newSegs.length + 1,
}),
)
debugCapture.log('KERAB-VALLEY-EXT-SPLIT', {
source: vr.source,
vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 },
vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 },
breaks: interiorPts.map((p) => ({ x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 })),
segCount: newSegs.length + 1,
})
}
}
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] revert 위해 target.__valleyExtTrims 에 누적.
// 기존 revert 로직(2566~) 이 hide/end/oldPt/originalAttrs 그대로 처리.
// 한 mousedown 에 여러 vExt 가 호출될 수 있어 concat.
if (newTrimRecords.length) {
target.__valleyExtTrims = (target.__valleyExtTrims || []).concat(newTrimRecords)
}
debugCapture.log('KERAB-VALLEY-EXT-TRIM', {
source: vr.source,
vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 },
vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 },
count: newTrimRecords.length,
trims: newTrimRecords.map((r) => ({
lineName: r.line?.lineName,
name: r.line?.name,
label: typeof labelOf === 'function' ? labelOf(r.line) : undefined,
hide: r.hide ?? false,
end: r.end,
oldPt: r.oldPt ? { x: Math.round(r.oldPt.x * 100) / 100, y: Math.round(r.oldPt.y * 100) / 100 } : null,
newPt: r.newPt ? { x: Math.round(r.newPt.x * 100) / 100, y: Math.round(r.newPt.y * 100) / 100 } : null,
now: r.line ? { x1: Math.round(r.line.x1 * 100) / 100, y1: Math.round(r.line.y1 * 100) / 100, x2: Math.round(r.line.x2 * 100) / 100, y2: Math.round(r.line.y2 * 100) / 100 } : null,
})),
})
} 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) {
logger.log('[KERAB-VALLEY-EXT] drawn ' + valleyExtensions.length)
}
// [KERAB-VALLEY-EXT 2026-05-28] 사용자 지시: 골짜기 라인이동(trim)·라인절삭(cascade) 다 비활성, 확장만.
// 골짜기 케라바 라인 별도 룰 후속에서 재활성 예정.
// const trimRecords = trimByValleyExtensions(valleyExtensions)
// cascadeHideByValleyExtensions(trimRecords)
const trimRecords = []
if (trimRecords.length) target.__valleyExtTrims = trimRecords
}
dumpInnerLineSnapshot('AFTER')
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
logger.log('[KERAB-SIMPLE] applied (sequential, drawKLine=' + drawKLine + ', extraApexes=' + extraApexes.length + ')')
return
}
dumpInnerLineSnapshot('AFTER')
logger.log('[KERAB-SIMPLE] no resolution possible — fallback attr-only')
target.set({ attributes })
applyKerabAttributeOnlyPattern()
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
return
}
// condition 1: 자연 만남 — ext 없이 hip 제거 + kLine
target.set({ attributes })
applyKerabKLinePattern(
roof,
target,
apex,
t1,
t2,
[h1Match.hip, h2Match.hip],
null,
null,
)
dumpInnerLineSnapshot('AFTER')
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
logger.log('[KERAB-SIMPLE] applied (kLine, natural-apex)')
return
}
}
target.set({ attributes })
applyKerabAttributeOnlyPattern()
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
logger.log('[KERAB-SIMPLE] attr-only fallback')
return
}
}
// [2240 KERAB-REVERT 2026-05-19] 대전제 1 역방향: 케라바→처마 변환 시 케라바 중점→apex ridge 가 존재하면
// ridge 1개 제거 + apex→c1, apex→c2 hip 2개 생성으로 처리하고 기존 rebuild 흐름은 건너뜀.
// ridge-at-mid 없으면 forward 가 attribute-only 였던 케이스 → 속성만 EAVES 로 토글.
if (typeRef.current === TYPES.EAVES && radioTypeRef.current === '1' && target.attributes?.type === LINE_TYPE.WALLLINE.GABLE) {
const roof = canvas
.getObjects()
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
if (roof && Array.isArray(roof.innerLines) && Array.isArray(roof.points)) {
// [KERAB-RULE-CHECK 2026-06-10] revert 변환 전(게이블 출폭) 상태를 R4 anchor 기준으로 캡처.
const kerabRevertBeforeSnap = snapshotKerabState(roof)
const c1 = nearestRoofPoint(roof, { x: target.x1, y: target.y1 })
const c2 = nearestRoofPoint(roof, { x: target.x2, y: target.y2 })
if (c1 && c2) {
// [KERAB-REVERT-EXTEND-45 2026-06-05] 룰: hip 은 wallbaseLine 의 45° (skeleton 이론 부정 X).
// 본체 inner endpoint = snapshot 그대로 (apex 위치).
// 본체 outer endpoint = wL 코너에서 45° 방향(snapshot 의 inner→outer 방향) 으로 ray cast → 새 rL 변 hit point.
// wL 코너는 라인이 지나가는 transit point 일 뿐 data endpoint 아님.
// skeleton-utils.js drawBaselineToRooflineHelpers (line 770~880) 와 동일 방식.
const surgicalAfter = () => {
// [KERAB-REVERT-EXTEND-45 2026-06-05] 룰: 출폭 변경 시 wallbaseLine 안의 내부라인 끝점 불변.
// surgical 의 CORNER-SHORTCUT(roofLine 코너로 스냅)·SHRINK-TRIM(__shrinkOrig 복원) 은 룰 위반 →
// skipInnerLines:true 로 roof.points + matchingRL/prev/nextRL + edge 객체만 갱신.
// 증상: (1) b1 ray-cast 후 b4 출폭조정 → SHRINK-TRIM restore 가 b1 hip 을 snapshot 좌표로 리셋
// (2) CORNER-SHORTCUT 가 hip outer endpoint 를 새 roofLine 코너로 강제 스냅.
// hip 의 outer endpoint 만 아래 ray-cast 로 새 rL 변까지 45° 확장한다.
// [KERAB-REVERT-VERTEX-EXTEND 2026-06-11] surgical 은 roof.points 를 새 출폭으로 갱신하므로,
// 게이블 코너 hip 판정용으로 이동 전 옛 폴리곤 꼭짓점을 미리 스냅샷한다.
const oldPolyPoints = (Array.isArray(roof.points) ? roof.points : []).map((p) => ({ x: p.x, y: p.y }))
if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0, { skipInnerLines: true })
// [45-UNIFY 2026-06-15] reclick 과 동일한 extendKerabHipsTo45 사용 (revert 가드 on):
// forward 가 붙여둔 __kerabRevertOuterWhich/Side 플래그로 marks 를 만든 뒤, surgical *후*
// 새 폴리곤에 대해 outerShared/outerOnVertex 가드를 적용해 45° 확장. 플래그는 사용 후 정리.
const marks = []
for (const il of roof.innerLines || []) {
if (!il || !il.__kerabRevertOuterWhich) continue
marks.push({ il, which: il.__kerabRevertOuterWhich, side: il.__kerabRevertOuterSide })
}
extendKerabHipsTo45(target, roof, marks, { useRevertGuards: true, oldPolyPoints })
for (const m of marks) {
delete m.il.__kerabRevertOuterWhich
delete m.il.__kerabRevertOuterSide
}
runKerabRuleCheck(roof, 'revert', kerabRevertBeforeSnap)
}
// [2240 KERAB-PARALLEL-HIPS 2026-05-19] forward 가 parallel-hips 였으면 target 에 스냅샷이 붙어있음 → hip 2개 복원
if (Array.isArray(target.__kerabParallelHipsSnapshot)) {
target.set({ attributes })
revertKerabParallelHipsPattern(roof, target)
surgicalAfter()
logger.log('[KERAB-REVERT] applied (parallel-hips) ' + JSON.stringify({ c1, c2 }))
return
}
// [KERAB-SIMPLE-REVERT 2026-05-20] kLineOnly 패턴: ridge.__targetId 로 직접 매칭 (nearestRoofPoint drift 회피)
const kLineRidge = roof.innerLines.find(
(il) => il && il.__patternKind === 'kLineOnly' && il.__targetId === target.id,
)
if (kLineRidge) {
target.set({ attributes })
// [KERAB-TYPE-EAVES-EDGEMOVE 2026-06-11] TYPE 출신 revert(케라바→처마)도 출폭이 바뀌므로 옛 변 위
// 면경계선이 따라와야 한다(안 그러면 rLine 겹침). apex(= kLine 의 cMid 반대 끝) + edgeIdx 미리 캡처.
const _kMid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 }
const _kApex =
Math.hypot(kLineRidge.x1 - _kMid.x, kLineRidge.y1 - _kMid.y) >= Math.hypot(kLineRidge.x2 - _kMid.x, kLineRidge.y2 - _kMid.y)
? { x: kLineRidge.x1, y: kLineRidge.y1 }
: { x: kLineRidge.x2, y: kLineRidge.y2 }
const _kTol = 0.5
const _kNP = roof.points.length
let _kEdgeIdx = -1
for (let i = 0; i < _kNP; i++) {
const p = roof.points[i]
const q = roof.points[(i + 1) % _kNP]
if (
(Math.hypot(p.x - c1.x, p.y - c1.y) < _kTol && Math.hypot(q.x - c2.x, q.y - c2.y) < _kTol) ||
(Math.hypot(p.x - c2.x, p.y - c2.y) < _kTol && Math.hypot(q.x - c1.x, q.y - c1.y) < _kTol)
) {
_kEdgeIdx = i
break
}
}
const ok = applyKerabRevertPattern(roof, target, c1, c2, { ridge: kLineRidge, apex: null })
if (ok) {
surgicalAfter()
moveStaleEdgeInnerLines(roof, c1, c2, _kEdgeIdx, _kApex)
extendTypeGableHipsStraightToRoofLine(roof, target, _kApex)
}
logger.log('[KERAB-REVERT] applied (kLineOnly via targetId) ' + JSON.stringify({ ok, edgeIdx: _kEdgeIdx, apex: _kApex }))
if (ok) return
}
// [2240 KERAB-MID-FROM-TARGET 2026-05-20] revert 도 target 중점 기준 ridge 검색 (forward 와 일관)
const tEnd1 = { x: target.x1, y: target.y1 }
const tEnd2 = { x: target.x2, y: target.y2 }
const ridgeAtMid = findRidgeAtMidpoint(roof, tEnd1, tEnd2)
// [KERAB-TYPE-EAVES 2026-06-11] TYPE 출신(native 마루·토글 이력 없음) 은 전용 함수로 분기.
// 기존 폴백(applyKerabRevertPattern single-ridge)은 apex=마루 far-end + 마루 전체 삭제라
// L/2 setback·마루 단축 규칙과 어긋남. native 마루일 때만 가로채고 나머지는 그대로.
// 불변식 "마루는 roofLine 까지" → native 마루 끝점은 wLine 중점이 아니라 roofLine 코너(c1/c2)
// 중점에 있다. 그래서 파인더는 c1/c2 기준 (tEnd 기준 ridgeAtMid 는 出幅>0 이면 null).
const typeRidge = ridgeAtMid || findRidgeAtMidpoint(roof, c1, c2)
if (ENABLE_TYPE_GABLE_TO_EAVES && typeRidge && isNativeTypeRidge(typeRidge.ridge)) {
target.set({ attributes })
// 변환 전 옛 roofLine 변(c1↔c2) 의 polygon 인덱스 캡처 — surgical 후 새 코너 좌표 매핑용.
const _EDGE_TOL = 0.5
const _NP = roof.points.length
let _edgeIdx = -1
for (let i = 0; i < _NP; i++) {
const p = roof.points[i]
const q = roof.points[(i + 1) % _NP]
if (
(Math.hypot(p.x - c1.x, p.y - c1.y) < _EDGE_TOL && Math.hypot(q.x - c2.x, q.y - c2.y) < _EDGE_TOL) ||
(Math.hypot(p.x - c2.x, p.y - c2.y) < _EDGE_TOL && Math.hypot(q.x - c1.x, q.y - c1.y) < _EDGE_TOL)
) {
_edgeIdx = i
break
}
}
const apexRes = applyTypeGableToEavesPattern(roof, target, typeRidge.ridge)
if (apexRes) {
surgicalAfter()
// [KERAB-TYPE-EAVES-EDGEMOVE 2026-06-11] surgicalAfter 는 skipInnerLines:true 라 옛 roofLine
// 변 위에 놓인 normal inner line(L자 면경계)이 안 따라온다 → 옛 위치에 ghost("한개 더").
// 변은 出幅만큼 평행이동(delta)했으므로, 옛 변 위 끝점만 동일 delta 로 평행이동(= NORMAL-ABS 동치).
// 단, cMid(변 중점)에 걸린 비-collinear 선의 끝점은 apex 로 수렴해야 한다(힙 사이 마루 금지).
// 케라바패턴 라인(내 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)
// [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] apex 는 벽기준 고정. 힙을 벽교점 방향 45° 그대로 roofLine 까지 직선 연장.
extendTypeGableHipsStraightToRoofLine(roof, target, apexRes)
// [KERAB-TYPE-EAVES-HIPROOF 2026-06-12] 양쪽 박공이 모두 처마가 되어 완전 寄棟이면,
// 세로 apex 2개·X자 교차를 버리고 힙·마루 규칙(R1/R2/R3)으로 가로 마루 + 절삭 힙 4개로 재구성.
reconstructHipRoofRidgeIfComplete(roof)
// [KERAB-RULE-CHECK 2026-06-12] 모든 라인변경(재구성 포함)이 끝난 최종 형상을 규칙으로 검증.
// surgicalAfter 내부 검사는 재구성 전 상태라, 최종 상태는 여기서 한 번 더 본다.
runKerabRuleCheck(roof, 'type-eaves', kerabRevertBeforeSnap)
}
logger.log('[KERAB-TYPE-EAVES] branch ' + JSON.stringify({ ok: !!apexRes, c1, c2, edgeIdx: _edgeIdx }))
if (apexRes) return
}
if (ridgeAtMid) {
target.set({ attributes })
const ok = applyKerabRevertPattern(roof, target, c1, c2, ridgeAtMid)
if (ok) surgicalAfter()
logger.log('[KERAB-REVERT] applied ' + JSON.stringify({ ok, c1, c2, apex: ridgeAtMid.apex }))
if (ok) return
}
// [KERAB-VALLEY-EAVES-RULE1 2026-06-16] A/B타입 native 케라바 변(스냅샷·native마루·kLine 전부 없음)의
// 첫 처마 변환 "씨앗". 양 코너에서 폴리곤 안쪽 45° 힙 2개를 그려 각자 첫 교차(다른 inner line 또는
// 상대편 roofLine)까지 뻗는다. R3 절삭만 — 반사·힙 사이 마루 생성은 보류(다각형 넓이 의존, 추후 결정).
// 出幅은 일반 라인변경과 동일하게 기존 surgical 로 처리. 게이트: 변 위에 collinear 게이블벽 inner line
// 이 실재할 때만(= 진짜 케라바 기하). 없으면 아래 attr-only 폴백.
{
const EDGE_TOL = 0.5
const NP = roof.points.length
let edgeIdx = -1
for (let i = 0; i < NP; i++) {
const p = roof.points[i]
const q = roof.points[(i + 1) % NP]
if (
(Math.hypot(p.x - c1.x, p.y - c1.y) < EDGE_TOL && Math.hypot(q.x - c2.x, q.y - c2.y) < EDGE_TOL) ||
(Math.hypot(p.x - c2.x, p.y - c2.y) < EDGE_TOL && Math.hypot(q.x - c1.x, q.y - c1.y) < EDGE_TOL)
) {
edgeIdx = i
break
}
}
// 변환되는 게이블 변의 "게이블벽" 전체를 제거한다(사용자 확정 2026-06-16).
// 게이블벽 = 변 c1↔c2(roofLine쪽) 또는 옛 出幅쪽 outerLine 과 같은 무한직선 위의 HIP 세그먼트.
// 변이 게이블일 때만 존재한 구조 → eaves 가 되면 소멸. 한 직선이 ridge 에 의해 여러 세그먼트로
// 쪼개져 변 구간 밖(아래)까지 뻗기도 한다(예: H-9 = 변 아래 연장). 따라서 변 구간 겹침이 아니라
// "무한직선 동일선상(양 끝점 수직거리<1)" 전체를 게이블벽으로 본다 — i5(변 위) + i10(변 아래) 모두.
// 이 전체 제거가 ridge(RG-2)의 anchor 를 끊어, 뒤의 dangling 정리에서 RG-2 까지 연쇄 제거된다.
const isCollinearWith = (l, A, B) => {
const dx = B.x - A.x
const dy = B.y - A.y
const lenSq = dx * dx + dy * dy || 1
const perp = (px, py) => {
const t = ((px - A.x) * dx + (py - A.y) * dy) / lenSq
return Math.hypot(px - (A.x + t * dx), py - (A.y + t * dy))
}
return perp(l.x1, l.y1) < 1.0 && perp(l.x2, l.y2) < 1.0
}
// 옛 出幅쪽 수직벽 식별용 — surgical 전 target outerLine 끝점(=出幅 위치) 캡처.
const tA = { x: target.x1, y: target.y1 }
const tB = { x: target.x2, y: target.y2 }
// 게이블벽(HIP)만 직접 제거. native 마루(RIDGE)는 여기서 보존하되, anchor 가 끊겨 dangling 이
// 되는 ridge(RG-2)는 뒤의 cleanupDangling 이 연쇄 제거한다(사용자 확정 2026-06-16).
const gableWallLines = (roof.innerLines || []).filter(
(l) =>
l && l.visible !== false && l.name === LINE_TYPE.SUBLINE.HIP && (isCollinearWith(l, c1, c2) || isCollinearWith(l, tA, tB)),
)
if (edgeIdx >= 0 && gableWallLines.length > 0) {
target.set({ attributes })
// 1) 옛 게이블벽 라인 제거 + 짝 확장 helper(extensionLine) 동반 제거.
for (const gw of gableWallLines) {
removeLine(gw)
removeOrphanExtensionsForHip(gw)
}
roof.innerLines = roof.innerLines.filter((l) => !gableWallLines.includes(l))
// 2) 出幅 반영 (일반 라인변경과 동일 — 기존 surgical, inner line 불변).
applyTargetOffsetSurgical(target, attributes?.offset ?? 0, { skipInnerLines: true })
// 3) 새 roofLine 코너 (인덱스는 surgical 후에도 안정).
const pts = roof.points
const nA = pts[edgeIdx]
const nB = pts[(edgeIdx + 1) % NP]
const newC1IsA = Math.hypot(nA.x - c1.x, nA.y - c1.y) <= Math.hypot(nA.x - c2.x, nA.y - c2.y)
const idxA = newC1IsA ? edgeIdx : (edgeIdx + 1) % NP
const idxB = newC1IsA ? (edgeIdx + 1) % NP : edgeIdx
const newC1 = pts[idxA]
const newC2 = pts[idxB]
// 폴리곤 중심 (inward 판정용).
let cenX = 0
let cenY = 0
for (const p of pts) {
cenX += p.x
cenY += p.y
}
cenX /= pts.length || 1
cenY /= pts.length || 1
// 코너의 두 이웃 변 방향 → 폴리곤 안쪽 45° 이등분선 (convex/reflex 모두 안쪽으로 보정).
const inwardDir = (idx) => {
const C = pts[idx]
const prev = pts[(idx - 1 + NP) % NP]
const next = pts[(idx + 1) % NP]
const n1 = Math.hypot(prev.x - C.x, prev.y - C.y) || 1
const n2 = Math.hypot(next.x - C.x, next.y - C.y) || 1
let dx = (prev.x - C.x) / n1 + (next.x - C.x) / n2
let dy = (prev.y - C.y) / n1 + (next.y - C.y) / n2
const dl = Math.hypot(dx, dy)
if (dl < 1e-6) {
// 일직선(180°) — 변 수직 안쪽 법선.
dx = -(next.y - C.y) / n2
dy = (next.x - C.x) / n2
} else {
dx /= dl
dy /= dl
}
if (dx * (cenX - C.x) + dy * (cenY - C.y) < 0) {
dx = -dx
dy = -dy
}
return { x: dx, y: dy }
}
// corner 에서 dir 로 ray → 첫 교차(roofLine 변 + 기존 inner line), 자기 변(t≈0) 제외.
const rayFirstHit = (P, dir) => {
const segHit = (A, B) => {
const ex = B.x - A.x
const ey = B.y - A.y
const den = dir.x * ey - dir.y * ex
if (Math.abs(den) < 1e-9) return Infinity
const t = ((A.x - P.x) * ey - (A.y - P.y) * ex) / den
const u = ((A.x - P.x) * dir.y - (A.y - P.y) * dir.x) / den
if (t > 1.0 && u >= -1e-6 && u <= 1 + 1e-6) return t
return Infinity
}
let best = Infinity
let hit = null
let hitLine = null
for (let i = 0; i < NP; i++) {
const t = segHit(pts[i], pts[(i + 1) % NP])
if (t < best) {
best = t
hit = { x: P.x + dir.x * t, y: P.y + dir.y * t, line: null, kind: 'roofLine' }
hitLine = null
}
}
for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
const t = segHit({ x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
if (t < best) {
best = t
hit = { x: P.x + dir.x * t, y: P.y + dir.y * t, line: il, kind: il.name }
hitLine = il
}
}
return hit
}
const mkValleyHip = (corner, hit) => {
const arr = [corner.x, corner.y, hit.x, hit.y]
const sz = calcLinePlaneSize({ x1: arr[0], y1: arr[1], x2: arr[2], y2: arr[3] })
const hip = new QLine(arr, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: LINE_TYPE.SUBLINE.HIP,
textMode: roof.textMode,
attributes: { roofId: roof.id, type: LINE_TYPE.SUBLINE.HIP, planeSize: sz, actualSize: sz },
})
hip.lineName = 'kerabPatternHip'
hip.startPoint = { x: hip.x1, y: hip.y1 }
hip.endPoint = { x: hip.x2, y: hip.y2 }
return hip
}
const addHipFrom = (corner, idx) => {
const dir = inwardDir(idx)
const hit = rayFirstHit(corner, dir)
if (!hit) return { dir, hit: null, hip: null, hitLine: null, hitKind: null }
const h = mkValleyHip(corner, hit)
canvas.add(h)
h.bringToFront()
roof.innerLines.push(h)
return { dir, hit, hip: h, hitLine: hit.line, hitKind: hit.kind }
}
// 임의 방향 ray 로 골짜기 힙 1개 생성(반사 힙용). 첫 교차까지.
const drawHipRay = (from, dir) => {
const hit = rayFirstHit(from, dir)
if (!hit) return null
const h = mkValleyHip(from, hit)
canvas.add(h)
h.bringToFront()
roof.innerLines.push(h)
return { hip: h, hit }
}
// 4) 게이블 구조 잔재 정리 — anchor(게이블벽 HIP) 제거로 dangling 이 된 HIP·확장라인(H-9 등)만 제거.
// ★ 마루(RIDGE)는 절대 제거하지 않는다 — dangling 이어도 보존(사용자 확정 2026-06-16:
// "마루를 지우면 안된다"). RIDGE 는 connectivity 판정에는 포함(다른 HIP 의 접합 대상)하되
// dangler 후보에서는 제외. VALLEY(골짜기확장)도 vExt 위에서 끝나는 정상 케이스라 제외.
// 새 골짜기 힙 그리기 *전에* 수행해야 raycast 가 깨끗한 기하에 닿아 SHORT-hit 를 피한다.
const cleanupDangling = () => {
const TOL = 2.0
const rpts = roof.points || []
const distToSeg = (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 minEdgeDist = (pt) => {
let m = Infinity
for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) {
const d = distToSeg(pt, rpts[j], rpts[i])
if (d < m) m = d
}
return m
}
const removed = []
for (let iter = 0; iter < 12; iter++) {
const surv = (roof.innerLines || []).filter(
(l) =>
l &&
l.visible !== false &&
(l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE || l.name === LINE_TYPE.SUBLINE.VALLEY),
)
const ends = []
for (const l of surv) {
ends.push({ x: l.x1, y: l.y1, line: l })
ends.push({ x: l.x2, y: l.y2, line: l })
}
// 마루(RIDGE)·VALLEY 는 dangling 이어도 제거 금지 — HIP 만 제거 후보.
const dangler = surv.find((l) => {
if (l.name !== LINE_TYPE.SUBLINE.HIP) return false
for (const p of [
{ x: l.x1, y: l.y1 },
{ x: l.x2, y: l.y2 },
]) {
const onBoundary = rpts.length >= 3 && minEdgeDist(p) <= TOL
const shared = ends.some((e) => e.line !== l && Math.hypot(e.x - p.x, e.y - p.y) < TOL)
if (!onBoundary && !shared) return true
}
return false
})
if (!dangler) break
removeLine(dangler)
roof.innerLines = roof.innerLines.filter((l) => l !== dangler)
removeOrphanExtensionsForHip(dangler)
removed.push({ n: dangler.name, ln: dangler.lineName || '-', x1: Math.round(dangler.x1), y1: Math.round(dangler.y1), x2: Math.round(dangler.x2), y2: Math.round(dangler.y2) })
}
return removed
}
const cleaned = cleanupDangling()
const resA = addHipFrom(newC1, idxA)
const resB = addHipFrom(newC2, idxB)
// 5b) 마루(RIDGE) 끝점 상호작용 — anchor 잃은 끝점 해소(사용자 확정 2026-06-16: "RG1(h8) 확장, RG2(b5) 절삭").
// 규칙: 마루는 절대 삭제 안 함. anchor 잃은(=경계 미접촉 + 다른 선과 미공유) 끝점은
// · 새 골짜기 힙이 그 마루를 가로지르면 → 교점에서 절삭(trim, 끝점을 교점으로 당김)
// · 가로지르는 힙이 없으면 → 끝점 방향으로 roofLine/다음 라인까지 확장(extend)
const resolveRidgeEndpoints = () => {
const TOL = 2.0
const rpts = roof.points || []
const newHips = [resA, resB].map((r) => r && r.hip).filter(Boolean)
const distToSeg = (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 minEdgeDist = (pt) => {
let m = Infinity
for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) {
const d = distToSeg(pt, rpts[j], rpts[i])
if (d < m) m = d
}
return m
}
// 선분 교차(내부 교점만, 끝점 t∈(eps,1-eps))
const segInt = (p1, p2, p3, p4) => {
const d1x = p2.x - p1.x
const d1y = p2.y - p1.y
const d2x = p4.x - p3.x
const d2y = p4.y - p3.y
const den = d1x * d2y - d1y * d2x
if (Math.abs(den) < 1e-9) return null
const t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / den
const u = ((p3.x - p1.x) * d1y - (p3.y - p1.y) * d1x) / den
if (t <= 1e-6 || t >= 1 - 1e-6 || u <= 1e-6 || u >= 1 - 1e-6) return null
return { x: p1.x + t * d1x, y: p1.y + t * d1y }
}
// 절삭 전용: 마루(rp→rq)는 내부만, 힙(ha→hb)은 끝점 닿음(T자)까지 허용
const segIntTrim = (rp, rq, ha, hb) => {
const d1x = rq.x - rp.x
const d1y = rq.y - rp.y
const d2x = hb.x - ha.x
const d2y = hb.y - ha.y
const den = d1x * d2y - d1y * d2x
if (Math.abs(den) < 1e-9) return null
const t = ((ha.x - rp.x) * d2y - (ha.y - rp.y) * d2x) / den
const u = ((ha.x - rp.x) * d1y - (ha.y - rp.y) * d1x) / den
if (t <= 1e-6 || t >= 1 - 1e-6) return null // 마루 내부만
if (u <= 1e-6 || u >= 1 + 1e-3) return null // 힙 끝점 닿음 허용
return { x: rp.x + t * d1x, y: rp.y + t * d1y }
}
// 힙의 먼 끝(코너=startPoint 반대쪽 endPoint)을 교차점으로 절삭 + 라벨 갱신
const trimHipFarEnd = (hip, cross) => {
hip.set({ x2: cross.x, y2: cross.y })
hip.endPoint = { x: cross.x, y: cross.y }
const nsz = calcLinePlaneSize({ x1: hip.x1, y1: hip.y1, x2: cross.x, y2: cross.y })
hip.attributes = { ...hip.attributes, planeSize: nsz, actualSize: nsz }
hip.setLength?.()
hip.setCoords?.()
hip.addLengthText?.()
}
const ridges = (roof.innerLines || []).filter((l) => l && l.visible !== false && l.name === LINE_TYPE.SUBLINE.RIDGE)
const allEnds = []
for (const l of (roof.innerLines || []).filter(
(l) =>
l &&
l.visible !== false &&
(l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE || l.name === LINE_TYPE.SUBLINE.VALLEY),
)) {
allEnds.push({ x: l.x1, y: l.y1, line: l })
allEnds.push({ x: l.x2, y: l.y2, line: l })
}
const touched = []
for (const rg of ridges) {
const endsOf = [
{ p: { x: rg.x1, y: rg.y1 }, q: { x: rg.x2, y: rg.y2 }, set: (pt) => rg.set({ x1: pt.x, y1: pt.y }) },
{ p: { x: rg.x2, y: rg.y2 }, q: { x: rg.x1, y: rg.y1 }, set: (pt) => rg.set({ x2: pt.x, y2: pt.y }) },
]
for (const { p, q, set } of endsOf) {
const onBoundary = rpts.length >= 3 && minEdgeDist(p) <= TOL
const shared = allEnds.some((e) => e.line !== rg && Math.hypot(e.x - p.x, e.y - p.y) < TOL)
if (onBoundary || shared) continue // anchor 있음 → 그대로
// (a) 가로지르는 힙 탐색 → 절삭
let trimPt = null
let trimD = Infinity
for (const h of newHips) {
const ip = segIntTrim(p, q, { x: h.x1, y: h.y1 }, { x: h.x2, y: h.y2 })
if (!ip) continue
const d = Math.hypot(ip.x - p.x, ip.y - p.y)
if (d < trimD) {
trimD = d
trimPt = ip
}
}
if (trimPt) {
set(trimPt)
touched.push({ act: 'trim', ln: rg.lineName || '-', to: { x: Math.round(trimPt.x), y: Math.round(trimPt.y) } })
continue
}
// (b) 확장 — q→p 방향으로 첫 교차(roofLine 변 + 다른 inner line, 자기 제외)까지
const dx = p.x - q.x
const dy = p.y - q.y
const mag = Math.hypot(dx, dy) || 1
const ux = dx / mag
const uy = dy / mag
const castHit = (A, B) => {
const ip = segInt({ x: q.x, y: q.y }, { x: q.x + ux * 1e5, y: q.y + uy * 1e5 }, A, B)
if (!ip) return Infinity
const t = (ip.x - p.x) * ux + (ip.y - p.y) * uy
return t > 1e-3 ? t : Infinity
}
let best = Infinity
let hit = null
let hitLine = null
for (let i = 0; i < rpts.length; i++) {
const A = rpts[i]
const B = rpts[(i + 1) % rpts.length]
const t = castHit(A, B)
if (t < best) {
best = t
hit = { x: q.x + ux * (mag + t), y: q.y + uy * (mag + t) }
hitLine = null // roofLine 변
}
}
for (const il of roof.innerLines || []) {
if (!il || il === rg || il.visible === false) continue
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
const t = castHit({ x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
if (t < best) {
best = t
hit = { x: q.x + ux * (mag + t), y: q.y + uy * (mag + t) }
hitLine = il
}
}
if (hit) {
set(hit)
touched.push({ act: 'extend', ln: rg.lineName || '-', to: { x: Math.round(hit.x), y: Math.round(hit.y) } })
// 확장한 마루가 힙 몸통에 닿으면 → 그 힙의 먼 끝(코너 반대쪽)을 교차점으로 절삭(힙은 마루를 넘지 못함)
if (hitLine && hitLine.name === LINE_TYPE.SUBLINE.HIP) {
trimHipFarEnd(hitLine, hit)
touched.push({ act: 'trim-hip', ln: hitLine.lineName || '-', to: { x: Math.round(hit.x), y: Math.round(hit.y) } })
}
}
}
}
return touched
}
// 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),
// 그 마루를 대칭축으로 반대편 면에 반사 힙 1개 생성(R7: 마루+hip 교점 반사).
// ★ 마루를 *확장*해서 만난 힙(hit 가 roofLine 이던 resA)은 Y 아님 → 반사 안 함(사용자 확정 2026-06-16).
const reflectOps = []
for (const res of [resA, resB]) {
if (diamond) break // 마름모를 만든 경우 힙을 인접 처마 힙 길이로 재설정했으므로 반사 불필요
if (!res || !res.hip || res.hitKind !== LINE_TYPE.SUBLINE.RIDGE || !res.hitLine) continue
const M = { x: res.hit.x, y: res.hit.y }
const rg = res.hitLine
const rlen = Math.hypot(rg.x2 - rg.x1, rg.y2 - rg.y1) || 1
const rx = (rg.x2 - rg.x1) / rlen
const ry = (rg.y2 - rg.y1) / rlen
// 반사 방향 = reflect(역입사) 마루축 기준. w=역입사(코너→M 반대), v_ref=2(w·r̂)r̂ - w
const wx = -res.dir.x
const wy = -res.dir.y
const dot = wx * rx + wy * ry
const refDir = { x: 2 * dot * rx - wx, y: 2 * dot * ry - wy }
const drawn = drawHipRay(M, refDir)
if (drawn) {
reflectOps.push({ from: { x: Math.round(M.x), y: Math.round(M.y) }, to: { x: Math.round(drawn.hit.x), y: Math.round(drawn.hit.y) } })
}
}
removeKerabHalfLabels(target.id)
hideOriginalLengthText(target.id, false)
canvas.renderAll()
runKerabRuleCheck(roof, 'valley-eaves-rule1', kerabRevertBeforeSnap)
logger.log(
'[KERAB-VALLEY-EAVES-RULE1] applied ' +
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
}
}
// [2240 KERAB-ATTR-ONLY 2026-05-19] ridge-at-mid 없음 → forward 가 attribute-only 였음 → 동일 대칭으로 속성만 토글
target.set({ attributes })
applyKerabAttributeOnlyPattern()
surgicalAfter()
logger.log('[KERAB-ATTR-ONLY] applied (revert) ' + JSON.stringify({ c1, c2 }))
return
}
}
}
target.set({
attributes,
})
const roofBases = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF && !obj.isFixed)
// moveLine 속성 및 이동된 baseLines 좌표 보존
const savedMoveProps = {}
const savedBaseLineCoords = {}
roofBases.forEach((roof) => {
if (roof.moveFlowLine || roof.moveUpDown) {
const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL && obj.attributes?.roofId === roof.id)
savedMoveProps[roof.id] = {
moveFlowLine: roof.moveFlowLine,
moveUpDown: roof.moveUpDown,
moveDirect: roof.moveDirect,
moveSelectLine: roof.moveSelectLine,
movePosition: roof.movePosition,
}
if (wall?.baseLines) {
savedBaseLineCoords[roof.id] = wall.baseLines.map((bl) => ({
x1: bl.x1, y1: bl.y1, x2: bl.x2, y2: bl.y2,
startPoint: bl.startPoint ? { ...bl.startPoint } : undefined,
endPoint: bl.endPoint ? { ...bl.endPoint } : undefined,
}))
}
}
roof.innerLines.forEach((line) => {
removeLine(line)
})
canvas.remove(roof)
})
const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL)
const removeTargets = canvas.getObjects().filter((obj) => obj.name === 'pitchText' || obj.name === 'lengthText')
removeTargets.forEach((obj) => {
canvas.remove(obj)
})
wallLines.forEach((wallLine) => {
addPitchTextsByOuterLines()
const roof = drawRoofPolygon(wallLine)
// moveLine 속성 복원
const saved = savedMoveProps[roof.id]
if (saved) {
// 이동된 baseLines 좌표 복원 (drawRoofPolygon이 리셋한 것을 되돌림)
const savedBL = savedBaseLineCoords[roof.id]
if (savedBL && roof.wall?.baseLines) {
roof.wall.baseLines.forEach((bl, i) => {
if (!savedBL[i]) return
bl.set({
x1: savedBL[i].x1, y1: savedBL[i].y1,
x2: savedBL[i].x2, y2: savedBL[i].y2,
startPoint: savedBL[i].startPoint,
endPoint: savedBL[i].endPoint,
})
})
}
// drawHelpLine 중에는 moveLine 미적용 (baseLines에 이미 반영됨)
// drawHelpLine 후 moveLine 속성 복원
canvas?.renderAll()
roof.drawHelpLine(settingModalFirstOptions)
Object.assign(roof, saved)
} else {
canvas?.renderAll()
roof.drawHelpLine(settingModalFirstOptions)
}
})
wallLines.forEach((wallLine) => {
convertPolygonToLines(wallLine)
})
canvas.renderAll()
addCanvasMouseEventListener('mouse:over', mouseOverEvent)
addCanvasMouseEventListener('mouse:down', mouseDownEvent)
}
// polygon의 lines를 이용해 line으로 변경하기
const convertPolygonToLines = (polygon) => {
polygon.set({ visible: false })
polygon.lines.forEach((line) => {
line.set({ visible: true })
line.set({ selectable: true })
line.set({ strokeWidth: 5 })
line.set({ parent: polygon })
line.bringToFront()
})
// canvas objects에서 polygon.lines를 제외한 다른 line의 selectable을 false로 변경
canvas
.getObjects()
.filter((obj) => obj.name !== 'outerLine' && obj.type === 'QLine')
.forEach((obj) => {
obj.set({ selectable: false })
})
canvas?.renderAll()
}
// 다시 다각형으로 변경하기
const convertLinesToPolygon = (polygon) => {
polygon.set({ visible: true })
polygon.lines.forEach((line) => {
line.set({ visible: false })
// line.set({ selectable: false })
})
canvas?.renderAll()
}
// [2240 KERAB-OFFSET-SURGICAL 2026-05-27] 본체는 `@/util/kerab-offset-surgical` 로 분리.
// 고객 회귀 확인을 위한 토글 — ENABLE_KERAB_OFFSET_SURGICAL false 면 즉시 false 반환.
const applyTargetOffsetSurgical = (target, newOffset, options) => {
if (!ENABLE_KERAB_OFFSET_SURGICAL) return false
return applyKerabOffsetSurgical(canvas, target, newOffset, options)
}
// [2240 KERAB-SINGLE-RIDGE 2026-05-19] 헬퍼 — 양 끝점 hip 페어 식별 및 ridge 단일화 적용
const KERAB_TOL = 0.5
const isSamePt = (a, b) => Math.abs(a.x - b.x) < KERAB_TOL && Math.abs(a.y - b.y) < KERAB_TOL
// outerLine 끝점(벽 좌표)을 가장 가까운 roof.points 코너(offset 적용 좌표)로 스냅
const nearestRoofPoint = (roof, point) => {
if (!Array.isArray(roof.points) || roof.points.length === 0) return null
let best = roof.points[0]
let bestD = (best.x - point.x) ** 2 + (best.y - point.y) ** 2
for (let i = 1; i < roof.points.length; i++) {
const p = roof.points[i]
const d = (p.x - point.x) ** 2 + (p.y - point.y) ** 2
if (d < bestD) {
bestD = d
best = p
}
}
return { x: best.x, y: best.y }
}
const findHipsAtPoint = (roof, point) => {
return roof.innerLines.filter((il) => {
if (!il || il.name !== LINE_TYPE.SUBLINE.HIP) return false
return isSamePt({ x: il.x1, y: il.y1 }, point) || isSamePt({ x: il.x2, y: il.y2 }, point)
})
}
const otherEndOfHip = (hip, corner) => {
const a = { x: hip.x1, y: hip.y1 }
const b = { x: hip.x2, y: hip.y2 }
return isSamePt(a, corner) ? b : a
}
const findHipPairWithSharedApex = (hipsAtP1, p1, hipsAtP2, p2) => {
for (const hA of hipsAtP1) {
const apexA = otherEndOfHip(hA, p1)
for (const hB of hipsAtP2) {
if (hB === hA) continue
const apexB = otherEndOfHip(hB, p2)
if (isSamePt(apexA, apexB)) return { hA, hB, apex: apexA }
}
}
return null
}
// [2240 KERAB-PARALLEL-HIPS 2026-05-19] 양 끝점 hip 페어가 평행한지 검사.
// 두 hip 방향벡터의 정규화 cross product 절대값 < 0.01 이면 평행 → 무한확장해도 교점 없음 → apex 없음.
// 이 케이스는 hip 만 제거하고 중앙선(kLine) 은 생성하지 않는다.
const findParallelHipPair = (hipsAtP1, p1, hipsAtP2, p2) => {
for (const hA of hipsAtP1) {
const oA = otherEndOfHip(hA, p1)
const dAx = oA.x - p1.x
const dAy = oA.y - p1.y
const magA = Math.hypot(dAx, dAy) || 1
for (const hB of hipsAtP2) {
if (hB === hA) continue
const oB = otherEndOfHip(hB, p2)
const dBx = oB.x - p2.x
const dBy = oB.y - p2.y
const magB = Math.hypot(dBx, dBy) || 1
const cross = (dAx * dBy - dAy * dBx) / (magA * magB)
if (Math.abs(cross) < 0.01) return { hA, hB }
}
}
return null
}
// [2240 KERAB-EXTENDED-APEX 2026-05-20] c1·c2 의 outer hip(H1, H2) 을 무한 확장한 교점을 apex 로 채택.
// 사용자 모델: H1, H2 가 (연장하면) 만나는 점이 apex. 처마 중점 수선 위에 있지 않아도 채택.
// (dotEaves 제약 제거 — 2026-05-20)
// 채택 조건: ① 두 hip 이 평행이 아님 ② 교점이 두 hip 끝점 너머(t,s>1) 에 위치
const findHipPairWithExtendedApex = (hipsAtP1, p1, hipsAtP2, p2) => {
for (const hA of hipsAtP1) {
const oA = otherEndOfHip(hA, p1)
// hip 자체를 무한 직선으로 본 교점 계산. 직선 파라미터: p1 + t*(oA - p1). t>1 이면 oA 너머(폴리곤 내부 방향).
const dAx = oA.x - p1.x
const dAy = oA.y - p1.y
for (const hB of hipsAtP2) {
if (hB === hA) continue
const oB = otherEndOfHip(hB, p2)
const dBx = oB.x - p2.x
const dBy = oB.y - p2.y
const denom = dAx * dBy - dAy * dBx
if (Math.abs(denom) < 1e-6) continue // 평행 → 영원히 못 만남
const px = p2.x - p1.x
const py = p2.y - p1.y
const t = (px * dBy - py * dBx) / denom
const s = (px * dAy - py * dAx) / denom
// [KERAB-EXTENDED-APEX-TS 2026-05-20] apex 가 hip 의 진행방향(양수 쪽) 에 있을 것만 요구.
// t,s > 1 (양쪽 모두 끝점 너머) 까지 강제하지 않음 — 한 쪽 hip 가 충분히 길어 apex 가
// 자기 segment 안쪽에 있는 케이스도 「H1, H2 가 만났다」로 인정 (사용자 모델, 2026-05-20).
if (t <= 1e-3 || s <= 1e-3) continue
const apex = { x: p1.x + t * dAx, y: p1.y + t * dAy }
return { hA, hB, apex }
}
}
return null
}
// [2240 KERAB-JUNCTION-EXTENDED 2026-05-19] H-7/H-1 의 junction 너머 inner hip(H-4/H-2) 을 무한직선으로
// 확장한 교점이 처마 중점 수선 위에 있으면 apex 채택. H-7/H-1 직접 확장이 가운데가 아닌
// L-노치 등 복합 형상에서 사용. apex 검증 후 outer hip 제거 + inner hip 확장 + ridge.
const findHipPairViaJunction = (roof, c1, c2) => {
const hipsAtC1 = findHipsAtPoint(roof, c1)
const hipsAtC2 = findHipsAtPoint(roof, c2)
logger.log('[JUNCTION-DIAG] c1/c2 hips', { c1, c2, c1n: hipsAtC1.length, c2n: hipsAtC2.length })
if (hipsAtC1.length === 0 || hipsAtC2.length === 0) return null
const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 }
const eVx = c2.x - c1.x
const eVy = c2.y - c1.y
const eMag = Math.hypot(eVx, eVy) || 1
for (const outer1 of hipsAtC1) {
const junction1 = otherEndOfHip(outer1, c1)
const innersAtJ1 = findHipsAtPoint(roof, junction1).filter((h) => h !== outer1)
logger.log('[JUNCTION-DIAG] j1', { junction1, innersAtJ1n: innersAtJ1.length })
if (innersAtJ1.length === 0) continue
for (const outer2 of hipsAtC2) {
if (outer2 === outer1) continue
const junction2 = otherEndOfHip(outer2, c2)
if (isSamePt(junction1, junction2)) {
logger.log('[JUNCTION-DIAG] junction shared → skip (shared-apex case)')
continue
}
const innersAtJ2 = findHipsAtPoint(roof, junction2).filter((h) => h !== outer2)
logger.log('[JUNCTION-DIAG] j2', { junction2, innersAtJ2n: innersAtJ2.length })
if (innersAtJ2.length === 0) continue
for (const inner1 of innersAtJ1) {
const innerFar1 = otherEndOfHip(inner1, junction1)
const dAx = innerFar1.x - junction1.x
const dAy = innerFar1.y - junction1.y
for (const inner2 of innersAtJ2) {
if (inner2 === inner1) continue
const innerFar2 = otherEndOfHip(inner2, junction2)
const dBx = innerFar2.x - junction2.x
const dBy = innerFar2.y - junction2.y
const denom = dAx * dBy - dAy * dBx
if (Math.abs(denom) < 1e-6) {
logger.log('[JUNCTION-DIAG] parallel inner hips')
continue
}
const px = junction2.x - junction1.x
const py = junction2.y - junction1.y
const t = (px * dBy - py * dBx) / denom
const apex = { x: junction1.x + t * dAx, y: junction1.y + t * dAy }
const dotEaves = ((apex.x - mid.x) * eVx + (apex.y - mid.y) * eVy) / eMag
logger.log('[JUNCTION-DIAG] candidate', { apex, dotEaves, threshold: KERAB_TOL })
if (Math.abs(dotEaves) > KERAB_TOL) continue
logger.log('[JUNCTION-DIAG] MATCH')
return { outer1, outer2, inner1, inner2, junction1, junction2, apex, innerFar1, innerFar2 }
}
}
}
}
return null
}
// 재사용 가능한 검증 헬퍼 — ridge 의 한 끝점이 주어진 line 의 중점과 만나는지
const midpointOfLine = (line) => ({ x: (line.x1 + line.x2) / 2, y: (line.y1 + line.y2) / 2 })
const ridgeMeetsMidpointOf = (ridge, line) => {
const mid = midpointOfLine(line)
return isSamePt({ x: ridge.x1, y: ridge.y1 }, mid) || isSamePt({ x: ridge.x2, y: ridge.y2 }, mid)
}
// [2240 KERAB-CENTRAL-RIDGE 2026-05-19] 특정 좌표 점에서 끝나는 ridge 1개 찾기.
// 케라바 패턴 ridge(kerabPatternRidge) 는 제외.
const findRidgeEndingAt = (roof, point) => {
for (const il of roof.innerLines) {
if (!il || il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
if (il.lineName === 'kerabPatternRidge') continue
if (isSamePt({ x: il.x1, y: il.y1 }, point)) return il
if (isSamePt({ x: il.x2, y: il.y2 }, point)) return il
}
return null
}
// [2240 KERAB-CENTRAL-RIDGE 2026-05-19] 점이 선분 위에 있는지 (tol 안 거리).
const isPointOnSegment = (p, seg, tol = KERAB_TOL) => {
const dx = seg.x2 - seg.x1
const dy = seg.y2 - seg.y1
const len2 = dx * dx + dy * dy
if (len2 < 1e-9) return Math.hypot(p.x - seg.x1, p.y - seg.y1) <= tol
let t = ((p.x - seg.x1) * dx + (p.y - seg.y1) * dy) / len2
t = Math.max(0, Math.min(1, t))
const px = seg.x1 + t * dx
const py = seg.y1 + t * dy
return Math.hypot(p.x - px, p.y - py) <= tol
}
// [2240 KERAB-SIMPLE 2026-05-20] target 끝점에서 가장 가까운 끝점을 가진 hip 검색.
// 처마 끝점(벽 좌표) vs hip 끝점(offset 좌표) 의 좌표계 차이(최대 ~50 단위) 는 거리 비교로 흡수.
// nearestRoofPoint 안 거침 — 처마가 짧을 때(<500mm) 인접 코너로 잘못 스냅되는 문제 회피.
// 동일 위치 겹치는 hip 도 좌표가 미세하게 달라 거리 최소값으로 구분 가능.
// [KERAB-SIMPLE 2026-05-20] target 끝점 pt 에 대응되는 hip 의 "near" 끝점은
// 코너의 대각선 offset 위치 (dx, dy 둘 다 0 이 아님). 축정렬(dx=0 또는 dy=0) 후보는
// 인접 L-노치 코너의 offset 일 가능성이 높아 제외. 대각 후보 없으면 폴백으로 최근접.
const AXIS_EPS = 1.0
const findHipAtEndpoint = (roof, pt) => {
let bestDiag = null
let bestDiagD = Infinity
let bestAny = null
let bestAnyD = Infinity
for (const il of roof.innerLines || []) {
if (!il || il.attributes?.type !== LINE_TYPE.SUBLINE.HIP) continue
const a = { x: il.x1, y: il.y1 }
const b = { x: il.x2, y: il.y2 }
const dA = Math.hypot(a.x - pt.x, a.y - pt.y)
const dB = Math.hypot(b.x - pt.x, b.y - pt.y)
const aDiag = Math.abs(a.x - pt.x) > AXIS_EPS && Math.abs(a.y - pt.y) > AXIS_EPS
const bDiag = Math.abs(b.x - pt.x) > AXIS_EPS && Math.abs(b.y - pt.y) > AXIS_EPS
if (dA < bestAnyD) { bestAnyD = dA; bestAny = { hip: il, near: a, far: b, dist: dA } }
if (dB < bestAnyD) { bestAnyD = dB; bestAny = { hip: il, near: b, far: a, dist: dB } }
if (aDiag && dA < bestDiagD) { bestDiagD = dA; bestDiag = { hip: il, near: a, far: b, dist: dA } }
if (bDiag && dB < bestDiagD) { bestDiagD = dB; bestDiag = { hip: il, near: b, far: a, dist: dB } }
}
return bestDiag || bestAny
}
// 두 직선(p1-p2, p3-p4)의 무한확장 교점. 평행이면 null.
const lineLineIntersection = (p1, p2, p3, p4) => {
const d = (p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x)
if (Math.abs(d) < 1e-6) return null
const t = ((p1.x - p3.x) * (p3.y - p4.y) - (p1.y - p3.y) * (p3.x - p4.x)) / d
return { x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y) }
}
// [KERAB-SIMPLE-OTHERHIP 2026-05-20] pt 위치에서 excludeHip 을 제외한 또 다른 hip 검색.
// RG-1 너머에서 같은 분기점(h*.far) 에 붙어있는 hip — 확장 방향을 정의함.
const findOtherHipAtPoint = (roof, pt, excludeHip, tol = 1.0) => {
let best = null
let bestD = Infinity
for (const il of roof.innerLines || []) {
if (!il || il === excludeHip) continue
if (il.attributes?.type !== LINE_TYPE.SUBLINE.HIP) continue
const a = { x: il.x1, y: il.y1 }
const b = { x: il.x2, y: il.y2 }
const dA = Math.hypot(a.x - pt.x, a.y - pt.y)
const dB = Math.hypot(b.x - pt.x, b.y - pt.y)
if (dA <= tol && dA < bestD) {
bestD = dA
best = { hip: il, near: a, far: b }
}
if (dB <= tol && dB < bestD) {
bestD = dB
best = { hip: il, near: b, far: a }
}
}
return best
}
// [KERAB-EXTENDER 2026-05-21] 사용자 전제 3: pt(접점) 에서 polygon 경계 라인을 제외한
// inner line(hip 또는 ridge) 을 extender 로 검색. extender 의 방향(near→far)이 확장 방향.
// 반환: { line, near, far, isHip } 또는 null.
const findExtenderAtPoint = (roof, pt, excludedLines = [], tol = 1.0) => {
const excludeSet = new Set(excludedLines)
let best = null
let bestD = Infinity
for (const il of roof.innerLines || []) {
if (!il || excludeSet.has(il)) continue
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
const a = { x: il.x1, y: il.y1 }
const b = { x: il.x2, y: il.y2 }
const dA = Math.hypot(a.x - pt.x, a.y - pt.y)
const dB = Math.hypot(b.x - pt.x, b.y - pt.y)
const isHip = il.name === LINE_TYPE.SUBLINE.HIP
if (dA <= tol && dA < bestD) {
bestD = dA
best = { line: il, near: a, far: b, isHip }
}
if (dB <= tol && dB < bestD) {
bestD = dB
best = { line: il, near: b, far: a, isHip }
}
}
return best
}
// [KERAB-SIMPLE-MIDRIDGE 2026-05-20] 두 hip 의 far 끝점 p1·p2 를 잇는 ridge 검색.
// 확장 apex 케이스에서 hip 사이를 잇던 RG-1 같은 마루를 제거 대상으로 식별.
const findRidgeBetweenHipFars = (roof, p1, p2, tol = KERAB_TOL) => {
for (const il of roof.innerLines || []) {
if (!il || il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
const a = { x: il.x1, y: il.y1 }
const b = { x: il.x2, y: il.y2 }
const aP1 = Math.hypot(a.x - p1.x, a.y - p1.y) <= tol
const aP2 = Math.hypot(a.x - p2.x, a.y - p2.y) <= tol
const bP1 = Math.hypot(b.x - p1.x, b.y - p1.y) <= tol
const bP2 = Math.hypot(b.x - p2.x, b.y - p2.y) <= tol
if ((aP1 && bP2) || (aP2 && bP1)) return il
}
return null
}
// [KERAB-POLYGON-BFS 2026-05-21] 사용자 전제 2: 선택한 외곽선이 속한 내부 다각형의
// inner line 경계를 BFS 로 추적. p1=h1.far → p2=h2.far 사이의 hip/ridge 체인을 모두 반환.
// excludeLines=[h1, h2] 로 시작점 hip 자체를 통과하지 않게 하고,
// 중간 junction 이 있는 비대칭 케이스(Ridge→junction→otherHip 등)도 한 번에 식별.
// 반환: [{ line, from, to }, ...] 또는 path 가 없으면 null.
const traceInnerPolygonPath = (roof, p1, p2, excludeLines = [], tol = KERAB_TOL) => {
const same = (a, b) => Math.hypot(a.x - b.x, a.y - b.y) <= tol
if (same(p1, p2)) return []
const excludeSet = new Set(excludeLines)
const ptKey = (p) => `${Math.round(p.x * 10) / 10}_${Math.round(p.y * 10) / 10}`
const visited = new Set([ptKey(p1)])
const queue = [{ point: p1, path: [] }]
while (queue.length > 0) {
const { point, path } = queue.shift()
for (const il of roof.innerLines || []) {
if (!il || excludeSet.has(il)) continue
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
const a = { x: il.x1, y: il.y1 }
const b = { x: il.x2, y: il.y2 }
let nextPoint = null
if (same(a, point)) nextPoint = b
else if (same(b, point)) nextPoint = a
if (!nextPoint) continue
if (same(nextPoint, p2)) {
return [...path, { line: il, from: point, to: nextPoint }]
}
const key = ptKey(nextPoint)
if (visited.has(key)) continue
visited.add(key)
queue.push({ point: nextPoint, path: [...path, { line: il, from: point, to: nextPoint }] })
}
}
return null
}
// apex 좌표를 끝점 또는 segment 위로 통과하는 ridge 검색 (RG-1 검증). tol 은 좌표 drift 흡수용.
const findRidgePassingThrough = (roof, pt, tol = KERAB_TOL) => {
for (const il of roof.innerLines || []) {
if (!il || il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
const a = { x: il.x1, y: il.y1 }
const b = { x: il.x2, y: il.y2 }
if (Math.hypot(a.x - pt.x, a.y - pt.y) <= tol) return il
if (Math.hypot(b.x - pt.x, b.y - pt.y) <= tol) return il
if (isPointOnSegment(pt, { x1: a.x, y1: a.y, x2: b.x, y2: b.y }, tol)) return il
}
return null
}
// [2240 KERAB-CENTRAL-RIDGE 2026-05-19] ridge 의 양 끝점이 ext1·ext2 각각의 segment 위에 있는지.
// RG-1 은 H-4/H-2 확장선 사이에 놓인 ridge — 두 확장선이 RG-1 의 양 끝점을 각각 통과한다.
const ridgeEndpointsOnBothExtensions = (ridge, ext1, ext2) => {
const a = { x: ridge.x1, y: ridge.y1 }
const b = { x: ridge.x2, y: ridge.y2 }
const seg1 = { x1: ext1.x1, y1: ext1.y1, x2: ext1.x2, y2: ext1.y2 }
const seg2 = { x1: ext2.x1, y1: ext2.y1, x2: ext2.x2, y2: ext2.y2 }
const aOn1 = isPointOnSegment(a, seg1)
const aOn2 = isPointOnSegment(a, seg2)
const bOn1 = isPointOnSegment(b, seg1)
const bOn2 = isPointOnSegment(b, seg2)
return (aOn1 && bOn2) || (aOn2 && bOn1)
}
// 케라바 중점→apex ridge 1개 찾기 (역방향 패턴의 전제)
const findRidgeAtMidpoint = (roof, c1, c2) => {
const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 }
for (const il of roof.innerLines) {
if (!il || il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
const a = { x: il.x1, y: il.y1 }
const b = { x: il.x2, y: il.y2 }
if (isSamePt(a, mid)) return { ridge: il, apex: b }
if (isSamePt(b, mid)) return { ridge: il, apex: a }
}
return null
}
// 케라바→처마 역변환 패턴: ridge 1개 제거 + hip 복원
// - junctionExtended: inner hip 좌표 원복 + outer hip 2개 신규 복원 (snapshot 기반)
// - 일반 single-ridge (__originalHips): outer hip 2개를 스냅샷 좌표 그대로 복원
// - 둘 다 없으면 폴백: c1↔apex, c2↔apex 신규 hip (이전 패턴 호환)
const applyKerabRevertPattern = (roof, target, c1, c2, ridgeAtMid) => {
const apex = ridgeAtMid.apex
// [KERAB-VALLEY-EXT 2026-05-27] forward 에서 추가됐던 처마확장(kerabPatternValleyExt) 제거.
// __targetId === target.id 매칭으로 해당 target 의 처마확장만 정확히 정리. 모든 패턴 분기 공통.
// roofBase 확장은 roof.innerLines 에 있고 wallBase 확장은 canvas 에만 있으므로 canvas 전체 스캔.
// [KERAB-VALLEY-OVERLAP 2026-05-28] wallBase 측은 lineName='kerabValleyOverlapLine' (본체 + 90도 보조선 2개).
// b polygon 의 lines[] 에는 apply() 단계에서 들어가므로, 여기서는 canvas 객체와 roof.innerLines 만 정리.
// b 의 lines[] 에서 제거하는 책임은 apply() 다음 사이클에서 자동 (canvas 에서 사라지므로).
const valleyExtsToRemove = (canvas.getObjects() || []).filter(
(il) => il && (il.lineName === 'kerabPatternValleyExt' || il.lineName === 'kerabValleyOverlapLine') && il.__targetId === target.id,
)
for (const v of valleyExtsToRemove) {
removeLine(v)
roof.innerLines = roof.innerLines.filter((il) => il !== v)
}
if (valleyExtsToRemove.length) {
logger.log('[KERAB-VALLEY-EXT] revert removed ' + valleyExtsToRemove.length)
}
// [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
// [VALLEY-EXT-TRIM-ENDPOINT-GUARD 2026-06-10] split 전용 레코드는 좌표/visible 변경이
// 없으므로 복원할 게 없다. split 세그먼트는 valleyExt(__targetId) 제거에서 정리됨.
if (rec.splitOnly) continue
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
}
// [KERAB-REVERT-MARK 2026-06-05] 룰 (b): hip 본체는 wallLine 끝점을 지나고 outer 끝점은 새 roofLine 코너까지 확장.
// buildHipFromSnapshot 시점에는 roof.points 가 아직 옛 출폭 → corner 좌표를 미리 정할 수 없음.
// 여기서는 outer 끝점이 어느 쪽(which=1/2, side=A/B)인지만 마크하고, 실제 좌표 snap 은 surgicalAfter() 안에서
// roof.points 가 새 출폭으로 갱신된 뒤 nearestRoofPoint 로 다시 잡는다.
// Corner-side 식별: hip 의 두 끝점 중 wallLine 양 끝점(wA/wB)에 더 가까운 쪽이 corner-side.
// apex-side 는 roof 안쪽 깊이 있어 두 wallLine 끝점에서 모두 멀다.
const markRevertOuter = (line) => {
if (!line) return
const wA = { x: target.x1, y: target.y1 }
const wB = { x: target.x2, y: target.y2 }
const e1 = { x: line.x1, y: line.y1 }
const e2 = { x: line.x2, y: line.y2 }
const dE1A = Math.hypot(e1.x - wA.x, e1.y - wA.y)
const dE1B = Math.hypot(e1.x - wB.x, e1.y - wB.y)
const dE2A = Math.hypot(e2.x - wA.x, e2.y - wA.y)
const dE2B = Math.hypot(e2.x - wB.x, e2.y - wB.y)
const minE1 = Math.min(dE1A, dE1B)
const minE2 = Math.min(dE2A, dE2B)
const which = minE1 <= minE2 ? 1 : 2
const side = which === 1 ? (dE1A <= dE1B ? 'A' : 'B') : dE2A <= dE2B ? 'A' : 'B'
line.__kerabRevertOuterWhich = which
line.__kerabRevertOuterSide = side
logger.log(
'[KERAB-REVERT-MARK] ' +
JSON.stringify({ lineName: line.lineName, which, side, minE1, minE2 }),
)
}
const buildHipFromSnapshot = (snap) => {
const pts = [snap.x1, snap.y1, snap.x2, snap.y2]
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
const hip = new QLine(pts, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 4,
name: LINE_TYPE.SUBLINE.HIP,
textMode: roof.textMode,
attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...snap.attributes },
})
if (snap.lineName) hip.lineName = snap.lineName
if (snap.__extended) hip.__extended = snap.__extended
markRevertOuter(hip)
return hip
}
const buildHipToApex = (cornerPt) => {
const pts = [cornerPt.x, cornerPt.y, apex.x, apex.y]
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
const hip = new QLine(pts, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: LINE_TYPE.SUBLINE.HIP,
textMode: roof.textMode,
attributes: { roofId: roof.id, planeSize: sz, actualSize: sz },
})
hip.lineName = 'kerabPatternHip'
return hip
}
const restoreInnerHipFromSnapshot = (hip, snap) => {
hip.set({ x1: snap.x1, y1: snap.y1, x2: snap.x2, y2: snap.y2 })
const ns = calcLinePlaneSize({ x1: snap.x1, y1: snap.y1, x2: snap.x2, y2: snap.y2 })
hip.attributes = { ...hip.attributes, ...snap.attributes, planeSize: ns, actualSize: ns }
}
// [2240 KERAB-KLINE-ONLY 2026-05-20] kLine 만 그렸던 패턴: ridge 1개 제거 + 제거됐던 hip 복원.
// forward 가 ridge add + hip 2개 remove 했으므로 revert 도 동일하게 되돌림.
if (ridgeAtMid.ridge.__patternKind === 'kLineOnly') {
const removedHips = Array.isArray(ridgeAtMid.ridge.__removedHipsSnapshot) ? ridgeAtMid.ridge.__removedHipsSnapshot : []
const removedRidges = Array.isArray(ridgeAtMid.ridge.__removedRidgesSnapshot) ? ridgeAtMid.ridge.__removedRidgesSnapshot : []
const extHipsCreated = Array.isArray(ridgeAtMid.ridge.__extHipsCreated) ? ridgeAtMid.ridge.__extHipsCreated : []
// [KERAB-SIMPLE-EXTHIP 2026-05-20] forward 에서 추가됐던 ext hip 제거.
for (const eh of extHipsCreated) {
if (!eh) continue
removeLine(eh)
roof.innerLines = roof.innerLines.filter((il) => il !== eh)
}
// [KERAB-EXTHIP-COORD-CLEAN 2026-06-15] __extHipsCreated 는 객체 참조라, 양쪽 박공→한 변씩 처마
// 복원 시퀀스에서 중간 인접변 작업이 ext-hip 을 파괴·재생성하면 죽은 객체를 가리켜 위 루프가
// no-op 이 된다 → 재생성된 ext-hip 이 orphan 으로 잔류(apex 에서 끝나는 1881 V, "라인은 roofLine
// 까지" 규칙 위반). 이 kLine 의 apex(=roofLine 반대쪽 안쪽 끝점) 를 공유하는 ext 라인을 좌표로
// 한 번 더 제거해 재생성돼도 잡는다. orphan 없으면 0개 제거 → 정상 케이스 무영향.
{
const _r = ridgeAtMid.ridge
const _tMid = { x: (target.x1 + target.x2) / 2, y: (target.y1 + target.y2) / 2 }
const _apexPt =
Math.hypot(_r.x1 - _tMid.x, _r.y1 - _tMid.y) >= Math.hypot(_r.x2 - _tMid.x, _r.y2 - _tMid.y)
? { x: _r.x1, y: _r.y1 }
: { x: _r.x2, y: _r.y2 }
const _EXT_TOL = 1.0
const _orphans = roof.innerLines.filter(
(il) =>
il &&
(il.lineName === 'kerabPatternExtHip' || il.lineName === 'kerabPatternExtRidge') &&
(Math.hypot(il.x1 - _apexPt.x, il.y1 - _apexPt.y) < _EXT_TOL || Math.hypot(il.x2 - _apexPt.x, il.y2 - _apexPt.y) < _EXT_TOL),
)
for (const eh of _orphans) {
removeLine(eh)
roof.innerLines = roof.innerLines.filter((il) => il !== eh)
}
if (_orphans.length) logger.log('[KERAB-EXTHIP-COORD-CLEAN] removed ' + JSON.stringify({ apex: _apexPt, count: _orphans.length }))
}
removeLine(ridgeAtMid.ridge)
roof.innerLines = roof.innerLines.filter((il) => il !== ridgeAtMid.ridge)
for (const snap of removedHips) {
const sz = calcLinePlaneSize({ x1: snap.x1, y1: snap.y1, x2: snap.x2, y2: snap.y2 })
const hip = new QLine([snap.x1, snap.y1, snap.x2, snap.y2], {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: LINE_TYPE.SUBLINE.HIP,
textMode: roof.textMode,
attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...(snap.attributes || {}) },
})
if (snap.lineName) hip.lineName = snap.lineName
if (snap.__extended) hip.__extended = snap.__extended
markRevertOuter(hip)
canvas.add(hip)
hip.bringToFront()
roof.innerLines.push(hip)
}
// [KERAB-SIMPLE-MIDRIDGE 2026-05-20] forward 시 제거됐던 hip-사이 ridge 복원.
for (const snap of removedRidges) {
if (!snap) continue
const pts = [snap.x1, snap.y1, snap.x2, snap.y2]
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
const restored = new QLine(pts, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: LINE_TYPE.SUBLINE.RIDGE,
textMode: roof.textMode,
attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...(snap.attributes || {}) },
})
if (snap.lineName) restored.lineName = snap.lineName
canvas.add(restored)
restored.bringToFront()
roof.innerLines.push(restored)
}
removeKerabHalfLabels(target.id)
hideOriginalLengthText(target.id, false)
canvas.renderAll()
return true
}
// junctionExtended: ext hip 2개 + ridge 1개 제거 + 제거됐던 RG-1 + outer/inner hip 4개 복원
if (ridgeAtMid.ridge.__patternKind === 'junctionExtended') {
const extHips = Array.isArray(ridgeAtMid.ridge.__patternExtHips) ? ridgeAtMid.ridge.__patternExtHips : []
extHips.forEach((h) => {
if (!h) return
removeLine(h)
roof.innerLines = roof.innerLines.filter((il) => il !== h)
})
const removedRidgeSnaps = Array.isArray(ridgeAtMid.ridge.__removedRidgesSnapshot) ? ridgeAtMid.ridge.__removedRidgesSnapshot : []
const removedHipSnaps = Array.isArray(ridgeAtMid.ridge.__removedHipsSnapshot) ? ridgeAtMid.ridge.__removedHipsSnapshot : []
removeLine(ridgeAtMid.ridge)
roof.innerLines = roof.innerLines.filter((il) => il !== ridgeAtMid.ridge)
removedRidgeSnaps.forEach((snap) => {
if (!snap) return
const pts = [snap.x1, snap.y1, snap.x2, snap.y2]
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
const restored = new QLine(pts, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: LINE_TYPE.SUBLINE.RIDGE,
textMode: roof.textMode,
attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...snap.attributes },
})
if (snap.lineName) restored.lineName = snap.lineName
canvas.add(restored)
restored.bringToFront()
roof.innerLines.push(restored)
})
removedHipSnaps.forEach((snap) => {
if (!snap) return
const hip = buildHipFromSnapshot(snap)
canvas.add(hip)
hip.bringToFront()
roof.innerLines.push(hip)
})
removeKerabHalfLabels(target.id)
hideOriginalLengthText(target.id, false)
canvas.renderAll()
return true
}
// single-ridge / 이전 패턴: outer hip 2개 신규 생성
const snaps = Array.isArray(ridgeAtMid.ridge.__originalHips) ? ridgeAtMid.ridge.__originalHips : null
const hip1 = snaps ? buildHipFromSnapshot(snaps[0]) : buildHipToApex(c1)
const hip2 = snaps ? buildHipFromSnapshot(snaps[1]) : buildHipToApex(c2)
removeLine(ridgeAtMid.ridge)
roof.innerLines = roof.innerLines.filter((il) => il !== ridgeAtMid.ridge)
canvas.add(hip1)
canvas.add(hip2)
hip1.bringToFront()
hip2.bringToFront()
roof.innerLines.push(hip1, hip2)
removeKerabHalfLabels(target.id)
hideOriginalLengthText(target.id, false)
canvas.renderAll()
return true
}
// [KERAB-TYPE-EAVES 2026-06-11] TYPE(A/B 박공) 출신 케라바→처마 전용 변환.
// forward 토글 스냅샷이 없는 native 마루(RIDGE) 케이스. 마루확장라인을 hip 2개로 "펼친다".
// 규칙(사용자 확정, 방향 무관):
// 1) apex = 선택라인(wLine) 중점에서 마루 방향(inward) 으로 L/2. (45° 등거리 = 반으로 쪼갬)
// 2) 마루는 케라바변에 닿은 끝점을 apex 로 단축.
// 3) hip 2개(wA→apex, wB→apex) 생성 + outer 마크 → surgicalAfter 가 새 출폭 rLine 까지 45° ray-cast.
// markRevertOuter 는 applyKerabRevertPattern 내부 클로저라 여기선 which/side 를 직접 마크.
const isNativeTypeRidge = (ridge) =>
!!ridge &&
ridge.name === LINE_TYPE.SUBLINE.RIDGE &&
!ridge.__patternKind &&
!ridge.__originalHips &&
!ridge.__removedHipsSnapshot &&
ridge.lineName !== 'kerabPatternRidge' &&
ridge.lineName !== 'kerabPatternExtRidge'
const applyTypeGableToEavesPattern = (roof, target, ridge) => {
const wA = { x: target.x1, y: target.y1 }
const wB = { x: target.x2, y: target.y2 }
const L = Math.hypot(wB.x - wA.x, wB.y - wA.y)
if (L < 1) return null
const mid = { x: (wA.x + wB.x) / 2, y: (wA.y + wB.y) / 2 }
// 마루의 케라바변쪽 끝점 식별 (단축 대상).
const rA = { x: ridge.x1, y: ridge.y1 }
const rB = { x: ridge.x2, y: ridge.y2 }
const midIsA = Math.hypot(rA.x - mid.x, rA.y - mid.y) <= Math.hypot(rB.x - mid.x, rB.y - mid.y)
// [KERAB-TYPE-EAVES-INWARD 2026-06-12] inward 방향을 변의 "폴리곤 안쪽 법선"으로 도출.
// 기존: ridge stub 의 far-end - mid. 마루가 이미 짧아진 stub(2회차 리버트)이면 far-end 가
// 처마변 위로 와서 방향이 바깥으로 뒤집힘 → apex 폴리곤 밖 폭주(R3-outside, roofLine 절삭 불변식 위반).
// 변⟂마루인 정상 切妻에서는 안쪽 법선 = 마루방향이라 45°/L/2 모델과 동치이고, stub 퇴화에 불변.
const cps = Array.isArray(roof.points) ? roof.points : []
let cenX = 0
let cenY = 0
for (const p of cps) {
cenX += p.x
cenY += p.y
}
cenX /= cps.length || 1
cenY /= cps.length || 1
const ex = (wB.x - wA.x) / L
const ey = (wB.y - wA.y) / L
let ix = -ey
let iy = ex
if (ix * (cenX - mid.x) + iy * (cenY - mid.y) < 0) {
ix = -ix
iy = -iy
}
// [KERAB-TYPE-EAVES-APEX-CLAMP 2026-06-15] apex 깊이 = L/2 (45° 이등변). 단 변이 폴리곤 inward
// 두께보다 길면(가로로 납작한 폴리곤) mid+inward*(L/2) 가 반대편 roofLine 을 넘어 폭주
// (R3-outside). "라인은 roofLine 까지" 규칙 → inward ray 가 반대편 변에 닿는 거리로 상한.
// 정상 切妻(세로로 긴 폴리곤)은 oppDist > L/2 라 min=L/2, 기존과 동치 → 무영향.
const oppRayDist = (P, dx, dy) => {
let best = Infinity
const M = cps.length
for (let i = 0; i < M; i++) {
const A = cps[i]
const B = cps[(i + 1) % M]
const ax = B.x - A.x
const ay = B.y - A.y
const den = dx * ay - dy * ax
if (Math.abs(den) < 1e-9) continue
const t = ((A.x - P.x) * ay - (A.y - P.y) * ax) / 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
}
// [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
}
const oppDist = oppRayDist(mid, ix, iy)
const apexDepth = Math.min(L / 2, Number.isFinite(oppDist) ? oppDist : L / 2)
const apex = { x: mid.x + ix * apexDepth, y: mid.y + iy * apexDepth }
// [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.
if (midIsA) ridge.set({ x1: apex.x, y1: apex.y })
else ridge.set({ x2: apex.x, y2: apex.y })
// [KERAB-TYPE-EAVES-RIDGE-ZERO 2026-06-15] apex 가 반대편 roofLine 에 클램프되면 native 마루가
// 거기로 단축돼 zero-length 가 될 수 있다(R2 위반). 객체는 revert 위해 보존하되 invisible 처리
// (R2 체크는 visible 만 검사). 마루 형상 재구성은 완전 우진각 시 reconstruct 가 담당.
const ridgeLen = Math.hypot(ridge.x2 - ridge.x1, ridge.y2 - ridge.y1)
if (ridgeLen < 1) ridge.visible = false
const rsz = calcLinePlaneSize({ x1: ridge.x1, y1: ridge.y1, x2: ridge.x2, y2: ridge.y2 })
if (ridge.attributes) {
ridge.attributes.planeSize = rsz
ridge.attributes.actualSize = rsz
}
if (typeof ridge.setCoords === 'function') ridge.setCoords()
if (typeof ridge.setLength === 'function') ridge.setLength()
if (typeof ridge.addLengthText === 'function') ridge.addLengthText()
}
// hip 2개: wA→endA, wB→endB. pts=[corner, end] 라 corner 끝(x1,y1)이 outer.
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 hip = new QLine(pts, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: LINE_TYPE.SUBLINE.HIP,
textMode: roof.textMode,
// [KERAB-TYPE-EAVES-ROUNDTRIP 2026-06-11] forward(처마→케라바) findHipAtEndpoint 는
// attributes.type === HIP 로 매칭한다. type 누락 시 재변환에서 hip 미인식 → attr-only 폴백.
attributes: { roofId: roof.id, type: LINE_TYPE.SUBLINE.HIP, planeSize: sz, actualSize: sz },
})
hip.lineName = 'kerabPatternHip'
hip.__kerabRevertOuterWhich = 1
hip.__kerabRevertOuterSide = side
return hip
}
const hip1 = mkHip(wA, 'A', endA)
const hip2 = mkHip(wB, 'B', endB)
canvas.add(hip1)
canvas.add(hip2)
hip1.bringToFront()
hip2.bringToFront()
roof.innerLines.push(hip1, hip2)
removeKerabHalfLabels(target.id)
hideOriginalLengthText(target.id, false)
canvas.renderAll()
logger.log(
'[KERAB-TYPE-EAVES] applied ' +
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
}
// [KERAB-TYPE-EAVES-EDGEMOVE 2026-06-11] TYPE 변환 후 옛 roofLine 변 위에 남은 normal inner line 이동.
// surgicalAfter(skipInnerLines:true) 는 폴리곤 변·내 hip 만 갱신하고 L자 면경계선은 안 옮긴다 →
// 옛 出幅 위치(ghost) 잔존. 변은 出幅만큼 평행이동했으므로 옛 변 위 끝점만 동일 delta 로 평행이동한다.
// (出幅 평행이동이라 변 위 모든 점의 변위가 동일 = recomputeNormalLine 의 NORMAL-ABS 와 동치.)
// 케라바패턴 라인은 ray-cast 가 이미 처리하므로 제외. oldC1/oldC2 = 변환 전 roofLine 코너.
// [KERAB-TYPE-EAVES-WEDGE 2026-06-11] apex 인자 추가. 처마 위상에서는 두 힙이 apex 에서 만나고
// 힙 사이 쐐기에는 라인이 없어야 한다. 옛 변 중점(cMid)에 끝점이 닿아 있던 非평행 라인(=힙)은
// 평행이동 시 쐐기를 가로지르므로, 그 끝점만 apex 로 수렴시킨다. 변과 평행한 라인(roofLine 절반)과
// 코너 끝점은 종전처럼 出幅 delta 로 평행이동.
const moveStaleEdgeInnerLines = (roof, oldC1, oldC2, edgeIdx, apex) => {
if (edgeIdx < 0 || !Array.isArray(roof.points)) return null
const N = roof.points.length
const nP = roof.points[edgeIdx]
const nQ = roof.points[(edgeIdx + 1) % N]
// c1↔c2 ↔ nP↔nQ 방향 정합 (idx 끝과 가까운 쪽으로 매핑).
const c1ToP = Math.hypot(oldC1.x - nP.x, oldC1.y - nP.y) <= Math.hypot(oldC1.x - nQ.x, oldC1.y - nQ.y)
const newA = c1ToP ? nP : nQ
const newB = c1ToP ? nQ : nP
// 出幅 평행이동 delta (양 코너 동일, 안전하게 평균).
const dxE = (newA.x - oldC1.x + (newB.x - oldC2.x)) / 2
const dyE = (newA.y - oldC1.y + (newB.y - oldC2.y)) / 2
if (Math.hypot(dxE, dyE) < 0.01) return null
const segDx = oldC2.x - oldC1.x
const segDy = oldC2.y - oldC1.y
const segLen2 = segDx * segDx + segDy * segDy || 1
const segLen = Math.sqrt(segLen2)
const onOldEdge = (px, py) => {
const t = ((px - oldC1.x) * segDx + (py - oldC1.y) * segDy) / segLen2
if (t < -0.02 || t > 1.02) return false
const prx = oldC1.x + t * segDx
const pry = oldC1.y + t * segDy
return Math.hypot(px - prx, py - pry) < 0.5
}
const oldMid = { x: (oldC1.x + oldC2.x) / 2, y: (oldC1.y + oldC2.y) / 2 }
const nearMid = (px, py) => Math.hypot(px - oldMid.x, py - oldMid.y) < 2.0
// 라인 방향이 변과 평행이면 cross 가 0 → roofLine 절반(평행이동 대상).
const isCollinearWithEdge = (x1, y1, x2, y2) => {
const lx = x2 - x1
const ly = y2 - y1
const llen = Math.hypot(lx, ly)
if (llen < 0.01) return true
const cross = Math.abs(lx * segDy - ly * segDx) / (llen * segLen)
return cross < 0.05
}
const isKerabPattern = (ln) =>
ln === 'kerabPatternHip' ||
ln === 'kerabPatternRidge' ||
ln === 'kerabPatternExtHip' ||
ln === 'kerabPatternExtRidge' ||
ln === 'kerabPatternValleyExt'
let movedCnt = 0
for (const il of roof.innerLines || []) {
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)
// 非평행 라인의 cMid 끝점은 apex 로 수렴, 그 외는 出幅 delta 로 평행이동.
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 }
return { x: px + dxE, y: py + dyE }
}
let moved = false
if (onOldEdge(il.x1, il.y1)) {
const np1 = relocate(il.x1, il.y1)
il.set({ x1: np1.x, y1: np1.y })
moved = true
}
if (onOldEdge(il.x2, il.y2)) {
const np2 = relocate(il.x2, il.y2)
il.set({ x2: np2.x, y2: np2.y })
moved = true
}
if (moved) {
const ns = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
if (il.attributes) {
il.attributes.planeSize = ns
il.attributes.actualSize = ns
}
if (typeof il.setCoords === 'function') il.setCoords()
if (typeof il.setLength === 'function') il.setLength()
if (typeof il.addLengthText === 'function') il.addLengthText()
movedCnt++
}
}
canvas.renderAll()
logger.log('[KERAB-TYPE-EAVES-EDGEMOVE] ' + JSON.stringify({ edgeIdx, delta: { dxE, dyE }, movedCnt }))
return { dxE, dyE }
}
// [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] 힙은 단일 직선 45°. apex 에서 벽교점(wLine 코너) 방향(=45°)
// 그대로 roofLine 까지 직선 연장한다. 꺾지 않고, roofLine '코너/교점'을 찾아가지도 않는다 — ray 가
// roofLine 변에 처음 닿는 지점이 끝점(出幅60 → 코너 10mm 못미쳐 변 위에서 끝남, 사용자 모델 그대로).
// apex 는 벽기준 고정값이라 出幅 무관(half-rule 폐기) — "出幅에 의해 힙·마루 변질" 차단.
// surgicalAfter/EXTEND-45 가 코너 스냅한 힙을 ray-cast 직선으로 덮어쓴다.
const extendTypeGableHipsStraightToRoofLine = (roof, target, apex) => {
if (!apex || !roof || !Array.isArray(roof.innerLines)) return
const pts = Array.isArray(roof.points) ? roof.points : []
if (pts.length < 2) return
const wcs = [
{ x: target.x1, y: target.y1 },
{ x: target.x2, y: target.y2 },
]
// ray P+t*dir 가 선분 A→B 와 만나는 t(>0) 반환, 없으면 Infinity.
const rayHit = (P, dir, A, B) => {
const ex = B.x - A.x
const ey = B.y - A.y
const den = dir.x * ey - dir.y * ex
if (Math.abs(den) < 1e-9) return Infinity
const t = ((A.x - P.x) * ey - (A.y - P.y) * ex) / den
const u = ((A.x - P.x) * dir.y - (A.y - P.y) * dir.x) / den
if (t > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6) return t
return Infinity
}
const APEX_TOL = 2.0
let extended = 0
for (const il of [...roof.innerLines]) {
if (!il || il.lineName !== 'kerabPatternHip') continue
const d1 = Math.hypot(il.x1 - apex.x, il.y1 - apex.y)
const d2 = Math.hypot(il.x2 - apex.x, il.y2 - apex.y)
if (Math.min(d1, d2) > APEX_TOL) continue
const apexIsE1 = d1 <= d2
const outerEnd = apexIsE1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
// outerEnd 에 가까운 벽교점(wLine 끝점) 선택.
const wc = Math.hypot(outerEnd.x - wcs[0].x, outerEnd.y - wcs[0].y) <= Math.hypot(outerEnd.x - wcs[1].x, outerEnd.y - wcs[1].y) ? wcs[0] : wcs[1]
// 방향 = apex→wc (45°). 이 방향 그대로 roofLine 까지 ray.
const dx = wc.x - apex.x
const dy = wc.y - apex.y
const dlen = Math.hypot(dx, dy) || 1
const dir = { x: dx / dlen, y: dy / dlen }
// wc 직전(살짝 안쪽)에서 ray 발사 → wc 통과 후 첫 roofLine 변 교차.
const P = { x: wc.x - dir.x * 0.05, y: wc.y - dir.y * 0.05 }
let best = Infinity
let hit = null
for (let i = 0; i < pts.length; i++) {
const A = pts[i]
const B = pts[(i + 1) % pts.length]
const t = rayHit(P, dir, A, B)
if (t < best) {
best = t
hit = { x: P.x + dir.x * t, y: P.y + dir.y * t }
}
}
if (!hit) continue
// 힙 = apex → hit (단일 직선 45°).
if (apexIsE1) il.set({ x1: apex.x, y1: apex.y, x2: hit.x, y2: hit.y })
else il.set({ x1: hit.x, y1: hit.y, x2: apex.x, y2: apex.y })
// [KERAB-TYPE-EAVES-STARTPOINT-SYNC 2026-06-12] .set 은 'modified' 를 안 띄워 startPoint/endPoint
// 가 옛 좌표로 남는다. 할당 graph(splitPolygonWithLines)는 startPoint/endPoint 로 면을 닫으므로
// 동기화 누락 시 절삭된 힙이 옛 좌표로 연결돼 인접 roofLine 이 면에 포함되지 않아 미할당된다.
il.startPoint = { x: il.x1, y: il.y1 }
il.endPoint = { x: il.x2, y: il.y2 }
const sz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
if (il.attributes) {
il.attributes.planeSize = sz
il.attributes.actualSize = sz
il.attributes.extended = true
}
il.__extended = true
if (typeof il.setCoords === 'function') il.setCoords()
if (typeof il.setLength === 'function') il.setLength()
if (typeof il.addLengthText === 'function') il.addLengthText()
extended++
}
canvas.renderAll()
logger.log('[KERAB-TYPE-EAVES-STRAIGHT] ' + JSON.stringify({ extended, apex }))
}
// [KERAB-TYPE-EAVES-HIPROOF 2026-06-12] 양쪽 박공이 모두 처마로 바뀌어 완전 寄棟(우진각)이 되는 순간 호출.
// per-edge L/2 apex 모델은 마루를 짧은 축(세로)으로 만들어 두 힙쌍이 X자로 교차하는 틀린 형상을 남긴다.
// 힙·마루 규칙으로 재구성: R2(라인은 교점에서 멈춤)·R3(교점 초과분 절삭)·R1(중앙선=마루).
// 각 힙이 "가장 먼저 만나는" 다른 힙과의 교점이 진짜 마루 끝점(짧은변 공유쌍). 두 끝점을 잇는 선이 마루.
// apex 기반 세로 마루 스텁은 제거하고 절삭된 힙 4개 + 가로 마루 1개(표준 우진각)로 교체.
const reconstructHipRoofRidgeIfComplete = (roof) => {
if (!roof || !Array.isArray(roof.innerLines) || !Array.isArray(roof.points)) return false
// 완전 寄棟 = 외곽선에 박공(GABLE)이 하나도 안 남음.
const outerLines = canvas.getObjects().filter((o) => o.name === 'outerLine' && o.attributes?.roofId === roof.id)
if (!outerLines.length) return false
if (outerLines.some((o) => o.attributes?.type === LINE_TYPE.WALLLINE.GABLE)) return false
// kerabPatternHip 정확히 4개일 때만 표준 우진각으로 간주.
const hips = roof.innerLines.filter((il) => il && il.lineName === 'kerabPatternHip' && il.visible !== false)
if (hips.length !== 4) return false
const pts = roof.points
let cenX = 0
let cenY = 0
for (const p of pts) {
cenX += p.x
cenY += p.y
}
cenX /= pts.length || 1
cenY /= pts.length || 1
// 각 힙의 outer(roofLine 코너쪽) / inner 끝점 + 방향(outer→inner, 45°).
const info = hips.map((il) => {
const d1 = Math.hypot(il.x1 - cenX, il.y1 - cenY)
const d2 = Math.hypot(il.x2 - cenX, il.y2 - cenY)
const outer = d1 >= d2 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
const inner = d1 >= d2 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
return { il, outer, dir: { x: inner.x - outer.x, y: inner.y - outer.y } }
})
// 두 힙 직선 교점 (a.outer + t·a.dir).
const intersect = (a, b) => {
const den = a.dir.x * b.dir.y - a.dir.y * b.dir.x
if (Math.abs(den) < 1e-9) return null
const t = ((b.outer.x - a.outer.x) * b.dir.y - (b.outer.y - a.outer.y) * b.dir.x) / den
const u = ((b.outer.x - a.outer.x) * a.dir.y - (b.outer.y - a.outer.y) * a.dir.x) / den
if (t <= 1e-6 || u <= 1e-6) return null
return { x: a.outer.x + a.dir.x * t, y: a.outer.y + a.dir.y * t, t }
}
// 각 힙이 "가장 먼저(최소 t)" 만나는 힙 = 마루 끝점 파트너.
for (let i = 0; i < info.length; i++) {
let best = null
let bestJ = -1
for (let j = 0; j < info.length; j++) {
if (i === j) continue
const ip = intersect(info[i], info[j])
if (!ip) continue
if (!best || ip.t < best.t) {
best = ip
bestJ = j
}
}
info[i].stop = best
info[i].partner = bestJ
}
if (info.some((h) => !h.stop)) return false
// 마루 끝점 클러스터링 (2개 기대).
const ridgePts = []
for (const h of info) {
const found = ridgePts.find((rp) => Math.hypot(rp.x - h.stop.x, rp.y - h.stop.y) < 1.0)
if (found) found.count++
else ridgePts.push({ x: h.stop.x, y: h.stop.y, count: 1 })
}
if (ridgePts.length !== 2 || ridgePts.some((rp) => rp.count !== 2)) {
logger.log('[KERAB-TYPE-EAVES-HIPROOF] skip ' + JSON.stringify({ ridgePts: ridgePts.length, counts: ridgePts.map((r) => r.count) }))
return false
}
// R2/R3: 각 힙을 outer → 마루 끝점으로 절삭.
for (const h of info) {
const rp = ridgePts.find((p) => Math.hypot(p.x - h.stop.x, p.y - h.stop.y) < 1.0)
const d1 = Math.hypot(h.il.x1 - cenX, h.il.y1 - cenY)
const d2 = Math.hypot(h.il.x2 - cenX, h.il.y2 - cenY)
if (d1 >= d2) h.il.set({ x2: rp.x, y2: rp.y })
else h.il.set({ x1: rp.x, y1: rp.y })
// [KERAB-TYPE-EAVES-STARTPOINT-SYNC 2026-06-12] 절삭 후 startPoint/endPoint 동기화.
// 누락 시 할당 graph 가 옛 inner 끝점으로 면을 닫으려다 실패 → 인접 roofLine 미할당.
h.il.startPoint = { x: h.il.x1, y: h.il.y1 }
h.il.endPoint = { x: h.il.x2, y: h.il.y2 }
const sz = calcLinePlaneSize({ x1: h.il.x1, y1: h.il.y1, x2: h.il.x2, y2: h.il.y2 })
if (h.il.attributes) {
h.il.attributes.planeSize = sz
h.il.attributes.actualSize = sz
}
if (typeof h.il.setCoords === 'function') h.il.setCoords()
if (typeof h.il.setLength === 'function') h.il.setLength()
if (typeof h.il.addLengthText === 'function') h.il.addLengthText()
}
// 기존 마루(세로 apex 스텁 등) 제거 — 완전 우진각엔 가로 마루 1개만.
const staleRidges = roof.innerLines.filter((il) => il && il.name === LINE_TYPE.SUBLINE.RIDGE)
for (const r of staleRidges) {
canvas.remove(r)
const idx = roof.innerLines.indexOf(r)
if (idx >= 0) roof.innerLines.splice(idx, 1)
}
// R1: 두 마루 끝점을 잇는 가로 마루 신규 생성.
const rp1 = ridgePts[0]
const rp2 = ridgePts[1]
const rpts = [rp1.x, rp1.y, rp2.x, rp2.y]
const rsz = calcLinePlaneSize({ x1: rpts[0], y1: rpts[1], x2: rpts[2], y2: rpts[3] })
const ridge = new QLine(rpts, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: LINE_TYPE.SUBLINE.RIDGE,
textMode: roof.textMode,
attributes: { roofId: roof.id, type: LINE_TYPE.SUBLINE.RIDGE, planeSize: rsz, actualSize: rsz },
})
ridge.lineName = 'ridge'
canvas.add(ridge)
ridge.bringToFront()
roof.innerLines.push(ridge)
if (typeof ridge.setCoords === 'function') ridge.setCoords()
if (typeof ridge.setLength === 'function') ridge.setLength()
if (typeof ridge.addLengthText === 'function') ridge.addLengthText()
canvas.renderAll()
logger.log(
'[KERAB-TYPE-EAVES-HIPROOF] reconstructed ' +
JSON.stringify({
ridge: { x1: Math.round(rp1.x), y1: Math.round(rp1.y), x2: Math.round(rp2.x), y2: Math.round(rp2.y) },
removedRidges: staleRidges.length,
}),
)
return true
}
// [KERAB-HALF-LABEL 2026-05-19] 케라바 외곽선의 좌우/상하 절반 길이 라벨
// 그림 그릴 단계에서는 외곽선은 1개로 유지하고 라벨만 2개 노출.
// 할당 시 splitPolygonWithLines 가 자연 분할하므로 그때는 사라지고 분할 라인 라벨이 대체.
const KERAB_HALF_LABEL_NAME = 'kerabHalfLabel'
const removeKerabHalfLabels = (parentId) => {
const labels = canvas
.getObjects()
.filter((obj) => obj.name === KERAB_HALF_LABEL_NAME && obj.parentId === parentId)
labels.forEach((obj) => canvas.remove(obj))
}
const addKerabHalfLabels = (target, c1, c2) => {
const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 }
const fullPlane = calcLinePlaneSize({ x1: c1.x, y1: c1.y, x2: c2.x, y2: c2.y })
const halfPlane = Math.round(fullPlane / 2)
const text = String(halfPlane).replace(/\.0$/, '')
const mkPos = (a, b) => {
const cx = (a.x + b.x) / 2
const cy = (a.y + b.y) / 2
// 수평/수직 라인에 한해 라벨을 라인 옆으로 살짝 띄움 (QLine.addLengthText 와 동일한 룰)
if (target.direction === 'left' || target.direction === 'right') return { left: cx, top: cy + 10 }
if (target.direction === 'top' || target.direction === 'bottom') return { left: cx + 10, top: cy }
return { left: cx, top: cy }
}
const buildLabel = (pos) =>
new fabric.Textbox(text, {
left: pos.left,
top: pos.top,
fontSize: target.fontSize,
parentId: target.id,
name: KERAB_HALF_LABEL_NAME,
editable: false,
selectable: true,
lockRotation: true,
lockScalingX: true,
lockScalingY: true,
planeSize: halfPlane,
actualSize: halfPlane,
})
canvas.add(buildLabel(mkPos(c1, mid)))
canvas.add(buildLabel(mkPos(mid, c2)))
}
const hideOriginalLengthText = (parentId, hide) => {
const lbl = canvas.getObjects().find((obj) => obj.name === 'lengthText' && obj.parentId === parentId)
if (lbl) lbl.set({ visible: !hide })
}
// [KERAB-RECT-SOLVER 2026-06-15] 사각형(axis-aligned 4코너) 결정론적 케라바 솔버.
// 토글 이력과 무관하게 「최종 4변 타입」만으로 내부선(힙/마루/케라바 줄기)을 매번 처음부터
// 재생성한다 = 출폭/변타입의 순수 함수. forward/revert/type 미로를 통째로 우회.
// 구조적으로 보장되는 두 불변식:
// 불변식1(Y): 줄기(마루)는 박공으로 바뀐 변(slope 없음) 방향으로만 뻗는다. \|/, /|\ 불가.
// 불변식2: 힙은 apex(45° 교점)에서 멈춘다 → wallbase 교점 초과(폴리곤 밖) 힙 없음.
// 지원: 박공 0개(우진각)·1개(Y, apex 내부일 때)·2개 마주보기(맞배). 그 외(인접 2박공/3·4박공/
// 비사각형/회전사각형/radio2 타입)는 false 반환 → 기존 경로 폴백.
const solveRectangleKerab = (roof, target, newAttributes) => {
try {
if (!roof || !Array.isArray(roof.points) || roof.points.length !== 4) return false
if (!Array.isArray(roof.innerLines)) roof.innerLines = []
const outerLines = canvas.getObjects().filter((o) => o.name === 'outerLine' && o.attributes?.roofId === roof.id)
// [KERAB-RECT-PROBE 2026-06-15] outerLine 이 wallbase(출폭0) 좌표인지 roofLine 좌표인지
// 실제 데이터로 확정하기 위한 진입 덤프. solver 가 좌표 불일치로 false 폴백해도 이건 먼저 찍힘.
logger.log(
'[KERAB-RECT-PROBE] entry ' +
JSON.stringify({
roofPoints: roof.points.map((p) => ({ x: Math.round(p.x * 10) / 10, y: Math.round(p.y * 10) / 10 })),
outerLineCount: outerLines.length,
outerLines: outerLines.map((o) => ({
type: o.attributes?.type,
offset: o.attributes?.offset,
x1: Math.round(o.x1 * 10) / 10,
y1: Math.round(o.y1 * 10) / 10,
x2: Math.round(o.x2 * 10) / 10,
y2: Math.round(o.y2 * 10) / 10,
})),
newType: newAttributes?.type,
newOffset: newAttributes?.offset,
}),
)
if (outerLines.length !== 4) return false
const newType = newAttributes?.type
const TOL = 0.6
const xs = roof.points.map((p) => p.x)
const ys = roof.points.map((p) => p.y)
let minX = Math.min(...xs)
let maxX = Math.max(...xs)
let minY = Math.min(...ys)
let maxY = Math.max(...ys)
let W = maxX - minX
let H = maxY - minY
if (W < 2 || H < 2) return false
// 네 코너가 실제 직사각형 코너와 일치해야 함 (axis-aligned).
const corners = [
{ x: minX, y: minY },
{ x: maxX, y: minY },
{ x: maxX, y: maxY },
{ x: minX, y: maxY },
]
for (const c of corners) {
if (!roof.points.some((p) => Math.abs(p.x - c.x) < TOL && Math.abs(p.y - c.y) < TOL)) return false
}
// [KERAB-RECT-SIDE-FIX 2026-06-15] outerLine 은 roof.points(roofLine)에서 出幅만큼 안쪽으로
// 들어온 wallbase 좌표라, roof.points bbox 와 직접 비교하면 出幅(예 50)만큼 어긋나 TOL 초과 →
// sideOf 가 null → 솔버 전체 false 폴백(증상: 토글 시 KERAB-SIMPLE 미로로 빠져 그림 깨짐).
// 변 분류는 outerLine 자체 bbox 기준으로, 지오메트리(힙/마루)는 그대로 roof.points 기준으로 푼다.
const oxs = outerLines.flatMap((o) => [o.x1, o.x2])
const oys = outerLines.flatMap((o) => [o.y1, o.y2])
const oMinX = Math.min(...oxs)
const oMaxX = Math.max(...oxs)
const oMinY = Math.min(...oys)
const oMaxY = Math.max(...oys)
// 각 외곽선 → 변(top/bottom/left/right) 분류 + 유효 타입(EAVES/GABLE) 확정.
const sideOf = (o) => {
const horiz = Math.abs(o.y1 - o.y2) < TOL
const vert = Math.abs(o.x1 - o.x2) < TOL
if (horiz && Math.abs((o.y1 + o.y2) / 2 - oMinY) < TOL) return 'top'
if (horiz && Math.abs((o.y1 + o.y2) / 2 - oMaxY) < TOL) return 'bottom'
if (vert && Math.abs((o.x1 + o.x2) / 2 - oMinX) < TOL) return 'left'
if (vert && Math.abs((o.x1 + o.x2) / 2 - oMaxX) < TOL) return 'right'
return null
}
const sides = {}
for (const o of outerLines) {
const s = sideOf(o)
if (!s || sides[s]) return false
const eff = o === target ? newType : o.attributes?.type
if (eff !== LINE_TYPE.WALLLINE.EAVES && eff !== LINE_TYPE.WALLLINE.GABLE) return false
sides[s] = { o, gable: eff === LINE_TYPE.WALLLINE.GABLE }
}
if (!sides.top || !sides.bottom || !sides.left || !sides.right) return false
const gTop = sides.top.gable
const gBottom = sides.bottom.gable
const gLeft = sides.left.gable
const gRight = sides.right.gable
const nGable = [gTop, gBottom, gLeft, gRight].filter(Boolean).length
// [KERAB-RECT-GATE 2026-06-15] 出幅을 경계에 반영하기 전에 지원 형상인지 먼저 판정한다.
// 미지원(인접 2박공/3·4박공/얕은 Y)인데 offset 을 먼저 옮기면 폴백된 미로가 offset 을 한 번 더
// 적용해 이중 出幅이 된다 → 반드시 무변형 상태에서 게이트하고, 미지원이면 boundary 손대지 않고 false.
{
let ok = false
if (nGable === 0) ok = true
// [KERAB-RECT-GATE-1 2026-06-15] 단일 박공은 항상 지원: depth<=span 이면 Y(apex)모델,
// long-edge(케라바, depth>span)면 CASE B(반대코너 45° 힙 2개 + 변 위 마루 1개). 둘 다 정의됨.
else if (nGable === 1) ok = true
else if (nGable === 2) {
ok = (gTop && gBottom) || (gLeft && gRight)
}
if (!ok) return false
}
// [KERAB-RECT-OFFSET 2026-06-15] 지원 형상 확정 후, 토글되는 변의 出幅 변경분을 경계(roofLine)에
// 먼저 반영한다(inner line 은 안 건드림). 이후 갱신된 roof.points 로 내부 지오메트리를 푼다.
// 出幅 입력이 기존과 같으면 delta 0 → 무동작. 다르면 maze 의 offset-surgical 과 동일 경로.
if (newAttributes && newAttributes.offset != null) {
applyTargetOffsetSurgical(target, newAttributes.offset, { skipInnerLines: true })
const nxs = roof.points.map((p) => p.x)
const nys = roof.points.map((p) => p.y)
minX = Math.min(...nxs)
maxX = Math.max(...nxs)
minY = Math.min(...nys)
maxY = Math.max(...nys)
W = maxX - minX
H = maxY - minY
}
const cx = (minX + maxX) / 2
const cy = (minY + maxY) / 2
const out = []
const addRidge = (x1, y1, x2, y2) => {
if (Math.hypot(x2 - x1, y2 - y1) >= 1) out.push({ x1, y1, x2, y2, kind: 'ridge' })
}
const addHip = (x1, y1, x2, y2) => {
if (Math.hypot(x2 - x1, y2 - y1) >= 1) out.push({ x1, y1, x2, y2, kind: 'hip' })
}
if (nGable === 0) {
// 우진각: 마루는 긴 축, apex 는 짧은 변/2 안쪽. 힙 4개.
if (W >= H) {
const inset = H / 2
const lA = { x: minX + inset, y: cy }
const rA = { x: maxX - inset, y: cy }
addRidge(lA.x, lA.y, rA.x, rA.y)
addHip(minX, minY, lA.x, lA.y)
addHip(minX, maxY, lA.x, lA.y)
addHip(maxX, minY, rA.x, rA.y)
addHip(maxX, maxY, rA.x, rA.y)
} else {
const inset = W / 2
const tA = { x: cx, y: minY + inset }
const bA = { x: cx, y: maxY - inset }
addRidge(tA.x, tA.y, bA.x, bA.y)
addHip(minX, minY, tA.x, tA.y)
addHip(maxX, minY, tA.x, tA.y)
addHip(minX, maxY, bA.x, bA.y)
addHip(maxX, maxY, bA.x, bA.y)
}
} else if (nGable === 1) {
// 박공변이 짧은/중간 변(맞은편 apex 가 폴리곤 안)이면 Y(apex+마루).
// 긴 변(케라바=마루와 평행, W>2H or H>2W)이면 맞은편 두 코너에서 45° 힙이 박공변까지
// 직진하고 박공변 위에 마루가 놓인다(CASE B). 어느 쪽이든 단일 박공은 항상 유효.
if (gBottom || gTop) {
const depth = W / 2 // apex 깊이 = 박공변(가로) 길이/2
if (depth <= H - 0.5) {
// Y: apex 가 맞은편 변 안쪽
if (gBottom) {
const apex = { x: cx, y: minY + depth }
addHip(minX, minY, apex.x, apex.y)
addHip(maxX, minY, apex.x, apex.y)
addRidge(apex.x, apex.y, cx, maxY)
} else {
const apex = { x: cx, y: maxY - depth }
addHip(minX, maxY, apex.x, apex.y)
addHip(maxX, maxY, apex.x, apex.y)
addRidge(apex.x, apex.y, cx, minY)
}
} else if (gBottom) {
// 케라바(긴 가로 변): 위쪽 두 코너 → 박공변(아래)까지 45° 직진 + 박공변 위 마루
addHip(minX, minY, minX + H, maxY)
addHip(maxX, minY, maxX - H, maxY)
addRidge(minX + H, maxY, maxX - H, maxY)
} else {
addHip(minX, maxY, minX + H, minY)
addHip(maxX, maxY, maxX - H, minY)
addRidge(minX + H, minY, maxX - H, minY)
}
} else {
const depth = H / 2
if (depth <= W - 0.5) {
if (gLeft) {
const apex = { x: maxX - depth, y: cy }
addHip(maxX, minY, apex.x, apex.y)
addHip(maxX, maxY, apex.x, apex.y)
addRidge(apex.x, apex.y, minX, cy)
} else {
const apex = { x: minX + depth, y: cy }
addHip(minX, minY, apex.x, apex.y)
addHip(minX, maxY, apex.x, apex.y)
addRidge(apex.x, apex.y, maxX, cy)
}
} else if (gLeft) {
// 케라바(긴 세로 변): 오른쪽 두 코너 → 박공변(왼쪽)까지 45° 직진 + 박공변 위 마루
addHip(maxX, minY, minX, minY + W)
addHip(maxX, maxY, minX, maxY - W)
addRidge(minX, minY + W, minX, maxY - W)
} else {
addHip(minX, minY, maxX, minY + W)
addHip(minX, maxY, maxX, maxY - W)
addRidge(maxX, minY + W, maxX, maxY - W)
}
}
} else if (nGable === 2) {
// 맞배: 마주보는 두 박공 사이를 잇는 단일 마루, 힙 없음.
if (gTop && gBottom) {
addRidge(cx, minY, cx, maxY)
} else if (gLeft && gRight) {
addRidge(minX, cy, maxX, cy)
} else {
return false // 인접 2박공 = 굽은 마루, 미지원 → 폴백
}
} else {
return false // 3·4 박공 → 폴백
}
if (!out.length) return false
const beforeSnap = snapshotKerabState(roof)
// 기존 내부 HIP/RIDGE(네이티브 + kerabPattern) 전부 제거 후 재생성.
for (const il of [...roof.innerLines]) {
if (!il) continue
if (il.name === LINE_TYPE.SUBLINE.HIP || il.name === LINE_TYPE.SUBLINE.RIDGE) {
canvas.remove(il)
const idx = roof.innerLines.indexOf(il)
if (idx >= 0) roof.innerLines.splice(idx, 1)
}
}
for (const o of outerLines) removeKerabHalfLabels(o.id)
for (const ln of out) {
const sz = calcLinePlaneSize({ x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 })
const isRidge = ln.kind === 'ridge'
const q = new QLine([ln.x1, ln.y1, ln.x2, ln.y2], {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: isRidge ? 4 : 3,
name: isRidge ? LINE_TYPE.SUBLINE.RIDGE : LINE_TYPE.SUBLINE.HIP,
textMode: roof.textMode,
attributes: {
roofId: roof.id,
type: isRidge ? LINE_TYPE.SUBLINE.RIDGE : LINE_TYPE.SUBLINE.HIP,
planeSize: sz,
actualSize: sz,
// [KERAB-RECT-EXTENDED 2026-06-15] 솔버 hip 은 roofLine 코너(=출폭 끝점)까지 이미 닿아 있다.
// allocation 의 integrateExtensionLines 가 ext+hip 머지로 hip 을 wall 코너까지 단축시키면
// split graph 가 polygon 코너와 끊겨 1면만 잡힌다(native hip 은 extended 플래그로 머지 스킵).
// 동일하게 extended=true 부여 → INTEGRATE 가 merge 스킵, ext 는 lines 에서만 제거.
...(isRidge ? {} : { extended: true }),
},
})
q.lineName = ln.kind
if (!isRidge) q.__extended = true
// 할당 graph(splitPolygonWithLines)는 startPoint/endPoint 로 면을 닫으므로 동기화 필수.
q.startPoint = { x: ln.x1, y: ln.y1 }
q.endPoint = { x: ln.x2, y: ln.y2 }
canvas.add(q)
q.bringToFront()
roof.innerLines.push(q)
if (typeof q.setCoords === 'function') q.setCoords()
if (typeof q.setLength === 'function') q.setLength()
if (typeof q.addLengthText === 'function') q.addLengthText()
}
// 박공 변엔 절반 라벨 + 원본 길이 숨김, 처마 변엔 원본 길이 노출.
for (const key of ['top', 'bottom', 'left', 'right']) {
const s = sides[key]
if (s.gable) {
addKerabHalfLabels(s.o, { x: s.o.x1, y: s.o.y1 }, { x: s.o.x2, y: s.o.y2 })
hideOriginalLengthText(s.o.id, true)
} else {
hideOriginalLengthText(s.o.id, false)
}
}
target.set({ attributes: newAttributes })
canvas.renderAll()
const phase = newType === LINE_TYPE.WALLLINE.GABLE ? 'forward' : 'revert'
runKerabRuleCheck(roof, phase, beforeSnap)
try {
reattachDebugLabels(canvas, roof.id)
} catch (e) {}
logger.log('[KERAB-RECT-SOLVER] built ' + JSON.stringify({ nGable, lines: out.length, W: Math.round(W), H: Math.round(H) }))
return true
} catch (err) {
logger.warn('[KERAB-RECT-SOLVER] error → fallback', err)
return false
}
}
// [KERAB-PATTERN-EXT-CLEAN 2026-05-19] forward 시 제거되는 hip 과 짝이었던 orphan
// extensionLine 정리. 한 끝점이 hip 의 한 끝점과 같고 다른 끝점이 hip 의 진행
// 방향과 동일선상에 있으면 짝으로 판정. 잔류 시 allocation 의 integrateExtensionLines
// 가 "짝없음" 으로 처리하고, ext 끝점이 외곽 polygon 을 잘못 자르는 잉여 분할 발생.
const removeOrphanExtensionsForHip = (hip) => {
const TOL = 1.0
const hipP1 = { x: hip.x1, y: hip.y1 }
const hipP2 = { x: hip.x2, y: hip.y2 }
const hipVx = hip.x2 - hip.x1
const hipVy = hip.y2 - hip.y1
const hipMag = Math.hypot(hipVx, hipVy) || 1
const same = (a, b) => Math.hypot(a.x - b.x, a.y - b.y) < TOL
const exts = canvas.getObjects().filter(
(o) => o?.lineName === 'extensionLine' && o.roofId === hip.parentId,
)
exts.forEach((ext) => {
const eP1 = { x: ext.x1, y: ext.y1 }
const eP2 = { x: ext.x2, y: ext.y2 }
const sharesP1 = same(eP1, hipP1) || same(eP1, hipP2)
const sharesP2 = same(eP2, hipP1) || same(eP2, hipP2)
if ((sharesP1 ? 1 : 0) + (sharesP2 ? 1 : 0) !== 1) return
const eVx = ext.x2 - ext.x1
const eVy = ext.y2 - ext.y1
const eMag = Math.hypot(eVx, eVy) || 1
const cross = (hipVx * eVy - hipVy * eVx) / (hipMag * eMag)
if (Math.abs(cross) >= 0.02) return // 동일선상 아님
canvas.remove(ext)
})
}
// [2240 KERAB-HIP-SNAPSHOT 2026-05-19] forward 시 제거되는 hip 의 원본 상태 캡처.
// revert 시 c1↔apex 로 새 hip 만들지 않고 이 스냅샷으로 원본 좌표/속성 그대로 복원.
const snapshotHip = (hip) => ({
x1: hip.x1,
y1: hip.y1,
x2: hip.x2,
y2: hip.y2,
attributes: hip.attributes ? { ...hip.attributes } : {},
lineName: hip.lineName,
__extended: hip.__extended,
})
// [2240 KERAB-SIMPLE 2026-05-20] 단순 알고리즘 전용 그리기 함수.
// mid(roof corner c1·c2 중점) → apex 중앙선 추가 + 양쪽 hip(h1·h2) 제거.
// c1, c2 가 hip.near (대각 offset roof corner) 이므로 그 중점이 roofLine 위 중점이 됨.
// revert 는 __patternKind='kLineOnly' 분기 + hipSnapshot 으로 ridge 제거 + hip 2개 복원.
const applyKerabKLinePattern = (roof, target, apex, c1, c2, hipsToRemove, ridgesToRemove, extLines, drawKLine = true, extraApexes = []) => {
// [KERAB-SIMPLE-KLINE-PERP 2026-05-20] kLine = apex 에서 roofLine(c1·c2 무한확장) 으로
// 내린 수선의 발 → apex. 시작점이 hip 교점이므로 이 직선 자체가 "hip 라인의 중앙선".
// roofLine 중앙과는 무관 (비대칭이면 빗겨 떨어진다).
// [KERAB-EXTENDER-MIXED 2026-05-21] drawKLine=false (hip+ridge mixed extender) 케이스:
// 사용자 전제 3 "hip과 마루가 만나면 그 자리에서 멈춘다" — kLine 없이 apex 까지의 ext line 만.
// revert 식별을 위해 apex 위치 zero-length invisible 마커 ridge 추가.
let ridge
if (drawKLine) {
const dx = c2.x - c1.x
const dy = c2.y - c1.y
const lenSq = dx * dx + dy * dy || 1
const t = ((apex.x - c1.x) * dx + (apex.y - c1.y) * dy) / lenSq
const naiveFoot = { x: c1.x + t * dx, y: c1.y + t * dy }
let foot = naiveFoot
// [KERAB-KLINE-TO-WALL 2026-05-21] kLine 도 roofLine 까지 확장 — apex 에서 naiveFoot 방향으로
// ray cast 해서 roof 폴리곤의 어떤 wall 이라도 가장 가까운 교점까지 연장.
// 비대칭 폴리곤에서 c1-c2 무한확장선이 폴리곤 밖으로 빠지는 케이스 흡수.
if (Array.isArray(roof.points) && roof.points.length >= 2) {
const fdx = naiveFoot.x - apex.x
const fdy = naiveFoot.y - apex.y
const flen2 = fdx * fdx + fdy * fdy
if (flen2 > 1e-6) {
const rayEnd = { x: apex.x + fdx, y: apex.y + fdy }
let bestPt = null
let bestDist = Infinity
for (let i = 0; i < roof.points.length; i++) {
const a = roof.points[i]
const b = roof.points[(i + 1) % roof.points.length]
const ip = lineLineIntersection(apex, rayEnd, a, b)
if (!ip) continue
const fwd = (ip.x - apex.x) * fdx + (ip.y - apex.y) * fdy
if (fwd <= 1e-3) continue
if (!isPointOnSegment(ip, { x1: a.x, y1: a.y, x2: b.x, y2: b.y })) continue
const dist = Math.hypot(ip.x - apex.x, ip.y - apex.y)
if (dist < bestDist) {
bestDist = dist
bestPt = ip
}
}
if (bestPt) foot = bestPt
}
}
// [KERAB-KLINE-STOP 2026-05-21] 새 kLine(apex→foot) 이 기존 kLine 가로지르면 가장 가까운 교점까지 클립.
const existingKLines = (roof.innerLines || []).filter(
(il) => il && il.lineName === 'kerabPatternRidge' && !il.__noKLine && il.__targetId !== target.id && il.visible !== false,
)
if (existingKLines.length) {
const segOnLine = (pt, ax, ay, bx, by, tol = 0.5) => {
const ddx = bx - ax
const ddy = by - ay
const sq = ddx * ddx + ddy * ddy
if (sq < 1e-6) return Math.hypot(pt.x - ax, pt.y - ay) <= tol
const tt = ((pt.x - ax) * ddx + (pt.y - ay) * ddy) / sq
const margin = tol / Math.sqrt(sq)
if (tt < -margin || tt > 1 + margin) return false
const px = ax + tt * ddx
const py = ay + tt * ddy
return Math.hypot(px - pt.x, py - pt.y) <= tol
}
let bestT = 1
let bestPt = null
const adx = foot.x - apex.x
const ady = foot.y - apex.y
for (const kl of existingKLines) {
const ip = lineLineIntersection(apex, foot, { x: kl.x1, y: kl.y1 }, { x: kl.x2, y: kl.y2 })
if (!ip) continue
if (!segOnLine(ip, kl.x1, kl.y1, kl.x2, kl.y2)) continue
const tt = (adx * (ip.x - apex.x) + ady * (ip.y - apex.y)) / (adx * adx + ady * ady || 1)
if (tt <= 1e-3 || tt > 1 + 1e-3) continue
if (tt < bestT) {
bestT = tt
bestPt = ip
}
}
if (bestPt) foot = bestPt
}
// [KERAB-NO-PIERCE 2026-05-21] kLine 도 정적 hip/ridge + 이번 ext line 가로지르면 거기서 정지.
// hipsToRemove/ridgesToRemove 는 곧 사라질 라인이므로 barrier 에서 제외.
{
const segOnLine = (pt, ax, ay, bx, by, tol = 0.5) => {
const ddx = bx - ax
const ddy = by - ay
const sq = ddx * ddx + ddy * ddy
if (sq < 1e-6) return Math.hypot(pt.x - ax, pt.y - ay) <= tol
const tt = ((pt.x - ax) * ddx + (pt.y - ay) * ddy) / sq
const margin = tol / Math.sqrt(sq)
if (tt < -margin || tt > 1 + margin) return false
const px = ax + tt * ddx
const py = ay + tt * ddy
return Math.hypot(px - pt.x, py - pt.y) <= tol
}
const removingHips = new Set(Array.isArray(hipsToRemove) ? hipsToRemove : [])
const removingRidges = new Set(Array.isArray(ridgesToRemove) ? ridgesToRemove : [])
const kBarriers = []
for (const il of roof.innerLines || []) {
if (!il) continue
if (removingHips.has(il) || removingRidges.has(il)) continue
if (il.lineName === 'kerabPatternRidge') continue
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
if (il.visible === false) continue
kBarriers.push({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
}
if (Array.isArray(extLines)) {
for (const seg of extLines) {
if (!seg) continue
kBarriers.push({ x1: seg.from.x, y1: seg.from.y, x2: seg.to.x, y2: seg.to.y })
}
}
let bestT = 1
let bestPt = null
const adx = foot.x - apex.x
const ady = foot.y - apex.y
const aSq = adx * adx + ady * ady || 1
for (const bl of kBarriers) {
const ip = lineLineIntersection(apex, foot, { x: bl.x1, y: bl.y1 }, { x: bl.x2, y: bl.y2 })
if (!ip) continue
if (!segOnLine(ip, bl.x1, bl.y1, bl.x2, bl.y2)) continue
const tt = (adx * (ip.x - apex.x) + ady * (ip.y - apex.y)) / aSq
if (tt <= 1e-3 || tt > 1 + 1e-3) continue
if (tt < bestT) {
bestT = tt
bestPt = ip
}
}
if (bestPt) foot = bestPt
}
const points = [foot.x, foot.y, apex.x, apex.y]
const sz = calcLinePlaneSize({ x1: points[0], y1: points[1], x2: points[2], y2: points[3] })
ridge = new QLine(points, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 4,
name: LINE_TYPE.SUBLINE.RIDGE,
textMode: roof.textMode,
attributes: { roofId: roof.id, planeSize: sz, actualSize: sz },
})
} else {
ridge = new QLine([apex.x, apex.y, apex.x, apex.y], {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: 'rgba(0,0,0,0)',
strokeWidth: 0,
name: LINE_TYPE.SUBLINE.RIDGE,
textMode: roof.textMode,
visible: false,
selectable: false,
attributes: { roofId: roof.id, planeSize: 0, actualSize: 0 },
})
}
ridge.lineName = 'kerabPatternRidge'
ridge.__patternKind = 'kLineOnly'
ridge.__targetId = target.id
ridge.__noKLine = !drawKLine
canvas.add(ridge)
ridge.bringToFront()
roof.innerLines.push(ridge)
// [KERAB-MULTI-APEX 2026-05-22] 추가 apex 각각 별도 kLine ridge — primary 의 apex→foot 방향을 reference 로 ray cast.
// c1·c2 line 에 수직 projection 으로 foot 을 잡으면 추가 apex 가 c1·c2 line 의 반대편이라 ray 방향이 뒤집힘.
// primary 의 unit direction 으로 강제 → primary 와 같은 방향(roofLine 쪽) 으로 kLine 연장.
const auxRidges = []
if (drawKLine && Array.isArray(extraApexes) && extraApexes.length) {
// primary 의 unit direction (apex→foot, roofLine 쪽).
const primDx = c2.x - c1.x
const primDy = c2.y - c1.y
const primLenSq = primDx * primDx + primDy * primDy || 1
const primT = ((apex.x - c1.x) * primDx + (apex.y - c1.y) * primDy) / primLenSq
const primNaiveFoot = { x: c1.x + primT * primDx, y: c1.y + primT * primDy }
const refFdx = primNaiveFoot.x - apex.x
const refFdy = primNaiveFoot.y - apex.y
const refFlen = Math.hypot(refFdx, refFdy) || 1
const refUx = refFdx / refFlen
const refUy = refFdy / refFlen
for (const exApex of extraApexes) {
let foot = { x: exApex.x + refUx, y: exApex.y + refUy }
if (Array.isArray(roof.points) && roof.points.length >= 2) {
const rayEnd = { x: exApex.x + refUx, y: exApex.y + refUy }
let bestPt = null
let bestDist = Infinity
for (let i = 0; i < roof.points.length; i++) {
const a = roof.points[i]
const b = roof.points[(i + 1) % roof.points.length]
const ip = lineLineIntersection(exApex, rayEnd, a, b)
if (!ip) continue
const fwd = (ip.x - exApex.x) * refUx + (ip.y - exApex.y) * refUy
if (fwd <= 1e-3) continue
if (!isPointOnSegment(ip, { x1: a.x, y1: a.y, x2: b.x, y2: b.y })) continue
const dist = Math.hypot(ip.x - exApex.x, ip.y - exApex.y)
if (dist < bestDist) {
bestDist = dist
bestPt = ip
}
}
if (bestPt) foot = bestPt
}
const pts = [foot.x, foot.y, exApex.x, exApex.y]
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
const auxRidge = new QLine(pts, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 4,
name: LINE_TYPE.SUBLINE.RIDGE,
textMode: roof.textMode,
attributes: { roofId: roof.id, planeSize: sz, actualSize: sz },
})
auxRidge.lineName = 'kerabPatternRidge'
auxRidge.__patternKind = 'kLineOnly'
auxRidge.__targetId = target.id
auxRidge.__auxApex = true
canvas.add(auxRidge)
auxRidge.bringToFront()
roof.innerLines.push(auxRidge)
auxRidges.push(auxRidge)
}
ridge.__auxApexRidges = auxRidges
}
// [KERAB-SIMPLE-EXTHIP 2026-05-20] 확장 hip/ridge 추가 — 접점에서 newApex 까지의 연장선.
// 원본 hip + RG-1 제거 전에 먼저 그려두어야 시각적으로 끊김 없이 전환됨.
// revert 시 ridge.__extHipsCreated 참조로 제거.
// [KERAB-EXTENDER-MIXED 2026-05-21] seg.isHip 으로 hip/ridge 구분 (mixed extender 케이스).
if (Array.isArray(extLines) && extLines.length) {
const created = []
for (const seg of extLines) {
if (!seg) continue
const pts = [seg.from.x, seg.from.y, seg.to.x, seg.to.y]
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
const segIsHip = seg.isHip !== false
const extLine = new QLine(pts, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: segIsHip ? LINE_TYPE.SUBLINE.HIP : LINE_TYPE.SUBLINE.RIDGE,
textMode: roof.textMode,
attributes: { roofId: roof.id, planeSize: sz, actualSize: sz },
})
extLine.lineName = segIsHip ? 'kerabPatternExtHip' : 'kerabPatternExtRidge'
extLine.__extended = true
canvas.add(extLine)
extLine.bringToFront()
roof.innerLines.push(extLine)
created.push(extLine)
}
ridge.__extHipsCreated = created
}
// [KERAB-SIMPLE-HIP-REMOVE 2026-05-20] 양쪽 hip 제거 + revert 스냅샷 저장
// [KERAB-POLYGON-BFS 2026-05-21] 폴리곤 경로상 path hip 도 함께 들어옴. lineName/__extended
// 까지 스냅샷에 보존해 revert 시 SK extended flag 등 메타 정보 복원.
if (Array.isArray(hipsToRemove) && hipsToRemove.length) {
const snapshot = []
for (const hip of hipsToRemove) {
if (!hip) continue
snapshot.push({
x1: hip.x1, y1: hip.y1, x2: hip.x2, y2: hip.y2,
attributes: hip.attributes ? { ...hip.attributes } : null,
lineName: hip.lineName,
__extended: hip.__extended,
})
removeLine(hip)
roof.innerLines = roof.innerLines.filter((il) => il !== hip)
}
ridge.__removedHipsSnapshot = snapshot
}
// [KERAB-SIMPLE-MIDRIDGE 2026-05-20] 확장 apex 케이스에서 hip 사이를 잇던 ridge 제거 + revert 스냅샷.
if (Array.isArray(ridgesToRemove) && ridgesToRemove.length) {
const ridgeSnapshot = []
for (const r of ridgesToRemove) {
if (!r) continue
ridgeSnapshot.push({
x1: r.x1,
y1: r.y1,
x2: r.x2,
y2: r.y2,
attributes: r.attributes ? { ...r.attributes } : null,
lineName: r.lineName,
})
removeLine(r)
roof.innerLines = roof.innerLines.filter((il) => il !== r)
}
ridge.__removedRidgesSnapshot = ridgeSnapshot
}
hideOriginalLengthText(target.id, true)
removeKerabHalfLabels(target.id)
addKerabHalfLabels(target, { x: target.x1, y: target.y1 }, { x: target.x2, y: target.y2 })
// [KERAB-SIMPLE-ROOFLABEL 2026-05-20] roofLine 위에도 half 라벨 추가 (c1·c2 기준)
addKerabHalfLabels(target, c1, c2)
canvas.renderAll()
return true
}
const applyKerabSingleRidgePattern = (roof, target, c1, c2, pair) => {
// [2240 KERAB-KLINE-ONLY 2026-05-20] 사용자 결정: 라인 삭제·생성 없이 중앙선(kLine)만 추가.
// - mid(케라바 외곽선 중점) → apex 으로 ridge 1개만 add
// - hip 페어/orphan ext/RG-1 등 기존 라인은 모두 그대로 보존
// - revert 는 __patternKind='kLineOnly' 분기로 ridge 만 제거
// [2240 KERAB-MID-FROM-TARGET 2026-05-20] mid 를 c1·c2(roof corner) 가 아닌 target 외곽선 실제 끝점으로 계산.
// roof.points 와 outerLine 좌표가 50 단위 drift 되어 있어도 외곽선 중점에서 정확히 출발.
const mid = { x: (target.x1 + target.x2) / 2, y: (target.y1 + target.y2) / 2 }
const points = [mid.x, mid.y, pair.apex.x, pair.apex.y]
const sz = calcLinePlaneSize({ x1: points[0], y1: points[1], x2: points[2], y2: points[3] })
const ridge = new QLine(points, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 4,
name: LINE_TYPE.SUBLINE.RIDGE,
textMode: roof.textMode,
attributes: { roofId: roof.id, planeSize: sz, actualSize: sz },
})
ridge.lineName = 'kerabPatternRidge'
ridge.__patternKind = 'kLineOnly'
if (!ridgeMeetsMidpointOf(ridge, { x1: target.x1, y1: target.y1, x2: target.x2, y2: target.y2 })) {
return false
}
canvas.add(ridge)
ridge.bringToFront()
roof.innerLines.push(ridge)
hideOriginalLengthText(target.id, true)
removeKerabHalfLabels(target.id)
addKerabHalfLabels(target, c1, c2)
canvas.renderAll()
return true
}
// [2240 KERAB-KLINE-ONLY 2026-05-20] junction-extended 도 동일: 중앙선만 그리고 기존 라인 무손상.
// mid → jp.apex ridge 1개만 add. ext hip 생성·RG-1 제거·outer hip 제거 전부 skip.
const applyKerabJunctionExtendedPattern = (roof, target, c1, c2, jp) => {
// [2240 KERAB-MID-FROM-TARGET 2026-05-20] mid = target 외곽선 실제 중점 (roof corner drift 제거).
const mid = { x: (target.x1 + target.x2) / 2, y: (target.y1 + target.y2) / 2 }
const ridgePoints = [mid.x, mid.y, jp.apex.x, jp.apex.y]
const ridgeSz = calcLinePlaneSize({ x1: ridgePoints[0], y1: ridgePoints[1], x2: ridgePoints[2], y2: ridgePoints[3] })
const ridge = new QLine(ridgePoints, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 4,
name: LINE_TYPE.SUBLINE.RIDGE,
textMode: roof.textMode,
attributes: { roofId: roof.id, planeSize: ridgeSz, actualSize: ridgeSz },
})
ridge.lineName = 'kerabPatternRidge'
ridge.__patternKind = 'kLineOnly'
if (!ridgeMeetsMidpointOf(ridge, { x1: target.x1, y1: target.y1, x2: target.x2, y2: target.y2 })) return false
canvas.add(ridge)
ridge.bringToFront()
roof.innerLines.push(ridge)
hideOriginalLengthText(target.id, true)
removeKerabHalfLabels(target.id)
addKerabHalfLabels(target, c1, c2)
canvas.renderAll()
return true
}
// [2240 KERAB-KLINE-ONLY 2026-05-20] 양 끝 hip 평행 케이스: apex 가 없어 중앙선조차 그릴 수 없음.
// "라인 삭제 금지 + 중앙선만 그리기" 정책상 hip 제거도 skip → attr-only 와 동일하게 동작.
const applyKerabParallelHipsPattern = () => {
canvas.renderAll()
return true
}
// [2240 KERAB-PARALLEL-HIPS 2026-05-19] revert: target.__kerabParallelHipsSnapshot 기반으로 hip 2개 복원.
const revertKerabParallelHipsPattern = (roof, target) => {
const snaps = Array.isArray(target.__kerabParallelHipsSnapshot) ? target.__kerabParallelHipsSnapshot : []
snaps.forEach((snap) => {
if (!snap) return
const pts = [snap.x1, snap.y1, snap.x2, snap.y2]
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
const hip = new QLine(pts, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 4,
name: LINE_TYPE.SUBLINE.HIP,
textMode: roof.textMode,
attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...snap.attributes },
})
if (snap.lineName) hip.lineName = snap.lineName
if (snap.__extended) hip.__extended = snap.__extended
canvas.add(hip)
hip.bringToFront()
roof.innerLines.push(hip)
})
delete target.__kerabParallelHipsSnapshot
canvas.renderAll()
return true
}
// [2240 KERAB-ATTR-ONLY 2026-05-19] fallback: hip 페어가 1·2·3·평행 어디에도 해당 안 될 때 (hip 0/1 개, apex 가 중앙 아님 등).
// - 외곽선 attributes.type 만 토글, hip 라인/ridge/라벨 등 SK 산출물 전부 무변경
// - 호출자가 이미 target.set({ attributes }) 적용한 상태로 들어옴 → 렌더만 갱신
// - forward/revert 양방향 동일 처리 (대칭)
const applyKerabAttributeOnlyPattern = () => {
canvas.renderAll()
return true
}
return { type, setType, buttonMenu, TYPES, pitchRef, offsetRef, widthRef, radioTypeRef, pitchText }
}