dev #868
@ -19,9 +19,9 @@ export default function PageTracker() {
|
||||
'background:#1e88e5;color:#fff;padding:2px 8px;border-radius:3px;font-weight:bold',
|
||||
)
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.title = `${pathname} · HANASYS DESIGN`
|
||||
}
|
||||
// if (typeof document !== 'undefined') {
|
||||
// document.title = `${pathname} · HANASYS DESIGN`
|
||||
// }
|
||||
}, [pathname])
|
||||
|
||||
return null
|
||||
|
||||
@ -11,6 +11,10 @@ import { useSwal } from '@/hooks/useSwal'
|
||||
import { usePopup } from '@/hooks/usePopup'
|
||||
import { getChonByDegree } from '@/util/canvas-util'
|
||||
import { settingModalFirstOptionsState } from '@/store/settingAtom'
|
||||
import { fabric } from 'fabric'
|
||||
import { QLine } from '@/components/fabric/QLine'
|
||||
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
|
||||
import { logger } from '@/util/logger'
|
||||
|
||||
// 처마.케라바 변경
|
||||
export function useEavesGableEdit(id) {
|
||||
@ -141,6 +145,7 @@ export function useEavesGableEdit(id) {
|
||||
}
|
||||
|
||||
const mouseDownEvent = (e) => {
|
||||
logger.log('[KERAB-MOUSEDOWN] fired', { hasTarget: !!e.target, name: e.target?.name, type: typeRef.current, radio: radioTypeRef.current })
|
||||
canvas.discardActiveObject()
|
||||
if (!e.target || (e.target && e.target.name !== 'outerLine')) {
|
||||
return
|
||||
@ -210,6 +215,107 @@ export function useEavesGableEdit(id) {
|
||||
break
|
||||
}
|
||||
|
||||
// [2240 KERAB-NOOP-REKLICK 2026-05-19] 같은 type 으로의 재클릭은 무동작.
|
||||
// - 케라바→케라바, 처마→처마 등. 기존 rebuild 흐름이 다시 돌면 패턴 상태
|
||||
// (ridge/half-label/orphan ext 정리)를 망가뜨림.
|
||||
// - radio 1 의 단순 변환에만 적용. JERKINHEAD/HIPANDGABLE 등 width 가 들어가는
|
||||
// radio 2 변환은 파라미터 갱신 가능성이 있어 그대로 진행.
|
||||
if (radioTypeRef.current === '1' && target.attributes?.type === attributes?.type) {
|
||||
logger.log(`[KERAB-NOOP] 이미 ${attributes.type} → 재변환 무시`)
|
||||
return
|
||||
}
|
||||
|
||||
// [2240 KERAB-SINGLE-RIDGE 2026-05-19] 대전제 1: 처마→케라바 변환 시 양 끝점에 동일 apex 의 hip 페어가 존재하면
|
||||
// hip 2개 제거 + 처마 중점→apex ridge 1개 생성으로 처리하고 기존 rebuild 흐름은 건너뜀.
|
||||
// 그 외 모든 케이스(hip 0/1개, 평행, 다중 변환 후 hip 소진 등)는 attribute-only 로 처리하고 rebuild 진입 차단.
|
||||
if (typeRef.current === TYPES.GABLE && radioTypeRef.current === '1') {
|
||||
const roof = canvas
|
||||
.getObjects()
|
||||
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
|
||||
logger.log('[KERAB-GUARD] roof check', {
|
||||
roofId: target.attributes?.roofId,
|
||||
roofFound: !!roof,
|
||||
innerLinesArray: Array.isArray(roof?.innerLines),
|
||||
pointsArray: Array.isArray(roof?.points),
|
||||
innerLinesLen: roof?.innerLines?.length,
|
||||
pointsLen: roof?.points?.length,
|
||||
})
|
||||
if (roof && Array.isArray(roof.innerLines) && Array.isArray(roof.points)) {
|
||||
// outerLine 끝점(벽 좌표)을 가장 가까운 roof 폴리곤 코너(offset 적용 좌표)로 스냅
|
||||
const c1 = nearestRoofPoint(roof, { x: target.x1, y: target.y1 })
|
||||
const c2 = nearestRoofPoint(roof, { x: target.x2, y: target.y2 })
|
||||
logger.log('[KERAB-GUARD] c1/c2', { targetXY: { x1: target.x1, y1: target.y1, x2: target.x2, y2: target.y2 }, c1, c2 })
|
||||
if (c1 && c2) {
|
||||
const hipsAtC1 = findHipsAtPoint(roof, c1)
|
||||
const hipsAtC2 = findHipsAtPoint(roof, c2)
|
||||
logger.log('[KERAB-GUARD] hips at corners', { c1n: hipsAtC1.length, c2n: hipsAtC2.length })
|
||||
const pair =
|
||||
hipsAtC1.length > 0 && hipsAtC2.length > 0 ? findHipPairWithSharedApex(hipsAtC1, c1, hipsAtC2, c2) : null
|
||||
logger.log('[KERAB-GUARD] shared-apex pair', { found: !!pair })
|
||||
if (pair) {
|
||||
target.set({ attributes })
|
||||
const ok = applyKerabSingleRidgePattern(roof, target, c1, c2, pair)
|
||||
logger.log('[KERAB-PATTERN] applied', { ok, c1, c2, apex: pair.apex })
|
||||
if (ok) return
|
||||
}
|
||||
// [2240 KERAB-JUNCTION-EXTENDED 2026-05-19] shared-apex 가 없으면 H-7/H-1 의 junction 너머
|
||||
// H-4/H-2(inner hip) 를 무한확장한 교점을 apex 로. 채택 시 outer(H-7,H-1) 제거 +
|
||||
// inner(H-4,H-2) 끝을 apex 로 늘림 + mid→apex ridge. H-7/H-1 직접 확장보다 안쪽까지 중앙선이 연장됨.
|
||||
const jp = findHipPairViaJunction(roof, c1, c2)
|
||||
logger.log('[KERAB-GUARD] junction-extended pair', { found: !!jp, apex: jp?.apex })
|
||||
if (jp) {
|
||||
target.set({ attributes })
|
||||
const ok = applyKerabJunctionExtendedPattern(roof, target, c1, c2, jp)
|
||||
logger.log('[KERAB-PATTERN] applied (junction-extended)', { ok })
|
||||
if (ok) return
|
||||
}
|
||||
// [2240 KERAB-EXTENDED-APEX 2026-05-19] junction-extended 도 안 되면 H-7/H-1 직접 무한확장한 교점을 apex 로.
|
||||
// apex 가 처마 중점 수선 위(가운데) 일 때만 채택. 채택 시 단일-ridge 패턴과 동일 처리.
|
||||
const pairExt =
|
||||
hipsAtC1.length > 0 && hipsAtC2.length > 0 ? findHipPairWithExtendedApex(hipsAtC1, c1, hipsAtC2, c2) : null
|
||||
logger.log('[KERAB-GUARD] extended-apex pair', { found: !!pairExt, apex: pairExt?.apex })
|
||||
if (pairExt) {
|
||||
target.set({ attributes })
|
||||
const ok = applyKerabSingleRidgePattern(roof, target, c1, c2, pairExt)
|
||||
logger.log('[KERAB-PATTERN] applied (extended)', { ok })
|
||||
if (ok) return
|
||||
}
|
||||
// [2240 KERAB-ATTR-ONLY 2026-05-19] hip 페어 없음(0/1개·평행·다중변환 소진·apex 가 중앙 아님 등) → 속성만 GABLE 로 변경
|
||||
target.set({ attributes })
|
||||
applyKerabAttributeOnlyPattern()
|
||||
logger.log('[KERAB-ATTR-ONLY] applied (forward)', { c1, c2, hipsC1: hipsAtC1.length, hipsC2: hipsAtC2.length })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [2240 KERAB-REVERT 2026-05-19] 대전제 1 역방향: 케라바→처마 변환 시 케라바 중점→apex ridge 가 존재하면
|
||||
// ridge 1개 제거 + apex→c1, apex→c2 hip 2개 생성으로 처리하고 기존 rebuild 흐름은 건너뜀.
|
||||
// ridge-at-mid 없으면 forward 가 attribute-only 였던 케이스 → 속성만 EAVES 로 토글.
|
||||
if (typeRef.current === TYPES.EAVES && radioTypeRef.current === '1' && target.attributes?.type === LINE_TYPE.WALLLINE.GABLE) {
|
||||
const roof = canvas
|
||||
.getObjects()
|
||||
.find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId)
|
||||
if (roof && Array.isArray(roof.innerLines) && Array.isArray(roof.points)) {
|
||||
const c1 = nearestRoofPoint(roof, { x: target.x1, y: target.y1 })
|
||||
const c2 = nearestRoofPoint(roof, { x: target.x2, y: target.y2 })
|
||||
if (c1 && c2) {
|
||||
const ridgeAtMid = findRidgeAtMidpoint(roof, c1, c2)
|
||||
if (ridgeAtMid) {
|
||||
target.set({ attributes })
|
||||
const ok = applyKerabRevertPattern(roof, target, c1, c2, ridgeAtMid)
|
||||
logger.log('[KERAB-REVERT] applied', { ok, c1, c2, apex: ridgeAtMid.apex })
|
||||
if (ok) return
|
||||
}
|
||||
// [2240 KERAB-ATTR-ONLY 2026-05-19] ridge-at-mid 없음 → forward 가 attribute-only 였음 → 동일 대칭으로 속성만 토글
|
||||
target.set({ attributes })
|
||||
applyKerabAttributeOnlyPattern()
|
||||
logger.log('[KERAB-ATTR-ONLY] applied (revert)', { c1, c2 })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
target.set({
|
||||
attributes,
|
||||
})
|
||||
@ -321,5 +427,541 @@ export function useEavesGableEdit(id) {
|
||||
canvas?.renderAll()
|
||||
}
|
||||
|
||||
// [2240 KERAB-SINGLE-RIDGE 2026-05-19] 헬퍼 — 양 끝점 hip 페어 식별 및 ridge 단일화 적용
|
||||
const KERAB_TOL = 0.5
|
||||
const isSamePt = (a, b) => Math.abs(a.x - b.x) < KERAB_TOL && Math.abs(a.y - b.y) < KERAB_TOL
|
||||
|
||||
// outerLine 끝점(벽 좌표)을 가장 가까운 roof.points 코너(offset 적용 좌표)로 스냅
|
||||
const nearestRoofPoint = (roof, point) => {
|
||||
if (!Array.isArray(roof.points) || roof.points.length === 0) return null
|
||||
let best = roof.points[0]
|
||||
let bestD = (best.x - point.x) ** 2 + (best.y - point.y) ** 2
|
||||
for (let i = 1; i < roof.points.length; i++) {
|
||||
const p = roof.points[i]
|
||||
const d = (p.x - point.x) ** 2 + (p.y - point.y) ** 2
|
||||
if (d < bestD) {
|
||||
bestD = d
|
||||
best = p
|
||||
}
|
||||
}
|
||||
return { x: best.x, y: best.y }
|
||||
}
|
||||
|
||||
const findHipsAtPoint = (roof, point) => {
|
||||
return roof.innerLines.filter((il) => {
|
||||
if (!il || il.name !== LINE_TYPE.SUBLINE.HIP) return false
|
||||
return isSamePt({ x: il.x1, y: il.y1 }, point) || isSamePt({ x: il.x2, y: il.y2 }, point)
|
||||
})
|
||||
}
|
||||
|
||||
const otherEndOfHip = (hip, corner) => {
|
||||
const a = { x: hip.x1, y: hip.y1 }
|
||||
const b = { x: hip.x2, y: hip.y2 }
|
||||
return isSamePt(a, corner) ? b : a
|
||||
}
|
||||
|
||||
const findHipPairWithSharedApex = (hipsAtP1, p1, hipsAtP2, p2) => {
|
||||
for (const hA of hipsAtP1) {
|
||||
const apexA = otherEndOfHip(hA, p1)
|
||||
for (const hB of hipsAtP2) {
|
||||
if (hB === hA) continue
|
||||
const apexB = otherEndOfHip(hB, p2)
|
||||
if (isSamePt(apexA, apexB)) return { hA, hB, apex: apexA }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// [2240 KERAB-EXTENDED-APEX 2026-05-19] B-5 끝점 hip(H-7, H-1) 을 무한 확장한 교점이
|
||||
// 처마 중점 수선 위에 있으면 apex 채택. 채택 시 단일-ridge 패턴과 동일하게 hip 2개 제거 + mid→apex ridge.
|
||||
const findHipPairWithExtendedApex = (hipsAtP1, p1, hipsAtP2, p2) => {
|
||||
const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }
|
||||
const eVx = p2.x - p1.x
|
||||
const eVy = p2.y - p1.y
|
||||
const eMag = Math.hypot(eVx, eVy) || 1
|
||||
for (const hA of hipsAtP1) {
|
||||
const oA = otherEndOfHip(hA, p1)
|
||||
// hA 의 진행방향: p1 → oA 의 반대로(oA 너머 확장) 이 아닌, hip 자체를 무한 직선으로 본 교점 계산.
|
||||
// 직선 파라미터: p1 + t*(oA - p1). t>1 이면 oA 너머(폴리곤 내부 방향).
|
||||
const dAx = oA.x - p1.x
|
||||
const dAy = oA.y - p1.y
|
||||
for (const hB of hipsAtP2) {
|
||||
if (hB === hA) continue
|
||||
const oB = otherEndOfHip(hB, p2)
|
||||
const dBx = oB.x - p2.x
|
||||
const dBy = oB.y - p2.y
|
||||
const denom = dAx * dBy - dAy * dBx
|
||||
if (Math.abs(denom) < 1e-6) continue // 평행 → 영원히 못 만남
|
||||
const px = p2.x - p1.x
|
||||
const py = p2.y - p1.y
|
||||
const t = (px * dBy - py * dBx) / denom
|
||||
const s = (px * dAy - py * dAx) / denom
|
||||
// hip 의 진행방향(끝점 너머) 으로 만나야 함. t,s > 1 (oA, oB 너머 확장)
|
||||
if (t <= 1 + 1e-3 || s <= 1 + 1e-3) continue
|
||||
const apex = { x: p1.x + t * dAx, y: p1.y + t * dAy }
|
||||
// 가운데 검증: (apex - mid) 이 처마 방향과 수직(내적 ≈ 0)
|
||||
const dotEaves = ((apex.x - mid.x) * eVx + (apex.y - mid.y) * eVy) / eMag
|
||||
if (Math.abs(dotEaves) > KERAB_TOL) continue
|
||||
return { hA, hB, apex }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// [2240 KERAB-JUNCTION-EXTENDED 2026-05-19] H-7/H-1 의 junction 너머 inner hip(H-4/H-2) 을 무한직선으로
|
||||
// 확장한 교점이 처마 중점 수선 위에 있으면 apex 채택. H-7/H-1 직접 확장이 가운데가 아닌
|
||||
// L-노치 등 복합 형상에서 사용. apex 검증 후 outer hip 제거 + inner hip 확장 + ridge.
|
||||
const findHipPairViaJunction = (roof, c1, c2) => {
|
||||
const hipsAtC1 = findHipsAtPoint(roof, c1)
|
||||
const hipsAtC2 = findHipsAtPoint(roof, c2)
|
||||
logger.log('[JUNCTION-DIAG] c1/c2 hips', { c1, c2, c1n: hipsAtC1.length, c2n: hipsAtC2.length })
|
||||
if (hipsAtC1.length === 0 || hipsAtC2.length === 0) return null
|
||||
const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 }
|
||||
const eVx = c2.x - c1.x
|
||||
const eVy = c2.y - c1.y
|
||||
const eMag = Math.hypot(eVx, eVy) || 1
|
||||
for (const outer1 of hipsAtC1) {
|
||||
const junction1 = otherEndOfHip(outer1, c1)
|
||||
const innersAtJ1 = findHipsAtPoint(roof, junction1).filter((h) => h !== outer1)
|
||||
logger.log('[JUNCTION-DIAG] j1', { junction1, innersAtJ1n: innersAtJ1.length })
|
||||
if (innersAtJ1.length === 0) continue
|
||||
for (const outer2 of hipsAtC2) {
|
||||
if (outer2 === outer1) continue
|
||||
const junction2 = otherEndOfHip(outer2, c2)
|
||||
if (isSamePt(junction1, junction2)) {
|
||||
logger.log('[JUNCTION-DIAG] junction shared → skip (shared-apex case)')
|
||||
continue
|
||||
}
|
||||
const innersAtJ2 = findHipsAtPoint(roof, junction2).filter((h) => h !== outer2)
|
||||
logger.log('[JUNCTION-DIAG] j2', { junction2, innersAtJ2n: innersAtJ2.length })
|
||||
if (innersAtJ2.length === 0) continue
|
||||
for (const inner1 of innersAtJ1) {
|
||||
const innerFar1 = otherEndOfHip(inner1, junction1)
|
||||
const dAx = innerFar1.x - junction1.x
|
||||
const dAy = innerFar1.y - junction1.y
|
||||
for (const inner2 of innersAtJ2) {
|
||||
if (inner2 === inner1) continue
|
||||
const innerFar2 = otherEndOfHip(inner2, junction2)
|
||||
const dBx = innerFar2.x - junction2.x
|
||||
const dBy = innerFar2.y - junction2.y
|
||||
const denom = dAx * dBy - dAy * dBx
|
||||
if (Math.abs(denom) < 1e-6) {
|
||||
logger.log('[JUNCTION-DIAG] parallel inner hips')
|
||||
continue
|
||||
}
|
||||
const px = junction2.x - junction1.x
|
||||
const py = junction2.y - junction1.y
|
||||
const t = (px * dBy - py * dBx) / denom
|
||||
const apex = { x: junction1.x + t * dAx, y: junction1.y + t * dAy }
|
||||
const dotEaves = ((apex.x - mid.x) * eVx + (apex.y - mid.y) * eVy) / eMag
|
||||
logger.log('[JUNCTION-DIAG] candidate', { apex, dotEaves, threshold: KERAB_TOL })
|
||||
if (Math.abs(dotEaves) > KERAB_TOL) continue
|
||||
logger.log('[JUNCTION-DIAG] MATCH')
|
||||
return { outer1, outer2, inner1, inner2, junction1, junction2, apex, innerFar1, innerFar2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 재사용 가능한 검증 헬퍼 — ridge 의 한 끝점이 주어진 line 의 중점과 만나는지
|
||||
const midpointOfLine = (line) => ({ x: (line.x1 + line.x2) / 2, y: (line.y1 + line.y2) / 2 })
|
||||
const ridgeMeetsMidpointOf = (ridge, line) => {
|
||||
const mid = midpointOfLine(line)
|
||||
return isSamePt({ x: ridge.x1, y: ridge.y1 }, mid) || isSamePt({ x: ridge.x2, y: ridge.y2 }, mid)
|
||||
}
|
||||
|
||||
// [2240 KERAB-CENTRAL-RIDGE 2026-05-19] 특정 좌표 점에서 끝나는 ridge 1개 찾기.
|
||||
// 케라바 패턴 ridge(kerabPatternRidge) 는 제외.
|
||||
const findRidgeEndingAt = (roof, point) => {
|
||||
for (const il of roof.innerLines) {
|
||||
if (!il || il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
|
||||
if (il.lineName === 'kerabPatternRidge') continue
|
||||
if (isSamePt({ x: il.x1, y: il.y1 }, point)) return il
|
||||
if (isSamePt({ x: il.x2, y: il.y2 }, point)) return il
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// [2240 KERAB-CENTRAL-RIDGE 2026-05-19] 점이 선분 위에 있는지 (tol 안 거리).
|
||||
const isPointOnSegment = (p, seg, tol = KERAB_TOL) => {
|
||||
const dx = seg.x2 - seg.x1
|
||||
const dy = seg.y2 - seg.y1
|
||||
const len2 = dx * dx + dy * dy
|
||||
if (len2 < 1e-9) return Math.hypot(p.x - seg.x1, p.y - seg.y1) <= tol
|
||||
let t = ((p.x - seg.x1) * dx + (p.y - seg.y1) * dy) / len2
|
||||
t = Math.max(0, Math.min(1, t))
|
||||
const px = seg.x1 + t * dx
|
||||
const py = seg.y1 + t * dy
|
||||
return Math.hypot(p.x - px, p.y - py) <= tol
|
||||
}
|
||||
|
||||
// [2240 KERAB-CENTRAL-RIDGE 2026-05-19] ridge 의 양 끝점이 ext1·ext2 각각의 segment 위에 있는지.
|
||||
// RG-1 은 H-4/H-2 확장선 사이에 놓인 ridge — 두 확장선이 RG-1 의 양 끝점을 각각 통과한다.
|
||||
const ridgeEndpointsOnBothExtensions = (ridge, ext1, ext2) => {
|
||||
const a = { x: ridge.x1, y: ridge.y1 }
|
||||
const b = { x: ridge.x2, y: ridge.y2 }
|
||||
const seg1 = { x1: ext1.x1, y1: ext1.y1, x2: ext1.x2, y2: ext1.y2 }
|
||||
const seg2 = { x1: ext2.x1, y1: ext2.y1, x2: ext2.x2, y2: ext2.y2 }
|
||||
const aOn1 = isPointOnSegment(a, seg1)
|
||||
const aOn2 = isPointOnSegment(a, seg2)
|
||||
const bOn1 = isPointOnSegment(b, seg1)
|
||||
const bOn2 = isPointOnSegment(b, seg2)
|
||||
return (aOn1 && bOn2) || (aOn2 && bOn1)
|
||||
}
|
||||
|
||||
// 케라바 중점→apex ridge 1개 찾기 (역방향 패턴의 전제)
|
||||
const findRidgeAtMidpoint = (roof, c1, c2) => {
|
||||
const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 }
|
||||
for (const il of roof.innerLines) {
|
||||
if (!il || il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
|
||||
const a = { x: il.x1, y: il.y1 }
|
||||
const b = { x: il.x2, y: il.y2 }
|
||||
if (isSamePt(a, mid)) return { ridge: il, apex: b }
|
||||
if (isSamePt(b, mid)) return { ridge: il, apex: a }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 케라바→처마 역변환 패턴: ridge 1개 제거 + hip 복원
|
||||
// - junctionExtended: inner hip 좌표 원복 + outer hip 2개 신규 복원 (snapshot 기반)
|
||||
// - 일반 single-ridge (__originalHips): outer hip 2개를 스냅샷 좌표 그대로 복원
|
||||
// - 둘 다 없으면 폴백: c1↔apex, c2↔apex 신규 hip (이전 패턴 호환)
|
||||
const applyKerabRevertPattern = (roof, target, c1, c2, ridgeAtMid) => {
|
||||
const apex = ridgeAtMid.apex
|
||||
const buildHipFromSnapshot = (snap) => {
|
||||
const pts = [snap.x1, snap.y1, snap.x2, snap.y2]
|
||||
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
|
||||
const hip = new QLine(pts, {
|
||||
parentId: roof.id,
|
||||
fontSize: roof.fontSize,
|
||||
stroke: '#1083E3',
|
||||
strokeWidth: 4,
|
||||
name: LINE_TYPE.SUBLINE.HIP,
|
||||
textMode: roof.textMode,
|
||||
attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...snap.attributes },
|
||||
})
|
||||
if (snap.lineName) hip.lineName = snap.lineName
|
||||
if (snap.__extended) hip.__extended = snap.__extended
|
||||
return hip
|
||||
}
|
||||
const buildHipToApex = (cornerPt) => {
|
||||
const pts = [cornerPt.x, cornerPt.y, apex.x, apex.y]
|
||||
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
|
||||
const hip = new QLine(pts, {
|
||||
parentId: roof.id,
|
||||
fontSize: roof.fontSize,
|
||||
stroke: '#1083E3',
|
||||
strokeWidth: 4,
|
||||
name: LINE_TYPE.SUBLINE.HIP,
|
||||
textMode: roof.textMode,
|
||||
attributes: { roofId: roof.id, planeSize: sz, actualSize: sz },
|
||||
})
|
||||
hip.lineName = 'kerabPatternHip'
|
||||
return hip
|
||||
}
|
||||
const restoreInnerHipFromSnapshot = (hip, snap) => {
|
||||
hip.set({ x1: snap.x1, y1: snap.y1, x2: snap.x2, y2: snap.y2 })
|
||||
const ns = calcLinePlaneSize({ x1: snap.x1, y1: snap.y1, x2: snap.x2, y2: snap.y2 })
|
||||
hip.attributes = { ...hip.attributes, ...snap.attributes, planeSize: ns, actualSize: ns }
|
||||
}
|
||||
|
||||
// junctionExtended: ext hip 2개 + ridge 1개 제거 + 제거됐던 RG-1 + outer/inner hip 4개 복원
|
||||
if (ridgeAtMid.ridge.__patternKind === 'junctionExtended') {
|
||||
const extHips = Array.isArray(ridgeAtMid.ridge.__patternExtHips) ? ridgeAtMid.ridge.__patternExtHips : []
|
||||
extHips.forEach((h) => {
|
||||
if (!h) return
|
||||
removeLine(h)
|
||||
roof.innerLines = roof.innerLines.filter((il) => il !== h)
|
||||
})
|
||||
const removedRidgeSnaps = Array.isArray(ridgeAtMid.ridge.__removedRidgesSnapshot) ? ridgeAtMid.ridge.__removedRidgesSnapshot : []
|
||||
const removedHipSnaps = Array.isArray(ridgeAtMid.ridge.__removedHipsSnapshot) ? ridgeAtMid.ridge.__removedHipsSnapshot : []
|
||||
removeLine(ridgeAtMid.ridge)
|
||||
roof.innerLines = roof.innerLines.filter((il) => il !== ridgeAtMid.ridge)
|
||||
removedRidgeSnaps.forEach((snap) => {
|
||||
if (!snap) return
|
||||
const pts = [snap.x1, snap.y1, snap.x2, snap.y2]
|
||||
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
|
||||
const restored = new QLine(pts, {
|
||||
parentId: roof.id,
|
||||
fontSize: roof.fontSize,
|
||||
stroke: '#1083E3',
|
||||
strokeWidth: 4,
|
||||
name: LINE_TYPE.SUBLINE.RIDGE,
|
||||
textMode: roof.textMode,
|
||||
attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...snap.attributes },
|
||||
})
|
||||
if (snap.lineName) restored.lineName = snap.lineName
|
||||
canvas.add(restored)
|
||||
restored.bringToFront()
|
||||
roof.innerLines.push(restored)
|
||||
})
|
||||
removedHipSnaps.forEach((snap) => {
|
||||
if (!snap) return
|
||||
const hip = buildHipFromSnapshot(snap)
|
||||
canvas.add(hip)
|
||||
hip.bringToFront()
|
||||
roof.innerLines.push(hip)
|
||||
})
|
||||
removeKerabHalfLabels(target.id)
|
||||
hideOriginalLengthText(target.id, false)
|
||||
canvas.renderAll()
|
||||
return true
|
||||
}
|
||||
|
||||
// single-ridge / 이전 패턴: outer hip 2개 신규 생성
|
||||
const snaps = Array.isArray(ridgeAtMid.ridge.__originalHips) ? ridgeAtMid.ridge.__originalHips : null
|
||||
const hip1 = snaps ? buildHipFromSnapshot(snaps[0]) : buildHipToApex(c1)
|
||||
const hip2 = snaps ? buildHipFromSnapshot(snaps[1]) : buildHipToApex(c2)
|
||||
removeLine(ridgeAtMid.ridge)
|
||||
roof.innerLines = roof.innerLines.filter((il) => il !== ridgeAtMid.ridge)
|
||||
canvas.add(hip1)
|
||||
canvas.add(hip2)
|
||||
hip1.bringToFront()
|
||||
hip2.bringToFront()
|
||||
roof.innerLines.push(hip1, hip2)
|
||||
removeKerabHalfLabels(target.id)
|
||||
hideOriginalLengthText(target.id, false)
|
||||
canvas.renderAll()
|
||||
return true
|
||||
}
|
||||
|
||||
// [KERAB-HALF-LABEL 2026-05-19] 케라바 외곽선의 좌우/상하 절반 길이 라벨
|
||||
// 그림 그릴 단계에서는 외곽선은 1개로 유지하고 라벨만 2개 노출.
|
||||
// 할당 시 splitPolygonWithLines 가 자연 분할하므로 그때는 사라지고 분할 라인 라벨이 대체.
|
||||
const KERAB_HALF_LABEL_NAME = 'kerabHalfLabel'
|
||||
|
||||
const removeKerabHalfLabels = (parentId) => {
|
||||
const labels = canvas
|
||||
.getObjects()
|
||||
.filter((obj) => obj.name === KERAB_HALF_LABEL_NAME && obj.parentId === parentId)
|
||||
labels.forEach((obj) => canvas.remove(obj))
|
||||
}
|
||||
|
||||
const addKerabHalfLabels = (target, c1, c2) => {
|
||||
const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 }
|
||||
const fullPlane = calcLinePlaneSize({ x1: c1.x, y1: c1.y, x2: c2.x, y2: c2.y })
|
||||
const halfPlane = Math.round(fullPlane / 2)
|
||||
const text = String(halfPlane).replace(/\.0$/, '')
|
||||
const mkPos = (a, b) => {
|
||||
const cx = (a.x + b.x) / 2
|
||||
const cy = (a.y + b.y) / 2
|
||||
// 수평/수직 라인에 한해 라벨을 라인 옆으로 살짝 띄움 (QLine.addLengthText 와 동일한 룰)
|
||||
if (target.direction === 'left' || target.direction === 'right') return { left: cx, top: cy + 10 }
|
||||
if (target.direction === 'top' || target.direction === 'bottom') return { left: cx + 10, top: cy }
|
||||
return { left: cx, top: cy }
|
||||
}
|
||||
const buildLabel = (pos) =>
|
||||
new fabric.Textbox(text, {
|
||||
left: pos.left,
|
||||
top: pos.top,
|
||||
fontSize: target.fontSize,
|
||||
parentId: target.id,
|
||||
name: KERAB_HALF_LABEL_NAME,
|
||||
editable: false,
|
||||
selectable: true,
|
||||
lockRotation: true,
|
||||
lockScalingX: true,
|
||||
lockScalingY: true,
|
||||
planeSize: halfPlane,
|
||||
actualSize: halfPlane,
|
||||
})
|
||||
canvas.add(buildLabel(mkPos(c1, mid)))
|
||||
canvas.add(buildLabel(mkPos(mid, c2)))
|
||||
}
|
||||
|
||||
const hideOriginalLengthText = (parentId, hide) => {
|
||||
const lbl = canvas.getObjects().find((obj) => obj.name === 'lengthText' && obj.parentId === parentId)
|
||||
if (lbl) lbl.set({ visible: !hide })
|
||||
}
|
||||
|
||||
// [KERAB-PATTERN-EXT-CLEAN 2026-05-19] forward 시 제거되는 hip 과 짝이었던 orphan
|
||||
// extensionLine 정리. 한 끝점이 hip 의 한 끝점과 같고 다른 끝점이 hip 의 진행
|
||||
// 방향과 동일선상에 있으면 짝으로 판정. 잔류 시 allocation 의 integrateExtensionLines
|
||||
// 가 "짝없음" 으로 처리하고, ext 끝점이 외곽 polygon 을 잘못 자르는 잉여 분할 발생.
|
||||
const removeOrphanExtensionsForHip = (hip) => {
|
||||
const TOL = 1.0
|
||||
const hipP1 = { x: hip.x1, y: hip.y1 }
|
||||
const hipP2 = { x: hip.x2, y: hip.y2 }
|
||||
const hipVx = hip.x2 - hip.x1
|
||||
const hipVy = hip.y2 - hip.y1
|
||||
const hipMag = Math.hypot(hipVx, hipVy) || 1
|
||||
const same = (a, b) => Math.hypot(a.x - b.x, a.y - b.y) < TOL
|
||||
const exts = canvas.getObjects().filter(
|
||||
(o) => o?.lineName === 'extensionLine' && o.roofId === hip.parentId,
|
||||
)
|
||||
exts.forEach((ext) => {
|
||||
const eP1 = { x: ext.x1, y: ext.y1 }
|
||||
const eP2 = { x: ext.x2, y: ext.y2 }
|
||||
const sharesP1 = same(eP1, hipP1) || same(eP1, hipP2)
|
||||
const sharesP2 = same(eP2, hipP1) || same(eP2, hipP2)
|
||||
if ((sharesP1 ? 1 : 0) + (sharesP2 ? 1 : 0) !== 1) return
|
||||
const eVx = ext.x2 - ext.x1
|
||||
const eVy = ext.y2 - ext.y1
|
||||
const eMag = Math.hypot(eVx, eVy) || 1
|
||||
const cross = (hipVx * eVy - hipVy * eVx) / (hipMag * eMag)
|
||||
if (Math.abs(cross) >= 0.02) return // 동일선상 아님
|
||||
canvas.remove(ext)
|
||||
})
|
||||
}
|
||||
|
||||
// [2240 KERAB-HIP-SNAPSHOT 2026-05-19] forward 시 제거되는 hip 의 원본 상태 캡처.
|
||||
// revert 시 c1↔apex 로 새 hip 만들지 않고 이 스냅샷으로 원본 좌표/속성 그대로 복원.
|
||||
const snapshotHip = (hip) => ({
|
||||
x1: hip.x1,
|
||||
y1: hip.y1,
|
||||
x2: hip.x2,
|
||||
y2: hip.y2,
|
||||
attributes: hip.attributes ? { ...hip.attributes } : {},
|
||||
lineName: hip.lineName,
|
||||
__extended: hip.__extended,
|
||||
})
|
||||
|
||||
const applyKerabSingleRidgePattern = (roof, target, c1, c2, pair) => {
|
||||
// 케라바 외곽선 중점은 roof 폴리곤 코너 c1↔c2 의 중점
|
||||
const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 }
|
||||
const points = [mid.x, mid.y, pair.apex.x, pair.apex.y]
|
||||
const sz = calcLinePlaneSize({ x1: points[0], y1: points[1], x2: points[2], y2: points[3] })
|
||||
const ridge = new QLine(points, {
|
||||
parentId: roof.id,
|
||||
fontSize: roof.fontSize,
|
||||
stroke: '#1083E3',
|
||||
strokeWidth: 4,
|
||||
name: LINE_TYPE.SUBLINE.RIDGE,
|
||||
textMode: roof.textMode,
|
||||
attributes: { roofId: roof.id, planeSize: sz, actualSize: sz },
|
||||
})
|
||||
// [KERAB-PATTERN-MARKER 2026-05-19] 할당 등 후속 흐름이 패턴 생성 라인을 식별할 수 있도록
|
||||
ridge.lineName = 'kerabPatternRidge'
|
||||
// [2240 KERAB-HIP-SNAPSHOT 2026-05-19] 원본 hip 스냅샷 ridge 에 첨부 (revert 시 원본 그대로 복원)
|
||||
ridge.__originalHips = [snapshotHip(pair.hA), snapshotHip(pair.hB)]
|
||||
// 불변식: ridge 한 끝점이 케라바 중점과 일치
|
||||
if (!ridgeMeetsMidpointOf(ridge, { x1: c1.x, y1: c1.y, x2: c2.x, y2: c2.y })) {
|
||||
return false
|
||||
}
|
||||
// 패턴이 직접 hip 페어 제거 + 짝 ext 동반 정리 (rebuild 흐름에 의존하지 않음)
|
||||
removeOrphanExtensionsForHip(pair.hA)
|
||||
removeOrphanExtensionsForHip(pair.hB)
|
||||
removeLine(pair.hA)
|
||||
removeLine(pair.hB)
|
||||
roof.innerLines = roof.innerLines.filter((il) => il !== pair.hA && il !== pair.hB)
|
||||
canvas.add(ridge)
|
||||
ridge.bringToFront()
|
||||
roof.innerLines.push(ridge)
|
||||
// 외곽선 라벨: 원본 1개 숨김 + 좌우/상하 절반 라벨 2개 추가
|
||||
hideOriginalLengthText(target.id, true)
|
||||
removeKerabHalfLabels(target.id)
|
||||
addKerabHalfLabels(target, c1, c2)
|
||||
canvas.renderAll()
|
||||
return true
|
||||
}
|
||||
|
||||
// [2240 KERAB-JUNCTION-EXTENDED 2026-05-19] H-4/H-2 확장 패턴 (추가만, 기존 라인 무손상).
|
||||
// 1) H-4 확장선(junction1 → apex) hip 추가
|
||||
// 2) H-2 확장선(junction2 → apex) hip 추가
|
||||
// 3) mid(B-5) → apex ridge 추가
|
||||
// 기존 outer(H-7/H-1)·inner(H-4/H-2)·RG-2 모두 그대로 유지. revert 시 추가된 3개만 제거.
|
||||
const applyKerabJunctionExtendedPattern = (roof, target, c1, c2, jp) => {
|
||||
const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 }
|
||||
const ridgePoints = [mid.x, mid.y, jp.apex.x, jp.apex.y]
|
||||
const ridgeSz = calcLinePlaneSize({ x1: ridgePoints[0], y1: ridgePoints[1], x2: ridgePoints[2], y2: ridgePoints[3] })
|
||||
const ridge = new QLine(ridgePoints, {
|
||||
parentId: roof.id,
|
||||
fontSize: roof.fontSize,
|
||||
stroke: '#1083E3',
|
||||
strokeWidth: 4,
|
||||
name: LINE_TYPE.SUBLINE.RIDGE,
|
||||
textMode: roof.textMode,
|
||||
attributes: { roofId: roof.id, planeSize: ridgeSz, actualSize: ridgeSz },
|
||||
})
|
||||
ridge.lineName = 'kerabPatternRidge'
|
||||
ridge.__patternKind = 'junctionExtended'
|
||||
if (!ridgeMeetsMidpointOf(ridge, { x1: c1.x, y1: c1.y, x2: c2.x, y2: c2.y })) return false
|
||||
|
||||
const buildExtensionHip = (from, to) => {
|
||||
const pts = [from.x, from.y, to.x, to.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: 4,
|
||||
name: LINE_TYPE.SUBLINE.HIP,
|
||||
textMode: roof.textMode,
|
||||
attributes: { roofId: roof.id, planeSize: sz, actualSize: sz },
|
||||
})
|
||||
hip.lineName = 'kerabPatternHip'
|
||||
hip.__patternKind = 'junctionExtended'
|
||||
return hip
|
||||
}
|
||||
const ext1 = buildExtensionHip(jp.junction1, jp.apex)
|
||||
const ext2 = buildExtensionHip(jp.junction2, jp.apex)
|
||||
ridge.__patternExtHips = [ext1, ext2]
|
||||
|
||||
// [2240 KERAB-CENTRAL-RIDGE 2026-05-19] RG-1(주 마루) 제거.
|
||||
// 양 끝점이 ext1·ext2 segment 위에 각각 놓인 ridge 가 RG-1.
|
||||
// (H-4/H-2 확장선이 RG-1 끝점을 통과 → RG-2 등 다른 ridge 는 두 확장선과 무관)
|
||||
const ridgesToRemove = roof.innerLines.filter(
|
||||
(il) =>
|
||||
il &&
|
||||
il.name === LINE_TYPE.SUBLINE.RIDGE &&
|
||||
il.lineName !== 'kerabPatternRidge' &&
|
||||
il !== ridge &&
|
||||
ridgeEndpointsOnBothExtensions(il, ext1, ext2),
|
||||
)
|
||||
logger.log('[KERAB-CENTRAL-RIDGE] lookup', {
|
||||
ext1: `(${Math.round(ext1.x1)},${Math.round(ext1.y1)})→(${Math.round(ext1.x2)},${Math.round(ext1.y2)})`,
|
||||
ext2: `(${Math.round(ext2.x1)},${Math.round(ext2.y1)})→(${Math.round(ext2.x2)},${Math.round(ext2.y2)})`,
|
||||
removeCount: ridgesToRemove.length,
|
||||
removeList: ridgesToRemove.map(
|
||||
(il) => `(${Math.round(il.x1)},${Math.round(il.y1)})→(${Math.round(il.x2)},${Math.round(il.y2)})`,
|
||||
),
|
||||
})
|
||||
if (ridgesToRemove.length > 0) {
|
||||
ridge.__removedRidgesSnapshot = ridgesToRemove.map(snapshotHip)
|
||||
for (const il of ridgesToRemove) removeLine(il)
|
||||
roof.innerLines = roof.innerLines.filter((il) => !ridgesToRemove.includes(il))
|
||||
}
|
||||
|
||||
// [2240 KERAB-HIP-REMOVE 2026-05-19] 대전제: 케라바 외곽선 양 끝점(c1·c2) 에 붙은 hip 만 제거.
|
||||
// outer1 (c1↔junction1), outer2 (c2↔junction2) — 사용자 선택 처마 양 끝점에서 만나는 hip
|
||||
// inner (junction 너머) 는 보존. orphan extension 동반 정리, 스냅샷 첨부.
|
||||
const hipsToRemove = [jp.outer1, jp.outer2]
|
||||
ridge.__removedHipsSnapshot = hipsToRemove.map(snapshotHip)
|
||||
for (const hip of hipsToRemove) {
|
||||
removeOrphanExtensionsForHip(hip)
|
||||
removeLine(hip)
|
||||
}
|
||||
roof.innerLines = roof.innerLines.filter((il) => !hipsToRemove.includes(il))
|
||||
logger.log('[KERAB-HIP-REMOVE] outer only', {
|
||||
outer1: `(${Math.round(jp.outer1.x1)},${Math.round(jp.outer1.y1)})→(${Math.round(jp.outer1.x2)},${Math.round(jp.outer1.y2)})`,
|
||||
outer2: `(${Math.round(jp.outer2.x1)},${Math.round(jp.outer2.y1)})→(${Math.round(jp.outer2.x2)},${Math.round(jp.outer2.y2)})`,
|
||||
})
|
||||
|
||||
canvas.add(ext1)
|
||||
canvas.add(ext2)
|
||||
canvas.add(ridge)
|
||||
ext1.bringToFront()
|
||||
ext2.bringToFront()
|
||||
ridge.bringToFront()
|
||||
roof.innerLines.push(ext1, ext2, ridge)
|
||||
|
||||
hideOriginalLengthText(target.id, true)
|
||||
removeKerabHalfLabels(target.id)
|
||||
addKerabHalfLabels(target, c1, c2)
|
||||
canvas.renderAll()
|
||||
return true
|
||||
}
|
||||
|
||||
// [2240 KERAB-ATTR-ONLY 2026-05-19] 평행 hip 케이스: 외곽선 attributes.type 만 토글.
|
||||
// - hip 라인/ridge/라벨 등 SK 산출물 전부 무변경
|
||||
// - 호출자가 이미 target.set({ attributes }) 적용한 상태로 들어옴 → 렌더만 갱신
|
||||
// - forward/revert 양방향 동일 처리 (대칭)
|
||||
const applyKerabAttributeOnlyPattern = () => {
|
||||
canvas.renderAll()
|
||||
return true
|
||||
}
|
||||
|
||||
return { type, setType, buttonMenu, TYPES, pitchRef, offsetRef, widthRef, radioTypeRef, pitchText }
|
||||
}
|
||||
|
||||
@ -777,11 +777,36 @@ export function useRoofAllocationSetting(id) {
|
||||
// extensionLine + 동일직선 SK 1:1 통합 (대각선 단일길이/각도 면적 산출)
|
||||
integrateExtensionLines(roofBase)
|
||||
|
||||
// [KERAB-PATTERN-DIAG 2026-05-19] 패턴 라인 상태 진단 — 분할 직전 스냅샷
|
||||
const __patternLines = roofBase.innerLines.filter(
|
||||
(l) => l?.lineName === 'kerabPatternHip' || l?.lineName === 'kerabPatternRidge',
|
||||
)
|
||||
if (__patternLines.length > 0) {
|
||||
logger.log(
|
||||
`[KERAB-PATTERN-DIAG] roof=${roofBase.id} patternLines=${__patternLines.length} ` +
|
||||
`roof.points=${roofBase.points?.length ?? 0} ` +
|
||||
`lines=${roofBase.lines?.length ?? 0} innerLines=${roofBase.innerLines.length}`,
|
||||
)
|
||||
__patternLines.forEach((l) =>
|
||||
logger.log(
|
||||
` [PATTERN] ${l.lineName} ${l.name} (${l.x1.toFixed(1)},${l.y1.toFixed(1)})` +
|
||||
`→(${l.x2.toFixed(1)},${l.y2.toFixed(1)}) ` +
|
||||
`type=${l.attributes?.type ?? 'none'} __extended=${l.__extended} attrExtended=${l.attributes?.extended}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (roofBase.separatePolygon.length > 0) {
|
||||
splitPolygonWithSeparate(roofBase.separatePolygon)
|
||||
} else {
|
||||
splitPolygonWithLines(roofBase)
|
||||
}
|
||||
|
||||
// [KERAB-PATTERN-DIAG 2026-05-19] 분할 후 결과 — 새 sub-roof 개수
|
||||
if (__patternLines.length > 0) {
|
||||
const __newRoofs = canvas.getObjects().filter((o) => o.name === 'roof' && !o.isFixed)
|
||||
logger.log(`[KERAB-PATTERN-DIAG] 분할 후 새 sub-roof=${__newRoofs.length}`)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.log(e)
|
||||
canvas.discardActiveObject()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user