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

2712 lines
133 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 { calcLinePlaneSize } from '@/util/qpolygon-utils'
import { findInteriorPoint } from '@/util/skeleton-utils'
import { logger } from '@/util/logger'
import { applyKerabOffsetSurgical } from '@/util/kerab-offset-surgical'
// [2240 KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 시 出幅 변경분 surgical 반영 기능 토글.
// false 로 두면 이번 세션 변경 전 동작 (apply/revert 시 출폭 입력값 무시) 으로 즉시 회귀.
const ENABLE_KERAB_OFFSET_SURGICAL = 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()
}
const mouseDownEvent = (e) => {
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()
if (!e.target || (e.target && e.target.name !== 'outerLine')) {
return
}
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
}
// [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) {
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
}
}
// [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-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)
const t1 = { x: target.x1, y: target.y1 }
const t2 = { 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))
}
// [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()
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()
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
}
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) => {
const ax = h2Match.near.x - h1Match.near.x
const ay = h2Match.near.y - h1Match.near.y
const aSq = ax * ax + ay * ay || 1
const tFoot = ((apex.x - h1Match.near.x) * ax + (apex.y - h1Match.near.y) * ay) / aSq
const foot = { x: h1Match.near.x + tFoot * ax, y: h1Match.near.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-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 })
}
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,
})
}
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,
))
newReflected.push({
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,
})
}
break
}
if (!processedAny && !pendingKLineCreated) break
extenderPool.push(...newReflected)
}
// [KERAB-ROOF-FALLBACK-ANYWALL 2026-05-21] 미해소 extender 를 roof 폴리곤의 어떤 wall 이라도
// inward 방향의 가장 가까운 wall 교점까지 확장. 반사 hip 이 target wall 과 반대쪽으로
// 향해도 다른 wall 에서 정지.
const roofPolygonWalls = []
if (Array.isArray(roof.points) && roof.points.length >= 2) {
for (let i = 0; i < roof.points.length; i++) {
roofPolygonWalls.push({
a: roof.points[i],
b: roof.points[(i + 1) % roof.points.length],
})
}
}
// [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)
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)
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,
h1Match.near,
h2Match.near,
[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) {
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
}
const valleyExtensions = []
for (const ep of valleyPlannedEndpoints) {
const start = { x: ep.sx, y: ep.sy }
// [KERAB-VALLEY-EXT 2026-05-27] self-extension 방향만 사용 (양 끝점 둘 다 시도).
// concave corner 측 끝점 → polygon 내부로 향해 hip/ridge hit → 그려짐.
// convex 측 끝점 → polygon 외부로 새서 hit 없음 → 자동 skip.
const dx = ep.sx - ep.ox
const dy = ep.sy - ep.oy
const len = Math.hypot(dx, dy) || 1
const ux = dx / len
const uy = dy / len
const FAR_RAY = 1e5
const rayEnd = { x: start.x + ux * FAR_RAY, y: start.y + uy * FAR_RAY }
let bestStop = null
let bestT = Infinity
let bestHit = null
for (const il of roof.innerLines || []) {
if (!il) continue
// [KERAB-VALLEY-EXT 2026-05-27] stopper = HIP / RIDGE 만.
if (il.lineName === 'kerabPatternValleyExt') continue
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
if (il.visible === false) continue
const a = { x: il.x1, y: il.y1 }
const b = { x: il.x2, y: il.y2 }
const ip = lineLineIntersection(start, rayEnd, a, b)
if (!ip) continue
if (!isOnSegV(ip, a.x, a.y, b.x, b.y)) continue
const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy
if (t < 1.0) continue
if (t < bestT) {
bestT = t
bestStop = ip
bestHit = il
}
}
if (bestStop) {
const seg = {
x1: start.x,
y1: start.y,
x2: bestStop.x,
y2: bestStop.y,
source: ep.label,
parent: ep.parent || null,
hitLine: bestHit,
hitPoint: { x: bestStop.x, y: bestStop.y },
hitLineName: bestHit && bestHit.lineName,
hitName: bestHit && bestHit.name,
}
valleyExtensions.push(seg)
logger.log('[KERAB-VALLEY-EXT] generated ' + JSON.stringify(seg))
} else {
logger.log('[KERAB-VALLEY-EXT] no ridge/hip hit for ' + ep.label)
}
}
// [KERAB-VALLEY-EXT-TRIM 2026-05-27] 처마확장 segment 는 1순위로 보존 (위에서 그려진 hitPoint 까지 그대로).
// 다음 단계: 처마확장이 만난 hitLine(hip/ridge) 의 외곽측 끝점을 hitPoint 로 단축(절삭).
// 도미노: 단축된 hitLine 의 옛 외곽 끝점에 닿아있던 다른 hip/ridge 의 그 끝점도 hitPoint 로 cascade 이동.
// revert 시 trimRecords 역순 복원 (도미노 → 원본 trim 순).
// [2026-05-27 비활성] flag=false. junction 공유 hip 까지 cascade 되는 부작용 (예: H-2 의도 외 절삭)
// 확인 — 절삭 모델은 다음 페이즈에서 concave-side-only 판정 추가 후 재활성.
const ENABLE_VALLEY_EXT_TRIM = false
const trimRecords = []
const TOL_DOMINO = 1.0
if (ENABLE_VALLEY_EXT_TRIM) for (const vr of valleyExtensions) {
if (!vr.source || !vr.source.startsWith('roofBase')) continue
if (!vr.hitLine || !vr.hitPoint) continue
const il = vr.hitLine
const hp = vr.hitPoint
const d1 = Math.hypot(il.x1 - vr.x1, il.y1 - vr.y1)
const d2 = Math.hypot(il.x2 - vr.x1, il.y2 - vr.y1)
const trimEnd = d1 < d2 ? 1 : 2
const oldX = trimEnd === 1 ? il.x1 : il.x2
const oldY = trimEnd === 1 ? il.y1 : il.y2
trimRecords.push({
line: il,
end: trimEnd,
oldPt: { x: oldX, y: oldY },
newPt: { x: hp.x, y: hp.y },
originalAttrs: { ...(il.attributes || {}) },
})
if (trimEnd === 1) il.set({ x1: hp.x, y1: hp.y })
else il.set({ x2: hp.x, y2: hp.y })
const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz }
logger.log(
'[KERAB-VALLEY-EXT-TRIM] trim ' +
JSON.stringify({ name: il.name, lineName: il.lineName, trimEnd, oldPt: { x: oldX, y: oldY }, hp }),
)
for (const other of roof.innerLines || []) {
if (!other || other === il) continue
if (other.lineName === 'kerabPatternValleyExt') continue
if (other.name !== LINE_TYPE.SUBLINE.HIP && other.name !== LINE_TYPE.SUBLINE.RIDGE) continue
const m1 = Math.hypot(other.x1 - oldX, other.y1 - oldY) <= TOL_DOMINO
const m2 = Math.hypot(other.x2 - oldX, other.y2 - oldY) <= TOL_DOMINO
if (!m1 && !m2) continue
trimRecords.push({
line: other,
end: m1 ? 1 : 2,
oldPt: { x: oldX, y: oldY },
newPt: { x: hp.x, y: hp.y },
originalAttrs: { ...(other.attributes || {}) },
domino: true,
})
if (m1) other.set({ x1: hp.x, y1: hp.y })
else other.set({ x2: hp.x, y2: hp.y })
const oSz = calcLinePlaneSize({ x1: other.x1, y1: other.y1, x2: other.x2, y2: other.y2 })
other.attributes = { ...other.attributes, planeSize: oSz, actualSize: oSz }
logger.log(
'[KERAB-VALLEY-EXT-TRIM] domino ' + JSON.stringify({ name: other.name, lineName: other.lineName, hp }),
)
}
}
if (trimRecords.length) target.__valleyExtTrims = trimRecords
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'))
const baseAttrs = { roofId: roof.id, planeSize: sz, actualSize: sz }
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)
}
if (valleyExtensions.length) {
logger.log('[KERAB-VALLEY-EXT] drawn ' + valleyExtensions.length)
}
}
logger.log('[KERAB-SIMPLE] applied (sequential, drawKLine=' + drawKLine + ', extraApexes=' + extraApexes.length + ')')
return
}
logger.log('[KERAB-SIMPLE] no resolution possible — fallback attr-only')
target.set({ attributes })
applyKerabAttributeOnlyPattern()
return
}
// condition 1: 자연 만남 — ext 없이 hip 제거 + kLine
target.set({ attributes })
applyKerabKLinePattern(
roof,
target,
apex,
h1Match.near,
h2Match.near,
[h1Match.hip, h2Match.hip],
null,
null,
)
logger.log('[KERAB-SIMPLE] applied (kLine, natural-apex)')
return
}
}
target.set({ attributes })
applyKerabAttributeOnlyPattern()
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)) {
const c1 = nearestRoofPoint(roof, { x: target.x1, y: target.y1 })
const c2 = nearestRoofPoint(roof, { x: target.x2, y: target.y2 })
if (c1 && c2) {
// [KERAB-OFFSET-SURGICAL 2026-05-27] revert 경로에서는 hip snapshot 좌표가 옛 corner 기준이라
// surgical 을 pattern 호출 **후**로 미룬다. pattern 이 옛 corner 로 hip 복원 후, surgical 의
// inner-line corner snap 이 hip 끝점도 새 corner 로 함께 이동시킨다.
const surgicalAfter = () => {
if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0)
}
// [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 })
const ok = applyKerabRevertPattern(roof, target, c1, c2, { ridge: kLineRidge, apex: null })
if (ok) surgicalAfter()
logger.log('[KERAB-REVERT] applied (kLineOnly via targetId) ' + JSON.stringify({ ok }))
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)
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
}
// [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) => {
if (!ENABLE_KERAB_OFFSET_SURGICAL) return false
return applyKerabOffsetSurgical(canvas, target, newOffset)
}
// [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 전체 스캔.
const valleyExtsToRemove = (canvas.getObjects() || []).filter(
(il) => il && il.lineName === 'kerabPatternValleyExt' && 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
if (rec.end === 1) il.set({ x1: rec.oldPt.x, y1: rec.oldPt.y })
else il.set({ x2: rec.oldPt.x, y2: rec.oldPt.y })
if (rec.originalAttrs) il.attributes = rec.originalAttrs
}
logger.log('[KERAB-VALLEY-EXT-TRIM] revert restored ' + target.__valleyExtTrims.length + ' trim(s)')
delete target.__valleyExtTrims
}
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
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)
}
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
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-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-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 }
}