切妻(게이블) 골짜기 변을 처마(eaves)로 전환 시: - collinear 게이블벽(HIP) 제거 후 出幅(offset) surgical 반영 - 양 코너 45° 골짜기 힙 2개 생성, 첫 교차까지 raycast - cleanupDangling: HIP dangler 만 제거, 마루(RIDGE)·VALLEY 절대 보존 - anchor 잃은 마루 끝점: 가로지르는 힙 있으면 절삭, 없으면 roofLine까지 확장 - 확장 마루가 힙 몸통 닿으면 힙 먼 끝 절삭(힙은 마루를 못 넘음) - 새 힙이 기존 마루에 직격(hitKind===RIDGE)이면 반대편 Y 반사 힙 생성 Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
5136 lines
268 KiB
JavaScript
5136 lines
268 KiB
JavaScript
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 { 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 { 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 { 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')
|
|
}
|
|
} 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
|
|
}
|
|
|
|
// [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 가 이미 처리 → 제외.
|
|
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
|
|
}
|
|
const ridgeOps = 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 (!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, ridgeOps, reflectOps }),
|
|
)
|
|
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
|
|
}
|
|
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 }
|
|
// 마루 단축: 케라바변쪽 끝점 → 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→apex, wB→apex. pts=[corner, apex] 라 corner 끝(x1,y1)이 outer(=which 1).
|
|
const mkHip = (corner, side) => {
|
|
const pts = [corner.x, corner.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,
|
|
// [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')
|
|
const hip2 = mkHip(wB, 'B')
|
|
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, inward: { ix: Number(ix.toFixed(3)), iy: Number(iy.toFixed(3)) } }),
|
|
)
|
|
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
|
|
const collinear = isCollinearWithEdge(il.x1, il.y1, il.x2, il.y2)
|
|
// 非평행 라인의 cMid 끝점은 apex 로 수렴, 그 외는 出幅 delta 로 평행이동.
|
|
const relocate = (px, py) => {
|
|
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 }
|
|
}
|