Merge pull request 'dev' (#914) from dev into prd-deploy
Reviewed-on: #914
This commit is contained in:
commit
8316fbb46d
@ -2769,6 +2769,12 @@ export function useEavesGableEdit(id) {
|
|||||||
moveStaleEdgeInnerLines(roof, c1, c2, _edgeIdx, apexRes)
|
moveStaleEdgeInnerLines(roof, c1, c2, _edgeIdx, apexRes)
|
||||||
// [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] apex 는 벽기준 고정. 힙을 벽교점 방향 45° 그대로 roofLine 까지 직선 연장.
|
// [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] apex 는 벽기준 고정. 힙을 벽교점 방향 45° 그대로 roofLine 까지 직선 연장.
|
||||||
extendTypeGableHipsStraightToRoofLine(roof, target, apexRes)
|
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 }))
|
logger.log('[KERAB-TYPE-EAVES] branch ' + JSON.stringify({ ok: !!apexRes, c1, c2, edgeIdx: _edgeIdx }))
|
||||||
if (apexRes) return
|
if (apexRes) return
|
||||||
@ -3524,16 +3530,31 @@ export function useEavesGableEdit(id) {
|
|||||||
const L = Math.hypot(wB.x - wA.x, wB.y - wA.y)
|
const L = Math.hypot(wB.x - wA.x, wB.y - wA.y)
|
||||||
if (L < 1) return null
|
if (L < 1) return null
|
||||||
const mid = { x: (wA.x + wB.x) / 2, y: (wA.y + wB.y) / 2 }
|
const mid = { x: (wA.x + wB.x) / 2, y: (wA.y + wB.y) / 2 }
|
||||||
// 마루의 케라바변쪽 끝점 식별 + 내부(inward) 방향 도출.
|
// 마루의 케라바변쪽 끝점 식별 (단축 대상).
|
||||||
const rA = { x: ridge.x1, y: ridge.y1 }
|
const rA = { x: ridge.x1, y: ridge.y1 }
|
||||||
const rB = { x: ridge.x2, y: ridge.y2 }
|
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 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
|
// [KERAB-TYPE-EAVES-INWARD 2026-06-12] inward 방향을 변의 "폴리곤 안쪽 법선"으로 도출.
|
||||||
let ix = ridgeInner.x - mid.x
|
// 기존: ridge stub 의 far-end - mid. 마루가 이미 짧아진 stub(2회차 리버트)이면 far-end 가
|
||||||
let iy = ridgeInner.y - mid.y
|
// 처마변 위로 와서 방향이 바깥으로 뒤집힘 → apex 폴리곤 밖 폭주(R3-outside, roofLine 절삭 불변식 위반).
|
||||||
const ilen = Math.hypot(ix, iy) || 1
|
// 변⟂마루인 정상 切妻에서는 안쪽 법선 = 마루방향이라 45°/L/2 모델과 동치이고, stub 퇴화에 불변.
|
||||||
ix /= ilen
|
const cps = Array.isArray(roof.points) ? roof.points : []
|
||||||
iy /= ilen
|
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) }
|
const apex = { x: mid.x + ix * (L / 2), y: mid.y + iy * (L / 2) }
|
||||||
// 마루 단축: 케라바변쪽 끝점 → apex.
|
// 마루 단축: 케라바변쪽 끝점 → apex.
|
||||||
if (midIsA) ridge.set({ x1: apex.x, y1: apex.y })
|
if (midIsA) ridge.set({ x1: apex.x, y1: apex.y })
|
||||||
@ -3602,8 +3623,8 @@ export function useEavesGableEdit(id) {
|
|||||||
const newA = c1ToP ? nP : nQ
|
const newA = c1ToP ? nP : nQ
|
||||||
const newB = c1ToP ? nQ : nP
|
const newB = c1ToP ? nQ : nP
|
||||||
// 出幅 평행이동 delta (양 코너 동일, 안전하게 평균).
|
// 出幅 평행이동 delta (양 코너 동일, 안전하게 평균).
|
||||||
const dxE = ((newA.x - oldC1.x) + (newB.x - oldC2.x)) / 2
|
const dxE = (newA.x - oldC1.x + (newB.x - oldC2.x)) / 2
|
||||||
const dyE = ((newA.y - oldC1.y) + (newB.y - oldC2.y)) / 2
|
const dyE = (newA.y - oldC1.y + (newB.y - oldC2.y)) / 2
|
||||||
if (Math.hypot(dxE, dyE) < 0.01) return null
|
if (Math.hypot(dxE, dyE) < 0.01) return null
|
||||||
const segDx = oldC2.x - oldC1.x
|
const segDx = oldC2.x - oldC1.x
|
||||||
const segDy = oldC2.y - oldC1.y
|
const segDy = oldC2.y - oldC1.y
|
||||||
@ -3704,10 +3725,7 @@ export function useEavesGableEdit(id) {
|
|||||||
const apexIsE1 = d1 <= d2
|
const apexIsE1 = d1 <= d2
|
||||||
const outerEnd = apexIsE1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
|
const outerEnd = apexIsE1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }
|
||||||
// outerEnd 에 가까운 벽교점(wLine 끝점) 선택.
|
// outerEnd 에 가까운 벽교점(wLine 끝점) 선택.
|
||||||
const wc =
|
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]
|
||||||
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.
|
// 방향 = apex→wc (45°). 이 방향 그대로 roofLine 까지 ray.
|
||||||
const dx = wc.x - apex.x
|
const dx = wc.x - apex.x
|
||||||
const dy = wc.y - apex.y
|
const dy = wc.y - apex.y
|
||||||
@ -3746,6 +3764,129 @@ export function useEavesGableEdit(id) {
|
|||||||
logger.log('[KERAB-TYPE-EAVES-STRAIGHT] ' + JSON.stringify({ extended, apex }))
|
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] 케라바 외곽선의 좌우/상하 절반 길이 라벨
|
// [KERAB-HALF-LABEL 2026-05-19] 케라바 외곽선의 좌우/상하 절반 길이 라벨
|
||||||
// 그림 그릴 단계에서는 외곽선은 1개로 유지하고 라벨만 2개 노출.
|
// 그림 그릴 단계에서는 외곽선은 1개로 유지하고 라벨만 2개 노출.
|
||||||
// 할당 시 splitPolygonWithLines 가 자연 분할하므로 그때는 사라지고 분할 라인 라벨이 대체.
|
// 할당 시 splitPolygonWithLines 가 자연 분할하므로 그때는 사라지고 분할 라인 라벨이 대체.
|
||||||
|
|||||||
@ -196,9 +196,14 @@ export function useCanvasEvent() {
|
|||||||
|
|
||||||
const selectionEvent = {
|
const selectionEvent = {
|
||||||
created: (e) => {
|
created: (e) => {
|
||||||
const target = e.selected[0]
|
|
||||||
setCurrentObject(target)
|
|
||||||
const { selected } = e
|
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) {
|
if (selected?.length > 0) {
|
||||||
selected.forEach((obj) => {
|
selected.forEach((obj) => {
|
||||||
@ -212,7 +217,12 @@ export function useCanvasEvent() {
|
|||||||
obj.set({ strokeWidth: 3 })
|
obj.set({ strokeWidth: 3 })
|
||||||
}
|
}
|
||||||
if (obj.name === POLYGON_TYPE.ROOF) {
|
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 })
|
obj.set({ stroke: originStroke })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -245,9 +255,12 @@ export function useCanvasEvent() {
|
|||||||
canvas.renderAll()
|
canvas.renderAll()
|
||||||
},
|
},
|
||||||
updated: (e) => {
|
updated: (e) => {
|
||||||
const target = e.selected[0]
|
|
||||||
setCurrentObject(target)
|
|
||||||
const { selected, deselected } = e
|
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) {
|
if (deselected?.length > 0) {
|
||||||
deselected.forEach((obj) => {
|
deselected.forEach((obj) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user