Merge branch 'dev' of https://git.hanasys.jp/qcast3/qcast-front into feature/redo-undo

This commit is contained in:
hyojun.choi 2026-06-15 10:17:17 +09:00
commit 234b720b78
2 changed files with 172 additions and 18 deletions

View File

@ -2772,6 +2772,12 @@ export function useEavesGableEdit(id) {
moveStaleEdgeInnerLines(roof, c1, c2, _edgeIdx, apexRes)
// [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] apex 는 벽기준 고정. 힙을 벽교점 방향 45° 그대로 roofLine 까지 직선 연장.
extendTypeGableHipsStraightToRoofLine(roof, target, apexRes)
// [KERAB-TYPE-EAVES-HIPROOF 2026-06-12] 양쪽 박공이 모두 처마가 되어 완전 寄棟이면,
// 세로 apex 2개·X자 교차를 버리고 힙·마루 규칙(R1/R2/R3)으로 가로 마루 + 절삭 힙 4개로 재구성.
reconstructHipRoofRidgeIfComplete(roof)
// [KERAB-RULE-CHECK 2026-06-12] 모든 라인변경(재구성 포함)이 끝난 최종 형상을 규칙으로 검증.
// surgicalAfter 내부 검사는 재구성 전 상태라, 최종 상태는 여기서 한 번 더 본다.
runKerabRuleCheck(roof, 'type-eaves', kerabRevertBeforeSnap)
}
logger.log('[KERAB-TYPE-EAVES] branch ' + JSON.stringify({ ok: !!apexRes, c1, c2, edgeIdx: _edgeIdx }))
if (apexRes) return
@ -3527,16 +3533,31 @@ export function useEavesGableEdit(id) {
const L = Math.hypot(wB.x - wA.x, wB.y - wA.y)
if (L < 1) return null
const mid = { x: (wA.x + wB.x) / 2, y: (wA.y + wB.y) / 2 }
// 마루의 케라바변쪽 끝점 식별 + 내부(inward) 방향 도출.
// 마루의 케라바변쪽 끝점 식별 (단축 대상).
const rA = { x: ridge.x1, y: ridge.y1 }
const rB = { x: ridge.x2, y: ridge.y2 }
const midIsA = Math.hypot(rA.x - mid.x, rA.y - mid.y) <= Math.hypot(rB.x - mid.x, rB.y - mid.y)
const ridgeInner = midIsA ? rB : rA
let ix = ridgeInner.x - mid.x
let iy = ridgeInner.y - mid.y
const ilen = Math.hypot(ix, iy) || 1
ix /= ilen
iy /= ilen
// [KERAB-TYPE-EAVES-INWARD 2026-06-12] inward 방향을 변의 "폴리곤 안쪽 법선"으로 도출.
// 기존: ridge stub 의 far-end - mid. 마루가 이미 짧아진 stub(2회차 리버트)이면 far-end 가
// 처마변 위로 와서 방향이 바깥으로 뒤집힘 → apex 폴리곤 밖 폭주(R3-outside, roofLine 절삭 불변식 위반).
// 변⟂마루인 정상 切妻에서는 안쪽 법선 = 마루방향이라 45°/L/2 모델과 동치이고, stub 퇴화에 불변.
const cps = Array.isArray(roof.points) ? roof.points : []
let cenX = 0
let cenY = 0
for (const p of cps) {
cenX += p.x
cenY += p.y
}
cenX /= cps.length || 1
cenY /= cps.length || 1
const ex = (wB.x - wA.x) / L
const ey = (wB.y - wA.y) / L
let ix = -ey
let iy = ex
if (ix * (cenX - mid.x) + iy * (cenY - mid.y) < 0) {
ix = -ix
iy = -iy
}
const apex = { x: mid.x + ix * (L / 2), y: mid.y + iy * (L / 2) }
// 마루 단축: 케라바변쪽 끝점 → apex.
if (midIsA) ridge.set({ x1: apex.x, y1: apex.y })
@ -3605,8 +3626,8 @@ export function useEavesGableEdit(id) {
const newA = c1ToP ? nP : nQ
const newB = c1ToP ? nQ : nP
// 出幅 평행이동 delta (양 코너 동일, 안전하게 평균).
const dxE = ((newA.x - oldC1.x) + (newB.x - oldC2.x)) / 2
const dyE = ((newA.y - oldC1.y) + (newB.y - oldC2.y)) / 2
const dxE = (newA.x - oldC1.x + (newB.x - oldC2.x)) / 2
const dyE = (newA.y - oldC1.y + (newB.y - oldC2.y)) / 2
if (Math.hypot(dxE, dyE) < 0.01) return null
const segDx = oldC2.x - oldC1.x
const segDy = oldC2.y - oldC1.y
@ -3707,10 +3728,7 @@ export function useEavesGableEdit(id) {
const apexIsE1 = d1 <= d2
const outerEnd = apexIsE1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
// outerEnd 에 가까운 벽교점(wLine 끝점) 선택.
const wc =
Math.hypot(outerEnd.x - wcs[0].x, outerEnd.y - wcs[0].y) <= Math.hypot(outerEnd.x - wcs[1].x, outerEnd.y - wcs[1].y)
? wcs[0]
: wcs[1]
const wc = Math.hypot(outerEnd.x - wcs[0].x, outerEnd.y - wcs[0].y) <= Math.hypot(outerEnd.x - wcs[1].x, outerEnd.y - wcs[1].y) ? wcs[0] : wcs[1]
// 방향 = apex→wc (45°). 이 방향 그대로 roofLine 까지 ray.
const dx = wc.x - apex.x
const dy = wc.y - apex.y
@ -3749,6 +3767,129 @@ export function useEavesGableEdit(id) {
logger.log('[KERAB-TYPE-EAVES-STRAIGHT] ' + JSON.stringify({ extended, apex }))
}
// [KERAB-TYPE-EAVES-HIPROOF 2026-06-12] 양쪽 박공이 모두 처마로 바뀌어 완전 寄棟(우진각)이 되는 순간 호출.
// per-edge L/2 apex 모델은 마루를 짧은 축(세로)으로 만들어 두 힙쌍이 X자로 교차하는 틀린 형상을 남긴다.
// 힙·마루 규칙으로 재구성: R2(라인은 교점에서 멈춤)·R3(교점 초과분 절삭)·R1(중앙선=마루).
// 각 힙이 "가장 먼저 만나는" 다른 힙과의 교점이 진짜 마루 끝점(짧은변 공유쌍). 두 끝점을 잇는 선이 마루.
// apex 기반 세로 마루 스텁은 제거하고 절삭된 힙 4개 + 가로 마루 1개(표준 우진각)로 교체.
const reconstructHipRoofRidgeIfComplete = (roof) => {
if (!roof || !Array.isArray(roof.innerLines) || !Array.isArray(roof.points)) return false
// 완전 寄棟 = 외곽선에 박공(GABLE)이 하나도 안 남음.
const outerLines = canvas.getObjects().filter((o) => o.name === 'outerLine' && o.attributes?.roofId === roof.id)
if (!outerLines.length) return false
if (outerLines.some((o) => o.attributes?.type === LINE_TYPE.WALLLINE.GABLE)) return false
// kerabPatternHip 정확히 4개일 때만 표준 우진각으로 간주.
const hips = roof.innerLines.filter((il) => il && il.lineName === 'kerabPatternHip' && il.visible !== false)
if (hips.length !== 4) return false
const pts = roof.points
let cenX = 0
let cenY = 0
for (const p of pts) {
cenX += p.x
cenY += p.y
}
cenX /= pts.length || 1
cenY /= pts.length || 1
// 각 힙의 outer(roofLine 코너쪽) / inner 끝점 + 방향(outer→inner, 45°).
const info = hips.map((il) => {
const d1 = Math.hypot(il.x1 - cenX, il.y1 - cenY)
const d2 = Math.hypot(il.x2 - cenX, il.y2 - cenY)
const outer = d1 >= d2 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 }
const inner = d1 >= d2 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
return { il, outer, dir: { x: inner.x - outer.x, y: inner.y - outer.y } }
})
// 두 힙 직선 교점 (a.outer + t·a.dir).
const intersect = (a, b) => {
const den = a.dir.x * b.dir.y - a.dir.y * b.dir.x
if (Math.abs(den) < 1e-9) return null
const t = ((b.outer.x - a.outer.x) * b.dir.y - (b.outer.y - a.outer.y) * b.dir.x) / den
const u = ((b.outer.x - a.outer.x) * a.dir.y - (b.outer.y - a.outer.y) * a.dir.x) / den
if (t <= 1e-6 || u <= 1e-6) return null
return { x: a.outer.x + a.dir.x * t, y: a.outer.y + a.dir.y * t, t }
}
// 각 힙이 "가장 먼저(최소 t)" 만나는 힙 = 마루 끝점 파트너.
for (let i = 0; i < info.length; i++) {
let best = null
let bestJ = -1
for (let j = 0; j < info.length; j++) {
if (i === j) continue
const ip = intersect(info[i], info[j])
if (!ip) continue
if (!best || ip.t < best.t) {
best = ip
bestJ = j
}
}
info[i].stop = best
info[i].partner = bestJ
}
if (info.some((h) => !h.stop)) return false
// 마루 끝점 클러스터링 (2개 기대).
const ridgePts = []
for (const h of info) {
const found = ridgePts.find((rp) => Math.hypot(rp.x - h.stop.x, rp.y - h.stop.y) < 1.0)
if (found) found.count++
else ridgePts.push({ x: h.stop.x, y: h.stop.y, count: 1 })
}
if (ridgePts.length !== 2 || ridgePts.some((rp) => rp.count !== 2)) {
logger.log('[KERAB-TYPE-EAVES-HIPROOF] skip ' + JSON.stringify({ ridgePts: ridgePts.length, counts: ridgePts.map((r) => r.count) }))
return false
}
// R2/R3: 각 힙을 outer → 마루 끝점으로 절삭.
for (const h of info) {
const rp = ridgePts.find((p) => Math.hypot(p.x - h.stop.x, p.y - h.stop.y) < 1.0)
const d1 = Math.hypot(h.il.x1 - cenX, h.il.y1 - cenY)
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 })
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
h.il.attributes.actualSize = sz
}
if (typeof h.il.setCoords === 'function') h.il.setCoords()
if (typeof h.il.setLength === 'function') h.il.setLength()
if (typeof h.il.addLengthText === 'function') h.il.addLengthText()
}
// 기존 마루(세로 apex 스텁 등) 제거 — 완전 우진각엔 가로 마루 1개만.
const staleRidges = roof.innerLines.filter((il) => il && il.name === LINE_TYPE.SUBLINE.RIDGE)
for (const r of staleRidges) {
canvas.remove(r)
const idx = roof.innerLines.indexOf(r)
if (idx >= 0) roof.innerLines.splice(idx, 1)
}
// R1: 두 마루 끝점을 잇는 가로 마루 신규 생성.
const rp1 = ridgePts[0]
const rp2 = ridgePts[1]
const rpts = [rp1.x, rp1.y, rp2.x, rp2.y]
const rsz = calcLinePlaneSize({ x1: rpts[0], y1: rpts[1], x2: rpts[2], y2: rpts[3] })
const ridge = new QLine(rpts, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: LINE_TYPE.SUBLINE.RIDGE,
textMode: roof.textMode,
attributes: { roofId: roof.id, type: LINE_TYPE.SUBLINE.RIDGE, planeSize: rsz, actualSize: rsz },
})
ridge.lineName = 'ridge'
canvas.add(ridge)
ridge.bringToFront()
roof.innerLines.push(ridge)
if (typeof ridge.setCoords === 'function') ridge.setCoords()
if (typeof ridge.setLength === 'function') ridge.setLength()
if (typeof ridge.addLengthText === 'function') ridge.addLengthText()
canvas.renderAll()
logger.log(
'[KERAB-TYPE-EAVES-HIPROOF] reconstructed ' +
JSON.stringify({
ridge: { x1: Math.round(rp1.x), y1: Math.round(rp1.y), x2: Math.round(rp2.x), y2: Math.round(rp2.y) },
removedRidges: staleRidges.length,
}),
)
return true
}
// [KERAB-HALF-LABEL 2026-05-19] 케라바 외곽선의 좌우/상하 절반 길이 라벨
// 그림 그릴 단계에서는 외곽선은 1개로 유지하고 라벨만 2개 노출.
// 할당 시 splitPolygonWithLines 가 자연 분할하므로 그때는 사라지고 분할 라인 라벨이 대체.

View File

@ -205,9 +205,14 @@ export function useCanvasEvent() {
const selectionEvent = {
created: (e) => {
const target = e.selected[0]
setCurrentObject(target)
const { selected } = e
// [DEL-DRAG-FIX 2026-06-12] 드래그 박스로 roof/surface 등이 섞여 잡히면 e.selected[0] 가 비-module 이 되어
// currentObject 가 module 로 안 잡힘 → contextMenu 에 module remove 미세팅 → Del 무반응.
// 모듈 기본설정 메뉴에서는 module 을 우선해 currentObject 로 설정.
const selectedModules = selected?.filter((o) => o.name === POLYGON_TYPE.MODULE) ?? []
const target =
currentMenu === MENU.MODULE_CIRCUIT_SETTING.BASIC_SETTING && selectedModules.length > 0 ? selectedModules[0] : e.selected[0]
setCurrentObject(target)
if (selected?.length > 0) {
selected.forEach((obj) => {
@ -221,7 +226,12 @@ export function useCanvasEvent() {
obj.set({ strokeWidth: 3 })
}
if (obj.name === POLYGON_TYPE.ROOF) {
canvas.discardActiveObject()
// [DEL-DRAG-FIX 2026-06-12] 드래그 박스 복수선택에 roof 가 섞이면 discardActiveObject 가 전체 선택을 해제하고
// selection:cleared → currentObject=null 로 만들어 Del 리스너가 제거됨(삭제 불가). 복수선택은
// refineModuleSelection 이 모듈만 정제하므로, 단일 roof 클릭일 때만 해제한다.
if (selected.length === 1) {
canvas.discardActiveObject()
}
obj.set({ stroke: originStroke })
}
}
@ -254,9 +264,12 @@ export function useCanvasEvent() {
canvas.renderAll()
},
updated: (e) => {
const target = e.selected[0]
setCurrentObject(target)
const { selected, deselected } = e
// [DEL-DRAG-FIX 2026-06-12] created 와 동일: 모듈 기본설정 메뉴에서는 module 우선으로 currentObject 설정
const selectedModules = selected?.filter((o) => o.name === POLYGON_TYPE.MODULE) ?? []
const target =
currentMenu === MENU.MODULE_CIRCUIT_SETTING.BASIC_SETTING && selectedModules.length > 0 ? selectedModules[0] : e.selected[0]
setCurrentObject(target)
if (deselected?.length > 0) {
deselected.forEach((obj) => {