Merge pull request 'dev' (#923) from dev into dev-deploy
Reviewed-on: #923
This commit is contained in:
commit
14b1823b9d
@ -171,8 +171,9 @@ export default function ModuleTabContents({ tabIndex, addRoof, setAddedRoofs, ro
|
||||
<div className="flex-item-btn-wrap">
|
||||
{/* 2026-06-05 공법 버튼을 배열 인덱스 대신 constTp로 매핑 — API 응답 항목 추가/순서변경(예: 標準施工(Ⅱ) WORK_LV_ID_-2) 시 인덱스 밀림 방지. 운영(구 API)엔 -2가 없어 optional 은 데이터 있을 때만 렌더 */}
|
||||
{[
|
||||
{ constTp: 'WORK_LV_ID_-1', label: '標準施工(Ⅰ)' },
|
||||
// [표시순서변경 2026-06-17] 標準(Ⅱ) 를 標準(Ⅰ) 앞으로 — 사용자 요청
|
||||
{ constTp: 'WORK_LV_ID_-2', label: '標準施工(Ⅱ)', optional: true },
|
||||
{ constTp: 'WORK_LV_ID_-1', label: '標準施工(Ⅰ)' },
|
||||
{ constTp: 'WORK_LV_ID_1', label: '標準施工' },
|
||||
{ constTp: 'WORK_LV_ID_3', label: '強化施工' },
|
||||
{ constTp: 'WORK_LV_ID_4', label: '多設施工' },
|
||||
|
||||
@ -867,8 +867,9 @@ const Trestle = forwardRef((props, ref) => {
|
||||
<div className="flex-item-btn-wrap">
|
||||
{/* 2026-06-05 공법 버튼을 배열 인덱스 대신 constTp로 매핑 — API 응답 항목 추가/순서변경(예: 標準施工(II) WORK_LV_ID_-2) 시 인덱스 밀림 방지. 운영(구 API)엔 -2가 없어 optional 은 데이터 있을 때만 렌더 */}
|
||||
{[
|
||||
{ constTp: 'WORK_LV_ID_-1', label: `${getMessage('modal.module.basic.setting.module.standard.construction')}(I)` },
|
||||
// [표시순서변경 2026-06-17] 標準(II) 를 標準(I) 앞으로 — 사용자 요청
|
||||
{ constTp: 'WORK_LV_ID_-2', label: `${getMessage('modal.module.basic.setting.module.standard.construction')}(II)`, optional: true },
|
||||
{ constTp: 'WORK_LV_ID_-1', label: `${getMessage('modal.module.basic.setting.module.standard.construction')}(I)` },
|
||||
{ constTp: 'WORK_LV_ID_1', label: getMessage('modal.module.basic.setting.module.standard.construction') },
|
||||
{ constTp: 'WORK_LV_ID_3', label: getMessage('modal.module.basic.setting.module.enforce.construction') },
|
||||
{ constTp: 'WORK_LV_ID_4', label: getMessage('modal.module.basic.setting.module.multiple.construction') },
|
||||
|
||||
@ -2769,6 +2769,416 @@ export function useEavesGableEdit(id) {
|
||||
logger.log('[KERAB-REVERT] applied ' + JSON.stringify({ ok, c1, c2, apex: ridgeAtMid.apex }))
|
||||
if (ok) return
|
||||
}
|
||||
// [KERAB-VALLEY-EAVES-RULE1 2026-06-16] A/B타입 native 케라바 변(스냅샷·native마루·kLine 전부 없음)의
|
||||
// 첫 처마 변환 "씨앗". 양 코너에서 폴리곤 안쪽 45° 힙 2개를 그려 각자 첫 교차(다른 inner line 또는
|
||||
// 상대편 roofLine)까지 뻗는다. R3 절삭만 — 반사·힙 사이 마루 생성은 보류(다각형 넓이 의존, 추후 결정).
|
||||
// 出幅은 일반 라인변경과 동일하게 기존 surgical 로 처리. 게이트: 변 위에 collinear 게이블벽 inner line
|
||||
// 이 실재할 때만(= 진짜 케라바 기하). 없으면 아래 attr-only 폴백.
|
||||
{
|
||||
const EDGE_TOL = 0.5
|
||||
const NP = roof.points.length
|
||||
let edgeIdx = -1
|
||||
for (let i = 0; i < NP; i++) {
|
||||
const p = roof.points[i]
|
||||
const q = roof.points[(i + 1) % NP]
|
||||
if (
|
||||
(Math.hypot(p.x - c1.x, p.y - c1.y) < EDGE_TOL && Math.hypot(q.x - c2.x, q.y - c2.y) < EDGE_TOL) ||
|
||||
(Math.hypot(p.x - c2.x, p.y - c2.y) < EDGE_TOL && Math.hypot(q.x - c1.x, q.y - c1.y) < EDGE_TOL)
|
||||
) {
|
||||
edgeIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// 변환되는 게이블 변의 "게이블벽" 전체를 제거한다(사용자 확정 2026-06-16).
|
||||
// 게이블벽 = 변 c1↔c2(roofLine쪽) 또는 옛 出幅쪽 outerLine 과 같은 무한직선 위의 HIP 세그먼트.
|
||||
// 변이 게이블일 때만 존재한 구조 → eaves 가 되면 소멸. 한 직선이 ridge 에 의해 여러 세그먼트로
|
||||
// 쪼개져 변 구간 밖(아래)까지 뻗기도 한다(예: H-9 = 변 아래 연장). 따라서 변 구간 겹침이 아니라
|
||||
// "무한직선 동일선상(양 끝점 수직거리<1)" 전체를 게이블벽으로 본다 — i5(변 위) + i10(변 아래) 모두.
|
||||
// 이 전체 제거가 ridge(RG-2)의 anchor 를 끊어, 뒤의 dangling 정리에서 RG-2 까지 연쇄 제거된다.
|
||||
const isCollinearWith = (l, A, B) => {
|
||||
const dx = B.x - A.x
|
||||
const dy = B.y - A.y
|
||||
const lenSq = dx * dx + dy * dy || 1
|
||||
const perp = (px, py) => {
|
||||
const t = ((px - A.x) * dx + (py - A.y) * dy) / lenSq
|
||||
return Math.hypot(px - (A.x + t * dx), py - (A.y + t * dy))
|
||||
}
|
||||
return perp(l.x1, l.y1) < 1.0 && perp(l.x2, l.y2) < 1.0
|
||||
}
|
||||
// 옛 出幅쪽 수직벽 식별용 — surgical 전 target outerLine 끝점(=出幅 위치) 캡처.
|
||||
const tA = { x: target.x1, y: target.y1 }
|
||||
const tB = { x: target.x2, y: target.y2 }
|
||||
// 게이블벽(HIP)만 직접 제거. native 마루(RIDGE)는 여기서 보존하되, anchor 가 끊겨 dangling 이
|
||||
// 되는 ridge(RG-2)는 뒤의 cleanupDangling 이 연쇄 제거한다(사용자 확정 2026-06-16).
|
||||
const gableWallLines = (roof.innerLines || []).filter(
|
||||
(l) =>
|
||||
l && l.visible !== false && l.name === LINE_TYPE.SUBLINE.HIP && (isCollinearWith(l, c1, c2) || isCollinearWith(l, tA, tB)),
|
||||
)
|
||||
if (edgeIdx >= 0 && gableWallLines.length > 0) {
|
||||
target.set({ attributes })
|
||||
// 1) 옛 게이블벽 라인 제거 + 짝 확장 helper(extensionLine) 동반 제거.
|
||||
for (const gw of gableWallLines) {
|
||||
removeLine(gw)
|
||||
removeOrphanExtensionsForHip(gw)
|
||||
}
|
||||
roof.innerLines = roof.innerLines.filter((l) => !gableWallLines.includes(l))
|
||||
// 2) 出幅 반영 (일반 라인변경과 동일 — 기존 surgical, inner line 불변).
|
||||
applyTargetOffsetSurgical(target, attributes?.offset ?? 0, { skipInnerLines: true })
|
||||
// 3) 새 roofLine 코너 (인덱스는 surgical 후에도 안정).
|
||||
const pts = roof.points
|
||||
const nA = pts[edgeIdx]
|
||||
const nB = pts[(edgeIdx + 1) % NP]
|
||||
const newC1IsA = Math.hypot(nA.x - c1.x, nA.y - c1.y) <= Math.hypot(nA.x - c2.x, nA.y - c2.y)
|
||||
const idxA = newC1IsA ? edgeIdx : (edgeIdx + 1) % NP
|
||||
const idxB = newC1IsA ? (edgeIdx + 1) % NP : edgeIdx
|
||||
const newC1 = pts[idxA]
|
||||
const newC2 = pts[idxB]
|
||||
// 폴리곤 중심 (inward 판정용).
|
||||
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
|
||||
// 코너의 두 이웃 변 방향 → 폴리곤 안쪽 45° 이등분선 (convex/reflex 모두 안쪽으로 보정).
|
||||
const inwardDir = (idx) => {
|
||||
const C = pts[idx]
|
||||
const prev = pts[(idx - 1 + NP) % NP]
|
||||
const next = pts[(idx + 1) % NP]
|
||||
const n1 = Math.hypot(prev.x - C.x, prev.y - C.y) || 1
|
||||
const n2 = Math.hypot(next.x - C.x, next.y - C.y) || 1
|
||||
let dx = (prev.x - C.x) / n1 + (next.x - C.x) / n2
|
||||
let dy = (prev.y - C.y) / n1 + (next.y - C.y) / n2
|
||||
const dl = Math.hypot(dx, dy)
|
||||
if (dl < 1e-6) {
|
||||
// 일직선(180°) — 변 수직 안쪽 법선.
|
||||
dx = -(next.y - C.y) / n2
|
||||
dy = (next.x - C.x) / n2
|
||||
} else {
|
||||
dx /= dl
|
||||
dy /= dl
|
||||
}
|
||||
if (dx * (cenX - C.x) + dy * (cenY - C.y) < 0) {
|
||||
dx = -dx
|
||||
dy = -dy
|
||||
}
|
||||
return { x: dx, y: dy }
|
||||
}
|
||||
// corner 에서 dir 로 ray → 첫 교차(roofLine 변 + 기존 inner line), 자기 변(t≈0) 제외.
|
||||
const rayFirstHit = (P, dir) => {
|
||||
const segHit = (A, B) => {
|
||||
const ex = B.x - A.x
|
||||
const ey = B.y - A.y
|
||||
const den = dir.x * ey - dir.y * ex
|
||||
if (Math.abs(den) < 1e-9) return Infinity
|
||||
const t = ((A.x - P.x) * ey - (A.y - P.y) * ex) / den
|
||||
const u = ((A.x - P.x) * dir.y - (A.y - P.y) * dir.x) / den
|
||||
if (t > 1.0 && u >= -1e-6 && u <= 1 + 1e-6) return t
|
||||
return Infinity
|
||||
}
|
||||
let best = Infinity
|
||||
let hit = null
|
||||
let hitLine = null
|
||||
for (let i = 0; i < NP; i++) {
|
||||
const t = segHit(pts[i], pts[(i + 1) % NP])
|
||||
if (t < best) {
|
||||
best = t
|
||||
hit = { x: P.x + dir.x * t, y: P.y + dir.y * t, line: null, kind: 'roofLine' }
|
||||
hitLine = null
|
||||
}
|
||||
}
|
||||
for (const il of roof.innerLines || []) {
|
||||
if (!il || il.visible === false) continue
|
||||
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
|
||||
const t = segHit({ x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
|
||||
if (t < best) {
|
||||
best = t
|
||||
hit = { x: P.x + dir.x * t, y: P.y + dir.y * t, line: il, kind: il.name }
|
||||
hitLine = il
|
||||
}
|
||||
}
|
||||
return hit
|
||||
}
|
||||
const mkValleyHip = (corner, hit) => {
|
||||
const arr = [corner.x, corner.y, hit.x, hit.y]
|
||||
const sz = calcLinePlaneSize({ x1: arr[0], y1: arr[1], x2: arr[2], y2: arr[3] })
|
||||
const hip = new QLine(arr, {
|
||||
parentId: roof.id,
|
||||
fontSize: roof.fontSize,
|
||||
stroke: '#1083E3',
|
||||
strokeWidth: 3,
|
||||
name: LINE_TYPE.SUBLINE.HIP,
|
||||
textMode: roof.textMode,
|
||||
attributes: { roofId: roof.id, type: LINE_TYPE.SUBLINE.HIP, planeSize: sz, actualSize: sz },
|
||||
})
|
||||
hip.lineName = 'kerabPatternHip'
|
||||
hip.startPoint = { x: hip.x1, y: hip.y1 }
|
||||
hip.endPoint = { x: hip.x2, y: hip.y2 }
|
||||
return hip
|
||||
}
|
||||
const addHipFrom = (corner, idx) => {
|
||||
const dir = inwardDir(idx)
|
||||
const hit = rayFirstHit(corner, dir)
|
||||
if (!hit) return { dir, hit: null, hip: null, hitLine: null, hitKind: null }
|
||||
const h = mkValleyHip(corner, hit)
|
||||
canvas.add(h)
|
||||
h.bringToFront()
|
||||
roof.innerLines.push(h)
|
||||
return { dir, hit, hip: h, hitLine: hit.line, hitKind: hit.kind }
|
||||
}
|
||||
// 임의 방향 ray 로 골짜기 힙 1개 생성(반사 힙용). 첫 교차까지.
|
||||
const drawHipRay = (from, dir) => {
|
||||
const hit = rayFirstHit(from, dir)
|
||||
if (!hit) return null
|
||||
const h = mkValleyHip(from, hit)
|
||||
canvas.add(h)
|
||||
h.bringToFront()
|
||||
roof.innerLines.push(h)
|
||||
return { hip: h, hit }
|
||||
}
|
||||
// 4) 게이블 구조 잔재 정리 — anchor(게이블벽 HIP) 제거로 dangling 이 된 HIP·확장라인(H-9 등)만 제거.
|
||||
// ★ 마루(RIDGE)는 절대 제거하지 않는다 — dangling 이어도 보존(사용자 확정 2026-06-16:
|
||||
// "마루를 지우면 안된다"). RIDGE 는 connectivity 판정에는 포함(다른 HIP 의 접합 대상)하되
|
||||
// dangler 후보에서는 제외. VALLEY(골짜기확장)도 vExt 위에서 끝나는 정상 케이스라 제외.
|
||||
// 새 골짜기 힙 그리기 *전에* 수행해야 raycast 가 깨끗한 기하에 닿아 SHORT-hit 를 피한다.
|
||||
const cleanupDangling = () => {
|
||||
const TOL = 2.0
|
||||
const rpts = roof.points || []
|
||||
const distToSeg = (p, a, b) => {
|
||||
const ex = b.x - a.x
|
||||
const ey = b.y - a.y
|
||||
const l2 = ex * ex + ey * ey || 1
|
||||
let t = ((p.x - a.x) * ex + (p.y - a.y) * ey) / l2
|
||||
t = Math.max(0, Math.min(1, t))
|
||||
return Math.hypot(p.x - (a.x + t * ex), p.y - (a.y + t * ey))
|
||||
}
|
||||
const minEdgeDist = (pt) => {
|
||||
let m = Infinity
|
||||
for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) {
|
||||
const d = distToSeg(pt, rpts[j], rpts[i])
|
||||
if (d < m) m = d
|
||||
}
|
||||
return m
|
||||
}
|
||||
const removed = []
|
||||
for (let iter = 0; iter < 12; iter++) {
|
||||
const surv = (roof.innerLines || []).filter(
|
||||
(l) =>
|
||||
l &&
|
||||
l.visible !== false &&
|
||||
(l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE || l.name === LINE_TYPE.SUBLINE.VALLEY),
|
||||
)
|
||||
const ends = []
|
||||
for (const l of surv) {
|
||||
ends.push({ x: l.x1, y: l.y1, line: l })
|
||||
ends.push({ x: l.x2, y: l.y2, line: l })
|
||||
}
|
||||
// 마루(RIDGE)·VALLEY 는 dangling 이어도 제거 금지 — HIP 만 제거 후보.
|
||||
const dangler = surv.find((l) => {
|
||||
if (l.name !== LINE_TYPE.SUBLINE.HIP) return false
|
||||
for (const p of [
|
||||
{ x: l.x1, y: l.y1 },
|
||||
{ x: l.x2, y: l.y2 },
|
||||
]) {
|
||||
const onBoundary = rpts.length >= 3 && minEdgeDist(p) <= TOL
|
||||
const shared = ends.some((e) => e.line !== l && Math.hypot(e.x - p.x, e.y - p.y) < TOL)
|
||||
if (!onBoundary && !shared) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
if (!dangler) break
|
||||
removeLine(dangler)
|
||||
roof.innerLines = roof.innerLines.filter((l) => l !== dangler)
|
||||
removeOrphanExtensionsForHip(dangler)
|
||||
removed.push({ n: dangler.name, ln: dangler.lineName || '-', x1: Math.round(dangler.x1), y1: Math.round(dangler.y1), x2: Math.round(dangler.x2), y2: Math.round(dangler.y2) })
|
||||
}
|
||||
return removed
|
||||
}
|
||||
const cleaned = cleanupDangling()
|
||||
const resA = addHipFrom(newC1, idxA)
|
||||
const resB = addHipFrom(newC2, idxB)
|
||||
// 5b) 마루(RIDGE) 끝점 상호작용 — anchor 잃은 끝점 해소(사용자 확정 2026-06-16: "RG1(h8) 확장, RG2(b5) 절삭").
|
||||
// 규칙: 마루는 절대 삭제 안 함. anchor 잃은(=경계 미접촉 + 다른 선과 미공유) 끝점은
|
||||
// · 새 골짜기 힙이 그 마루를 가로지르면 → 교점에서 절삭(trim, 끝점을 교점으로 당김)
|
||||
// · 가로지르는 힙이 없으면 → 끝점 방향으로 roofLine/다음 라인까지 확장(extend)
|
||||
const resolveRidgeEndpoints = () => {
|
||||
const TOL = 2.0
|
||||
const rpts = roof.points || []
|
||||
const newHips = [resA, resB].map((r) => r && r.hip).filter(Boolean)
|
||||
const distToSeg = (p, a, b) => {
|
||||
const ex = b.x - a.x
|
||||
const ey = b.y - a.y
|
||||
const l2 = ex * ex + ey * ey || 1
|
||||
let t = ((p.x - a.x) * ex + (p.y - a.y) * ey) / l2
|
||||
t = Math.max(0, Math.min(1, t))
|
||||
return Math.hypot(p.x - (a.x + t * ex), p.y - (a.y + t * ey))
|
||||
}
|
||||
const minEdgeDist = (pt) => {
|
||||
let m = Infinity
|
||||
for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) {
|
||||
const d = distToSeg(pt, rpts[j], rpts[i])
|
||||
if (d < m) m = d
|
||||
}
|
||||
return m
|
||||
}
|
||||
// 선분 교차(내부 교점만, 끝점 t∈(eps,1-eps))
|
||||
const segInt = (p1, p2, p3, p4) => {
|
||||
const d1x = p2.x - p1.x
|
||||
const d1y = p2.y - p1.y
|
||||
const d2x = p4.x - p3.x
|
||||
const d2y = p4.y - p3.y
|
||||
const den = d1x * d2y - d1y * d2x
|
||||
if (Math.abs(den) < 1e-9) return null
|
||||
const t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / den
|
||||
const u = ((p3.x - p1.x) * d1y - (p3.y - p1.y) * d1x) / den
|
||||
if (t <= 1e-6 || t >= 1 - 1e-6 || u <= 1e-6 || u >= 1 - 1e-6) return null
|
||||
return { x: p1.x + t * d1x, y: p1.y + t * d1y }
|
||||
}
|
||||
// 절삭 전용: 마루(rp→rq)는 내부만, 힙(ha→hb)은 끝점 닿음(T자)까지 허용
|
||||
const segIntTrim = (rp, rq, ha, hb) => {
|
||||
const d1x = rq.x - rp.x
|
||||
const d1y = rq.y - rp.y
|
||||
const d2x = hb.x - ha.x
|
||||
const d2y = hb.y - ha.y
|
||||
const den = d1x * d2y - d1y * d2x
|
||||
if (Math.abs(den) < 1e-9) return null
|
||||
const t = ((ha.x - rp.x) * d2y - (ha.y - rp.y) * d2x) / den
|
||||
const u = ((ha.x - rp.x) * d1y - (ha.y - rp.y) * d1x) / den
|
||||
if (t <= 1e-6 || t >= 1 - 1e-6) return null // 마루 내부만
|
||||
if (u <= 1e-6 || u >= 1 + 1e-3) return null // 힙 끝점 닿음 허용
|
||||
return { x: rp.x + t * d1x, y: rp.y + t * d1y }
|
||||
}
|
||||
// 힙의 먼 끝(코너=startPoint 반대쪽 endPoint)을 교차점으로 절삭 + 라벨 갱신
|
||||
const trimHipFarEnd = (hip, cross) => {
|
||||
hip.set({ x2: cross.x, y2: cross.y })
|
||||
hip.endPoint = { x: cross.x, y: cross.y }
|
||||
const nsz = calcLinePlaneSize({ x1: hip.x1, y1: hip.y1, x2: cross.x, y2: cross.y })
|
||||
hip.attributes = { ...hip.attributes, planeSize: nsz, actualSize: nsz }
|
||||
hip.setLength?.()
|
||||
hip.setCoords?.()
|
||||
hip.addLengthText?.()
|
||||
}
|
||||
const ridges = (roof.innerLines || []).filter((l) => l && l.visible !== false && l.name === LINE_TYPE.SUBLINE.RIDGE)
|
||||
const allEnds = []
|
||||
for (const l of (roof.innerLines || []).filter(
|
||||
(l) =>
|
||||
l &&
|
||||
l.visible !== false &&
|
||||
(l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE || l.name === LINE_TYPE.SUBLINE.VALLEY),
|
||||
)) {
|
||||
allEnds.push({ x: l.x1, y: l.y1, line: l })
|
||||
allEnds.push({ x: l.x2, y: l.y2, line: l })
|
||||
}
|
||||
const touched = []
|
||||
for (const rg of ridges) {
|
||||
const endsOf = [
|
||||
{ p: { x: rg.x1, y: rg.y1 }, q: { x: rg.x2, y: rg.y2 }, set: (pt) => rg.set({ x1: pt.x, y1: pt.y }) },
|
||||
{ p: { x: rg.x2, y: rg.y2 }, q: { x: rg.x1, y: rg.y1 }, set: (pt) => rg.set({ x2: pt.x, y2: pt.y }) },
|
||||
]
|
||||
for (const { p, q, set } of endsOf) {
|
||||
const onBoundary = rpts.length >= 3 && minEdgeDist(p) <= TOL
|
||||
const shared = allEnds.some((e) => e.line !== rg && Math.hypot(e.x - p.x, e.y - p.y) < TOL)
|
||||
if (onBoundary || shared) continue // anchor 있음 → 그대로
|
||||
// (a) 가로지르는 힙 탐색 → 절삭
|
||||
let trimPt = null
|
||||
let trimD = Infinity
|
||||
for (const h of newHips) {
|
||||
const ip = segIntTrim(p, q, { x: h.x1, y: h.y1 }, { x: h.x2, y: h.y2 })
|
||||
if (!ip) continue
|
||||
const d = Math.hypot(ip.x - p.x, ip.y - p.y)
|
||||
if (d < trimD) {
|
||||
trimD = d
|
||||
trimPt = ip
|
||||
}
|
||||
}
|
||||
if (trimPt) {
|
||||
set(trimPt)
|
||||
touched.push({ act: 'trim', ln: rg.lineName || '-', to: { x: Math.round(trimPt.x), y: Math.round(trimPt.y) } })
|
||||
continue
|
||||
}
|
||||
// (b) 확장 — q→p 방향으로 첫 교차(roofLine 변 + 다른 inner line, 자기 제외)까지
|
||||
const dx = p.x - q.x
|
||||
const dy = p.y - q.y
|
||||
const mag = Math.hypot(dx, dy) || 1
|
||||
const ux = dx / mag
|
||||
const uy = dy / mag
|
||||
const castHit = (A, B) => {
|
||||
const ip = segInt({ x: q.x, y: q.y }, { x: q.x + ux * 1e5, y: q.y + uy * 1e5 }, A, B)
|
||||
if (!ip) return Infinity
|
||||
const t = (ip.x - p.x) * ux + (ip.y - p.y) * uy
|
||||
return t > 1e-3 ? t : Infinity
|
||||
}
|
||||
let best = Infinity
|
||||
let hit = null
|
||||
let hitLine = null
|
||||
for (let i = 0; i < rpts.length; i++) {
|
||||
const A = rpts[i]
|
||||
const B = rpts[(i + 1) % rpts.length]
|
||||
const t = castHit(A, B)
|
||||
if (t < best) {
|
||||
best = t
|
||||
hit = { x: q.x + ux * (mag + t), y: q.y + uy * (mag + t) }
|
||||
hitLine = null // roofLine 변
|
||||
}
|
||||
}
|
||||
for (const il of roof.innerLines || []) {
|
||||
if (!il || il === rg || il.visible === false) continue
|
||||
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
|
||||
const t = castHit({ x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 })
|
||||
if (t < best) {
|
||||
best = t
|
||||
hit = { x: q.x + ux * (mag + t), y: q.y + uy * (mag + t) }
|
||||
hitLine = il
|
||||
}
|
||||
}
|
||||
if (hit) {
|
||||
set(hit)
|
||||
touched.push({ act: 'extend', ln: rg.lineName || '-', to: { x: Math.round(hit.x), y: Math.round(hit.y) } })
|
||||
// 확장한 마루가 힙 몸통에 닿으면 → 그 힙의 먼 끝(코너 반대쪽)을 교차점으로 절삭(힙은 마루를 넘지 못함)
|
||||
if (hitLine && hitLine.name === LINE_TYPE.SUBLINE.HIP) {
|
||||
trimHipFarEnd(hitLine, hit)
|
||||
touched.push({ act: 'trim-hip', ln: hitLine.lineName || '-', to: { x: Math.round(hit.x), y: Math.round(hit.y) } })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return touched
|
||||
}
|
||||
const ridgeOps = resolveRidgeEndpoints()
|
||||
// 5c) Y 반사 힙 — 새 힙이 *기존 마루(RIDGE)에 직접 부딪힌* 경우만(=raycast hit 가 RIDGE),
|
||||
// 그 마루를 대칭축으로 반대편 면에 반사 힙 1개 생성(R7: 마루+hip 교점 반사).
|
||||
// ★ 마루를 *확장*해서 만난 힙(hit 가 roofLine 이던 resA)은 Y 아님 → 반사 안 함(사용자 확정 2026-06-16).
|
||||
const reflectOps = []
|
||||
for (const res of [resA, resB]) {
|
||||
if (!res || !res.hip || res.hitKind !== LINE_TYPE.SUBLINE.RIDGE || !res.hitLine) continue
|
||||
const M = { x: res.hit.x, y: res.hit.y }
|
||||
const rg = res.hitLine
|
||||
const rlen = Math.hypot(rg.x2 - rg.x1, rg.y2 - rg.y1) || 1
|
||||
const rx = (rg.x2 - rg.x1) / rlen
|
||||
const ry = (rg.y2 - rg.y1) / rlen
|
||||
// 반사 방향 = reflect(역입사) 마루축 기준. w=역입사(코너→M 반대), v_ref=2(w·r̂)r̂ - w
|
||||
const wx = -res.dir.x
|
||||
const wy = -res.dir.y
|
||||
const dot = wx * rx + wy * ry
|
||||
const refDir = { x: 2 * dot * rx - wx, y: 2 * dot * ry - wy }
|
||||
const drawn = drawHipRay(M, refDir)
|
||||
if (drawn) {
|
||||
reflectOps.push({ from: { x: Math.round(M.x), y: Math.round(M.y) }, to: { x: Math.round(drawn.hit.x), y: Math.round(drawn.hit.y) } })
|
||||
}
|
||||
}
|
||||
removeKerabHalfLabels(target.id)
|
||||
hideOriginalLengthText(target.id, false)
|
||||
canvas.renderAll()
|
||||
runKerabRuleCheck(roof, 'valley-eaves-rule1', kerabRevertBeforeSnap)
|
||||
logger.log(
|
||||
'[KERAB-VALLEY-EAVES-RULE1] applied ' +
|
||||
JSON.stringify({ edgeIdx, removed: gableWallLines.length, cleaned, newC1, newC2, resA, resB, ridgeOps, reflectOps }),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
// [2240 KERAB-ATTR-ONLY 2026-05-19] ridge-at-mid 없음 → forward 가 attribute-only 였음 → 동일 대칭으로 속성만 토글
|
||||
target.set({ attributes })
|
||||
applyKerabAttributeOnlyPattern()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user