dev #910
@ -1141,55 +1141,6 @@ export function useModule() {
|
|||||||
canvas.renderAll()
|
canvas.renderAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// [MODULE-MULTI-SELECT 2026-06-10] 드래그 완료 후 지붕면 이탈/겹침 검증 → 위반 시 startPos 복원, 정상이면 확정
|
|
||||||
const commitModuleDragMove = (target, startPos) => {
|
|
||||||
if (!canvas || !target) return
|
|
||||||
const isGroup = target.type === 'activeSelection'
|
|
||||||
const modules = isGroup
|
|
||||||
? target.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE)
|
|
||||||
: target.name === POLYGON_TYPE.MODULE
|
|
||||||
? [target]
|
|
||||||
: []
|
|
||||||
if (modules.length === 0) return
|
|
||||||
|
|
||||||
const movingIds = new Set(modules.map((module) => module.id))
|
|
||||||
const objects = getObjects()
|
|
||||||
|
|
||||||
const invalid = modules.some((module) => {
|
|
||||||
const surface = canvas.getObjects().find((obj) => obj.id === module.surfaceId && obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE)
|
|
||||||
if (surface && isOutsideSurface(module, surface)) return true
|
|
||||||
const others = canvas
|
|
||||||
.getObjects()
|
|
||||||
.filter((obj) => obj.name === POLYGON_TYPE.MODULE && obj.surfaceId === module.surfaceId && !movingIds.has(obj.id))
|
|
||||||
if (isOverlapOtherModules(module, others)) return true
|
|
||||||
if (isOverlapObjects(module, objects)) return true
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
// 위반 시 드래그 시작 위치로 복원 (그룹/단일 공통 — 둘 다 left/top 평행이동)
|
|
||||||
if (invalid && startPos) {
|
|
||||||
target.set({ left: startPos.left, top: startPos.top })
|
|
||||||
target.setCoords()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 그룹은 자식 좌표가 상대값이라 discard 로 절대좌표 베이킹. 단일은 이미 절대좌표라 선택 유지.
|
|
||||||
if (isGroup) {
|
|
||||||
canvas.discardActiveObject()
|
|
||||||
}
|
|
||||||
|
|
||||||
const surfaceIds = [...new Set(modules.map((module) => module.surfaceId))]
|
|
||||||
surfaceIds.forEach((surfaceId) => recalculateAllModulesCoords(surfaceId))
|
|
||||||
canvas.requestRenderAll()
|
|
||||||
|
|
||||||
if (invalid) {
|
|
||||||
swalFire({
|
|
||||||
title: getMessage('can.not.move.module'),
|
|
||||||
icon: 'error',
|
|
||||||
type: 'alert',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [PLAN-CORRUPT-RESTORE 2026-05-18] 손상 module 좌표 복원.
|
* [PLAN-CORRUPT-RESTORE 2026-05-18] 손상 module 좌표 복원.
|
||||||
*
|
*
|
||||||
@ -1329,7 +1280,6 @@ export function useModule() {
|
|||||||
moduleRoofRemove,
|
moduleRoofRemove,
|
||||||
alignModule,
|
alignModule,
|
||||||
recalculateAllModulesCoords,
|
recalculateAllModulesCoords,
|
||||||
commitModuleDragMove,
|
|
||||||
restoreCorruptedModules,
|
restoreCorruptedModules,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,10 @@ import { applyKerabOffsetSurgical } from '@/util/kerab-offset-surgical'
|
|||||||
// false 로 두면 이번 세션 변경 전 동작 (apply/revert 시 출폭 입력값 무시) 으로 즉시 회귀.
|
// false 로 두면 이번 세션 변경 전 동작 (apply/revert 시 출폭 입력값 무시) 으로 즉시 회귀.
|
||||||
const ENABLE_KERAB_OFFSET_SURGICAL = true
|
const ENABLE_KERAB_OFFSET_SURGICAL = true
|
||||||
|
|
||||||
|
// [KERAB-TYPE-EAVES 2026-06-11] TYPE(A/B 박공) 출신 케라바→처마 전용 변환 토글.
|
||||||
|
// false 면 기존 applyKerabRevertPattern 폴백(토글 이력 기반) 으로 회귀.
|
||||||
|
const ENABLE_TYPE_GABLE_TO_EAVES = true
|
||||||
|
|
||||||
// 처마.케라바 변경
|
// 처마.케라바 변경
|
||||||
export function useEavesGableEdit(id) {
|
export function useEavesGableEdit(id) {
|
||||||
const canvas = useRecoilValue(canvasState)
|
const canvas = useRecoilValue(canvasState)
|
||||||
@ -152,6 +156,136 @@ export function useEavesGableEdit(id) {
|
|||||||
canvas.renderAll()
|
canvas.renderAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [KERAB-RULE-CHECK 2026-06-10] 케라바(처마↔게이블) 토글 종료 시 결과가 도메인 규칙에
|
||||||
|
// 맞는지 자동 판정하는 진단 단계. forward/revert 양쪽 끝에서 호출. 로컬 전용 — 위반은
|
||||||
|
// logger.warn 로 위반 라인만 덤프(production 은 DCE 로 제거).
|
||||||
|
// 규칙:
|
||||||
|
// R1 dangling: 모든 visible hip/ridge 끝점은 roofLine 코너에 닿거나 다른 inner line 과
|
||||||
|
// 공유돼야 한다(떠 있는 끝점=벽 교점/코너 이탈). 골짜기 내부 hip 은 양 끝이
|
||||||
|
// 다른 라인과 공유되므로 자동 통과(예외 불필요).
|
||||||
|
// R2 zero-length: 길이 0 으로 붕괴된 visible 라인(=라인 소실) 금지.
|
||||||
|
// R3 outside: 끝점이 roofLine 폴리곤 밖(경계 tol 초과)으로 이탈 금지.
|
||||||
|
// R4 anchor: 토글 전후로 움직이지 않은 roofLine 코너(stable corner)의 끝점 점유수 불변
|
||||||
|
// (코너에서 hip 이 떨어지거나 엉뚱한 코너로 횡단하면 점유수 변화).
|
||||||
|
const snapshotKerabState = (roof) => {
|
||||||
|
if (!roof || !Array.isArray(roof.innerLines)) return null
|
||||||
|
const lines = roof.innerLines
|
||||||
|
.filter((l) => l && (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE) && l.visible !== false)
|
||||||
|
.map((l) => ({ x1: l.x1, y1: l.y1, x2: l.x2, y2: l.y2 }))
|
||||||
|
const points = (Array.isArray(roof.points) ? roof.points : []).map((p) => ({ x: p.x, y: p.y }))
|
||||||
|
return { lines, points }
|
||||||
|
}
|
||||||
|
const runKerabRuleCheck = (roof, phase, before) => {
|
||||||
|
try {
|
||||||
|
if (!roof || !Array.isArray(roof.innerLines)) return
|
||||||
|
const TOL = 2.0
|
||||||
|
const OUT_TOL = 3.0
|
||||||
|
const ZERO = 1.0
|
||||||
|
const r1 = (n) => Math.round(n * 10) / 10
|
||||||
|
const fails = []
|
||||||
|
const rpts = Array.isArray(roof.points) ? roof.points : []
|
||||||
|
// 검사 대상 = visible 마루/힙. 끝점 공유(접합) 판정에는 골짜기확장(VALLEY)까지 포함 —
|
||||||
|
// RG-1 확장(kerabPatternExtRidge)은 vExt(VALLEY) 위에서 끝나므로 VALLEY 를 빼면 오탐.
|
||||||
|
const visLines = roof.innerLines.filter(
|
||||||
|
(l) => l && (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE) && l.visible !== false,
|
||||||
|
)
|
||||||
|
const connLines = roof.innerLines.filter(
|
||||||
|
(l) =>
|
||||||
|
l &&
|
||||||
|
(l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE || l.name === LINE_TYPE.SUBLINE.VALLEY) &&
|
||||||
|
l.visible !== false,
|
||||||
|
)
|
||||||
|
const info = (l) => ({ n: l.name, ln: l.lineName || '-', x1: r1(l.x1), y1: r1(l.y1), x2: r1(l.x2), y2: r1(l.y2) })
|
||||||
|
const onCorner = (p) => rpts.some((c) => c && Math.hypot(c.x - p.x, c.y - p.y) < TOL)
|
||||||
|
const ends = []
|
||||||
|
for (const l of connLines) {
|
||||||
|
ends.push({ x: l.x1, y: l.y1, line: l })
|
||||||
|
ends.push({ x: l.x2, y: l.y2, line: l })
|
||||||
|
}
|
||||||
|
const sharedWithOther = (p, self) => ends.some((e) => e.line !== self && Math.hypot(e.x - p.x, e.y - p.y) < TOL)
|
||||||
|
// 폴리곤 내부/경계 판정 (ray-casting + edge 거리 tol)
|
||||||
|
const pip = (pt) => {
|
||||||
|
let inside = false
|
||||||
|
for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) {
|
||||||
|
const xi = rpts[i].x
|
||||||
|
const yi = rpts[i].y
|
||||||
|
const xj = rpts[j].x
|
||||||
|
const yj = rpts[j].y
|
||||||
|
const intersect = yi > pt.y !== yj > pt.y && pt.x < ((xj - xi) * (pt.y - yi)) / (yj - yi + 1e-12) + xi
|
||||||
|
if (intersect) inside = !inside
|
||||||
|
}
|
||||||
|
return inside
|
||||||
|
}
|
||||||
|
const distToSeg = (p, a, b) => {
|
||||||
|
const dx = b.x - a.x
|
||||||
|
const dy = b.y - a.y
|
||||||
|
const l2 = dx * dx + dy * dy || 1
|
||||||
|
let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2
|
||||||
|
t = Math.max(0, Math.min(1, t))
|
||||||
|
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))
|
||||||
|
}
|
||||||
|
const minEdgeDist = (pt) => {
|
||||||
|
let m = Infinity
|
||||||
|
for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) {
|
||||||
|
const d = distToSeg(pt, rpts[j], rpts[i])
|
||||||
|
if (d < m) m = d
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
for (const l of visLines) {
|
||||||
|
const endpts = [
|
||||||
|
{ x: l.x1, y: l.y1 },
|
||||||
|
{ x: l.x2, y: l.y2 },
|
||||||
|
]
|
||||||
|
// R2 zero-length
|
||||||
|
if (Math.hypot(l.x2 - l.x1, l.y2 - l.y1) < ZERO) {
|
||||||
|
fails.push({ rule: 'R2-zero-length', line: info(l) })
|
||||||
|
}
|
||||||
|
for (const p of endpts) {
|
||||||
|
// R1 dangling: 끝점은 roofLine 경계(코너 + 변)에 닿거나 다른 내부선과 공유돼야 한다.
|
||||||
|
// kLine(중앙 마루)·게이블 hip 은 roofLine '코너'가 아닌 '변' 중간에 닿는 게 정상 →
|
||||||
|
// 코너만 보면 오탐. minEdgeDist 로 변까지 포함해 경계 도달을 판정한다.
|
||||||
|
const onBoundary = rpts.length >= 3 ? minEdgeDist(p) <= TOL : onCorner(p)
|
||||||
|
if (!onBoundary && !sharedWithOther(p, l)) {
|
||||||
|
fails.push({ rule: 'R1-dangling', line: info(l), at: { x: r1(p.x), y: r1(p.y) } })
|
||||||
|
}
|
||||||
|
// R3 outside roofLine
|
||||||
|
if (rpts.length >= 3 && !pip(p) && minEdgeDist(p) > OUT_TOL) {
|
||||||
|
fails.push({ rule: 'R3-outside', line: info(l), at: { x: r1(p.x), y: r1(p.y) } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// R4 anchor: stable roofLine corner 점유수 불변
|
||||||
|
if (before && Array.isArray(before.points) && Array.isArray(before.lines)) {
|
||||||
|
const countOn = (lineArr, c) => {
|
||||||
|
let n = 0
|
||||||
|
for (const l of lineArr) {
|
||||||
|
if (Math.hypot(l.x1 - c.x, l.y1 - c.y) < TOL) n++
|
||||||
|
if (Math.hypot(l.x2 - c.x, l.y2 - c.y) < TOL) n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
const afterPlain = visLines.map((l) => ({ x1: l.x1, y1: l.y1, x2: l.x2, y2: l.y2 }))
|
||||||
|
for (const c of rpts) {
|
||||||
|
const stable = before.points.some((b) => Math.hypot(b.x - c.x, b.y - c.y) < TOL)
|
||||||
|
if (!stable) continue
|
||||||
|
const bN = countOn(before.lines, c)
|
||||||
|
const aN = countOn(afterPlain, c)
|
||||||
|
if (bN !== aN) {
|
||||||
|
fails.push({ rule: 'R4-anchor', at: { x: r1(c.x), y: r1(c.y) }, before: bN, after: aN })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fails.length) {
|
||||||
|
logger.warn('[KERAB-RULE-CHECK] ' + phase + ' FAIL(' + fails.length + ') ' + JSON.stringify(fails))
|
||||||
|
} else {
|
||||||
|
logger.log('[KERAB-RULE-CHECK] ' + phase + ' PASS')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('[KERAB-RULE-CHECK] error', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mouseDownEvent = (e) => {
|
const mouseDownEvent = (e) => {
|
||||||
// [KERAB-MOUSEDOWN-GUARD 2026-05-29] outerLine 아닌 target(ridge/lengthText 등) 클릭 시
|
// [KERAB-MOUSEDOWN-GUARD 2026-05-29] outerLine 아닌 target(ridge/lengthText 등) 클릭 시
|
||||||
// discardActiveObject·로그·후속 처리 모두 skip — 다른 hook 의 active 흐름 보호.
|
// discardActiveObject·로그·후속 처리 모두 skip — 다른 hook 의 active 흐름 보호.
|
||||||
@ -238,6 +372,53 @@ export function useEavesGableEdit(id) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [KERAB-STATE-DUMP 2026-06-11] A/B타입(가로/세로 케라바) 토글 진입 시점 지붕 상태 진단.
|
||||||
|
// 벽 type 지정은 있는데 내부 skLine 이 0인 "무에서 유" 케이스 파악용 — 사용자 설명 전 사실 수집.
|
||||||
|
// 읽기 전용(좌표/visible/type 만 덤프), 동작 변경 없음. logger 게이트(local 만 출력).
|
||||||
|
{
|
||||||
|
const _dr = canvas
|
||||||
|
.getObjects()
|
||||||
|
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
|
||||||
|
const _r1 = (v) => (typeof v === 'number' ? Math.round(v * 10) / 10 : v)
|
||||||
|
logger.log(
|
||||||
|
'[KERAB-STATE-DUMP] ' +
|
||||||
|
JSON.stringify({
|
||||||
|
uiType: typeRef.current,
|
||||||
|
radio: radioTypeRef.current,
|
||||||
|
target: {
|
||||||
|
type: target.attributes?.type,
|
||||||
|
offset: target.attributes?.offset,
|
||||||
|
x1: _r1(target.x1),
|
||||||
|
y1: _r1(target.y1),
|
||||||
|
x2: _r1(target.x2),
|
||||||
|
y2: _r1(target.y2),
|
||||||
|
},
|
||||||
|
willBecome: attributes?.type,
|
||||||
|
roofId: target.attributes?.roofId,
|
||||||
|
roofFound: !!_dr,
|
||||||
|
points: _dr?.points?.map((p) => ({ x: _r1(p.x), y: _r1(p.y) })),
|
||||||
|
lines: _dr?.lines?.map((l) => ({
|
||||||
|
type: l.attributes?.type,
|
||||||
|
offset: l.attributes?.offset,
|
||||||
|
x1: _r1(l.x1),
|
||||||
|
y1: _r1(l.y1),
|
||||||
|
x2: _r1(l.x2),
|
||||||
|
y2: _r1(l.y2),
|
||||||
|
})),
|
||||||
|
innerCount: (_dr?.innerLines || []).length,
|
||||||
|
innerLines: (_dr?.innerLines || []).map((il) => ({
|
||||||
|
lineName: il.lineName,
|
||||||
|
type: il.attributes?.type,
|
||||||
|
visible: il.visible !== false,
|
||||||
|
x1: _r1(il.x1),
|
||||||
|
y1: _r1(il.y1),
|
||||||
|
x2: _r1(il.x2),
|
||||||
|
y2: _r1(il.y2),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// [2240 KERAB-NOOP-REKLICK 2026-05-19] 같은 type 으로의 재클릭은 무동작.
|
// [2240 KERAB-NOOP-REKLICK 2026-05-19] 같은 type 으로의 재클릭은 무동작.
|
||||||
// - 케라바→케라바, 처마→처마 등. 기존 rebuild 흐름이 다시 돌면 패턴 상태
|
// - 케라바→케라바, 처마→처마 등. 기존 rebuild 흐름이 다시 돌면 패턴 상태
|
||||||
// (ridge/half-label/orphan ext 정리)를 망가뜨림.
|
// (ridge/half-label/orphan ext 정리)를 망가뜨림.
|
||||||
@ -461,6 +642,8 @@ export function useEavesGableEdit(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
dumpInnerLineSnapshot('BEFORE')
|
dumpInnerLineSnapshot('BEFORE')
|
||||||
|
// [KERAB-RULE-CHECK 2026-06-10] surgical 전(원본 출폭) 상태를 R4 anchor 기준으로 캡처.
|
||||||
|
const kerabBeforeSnap = snapshotKerabState(roof)
|
||||||
// [KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 직전 target.attributes.offset 을 roofLine 에 surgical 반영.
|
// [KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 직전 target.attributes.offset 을 roofLine 에 surgical 반영.
|
||||||
// SK 재실행 없이 외곽 corner / inner-line endpoint 만 이동 → kLine 등 layered custom 라인 보존.
|
// SK 재실행 없이 외곽 corner / inner-line endpoint 만 이동 → kLine 등 layered custom 라인 보존.
|
||||||
if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0)
|
if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0)
|
||||||
@ -668,6 +851,7 @@ export function useEavesGableEdit(id) {
|
|||||||
logger.log('[KERAB-SIMPLE] no polygon path → attr-only fallback')
|
logger.log('[KERAB-SIMPLE] no polygon path → attr-only fallback')
|
||||||
target.set({ attributes })
|
target.set({ attributes })
|
||||||
applyKerabAttributeOnlyPattern()
|
applyKerabAttributeOnlyPattern()
|
||||||
|
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const polygonLines = [h1Match.hip, h2Match.hip, ...polygonPath.map((p) => p.line)]
|
const polygonLines = [h1Match.hip, h2Match.hip, ...polygonPath.map((p) => p.line)]
|
||||||
@ -742,6 +926,7 @@ export function useEavesGableEdit(id) {
|
|||||||
logger.log('[KERAB-SIMPLE] no extenders — fallback attr-only')
|
logger.log('[KERAB-SIMPLE] no extenders — fallback attr-only')
|
||||||
target.set({ attributes })
|
target.set({ attributes })
|
||||||
applyKerabAttributeOnlyPattern()
|
applyKerabAttributeOnlyPattern()
|
||||||
|
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 인쪽 방향 검증: extender 자연 방향(near→far)의 반대(near→inward) 와 일치해야 함.
|
// 인쪽 방향 검증: extender 자연 방향(near→far)의 반대(near→inward) 와 일치해야 함.
|
||||||
@ -1720,36 +1905,17 @@ export function useEavesGableEdit(id) {
|
|||||||
wallHit = ip
|
wallHit = ip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let ridgeStop = null
|
// [KERAB-VALLEY-HALF 2026-06-11] A/B 타입 표준 규칙(방향 무관):
|
||||||
let ridgeT = Infinity
|
// 내부확장라인은 맞은편 roofLine 까지 거리의 "절반"에서 멈춘다.
|
||||||
// [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 원래 ridge 만 stop (roof 측 post 경로).
|
// 기존 ridge-stop / wall-끝까지 휴리스틱은 ridge 가 우연히 중간에 있으면 절반처럼,
|
||||||
for (const il of roof.innerLines || []) {
|
// 없으면 끝까지 가버려 방향(세로/가로)에 따라 결과가 들쭉날쭉했다 → 항상 절반으로 통일.
|
||||||
if (!il || il.visible === false) continue
|
// wallHit = 이 ray 가 만나는 맞은편 roof 외곽선(roofLine). 그 중점이 stop.
|
||||||
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
|
if (wallHit) {
|
||||||
if (il.lineName !== 'ridge') continue
|
bestStop = { x: (start.x + wallHit.x) / 2, y: (start.y + wallHit.y) / 2 }
|
||||||
const ip = lineLineIntersection(start, rayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
|
|
||||||
if (!ip) continue
|
|
||||||
if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue
|
|
||||||
const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy
|
|
||||||
if (t < 0.5) continue
|
|
||||||
if (wallT !== Infinity && t > wallT + 0.5) continue
|
|
||||||
if (t < ridgeT) {
|
|
||||||
ridgeT = t
|
|
||||||
ridgeStop = ip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (ridgeStop) {
|
|
||||||
bestStop = ridgeStop
|
|
||||||
logger.log(
|
logger.log(
|
||||||
'[KERAB-VALLEY-EXT-RIDGE-STOP] post label=' + ep.label +
|
'[KERAB-VALLEY-HALF] post label=' + ep.label +
|
||||||
' ridgeT=' + Math.round(ridgeT * 100) / 100 +
|
' wallHit={' + Math.round(wallHit.x * 100) / 100 + ',' + Math.round(wallHit.y * 100) / 100 + '}' +
|
||||||
' stop={' + Math.round(bestStop.x * 100) / 100 + ',' + Math.round(bestStop.y * 100) / 100 + '}',
|
' halfStop={' + Math.round(bestStop.x * 100) / 100 + ',' + Math.round(bestStop.y * 100) / 100 + '}',
|
||||||
)
|
|
||||||
} else if (wallHit) {
|
|
||||||
bestStop = wallHit
|
|
||||||
logger.log(
|
|
||||||
'[KERAB-VALLEY-EXT-WALL] post label=' + ep.label +
|
|
||||||
' end={' + Math.round(bestStop.x * 100) / 100 + ',' + Math.round(bestStop.y * 100) / 100 + '}',
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (bestStop) {
|
if (bestStop) {
|
||||||
@ -1915,15 +2081,11 @@ export function useEavesGableEdit(id) {
|
|||||||
const offX = newWStart.x - vStart.x
|
const offX = newWStart.x - vStart.x
|
||||||
const offY = newWStart.y - vStart.y
|
const offY = newWStart.y - vStart.y
|
||||||
const wEndProj = { x: vEnd.x + offX, y: vEnd.y + offY }
|
const wEndProj = { x: vEnd.x + offX, y: vEnd.y + offY }
|
||||||
// wEnd(원 ray hit) 가 wEndProj 보다 ray 방향으로 더 멀리 있는지 — newWStart 기준 투영 비교.
|
|
||||||
const tProj = (wEndProj.x - newWStart.x) * wUx + (wEndProj.y - newWStart.y) * wUy
|
|
||||||
const tEndR = (wEnd.x - newWStart.x) * wUx + (wEnd.y - newWStart.y) * wUy
|
|
||||||
buildOverlapLine(newWStart, wEndProj, '-wall')
|
buildOverlapLine(newWStart, wEndProj, '-wall')
|
||||||
buildOverlapLine(vStart, newWStart, '-bridge-start')
|
buildOverlapLine(vStart, newWStart, '-bridge-start')
|
||||||
buildOverlapLine(vEnd, wEndProj, '-bridge-end')
|
buildOverlapLine(vEnd, wEndProj, '-bridge-end')
|
||||||
if (tEndR > tProj + 0.5) {
|
// [KERAB-VALLEY-HALF 2026-06-11] 절반 규칙: wLine(wallBase) 도 vEnd(절반) 까지만.
|
||||||
buildOverlapLine(wEndProj, wEnd, '-extend')
|
// wEndProj 는 절반인 vEnd 에서 파생되므로, wEnd(원 ray full hit) 너머 확장은 금지.
|
||||||
}
|
|
||||||
logger.log(
|
logger.log(
|
||||||
'[KERAB-VALLEY-OVERLAP] drawn ' +
|
'[KERAB-VALLEY-OVERLAP] drawn ' +
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@ -1934,7 +2096,7 @@ export function useEavesGableEdit(id) {
|
|||||||
wEnd: { x: Math.round(wEnd.x * 100) / 100, y: Math.round(wEnd.y * 100) / 100 },
|
wEnd: { x: Math.round(wEnd.x * 100) / 100, y: Math.round(wEnd.y * 100) / 100 },
|
||||||
vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 },
|
vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 },
|
||||||
vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 },
|
vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 },
|
||||||
extended: tEndR > tProj + 0.5,
|
extended: false,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1965,6 +2127,16 @@ export function useEavesGableEdit(id) {
|
|||||||
if (!ip) continue
|
if (!ip) continue
|
||||||
if (!isOnSegV(ip, vStart.x, vStart.y, vEnd.x, vEnd.y)) continue
|
if (!isOnSegV(ip, vStart.x, vStart.y, vEnd.x, vEnd.y)) continue
|
||||||
if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue
|
if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue
|
||||||
|
// [VALLEY-EXT-TRIM-ENDPOINT-GUARD 2026-06-10] ip 가 il 끝점과 일치하면
|
||||||
|
// 그 라인은 vExt 를 가로지르는(cross) 게 아니라 vExt 에서 끝나는(terminate)
|
||||||
|
// 라인(예: RG-1 연장선, 한 끝이 이미 vExt 위). 반대 끝을 ip 로 절삭하면 양 끝이
|
||||||
|
// 같은 점이 되어 길이 0 붕괴 → 라인 소실. 절삭은 막되, 끝점이 vExt 내부점이면
|
||||||
|
// split 은 여전히 필요(그래프 노드 공유 → 할당 dead-end 방지)하므로 split 전용
|
||||||
|
// 레코드만 push 한다(좌표 변경/cascade 없음). revert 는 splitOnly 를 건너뛴다.
|
||||||
|
if (Math.hypot(ip.x - il.x1, ip.y - il.y1) < 1.0 || Math.hypot(ip.x - il.x2, ip.y - il.y2) < 1.0) {
|
||||||
|
newTrimRecords.push({ line: il, splitOnly: true, newPt: { x: ip.x, y: ip.y } })
|
||||||
|
continue
|
||||||
|
}
|
||||||
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] 절삭 방향 — V apex 우선 룰.
|
// [KERAB-VALLEY-EXT-TRIM 2026-05-29] 절삭 방향 — V apex 우선 룰.
|
||||||
// V apex = 케라바 확장 hip(kerabPatternHip/kerabPatternExtHip) 두 개의 교점.
|
// V apex = 케라바 확장 hip(kerabPatternHip/kerabPatternExtHip) 두 개의 교점.
|
||||||
// 원래 hip(lineName='hip') 모임은 V apex 아님 — Y 출발점 룰은 케라바 패턴 한정.
|
// 원래 hip(lineName='hip') 모임은 V apex 아님 — Y 출발점 룰은 케라바 패턴 한정.
|
||||||
@ -2313,6 +2485,7 @@ export function useEavesGableEdit(id) {
|
|||||||
if (trimRecords.length) target.__valleyExtTrims = trimRecords
|
if (trimRecords.length) target.__valleyExtTrims = trimRecords
|
||||||
}
|
}
|
||||||
dumpInnerLineSnapshot('AFTER')
|
dumpInnerLineSnapshot('AFTER')
|
||||||
|
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
|
||||||
logger.log('[KERAB-SIMPLE] applied (sequential, drawKLine=' + drawKLine + ', extraApexes=' + extraApexes.length + ')')
|
logger.log('[KERAB-SIMPLE] applied (sequential, drawKLine=' + drawKLine + ', extraApexes=' + extraApexes.length + ')')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -2320,6 +2493,7 @@ export function useEavesGableEdit(id) {
|
|||||||
logger.log('[KERAB-SIMPLE] no resolution possible — fallback attr-only')
|
logger.log('[KERAB-SIMPLE] no resolution possible — fallback attr-only')
|
||||||
target.set({ attributes })
|
target.set({ attributes })
|
||||||
applyKerabAttributeOnlyPattern()
|
applyKerabAttributeOnlyPattern()
|
||||||
|
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// condition 1: 자연 만남 — ext 없이 hip 제거 + kLine
|
// condition 1: 자연 만남 — ext 없이 hip 제거 + kLine
|
||||||
@ -2335,12 +2509,14 @@ export function useEavesGableEdit(id) {
|
|||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
dumpInnerLineSnapshot('AFTER')
|
dumpInnerLineSnapshot('AFTER')
|
||||||
|
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
|
||||||
logger.log('[KERAB-SIMPLE] applied (kLine, natural-apex)')
|
logger.log('[KERAB-SIMPLE] applied (kLine, natural-apex)')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
target.set({ attributes })
|
target.set({ attributes })
|
||||||
applyKerabAttributeOnlyPattern()
|
applyKerabAttributeOnlyPattern()
|
||||||
|
runKerabRuleCheck(roof, 'forward', kerabBeforeSnap)
|
||||||
logger.log('[KERAB-SIMPLE] attr-only fallback')
|
logger.log('[KERAB-SIMPLE] attr-only fallback')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -2354,6 +2530,8 @@ export function useEavesGableEdit(id) {
|
|||||||
.getObjects()
|
.getObjects()
|
||||||
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
|
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
|
||||||
if (roof && Array.isArray(roof.innerLines) && Array.isArray(roof.points)) {
|
if (roof && Array.isArray(roof.innerLines) && Array.isArray(roof.points)) {
|
||||||
|
// [KERAB-RULE-CHECK 2026-06-10] revert 변환 전(게이블 출폭) 상태를 R4 anchor 기준으로 캡처.
|
||||||
|
const kerabRevertBeforeSnap = snapshotKerabState(roof)
|
||||||
const c1 = nearestRoofPoint(roof, { x: target.x1, y: target.y1 })
|
const c1 = nearestRoofPoint(roof, { x: target.x1, y: target.y1 })
|
||||||
const c2 = nearestRoofPoint(roof, { x: target.x2, y: target.y2 })
|
const c2 = nearestRoofPoint(roof, { x: target.x2, y: target.y2 })
|
||||||
if (c1 && c2) {
|
if (c1 && c2) {
|
||||||
@ -2369,6 +2547,9 @@ export function useEavesGableEdit(id) {
|
|||||||
// 증상: (1) b1 ray-cast 후 b4 출폭조정 → SHRINK-TRIM restore 가 b1 hip 을 snapshot 좌표로 리셋
|
// 증상: (1) b1 ray-cast 후 b4 출폭조정 → SHRINK-TRIM restore 가 b1 hip 을 snapshot 좌표로 리셋
|
||||||
// (2) CORNER-SHORTCUT 가 hip outer endpoint 를 새 roofLine 코너로 강제 스냅.
|
// (2) CORNER-SHORTCUT 가 hip outer endpoint 를 새 roofLine 코너로 강제 스냅.
|
||||||
// hip 의 outer endpoint 만 아래 ray-cast 로 새 rL 변까지 45° 확장한다.
|
// hip 의 outer endpoint 만 아래 ray-cast 로 새 rL 변까지 45° 확장한다.
|
||||||
|
// [KERAB-REVERT-VERTEX-EXTEND 2026-06-11] surgical 은 roof.points 를 새 출폭으로 갱신하므로,
|
||||||
|
// 게이블 코너 hip 판정용으로 이동 전 옛 폴리곤 꼭짓점을 미리 스냅샷한다.
|
||||||
|
const oldPolyPoints = (Array.isArray(roof.points) ? roof.points : []).map((p) => ({ x: p.x, y: p.y }))
|
||||||
if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0, { skipInnerLines: true })
|
if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0, { skipInnerLines: true })
|
||||||
const wA = { x: target.x1, y: target.y1 }
|
const wA = { x: target.x1, y: target.y1 }
|
||||||
const wB = { x: target.x2, y: target.y2 }
|
const wB = { x: target.x2, y: target.y2 }
|
||||||
@ -2394,35 +2575,83 @@ export function useEavesGableEdit(id) {
|
|||||||
const wCorner = side === 'A' ? wA : wB
|
const wCorner = side === 'A' ? wA : wB
|
||||||
const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
|
const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
|
||||||
const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
|
const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
|
||||||
// hip 방향 = inner → outer (snapshot 좌표 기반 = 45°). 동일 직선이면 wL 코너도 위에 있음.
|
// [KERAB-REVERT-SHARED-ENDPOINT-GUARD 2026-06-10] outer(확장) 끝이 다른 내부선과
|
||||||
const dx = outerOld.x - innerEnd.x
|
// 공유되면 확장하지 않는다. 골짜기(valley) 내부 hip 은 양 끝이 skeleton 교점이라 outer 도
|
||||||
const dy = outerOld.y - innerEnd.y
|
// 공유 → 자동 제외. 게이블/코너 hip 의 outer 는 free(roofLine 변에 홀로 도달) → 확장.
|
||||||
const dlen = Math.hypot(dx, dy)
|
// 판정이 출폭·snapshot 좌표와 무관한 구조 기준이라 다중 출폭 변경에도 안 깨진다.
|
||||||
if (dlen < 1e-6) {
|
// (구 c1/c2 가드는 c1/c2 를 현재 출폭으로, outerOld 를 forward 당시 출폭으로 잡아
|
||||||
delete il.__kerabRevertOuterWhich
|
// 출폭이 여러 번 바뀌면 게이블 hip 을 interior 로 오판 → SK 확장이 중간에 멈췄다.)
|
||||||
delete il.__kerabRevertOuterSide
|
const SHARE_TOL = 2.0
|
||||||
continue
|
const outerShared = (roof.innerLines || []).some(
|
||||||
|
(o) =>
|
||||||
|
o &&
|
||||||
|
o !== il &&
|
||||||
|
o.visible !== false &&
|
||||||
|
(Math.hypot(o.x1 - outerOld.x, o.y1 - outerOld.y) < SHARE_TOL ||
|
||||||
|
Math.hypot(o.x2 - outerOld.x, o.y2 - outerOld.y) < SHARE_TOL),
|
||||||
|
)
|
||||||
|
// [KERAB-REVERT-VERTEX-EXTEND 2026-06-11] TYPE 출신 게이블 hip 의 outer 끝은 폴리곤 코너(roofLine 꼭짓점)
|
||||||
|
// 라 인접 면경계선과 공유 → outerShared 가 true 가 돼 확장이 스킵됐다(처마변경 실패: hip 이 옛 코너에 멈춤).
|
||||||
|
// 골짜기 내부 hip 의 outer 는 폴리곤 경계에 없는 skeleton 교점이다. 따라서 outerOld 가 (이동 전) 폴리곤
|
||||||
|
// 꼭짓점이면 게이블 코너 hip 으로 보고 확장을 강제, 공유여도 스킵하지 않는다.
|
||||||
|
const VERT_TOL = 2.0
|
||||||
|
// outerOld 가 (이동 전) 폴리곤 꼭짓점이면 그 인덱스를 잡는다. surgical 은 점 순서·개수를 보존하므로
|
||||||
|
// 같은 인덱스의 새 roof.points 가 곧 이동 후 코너 = 게이블 hip 의 새 outer 끝점.
|
||||||
|
let vtxIdx = -1
|
||||||
|
for (let vi = 0; vi < oldPolyPoints.length; vi++) {
|
||||||
|
const p = oldPolyPoints[vi]
|
||||||
|
if (p && Math.hypot(p.x - outerOld.x, p.y - outerOld.y) < VERT_TOL) {
|
||||||
|
vtxIdx = vi
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const ux = dx / dlen
|
const outerOnVertex = vtxIdx >= 0
|
||||||
const uy = dy / dlen
|
if (outerShared && !outerOnVertex) {
|
||||||
// wL 코너에서 45° 방향으로 ray → 새 rL 변 (roof.points 는 surgical 후 새 출폭) 의 첫 hit.
|
|
||||||
let bestT = Infinity
|
|
||||||
for (let k = 0; k < M; k++) {
|
|
||||||
const A = rps[k]
|
|
||||||
const B = rps[(k + 1) % M]
|
|
||||||
const t = rayHit(wCorner, { x: ux, y: uy }, A, B)
|
|
||||||
if (t < bestT) bestT = t
|
|
||||||
}
|
|
||||||
if (!isFinite(bestT) || bestT < 0.5) {
|
|
||||||
logger.log(
|
logger.log(
|
||||||
'[KERAB-REVERT-EXTEND-45] no-hit ' +
|
'[KERAB-REVERT-EXTEND-45] skip (interior hip — outer shared) ' +
|
||||||
JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } }),
|
JSON.stringify({ lineName: il.lineName, which, side, outerOld }),
|
||||||
)
|
)
|
||||||
delete il.__kerabRevertOuterWhich
|
delete il.__kerabRevertOuterWhich
|
||||||
delete il.__kerabRevertOuterSide
|
delete il.__kerabRevertOuterSide
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const hit = { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT }
|
let hit
|
||||||
|
// [KERAB-REVERT-VERTEX-EXTEND 2026-06-11] 게이블 코너 hip: 45° ray-cast 는 출폭이 클 때 인접
|
||||||
|
// 변에 먼저 닿아(예: 출폭 80 → -803 에서 멈춤) 코너(-839)에 못 미친다 → rLine 겹침/틈.
|
||||||
|
// surgical 후 동일 인덱스 코너로 직접 스냅해 항상 새 roofLine 꼭짓점까지 도달시킨다.
|
||||||
|
if (outerOnVertex && rps.length === oldPolyPoints.length && rps[vtxIdx]) {
|
||||||
|
hit = { x: rps[vtxIdx].x, y: rps[vtxIdx].y }
|
||||||
|
} else {
|
||||||
|
// hip 방향 = inner → outer (snapshot 좌표 기반 = 45°). 동일 직선이면 wL 코너도 위에 있음.
|
||||||
|
const dx = outerOld.x - innerEnd.x
|
||||||
|
const dy = outerOld.y - innerEnd.y
|
||||||
|
const dlen = Math.hypot(dx, dy)
|
||||||
|
if (dlen < 1e-6) {
|
||||||
|
delete il.__kerabRevertOuterWhich
|
||||||
|
delete il.__kerabRevertOuterSide
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const ux = dx / dlen
|
||||||
|
const uy = dy / dlen
|
||||||
|
// wL 코너에서 45° 방향으로 ray → 새 rL 변 (roof.points 는 surgical 후 새 출폭) 의 첫 hit.
|
||||||
|
let bestT = Infinity
|
||||||
|
for (let k = 0; k < M; k++) {
|
||||||
|
const A = rps[k]
|
||||||
|
const B = rps[(k + 1) % M]
|
||||||
|
const t = rayHit(wCorner, { x: ux, y: uy }, A, B)
|
||||||
|
if (t < bestT) bestT = t
|
||||||
|
}
|
||||||
|
if (!isFinite(bestT) || bestT < 0.5) {
|
||||||
|
logger.log(
|
||||||
|
'[KERAB-REVERT-EXTEND-45] no-hit ' +
|
||||||
|
JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } }),
|
||||||
|
)
|
||||||
|
delete il.__kerabRevertOuterWhich
|
||||||
|
delete il.__kerabRevertOuterSide
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hit = { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT }
|
||||||
|
}
|
||||||
const oldLen = Math.hypot(outerOld.x - innerEnd.x, outerOld.y - innerEnd.y)
|
const oldLen = Math.hypot(outerOld.x - innerEnd.x, outerOld.y - innerEnd.y)
|
||||||
const newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y)
|
const newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y)
|
||||||
const ratio = oldLen > 0 ? newLen / oldLen : 1
|
const ratio = oldLen > 0 ? newLen / oldLen : 1
|
||||||
@ -2456,6 +2685,7 @@ export function useEavesGableEdit(id) {
|
|||||||
delete il.__kerabRevertOuterWhich
|
delete il.__kerabRevertOuterWhich
|
||||||
delete il.__kerabRevertOuterSide
|
delete il.__kerabRevertOuterSide
|
||||||
}
|
}
|
||||||
|
runKerabRuleCheck(roof, 'revert', kerabRevertBeforeSnap)
|
||||||
}
|
}
|
||||||
// [2240 KERAB-PARALLEL-HIPS 2026-05-19] forward 가 parallel-hips 였으면 target 에 스냅샷이 붙어있음 → hip 2개 복원
|
// [2240 KERAB-PARALLEL-HIPS 2026-05-19] forward 가 parallel-hips 였으면 target 에 스냅샷이 붙어있음 → hip 2개 복원
|
||||||
if (Array.isArray(target.__kerabParallelHipsSnapshot)) {
|
if (Array.isArray(target.__kerabParallelHipsSnapshot)) {
|
||||||
@ -2471,15 +2701,78 @@ export function useEavesGableEdit(id) {
|
|||||||
)
|
)
|
||||||
if (kLineRidge) {
|
if (kLineRidge) {
|
||||||
target.set({ attributes })
|
target.set({ attributes })
|
||||||
|
// [KERAB-TYPE-EAVES-EDGEMOVE 2026-06-11] TYPE 출신 revert(케라바→처마)도 출폭이 바뀌므로 옛 변 위
|
||||||
|
// 면경계선이 따라와야 한다(안 그러면 rLine 겹침). apex(= kLine 의 cMid 반대 끝) + edgeIdx 미리 캡처.
|
||||||
|
const _kMid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 }
|
||||||
|
const _kApex =
|
||||||
|
Math.hypot(kLineRidge.x1 - _kMid.x, kLineRidge.y1 - _kMid.y) >= Math.hypot(kLineRidge.x2 - _kMid.x, kLineRidge.y2 - _kMid.y)
|
||||||
|
? { x: kLineRidge.x1, y: kLineRidge.y1 }
|
||||||
|
: { x: kLineRidge.x2, y: kLineRidge.y2 }
|
||||||
|
const _kTol = 0.5
|
||||||
|
const _kNP = roof.points.length
|
||||||
|
let _kEdgeIdx = -1
|
||||||
|
for (let i = 0; i < _kNP; i++) {
|
||||||
|
const p = roof.points[i]
|
||||||
|
const q = roof.points[(i + 1) % _kNP]
|
||||||
|
if (
|
||||||
|
(Math.hypot(p.x - c1.x, p.y - c1.y) < _kTol && Math.hypot(q.x - c2.x, q.y - c2.y) < _kTol) ||
|
||||||
|
(Math.hypot(p.x - c2.x, p.y - c2.y) < _kTol && Math.hypot(q.x - c1.x, q.y - c1.y) < _kTol)
|
||||||
|
) {
|
||||||
|
_kEdgeIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
const ok = applyKerabRevertPattern(roof, target, c1, c2, { ridge: kLineRidge, apex: null })
|
const ok = applyKerabRevertPattern(roof, target, c1, c2, { ridge: kLineRidge, apex: null })
|
||||||
if (ok) surgicalAfter()
|
if (ok) {
|
||||||
logger.log('[KERAB-REVERT] applied (kLineOnly via targetId) ' + JSON.stringify({ ok }))
|
surgicalAfter()
|
||||||
|
moveStaleEdgeInnerLines(roof, c1, c2, _kEdgeIdx, _kApex)
|
||||||
|
extendTypeGableHipsStraightToRoofLine(roof, target, _kApex)
|
||||||
|
}
|
||||||
|
logger.log('[KERAB-REVERT] applied (kLineOnly via targetId) ' + JSON.stringify({ ok, edgeIdx: _kEdgeIdx, apex: _kApex }))
|
||||||
if (ok) return
|
if (ok) return
|
||||||
}
|
}
|
||||||
// [2240 KERAB-MID-FROM-TARGET 2026-05-20] revert 도 target 중점 기준 ridge 검색 (forward 와 일관)
|
// [2240 KERAB-MID-FROM-TARGET 2026-05-20] revert 도 target 중점 기준 ridge 검색 (forward 와 일관)
|
||||||
const tEnd1 = { x: target.x1, y: target.y1 }
|
const tEnd1 = { x: target.x1, y: target.y1 }
|
||||||
const tEnd2 = { x: target.x2, y: target.y2 }
|
const tEnd2 = { x: target.x2, y: target.y2 }
|
||||||
const ridgeAtMid = findRidgeAtMidpoint(roof, tEnd1, tEnd2)
|
const ridgeAtMid = findRidgeAtMidpoint(roof, tEnd1, tEnd2)
|
||||||
|
// [KERAB-TYPE-EAVES 2026-06-11] TYPE 출신(native 마루·토글 이력 없음) 은 전용 함수로 분기.
|
||||||
|
// 기존 폴백(applyKerabRevertPattern single-ridge)은 apex=마루 far-end + 마루 전체 삭제라
|
||||||
|
// L/2 setback·마루 단축 규칙과 어긋남. native 마루일 때만 가로채고 나머지는 그대로.
|
||||||
|
// 불변식 "마루는 roofLine 까지" → native 마루 끝점은 wLine 중점이 아니라 roofLine 코너(c1/c2)
|
||||||
|
// 중점에 있다. 그래서 파인더는 c1/c2 기준 (tEnd 기준 ridgeAtMid 는 出幅>0 이면 null).
|
||||||
|
const typeRidge = ridgeAtMid || findRidgeAtMidpoint(roof, c1, c2)
|
||||||
|
if (ENABLE_TYPE_GABLE_TO_EAVES && typeRidge && isNativeTypeRidge(typeRidge.ridge)) {
|
||||||
|
target.set({ attributes })
|
||||||
|
// 변환 전 옛 roofLine 변(c1↔c2) 의 polygon 인덱스 캡처 — surgical 후 새 코너 좌표 매핑용.
|
||||||
|
const _EDGE_TOL = 0.5
|
||||||
|
const _NP = roof.points.length
|
||||||
|
let _edgeIdx = -1
|
||||||
|
for (let i = 0; i < _NP; i++) {
|
||||||
|
const p = roof.points[i]
|
||||||
|
const q = roof.points[(i + 1) % _NP]
|
||||||
|
if (
|
||||||
|
(Math.hypot(p.x - c1.x, p.y - c1.y) < _EDGE_TOL && Math.hypot(q.x - c2.x, q.y - c2.y) < _EDGE_TOL) ||
|
||||||
|
(Math.hypot(p.x - c2.x, p.y - c2.y) < _EDGE_TOL && Math.hypot(q.x - c1.x, q.y - c1.y) < _EDGE_TOL)
|
||||||
|
) {
|
||||||
|
_edgeIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const apexRes = applyTypeGableToEavesPattern(roof, target, typeRidge.ridge)
|
||||||
|
if (apexRes) {
|
||||||
|
surgicalAfter()
|
||||||
|
// [KERAB-TYPE-EAVES-EDGEMOVE 2026-06-11] surgicalAfter 는 skipInnerLines:true 라 옛 roofLine
|
||||||
|
// 변 위에 놓인 normal inner line(L자 면경계)이 안 따라온다 → 옛 위치에 ghost("한개 더").
|
||||||
|
// 변은 出幅만큼 평행이동(delta)했으므로, 옛 변 위 끝점만 동일 delta 로 평행이동(= NORMAL-ABS 동치).
|
||||||
|
// 단, cMid(변 중점)에 걸린 비-collinear 선의 끝점은 apex 로 수렴해야 한다(힙 사이 마루 금지).
|
||||||
|
// 케라바패턴 라인(내 hip)은 surgicalAfter 의 ray-cast 가 이미 처리 → 제외.
|
||||||
|
moveStaleEdgeInnerLines(roof, c1, c2, _edgeIdx, apexRes)
|
||||||
|
// [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] apex 는 벽기준 고정. 힙을 벽교점 방향 45° 그대로 roofLine 까지 직선 연장.
|
||||||
|
extendTypeGableHipsStraightToRoofLine(roof, target, apexRes)
|
||||||
|
}
|
||||||
|
logger.log('[KERAB-TYPE-EAVES] branch ' + JSON.stringify({ ok: !!apexRes, c1, c2, edgeIdx: _edgeIdx }))
|
||||||
|
if (apexRes) return
|
||||||
|
}
|
||||||
if (ridgeAtMid) {
|
if (ridgeAtMid) {
|
||||||
target.set({ attributes })
|
target.set({ attributes })
|
||||||
const ok = applyKerabRevertPattern(roof, target, c1, c2, ridgeAtMid)
|
const ok = applyKerabRevertPattern(roof, target, c1, c2, ridgeAtMid)
|
||||||
@ -3012,6 +3305,9 @@ export function useEavesGableEdit(id) {
|
|||||||
const rec = target.__valleyExtTrims[i]
|
const rec = target.__valleyExtTrims[i]
|
||||||
const il = rec.line
|
const il = rec.line
|
||||||
if (!il) continue
|
if (!il) continue
|
||||||
|
// [VALLEY-EXT-TRIM-ENDPOINT-GUARD 2026-06-10] split 전용 레코드는 좌표/visible 변경이
|
||||||
|
// 없으므로 복원할 게 없다. split 세그먼트는 valleyExt(__targetId) 제거에서 정리됨.
|
||||||
|
if (rec.splitOnly) continue
|
||||||
if (rec.hide) {
|
if (rec.hide) {
|
||||||
il.visible = rec.originalVisible !== false
|
il.visible = rec.originalVisible !== false
|
||||||
} else {
|
} else {
|
||||||
@ -3206,6 +3502,250 @@ export function useEavesGableEdit(id) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [KERAB-TYPE-EAVES 2026-06-11] TYPE(A/B 박공) 출신 케라바→처마 전용 변환.
|
||||||
|
// forward 토글 스냅샷이 없는 native 마루(RIDGE) 케이스. 마루확장라인을 hip 2개로 "펼친다".
|
||||||
|
// 규칙(사용자 확정, 방향 무관):
|
||||||
|
// 1) apex = 선택라인(wLine) 중점에서 마루 방향(inward) 으로 L/2. (45° 등거리 = 반으로 쪼갬)
|
||||||
|
// 2) 마루는 케라바변에 닿은 끝점을 apex 로 단축.
|
||||||
|
// 3) hip 2개(wA→apex, wB→apex) 생성 + outer 마크 → surgicalAfter 가 새 출폭 rLine 까지 45° ray-cast.
|
||||||
|
// markRevertOuter 는 applyKerabRevertPattern 내부 클로저라 여기선 which/side 를 직접 마크.
|
||||||
|
const isNativeTypeRidge = (ridge) =>
|
||||||
|
!!ridge &&
|
||||||
|
ridge.name === LINE_TYPE.SUBLINE.RIDGE &&
|
||||||
|
!ridge.__patternKind &&
|
||||||
|
!ridge.__originalHips &&
|
||||||
|
!ridge.__removedHipsSnapshot &&
|
||||||
|
ridge.lineName !== 'kerabPatternRidge' &&
|
||||||
|
ridge.lineName !== 'kerabPatternExtRidge'
|
||||||
|
|
||||||
|
const applyTypeGableToEavesPattern = (roof, target, ridge) => {
|
||||||
|
const wA = { x: target.x1, y: target.y1 }
|
||||||
|
const wB = { x: target.x2, y: target.y2 }
|
||||||
|
const L = Math.hypot(wB.x - wA.x, wB.y - wA.y)
|
||||||
|
if (L < 1) return null
|
||||||
|
const mid = { x: (wA.x + wB.x) / 2, y: (wA.y + wB.y) / 2 }
|
||||||
|
// 마루의 케라바변쪽 끝점 식별 + 내부(inward) 방향 도출.
|
||||||
|
const rA = { x: ridge.x1, y: ridge.y1 }
|
||||||
|
const rB = { x: ridge.x2, y: ridge.y2 }
|
||||||
|
const midIsA = Math.hypot(rA.x - mid.x, rA.y - mid.y) <= Math.hypot(rB.x - mid.x, rB.y - mid.y)
|
||||||
|
const ridgeInner = midIsA ? rB : rA
|
||||||
|
let ix = ridgeInner.x - mid.x
|
||||||
|
let iy = ridgeInner.y - mid.y
|
||||||
|
const ilen = Math.hypot(ix, iy) || 1
|
||||||
|
ix /= ilen
|
||||||
|
iy /= ilen
|
||||||
|
const apex = { x: mid.x + ix * (L / 2), y: mid.y + iy * (L / 2) }
|
||||||
|
// 마루 단축: 케라바변쪽 끝점 → apex.
|
||||||
|
if (midIsA) ridge.set({ x1: apex.x, y1: apex.y })
|
||||||
|
else ridge.set({ x2: apex.x, y2: apex.y })
|
||||||
|
const rsz = calcLinePlaneSize({ x1: ridge.x1, y1: ridge.y1, x2: ridge.x2, y2: ridge.y2 })
|
||||||
|
if (ridge.attributes) {
|
||||||
|
ridge.attributes.planeSize = rsz
|
||||||
|
ridge.attributes.actualSize = rsz
|
||||||
|
}
|
||||||
|
if (typeof ridge.setCoords === 'function') ridge.setCoords()
|
||||||
|
if (typeof ridge.setLength === 'function') ridge.setLength()
|
||||||
|
if (typeof ridge.addLengthText === 'function') ridge.addLengthText()
|
||||||
|
// hip 2개: wA→apex, wB→apex. pts=[corner, apex] 라 corner 끝(x1,y1)이 outer(=which 1).
|
||||||
|
const mkHip = (corner, side) => {
|
||||||
|
const pts = [corner.x, corner.y, apex.x, apex.y]
|
||||||
|
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
|
||||||
|
const hip = new QLine(pts, {
|
||||||
|
parentId: roof.id,
|
||||||
|
fontSize: roof.fontSize,
|
||||||
|
stroke: '#1083E3',
|
||||||
|
strokeWidth: 3,
|
||||||
|
name: LINE_TYPE.SUBLINE.HIP,
|
||||||
|
textMode: roof.textMode,
|
||||||
|
// [KERAB-TYPE-EAVES-ROUNDTRIP 2026-06-11] forward(처마→케라바) findHipAtEndpoint 는
|
||||||
|
// attributes.type === HIP 로 매칭한다. type 누락 시 재변환에서 hip 미인식 → attr-only 폴백.
|
||||||
|
attributes: { roofId: roof.id, type: LINE_TYPE.SUBLINE.HIP, planeSize: sz, actualSize: sz },
|
||||||
|
})
|
||||||
|
hip.lineName = 'kerabPatternHip'
|
||||||
|
hip.__kerabRevertOuterWhich = 1
|
||||||
|
hip.__kerabRevertOuterSide = side
|
||||||
|
return hip
|
||||||
|
}
|
||||||
|
const hip1 = mkHip(wA, 'A')
|
||||||
|
const hip2 = mkHip(wB, 'B')
|
||||||
|
canvas.add(hip1)
|
||||||
|
canvas.add(hip2)
|
||||||
|
hip1.bringToFront()
|
||||||
|
hip2.bringToFront()
|
||||||
|
roof.innerLines.push(hip1, hip2)
|
||||||
|
removeKerabHalfLabels(target.id)
|
||||||
|
hideOriginalLengthText(target.id, false)
|
||||||
|
canvas.renderAll()
|
||||||
|
logger.log(
|
||||||
|
'[KERAB-TYPE-EAVES] applied ' +
|
||||||
|
JSON.stringify({ L: Math.round(L), mid, apex, inward: { ix: Number(ix.toFixed(3)), iy: Number(iy.toFixed(3)) } }),
|
||||||
|
)
|
||||||
|
return apex
|
||||||
|
}
|
||||||
|
|
||||||
|
// [KERAB-TYPE-EAVES-EDGEMOVE 2026-06-11] TYPE 변환 후 옛 roofLine 변 위에 남은 normal inner line 이동.
|
||||||
|
// surgicalAfter(skipInnerLines:true) 는 폴리곤 변·내 hip 만 갱신하고 L자 면경계선은 안 옮긴다 →
|
||||||
|
// 옛 出幅 위치(ghost) 잔존. 변은 出幅만큼 평행이동했으므로 옛 변 위 끝점만 동일 delta 로 평행이동한다.
|
||||||
|
// (出幅 평행이동이라 변 위 모든 점의 변위가 동일 = recomputeNormalLine 의 NORMAL-ABS 와 동치.)
|
||||||
|
// 케라바패턴 라인은 ray-cast 가 이미 처리하므로 제외. oldC1/oldC2 = 변환 전 roofLine 코너.
|
||||||
|
// [KERAB-TYPE-EAVES-WEDGE 2026-06-11] apex 인자 추가. 처마 위상에서는 두 힙이 apex 에서 만나고
|
||||||
|
// 힙 사이 쐐기에는 라인이 없어야 한다. 옛 변 중점(cMid)에 끝점이 닿아 있던 非평행 라인(=힙)은
|
||||||
|
// 평행이동 시 쐐기를 가로지르므로, 그 끝점만 apex 로 수렴시킨다. 변과 평행한 라인(roofLine 절반)과
|
||||||
|
// 코너 끝점은 종전처럼 出幅 delta 로 평행이동.
|
||||||
|
const moveStaleEdgeInnerLines = (roof, oldC1, oldC2, edgeIdx, apex) => {
|
||||||
|
if (edgeIdx < 0 || !Array.isArray(roof.points)) return null
|
||||||
|
const N = roof.points.length
|
||||||
|
const nP = roof.points[edgeIdx]
|
||||||
|
const nQ = roof.points[(edgeIdx + 1) % N]
|
||||||
|
// c1↔c2 ↔ nP↔nQ 방향 정합 (idx 끝과 가까운 쪽으로 매핑).
|
||||||
|
const c1ToP = Math.hypot(oldC1.x - nP.x, oldC1.y - nP.y) <= Math.hypot(oldC1.x - nQ.x, oldC1.y - nQ.y)
|
||||||
|
const newA = c1ToP ? nP : nQ
|
||||||
|
const newB = c1ToP ? nQ : nP
|
||||||
|
// 出幅 평행이동 delta (양 코너 동일, 안전하게 평균).
|
||||||
|
const dxE = ((newA.x - oldC1.x) + (newB.x - oldC2.x)) / 2
|
||||||
|
const dyE = ((newA.y - oldC1.y) + (newB.y - oldC2.y)) / 2
|
||||||
|
if (Math.hypot(dxE, dyE) < 0.01) return null
|
||||||
|
const segDx = oldC2.x - oldC1.x
|
||||||
|
const segDy = oldC2.y - oldC1.y
|
||||||
|
const segLen2 = segDx * segDx + segDy * segDy || 1
|
||||||
|
const segLen = Math.sqrt(segLen2)
|
||||||
|
const onOldEdge = (px, py) => {
|
||||||
|
const t = ((px - oldC1.x) * segDx + (py - oldC1.y) * segDy) / segLen2
|
||||||
|
if (t < -0.02 || t > 1.02) return false
|
||||||
|
const prx = oldC1.x + t * segDx
|
||||||
|
const pry = oldC1.y + t * segDy
|
||||||
|
return Math.hypot(px - prx, py - pry) < 0.5
|
||||||
|
}
|
||||||
|
const oldMid = { x: (oldC1.x + oldC2.x) / 2, y: (oldC1.y + oldC2.y) / 2 }
|
||||||
|
const nearMid = (px, py) => Math.hypot(px - oldMid.x, py - oldMid.y) < 2.0
|
||||||
|
// 라인 방향이 변과 평행이면 cross 가 0 → roofLine 절반(평행이동 대상).
|
||||||
|
const isCollinearWithEdge = (x1, y1, x2, y2) => {
|
||||||
|
const lx = x2 - x1
|
||||||
|
const ly = y2 - y1
|
||||||
|
const llen = Math.hypot(lx, ly)
|
||||||
|
if (llen < 0.01) return true
|
||||||
|
const cross = Math.abs(lx * segDy - ly * segDx) / (llen * segLen)
|
||||||
|
return cross < 0.05
|
||||||
|
}
|
||||||
|
const isKerabPattern = (ln) =>
|
||||||
|
ln === 'kerabPatternHip' ||
|
||||||
|
ln === 'kerabPatternRidge' ||
|
||||||
|
ln === 'kerabPatternExtHip' ||
|
||||||
|
ln === 'kerabPatternExtRidge' ||
|
||||||
|
ln === 'kerabPatternValleyExt'
|
||||||
|
let movedCnt = 0
|
||||||
|
for (const il of roof.innerLines || []) {
|
||||||
|
if (!il || isKerabPattern(il.lineName)) continue
|
||||||
|
const collinear = isCollinearWithEdge(il.x1, il.y1, il.x2, il.y2)
|
||||||
|
// 非평행 라인의 cMid 끝점은 apex 로 수렴, 그 외는 出幅 delta 로 평행이동.
|
||||||
|
const relocate = (px, py) => {
|
||||||
|
if (apex && !collinear && nearMid(px, py)) return { x: apex.x, y: apex.y }
|
||||||
|
return { x: px + dxE, y: py + dyE }
|
||||||
|
}
|
||||||
|
let moved = false
|
||||||
|
if (onOldEdge(il.x1, il.y1)) {
|
||||||
|
const np1 = relocate(il.x1, il.y1)
|
||||||
|
il.set({ x1: np1.x, y1: np1.y })
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
if (onOldEdge(il.x2, il.y2)) {
|
||||||
|
const np2 = relocate(il.x2, il.y2)
|
||||||
|
il.set({ x2: np2.x, y2: np2.y })
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
if (moved) {
|
||||||
|
const ns = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
|
||||||
|
if (il.attributes) {
|
||||||
|
il.attributes.planeSize = ns
|
||||||
|
il.attributes.actualSize = ns
|
||||||
|
}
|
||||||
|
if (typeof il.setCoords === 'function') il.setCoords()
|
||||||
|
if (typeof il.setLength === 'function') il.setLength()
|
||||||
|
if (typeof il.addLengthText === 'function') il.addLengthText()
|
||||||
|
movedCnt++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas.renderAll()
|
||||||
|
logger.log('[KERAB-TYPE-EAVES-EDGEMOVE] ' + JSON.stringify({ edgeIdx, delta: { dxE, dyE }, movedCnt }))
|
||||||
|
return { dxE, dyE }
|
||||||
|
}
|
||||||
|
|
||||||
|
// [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] 힙은 단일 직선 45°. apex 에서 벽교점(wLine 코너) 방향(=45°)
|
||||||
|
// 그대로 roofLine 까지 직선 연장한다. 꺾지 않고, roofLine '코너/교점'을 찾아가지도 않는다 — ray 가
|
||||||
|
// roofLine 변에 처음 닿는 지점이 끝점(出幅60 → 코너 10mm 못미쳐 변 위에서 끝남, 사용자 모델 그대로).
|
||||||
|
// apex 는 벽기준 고정값이라 出幅 무관(half-rule 폐기) — "出幅에 의해 힙·마루 변질" 차단.
|
||||||
|
// surgicalAfter/EXTEND-45 가 코너 스냅한 힙을 ray-cast 직선으로 덮어쓴다.
|
||||||
|
const extendTypeGableHipsStraightToRoofLine = (roof, target, apex) => {
|
||||||
|
if (!apex || !roof || !Array.isArray(roof.innerLines)) return
|
||||||
|
const pts = Array.isArray(roof.points) ? roof.points : []
|
||||||
|
if (pts.length < 2) return
|
||||||
|
const wcs = [
|
||||||
|
{ x: target.x1, y: target.y1 },
|
||||||
|
{ x: target.x2, y: target.y2 },
|
||||||
|
]
|
||||||
|
// ray P+t*dir 가 선분 A→B 와 만나는 t(>0) 반환, 없으면 Infinity.
|
||||||
|
const rayHit = (P, dir, A, B) => {
|
||||||
|
const ex = B.x - A.x
|
||||||
|
const ey = B.y - A.y
|
||||||
|
const den = dir.x * ey - dir.y * ex
|
||||||
|
if (Math.abs(den) < 1e-9) return Infinity
|
||||||
|
const t = ((A.x - P.x) * ey - (A.y - P.y) * ex) / den
|
||||||
|
const u = ((A.x - P.x) * dir.y - (A.y - P.y) * dir.x) / den
|
||||||
|
if (t > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6) return t
|
||||||
|
return Infinity
|
||||||
|
}
|
||||||
|
const APEX_TOL = 2.0
|
||||||
|
let extended = 0
|
||||||
|
for (const il of [...roof.innerLines]) {
|
||||||
|
if (!il || il.lineName !== 'kerabPatternHip') continue
|
||||||
|
const d1 = Math.hypot(il.x1 - apex.x, il.y1 - apex.y)
|
||||||
|
const d2 = Math.hypot(il.x2 - apex.x, il.y2 - apex.y)
|
||||||
|
if (Math.min(d1, d2) > APEX_TOL) continue
|
||||||
|
const apexIsE1 = d1 <= d2
|
||||||
|
const outerEnd = apexIsE1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
|
||||||
|
// outerEnd 에 가까운 벽교점(wLine 끝점) 선택.
|
||||||
|
const wc =
|
||||||
|
Math.hypot(outerEnd.x - wcs[0].x, outerEnd.y - wcs[0].y) <= Math.hypot(outerEnd.x - wcs[1].x, outerEnd.y - wcs[1].y)
|
||||||
|
? wcs[0]
|
||||||
|
: wcs[1]
|
||||||
|
// 방향 = apex→wc (45°). 이 방향 그대로 roofLine 까지 ray.
|
||||||
|
const dx = wc.x - apex.x
|
||||||
|
const dy = wc.y - apex.y
|
||||||
|
const dlen = Math.hypot(dx, dy) || 1
|
||||||
|
const dir = { x: dx / dlen, y: dy / dlen }
|
||||||
|
// wc 직전(살짝 안쪽)에서 ray 발사 → wc 통과 후 첫 roofLine 변 교차.
|
||||||
|
const P = { x: wc.x - dir.x * 0.05, y: wc.y - dir.y * 0.05 }
|
||||||
|
let best = Infinity
|
||||||
|
let hit = null
|
||||||
|
for (let i = 0; i < pts.length; i++) {
|
||||||
|
const A = pts[i]
|
||||||
|
const B = pts[(i + 1) % pts.length]
|
||||||
|
const t = rayHit(P, dir, A, B)
|
||||||
|
if (t < best) {
|
||||||
|
best = t
|
||||||
|
hit = { x: P.x + dir.x * t, y: P.y + dir.y * t }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hit) continue
|
||||||
|
// 힙 = apex → hit (단일 직선 45°).
|
||||||
|
if (apexIsE1) il.set({ x1: apex.x, y1: apex.y, x2: hit.x, y2: hit.y })
|
||||||
|
else il.set({ x1: hit.x, y1: hit.y, x2: apex.x, y2: apex.y })
|
||||||
|
const sz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
|
||||||
|
if (il.attributes) {
|
||||||
|
il.attributes.planeSize = sz
|
||||||
|
il.attributes.actualSize = sz
|
||||||
|
il.attributes.extended = true
|
||||||
|
}
|
||||||
|
il.__extended = true
|
||||||
|
if (typeof il.setCoords === 'function') il.setCoords()
|
||||||
|
if (typeof il.setLength === 'function') il.setLength()
|
||||||
|
if (typeof il.addLengthText === 'function') il.addLengthText()
|
||||||
|
extended++
|
||||||
|
}
|
||||||
|
canvas.renderAll()
|
||||||
|
logger.log('[KERAB-TYPE-EAVES-STRAIGHT] ' + JSON.stringify({ extended, apex }))
|
||||||
|
}
|
||||||
|
|
||||||
// [KERAB-HALF-LABEL 2026-05-19] 케라바 외곽선의 좌우/상하 절반 길이 라벨
|
// [KERAB-HALF-LABEL 2026-05-19] 케라바 외곽선의 좌우/상하 절반 길이 라벨
|
||||||
// 그림 그릴 단계에서는 외곽선은 1개로 유지하고 라벨만 2개 노출.
|
// 그림 그릴 단계에서는 외곽선은 1개로 유지하고 라벨만 2개 노출.
|
||||||
// 할당 시 splitPolygonWithLines 가 자연 분할하므로 그때는 사라지고 분할 라인 라벨이 대체.
|
// 할당 시 splitPolygonWithLines 가 자연 분할하므로 그때는 사라지고 분할 라인 라벨이 대체.
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { fontSelector } from '@/store/fontAtom'
|
|||||||
import { MENU, POLYGON_TYPE } from '@/common/common'
|
import { MENU, POLYGON_TYPE } from '@/common/common'
|
||||||
import { useText } from '@/hooks/useText'
|
import { useText } from '@/hooks/useText'
|
||||||
import { usePolygon } from '@/hooks/usePolygon'
|
import { usePolygon } from '@/hooks/usePolygon'
|
||||||
import { useModule } from '@/hooks/module/useModule'
|
|
||||||
|
|
||||||
// 캔버스에 필요한 이벤트
|
// 캔버스에 필요한 이벤트
|
||||||
export function useCanvasEvent() {
|
export function useCanvasEvent() {
|
||||||
@ -23,7 +22,6 @@ export function useCanvasEvent() {
|
|||||||
const isManualModuleSetup = useRecoilValue(isManualModuleSetupState)
|
const isManualModuleSetup = useRecoilValue(isManualModuleSetupState)
|
||||||
const { changeCorridorDimensionText } = useText()
|
const { changeCorridorDimensionText } = useText()
|
||||||
const { setPolygonLinesActualSize } = usePolygon()
|
const { setPolygonLinesActualSize } = usePolygon()
|
||||||
const { commitModuleDragMove } = useModule()
|
|
||||||
// [MODULE-MULTI-SELECT 2026-06-10] 모듈 복수선택 refine 재진입 가드 (rebuild 중첩 방지)
|
// [MODULE-MULTI-SELECT 2026-06-10] 모듈 복수선택 refine 재진입 가드 (rebuild 중첩 방지)
|
||||||
const refiningRef = useRef(false)
|
const refiningRef = useRef(false)
|
||||||
|
|
||||||
@ -61,7 +59,6 @@ export function useCanvasEvent() {
|
|||||||
canvas?.on('object:added', objectEvent.onChange)
|
canvas?.on('object:added', objectEvent.onChange)
|
||||||
canvas?.on('object:added', objectEvent.addEvent)
|
canvas?.on('object:added', objectEvent.addEvent)
|
||||||
canvas?.on('object:modified', objectEvent.onChange)
|
canvas?.on('object:modified', objectEvent.onChange)
|
||||||
canvas?.on('object:modified', handleModuleSelectionMoved)
|
|
||||||
canvas?.on('object:removed', objectEvent.onChange)
|
canvas?.on('object:removed', objectEvent.onChange)
|
||||||
canvas?.on('selection:cleared', selectionEvent.cleared)
|
canvas?.on('selection:cleared', selectionEvent.cleared)
|
||||||
canvas?.on('selection:created', selectionEvent.created)
|
canvas?.on('selection:created', selectionEvent.created)
|
||||||
@ -294,10 +291,10 @@ export function useCanvasEvent() {
|
|||||||
const active = canvas.getActiveObject()
|
const active = canvas.getActiveObject()
|
||||||
if (!active) return
|
if (!active) return
|
||||||
|
|
||||||
// 단일 모듈 선택: 마우스 이동 가능하도록 이동잠금 해제 (회전·스케일은 잠금 유지).
|
// [MODULE-DRAG-DISABLE 2026-06-12] 단일 모듈 선택: 마우스 이동 잠금 유지(드래그 박스 복수선택·Del 삭제만 허용). 회전·스케일도 잠금.
|
||||||
if (active.type !== 'activeSelection') {
|
if (active.type !== 'activeSelection') {
|
||||||
if (active.name === POLYGON_TYPE.MODULE) {
|
if (active.name === POLYGON_TYPE.MODULE) {
|
||||||
active.set({ lockMovementX: false, lockMovementY: false, lockRotation: true, lockScalingX: true, lockScalingY: true, hasControls: false })
|
active.set({ lockMovementX: true, lockMovementY: true, lockRotation: true, lockScalingX: true, lockScalingY: true, hasControls: false })
|
||||||
canvas.requestRenderAll()
|
canvas.requestRenderAll()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@ -317,11 +314,12 @@ export function useCanvasEvent() {
|
|||||||
refiningRef.current = false
|
refiningRef.current = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [MODULE-DRAG-DISABLE 2026-06-12] 복수선택 그룹도 이동 잠금 유지(드래그 박스 선택·Del 삭제만 허용).
|
||||||
const finalActive = canvas.getActiveObject()
|
const finalActive = canvas.getActiveObject()
|
||||||
if (finalActive?.type === 'activeSelection') {
|
if (finalActive?.type === 'activeSelection') {
|
||||||
finalActive.set({
|
finalActive.set({
|
||||||
lockMovementX: false,
|
lockMovementX: true,
|
||||||
lockMovementY: false,
|
lockMovementY: true,
|
||||||
lockScalingX: true,
|
lockScalingX: true,
|
||||||
lockScalingY: true,
|
lockScalingY: true,
|
||||||
lockRotation: true,
|
lockRotation: true,
|
||||||
@ -331,22 +329,6 @@ export function useCanvasEvent() {
|
|||||||
canvas.requestRenderAll()
|
canvas.requestRenderAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// [MODULE-MULTI-SELECT 2026-06-10] object:modified 시 지붕면 경계 검증 → 위반이면 드래그 시작 좌표로 복원
|
|
||||||
const handleModuleSelectionMoved = (e) => {
|
|
||||||
if (currentMenu !== MENU.MODULE_CIRCUIT_SETTING.BASIC_SETTING) return
|
|
||||||
const target = e?.target
|
|
||||||
if (!target) return
|
|
||||||
|
|
||||||
const isGroup = target.type === 'activeSelection'
|
|
||||||
const isSingleModule = target.name === POLYGON_TYPE.MODULE
|
|
||||||
if (!isGroup && !isSingleModule) return
|
|
||||||
if (isGroup && !target.getObjects().some((obj) => obj.name === POLYGON_TYPE.MODULE)) return
|
|
||||||
|
|
||||||
const original = e?.transform?.original
|
|
||||||
const startPos = original ? { left: original.left, top: original.top } : null
|
|
||||||
commitModuleDragMove(target, startPos)
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeEventOnCanvas = () => {
|
const removeEventOnCanvas = () => {
|
||||||
canvas?.off('object:added')
|
canvas?.off('object:added')
|
||||||
canvas?.off('object:modified')
|
canvas?.off('object:modified')
|
||||||
|
|||||||
@ -40,6 +40,18 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
|
|||||||
const oldOffset = matchingRL.attributes?.offset ?? 0
|
const oldOffset = matchingRL.attributes?.offset ?? 0
|
||||||
if (Math.abs(newOffset - oldOffset) < 1e-3) return false
|
if (Math.abs(newOffset - oldOffset) < 1e-3) return false
|
||||||
|
|
||||||
|
// [RIDGE-DIAG 2026-06-10] 자연 마루(ridge) 길이 간헐 변동 추적 — surgical 진입 전 before 스냅샷,
|
||||||
|
// 함수 종료 직전 길이 비교. 처마/케라바/revert(skipInnerLines 포함) 모든 경로 감시.
|
||||||
|
// 출폭 변경 시 자연 마루 끝점(apex)은 wallLine 안쪽이라 불변이 룰 → 길이 변동은 버그.
|
||||||
|
// kerabPatternRidge(kLine)/kerabPatternExtRidge 는 출폭에 따라 정상적으로 신축 → 감시 제외.
|
||||||
|
const isRidgeDiagLine = (il) => il && il.lineName === 'ridge'
|
||||||
|
const ridgeDiagBefore = new Map()
|
||||||
|
for (const il of roof.innerLines || []) {
|
||||||
|
if (isRidgeDiagLine(il)) {
|
||||||
|
ridgeDiagBefore.set(il, { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2, len: Math.hypot(il.x2 - il.x1, il.y2 - il.y1) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const N = roof.lines.length
|
const N = roof.lines.length
|
||||||
const prevRL = roof.lines[(idx - 1 + N) % N]
|
const prevRL = roof.lines[(idx - 1 + N) % N]
|
||||||
const nextRL = roof.lines[(idx + 1) % N]
|
const nextRL = roof.lines[(idx + 1) % N]
|
||||||
@ -118,15 +130,6 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
|
|||||||
// 원본 좌표는 il.__shrinkOrig 에 보관 — 첫 절삭 시점에 백업, 양 끝 모두 안쪽으로 복귀 시 삭제.
|
// 원본 좌표는 il.__shrinkOrig 에 보관 — 첫 절삭 시점에 백업, 양 끝 모두 안쪽으로 복귀 시 삭제.
|
||||||
// 매 surgical 호출마다 원본 기반으로 재계산하므로 출폭 증감 무관 idempotent.
|
// 매 surgical 호출마다 원본 기반으로 재계산하므로 출폭 증감 무관 idempotent.
|
||||||
if (!skipInnerLines) {
|
if (!skipInnerLines) {
|
||||||
const newAxisMid = { x: (newCorner1.x + newCorner2.x) / 2, y: (newCorner1.y + newCorner2.y) / 2 }
|
|
||||||
const OUTSIDE_TOL = 0.5
|
|
||||||
const segOk = (ip) => {
|
|
||||||
const minX = Math.min(newCorner1.x, newCorner2.x) - OUTSIDE_TOL
|
|
||||||
const maxX = Math.max(newCorner1.x, newCorner2.x) + OUTSIDE_TOL
|
|
||||||
const minY = Math.min(newCorner1.y, newCorner2.y) - OUTSIDE_TOL
|
|
||||||
const maxY = Math.max(newCorner1.y, newCorner2.y) + OUTSIDE_TOL
|
|
||||||
return ip.x >= minX && ip.x <= maxX && ip.y >= minY && ip.y <= maxY
|
|
||||||
}
|
|
||||||
// [KERAB-PATTERN-CORNER-SNAP 2026-06-01] 케라바 패턴 라인(kLine/ExtRidge/Hip/ExtHip)은
|
// [KERAB-PATTERN-CORNER-SNAP 2026-06-01] 케라바 패턴 라인(kLine/ExtRidge/Hip/ExtHip)은
|
||||||
// roofLine corner 변경에 따라 끝점도 같이 이동. corner 일치뿐 아니라 옛 segment 위의
|
// roofLine corner 변경에 따라 끝점도 같이 이동. corner 일치뿐 아니라 옛 segment 위의
|
||||||
// 중간 점(예: kLine 끝점이 옛 roofLine 변 위)도 새 segment 의 동일 t 비율 점으로 매핑.
|
// 중간 점(예: kLine 끝점이 옛 roofLine 변 위)도 새 segment 의 동일 t 비율 점으로 매핑.
|
||||||
@ -154,31 +157,66 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
|
|||||||
if (t < -0.05 || t > 1.05) return null
|
if (t < -0.05 || t > 1.05) return null
|
||||||
return { x: newCorner1.x + t * newSegDx, y: newCorner1.y + t * newSegDy }
|
return { x: newCorner1.x + t * newSegDx, y: newCorner1.y + t * newSegDy }
|
||||||
}
|
}
|
||||||
for (const il of roof.innerLines || []) {
|
// [KERAB-APEX-INVARIANT 2026-06-10] skeleton 교점(junction) 판별식(기하, lineName 아님).
|
||||||
if (!il) continue
|
// 한 점이 다른 내부선 ≥1개의 끝점과 공유되면 그 점은 라인끼리 만나는 junction 이다.
|
||||||
|
// junction 은 wallLine 이 정하는 고정점이라 出幅 변경과 무관 → CORNER-SNAP/CASCADE 가 끌면 안 됨.
|
||||||
// 케라바 패턴 라인: 옛 roofLine segment 위 끝점 → 새 segment 의 동일 t 점.
|
// degree≥1 임계: Y 형 apex(ridge·2hip, degree 2)뿐 아니라 케라바 병합 후 콜리니어 ridge 하나만
|
||||||
// 한 끝만 segment 위(vExt 등 self-extension)일 때는 다른 끝도 같은 변위로 평행 이동 →
|
// 남은 junction(degree 1)도 잡아야 한다. (degree≥2 였을 때 병합 apex 를 놓쳐 kLine 의 apex 끝을
|
||||||
// 직각/형상 보존. 절삭/복원 흐름 skip.
|
// 끌고 자연 ridge 까지 cascade → 마루 길이 증가 + revert R1-dangling 발생.) vExt 는 !isVExt 로 제외.
|
||||||
if (isKerabPatternLine(il)) {
|
const APEX_SHARE_TOL = 2.0
|
||||||
|
const skeletonApexDegree = (pt, selfLine) => {
|
||||||
|
let deg = 0
|
||||||
|
for (const o of roof.innerLines || []) {
|
||||||
|
if (!o || o === selfLine || o.visible === false) continue
|
||||||
|
if (
|
||||||
|
Math.hypot(o.x1 - pt.x, o.y1 - pt.y) < APEX_SHARE_TOL ||
|
||||||
|
Math.hypot(o.x2 - pt.x, o.y2 - pt.y) < APEX_SHARE_TOL
|
||||||
|
)
|
||||||
|
deg++
|
||||||
|
}
|
||||||
|
return deg
|
||||||
|
}
|
||||||
|
// [KERAB-OFFSET-FUNCTIONIZE 2026-06-10] 라인 성격별 독립 핸들러.
|
||||||
|
// 두 핸들러는 공유 가변상태 없이 각자 il 만 갱신 → 골짜기↔일반 상호 간섭(whack-a-mole) 차단.
|
||||||
|
// 케라바 패턴 라인: 옛 roofLine segment 위 끝점 → 새 segment 의 동일 t 점.
|
||||||
|
// 한 끝만 segment 위(vExt 등 self-extension)일 때는 다른 끝도 같은 변위로 평행 이동 →
|
||||||
|
// 직각/형상 보존. 절삭/복원 흐름 skip.
|
||||||
|
const recomputeKerabPatternLine = (il) => {
|
||||||
const oldX1 = il.x1
|
const oldX1 = il.x1
|
||||||
const oldY1 = il.y1
|
const oldY1 = il.y1
|
||||||
const oldX2 = il.x2
|
const oldX2 = il.x2
|
||||||
const oldY2 = il.y2
|
const oldY2 = il.y2
|
||||||
const np1 = mapToNewSeg({ x: il.x1, y: il.y1 })
|
const np1 = mapToNewSeg({ x: il.x1, y: il.y1 })
|
||||||
const np2 = mapToNewSeg({ x: il.x2, y: il.y2 })
|
const np2 = mapToNewSeg({ x: il.x2, y: il.y2 })
|
||||||
|
// [KERAB-APEX-INVARIANT 2026-06-10] 한 끝만 roofLine 에 매핑될 때:
|
||||||
|
// 매핑 안 된 끝점이 junction(다른 내부선 ≥1 공유)이면 出幅 무관 고정점 →
|
||||||
|
// 이동 금지(junction 중심 pivot). vExt(수직 self-extension)는 우선순위상 평행이동 유지.
|
||||||
|
// 그 외(free 끝점, degree 0)만 같은 변위 평행이동(직각/형상 보존).
|
||||||
|
const isVExt = il.lineName === 'kerabPatternValleyExt'
|
||||||
|
let apexPivot = false
|
||||||
if (np1 && np2) {
|
if (np1 && np2) {
|
||||||
il.set({ x1: np1.x, y1: np1.y, x2: np2.x, y2: np2.y })
|
il.set({ x1: np1.x, y1: np1.y, x2: np2.x, y2: np2.y })
|
||||||
} else if (np1 && !np2) {
|
} else if (np1 && !np2) {
|
||||||
const dx = np1.x - il.x1
|
if (!isVExt && skeletonApexDegree({ x: il.x2, y: il.y2 }, il) >= 1) {
|
||||||
const dy = np1.y - il.y1
|
il.set({ x1: np1.x, y1: np1.y })
|
||||||
il.set({ x1: np1.x, y1: np1.y, x2: il.x2 + dx, y2: il.y2 + dy })
|
apexPivot = true
|
||||||
|
} else {
|
||||||
|
const dx = np1.x - il.x1
|
||||||
|
const dy = np1.y - il.y1
|
||||||
|
il.set({ x1: np1.x, y1: np1.y, x2: il.x2 + dx, y2: il.y2 + dy })
|
||||||
|
}
|
||||||
} else if (!np1 && np2) {
|
} else if (!np1 && np2) {
|
||||||
const dx = np2.x - il.x2
|
if (!isVExt && skeletonApexDegree({ x: il.x1, y: il.y1 }, il) >= 1) {
|
||||||
const dy = np2.y - il.y2
|
il.set({ x2: np2.x, y2: np2.y })
|
||||||
il.set({ x1: il.x1 + dx, y1: il.y1 + dy, x2: np2.x, y2: np2.y })
|
apexPivot = true
|
||||||
|
} else {
|
||||||
|
const dx = np2.x - il.x2
|
||||||
|
const dy = np2.y - il.y2
|
||||||
|
il.set({ x1: il.x1 + dx, y1: il.y1 + dy, x2: np2.x, y2: np2.y })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (typeof il.setCoords === 'function') il.setCoords()
|
if (typeof il.setCoords === 'function') il.setCoords()
|
||||||
|
if (isRidgeDiagLine(il)) il.__ridgeDiagBranch = `PATTERN-MAP np1=${!!np1} np2=${!!np2}`
|
||||||
if (np1 || np2) {
|
if (np1 || np2) {
|
||||||
logger.log(
|
logger.log(
|
||||||
'[KERAB-PATTERN-CORNER-SNAP] mapped ' +
|
'[KERAB-PATTERN-CORNER-SNAP] mapped ' +
|
||||||
@ -203,7 +241,10 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
|
|||||||
const projY = oldY1 + t * sdy
|
const projY = oldY1 + t * sdy
|
||||||
return Math.hypot(px - projX, py - projY) < 1.0
|
return Math.hypot(px - projX, py - projY) < 1.0
|
||||||
}
|
}
|
||||||
if (Math.hypot(dxVExt, dyVExt) > 0.01) {
|
// [KERAB-APEX-INVARIANT 2026-06-10] apex pivot(매핑 안 된 끝점이 apex 라 고정)인 경우는
|
||||||
|
// 라인이 평행이동이 아니라 apex 중심 회전이므로 cascade 금지 — apex 공유 ridge/hip 끌림 방지.
|
||||||
|
// vExt 등 실제 평행이동(양 끝 같은 변위)일 때만 옛 segment 위 끝점 전파.
|
||||||
|
if (!apexPivot && Math.hypot(dxVExt, dyVExt) > 0.01) {
|
||||||
// cascade 대상: roof.innerLines + canvas 의 kerabValleyOverlapLine (innerLines 미포함).
|
// cascade 대상: roof.innerLines + canvas 의 kerabValleyOverlapLine (innerLines 미포함).
|
||||||
// overlap 보조선들은 vExt 의 끝점과 90도로 만나므로 vExt 이동에 같이 따라가야 정합.
|
// overlap 보조선들은 vExt 의 끝점과 90도로 만나므로 vExt 이동에 같이 따라가야 정합.
|
||||||
const overlapInCanvas = (canvas.getObjects() || []).filter(
|
const overlapInCanvas = (canvas.getObjects() || []).filter(
|
||||||
@ -255,97 +296,76 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const orig = il.__shrinkOrig || { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }
|
const recomputeNormalLine = (il) => {
|
||||||
const orig1 = { x: orig.x1, y: orig.y1 }
|
// [KERAB-OFFSET-NORMAL-ABS 2026-06-10] 일반 라인(힙/마루/골짜기) 절대 재계산.
|
||||||
const orig2 = { x: orig.x2, y: orig.y2 }
|
// 도메인: 모든 끝점은 둘 중 하나 —
|
||||||
|
// (a) anchor = apex(wallLine 이 정하는 skeleton 교점) 또는 변경 무관한 다른 roofLine 변 위 점 → 出幅 불변, 고정.
|
||||||
// [KERAB-OFFSET-CORNER-SHORTCUT 2026-06-01] orig 끝점이 옛 corner 와 일치하면 새 corner 로 직접 snap.
|
// (b) hit = 변경된 roofLine 변(oldCorner1→oldCorner2) 위의 처마 끝 → 새 변으로 다시 그린다.
|
||||||
// 일반 절삭 흐름은 il segment 와 새 roofLine 변의 lineLineIntersection 으로 ip 계산하는데,
|
// 변경 변과 무관한 라인(양 끝 모두 hit 아님)은 그대로. 증감·복원 분기 불필요(절대값이라 idempotent).
|
||||||
// 끝점이 옛 corner 위에 있으면 ip 가 새 corner segment 밖으로 떨어져 segOk 가 reject → 절삭 실패.
|
// __shrinkOrig snapshot 제거 — apex 가 이미 불변(현재값=원본)이라 스냅샷 없이 매번 절대 재계산.
|
||||||
// 그 결과 c1·c2 비대칭으로 kLine 대각선 변형됨.
|
// hit 재계산 규칙:
|
||||||
const CORNER_SNAP_TOL_TRIM = 0.5
|
// - 끝점이 옛 corner 와 일치 → 새 corner 로 snap. (코너는 인접 변끼리의 교점이라
|
||||||
let cornerSnapped = false
|
// 라인 자기 방향 교점과 위치가 다르다 → 반드시 코너로 보정. 구 CORNER-SHORTCUT 의 기하 근거.)
|
||||||
const trySnap = (epx, epy, which) => {
|
// - 끝점이 변 중간(mid-edge) → anchor→hit 라인 방향 보존하며 새 변과의 교점. 방향은 평행
|
||||||
if (Math.hypot(epx - oldCorner1.x, epy - oldCorner1.y) < CORNER_SNAP_TOL_TRIM) {
|
// 出幅 변경에 불변(skeleton 각이등분선)이라 교점이 곧 새 처마 끝. 증가=확장/감소=절삭 동일식.
|
||||||
if (!il.__shrinkOrig) il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 }
|
const CORNER_TOL = 0.5
|
||||||
if (which === 1) il.set({ x1: newCorner1.x, y1: newCorner1.y })
|
const atCorner = (px, py) => {
|
||||||
else il.set({ x2: newCorner1.x, y2: newCorner1.y })
|
if (Math.hypot(px - oldCorner1.x, py - oldCorner1.y) < CORNER_TOL) return newCorner1
|
||||||
cornerSnapped = true
|
if (Math.hypot(px - oldCorner2.x, py - oldCorner2.y) < CORNER_TOL) return newCorner2
|
||||||
return true
|
return null
|
||||||
|
}
|
||||||
|
const e1 = { x: il.x1, y: il.y1 }
|
||||||
|
const e2 = { x: il.x2, y: il.y2 }
|
||||||
|
const m1 = mapToNewSeg(e1)
|
||||||
|
const m2 = mapToNewSeg(e2)
|
||||||
|
// 변경 변에 닿지 않는 라인(마루 등) → 무동작.
|
||||||
|
if (!m1 && !m2) return
|
||||||
|
const c1 = atCorner(e1.x, e1.y)
|
||||||
|
const c2 = atCorner(e2.x, e2.y)
|
||||||
|
let n1 = e1
|
||||||
|
let n2 = e2
|
||||||
|
let branch = ''
|
||||||
|
if (m1 && m2 && !c1 && !c2) {
|
||||||
|
// 양 끝 모두 변 중간 → 변 위에 누운 라인(드묾). t 비율로 매핑.
|
||||||
|
n1 = m1
|
||||||
|
n2 = m2
|
||||||
|
branch = 'BOTH-MID'
|
||||||
|
} else {
|
||||||
|
if (c1) {
|
||||||
|
n1 = c1
|
||||||
|
} else if (m1) {
|
||||||
|
// [KERAB-OFFSET-COLLINEAR 2026-06-11] 라인 방향이 변과 평행(변 위에 누운 면경계 세그먼트)이면
|
||||||
|
// 교점이 없어 ip=null → 옛날엔 return 으로 라인 통째 포기(=옛 위치 ghost). 이때는 변이
|
||||||
|
// 평행이동했으므로 mapToNewSeg(m1) 의 t-비율 투영이 곧 새 변 위 대응점 → 그걸로 폴백.
|
||||||
|
const ip = lineLineIntersection(e2, e1, newCorner1, newCorner2)
|
||||||
|
n1 = ip || m1
|
||||||
}
|
}
|
||||||
if (Math.hypot(epx - oldCorner2.x, epy - oldCorner2.y) < CORNER_SNAP_TOL_TRIM) {
|
if (c2) {
|
||||||
if (!il.__shrinkOrig) il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 }
|
n2 = c2
|
||||||
if (which === 1) il.set({ x1: newCorner2.x, y1: newCorner2.y })
|
} else if (m2) {
|
||||||
else il.set({ x2: newCorner2.x, y2: newCorner2.y })
|
const ip = lineLineIntersection(e1, e2, newCorner1, newCorner2)
|
||||||
cornerSnapped = true
|
n2 = ip || m2
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
return false
|
branch = `c1=${!!c1} c2=${!!c2} m1=${!!m1} m2=${!!m2}`
|
||||||
}
|
}
|
||||||
trySnap(orig.x1, orig.y1, 1)
|
il.set({ x1: n1.x, y1: n1.y, x2: n2.x, y2: n2.y })
|
||||||
trySnap(orig.x2, orig.y2, 2)
|
|
||||||
if (cornerSnapped) {
|
|
||||||
if (typeof il.setCoords === 'function') il.setCoords()
|
|
||||||
logger.log(
|
|
||||||
'[KERAB-OFFSET-CORNER-SHORTCUT] snapped ' +
|
|
||||||
JSON.stringify({ lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const d1 = (orig1.x - newAxisMid.x) * nx + (orig1.y - newAxisMid.y) * ny
|
|
||||||
const d2 = (orig2.x - newAxisMid.x) * nx + (orig2.y - newAxisMid.y) * ny
|
|
||||||
|
|
||||||
// 양 끝 모두 새 polygon 안쪽 → 원본 복원 + 백업 삭제
|
|
||||||
if (d1 <= OUTSIDE_TOL && d2 <= OUTSIDE_TOL) {
|
|
||||||
if (il.__shrinkOrig) {
|
|
||||||
il.set({ x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 })
|
|
||||||
if (typeof il.setCoords === 'function') il.setCoords()
|
|
||||||
logger.log(
|
|
||||||
'[KERAB-OFFSET-SHRINK-TRIM] restored ' +
|
|
||||||
JSON.stringify({ lineName: il.lineName, x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 }),
|
|
||||||
)
|
|
||||||
delete il.__shrinkOrig
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 바깥 끝점 절삭 (원본 segment 와 새 roofLine 변의 교점)
|
|
||||||
let nx1 = orig.x1
|
|
||||||
let ny1 = orig.y1
|
|
||||||
let nx2 = orig.x2
|
|
||||||
let ny2 = orig.y2
|
|
||||||
let trimmed = false
|
|
||||||
if (d1 > OUTSIDE_TOL) {
|
|
||||||
const ip = lineLineIntersection(orig1, orig2, newCorner1, newCorner2)
|
|
||||||
if (ip && segOk(ip)) {
|
|
||||||
nx1 = ip.x
|
|
||||||
ny1 = ip.y
|
|
||||||
trimmed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (d2 > OUTSIDE_TOL) {
|
|
||||||
const ip = lineLineIntersection(orig1, orig2, newCorner1, newCorner2)
|
|
||||||
if (ip && segOk(ip)) {
|
|
||||||
nx2 = ip.x
|
|
||||||
ny2 = ip.y
|
|
||||||
trimmed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!trimmed) continue
|
|
||||||
if (!il.__shrinkOrig) {
|
|
||||||
il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 }
|
|
||||||
}
|
|
||||||
il.set({ x1: nx1, y1: ny1, x2: nx2, y2: ny2 })
|
|
||||||
if (typeof il.setCoords === 'function') il.setCoords()
|
if (typeof il.setCoords === 'function') il.setCoords()
|
||||||
|
if (isRidgeDiagLine(il)) il.__ridgeDiagBranch = `NORMAL-ABS ${branch}`
|
||||||
logger.log(
|
logger.log(
|
||||||
'[KERAB-OFFSET-SHRINK-TRIM] trimmed ' +
|
'[KERAB-OFFSET-NORMAL-ABS] ' +
|
||||||
JSON.stringify({ lineName: il.lineName, orig, newPts: { x1: nx1, y1: ny1, x2: nx2, y2: ny2 } }),
|
JSON.stringify({ lineName: il.lineName, branch, newPts: { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 } }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [KERAB-OFFSET-FUNCTIONIZE 2026-06-10] dispatch — 라인 성격으로만 분기.
|
||||||
|
// 골짜기(케라바 패턴) 라인과 일반 라인은 각자 독립 핸들러로 처리되어 서로 간섭하지 않는다.
|
||||||
|
for (const il of roof.innerLines || []) {
|
||||||
|
if (!il) continue
|
||||||
|
if (isKerabPatternLine(il)) recomputeKerabPatternLine(il)
|
||||||
|
else recomputeNormalLine(il)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// points 는 새 배열로 set 해야 fabric 의 dirty 감지가 동작.
|
// points 는 새 배열로 set 해야 fabric 의 dirty 감지가 동작.
|
||||||
@ -412,6 +432,30 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {}
|
|||||||
}
|
}
|
||||||
if (typeof roof.addLengthText === 'function') roof.addLengthText()
|
if (typeof roof.addLengthText === 'function') roof.addLengthText()
|
||||||
|
|
||||||
|
// [RIDGE-DIAG 2026-06-10] 마루 길이 변동 검사 — 0.5mm 넘게 변하면 어느 분기(branch)에서
|
||||||
|
// 끌렸는지 + before/after 좌표를 warn 으로 남긴다. 우연 재발 시 콘솔에서 즉시 원인 특정.
|
||||||
|
for (const [il, before] of ridgeDiagBefore) {
|
||||||
|
const afterLen = Math.hypot(il.x2 - il.x1, il.y2 - il.y1)
|
||||||
|
const delta = afterLen - before.len
|
||||||
|
if (Math.abs(delta) > 0.5) {
|
||||||
|
logger.warn(
|
||||||
|
'[RIDGE-DIAG] 마루 길이 변동 ' +
|
||||||
|
JSON.stringify({
|
||||||
|
lineName: il.lineName,
|
||||||
|
branch: il.__ridgeDiagBranch || '(no-branch/skipInnerLines?)',
|
||||||
|
beforeLen: Math.round(before.len * 100) / 100,
|
||||||
|
afterLen: Math.round(afterLen * 100) / 100,
|
||||||
|
delta: Math.round(delta * 100) / 100,
|
||||||
|
oldOffset,
|
||||||
|
newOffset,
|
||||||
|
before: { x1: before.x1, y1: before.y1, x2: before.x2, y2: before.y2 },
|
||||||
|
after: { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
delete il.__ridgeDiagBranch
|
||||||
|
}
|
||||||
|
|
||||||
canvas.renderAll()
|
canvas.renderAll()
|
||||||
logger.log(
|
logger.log(
|
||||||
'[KERAB-OFFSET-SURGICAL] applied ' +
|
'[KERAB-OFFSET-SURGICAL] applied ' +
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user