dev #872

Merged
ysCha merged 111 commits from dev into dev-deploy 2026-05-29 16:09:40 +09:00
3 changed files with 670 additions and 3 deletions
Showing only changes of commit d48c94c99e - Show all commits

View File

@ -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

View File

@ -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 }
}

View File

@ -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()