|
|
|
|
@ -167,6 +167,184 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
// R3 outside: 끝점이 roofLine 폴리곤 밖(경계 tol 초과)으로 이탈 금지.
|
|
|
|
|
// R4 anchor: 토글 전후로 움직이지 않은 roofLine 코너(stable corner)의 끝점 점유수 불변
|
|
|
|
|
// (코너에서 hip 이 떨어지거나 엉뚱한 코너로 횡단하면 점유수 변화).
|
|
|
|
|
// [KERAB-OFFSET-HELPER 2026-06-15] 출폭함수 빌딩블록 (1/2): 케라바 hip(확장라인) 식별.
|
|
|
|
|
// reclick 검증 코드에서 무손실 추출. surgical 적용 *전*(옛 폴리곤) 에 호출해야 한다 —
|
|
|
|
|
// outer endpoint = 옛 roof.points 변 위 끝점, side = hip 직선이 지나는 wall 코너(A/B).
|
|
|
|
|
const detectKerabHipMarks = (target, roof) => {
|
|
|
|
|
const marks = []
|
|
|
|
|
if (!roof || !Array.isArray(roof.innerLines) || !Array.isArray(roof.points)) return marks
|
|
|
|
|
const wA = { x: target.x1, y: target.y1 }
|
|
|
|
|
const wB = { x: target.x2, y: target.y2 }
|
|
|
|
|
const pts = roof.points
|
|
|
|
|
const isOnOldPolyEdge = (P) => {
|
|
|
|
|
for (let i = 0; i < pts.length; i++) {
|
|
|
|
|
const A = pts[i]
|
|
|
|
|
const B = pts[(i + 1) % pts.length]
|
|
|
|
|
const ddx = B.x - A.x
|
|
|
|
|
const ddy = B.y - A.y
|
|
|
|
|
const lenSq = ddx * ddx + ddy * ddy
|
|
|
|
|
if (lenSq < 1e-6) continue
|
|
|
|
|
const t = ((P.x - A.x) * ddx + (P.y - A.y) * ddy) / lenSq
|
|
|
|
|
if (t < -0.02 || t > 1.02) continue
|
|
|
|
|
const projX = A.x + t * ddx
|
|
|
|
|
const projY = A.y + t * ddy
|
|
|
|
|
const d = Math.hypot(P.x - projX, P.y - projY)
|
|
|
|
|
if (d < 2.0) return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
for (const il of roof.innerLines) {
|
|
|
|
|
if (!il || (il.lineName !== 'hip' && il.lineName !== 'kerabPatternHip')) continue
|
|
|
|
|
const e1 = { x: il.x1, y: il.y1 }
|
|
|
|
|
const e2 = { x: il.x2, y: il.y2 }
|
|
|
|
|
const e1OnEdge = isOnOldPolyEdge(e1)
|
|
|
|
|
const e2OnEdge = isOnOldPolyEdge(e2)
|
|
|
|
|
let which = null
|
|
|
|
|
if (e1OnEdge && !e2OnEdge) which = 1
|
|
|
|
|
else if (e2OnEdge && !e1OnEdge) which = 2
|
|
|
|
|
if (which === null) continue
|
|
|
|
|
const outerEnd = which === 1 ? e1 : e2
|
|
|
|
|
const innerEnd = which === 1 ? e2 : e1
|
|
|
|
|
const ddx = outerEnd.x - innerEnd.x
|
|
|
|
|
const ddy = outerEnd.y - innerEnd.y
|
|
|
|
|
const llen = Math.hypot(ddx, ddy) || 1
|
|
|
|
|
const distPerp = (P) => Math.abs((ddx * (innerEnd.y - P.y) - ddy * (innerEnd.x - P.x)) / llen)
|
|
|
|
|
const dA = distPerp(wA)
|
|
|
|
|
const dB = distPerp(wB)
|
|
|
|
|
if (Math.min(dA, dB) > 5.0) continue
|
|
|
|
|
const side = dA <= dB ? 'A' : 'B'
|
|
|
|
|
marks.push({ il, which, side })
|
|
|
|
|
}
|
|
|
|
|
logger.log(
|
|
|
|
|
'[KERAB-OFFSET-ONLY-RECLICK-EAVES] hip marks ' +
|
|
|
|
|
JSON.stringify(marks.map((m) => ({ which: m.which, side: m.side, lineName: m.il.lineName }))),
|
|
|
|
|
)
|
|
|
|
|
return marks
|
|
|
|
|
}
|
|
|
|
|
// [KERAB-OFFSET-HELPER 2026-06-15] 출폭함수 빌딩블록 (2/2): 케라바 hip 을 45° ray-cast 확장.
|
|
|
|
|
// surgical 적용 *후*(새 폴리곤) 에 호출. wall 코너에서 옛 hip 방향(45°)으로 새 roofLine 변까지.
|
|
|
|
|
// CORNER-SNAP 금지 룰의 실제 구현부 — roofLine 코너가 아니라 교점까지 뻗는다.
|
|
|
|
|
// [45-UNIFY 2026-06-15] reclick·revert 두 경로의 45° 구현을 한 벌로 통합. 가드는 opt-in:
|
|
|
|
|
// - useRevertGuards=false(reclick): 순수 45° ray-cast (기존 reclick 동작 그대로).
|
|
|
|
|
// - useRevertGuards=true(revert): outerShared(골짜기 내부 hip 제외) + outerOnVertex(게이블 코너
|
|
|
|
|
// hip 은 같은 인덱스 새 꼭짓점으로 직접 스냅) 가드 적용. oldPolyPoints = 이동 전 폴리곤 꼭짓점.
|
|
|
|
|
const extendKerabHipsTo45 = (target, roof, hipMarks, options = {}) => {
|
|
|
|
|
if (!roof || !Array.isArray(roof.points) || !hipMarks || !hipMarks.length) return
|
|
|
|
|
const { useRevertGuards = false, oldPolyPoints = null } = options
|
|
|
|
|
const wA = { x: target.x1, y: target.y1 }
|
|
|
|
|
const wB = { x: target.x2, y: target.y2 }
|
|
|
|
|
const rps = roof.points
|
|
|
|
|
const M = rps.length
|
|
|
|
|
if (!M) return
|
|
|
|
|
const baseTag = useRevertGuards ? '[KERAB-REVERT-EXTEND-45]' : '[KERAB-OFFSET-ONLY-RECLICK-EAVES]'
|
|
|
|
|
const okTag = useRevertGuards ? '[KERAB-REVERT-EXTEND-45] ' : '[KERAB-OFFSET-ONLY-RECLICK-EAVES] extended '
|
|
|
|
|
const rayHit = (P, dir, A, B) => {
|
|
|
|
|
const sx = B.x - A.x
|
|
|
|
|
const sy = B.y - A.y
|
|
|
|
|
const denom = dir.x * sy - dir.y * sx
|
|
|
|
|
if (Math.abs(denom) < 1e-9) return Infinity
|
|
|
|
|
const ax = A.x - P.x
|
|
|
|
|
const ay = A.y - P.y
|
|
|
|
|
const t = (ax * sy - ay * sx) / denom
|
|
|
|
|
const s = (ax * dir.y - ay * dir.x) / denom
|
|
|
|
|
if (t > 1e-6 && s >= -1e-6 && s <= 1 + 1e-6) return t
|
|
|
|
|
return Infinity
|
|
|
|
|
}
|
|
|
|
|
const cast45 = (wCorner, innerEnd, outerOld, il, which, side) => {
|
|
|
|
|
const dx = outerOld.x - innerEnd.x
|
|
|
|
|
const dy = outerOld.y - innerEnd.y
|
|
|
|
|
const dlen = Math.hypot(dx, dy)
|
|
|
|
|
if (dlen < 1e-6) return null
|
|
|
|
|
const ux = dx / dlen
|
|
|
|
|
const uy = dy / dlen
|
|
|
|
|
let bestT = Infinity
|
|
|
|
|
for (let k = 0; k < M; k++) {
|
|
|
|
|
const A = rps[k]
|
|
|
|
|
const B = rps[(k + 1) % M]
|
|
|
|
|
const t = rayHit(wCorner, { x: ux, y: uy }, A, B)
|
|
|
|
|
if (t < bestT) bestT = t
|
|
|
|
|
}
|
|
|
|
|
if (!isFinite(bestT) || bestT < 0.5) {
|
|
|
|
|
logger.log(baseTag + ' no-hit ' + JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } }))
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
return { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT }
|
|
|
|
|
}
|
|
|
|
|
for (const mark of hipMarks) {
|
|
|
|
|
const { il, which, side } = mark
|
|
|
|
|
const wCorner = side === 'A' ? wA : wB
|
|
|
|
|
const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
|
|
|
|
|
const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
|
|
|
|
|
let hit
|
|
|
|
|
if (useRevertGuards) {
|
|
|
|
|
// outerShared: outer 끝이 다른 내부선과 공유 → 골짜기 내부 hip → 확장 스킵.
|
|
|
|
|
const SHARE_TOL = 2.0
|
|
|
|
|
const outerShared = (roof.innerLines || []).some(
|
|
|
|
|
(o) =>
|
|
|
|
|
o &&
|
|
|
|
|
o !== il &&
|
|
|
|
|
o.visible !== false &&
|
|
|
|
|
(Math.hypot(o.x1 - outerOld.x, o.y1 - outerOld.y) < SHARE_TOL ||
|
|
|
|
|
Math.hypot(o.x2 - outerOld.x, o.y2 - outerOld.y) < SHARE_TOL),
|
|
|
|
|
)
|
|
|
|
|
// outerOnVertex: outer 가 (이동 전) 폴리곤 꼭짓점이면 게이블 코너 hip → 같은 인덱스 새 꼭짓점 스냅.
|
|
|
|
|
const VERT_TOL = 2.0
|
|
|
|
|
const opp = Array.isArray(oldPolyPoints) ? oldPolyPoints : []
|
|
|
|
|
let vtxIdx = -1
|
|
|
|
|
for (let vi = 0; vi < opp.length; vi++) {
|
|
|
|
|
const p = opp[vi]
|
|
|
|
|
if (p && Math.hypot(p.x - outerOld.x, p.y - outerOld.y) < VERT_TOL) {
|
|
|
|
|
vtxIdx = vi
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const outerOnVertex = vtxIdx >= 0
|
|
|
|
|
if (outerShared && !outerOnVertex) {
|
|
|
|
|
logger.log(baseTag + ' skip (interior hip — outer shared) ' + JSON.stringify({ lineName: il.lineName, which, side, outerOld }))
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if (outerOnVertex && rps.length === opp.length && rps[vtxIdx]) {
|
|
|
|
|
hit = { x: rps[vtxIdx].x, y: rps[vtxIdx].y }
|
|
|
|
|
} else {
|
|
|
|
|
hit = cast45(wCorner, innerEnd, outerOld, il, which, side)
|
|
|
|
|
if (!hit) continue
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
hit = cast45(wCorner, innerEnd, outerOld, il, which, side)
|
|
|
|
|
if (!hit) continue
|
|
|
|
|
}
|
|
|
|
|
const oldLen = Math.hypot(outerOld.x - innerEnd.x, outerOld.y - innerEnd.y)
|
|
|
|
|
const newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y)
|
|
|
|
|
const ratio = oldLen > 0 ? newLen / oldLen : 1
|
|
|
|
|
if (which === 1) il.set({ x1: hit.x, y1: hit.y })
|
|
|
|
|
else il.set({ x2: hit.x, y2: hit.y })
|
|
|
|
|
if (il.attributes) {
|
|
|
|
|
const oldPlane = il.attributes.planeSize ?? 0
|
|
|
|
|
const oldActual = il.attributes.actualSize ?? 0
|
|
|
|
|
il.attributes.planeSize = Math.round(oldPlane * ratio * 100) / 100
|
|
|
|
|
il.attributes.actualSize = Math.round(oldActual * ratio * 100) / 100
|
|
|
|
|
il.attributes.extended = true
|
|
|
|
|
}
|
|
|
|
|
il.__extended = true
|
|
|
|
|
if (typeof il.setCoords === 'function') il.setCoords()
|
|
|
|
|
if (typeof il.setLength === 'function') il.setLength()
|
|
|
|
|
if (typeof il.addLengthText === 'function') il.addLengthText()
|
|
|
|
|
logger.log(
|
|
|
|
|
okTag +
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
lineName: il.lineName,
|
|
|
|
|
which,
|
|
|
|
|
side,
|
|
|
|
|
hit,
|
|
|
|
|
ratio: Number(ratio.toFixed(3)),
|
|
|
|
|
x1: il.x1,
|
|
|
|
|
y1: il.y1,
|
|
|
|
|
x2: il.x2,
|
|
|
|
|
y2: il.y2,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const snapshotKerabState = (roof) => {
|
|
|
|
|
if (!roof || !Array.isArray(roof.innerLines)) return null
|
|
|
|
|
const lines = roof.innerLines
|
|
|
|
|
@ -441,137 +619,50 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
// (케라바 ONLY-RECLICK 은 KERAB-PATTERN-CORNER-SNAP 필요하므로 그대로 둠.)
|
|
|
|
|
const isEaves = attributes?.type === LINE_TYPE.WALLLINE.EAVES
|
|
|
|
|
let reclickRoof = null
|
|
|
|
|
const hipMarks = []
|
|
|
|
|
let hasKerabPattern = false
|
|
|
|
|
let hipMarks = []
|
|
|
|
|
if (isEaves) {
|
|
|
|
|
reclickRoof = canvas
|
|
|
|
|
.getObjects()
|
|
|
|
|
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
|
|
|
|
|
if (reclickRoof && Array.isArray(reclickRoof.innerLines) && Array.isArray(reclickRoof.points)) {
|
|
|
|
|
const wA = { x: target.x1, y: target.y1 }
|
|
|
|
|
const wB = { x: target.x2, y: target.y2 }
|
|
|
|
|
const pts = reclickRoof.points
|
|
|
|
|
// outer endpoint 식별: 옛 roof.points 변 위 (perpendicular distance < 2) 인 끝점.
|
|
|
|
|
const isOnOldPolyEdge = (P) => {
|
|
|
|
|
for (let i = 0; i < pts.length; i++) {
|
|
|
|
|
const A = pts[i]
|
|
|
|
|
const B = pts[(i + 1) % pts.length]
|
|
|
|
|
const ddx = B.x - A.x
|
|
|
|
|
const ddy = B.y - A.y
|
|
|
|
|
const lenSq = ddx * ddx + ddy * ddy
|
|
|
|
|
if (lenSq < 1e-6) continue
|
|
|
|
|
const t = ((P.x - A.x) * ddx + (P.y - A.y) * ddy) / lenSq
|
|
|
|
|
if (t < -0.02 || t > 1.02) continue
|
|
|
|
|
const projX = A.x + t * ddx
|
|
|
|
|
const projY = A.y + t * ddy
|
|
|
|
|
const d = Math.hypot(P.x - projX, P.y - projY)
|
|
|
|
|
if (d < 2.0) return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
for (const il of reclickRoof.innerLines) {
|
|
|
|
|
if (!il || il.lineName !== 'hip') continue
|
|
|
|
|
const e1 = { x: il.x1, y: il.y1 }
|
|
|
|
|
const e2 = { x: il.x2, y: il.y2 }
|
|
|
|
|
const e1OnEdge = isOnOldPolyEdge(e1)
|
|
|
|
|
const e2OnEdge = isOnOldPolyEdge(e2)
|
|
|
|
|
let which = null
|
|
|
|
|
if (e1OnEdge && !e2OnEdge) which = 1
|
|
|
|
|
else if (e2OnEdge && !e1OnEdge) which = 2
|
|
|
|
|
if (which === null) continue
|
|
|
|
|
const outerEnd = which === 1 ? e1 : e2
|
|
|
|
|
const innerEnd = which === 1 ? e2 : e1
|
|
|
|
|
// transit corner: hip 직선 위에 wA/wB 중 어느 코너가 있는지 (perpendicular distance).
|
|
|
|
|
const ddx = outerEnd.x - innerEnd.x
|
|
|
|
|
const ddy = outerEnd.y - innerEnd.y
|
|
|
|
|
const llen = Math.hypot(ddx, ddy) || 1
|
|
|
|
|
const distPerp = (P) => Math.abs((ddx * (innerEnd.y - P.y) - ddy * (innerEnd.x - P.x)) / llen)
|
|
|
|
|
const dA = distPerp(wA)
|
|
|
|
|
const dB = distPerp(wB)
|
|
|
|
|
if (Math.min(dA, dB) > 5.0) continue
|
|
|
|
|
const side = dA <= dB ? 'A' : 'B'
|
|
|
|
|
hipMarks.push({ il, which, side })
|
|
|
|
|
}
|
|
|
|
|
logger.log(
|
|
|
|
|
'[KERAB-OFFSET-ONLY-RECLICK-EAVES] hip marks ' +
|
|
|
|
|
JSON.stringify(hipMarks.map((m) => ({ which: m.which, side: m.side, lineName: m.il.lineName }))),
|
|
|
|
|
hasKerabPattern = reclickRoof.innerLines.some(
|
|
|
|
|
(il) => il && typeof il.lineName === 'string' && il.lineName.startsWith('kerabPattern'),
|
|
|
|
|
)
|
|
|
|
|
// surgical 적용 *전*(옛 폴리곤) 에 케라바 hip 식별 — outer endpoint 가 옛 변 위에 있어야 잡힌다.
|
|
|
|
|
hipMarks = detectKerabHipMarks(target, reclickRoof)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
applyTargetOffsetSurgical(target, newOffset, isEaves ? { skipInnerLines: true } : undefined)
|
|
|
|
|
if (isEaves && reclickRoof && hipMarks.length) {
|
|
|
|
|
const wA = { x: target.x1, y: target.y1 }
|
|
|
|
|
const wB = { x: target.x2, y: target.y2 }
|
|
|
|
|
const rps = reclickRoof.points
|
|
|
|
|
const M = rps.length
|
|
|
|
|
const rayHit = (P, dir, A, B) => {
|
|
|
|
|
const sx = B.x - A.x
|
|
|
|
|
const sy = B.y - A.y
|
|
|
|
|
const denom = dir.x * sy - dir.y * sx
|
|
|
|
|
if (Math.abs(denom) < 1e-9) return Infinity
|
|
|
|
|
const ax = A.x - P.x
|
|
|
|
|
const ay = A.y - P.y
|
|
|
|
|
const t = (ax * sy - ay * sx) / denom
|
|
|
|
|
const s = (ax * dir.y - ay * dir.x) / denom
|
|
|
|
|
if (t > 1e-6 && s >= -1e-6 && s <= 1 + 1e-6) return t
|
|
|
|
|
return Infinity
|
|
|
|
|
}
|
|
|
|
|
for (const mark of hipMarks) {
|
|
|
|
|
const { il, which, side } = mark
|
|
|
|
|
const wCorner = side === 'A' ? wA : wB
|
|
|
|
|
const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
|
|
|
|
|
const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
|
|
|
|
|
const dx = outerOld.x - innerEnd.x
|
|
|
|
|
const dy = outerOld.y - innerEnd.y
|
|
|
|
|
const dlen = Math.hypot(dx, dy)
|
|
|
|
|
if (dlen < 1e-6) continue
|
|
|
|
|
const ux = dx / dlen
|
|
|
|
|
const uy = dy / dlen
|
|
|
|
|
let bestT = Infinity
|
|
|
|
|
for (let k = 0; k < M; k++) {
|
|
|
|
|
const A = rps[k]
|
|
|
|
|
const B = rps[(k + 1) % M]
|
|
|
|
|
const t = rayHit(wCorner, { x: ux, y: uy }, A, B)
|
|
|
|
|
if (t < bestT) bestT = t
|
|
|
|
|
}
|
|
|
|
|
if (!isFinite(bestT) || bestT < 0.5) {
|
|
|
|
|
logger.log(
|
|
|
|
|
'[KERAB-OFFSET-ONLY-RECLICK-EAVES] no-hit ' +
|
|
|
|
|
JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } }),
|
|
|
|
|
)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
const hit = { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT }
|
|
|
|
|
const oldLen = Math.hypot(outerOld.x - innerEnd.x, outerOld.y - innerEnd.y)
|
|
|
|
|
const newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y)
|
|
|
|
|
const ratio = oldLen > 0 ? newLen / oldLen : 1
|
|
|
|
|
if (which === 1) il.set({ x1: hit.x, y1: hit.y })
|
|
|
|
|
else il.set({ x2: hit.x, y2: hit.y })
|
|
|
|
|
if (il.attributes) {
|
|
|
|
|
const oldPlane = il.attributes.planeSize ?? 0
|
|
|
|
|
const oldActual = il.attributes.actualSize ?? 0
|
|
|
|
|
il.attributes.planeSize = Math.round(oldPlane * ratio * 100) / 100
|
|
|
|
|
il.attributes.actualSize = Math.round(oldActual * ratio * 100) / 100
|
|
|
|
|
il.attributes.extended = true
|
|
|
|
|
}
|
|
|
|
|
il.__extended = true
|
|
|
|
|
// [KERAB-OFFSET-RECLICK-UNIFY 2026-06-15] 出幅 재변경(eaves→eaves)을 skLine 과 동일 경로로 통일.
|
|
|
|
|
// 출발 형상(skLine / A/B타입)에 무관하게 동일 규칙(= 출폭함수):
|
|
|
|
|
// - 케라바 hip(확장라인)은 surgical CORNER-SNAP 금지 → wall 코너에서 45° ray-cast 로 roofLine 까지.
|
|
|
|
|
// - 측면 수직선/변조각(normal line)은 surgical(recomputeNormalLine)이 새 코너로 추종.
|
|
|
|
|
// skLine: 케라바 패턴 없음 → skipInnerLines(전부 45°가 처리).
|
|
|
|
|
// A/B타입: 케라바 패턴 보유 → skipKerabHips(normal/ridge 는 surgical, hip 만 45°가 처리).
|
|
|
|
|
// 출폭 증감(확장/수축) 양방향이 한 경로에서 일관 처리됨.
|
|
|
|
|
const reclickBeforeSnap = isEaves && reclickRoof ? snapshotKerabState(reclickRoof) : null
|
|
|
|
|
applyTargetOffsetSurgical(
|
|
|
|
|
target,
|
|
|
|
|
newOffset,
|
|
|
|
|
isEaves ? (hasKerabPattern ? { skipKerabHips: true } : { skipInnerLines: true }) : undefined,
|
|
|
|
|
)
|
|
|
|
|
// surgical 적용 *후*(새 폴리곤) 에 케라바 hip 을 45° ray-cast 로 새 roofLine 변까지 확장.
|
|
|
|
|
if (isEaves && reclickRoof) extendKerabHipsTo45(target, reclickRoof, hipMarks)
|
|
|
|
|
// [KERAB-OFFSET-RECLICK-UNIFY 2026-06-15] A/B타입은 케라바 hip 외 inner line 좌표가
|
|
|
|
|
// surgical 내부(recomputeKerabPatternLine/recomputeNormalLine)에서 갱신됨 →
|
|
|
|
|
// 길이/치수 라벨만 새 좌표 기준으로 새로고침한다(hip 은 45° 블록에서 이미 갱신).
|
|
|
|
|
if (isEaves && hasKerabPattern && reclickRoof && Array.isArray(reclickRoof.innerLines)) {
|
|
|
|
|
for (const il of reclickRoof.innerLines) {
|
|
|
|
|
if (!il || il.lineName === 'kerabPatternHip') continue
|
|
|
|
|
if (typeof il.setCoords === 'function') il.setCoords()
|
|
|
|
|
if (typeof il.setLength === 'function') il.setLength()
|
|
|
|
|
if (typeof il.addLengthText === 'function') il.addLengthText()
|
|
|
|
|
logger.log(
|
|
|
|
|
'[KERAB-OFFSET-ONLY-RECLICK-EAVES] extended ' +
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
lineName: il.lineName,
|
|
|
|
|
which,
|
|
|
|
|
side,
|
|
|
|
|
hit,
|
|
|
|
|
ratio: Number(ratio.toFixed(3)),
|
|
|
|
|
x1: il.x1,
|
|
|
|
|
y1: il.y1,
|
|
|
|
|
x2: il.x2,
|
|
|
|
|
y2: il.y2,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
logger.log('[KERAB-OFFSET-RECLICK-UNIFY] surgical label refresh done')
|
|
|
|
|
}
|
|
|
|
|
// [KERAB-OFFSET-RECLICK-RULECHECK 2026-06-15] 라인변경(출폭 재적용) 후 규칙 검사 — 사용자 요구.
|
|
|
|
|
if (isEaves && reclickRoof && reclickBeforeSnap) {
|
|
|
|
|
runKerabRuleCheck(reclickRoof, 'reclick', reclickBeforeSnap)
|
|
|
|
|
}
|
|
|
|
|
target.set({ attributes })
|
|
|
|
|
canvas.renderAll()
|
|
|
|
|
@ -604,6 +695,19 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [KERAB-RECT-SOLVER 2026-06-15] 사각형이면 토글 이력과 무관한 결정론적 솔버로 처리.
|
|
|
|
|
// 최종 4변 타입만으로 내부선 전부 재생성(Y 불변식 + apex 멈춤 구조적 보장). 미지원 형상은
|
|
|
|
|
// false → 아래 기존 forward/revert/type 경로로 폴백.
|
|
|
|
|
if (radioTypeRef.current === '1' && (attributes?.type === LINE_TYPE.WALLLINE.EAVES || attributes?.type === LINE_TYPE.WALLLINE.GABLE)) {
|
|
|
|
|
const rectRoof = canvas
|
|
|
|
|
.getObjects()
|
|
|
|
|
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
|
|
|
|
|
if (rectRoof && solveRectangleKerab(rectRoof, target, attributes)) {
|
|
|
|
|
logger.log('[KERAB-RECT-SOLVER] handled deterministically → maze 우회')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [2240 KERAB-SIMPLE 2026-05-20] 사용자 설명 정직 알고리즘:
|
|
|
|
|
// 1) target 양 끝점에 직접 끝이 닿은 hip 2개를 찾는다 (nearestRoofPoint 안 씀)
|
|
|
|
|
// 2) 두 hip 직선의 무한확장 교점 = apex
|
|
|
|
|
@ -2551,139 +2655,18 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
// 게이블 코너 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 })
|
|
|
|
|
const wA = { x: target.x1, y: target.y1 }
|
|
|
|
|
const wB = { x: target.x2, y: target.y2 }
|
|
|
|
|
const rps = Array.isArray(roof.points) ? roof.points : []
|
|
|
|
|
const M = rps.length
|
|
|
|
|
if (!M) return
|
|
|
|
|
const rayHit = (P, dir, A, B) => {
|
|
|
|
|
const sx = B.x - A.x
|
|
|
|
|
const sy = B.y - A.y
|
|
|
|
|
const denom = dir.x * sy - dir.y * sx
|
|
|
|
|
if (Math.abs(denom) < 1e-9) return Infinity
|
|
|
|
|
const ax = A.x - P.x
|
|
|
|
|
const ay = A.y - P.y
|
|
|
|
|
const t = (ax * sy - ay * sx) / denom
|
|
|
|
|
const s = (ax * dir.y - ay * dir.x) / denom
|
|
|
|
|
if (t > 1e-6 && s >= -1e-6 && s <= 1 + 1e-6) return t
|
|
|
|
|
return Infinity
|
|
|
|
|
}
|
|
|
|
|
// [45-UNIFY 2026-06-15] reclick 과 동일한 extendKerabHipsTo45 사용 (revert 가드 on):
|
|
|
|
|
// forward 가 붙여둔 __kerabRevertOuterWhich/Side 플래그로 marks 를 만든 뒤, surgical *후*
|
|
|
|
|
// 새 폴리곤에 대해 outerShared/outerOnVertex 가드를 적용해 45° 확장. 플래그는 사용 후 정리.
|
|
|
|
|
const marks = []
|
|
|
|
|
for (const il of roof.innerLines || []) {
|
|
|
|
|
if (!il || !il.__kerabRevertOuterWhich) continue
|
|
|
|
|
const which = il.__kerabRevertOuterWhich
|
|
|
|
|
const side = il.__kerabRevertOuterSide
|
|
|
|
|
const wCorner = side === 'A' ? wA : wB
|
|
|
|
|
const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
|
|
|
|
|
const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
|
|
|
|
|
// [KERAB-REVERT-SHARED-ENDPOINT-GUARD 2026-06-10] outer(확장) 끝이 다른 내부선과
|
|
|
|
|
// 공유되면 확장하지 않는다. 골짜기(valley) 내부 hip 은 양 끝이 skeleton 교점이라 outer 도
|
|
|
|
|
// 공유 → 자동 제외. 게이블/코너 hip 의 outer 는 free(roofLine 변에 홀로 도달) → 확장.
|
|
|
|
|
// 판정이 출폭·snapshot 좌표와 무관한 구조 기준이라 다중 출폭 변경에도 안 깨진다.
|
|
|
|
|
// (구 c1/c2 가드는 c1/c2 를 현재 출폭으로, outerOld 를 forward 당시 출폭으로 잡아
|
|
|
|
|
// 출폭이 여러 번 바뀌면 게이블 hip 을 interior 로 오판 → SK 확장이 중간에 멈췄다.)
|
|
|
|
|
const SHARE_TOL = 2.0
|
|
|
|
|
const outerShared = (roof.innerLines || []).some(
|
|
|
|
|
(o) =>
|
|
|
|
|
o &&
|
|
|
|
|
o !== il &&
|
|
|
|
|
o.visible !== false &&
|
|
|
|
|
(Math.hypot(o.x1 - outerOld.x, o.y1 - outerOld.y) < SHARE_TOL ||
|
|
|
|
|
Math.hypot(o.x2 - outerOld.x, o.y2 - outerOld.y) < SHARE_TOL),
|
|
|
|
|
)
|
|
|
|
|
// [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 outerOnVertex = vtxIdx >= 0
|
|
|
|
|
if (outerShared && !outerOnVertex) {
|
|
|
|
|
logger.log(
|
|
|
|
|
'[KERAB-REVERT-EXTEND-45] skip (interior hip — outer shared) ' +
|
|
|
|
|
JSON.stringify({ lineName: il.lineName, which, side, outerOld }),
|
|
|
|
|
)
|
|
|
|
|
delete il.__kerabRevertOuterWhich
|
|
|
|
|
delete il.__kerabRevertOuterSide
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
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 newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y)
|
|
|
|
|
const ratio = oldLen > 0 ? newLen / oldLen : 1
|
|
|
|
|
if (which === 1) il.set({ x1: hit.x, y1: hit.y })
|
|
|
|
|
else il.set({ x2: hit.x, y2: hit.y })
|
|
|
|
|
if (il.attributes) {
|
|
|
|
|
const oldPlane = il.attributes.planeSize ?? 0
|
|
|
|
|
const oldActual = il.attributes.actualSize ?? 0
|
|
|
|
|
il.attributes.planeSize = Math.round(oldPlane * ratio * 100) / 100
|
|
|
|
|
il.attributes.actualSize = Math.round(oldActual * ratio * 100) / 100
|
|
|
|
|
il.attributes.extended = true
|
|
|
|
|
}
|
|
|
|
|
il.__extended = true
|
|
|
|
|
if (typeof il.setCoords === 'function') il.setCoords()
|
|
|
|
|
if (typeof il.setLength === 'function') il.setLength()
|
|
|
|
|
if (typeof il.addLengthText === 'function') il.addLengthText()
|
|
|
|
|
logger.log(
|
|
|
|
|
'[KERAB-REVERT-EXTEND-45] ' +
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
lineName: il.lineName,
|
|
|
|
|
which,
|
|
|
|
|
side,
|
|
|
|
|
hit,
|
|
|
|
|
ratio: Number(ratio.toFixed(3)),
|
|
|
|
|
x1: il.x1,
|
|
|
|
|
y1: il.y1,
|
|
|
|
|
x2: il.x2,
|
|
|
|
|
y2: il.y2,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
delete il.__kerabRevertOuterWhich
|
|
|
|
|
delete il.__kerabRevertOuterSide
|
|
|
|
|
marks.push({ il, which: il.__kerabRevertOuterWhich, side: il.__kerabRevertOuterSide })
|
|
|
|
|
}
|
|
|
|
|
extendKerabHipsTo45(target, roof, marks, { useRevertGuards: true, oldPolyPoints })
|
|
|
|
|
for (const m of marks) {
|
|
|
|
|
delete m.il.__kerabRevertOuterWhich
|
|
|
|
|
delete m.il.__kerabRevertOuterSide
|
|
|
|
|
}
|
|
|
|
|
runKerabRuleCheck(roof, 'revert', kerabRevertBeforeSnap)
|
|
|
|
|
}
|
|
|
|
|
@ -3403,6 +3386,31 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
removeLine(eh)
|
|
|
|
|
roof.innerLines = roof.innerLines.filter((il) => il !== eh)
|
|
|
|
|
}
|
|
|
|
|
// [KERAB-EXTHIP-COORD-CLEAN 2026-06-15] __extHipsCreated 는 객체 참조라, 양쪽 박공→한 변씩 처마
|
|
|
|
|
// 복원 시퀀스에서 중간 인접변 작업이 ext-hip 을 파괴·재생성하면 죽은 객체를 가리켜 위 루프가
|
|
|
|
|
// no-op 이 된다 → 재생성된 ext-hip 이 orphan 으로 잔류(apex 에서 끝나는 1881 V, "라인은 roofLine
|
|
|
|
|
// 까지" 규칙 위반). 이 kLine 의 apex(=roofLine 반대쪽 안쪽 끝점) 를 공유하는 ext 라인을 좌표로
|
|
|
|
|
// 한 번 더 제거해 재생성돼도 잡는다. orphan 없으면 0개 제거 → 정상 케이스 무영향.
|
|
|
|
|
{
|
|
|
|
|
const _r = ridgeAtMid.ridge
|
|
|
|
|
const _tMid = { x: (target.x1 + target.x2) / 2, y: (target.y1 + target.y2) / 2 }
|
|
|
|
|
const _apexPt =
|
|
|
|
|
Math.hypot(_r.x1 - _tMid.x, _r.y1 - _tMid.y) >= Math.hypot(_r.x2 - _tMid.x, _r.y2 - _tMid.y)
|
|
|
|
|
? { x: _r.x1, y: _r.y1 }
|
|
|
|
|
: { x: _r.x2, y: _r.y2 }
|
|
|
|
|
const _EXT_TOL = 1.0
|
|
|
|
|
const _orphans = roof.innerLines.filter(
|
|
|
|
|
(il) =>
|
|
|
|
|
il &&
|
|
|
|
|
(il.lineName === 'kerabPatternExtHip' || il.lineName === 'kerabPatternExtRidge') &&
|
|
|
|
|
(Math.hypot(il.x1 - _apexPt.x, il.y1 - _apexPt.y) < _EXT_TOL || Math.hypot(il.x2 - _apexPt.x, il.y2 - _apexPt.y) < _EXT_TOL),
|
|
|
|
|
)
|
|
|
|
|
for (const eh of _orphans) {
|
|
|
|
|
removeLine(eh)
|
|
|
|
|
roof.innerLines = roof.innerLines.filter((il) => il !== eh)
|
|
|
|
|
}
|
|
|
|
|
if (_orphans.length) logger.log('[KERAB-EXTHIP-COORD-CLEAN] removed ' + JSON.stringify({ apex: _apexPt, count: _orphans.length }))
|
|
|
|
|
}
|
|
|
|
|
removeLine(ridgeAtMid.ridge)
|
|
|
|
|
roof.innerLines = roof.innerLines.filter((il) => il !== ridgeAtMid.ridge)
|
|
|
|
|
for (const snap of removedHips) {
|
|
|
|
|
@ -3555,10 +3563,37 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
ix = -ix
|
|
|
|
|
iy = -iy
|
|
|
|
|
}
|
|
|
|
|
const apex = { x: mid.x + ix * (L / 2), y: mid.y + iy * (L / 2) }
|
|
|
|
|
// [KERAB-TYPE-EAVES-APEX-CLAMP 2026-06-15] apex 깊이 = L/2 (45° 이등변). 단 변이 폴리곤 inward
|
|
|
|
|
// 두께보다 길면(가로로 납작한 폴리곤) mid+inward*(L/2) 가 반대편 roofLine 을 넘어 폭주
|
|
|
|
|
// (R3-outside). "라인은 roofLine 까지" 규칙 → inward ray 가 반대편 변에 닿는 거리로 상한.
|
|
|
|
|
// 정상 切妻(세로로 긴 폴리곤)은 oppDist > L/2 라 min=L/2, 기존과 동치 → 무영향.
|
|
|
|
|
const oppRayDist = (P, dx, dy) => {
|
|
|
|
|
let best = Infinity
|
|
|
|
|
const M = cps.length
|
|
|
|
|
for (let i = 0; i < M; i++) {
|
|
|
|
|
const A = cps[i]
|
|
|
|
|
const B = cps[(i + 1) % M]
|
|
|
|
|
const ax = B.x - A.x
|
|
|
|
|
const ay = B.y - A.y
|
|
|
|
|
const den = dx * ay - dy * ax
|
|
|
|
|
if (Math.abs(den) < 1e-9) continue
|
|
|
|
|
const t = ((A.x - P.x) * ay - (A.y - P.y) * ax) / den
|
|
|
|
|
const u = ((A.x - P.x) * dy - (A.y - P.y) * dx) / den
|
|
|
|
|
if (t > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6 && t < best) best = t
|
|
|
|
|
}
|
|
|
|
|
return best
|
|
|
|
|
}
|
|
|
|
|
const oppDist = oppRayDist(mid, ix, iy)
|
|
|
|
|
const apexDepth = Math.min(L / 2, Number.isFinite(oppDist) ? oppDist : L / 2)
|
|
|
|
|
const apex = { x: mid.x + ix * apexDepth, y: mid.y + iy * apexDepth }
|
|
|
|
|
// 마루 단축: 케라바변쪽 끝점 → apex.
|
|
|
|
|
if (midIsA) ridge.set({ x1: apex.x, y1: apex.y })
|
|
|
|
|
else ridge.set({ x2: apex.x, y2: apex.y })
|
|
|
|
|
// [KERAB-TYPE-EAVES-RIDGE-ZERO 2026-06-15] apex 가 반대편 roofLine 에 클램프되면 native 마루가
|
|
|
|
|
// 거기로 단축돼 zero-length 가 될 수 있다(R2 위반). 객체는 revert 위해 보존하되 invisible 처리
|
|
|
|
|
// (R2 체크는 visible 만 검사). 마루 형상 재구성은 완전 우진각 시 reconstruct 가 담당.
|
|
|
|
|
const ridgeLen = Math.hypot(ridge.x2 - ridge.x1, ridge.y2 - ridge.y1)
|
|
|
|
|
if (ridgeLen < 1) ridge.visible = false
|
|
|
|
|
const rsz = calcLinePlaneSize({ x1: ridge.x1, y1: ridge.y1, x2: ridge.x2, y2: ridge.y2 })
|
|
|
|
|
if (ridge.attributes) {
|
|
|
|
|
ridge.attributes.planeSize = rsz
|
|
|
|
|
@ -3748,6 +3783,11 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
// 힙 = apex → hit (단일 직선 45°).
|
|
|
|
|
if (apexIsE1) il.set({ x1: apex.x, y1: apex.y, x2: hit.x, y2: hit.y })
|
|
|
|
|
else il.set({ x1: hit.x, y1: hit.y, x2: apex.x, y2: apex.y })
|
|
|
|
|
// [KERAB-TYPE-EAVES-STARTPOINT-SYNC 2026-06-12] .set 은 'modified' 를 안 띄워 startPoint/endPoint
|
|
|
|
|
// 가 옛 좌표로 남는다. 할당 graph(splitPolygonWithLines)는 startPoint/endPoint 로 면을 닫으므로
|
|
|
|
|
// 동기화 누락 시 절삭된 힙이 옛 좌표로 연결돼 인접 roofLine 이 면에 포함되지 않아 미할당된다.
|
|
|
|
|
il.startPoint = { x: il.x1, y: il.y1 }
|
|
|
|
|
il.endPoint = { x: il.x2, y: il.y2 }
|
|
|
|
|
const sz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
|
|
|
|
|
if (il.attributes) {
|
|
|
|
|
il.attributes.planeSize = sz
|
|
|
|
|
@ -3839,6 +3879,10 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
const d2 = Math.hypot(h.il.x2 - cenX, h.il.y2 - cenY)
|
|
|
|
|
if (d1 >= d2) h.il.set({ x2: rp.x, y2: rp.y })
|
|
|
|
|
else h.il.set({ x1: rp.x, y1: rp.y })
|
|
|
|
|
// [KERAB-TYPE-EAVES-STARTPOINT-SYNC 2026-06-12] 절삭 후 startPoint/endPoint 동기화.
|
|
|
|
|
// 누락 시 할당 graph 가 옛 inner 끝점으로 면을 닫으려다 실패 → 인접 roofLine 미할당.
|
|
|
|
|
h.il.startPoint = { x: h.il.x1, y: h.il.y1 }
|
|
|
|
|
h.il.endPoint = { x: h.il.x2, y: h.il.y2 }
|
|
|
|
|
const sz = calcLinePlaneSize({ x1: h.il.x1, y1: h.il.y1, x2: h.il.x2, y2: h.il.y2 })
|
|
|
|
|
if (h.il.attributes) {
|
|
|
|
|
h.il.attributes.planeSize = sz
|
|
|
|
|
@ -3936,6 +3980,296 @@ export function useEavesGableEdit(id) {
|
|
|
|
|
if (lbl) lbl.set({ visible: !hide })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [KERAB-RECT-SOLVER 2026-06-15] 사각형(axis-aligned 4코너) 결정론적 케라바 솔버.
|
|
|
|
|
// 토글 이력과 무관하게 「최종 4변 타입」만으로 내부선(힙/마루/케라바 줄기)을 매번 처음부터
|
|
|
|
|
// 재생성한다 = 출폭/변타입의 순수 함수. forward/revert/type 미로를 통째로 우회.
|
|
|
|
|
// 구조적으로 보장되는 두 불변식:
|
|
|
|
|
// 불변식1(Y): 줄기(마루)는 박공으로 바뀐 변(slope 없음) 방향으로만 뻗는다. \|/, /|\ 불가.
|
|
|
|
|
// 불변식2: 힙은 apex(45° 교점)에서 멈춘다 → wallbase 교점 초과(폴리곤 밖) 힙 없음.
|
|
|
|
|
// 지원: 박공 0개(우진각)·1개(Y, apex 내부일 때)·2개 마주보기(맞배). 그 외(인접 2박공/3·4박공/
|
|
|
|
|
// 비사각형/회전사각형/radio2 타입)는 false 반환 → 기존 경로 폴백.
|
|
|
|
|
const solveRectangleKerab = (roof, target, newAttributes) => {
|
|
|
|
|
try {
|
|
|
|
|
if (!roof || !Array.isArray(roof.points) || roof.points.length !== 4) return false
|
|
|
|
|
if (!Array.isArray(roof.innerLines)) roof.innerLines = []
|
|
|
|
|
const outerLines = canvas.getObjects().filter((o) => o.name === 'outerLine' && o.attributes?.roofId === roof.id)
|
|
|
|
|
// [KERAB-RECT-PROBE 2026-06-15] outerLine 이 wallbase(출폭0) 좌표인지 roofLine 좌표인지
|
|
|
|
|
// 실제 데이터로 확정하기 위한 진입 덤프. solver 가 좌표 불일치로 false 폴백해도 이건 먼저 찍힘.
|
|
|
|
|
logger.log(
|
|
|
|
|
'[KERAB-RECT-PROBE] entry ' +
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
roofPoints: roof.points.map((p) => ({ x: Math.round(p.x * 10) / 10, y: Math.round(p.y * 10) / 10 })),
|
|
|
|
|
outerLineCount: outerLines.length,
|
|
|
|
|
outerLines: outerLines.map((o) => ({
|
|
|
|
|
type: o.attributes?.type,
|
|
|
|
|
offset: o.attributes?.offset,
|
|
|
|
|
x1: Math.round(o.x1 * 10) / 10,
|
|
|
|
|
y1: Math.round(o.y1 * 10) / 10,
|
|
|
|
|
x2: Math.round(o.x2 * 10) / 10,
|
|
|
|
|
y2: Math.round(o.y2 * 10) / 10,
|
|
|
|
|
})),
|
|
|
|
|
newType: newAttributes?.type,
|
|
|
|
|
newOffset: newAttributes?.offset,
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
if (outerLines.length !== 4) return false
|
|
|
|
|
const newType = newAttributes?.type
|
|
|
|
|
const TOL = 0.6
|
|
|
|
|
const xs = roof.points.map((p) => p.x)
|
|
|
|
|
const ys = roof.points.map((p) => p.y)
|
|
|
|
|
let minX = Math.min(...xs)
|
|
|
|
|
let maxX = Math.max(...xs)
|
|
|
|
|
let minY = Math.min(...ys)
|
|
|
|
|
let maxY = Math.max(...ys)
|
|
|
|
|
let W = maxX - minX
|
|
|
|
|
let H = maxY - minY
|
|
|
|
|
if (W < 2 || H < 2) return false
|
|
|
|
|
// 네 코너가 실제 직사각형 코너와 일치해야 함 (axis-aligned).
|
|
|
|
|
const corners = [
|
|
|
|
|
{ x: minX, y: minY },
|
|
|
|
|
{ x: maxX, y: minY },
|
|
|
|
|
{ x: maxX, y: maxY },
|
|
|
|
|
{ x: minX, y: maxY },
|
|
|
|
|
]
|
|
|
|
|
for (const c of corners) {
|
|
|
|
|
if (!roof.points.some((p) => Math.abs(p.x - c.x) < TOL && Math.abs(p.y - c.y) < TOL)) return false
|
|
|
|
|
}
|
|
|
|
|
// [KERAB-RECT-SIDE-FIX 2026-06-15] outerLine 은 roof.points(roofLine)에서 出幅만큼 안쪽으로
|
|
|
|
|
// 들어온 wallbase 좌표라, roof.points bbox 와 직접 비교하면 出幅(예 50)만큼 어긋나 TOL 초과 →
|
|
|
|
|
// sideOf 가 null → 솔버 전체 false 폴백(증상: 토글 시 KERAB-SIMPLE 미로로 빠져 그림 깨짐).
|
|
|
|
|
// 변 분류는 outerLine 자체 bbox 기준으로, 지오메트리(힙/마루)는 그대로 roof.points 기준으로 푼다.
|
|
|
|
|
const oxs = outerLines.flatMap((o) => [o.x1, o.x2])
|
|
|
|
|
const oys = outerLines.flatMap((o) => [o.y1, o.y2])
|
|
|
|
|
const oMinX = Math.min(...oxs)
|
|
|
|
|
const oMaxX = Math.max(...oxs)
|
|
|
|
|
const oMinY = Math.min(...oys)
|
|
|
|
|
const oMaxY = Math.max(...oys)
|
|
|
|
|
// 각 외곽선 → 변(top/bottom/left/right) 분류 + 유효 타입(EAVES/GABLE) 확정.
|
|
|
|
|
const sideOf = (o) => {
|
|
|
|
|
const horiz = Math.abs(o.y1 - o.y2) < TOL
|
|
|
|
|
const vert = Math.abs(o.x1 - o.x2) < TOL
|
|
|
|
|
if (horiz && Math.abs((o.y1 + o.y2) / 2 - oMinY) < TOL) return 'top'
|
|
|
|
|
if (horiz && Math.abs((o.y1 + o.y2) / 2 - oMaxY) < TOL) return 'bottom'
|
|
|
|
|
if (vert && Math.abs((o.x1 + o.x2) / 2 - oMinX) < TOL) return 'left'
|
|
|
|
|
if (vert && Math.abs((o.x1 + o.x2) / 2 - oMaxX) < TOL) return 'right'
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
const sides = {}
|
|
|
|
|
for (const o of outerLines) {
|
|
|
|
|
const s = sideOf(o)
|
|
|
|
|
if (!s || sides[s]) return false
|
|
|
|
|
const eff = o === target ? newType : o.attributes?.type
|
|
|
|
|
if (eff !== LINE_TYPE.WALLLINE.EAVES && eff !== LINE_TYPE.WALLLINE.GABLE) return false
|
|
|
|
|
sides[s] = { o, gable: eff === LINE_TYPE.WALLLINE.GABLE }
|
|
|
|
|
}
|
|
|
|
|
if (!sides.top || !sides.bottom || !sides.left || !sides.right) return false
|
|
|
|
|
|
|
|
|
|
const gTop = sides.top.gable
|
|
|
|
|
const gBottom = sides.bottom.gable
|
|
|
|
|
const gLeft = sides.left.gable
|
|
|
|
|
const gRight = sides.right.gable
|
|
|
|
|
const nGable = [gTop, gBottom, gLeft, gRight].filter(Boolean).length
|
|
|
|
|
|
|
|
|
|
// [KERAB-RECT-GATE 2026-06-15] 出幅을 경계에 반영하기 전에 지원 형상인지 먼저 판정한다.
|
|
|
|
|
// 미지원(인접 2박공/3·4박공/얕은 Y)인데 offset 을 먼저 옮기면 폴백된 미로가 offset 을 한 번 더
|
|
|
|
|
// 적용해 이중 出幅이 된다 → 반드시 무변형 상태에서 게이트하고, 미지원이면 boundary 손대지 않고 false.
|
|
|
|
|
{
|
|
|
|
|
let ok = false
|
|
|
|
|
if (nGable === 0) ok = true
|
|
|
|
|
// [KERAB-RECT-GATE-1 2026-06-15] 단일 박공은 항상 지원: depth<=span 이면 Y(apex)모델,
|
|
|
|
|
// long-edge(케라바, depth>span)면 CASE B(반대코너 45° 힙 2개 + 변 위 마루 1개). 둘 다 정의됨.
|
|
|
|
|
else if (nGable === 1) ok = true
|
|
|
|
|
else if (nGable === 2) {
|
|
|
|
|
ok = (gTop && gBottom) || (gLeft && gRight)
|
|
|
|
|
}
|
|
|
|
|
if (!ok) return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [KERAB-RECT-OFFSET 2026-06-15] 지원 형상 확정 후, 토글되는 변의 出幅 변경분을 경계(roofLine)에
|
|
|
|
|
// 먼저 반영한다(inner line 은 안 건드림). 이후 갱신된 roof.points 로 내부 지오메트리를 푼다.
|
|
|
|
|
// 出幅 입력이 기존과 같으면 delta 0 → 무동작. 다르면 maze 의 offset-surgical 과 동일 경로.
|
|
|
|
|
if (newAttributes && newAttributes.offset != null) {
|
|
|
|
|
applyTargetOffsetSurgical(target, newAttributes.offset, { skipInnerLines: true })
|
|
|
|
|
const nxs = roof.points.map((p) => p.x)
|
|
|
|
|
const nys = roof.points.map((p) => p.y)
|
|
|
|
|
minX = Math.min(...nxs)
|
|
|
|
|
maxX = Math.max(...nxs)
|
|
|
|
|
minY = Math.min(...nys)
|
|
|
|
|
maxY = Math.max(...nys)
|
|
|
|
|
W = maxX - minX
|
|
|
|
|
H = maxY - minY
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cx = (minX + maxX) / 2
|
|
|
|
|
const cy = (minY + maxY) / 2
|
|
|
|
|
|
|
|
|
|
const out = []
|
|
|
|
|
const addRidge = (x1, y1, x2, y2) => {
|
|
|
|
|
if (Math.hypot(x2 - x1, y2 - y1) >= 1) out.push({ x1, y1, x2, y2, kind: 'ridge' })
|
|
|
|
|
}
|
|
|
|
|
const addHip = (x1, y1, x2, y2) => {
|
|
|
|
|
if (Math.hypot(x2 - x1, y2 - y1) >= 1) out.push({ x1, y1, x2, y2, kind: 'hip' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (nGable === 0) {
|
|
|
|
|
// 우진각: 마루는 긴 축, apex 는 짧은 변/2 안쪽. 힙 4개.
|
|
|
|
|
if (W >= H) {
|
|
|
|
|
const inset = H / 2
|
|
|
|
|
const lA = { x: minX + inset, y: cy }
|
|
|
|
|
const rA = { x: maxX - inset, y: cy }
|
|
|
|
|
addRidge(lA.x, lA.y, rA.x, rA.y)
|
|
|
|
|
addHip(minX, minY, lA.x, lA.y)
|
|
|
|
|
addHip(minX, maxY, lA.x, lA.y)
|
|
|
|
|
addHip(maxX, minY, rA.x, rA.y)
|
|
|
|
|
addHip(maxX, maxY, rA.x, rA.y)
|
|
|
|
|
} else {
|
|
|
|
|
const inset = W / 2
|
|
|
|
|
const tA = { x: cx, y: minY + inset }
|
|
|
|
|
const bA = { x: cx, y: maxY - inset }
|
|
|
|
|
addRidge(tA.x, tA.y, bA.x, bA.y)
|
|
|
|
|
addHip(minX, minY, tA.x, tA.y)
|
|
|
|
|
addHip(maxX, minY, tA.x, tA.y)
|
|
|
|
|
addHip(minX, maxY, bA.x, bA.y)
|
|
|
|
|
addHip(maxX, maxY, bA.x, bA.y)
|
|
|
|
|
}
|
|
|
|
|
} else if (nGable === 1) {
|
|
|
|
|
// 박공변이 짧은/중간 변(맞은편 apex 가 폴리곤 안)이면 Y(apex+마루).
|
|
|
|
|
// 긴 변(케라바=마루와 평행, W>2H or H>2W)이면 맞은편 두 코너에서 45° 힙이 박공변까지
|
|
|
|
|
// 직진하고 박공변 위에 마루가 놓인다(CASE B). 어느 쪽이든 단일 박공은 항상 유효.
|
|
|
|
|
if (gBottom || gTop) {
|
|
|
|
|
const depth = W / 2 // apex 깊이 = 박공변(가로) 길이/2
|
|
|
|
|
if (depth <= H - 0.5) {
|
|
|
|
|
// Y: apex 가 맞은편 변 안쪽
|
|
|
|
|
if (gBottom) {
|
|
|
|
|
const apex = { x: cx, y: minY + depth }
|
|
|
|
|
addHip(minX, minY, apex.x, apex.y)
|
|
|
|
|
addHip(maxX, minY, apex.x, apex.y)
|
|
|
|
|
addRidge(apex.x, apex.y, cx, maxY)
|
|
|
|
|
} else {
|
|
|
|
|
const apex = { x: cx, y: maxY - depth }
|
|
|
|
|
addHip(minX, maxY, apex.x, apex.y)
|
|
|
|
|
addHip(maxX, maxY, apex.x, apex.y)
|
|
|
|
|
addRidge(apex.x, apex.y, cx, minY)
|
|
|
|
|
}
|
|
|
|
|
} else if (gBottom) {
|
|
|
|
|
// 케라바(긴 가로 변): 위쪽 두 코너 → 박공변(아래)까지 45° 직진 + 박공변 위 마루
|
|
|
|
|
addHip(minX, minY, minX + H, maxY)
|
|
|
|
|
addHip(maxX, minY, maxX - H, maxY)
|
|
|
|
|
addRidge(minX + H, maxY, maxX - H, maxY)
|
|
|
|
|
} else {
|
|
|
|
|
addHip(minX, maxY, minX + H, minY)
|
|
|
|
|
addHip(maxX, maxY, maxX - H, minY)
|
|
|
|
|
addRidge(minX + H, minY, maxX - H, minY)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const depth = H / 2
|
|
|
|
|
if (depth <= W - 0.5) {
|
|
|
|
|
if (gLeft) {
|
|
|
|
|
const apex = { x: maxX - depth, y: cy }
|
|
|
|
|
addHip(maxX, minY, apex.x, apex.y)
|
|
|
|
|
addHip(maxX, maxY, apex.x, apex.y)
|
|
|
|
|
addRidge(apex.x, apex.y, minX, cy)
|
|
|
|
|
} else {
|
|
|
|
|
const apex = { x: minX + depth, y: cy }
|
|
|
|
|
addHip(minX, minY, apex.x, apex.y)
|
|
|
|
|
addHip(minX, maxY, apex.x, apex.y)
|
|
|
|
|
addRidge(apex.x, apex.y, maxX, cy)
|
|
|
|
|
}
|
|
|
|
|
} else if (gLeft) {
|
|
|
|
|
// 케라바(긴 세로 변): 오른쪽 두 코너 → 박공변(왼쪽)까지 45° 직진 + 박공변 위 마루
|
|
|
|
|
addHip(maxX, minY, minX, minY + W)
|
|
|
|
|
addHip(maxX, maxY, minX, maxY - W)
|
|
|
|
|
addRidge(minX, minY + W, minX, maxY - W)
|
|
|
|
|
} else {
|
|
|
|
|
addHip(minX, minY, maxX, minY + W)
|
|
|
|
|
addHip(minX, maxY, maxX, maxY - W)
|
|
|
|
|
addRidge(maxX, minY + W, maxX, maxY - W)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if (nGable === 2) {
|
|
|
|
|
// 맞배: 마주보는 두 박공 사이를 잇는 단일 마루, 힙 없음.
|
|
|
|
|
if (gTop && gBottom) {
|
|
|
|
|
addRidge(cx, minY, cx, maxY)
|
|
|
|
|
} else if (gLeft && gRight) {
|
|
|
|
|
addRidge(minX, cy, maxX, cy)
|
|
|
|
|
} else {
|
|
|
|
|
return false // 인접 2박공 = 굽은 마루, 미지원 → 폴백
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
return false // 3·4 박공 → 폴백
|
|
|
|
|
}
|
|
|
|
|
if (!out.length) return false
|
|
|
|
|
|
|
|
|
|
const beforeSnap = snapshotKerabState(roof)
|
|
|
|
|
// 기존 내부 HIP/RIDGE(네이티브 + kerabPattern) 전부 제거 후 재생성.
|
|
|
|
|
for (const il of [...roof.innerLines]) {
|
|
|
|
|
if (!il) continue
|
|
|
|
|
if (il.name === LINE_TYPE.SUBLINE.HIP || il.name === LINE_TYPE.SUBLINE.RIDGE) {
|
|
|
|
|
canvas.remove(il)
|
|
|
|
|
const idx = roof.innerLines.indexOf(il)
|
|
|
|
|
if (idx >= 0) roof.innerLines.splice(idx, 1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const o of outerLines) removeKerabHalfLabels(o.id)
|
|
|
|
|
for (const ln of out) {
|
|
|
|
|
const sz = calcLinePlaneSize({ x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 })
|
|
|
|
|
const isRidge = ln.kind === 'ridge'
|
|
|
|
|
const q = new QLine([ln.x1, ln.y1, ln.x2, ln.y2], {
|
|
|
|
|
parentId: roof.id,
|
|
|
|
|
fontSize: roof.fontSize,
|
|
|
|
|
stroke: '#1083E3',
|
|
|
|
|
strokeWidth: isRidge ? 4 : 3,
|
|
|
|
|
name: isRidge ? LINE_TYPE.SUBLINE.RIDGE : LINE_TYPE.SUBLINE.HIP,
|
|
|
|
|
textMode: roof.textMode,
|
|
|
|
|
attributes: {
|
|
|
|
|
roofId: roof.id,
|
|
|
|
|
type: isRidge ? LINE_TYPE.SUBLINE.RIDGE : LINE_TYPE.SUBLINE.HIP,
|
|
|
|
|
planeSize: sz,
|
|
|
|
|
actualSize: sz,
|
|
|
|
|
// [KERAB-RECT-EXTENDED 2026-06-15] 솔버 hip 은 roofLine 코너(=출폭 끝점)까지 이미 닿아 있다.
|
|
|
|
|
// allocation 의 integrateExtensionLines 가 ext+hip 머지로 hip 을 wall 코너까지 단축시키면
|
|
|
|
|
// split graph 가 polygon 코너와 끊겨 1면만 잡힌다(native hip 은 extended 플래그로 머지 스킵).
|
|
|
|
|
// 동일하게 extended=true 부여 → INTEGRATE 가 merge 스킵, ext 는 lines 에서만 제거.
|
|
|
|
|
...(isRidge ? {} : { extended: true }),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
q.lineName = ln.kind
|
|
|
|
|
if (!isRidge) q.__extended = true
|
|
|
|
|
// 할당 graph(splitPolygonWithLines)는 startPoint/endPoint 로 면을 닫으므로 동기화 필수.
|
|
|
|
|
q.startPoint = { x: ln.x1, y: ln.y1 }
|
|
|
|
|
q.endPoint = { x: ln.x2, y: ln.y2 }
|
|
|
|
|
canvas.add(q)
|
|
|
|
|
q.bringToFront()
|
|
|
|
|
roof.innerLines.push(q)
|
|
|
|
|
if (typeof q.setCoords === 'function') q.setCoords()
|
|
|
|
|
if (typeof q.setLength === 'function') q.setLength()
|
|
|
|
|
if (typeof q.addLengthText === 'function') q.addLengthText()
|
|
|
|
|
}
|
|
|
|
|
// 박공 변엔 절반 라벨 + 원본 길이 숨김, 처마 변엔 원본 길이 노출.
|
|
|
|
|
for (const key of ['top', 'bottom', 'left', 'right']) {
|
|
|
|
|
const s = sides[key]
|
|
|
|
|
if (s.gable) {
|
|
|
|
|
addKerabHalfLabels(s.o, { x: s.o.x1, y: s.o.y1 }, { x: s.o.x2, y: s.o.y2 })
|
|
|
|
|
hideOriginalLengthText(s.o.id, true)
|
|
|
|
|
} else {
|
|
|
|
|
hideOriginalLengthText(s.o.id, false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
target.set({ attributes: newAttributes })
|
|
|
|
|
canvas.renderAll()
|
|
|
|
|
const phase = newType === LINE_TYPE.WALLLINE.GABLE ? 'forward' : 'revert'
|
|
|
|
|
runKerabRuleCheck(roof, phase, beforeSnap)
|
|
|
|
|
try {
|
|
|
|
|
reattachDebugLabels(canvas, roof.id)
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
logger.log('[KERAB-RECT-SOLVER] built ' + JSON.stringify({ nGable, lines: out.length, W: Math.round(W), H: Math.round(H) }))
|
|
|
|
|
return true
|
|
|
|
|
} catch (err) {
|
|
|
|
|
logger.warn('[KERAB-RECT-SOLVER] error → fallback', err)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// [KERAB-PATTERN-EXT-CLEAN 2026-05-19] forward 시 제거되는 hip 과 짝이었던 orphan
|
|
|
|
|
// extensionLine 정리. 한 끝점이 hip 의 한 끝점과 같고 다른 끝점이 hip 의 진행
|
|
|
|
|
// 방향과 동일선상에 있으면 짝으로 판정. 잔류 시 allocation 의 integrateExtensionLines
|
|
|
|
|
|