diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index c9eff3c47..a25dc6edc 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -70,6 +70,8 @@ export const STATE_REGISTRY = { "corridor-deform": { bucket: "pref", scope: "global" }, /** B05 3D 옛 방식 A(실루엣 갈아 끼우기 · 115-4 비교용) — "1" 켬 · 그 밖 끔 */ "corridor-legacy": { bucket: "pref", scope: "global" }, + /** B05 3D 방식 고르기(115-5) — current · legacy-a · new1(기본) · proposal2. 옛 두 키는 읽어서 옮김 */ + "corridor-mode": { bucket: "pref", scope: "global" }, /** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */ "routeedit-view": { bucket: "pref", scope: "project" }, /** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */ diff --git a/B05_Profile/B05_Profile_UI_Corridor_Deform.ts b/B05_Profile/B05_Profile_UI_Corridor_Deform.ts index b550f7d34..141eb76e7 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Deform.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Deform.ts @@ -495,11 +495,12 @@ export function buildDeformField( return out; } - /** 그 자리의 비탈 종류 — 앞뒤 행 중 하나라도 비탈 끝 안이면 그 종류. */ - function zoneOf(r: number, si: number, s: number): 0 | 1 | 2 { - for (const k of [r, r + 1]) { - if (s < catches[k * 4 + si * 2]) return 1; - if (s < catches[k * 4 + si * 2 + 1]) return 2; + /** 그 자리의 비탈 종류 — 앞뒤 행 비탈 끝을 보간한 선 안이면 그 종류(비탈 끝 브레이크라인과 같은 선). */ + function zoneOf(r: number, si: number, s: number, t: number): 0 | 1 | 2 { + for (let ki = 0; ki < 2; ki += 1) { + const a = catches[r * 4 + si * 2 + ki]; + const b = catches[(r + 1) * 4 + si * 2 + ki]; + if (s < a * (1 - t) + b * t) return ki === 0 ? 1 : 2; } return 0; } @@ -515,7 +516,7 @@ export function buildDeformField( out = Math.min(out, Math.max(z, limit.ceiling ?? z)); } } - return { z: out, zone: at.outward < 0 ? 0 : zoneOf(at.row, si, s) }; + return { z: out, zone: at.outward < 0 ? 0 : zoneOf(at.row, si, s, at.t) }; } return { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Deform_Breakline.ts b/B05_Profile/B05_Profile_UI_Corridor_Deform_Breakline.ts new file mode 100644 index 000000000..d2a71b246 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Deform_Breakline.ts @@ -0,0 +1,612 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Deform_Breakline.ts + * 새 방식 지형의 **브레이크라인 국소 재삼각분할**(계획서 115-5 P1-3). + * + * 노면 끝(앵커) · 비탈 끝 · 단면 꺾임 · 구조물 구멍 선을 지형 메시에 끼워 넣는다. + * · 선이 걸친 삼각형만 그 삼각형 안 선 조각으로 면을 나눠(평면 그래프 면 걷기) + * `ShapeUtils.triangulateShape`(earcut)로 다시 삼각분할한다. + * · 삼각형 변과 선의 교점은 **변 꼭짓점 번호 순서로 정한 식**으로 내 이웃 삼각형과 + * 비트까지 같다 — 변형 뒤에도 T자 균열이 없다(`_Clip.ts` 무한직선 절단 대체). + * · 지울 자리(노면부 · 구멍) 면은 버리고, 남은 면은 무게중심 자리로 한 색(성토 · 절토 · + * 원지반)을 칠한다 — 색 경계 = 비탈 끝 선. + * · 손댈 자리(`touched` — 행별 비탈 끝 + 5 m 띠) 밖 삼각형은 번호만 넘긴다(P2-5). + * + * 원본 geometry 는 읽기만 한다 — 새 위치 · 속성 배열을 만든다. 법선은 만들지 않는다(`_Clip.ts`). + * ========================================================================== */ + +import * as THREE from "three"; +import type { P2 } from "./B05_Profile_UI_Corridor_Region"; + +/** 브레이크라인 하나 — 씬 평면(x, z) 꺾은선. 닫힌 고리는 끝점 = 첫 점. */ +export type Breakline = P2[]; + +/** 변형 결과(씬 Y) · 자리(0 원지반 · 1 성토 · 2 절토). */ +export interface BandPoint { + y: number; + zone: 0 | 1 | 2; +} + +export interface BandContext { + lines: ReadonlyArray; + /** 지울 자리(노면부 · 구조물 구멍). */ + removed: (p: P2) => boolean; + /** 손댈 자리 — 밖 삼각형은 그대로 넘긴다. */ + touched: (p: P2) => boolean; + /** 씬 좌표 정점 → 변형 높이 · 자리. */ + deform: (x: number, y: number, z: number) => BandPoint; + /** 자리별 색(r, g, b · 0~1). */ + tint: (zone: 1 | 2) => [number, number, number]; +} + +export interface BandStats { + split: number; + /** 선이 걸쳤는데 못 나눈 삼각형(퇴화) — 통째로 둔다. */ + fallback: number; + moved: number; + raised: number; + lowered: number; + maxRaiseM: number; + maxCutM: number; +} + +const CELL_M = 2; +/** 점 같음 판정(m) — 교점 · 꼭짓점 물림. */ +const SNAP_M = 1e-6; +/** 이보다 작게 움직이면 움직였다고 세지 않는다(m). */ +const MOVE_MIN_M = 0.01; + +/** 선분 격자 색인 — 선분 [px, pz, qx, qz] 와 칸별 선분 번호. */ +class SegmentGrid { + readonly segs: number[] = []; + private cells = new Map(); + private minX = Infinity; + private minZ = Infinity; + private cols = 1; + + constructor(lines: ReadonlyArray) { + for (const line of lines) { + for (let i = 1; i < line.length; i += 1) { + const p = line[i - 1]; + const q = line[i]; + if (Math.hypot(q.x - p.x, q.z - p.z) < SNAP_M) continue; + this.segs.push(p.x, p.z, q.x, q.z); + this.minX = Math.min(this.minX, p.x, q.x); + this.minZ = Math.min(this.minZ, p.z, q.z); + } + } + let maxX = -Infinity; + for (let s = 0; s < this.segs.length; s += 4) + maxX = Math.max(maxX, this.segs[s], this.segs[s + 2]); + this.cols = Math.max(1, Math.ceil((maxX - this.minX) / CELL_M) + 1); + for (let s = 0; s < this.segs.length; s += 4) { + const [c0, c1] = this.span(this.segs[s], this.segs[s + 2], this.minX); + const [r0, r1] = this.span(this.segs[s + 1], this.segs[s + 3], this.minZ); + for (let r = r0; r <= r1; r += 1) { + for (let c = c0; c <= c1; c += 1) { + const key = r * this.cols + c; + const list = this.cells.get(key); + if (list) list.push(s / 4); + else this.cells.set(key, [s / 4]); + } + } + } + } + + private span(a: number, b: number, origin: number): [number, number] { + return [ + Math.floor((Math.min(a, b) - origin) / CELL_M), + Math.floor((Math.max(a, b) - origin) / CELL_M), + ]; + } + + /** 상자에 걸칠 수 있는 선분 번호(중복 없음). */ + near(minX: number, maxX: number, minZ: number, maxZ: number): number[] { + if (!this.segs.length) return []; + const [c0, c1] = this.span(minX, maxX, this.minX); + const [r0, r1] = this.span(minZ, maxZ, this.minZ); + const out: number[] = []; + for (let r = r0; r <= r1; r += 1) { + for (let c = c0; c <= c1; c += 1) { + const list = this.cells.get(r * this.cols + c); + if (list) for (const s of list) if (!out.includes(s)) out.push(s); + } + } + return out; + } +} + +const cross = (ax: number, az: number, bx: number, bz: number): number => ax * bz - az * bx; + +function polygonArea(points: ReadonlyArray): number { + let area = 0; + for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) { + area += points[j].x * points[i].z - points[i].x * points[j].z; + } + return area / 2; +} + +function insidePolygon(points: ReadonlyArray, p: P2): boolean { + let hit = false; + for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) { + const a = points[i]; + const b = points[j]; + if (a.z > p.z !== b.z > p.z && p.x < ((b.x - a.x) * (p.z - a.z)) / (b.z - a.z) + a.x) + hit = !hit; + } + return hit; +} + +/** 삼각형 안 선 조각 한 끝 — 자리와, 경계 위면 어느 변(0~2) · 꼭짓점(3~5)인가. */ +interface PieceEnd { + u: number; + p: P2; + on: number[]; +} + +/** + * 삼각형(꼭짓점 번호 ids)을 선분들로 나눈 면들 — 면마다 [바깥 고리, ...구멍 고리]. + * 걸친 선이 없으면 null · 퇴화로 못 나누면 변 위 마디만 품은 삼각형 한 면(`fallback`). + */ +export function splitTriangle( + corners: [P2, P2, P2], + ids: [number, number, number], + segIds: ReadonlyArray, + segs: ReadonlyArray, +): Array<{ contour: P2[]; holes: P2[][]; fallback?: boolean }> | null { + const pieces: Array<{ a: PieceEnd; b: PieceEnd; cuts: Array<{ t: number; p: P2 }> }> = []; + /** 변 위 교점 전부 — 조각이 안 남아도(변에 스친 선) 마디로 둔다. 이웃 삼각형도 같은 교점을 + * 같은 식으로 내므로 양쪽이 늘 같은 마디를 갖는다(T자 균열 0). */ + const edgeHits: Array<{ e: number; p: P2 }> = []; + const triArea = polygonArea(corners); + if (Math.abs(triArea) < 1e-10) return null; + const sign = Math.sign(triArea); + const inside = (p: P2): boolean => { + for (let e = 0; e < 3; e += 1) { + const a = corners[e]; + const b = corners[(e + 1) % 3]; + if (sign * cross(b.x - a.x, b.z - a.z, p.x - a.x, p.z - a.z) <= 0) return false; + } + return true; + }; + for (const s of segIds) { + const P = { x: segs[s * 4], z: segs[s * 4 + 1] }; + const Q = { x: segs[s * 4 + 2], z: segs[s * 4 + 3] }; + const ends: PieceEnd[] = []; + let collinear = false; + for (let e = 0; e < 3 && !collinear; e += 1) { + // 변은 꼭짓점 번호 작은 쪽 → 큰 쪽으로 — 이웃 삼각형과 같은 식 · 같은 값. + const swap = ids[e] > ids[(e + 1) % 3]; + const lo = corners[swap ? (e + 1) % 3 : e]; + const hi = corners[swap ? e : (e + 1) % 3]; + const ex = hi.x - lo.x; + const ez = hi.z - lo.z; + const length = Math.hypot(ex, ez); + const d1 = cross(ex, ez, P.x - lo.x, P.z - lo.z) / length; + const d2 = cross(ex, ez, Q.x - lo.x, Q.z - lo.z) / length; + if (Math.abs(d1) < SNAP_M && Math.abs(d2) < SNAP_M) collinear = true; + if ((d1 > 0 && d2 > 0) || (d1 < 0 && d2 < 0) || d1 === d2) continue; + const u = d1 / (d1 - d2); + const x = P.x + (Q.x - P.x) * u; + const z = P.z + (Q.z - P.z) * u; + const w = ((x - lo.x) * ex + (z - lo.z) * ez) / (length * length); + if (w * length < -SNAP_M || (w - 1) * length > SNAP_M) continue; + const corner = + w * length < SNAP_M + ? swap + ? (e + 1) % 3 + : e + : (1 - w) * length < SNAP_M + ? swap + ? e + : (e + 1) % 3 + : -1; + if (corner >= 0) { + ends.push({ u, p: corners[corner], on: [corner + 3, corner, (corner + 2) % 3] }); + } else { + ends.push({ u, p: { x, z }, on: [e] }); + edgeHits.push({ e, p: { x, z } }); + } + } + if (collinear) continue; + if (inside(P)) ends.push({ u: 0, p: P, on: [] }); + if (inside(Q)) ends.push({ u: 1, p: Q, on: [] }); + if (ends.length < 2) continue; + ends.sort((m, n) => m.u - n.u); + const a = ends[0]; + const b = ends[ends.length - 1]; + if (Math.hypot(a.p.x - b.p.x, a.p.z - b.p.z) < SNAP_M) continue; + // 두 끝이 같은 변 위 — 경계를 따라가는 선이라 나눌 것이 없다. + if (a.on.some((k) => k < 3 && b.on.includes(k))) continue; + pieces.push({ a, b, cuts: [] }); + } + if (!pieces.length && !edgeHits.length) return null; + // 선 조각끼리 교차 — 두 조각 모두에 마디를 넣는다. + for (let i = 0; i < pieces.length; i += 1) { + for (let j = i + 1; j < pieces.length; j += 1) { + const A = pieces[i]; + const B = pieces[j]; + const rx = A.b.p.x - A.a.p.x; + const rz = A.b.p.z - A.a.p.z; + const sx = B.b.p.x - B.a.p.x; + const sz = B.b.p.z - B.a.p.z; + const den = cross(rx, rz, sx, sz); + if (Math.abs(den) < 1e-14) continue; + const qx = B.a.p.x - A.a.p.x; + const qz = B.a.p.z - A.a.p.z; + const t = cross(qx, qz, sx, sz) / den; + const v = cross(qx, qz, rx, rz) / den; + const ta = SNAP_M / Math.hypot(rx, rz); + const tb = SNAP_M / Math.hypot(sx, sz); + if (t < -ta || t > 1 + ta || v < -tb || v > 1 + tb) continue; + const innerA = t > ta && t < 1 - ta; + const innerB = v > tb && v < 1 - tb; + if (!innerA && !innerB) continue; // 끝끼리 닿음 — 같은 마디로 묶인다. + // 한쪽 끝이 다른 조각 가운데에 닿음(T) — 그 끝 자리를 가운데 쪽 조각의 마디로. + const p = + innerA && innerB + ? { x: A.a.p.x + rx * t, z: A.a.p.z + rz * t } + : innerA + ? v <= tb + ? B.a.p + : B.b.p + : t <= ta + ? A.a.p + : A.b.p; + if (innerA) A.cuts.push({ t, p }); + if (innerB) B.cuts.push({ t: v, p }); + } + } + + // 평면 그래프 — SNAP 안 마디는 하나로 묶는다(거의 겹친 마디는 각 순서를 흔들어 면 걷기를 깬다). + const nodes: P2[] = []; + const node = (p: P2): number => { + const found = nodes.findIndex((q) => Math.hypot(q.x - p.x, q.z - p.z) < SNAP_M * 10); + if (found >= 0) return found; + nodes.push(p); + return nodes.length - 1; + }; + corners.forEach(node); + const adj: Array> = []; + const link = (i: number, j: number): void => { + if (i === j) return; + (adj[i] ??= new Set()).add(j); + (adj[j] ??= new Set()).add(i); + }; + const onEdge: Array> = [[], [], []]; + for (const piece of pieces) { + const chain = [piece.a.p, ...piece.cuts.sort((m, n) => m.t - n.t).map((c) => c.p), piece.b.p]; + const idsOf = chain.map(node); + for (let k = 1; k < idsOf.length; k += 1) link(idsOf[k - 1], idsOf[k]); + } + for (const { e, p } of edgeHits) { + const from = corners[e]; + const to = corners[(e + 1) % 3]; + const w = Math.hypot(p.x - from.x, p.z - from.z) / Math.hypot(to.x - from.x, to.z - from.z); + onEdge[e].push({ w, id: node(p) }); + } + for (let e = 0; e < 3; e += 1) { + const stops = [ + { w: 0, id: e }, + ...onEdge[e].sort((m, n) => m.w - n.w), + { w: 1, id: (e + 1) % 3 }, + ]; + for (let k = 1; k < stops.length; k += 1) link(stops[k - 1].id, stops[k].id); + } + /** 퇴화 대비 — 안쪽 선은 버리고 변 위 마디만 품은 한 면(이웃과 T자 균열은 없다). */ + const boundaryOnly = (): Array<{ contour: P2[]; holes: P2[][]; fallback: boolean }> => [ + { + contour: [0, 1, 2].flatMap((e) => [corners[e], ...onEdge[e].map((stop) => nodes[stop.id])]), + holes: [], + fallback: true, + }, + ]; + // 매달린 선(한쪽 끝이 삼각형 안에서 끝남)은 면을 안 나눈다 — 걷어 낸다. + let pruned = true; + while (pruned) { + pruned = false; + adj.forEach((set, i) => { + if (set && set.size === 1) { + const [j] = set; + adj[j].delete(i); + set.clear(); + pruned = true; + } + }); + } + // 면 걷기 — 들어온 변을 뒤집은 방향에서 시계방향 다음 변. 양(반시계) 면 = 안쪽 면. + const order: number[][] = adj.map((set, i) => + set + ? [...set].sort( + (m, n) => + Math.atan2(nodes[m].z - nodes[i].z, nodes[m].x - nodes[i].x) - + Math.atan2(nodes[n].z - nodes[i].z, nodes[n].x - nodes[i].x), + ) + : [], + ); + const seen = new Set(); + const N = nodes.length; + const faces: P2[][] = []; + const faceIds: number[][] = []; + order.forEach((list, i) => + list.forEach((j) => { + if (seen.has(i * N + j)) return; + const ring: number[] = []; + let u = i; + let v = j; + while (!seen.has(u * N + v) && ring.length <= 4 * N) { + seen.add(u * N + v); + ring.push(u); + const around = order[v]; + const k = around.indexOf(u); + const w = around[(k - 1 + around.length) % around.length]; + u = v; + v = w; + } + faces.push(ring.map((id) => nodes[id])); + faceIds.push(ring); + }), + ); + const filled: Array<{ contour: P2[]; holes: P2[][]; ids: Set }> = []; + const holes: Array<{ ring: P2[]; ids: number[] }> = []; + let total = 0; + faces.forEach((ring, f) => { + // 면 걷기 규칙상 안쪽 면은 늘 반시계(넓이 +) — 삼각형 감김과 무관. + const area = polygonArea(ring); + if (area > 1e-12) { + filled.push({ contour: ring, holes: [], ids: new Set(faceIds[f]) }); + total += area; + } else if (area < -1e-12 && !faceIds[f].includes(0)) { + holes.push({ ring, ids: faceIds[f] }); + } + }); + // 삼각형 안에 따로 떠 있는 고리(작은 구멍) — 그 고리를 품은 면의 구멍으로. + for (const hole of holes) { + const host = filled + .filter((face) => !hole.ids.some((id) => face.ids.has(id))) + .filter((face) => insidePolygon(face.contour, hole.ring[0])) + .sort((m, n) => Math.abs(polygonArea(m.contour)) - Math.abs(polygonArea(n.contour)))[0]; + if (!host) return boundaryOnly(); + host.holes.push(hole.ring); + total -= Math.abs(polygonArea(hole.ring)); + } + // 면 넓이 합이 삼각형과 다르면(퇴화) 나누지 않는다. 면이 하나여도 변 위 마디(매달린 선이 + // 이웃 삼각형을 나눈 자리)를 품으므로 그대로 낸다 — 통째로 두면 T자 균열. + if (Math.abs(total - Math.abs(triArea)) > 1e-6 * Math.max(1, Math.abs(triArea))) { + return boundaryOnly(); + } + return filled.map(({ contour, holes: inner }) => ({ contour, holes: inner })); +} + +/** 넓이 0 에 가까운 삼각형 — 가장 긴 변에서 맞은 꼭짓점까지 높이가 SNAP 안. */ +function sliver(a: P2, b: P2, c: P2): boolean { + const twice = Math.abs(cross(b.x - a.x, b.z - a.z, c.x - a.x, c.z - a.z)); + const longest = Math.max( + Math.hypot(b.x - a.x, b.z - a.z), + Math.hypot(c.x - b.x, c.z - b.z), + Math.hypot(a.x - c.x, a.z - c.z), + ); + return twice < SNAP_M * longest; +} + +/** + * earcut 이 일직선 점을 걸러 내거나 넓이 0 귀로만 쓴 자리를 되살린다 — 변 위 마디가 빠지면 + * 이웃 면과 T자 균열. 넓이 0 삼각형은 버리고, 빠진 점을 변 위에 둔 삼각형을 그 점에서 둘로. + */ +function keepCollinear(ring: ReadonlyArray, tris: number[][]): number[][] { + const out = tris.filter(([a, b, c]) => !sliver(ring[a], ring[b], ring[c])).map((tri) => [...tri]); + const used = new Set(out.flat()); + ring.forEach((p, k) => { + if (used.has(k)) return; + for (let i = 0; i < out.length; i += 1) { + const tri = out[i]; + for (let e = 0; e < 3; e += 1) { + const a = ring[tri[e]]; + const b = ring[tri[(e + 1) % 3]]; + const ex = b.x - a.x; + const ez = b.z - a.z; + const length2 = ex * ex + ez * ez; + const along = (p.x - a.x) * ex + (p.z - a.z) * ez; + if (along <= 0 || along >= length2) continue; + if (Math.abs(cross(ex, ez, p.x - a.x, p.z - a.z)) / Math.sqrt(length2) > 1e-7) continue; + const c = tri[(e + 2) % 3]; + out.splice(i, 1, [tri[e], k, c], [k, tri[(e + 1) % 3], c]); + used.add(k); + return; + } + } + }); + return out; +} + +/** 보간해 이어 갈 속성 — 위치 · 법선을 뺀 나머지(`_Clip.ts` 와 같은 규칙). */ +function attributeSlots(geometry: THREE.BufferGeometry): Array<{ + name: string; + size: number; + source: THREE.BufferAttribute; + values: ArrayLike; + extra: number[]; +}> { + return Object.keys(geometry.attributes) + .filter((name) => name !== "position" && name !== "normal") + .map((name) => { + const source = geometry.getAttribute(name) as THREE.BufferAttribute; + const values = ( + source.array as unknown as { subarray(a: number, b: number): ArrayLike } + ).subarray(0, source.count * source.itemSize); + return { name, size: source.itemSize, source, values, extra: [] as number[] }; + }); +} + +/** + * 지형 한 조각을 브레이크라인으로 다시 나누고 높이를 옮긴 사본 — 칠한 정점 표식과 함께. + */ +export function retriangulateBand( + geometry: THREE.BufferGeometry, + ctx: BandContext, + stats: BandStats, +): { geometry: THREE.BufferGeometry; keep: Uint8Array } { + const position = geometry.getAttribute("position") as THREE.BufferAttribute; + const base = position.count; + const px = (position.array as Float32Array).subarray(0, base * 3); + const positions = Float32Array.from(px); + const index = geometry.getIndex(); + const triCount = index ? index.count / 3 : base / 3; + const slots = attributeSlots(geometry); + const color = slots.find((slot) => slot.name === "color"); + const scale = color?.source.normalized + ? color.source.array instanceof Uint8Array + ? 255 + : 65535 + : 1; + const grid = new SegmentGrid(ctx.lines); + const kept: number[] = []; + const extra: number[] = []; + const zones: number[] = []; + /** 원 정점 — 0 아직 · 1 원지반 · 2 성토 · 3 절토(+1). */ + const done = new Uint8Array(base); + + const count = (delta: number): void => { + if (Math.abs(delta) < MOVE_MIN_M) return; + stats.moved += 1; + if (delta > 0) { + stats.raised += 1; + stats.maxRaiseM = Math.max(stats.maxRaiseM, delta); + } else { + stats.lowered += 1; + stats.maxCutM = Math.max(stats.maxCutM, -delta); + } + }; + const settle = (v: number): void => { + if (done[v]) return; + const y = px[v * 3 + 1]; + const out = ctx.deform(px[v * 3], y, px[v * 3 + 2]); + positions[v * 3 + 1] = out.y; + done[v] = out.zone + 1; + count(out.y - y); + }; + + for (let t = 0; t < triCount; t += 1) { + const ids: [number, number, number] = [0, 1, 2].map((k) => + index ? index.getX(t * 3 + k) : t * 3 + k, + ) as [number, number, number]; + const corners = ids.map((v) => ({ x: px[v * 3], z: px[v * 3 + 2] })) as [P2, P2, P2]; + const centroid = { + x: (corners[0].x + corners[1].x + corners[2].x) / 3, + z: (corners[0].z + corners[1].z + corners[2].z) / 3, + }; + if (!corners.some(ctx.touched) && !ctx.touched(centroid)) { + kept.push(...ids); + continue; + } + const near = grid.near( + Math.min(corners[0].x, corners[1].x, corners[2].x), + Math.max(corners[0].x, corners[1].x, corners[2].x), + Math.min(corners[0].z, corners[1].z, corners[2].z), + Math.max(corners[0].z, corners[1].z, corners[2].z), + ); + const faces = near.length ? splitTriangle(corners, ids, near, grid.segs) : null; + if (faces?.some((face) => face.fallback)) stats.fallback += 1; + if (!faces) { + if (ctx.removed(centroid)) continue; + ids.forEach(settle); + kept.push(...ids); + continue; + } + stats.split += 1; + // 원 삼각형 평면 · 속성 무게중심 보간. + const [a, b, c] = corners; + const den = (b.z - c.z) * (a.x - c.x) + (c.x - b.x) * (a.z - c.z); + const weights = (p: P2): [number, number, number] => { + const w0 = ((b.z - c.z) * (p.x - c.x) + (c.x - b.x) * (p.z - c.z)) / den; + const w1 = ((c.z - a.z) * (p.x - c.x) + (a.x - c.x) * (p.z - c.z)) / den; + return [w0, w1, 1 - w0 - w1]; + }; + const sign = Math.sign(den); + for (const face of faces) { + const ring = [...face.contour, ...face.holes.flat()]; + const tris = keepCollinear( + ring, + THREE.ShapeUtils.triangulateShape( + face.contour.map((p) => new THREE.Vector2(p.x, p.z)), + face.holes.map((hole) => hole.map((p) => new THREE.Vector2(p.x, p.z))), + ), + ); + for (const tri of tris) { + const pts = tri.map((k) => ring[k]); + const mid = { + x: (pts[0].x + pts[1].x + pts[2].x) / 3, + z: (pts[0].z + pts[1].z + pts[2].z) / 3, + }; + if (ctx.removed(mid)) continue; + const w = weights(mid); + const zone = ctx.deform( + mid.x, + w[0] * px[ids[0] * 3 + 1] + w[1] * px[ids[1] * 3 + 1] + w[2] * px[ids[2] * 3 + 1], + mid.z, + ).zone; + const turn = cross( + pts[1].x - pts[0].x, + pts[1].z - pts[0].z, + pts[2].x - pts[0].x, + pts[2].z - pts[0].z, + ); + // 원 삼각형과 같은 감김(앞면 그대로). + const ordered = Math.sign(turn) === sign ? pts : [pts[0], pts[2], pts[1]]; + for (const p of ordered) { + const pw = weights(p); + const y = + pw[0] * px[ids[0] * 3 + 1] + pw[1] * px[ids[1] * 3 + 1] + pw[2] * px[ids[2] * 3 + 1]; + const out = ctx.deform(p.x, y, p.z); + count(out.y - y); + kept.push(base + extra.length / 3); + extra.push(p.x, out.y, p.z); + zones.push(zone); + for (const slot of slots) { + for (let k = 0; k < slot.size; k += 1) { + slot.extra.push( + pw[0] * slot.values[ids[0] * slot.size + k] + + pw[1] * slot.values[ids[1] * slot.size + k] + + pw[2] * slot.values[ids[2] * slot.size + k], + ); + } + } + } + } + } + } + + const out = new THREE.BufferGeometry(); + const all = new Float32Array(base * 3 + extra.length); + all.set(positions, 0); + all.set(extra, base * 3); + out.setAttribute("position", new THREE.BufferAttribute(all, 3)); + const keep = new Uint8Array(base + zones.length); + const paint = (values: { [i: number]: number }, v: number, zone: number): void => { + if (!color || zone < 1) return; + const rgb = ctx.tint(zone as 1 | 2); + for (let k = 0; k < 3; k += 1) + values[v * color.size + k] = scale === 1 ? rgb[k] : Math.round(rgb[k] * scale); + keep[v] = 1; + }; + for (const slot of slots) { + const ctor = slot.source.array.constructor as new (length: number) => THREE.TypedArray; + const merged = new ctor(base * slot.size + slot.extra.length); + merged.set(slot.values as ArrayLike, 0); + merged.set( + slot.source.normalized ? slot.extra.map((value) => Math.round(value)) : slot.extra, + base * slot.size, + ); + if (slot === color) { + for (let v = 0; v < base; v += 1) paint(merged, v, done[v] - 1); + zones.forEach((zone, i) => paint(merged, base + i, zone)); + } + out.setAttribute( + slot.name, + new THREE.BufferAttribute(merged, slot.size, slot.source.normalized), + ); + } + out.setIndex(kept); + out.computeBoundingSphere(); + out.computeBoundingBox(); + return { geometry: out, keep }; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Deform_Structure.ts b/B05_Profile/B05_Profile_UI_Corridor_Deform_Structure.ts index 696437682..240e465d1 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Deform_Structure.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Deform_Structure.ts @@ -11,7 +11,7 @@ * ========================================================================== */ import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; -import type { CorridorStructure } from "./B05_Profile_UI_Corridor_Structures"; +import type { CorridorStructure, StructureFrame } from "./B05_Profile_UI_Corridor_Structures"; import { classifyStation, PIECE_COLS, @@ -164,14 +164,39 @@ function setOf( return structures.filter((s) => Math.abs(s.chainage_m - section.chainage_m) < 1e-6); } -/** 지형 구멍 고리(모델 좌표) — 구조물 풋프린트 · BOX 아령 외곽. 도로부 안쪽은 뺀다. */ +/** 윗면으로 볼 높이 여유(m) — 링 폴리곤 최고점에서 이만큼 안. */ +const TOP_TOLERANCE_M = 0.05; +/** 구조물 둘레 드레이프 띠(m) — 풋프린트 밖 이만큼까지 지형을 윗면 아래로(115-5 P1-4). */ +export const DRAPE_BAND_M = 0.75; +/** 관 입구 구멍 — 끝에서 안쪽으로 볼 길이 · 걸음(m). */ +const MOUTH_REACH_M = 3; +const MOUTH_STEP_M = 0.1; + +/** 링 폴리곤의 윗면 점만 — 기초 · 뒷굽은 빼고 솔리드 윗면 외곽만 구멍으로(115-5 P1-4). */ +function topOnly(polygon: ReadonlyArray<[number, number]>): Array<[number, number]> { + const top = Math.max(...polygon.map((point) => point[1])); + return polygon.filter((point) => point[1] >= top - TOP_TOLERANCE_M); +} + +function topStructure(structure: CorridorStructure): CorridorStructure { + return { + ...structure, + polygon: structure.polygon && topOnly(structure.polygon), + polygons: structure.polygons?.map(topOnly), + }; +} + +/** + * 지형 구멍 고리(모델 좌표) — 구조물 **윗면** 외곽 · BOX 아령 외곽. 도로부 안쪽은 뺀다. + * 기초 · 뒷굽은 지형이 묻는다(둘레 드레이프 `structureTopAt`). + */ export function structureHoleLoops( sections: ReadonlyArray, structures: ReadonlyArray, ): Array> { const loops: Array> = []; for (const section of sections) { - const set = setOf(section, structures); + const set = setOf(section, structures).map(topStructure); const edges = section.design?.road_edges; if (!set.length || !edges) continue; (["left", "right"] as const).forEach((side) => { @@ -248,3 +273,158 @@ export function noFillLimits( } return limits; } + +/** 한 구조물의 링 사이 마디 — 둘레 드레이프 조회용. */ +interface DrapeSpan { + a: StructureFrame; + b: StructureFrame; + /** 링마다 [편거리 lo, hi, 윗면 표고(dz 포함)]. */ + wa: [number, number, number]; + wb: [number, number, number]; + /** 끝 마디 밖으로 늘릴 비율(띠 / 링 간격). */ + before: number; + after: number; + box: [number, number, number, number]; +} + +/** + * 구조물 둘레 드레이프(115-5 P1-4) — 풋프린트 안 · 둘레 띠 자리의 솔리드 윗면 표고(모델 + * 좌표). 지형은 `min(z', 윗면)` — 절토 어깨 · 성토가 벽 상단에서 멈춘다. 없으면 null. + * 겹친 부재는 가장 높은 윗면. 솔리드는 읽기만 한다. + */ +export function structureTopAt( + structures: ReadonlyArray, +): (x: number, y: number) => number | null { + const spans: DrapeSpan[] = []; + for (const structure of structures) { + const rings = structure.rings; + if (structure.kind === "pipe" || !rings || rings.length < 2) continue; + const window = (index: number): [number, number, number] | null => { + const polygon = structure.polygons?.[index] ?? structure.polygon; + if (!polygon?.length) return null; + const offsets = polygon.map((point) => point[0]); + const top = Math.max(...polygon.map((point) => point[1])); + return [Math.min(...offsets), Math.max(...offsets), top + rings[index].dz]; + }; + for (let i = 0; i < rings.length - 1; i += 1) { + const wa = window(i); + const wb = window(i + 1); + if (!wa || !wb) continue; + const a = rings[i]; + const b = rings[i + 1]; + const gap = Math.hypot(b.cx - a.cx, b.cy - a.cy); + if (gap < 1e-6) continue; + const corners = [a, b].flatMap((frame, k) => { + const w = k ? wb : wa; + return [w[0] - DRAPE_BAND_M, w[1] + DRAPE_BAND_M].map((o) => [ + frame.cx + frame.leftX * o, + frame.cy + frame.leftY * o, + ]); + }); + const xs = corners.map((p) => p[0]); + const ys = corners.map((p) => p[1]); + spans.push({ + a, + b, + wa, + wb, + before: i === 0 ? DRAPE_BAND_M / gap : 0, + after: i === rings.length - 2 ? DRAPE_BAND_M / gap : 0, + box: [ + Math.min(...xs) - DRAPE_BAND_M, + Math.max(...xs) + DRAPE_BAND_M, + Math.min(...ys) - DRAPE_BAND_M, + Math.max(...ys) + DRAPE_BAND_M, + ], + }); + } + } + // 칸 색인 — 정점마다 마디 전부를 훑지 않는다. + const CELL = 4; + const cells = new Map(); + for (const span of spans) { + const [x0, x1, y0, y1] = span.box; + for (let c = Math.floor(x0 / CELL); c <= Math.floor(x1 / CELL); c += 1) { + for (let g = Math.floor(y0 / CELL); g <= Math.floor(y1 / CELL); g += 1) { + const key = `${c},${g}`; + const list = cells.get(key); + if (list) list.push(span); + else cells.set(key, [span]); + } + } + } + return (x, y) => { + let best: number | null = null; + for (const span of cells.get(`${Math.floor(x / CELL)},${Math.floor(y / CELL)}`) ?? []) { + const [x0, x1, y0, y1] = span.box; + if (x < x0 || x > x1 || y < y0 || y > y1) continue; + const { a, b, wa, wb } = span; + const along = (f: StructureFrame): number => (x - f.cx) * f.leftY - (y - f.cy) * f.leftX; + const a0 = along(a); + const a1 = along(b); + if (Math.abs(a0 - a1) < 1e-12) continue; + const t = a0 / (a0 - a1); + if (t < -span.before || t > 1 + span.after) continue; + const k = Math.min(1, Math.max(0, t)); + const oa = (x - a.cx) * a.leftX + (y - a.cy) * a.leftY; + const ob = (x - b.cx) * b.leftX + (y - b.cy) * b.leftY; + const o = oa + (ob - oa) * k; + const lo = wa[0] + (wb[0] - wa[0]) * k - DRAPE_BAND_M; + const hi = wa[1] + (wb[1] - wa[1]) * k + DRAPE_BAND_M; + if (o < lo || o > hi) continue; + const top = wa[2] + (wb[2] - wa[2]) * k; + if (best === null || top > best) best = top; + } + return best; + }; +} + +/** + * 관 입구 구멍(115-5 P1-4) — 관 끝에서 안쪽으로 관 머리가 변형 지형 위로 드러난 만큼의 + * 관경 폭 사각 고리(모델 좌표). 묻힌 끝은 구멍 없음. + */ +export function pipeMouthLoops( + structures: ReadonlyArray, + groundAfter: (x: number, y: number) => number | null, +): Array> { + const loops: Array> = []; + for (const structure of structures) { + const { pipe, frame } = structure; + if (structure.kind !== "pipe" || !pipe || !frame) continue; + const length = Math.abs(pipe.end[0] - pipe.start[0]); + if (length < 0.2) continue; + const half = pipe.diameterM / 2; + const plan = (o: number, lateral: number): [number, number] => [ + frame.cx + frame.leftX * o + frame.leftY * lateral, + frame.cy + frame.leftY * o - frame.leftX * lateral, + ]; + const crownAt = (o: number): number => { + const t = (o - pipe.start[0]) / (pipe.end[0] - pipe.start[0]); + return pipe.start[1] + (pipe.end[1] - pipe.start[1]) * t + pipe.diameterM; + }; + for (const [from, to] of [ + [pipe.start[0], pipe.end[0]], + [pipe.end[0], pipe.start[0]], + ]) { + const dir = Math.sign(to - from); + let reach = 0; + for (let d = 0; d <= Math.min(MOUTH_REACH_M, length / 2) + 1e-9; d += MOUTH_STEP_M) { + const o = from + dir * d; + const [x, y] = plan(o, 0); + const ground = groundAfter(x, y); + if (ground === null || crownAt(o) <= ground) break; + reach = d; + } + if (reach < MOUTH_STEP_M) continue; + const inner = from + dir * reach; + loops.push([ + plan(from, -half), + plan(inner, -half), + plan(inner, half), + plan(from, half), + plan(from, -half), + ]); + } + } + return loops; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Deform_Terrain.ts b/B05_Profile/B05_Profile_UI_Corridor_Deform_Terrain.ts index b1cda72cf..4ff87d5d8 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Deform_Terrain.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Deform_Terrain.ts @@ -1,12 +1,14 @@ /* ============================================================================= * B05_Profile_UI_Corridor_Deform_Terrain.ts - * 새 방식 지형 — 밴드 사본을 노면부 바깥선에서 자르고(`clipGeometry`) 사본 정점의 - * 높이만 변형 장(`_Corridor_Deform.ts`)대로 옮긴다(계획서 115-1). + * 새 방식 지형 — 밴드 사본에 브레이크라인(노면 끝 · 비탈 끝 · 단면 꺾임 · 구조물 윗면 + * 외곽 · 관 입구)을 끼워 다시 삼각분할하고(`_Deform_Breakline.ts`) 사본 정점의 높이만 + * 변형 장(`_Corridor_Deform.ts`)대로 옮긴다(계획서 115-1 · 115-5). * - * 원본 · 분할본 정점 버퍼는 건드리지 않는다 — `clipGeometry` 가 새 위치 배열을 만들고, - * 바뀐 것이 없을 땐 여기서 복제한다. [예상형상] OFF 면 뷰어가 원본을 그대로 보인다. - * 법선은 새로 만들지 않는다(감기 방향 뒤집혀 검게 된 전례 · `_Clip.ts`). - * 색 = 높이 변화 부호 — 절토 · 성토 색(코리도 리본과 같은 색), 빗금 없음. + * 원본 · 분할본 정점 버퍼는 건드리지 않는다 — 새 위치 배열을 만든다. [예상형상] OFF 면 + * 뷰어가 원본을 그대로 보인다. 법선은 새로 만들지 않는다(`_Clip.ts`). + * 색 = 자리(비탈 끝 안 성토 · 절토) — 면 단위 · 경계 = 비탈 끝 선 · 빗금 없음. + * 구조물 둘레는 솔리드 윗면 아래로 드레이프(115-5 P1-4). 손대는 자리는 행별 비탈 끝 + 5 m + * 띠(P2-5) — 그 밖 지형은 1 mm 도 안 움직인다. * ========================================================================== */ import * as THREE from "three"; @@ -14,13 +16,7 @@ import type { ModelBounds } from "./B05_Profile_UI_Markers"; import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build_Types"; import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split"; import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; -import { clipGeometry } from "./B05_Profile_UI_Corridor_Clip"; -import { - CellRegion, - PlanLoopRegion, - scenePlanMapper, - type PlanRegion, -} from "./B05_Profile_UI_Corridor_Region"; +import { CellRegion, scenePlanMapper, type P2 } from "./B05_Profile_UI_Corridor_Region"; import { buildDeformField, stationProfileSource, @@ -29,15 +25,25 @@ import { } from "./B05_Profile_UI_Corridor_Deform"; import { noFillLimits, + pipeMouthLoops, structureHoleLoops, structureSlopes, + structureTopAt, } from "./B05_Profile_UI_Corridor_Deform_Structure"; +import { + retriangulateBand, + type BandStats, + type Breakline, +} from "./B05_Profile_UI_Corridor_Deform_Breakline"; /** 절토 · 성토 색 — 코리도 리본 색(`_Mesh.ts` KIND_COLORS)과 같은 값. */ const CUT_COLOR = new THREE.Color(0xb5179e); const FILL_COLOR = new THREE.Color(0x06b6d4); -/** 이보다 작게 움직인 정점은 원지반 색 그대로(m). */ -const TINT_MIN_M = 0.01; +/** 손대는 띠 — 비탈 끝 밖 여유(m) · 칸(m). */ +const BAND_PAD_M = 5; +const MASK_CELL_M = 2; +/** 비탈 끝 · 꺾임 선으로 세울 최소 거리(m) — 앵커 · 비탈 끝에 겹친 선은 세우지 않는다. */ +const LINE_MIN_M = 0.05; /** 노면부 띠 — 행마다 좌우 앵커 사이 사각형. 지형은 이 안을 도려낸다. */ class RoadStrip extends CellRegion { @@ -58,62 +64,140 @@ class RoadStrip extends CellRegion { } /** 지형 변형 요약 — 화면 밖 수치 확인용(`__corridorDeform`). */ -export interface DeformStats { +export interface DeformStats extends BandStats { rows: number; holes: number; - moved: number; - raised: number; - lowered: number; - maxRaiseM: number; - maxCutM: number; + lines: number; } -/** - * 사본 geometry 정점 높이를 옮기고 색을 칠한다. 씬 좌표(Y-up) ↔ 모델 좌표는 뷰어 규약. - * 칠한 정점은 `userData.keepColor` 에 표시해 흑백 지형 토글이 덮지 않게 한다. - */ -function deformGeometry( - geometry: THREE.BufferGeometry, +/** 브레이크라인 — 노면 끝(앵커 · 마구리) · 행별 비탈 끝 · 단면 꺾임(씬 평면). */ +export function deformBreaklines( field: DeformField, - bounds: ModelBounds, - stats: DeformStats, -): Uint8Array { - const ox = (bounds.x[0] + bounds.x[1]) / 2; - const oy = (bounds.y[0] + bounds.y[1]) / 2; - const oz = (bounds.z[0] + bounds.z[1]) / 2; - const position = geometry.getAttribute("position") as THREE.BufferAttribute; - const color = geometry.getAttribute("color") as THREE.BufferAttribute | undefined; - const keep = new Uint8Array(position.count); - for (let v = 0; v < position.count; v += 1) { - const sceneY = position.getY(v); - const z = sceneY + oz; - const next = field.deformZ(position.getX(v) + ox, -position.getZ(v) + oy, z); - const delta = next - z; - if (Math.abs(delta) < TINT_MIN_M) continue; - position.setY(v, next - oz); - stats.moved += 1; - if (delta > 0) { - stats.raised += 1; - stats.maxRaiseM = Math.max(stats.maxRaiseM, delta); - } else { - stats.lowered += 1; - stats.maxCutM = Math.max(stats.maxCutM, -delta); - } - if (color) { - const tint = delta > 0 ? FILL_COLOR : CUT_COLOR; - color.setXYZ(v, tint.r, tint.g, tint.b); - keep[v] = 1; + toScene: (x: number, y: number) => P2, +): Breakline[] { + const rows = field.rows; + const n = rows.length; + const lines: Breakline[] = [ + rows.map((row) => toScene(row.left[0], row.left[1])), + rows.map((row) => toScene(row.right[0], row.right[1])), + [toScene(rows[0].left[0], rows[0].left[1]), toScene(rows[0].right[0], rows[0].right[1])], + [ + toScene(rows[n - 1].left[0], rows[n - 1].left[1]), + toScene(rows[n - 1].right[0], rows[n - 1].right[1]), + ], + ]; + const at = (r: number, si: number, s: number): P2 => toScene(...field.pointAt(r, si, s)); + for (let si = 0; si < 2; si += 1) { + for (let ki = 0; ki < 2; ki += 1) { + let run: P2[] = []; + const runs = new Map(); + const flush = (line: P2[]): void => { + if (line.length >= 2) lines.push(line); + }; + let start = -1; + for (let r = 0; r < n; r += 1) { + const reach = field.catchAt(r, si, ki); + if (reach > LINE_MIN_M) { + if (!run.length) start = r; + run.push(at(r, si, reach)); + } + // 비탈이 시작 · 끝나는 행 — 앵커 → 비탈 끝 마구리 선(색 경계 · 끝 단차). + const ends = run.length && (reach <= LINE_MIN_M || r === n - 1); + if (ends) { + const last = reach > LINE_MIN_M ? r : r - 1; + for (const k of new Set([start, last])) { + lines.push([at(k, si, 0), at(k, si, field.catchAt(k, si, ki))]); + } + } + if (reach <= LINE_MIN_M) { + flush(run); + run = []; + } + const cols = new Set(); + for (const [col, s] of field.bendsAt(r, si, ki)) { + if (s < LINE_MIN_M || s > reach - LINE_MIN_M) continue; + cols.add(col); + const line = runs.get(col) ?? []; + line.push(at(r, si, s)); + runs.set(col, line); + } + runs.forEach((line, col) => { + if (cols.has(col)) return; + flush(line); + runs.delete(col); + }); + } + flush(run); + runs.forEach(flush); } } - position.needsUpdate = true; - if (color) color.needsUpdate = true; - geometry.computeBoundingSphere(); - geometry.computeBoundingBox(); - return keep; + return lines; +} + +/** 손대는 자리 — 행마다 노면부 + 좌우 비탈 끝 + 5 m 띠, 구멍 상자 둘레(P2-5). */ +function bandMask( + field: DeformField, + toScene: (x: number, y: number) => P2, + holes: ReadonlyArray, +): (p: P2) => boolean { + const marks: P2[] = []; + field.rows.forEach((row, r) => { + const width = Math.hypot(row.left[0] - row.right[0], row.left[1] - row.right[1]); + const reach = (si: number): number => + Math.max(field.catchAt(r, si, 0), field.catchAt(r, si, 1)) + BAND_PAD_M; + for (let s = -width; s <= reach(0); s += 1) marks.push(toScene(...field.pointAt(r, 0, s))); + for (let s = 0; s <= reach(1); s += 1) marks.push(toScene(...field.pointAt(r, 1, s))); + }); + holes.forEach((loop) => { + const xs = loop.map((p) => p.x); + const zs = loop.map((p) => p.z); + for (let x = Math.min(...xs) - 1.5; x <= Math.max(...xs) + 1.5; x += 1) { + for (let z = Math.min(...zs) - 1.5; z <= Math.max(...zs) + 1.5; z += 1) marks.push({ x, z }); + } + }); + if (!marks.length) return () => false; + let minX = Infinity; + let minZ = Infinity; + let maxX = -Infinity; + let maxZ = -Infinity; + for (const p of marks) { + minX = Math.min(minX, p.x); + minZ = Math.min(minZ, p.z); + maxX = Math.max(maxX, p.x); + maxZ = Math.max(maxZ, p.z); + } + minX -= MASK_CELL_M * 2; + minZ -= MASK_CELL_M * 2; + const cols = Math.ceil((maxX - minX) / MASK_CELL_M) + 3; + const lines = Math.ceil((maxZ - minZ) / MASK_CELL_M) + 3; + const mask = new Uint8Array(cols * lines); + for (const p of marks) { + const c = Math.floor((p.x - minX) / MASK_CELL_M); + const g = Math.floor((p.z - minZ) / MASK_CELL_M); + for (let dg = -1; dg <= 1; dg += 1) { + for (let dc = -1; dc <= 1; dc += 1) mask[(g + dg) * cols + c + dc] = 1; + } + } + return (p) => { + const c = Math.floor((p.x - minX) / MASK_CELL_M); + const g = Math.floor((p.z - minZ) / MASK_CELL_M); + return c >= 0 && g >= 0 && c < cols && g < lines && mask[g * cols + c] === 1; + }; +} + +function insideLoop(loop: ReadonlyArray, p: P2): boolean { + let hit = false; + for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) { + const a = loop[i]; + const b = loop[j]; + if (a.z > p.z !== b.z > p.z && p.x < ((b.x - a.x) * (p.z - a.z)) / (b.z - a.z) + a.x) + hit = !hit; + } + return hit; } /** - * 새 방식 지형 — 원거리 조각은 그대로, 밴드 조각만 잘라 변형한다. 변형 장을 못 세우면 + * 새 방식 지형 — 원거리 조각은 그대로, 밴드 조각만 다시 나눠 변형한다. 변형 장을 못 세우면 * (노면부 행 부족 · 측점 단면 없음) null — 부르는 쪽이 옛 방식으로 그린다. */ export function deformTerrain( @@ -136,19 +220,69 @@ export function deformTerrain( const source = stationProfileSource(sections, structureSlopes(sections)); const field = buildDeformField(build, source, groundAt, noFillLimits(sections, structures)); if (!field) return null; - // 지형 구멍 = 노면부 띠 + 구조물 풋프린트 · BOX 아령 외곽 — 솔리드가 그 자리를 메운다. - const regions: PlanRegion[] = [new RoadStrip(field.rows, bounds)]; - const holes = new PlanLoopRegion(structureHoleLoops(sections, structures), bounds); - if (!holes.isEmpty) regions.push(holes); + const toScene = scenePlanMapper(bounds); + // 지형 구멍 = 노면부 띠 + 구조물 윗면 외곽 · BOX 아령 외곽 + 관 입구 — 솔리드가 메운다. + const road = new RoadStrip(field.rows, bounds); + const groundAfter = (x: number, y: number): number | null => { + const ground = groundAt(x, y); + return ground === null ? null : field.deformZ(x, y, ground); + }; + const holes = [ + ...structureHoleLoops(sections, structures), + ...pipeMouthLoops(structures, groundAfter), + ] + .map((loop) => loop.map(([x, y]) => toScene(x, y))) + .filter((loop) => loop.length >= 3); + const closed = holes.map((loop) => { + const [first] = loop; + const last = loop[loop.length - 1]; + return first.x === last.x && first.z === last.z ? loop : [...loop, first]; + }); + const lines = [...deformBreaklines(field, toScene), ...closed]; + const boxes = holes.map((loop) => { + const xs = loop.map((p) => p.x); + const zs = loop.map((p) => p.z); + return [Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)]; + }); + const topAt = structureTopAt(structures); const stats: DeformStats = { rows: field.rows.length, - holes: regions.length - 1, + holes: holes.length, + lines: lines.length, + split: 0, + fallback: 0, moved: 0, raised: 0, lowered: 0, maxRaiseM: 0, maxCutM: 0, }; + const context = { + lines, + removed: (p: P2): boolean => + road.contains(p) || + holes.some( + (loop, i) => + p.x >= boxes[i][0] && + p.x <= boxes[i][1] && + p.z >= boxes[i][2] && + p.z <= boxes[i][3] && + insideLoop(loop, p), + ), + touched: bandMask(field, toScene, holes), + deform: (x: number, y: number, z: number) => { + const mx = x + ox; + const my = -z + oy; + const at = field.deformAt(mx, my, y + oz); + const top = topAt(mx, my); + const out = top !== null && at.z > top ? top : at.z; + return { y: out - oz, zone: at.zone }; + }, + tint: (zone: 1 | 2): [number, number, number] => { + const color = zone === 1 ? FILL_COLOR : CUT_COLOR; + return [color.r, color.g, color.b]; + }, + }; const root = new THREE.Group(); root.name = "terrain-deformed"; split.near.forEach((part) => { @@ -159,10 +293,19 @@ export function deformTerrain( root.add(points); return; } - const geometry = clipGeometry(part.geometry, regions) ?? part.geometry.clone(); + const { geometry, keep } = retriangulateBand(part.geometry, context, stats); + const normal = part.geometry.getAttribute("normal") as THREE.BufferAttribute | undefined; + if (normal) { + // 법선은 원본 것만 물려받는다 — 새 정점은 위 방향(`_Clip.ts` 와 같은 규칙). + const count = geometry.getAttribute("position").count; + const normals = new Float32Array(count * 3); + normals.set((normal.array as Float32Array).subarray(0, normal.count * 3), 0); + for (let v = normal.count; v < count; v += 1) normals[v * 3 + 1] = 1; + geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3)); + } const mesh = new THREE.Mesh(geometry, part.material); mesh.userData.owned = true; - mesh.userData.keepColor = deformGeometry(geometry, field, bounds, stats); + mesh.userData.keepColor = keep; root.add(mesh); }); split.far.forEach((part) => { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Modes.ts b/B05_Profile/B05_Profile_UI_Corridor_Modes.ts new file mode 100644 index 000000000..fd91d5a7c --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Modes.ts @@ -0,0 +1,191 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Modes.ts + * 3D 예상형상 **방식 등록부**(계획서 115-5) — 3D 보기 줄 「3D 방식: 지금 · 옛 A · 새 ① · + * 제안 ②」 고르기 한 줄이 이 표를 본다. + * + * 방식 하나 = 이름 + 만들기 함수 둘 — `corridor`(코리도 그룹에 그릴 빌드) · `terrain`(지형 + * · 없으면 그 빌드로 띠만 자른다). 제안 ② 는 다른 창이 새 파일을 만들고 아래 + * `CORRIDOR_MODES` 에 한 줄 등록한다. 등록이 없으면 그 단추는 꺼진다. + * 설정 키는 `corridor-mode` 하나 — 옛 두 단추(115-1 `corridor-deform` · 115-4 + * `corridor-legacy`) 값은 읽어서 옮긴다. 기본 = 새 ①. + * ========================================================================== */ + +import type * as THREE from "three"; +import type { ModelBounds } from "./B05_Profile_UI_Markers"; +import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; +import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; +import type { TerrainBandSplit } from "./B05_Profile_UI_Corridor_Split"; +import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt"; +import { deformTerrain } from "./B05_Profile_UI_Corridor_Deform_Terrain"; +import { legacyCorridorOf, legacyReady } from "./B05_Profile_UI_Corridor_Legacy"; +import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; + +/** 고르기 한 줄의 자리 — 순서 · 이름. 등록이 없는 자리는 단추가 꺼진다. */ +export const MODE_SLOTS = [ + ["current", "지금"], + ["legacy-a", "옛 A"], + ["new1", "새 ①"], + ["proposal2", "제안 ②"], +] as const; + +export type CorridorModeId = (typeof MODE_SLOTS)[number][0]; + +export const DEFAULT_MODE: CorridorModeId = "new1"; + +/** 만들기 함수가 받는 것 — 모델 좌표 범위와 지형 높이 색인(없으면 null). */ +export interface CorridorModeContext { + bounds: ModelBounds; + heightIndex: TerrainHeightIndex | null; +} + +export interface CorridorModeTerrainInput extends CorridorModeContext { + /** 본체 빌드(종횡단 정본 그대로 · 읽기만). */ + build: CorridorBuildResult; + /** 이 방식의 `corridor` 가 낸 빌드. */ + corridor: CorridorBuildResult; + /** 지형 밴드 분할본(near/far) — 읽기만 · 사본을 만들어 쓴다. */ + split: TerrainBandSplit; +} + +export interface CorridorMode { + id: CorridorModeId; + /** 처음 만들기가 무거운가 — 참이면 알림을 먼저 띄우고 다음 틈에 만든다. */ + slow?: (build: CorridorBuildResult) => boolean; + /** 코리도 그룹에 그릴 빌드 — null 이면 기본 방식으로 그린다. */ + corridor: (build: CorridorBuildResult, ctx: CorridorModeContext) => CorridorBuildResult | null; + /** 지형 — 없거나 null 이면 `corridor` 빌드로 띠만 자른다(`clipTerrain`). */ + terrain?: (input: CorridorModeTerrainInput) => { root: THREE.Object3D; stats?: unknown } | null; +} + +/** 모델 좌표 원지반 높이 — 색인이 없으면 null. */ +function groundOf(ctx: CorridorModeContext): ((x: number, y: number) => number | null) | null { + const index = ctx.heightIndex; + if (!index) return null; + const ox = (ctx.bounds.x[0] + ctx.bounds.x[1]) / 2; + const oy = (ctx.bounds.y[0] + ctx.bounds.y[1]) / 2; + const oz = (ctx.bounds.z[0] + ctx.bounds.z[1]) / 2; + return (x, y) => { + const height = index.heightAt(x - ox, -(y - oy)); + return height === null ? null : height + oz; + }; +} + +/** + * 비탈 최외곽(catch) 정점 z를 지형에 투영 — 샘플러·preview 메쉬 간 심 틈 방지. + * 조회는 격자 색인(TerrainHeightIndex)으로 한다 — Raycaster는 삼각형을 전부 + * 훑어 편집마다 초 단위로 멎었다(2026-08-23 실측 3.6초 단일 블록). + */ +function snapCorridorEdges(build: CorridorBuildResult, ctx: CorridorModeContext): void { + const groundAt = groundOf(ctx); + if (!groundAt) return; + build.ribbons.forEach((ribbon) => { + // 측구는 제외 — 바깥 상단은 노견과 같은 Z가 규칙(2026-08-23 사용자 지시). + // 지형 스냅하면 측구 바깥벽이 원지반까지 끌려 올라가 U형이 깨진다. + if (ribbon.kind !== "cut" && ribbon.kind !== "fill") return; + // 구조물 구간 패치도 제외(2026-08-27) — B06 성토 구간은 벽 뒷면이나 구체 + // 최상단에서 **끝나는 게 정상**이라 바깥 끝이 지반이 아니다. 스냅하면 그 + // 자리가 원지반까지 끌려 내려가 세로 지느러미가 생긴다(실측 4.1m). + if (ribbon.patch) return; + const cols = ribbon.colCount; + const outerCol = ribbon.side === "right" ? 0 : cols - 1; + const innerCol = ribbon.side === "right" ? cols - 1 : 0; + for (let row = 0; row < ribbon.chainages.length; row += 1) { + const outer = (row * cols + outerCol) * 3; + const inner = (row * cols + innerCol) * 3; + // 축퇴 구간(전이부, 폭≈0)은 그대로 둔다 — 노견 끝을 지형에 끌어붙이지 않는다. + const width = Math.hypot( + ribbon.positions[outer] - ribbon.positions[inner], + ribbon.positions[outer + 1] - ribbon.positions[inner + 1], + ); + if (width < 1e-4) continue; + const height = groundAt(ribbon.positions[outer], ribbon.positions[outer + 1]); + if (height !== null) ribbon.positions[outer + 2] = height; + } + }); +} + +/** + * 패치(변형 성토면) 바깥 끝 스커트를 붙인다 — 지형 메시 색인이 있어야 밑선을 + * 읽으므로 뷰어에서 뒤늦게 만든다(2026-08-27 사용자). 붙인 판은 절취 측벽과 같은 + * `cutWalls` 경로로 그려진다. 다시 붙일 땐 옛 스커트를 먼저 걷어낸다. + */ +function attachPatchSkirts(build: CorridorBuildResult, ctx: CorridorModeContext): void { + const groundAt = groundOf(ctx); + if (!groundAt) return; + const { walls, stats } = buildPatchSkirts(build.ribbons, groundAt); + const kept = (build.cutWalls ?? []).filter((wall) => !wall.patchSkirt); + build.cutWalls = [...kept, ...walls]; + (window as unknown as { __corridorSkirt?: unknown }).__corridorSkirt = stats; +} + +/** 새 방식이 그리는 코리도 — 노면부 리본 · 마구리 · 구조물만(비탈은 지형 변형 몫). */ +function roadOnly(build: CorridorBuildResult): CorridorBuildResult { + return { + ...build, + ribbons: build.ribbons.filter((ribbon) => ribbon.kind !== "cut" && ribbon.kind !== "fill"), + planCurves: [], + cutWalls: [], + }; +} + +/** 방식 등록부 — 제안 ② 는 아래 주석 자리에 한 줄. */ +export const CORRIDOR_MODES: CorridorMode[] = [ + // 지금 — 비탈 리본 · 스케치 · 패치 · 지형은 띠만 자름(115-1 전 방식). + { + id: "current", + corridor: (build, ctx) => { + snapCorridorEdges(build, ctx); + attachPatchSkirts(build, ctx); + return build; + }, + }, + // 옛 A(115-4) — 08-25 실루엣 갈아 끼우기 · 저장 안 함(브라우저 즉석 · 해시별 메모). + { + id: "legacy-a", + slow: (build) => !legacyReady(build), + corridor: (build, ctx) => { + const legacy = legacyCorridorOf(build); + if (legacy) snapCorridorEdges(legacy, ctx); + return legacy; + }, + }, + // 새 ①(115-1 · 115-5) — 노면부만 그리고 비탈은 지형 메시 변형 + 브레이크라인. + { + id: "new1", + corridor: (build) => roadOnly(build), + terrain: ({ split, build, bounds, heightIndex }) => + heightIndex ? deformTerrain(split, build, bounds, heightIndex) : null, + }, + // 제안 ② — 다른 창이 여기 한 줄: { id: "proposal2", corridor: …, terrain: … }, +]; + +export function modeOf(id: CorridorModeId): CorridorMode | null { + return CORRIDOR_MODES.find((mode) => mode.id === id) ?? null; +} + +const listeners = new Set<(id: CorridorModeId) => void>(); + +/** 고른 방식 — 저장값이 없으면 옛 두 단추 값을 옮겨 읽는다. */ +export function corridorModeId(): CorridorModeId { + const saved = readStateRaw("corridor-mode"); + const known = MODE_SLOTS.find(([id]) => id === saved); + if (known) return known[0]; + if (readStateRaw("corridor-legacy") === "1") return "legacy-a"; + if (readStateRaw("corridor-deform") === "0") return "current"; + return DEFAULT_MODE; +} + +/** 실제로 그릴 방식 — 고른 자리가 비었으면(제안 ② 미등록) 기본. */ +export function activeCorridorMode(): CorridorMode { + return modeOf(corridorModeId()) ?? (modeOf(DEFAULT_MODE) as CorridorMode); +} + +export function setCorridorMode(id: CorridorModeId): void { + writeStateRaw("corridor-mode", id); + listeners.forEach((listener) => listener(id)); +} + +export function onCorridorMode(listener: (id: CorridorModeId) => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} diff --git a/B05_Profile/B05_Profile_UI_Panel_View.ts b/B05_Profile/B05_Profile_UI_Panel_View.ts index bba85e054..9e1ceec70 100644 --- a/B05_Profile/B05_Profile_UI_Panel_View.ts +++ b/B05_Profile/B05_Profile_UI_Panel_View.ts @@ -6,11 +6,12 @@ import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields"; import { - corridorDeformOn, - corridorLegacyOn, - setCorridorDeform, - setCorridorLegacy, -} from "./B05_Profile_UI_Viewer_Corridor"; + corridorModeId, + MODE_SLOTS, + modeOf, + onCorridorMode, + setCorridorMode, +} from "./B05_Profile_UI_Corridor_Modes"; /** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */ export interface ViewControlCallbacks { @@ -62,16 +63,34 @@ export function createViewControls(callbacks: ViewControlCallbacks): { return { kind, element }; }); viewButtons.append(...projectionButtons.map(({ element }) => element)); + // 3D 방식 고르기 한 줄(115-5) — 원근 · 직교와 같은 꼴. 등록 안 된 자리(제안 ②)는 꺼짐. + const modeButtons = document.createElement("div"); + modeButtons.className = "b05-route__view-group"; + const modeLabel = document.createElement("span"); + modeLabel.className = "b05-route__view-label"; + modeLabel.textContent = "3D 방식"; + const modeChoices = MODE_SLOTS.map(([id, label]) => { + const element = button(label, () => setCorridorMode(id), "glass"); + element.disabled = !modeOf(id); + if (element.disabled) element.title = "아직 없는 방식입니다."; + return { id, element }; + }); + const showMode = (): void => { + const current = corridorModeId(); + modeChoices.forEach(({ id, element }) => { + element.classList.toggle("is-active", id === current); + element.setAttribute("aria-pressed", String(id === current)); + }); + }; + showMode(); + onCorridorMode(showMode); + modeButtons.append(modeLabel, ...modeChoices.map(({ element }) => element)); const visibilityButtons = document.createElement("div"); visibilityButtons.className = "b05-route__view-group"; visibilityButtons.append( toggleButton("지표면", true, callbacks.onSurfaceVisible), // 예상형상(2026-08-23): ON=공사 후 형상(기본), OFF=원지반 완전체. toggleButton("예상형상", true, callbacks.onCorridorVisible), - // 예상형상 그리기(115-1) — 켬 = 새 방식(지형 메시 변형) · 끔 = 옛 방식(스케치 · 패치). - toggleButton("새 방식", corridorDeformOn(), setCorridorDeform), - // 옛 방식 A(115-4) — 08-25 실루엣 갈아 끼우기 · 켜면 새 방식 단추와 상관없이 A 형상. - toggleButton("옛 방식 A(실루엣)", corridorLegacyOn(), setCorridorLegacy), toggleButton("등고선", true, callbacks.onContoursVisible), toggleButton("축 표시", false, callbacks.onAxesVisible), toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible), @@ -102,9 +121,12 @@ export function createViewControls(callbacks: ViewControlCallbacks): { separator1.className = "b05-route__view-separator"; const separator2 = separator1.cloneNode() as HTMLSpanElement; const separator3 = separator1.cloneNode() as HTMLSpanElement; + const separator4 = separator1.cloneNode() as HTMLSpanElement; viewControls.append( viewButtons, separator1, + modeButtons, + separator4, visibilityButtons, separator2, button("뷰 초기화", callbacks.onResetView, "glass"), diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index 4ae803d58..3c085f575 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -205,6 +205,14 @@ gap: var(--spacing-4); } +/* 3D 방식 고르기 한 줄 머리(115-5). */ +.b05-route__view-label { + align-self: center; + font-size: var(--font-size-12, 12px); + color: var(--color-text-secondary, inherit); + white-space: nowrap; +} + .b05-route__view-controls .ui-btn { height: 34px; padding: 0 var(--spacing-8); diff --git a/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts b/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts index 355481255..a4ae28898 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Corridor.ts @@ -17,53 +17,17 @@ import { import { clipTerrain } from "./B05_Profile_UI_Corridor_Clip"; import { setCorridorBuildSummary } from "./B05_Profile_UI_Viewer_Debug"; import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain"; -import { buildPatchSkirts } from "./B05_Profile_UI_Corridor_Skirt"; import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split"; -import { deformTerrain } from "./B05_Profile_UI_Corridor_Deform_Terrain"; -import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; -import { legacyCorridorOf, legacyReady } from "./B05_Profile_UI_Corridor_Legacy"; +import { + activeCorridorMode, + DEFAULT_MODE, + MODE_SLOTS, + modeOf, + onCorridorMode, + type CorridorMode, +} from "./B05_Profile_UI_Corridor_Modes"; import { showToast } from "@ui/ui_template_elements"; -/** - * 3D 예상형상 그리기 방식(115-1) — 새 방식(지형 메시 변형 · 기본) / 옛 방식(스케치 · 패치). - * 사용자가 두 화면을 견주는 동안만 둔다(115-3 에서 옛 방식과 함께 지운다). - */ -const deformListeners = new Set<(on: boolean) => void>(); - -export function corridorDeformOn(): boolean { - return readStateRaw("corridor-deform") !== "0"; -} - -export function setCorridorDeform(on: boolean): void { - writeStateRaw("corridor-deform", on ? "1" : "0"); - deformListeners.forEach((listener) => listener(on)); -} - -/** - * 옛 방식 A(115-4 · 08-25 실루엣 갈아 끼우기) — 켜면 「새 방식」 단추와 상관없이 A 형상. - * 끄면 지금처럼(새 방식 켬/끔). 비교용 — 저장하지 않는다. - */ -const legacyListeners = new Set<(on: boolean) => void>(); - -export function corridorLegacyOn(): boolean { - return readStateRaw("corridor-legacy") === "1"; -} - -export function setCorridorLegacy(on: boolean): void { - writeStateRaw("corridor-legacy", on ? "1" : "0"); - legacyListeners.forEach((listener) => listener(on)); -} - -/** 새 방식이 그리는 코리도 — 노면부 리본 · 마구리 · 구조물만(비탈은 지형 변형 몫). */ -function roadOnly(build: CorridorBuildResult): CorridorBuildResult { - return { - ...build, - ribbons: build.ribbons.filter((ribbon) => ribbon.kind !== "cut" && ribbon.kind !== "fill"), - planCurves: [], - cutWalls: [], - }; -} - /** 서피스 삼각형 수 — 클리핑이 실제로 걷어냈는지 확인하는 계측용. */ function countTriangles(root: THREE.Object3D | null): number { let total = 0; @@ -148,20 +112,12 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor { let bandSplit: TerrainBandSplit | null = null; let corridorOn = true; // [예상형상] 기본 ON — 계획서피스가 보이는 게 기본값. let surfaceOn = true; // 기존 [지표면] 토글 상태(코리도 스왑과 조합). - let deformOn = corridorDeformOn(); - const onDeform = (on: boolean): void => { - deformOn = on; + /** 지금 그리는 방식(115-5 고르기 한 줄)과 그 방식이 낸 코리도 빌드. */ + let mode: CorridorMode = activeCorridorMode(); + let drawnBuild: CorridorBuildResult | null = null; + const offMode = onCorridorMode(() => { if (corridorBuild) set(corridorBuild); - }; - deformListeners.add(onDeform); - let legacyOn = corridorLegacyOn(); - /** 지금 그리고 있는 옛 방식 A 빌드 — 본체 빌드와 따로 둔 객체(심 보정이 제자리 수정). */ - let legacyBuild: CorridorBuildResult | null = null; - const onLegacy = (on: boolean): void => { - legacyOn = on; - if (corridorBuild) set(corridorBuild); - }; - legacyListeners.add(onLegacy); + }); /** 표시 상태 일괄 적용 — 클리핑본이 준비되기 전에는 원본 지형을 그대로 둔다. */ function applyCorridorVisibility(): void { @@ -230,70 +186,6 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor { corridorGroup = null; } - /** - * 비탈 최외곽(catch) 정점 z를 지형에 투영 — 샘플러·preview 메쉬 간 심 틈 방지. - * 조회는 격자 색인(TerrainHeightIndex)으로 한다 — Raycaster는 삼각형을 전부 - * 훑어 편집마다 초 단위로 멎었다(2026-08-23 실측 3.6초 단일 블록). - */ - function snapCorridorEdges(build: CorridorBuildResult): void { - const bounds = deps.bounds(); - if (!bounds) return; - const index = deps.heightIndex(); - if (!index) return; - const ox = (bounds.x[0] + bounds.x[1]) / 2; - const oy = (bounds.y[0] + bounds.y[1]) / 2; - const oz = (bounds.z[0] + bounds.z[1]) / 2; - build.ribbons.forEach((ribbon) => { - // 측구는 제외 — 바깥 상단은 노견과 같은 Z가 규칙(2026-08-23 사용자 지시). - // 지형 스냅하면 측구 바깥벽이 원지반까지 끌려 올라가 U형이 깨진다. - if (ribbon.kind !== "cut" && ribbon.kind !== "fill") return; - // 구조물 구간 패치도 제외(2026-08-27) — B06 성토 구간은 벽 뒷면이나 구체 - // 최상단에서 **끝나는 게 정상**이라 바깥 끝이 지반이 아니다. 스냅하면 그 - // 자리가 원지반까지 끌려 내려가 세로 지느러미가 생긴다(실측 4.1m). - if (ribbon.patch) return; - const cols = ribbon.colCount; - const outerCol = ribbon.side === "right" ? 0 : cols - 1; - const innerCol = ribbon.side === "right" ? cols - 1 : 0; - for (let row = 0; row < ribbon.chainages.length; row += 1) { - const outer = (row * cols + outerCol) * 3; - const inner = (row * cols + innerCol) * 3; - // 축퇴 구간(전이부, 폭≈0)은 그대로 둔다 — 노견 끝을 지형에 끌어붙이지 않는다. - const width = Math.hypot( - ribbon.positions[outer] - ribbon.positions[inner], - ribbon.positions[outer + 1] - ribbon.positions[inner + 1], - ); - if (width < 1e-4) continue; - const height = index.heightAt( - ribbon.positions[outer] - ox, - -(ribbon.positions[outer + 1] - oy), - ); - if (height !== null) ribbon.positions[outer + 2] = height + oz; - } - }); - } - - /** - * 패치(변형 성토면) 바깥 끝 스커트를 붙인다 — 지형 메시 색인이 있어야 밑선을 - * 읽으므로 뷰어에서 뒤늦게 만든다(2026-08-27 사용자). 붙인 판은 절취 측벽과 같은 - * `cutWalls` 경로로 그려진다. 다시 붙일 땐 옛 스커트를 먼저 걷어낸다. - */ - function attachPatchSkirts(build: CorridorBuildResult): void { - const bounds = deps.bounds(); - if (!bounds) return; - const index = deps.heightIndex(); - if (!index) return; - const ox = (bounds.x[0] + bounds.x[1]) / 2; - const oy = (bounds.y[0] + bounds.y[1]) / 2; - const oz = (bounds.z[0] + bounds.z[1]) / 2; - const { walls, stats } = buildPatchSkirts(build.ribbons, (x, y) => { - const height = index.heightAt(x - ox, -(y - oy)); - return height === null ? null : height + oz; - }); - const kept = (build.cutWalls ?? []).filter((wall) => !wall.patchSkirt); - build.cutWalls = [...kept, ...walls]; - (window as unknown as { __corridorSkirt?: unknown }).__corridorSkirt = stats; - } - /** 클리핑본 재생성 — 무거우므로 코리도 표시 후 다음 프레임에 수행(비동기 스왑). */ function scheduleTerrainClip(): void { disposeClippedTerrain(); @@ -307,14 +199,21 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor { const bounds = deps.bounds(); // 예약 사이에 코리도가 교체·제거됐으면 이 클립은 폐기한다. if (corridorBuild !== buildAtSchedule || !terrain || !bounds) return; - const split = ensureBandSplit(legacyBuild ?? buildAtSchedule); + const drawn = drawnBuild ?? buildAtSchedule; + const split = ensureBandSplit(drawn); if (!split) return; - // 옛 방식 A 는 지형을 비탈 끝 띠만 자른다(스케치 · 자른 자리 없음 → 원지반 되살림 없음). - const index = deformOn && !legacyBuild ? deps.heightIndex() : null; - const deformed = index ? deformTerrain(split, buildAtSchedule, bounds, index) : null; + // 방식이 지형을 안 내면 그 방식 빌드로 띠만 자른다(지금 · 옛 A). + const shaped = + mode.terrain?.({ + build: buildAtSchedule, + corridor: drawn, + split, + bounds, + heightIndex: deps.heightIndex(), + }) ?? null; (window as unknown as { __corridorDeform?: unknown }).__corridorDeform = - deformed?.stats ?? null; - const clipped = deformed?.root ?? clipTerrain(split, legacyBuild ?? buildAtSchedule, bounds); + shaped?.stats ?? null; + const clipped = shaped?.root ?? clipTerrain(split, drawn, bounds); disposeClippedTerrain(); clippedTerrain = clipped; scene.add(clipped); @@ -332,34 +231,37 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor { } function set(build: CorridorBuildResult | null): void { - // 옛 방식 A 는 처음 켤 때 즉석으로 만든다(몇 초) — 알림을 먼저 띄우고 다음 틈에 만든다. - if (build && legacyOn && !legacyReady(build)) { + const picked = activeCorridorMode(); + // 처음 만들기가 무거운 방식(옛 A 즉석 빌드 · 몇 초) — 알림을 먼저 띄우고 다음 틈에 만든다. + if (build && picked.slow?.(build)) { corridorBuild = build; - showToast("옛 방식 A(실루엣) 형상을 만드는 중입니다…", "info"); + const label = MODE_SLOTS.find(([id]) => id === picked.id)?.[1] ?? picked.id; + showToast(`3D 방식 「${label}」 형상을 만드는 중입니다…`, "info"); window.setTimeout(() => { - if (corridorBuild !== build || !legacyOn) return; - legacyCorridorOf(build); + if (corridorBuild !== build || activeCorridorMode() !== picked) return; + const bounds = deps.bounds(); + if (bounds) picked.corridor(build, { bounds, heightIndex: deps.heightIndex() }); set(build); }, 50); return; } disposeCorridorGroup(); corridorBuild = build; - legacyBuild = build && legacyOn ? legacyCorridorOf(build) : null; - setCorridorBuildSummary(legacyBuild ?? build); + mode = picked; + drawnBuild = null; const bounds = deps.bounds(); - if (legacyBuild && bounds) { - snapCorridorEdges(legacyBuild); - corridorGroup = createCorridorGroup(legacyBuild, bounds); - scene.add(corridorGroup); - deps.onGroup(); - } else if (build && bounds) { - // 새 방식은 비탈 리본 · 패치 · 측벽을 그리지 않는다 — 지형 변형이 그 자리를 맡는다. - if (!deformOn) { - snapCorridorEdges(build); - attachPatchSkirts(build); + if (build && bounds) { + const ctx = { bounds, heightIndex: deps.heightIndex() }; + drawnBuild = mode.corridor(build, ctx); + // 그 방식이 못 그리면(옛 A 입력 없음) 기본 방식으로. + if (!drawnBuild) { + mode = modeOf(DEFAULT_MODE) as CorridorMode; + drawnBuild = mode.corridor(build, ctx); } - corridorGroup = createCorridorGroup(deformOn ? roadOnly(build) : build, bounds); + } + setCorridorBuildSummary(drawnBuild ?? build); + if (drawnBuild && bounds) { + corridorGroup = createCorridorGroup(drawnBuild, bounds); scene.add(corridorGroup); deps.onGroup(); } @@ -404,8 +306,7 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor { group: () => corridorGroup, clipped: () => clippedTerrain, dispose() { - deformListeners.delete(onDeform); - legacyListeners.delete(onLegacy); + offMode(); corridorBuild = null; // 예약된 클립 콜백 무효화. disposeCorridorGroup(); disposeClippedTerrain(); diff --git a/resources/tester/helper_115_5_corridor_improve.cjs b/resources/tester/helper_115_5_corridor_improve.cjs index 38f674344..7e0fa52de 100644 --- a/resources/tester/helper_115_5_corridor_improve.cjs +++ b/resources/tester/helper_115_5_corridor_improve.cjs @@ -44,8 +44,8 @@ const r4 = (v) => (v === null || v === undefined ? v : Math.round(v * 10000) / 1 const HALF = 3; // 노면부 반폭(차도 2.5 + 노견 0.5) const ROAD_Z = 100; -/** 직선 노선(x 축) 위 측점 하나 — 지반 평탄, 양측 같은 비탈(경사 1:n). */ -function station(chainage, mode, groundZ, ratio) { +/** 직선 노선 위 측점 하나 — 지반 평탄, 양측 같은 비탈(경사 1:n). 노선은 x 축에서 angle 만큼 돈다. */ +function station(chainage, mode, groundZ, ratio, angle = 0) { const w = Math.abs(groundZ - ROAD_Z) * ratio; const pts = [ [-30, groundZ], @@ -64,10 +64,10 @@ function station(chainage, mode, groundZ, ratio) { chainage_m: chainage, label: "", kind: "regular", - center_x: chainage, - center_y: 0, + center_x: chainage * Math.cos(angle), + center_y: chainage * Math.sin(angle), center_z: groundZ, - frame: { left_xy: [0, 1] }, + frame: { left_xy: [-Math.sin(angle), Math.cos(angle)] }, samples, design: { section_mode: mode, @@ -187,4 +187,221 @@ const results = {}; results.rowPlace = { alongMax, lateralMax, folds }; } +const { deformTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Terrain")); +const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split")); +const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain")); + +/** 격자 지형(모델 좌표 [x0,x1]×[y0,y1] · 칸 step) — 높이 함수 · 정점색 200. */ +function gridTerrain(x0, x1, y0, y1, step, heightAt, bounds) { + const cx = (bounds.x[0] + bounds.x[1]) / 2; + const cy = (bounds.y[0] + bounds.y[1]) / 2; + const cz = (bounds.z[0] + bounds.z[1]) / 2; + const nx = Math.round((x1 - x0) / step) + 1; + const ny = Math.round((y1 - y0) / step) + 1; + const positions = new Float32Array(nx * ny * 3); + for (let j = 0; j < ny; j += 1) { + for (let i = 0; i < nx; i += 1) { + const v = j * nx + i; + const x = x0 + i * step; + const y = y0 + j * step; + positions[v * 3] = x - cx; + positions[v * 3 + 1] = heightAt(x, y) - cz; + positions[v * 3 + 2] = -(y - cy); + } + } + const index = []; + for (let j = 0; j < ny - 1; j += 1) { + for (let i = 0; i < nx - 1; i += 1) { + const a = j * nx + i; + index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx); + } + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + const colors = new Uint8Array(nx * ny * 4).fill(200); + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 4, true)); + geometry.setIndex(index); + return new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true })); +} + +function shape(build, bounds, terrain) { + const split = new TerrainBandSplit(terrain, { minX: -200, maxX: 200, minZ: -200, maxZ: 200 }); + return deformTerrain(split, build, bounds, new TerrainHeightIndex(terrain)); +} + +/** 변형 지형 삼각형들 — 모델 좌표 [x,y,z]×3 · 정점 칠 표식. */ +function trianglesOf(out, bounds) { + const cx = (bounds.x[0] + bounds.x[1]) / 2; + const cy = (bounds.y[0] + bounds.y[1]) / 2; + const cz = (bounds.z[0] + bounds.z[1]) / 2; + const tris = []; + out.root.traverse((child) => { + if (!(child instanceof THREE.Mesh) || !child.userData.owned) return; + const p = child.geometry.getAttribute("position"); + const idx = child.geometry.getIndex(); + const keep = child.userData.keepColor; + for (let t = 0; t < idx.count; t += 3) { + const vs = [0, 1, 2].map((k) => idx.getX(t + k)); + tris.push({ + pts: vs.map((v) => [p.getX(v) + cx, -p.getZ(v) + cy, p.getY(v) + cz]), + keep: vs.map((v) => keep[v]), + }); + } + }); + return tris; +} + +/** 점을 덮는 삼각형들의 높이 — 넓이 0 은 뺀다. */ +function heightsAt(tris, x, y) { + const out = []; + for (const { pts } of tris) { + const [a, b, c] = pts; + const den = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]); + if (Math.abs(den) < 1e-12) continue; + const w0 = ((b[1] - c[1]) * (x - c[0]) + (c[0] - b[0]) * (y - c[1])) / den; + const w1 = ((c[1] - a[1]) * (x - c[0]) + (a[0] - c[0]) * (y - c[1])) / den; + const w2 = 1 - w0 - w1; + if (w0 < -1e-7 || w1 < -1e-7 || w2 < -1e-7) continue; + out.push(w0 * a[2] + w1 * b[2] + w2 * c[2]); + } + return out; +} + +// ④ 브레이크라인(P1-3) — 격자에 비스듬한 노선(31°) · 성토 1:1.5 · 울퉁불퉁 지형. 모든 정점에서 +// 덮는 삼각형 높이가 같다(T자 균열 0) · 색은 면 단위 · 비탈 끝 선 위 높이 = 원지반. +{ + const angle = (31 * Math.PI) / 180; + const sections = [0, 15, 30, 45].map((c) => station(c, "both_fill", 96, 1.5, angle)); + const route = [ + { x: 0, y: 0 }, + { x: 45 * Math.cos(angle), y: 45 * Math.sin(angle) }, + ]; + const build = buildCorridor(sections, route); + build.sections = sections; + const bounds = { x: [-40, 80], y: [-40, 70], z: [80, 110] }; + const ground = (x, y) => 96 + 0.3 * Math.sin(x * 0.7) + 0.2 * Math.cos(y * 0.9); + const out = shape(build, bounds, gridTerrain(-20, 60, -20, 50, 0.97, ground, bounds)); + const tris = trianglesOf(out, bounds); + let worst = 0; + let checked = 0; + const seen = new Set(); + for (const { pts } of tris) { + for (const [x, y] of pts) { + const key = `${Math.round(x * 1e4)},${Math.round(y * 1e4)}`; + if (seen.has(key)) continue; + seen.add(key); + const hs = heightsAt(tris, x, y); + if (hs.length < 2) continue; + checked += 1; + worst = Math.max(worst, Math.max(...hs) - Math.min(...hs)); + } + } + const mixed = tris.filter(({ keep }) => keep.some((k) => k) && keep.some((k) => !k)).length; + // 비탈 끝 선(가운데 행 좌측) 위 높이 = 원지반. + const field = deform.buildDeformField(build, deform.stationProfileSource(sections), ground); + const row = Math.floor(field.rows.length / 2); + const [tx, ty] = field.pointAt(row, 0, field.catchAt(row, 0, 0)); + const toe = heightsAt(tris, tx, ty); + results.breakline = { + lines: out.stats.lines, + split: out.stats.split, + fallback: out.stats.fallback, + checked, + worst, + mixed, + toeGap: toe.length ? Math.max(...toe.map((h) => Math.abs(h - ground(tx, ty)))) : null, + }; +} + +// ⑤ 구조물 둘레(P1-4) — 20 측점 좌측 옹벽(기초 5~7 · 높이 95.5 / 벽체 5~5.6 · 상단 99.5). +// 지형(100)이 벽 상단보다 높다. 구멍은 벽체 윗면만 · 기초 위 · 둘레 띠는 상단(99.5)까지 내림 · +// 띠 밖은 그대로. +{ + const sections = [0, 20, 40].map((c) => station(c, "both_fill", 100, 1.5)); + const build = buildCorridor(sections, routeOf(sections)); + build.sections = sections; + const ring = (cx) => ({ cx, cy: 0, leftX: 0, leftY: 1, dz: 0 }); + build.structures = [ + { + chainage_m: 20, + kind: "revet", + polygon: [ + [5, 95], + [7, 95], + [7, 95.5], + [5.6, 95.5], + [5.6, 99.5], + [5, 99.5], + ], + rings: [ring(18), ring(20), ring(22)], + }, + ]; + const bounds = { x: [-10, 50], y: [-20, 20], z: [90, 110] }; + const out = shape( + build, + bounds, + gridTerrain(-10, 50, -20, 20, 0.5, () => 100, bounds), + ); + const tris = trianglesOf(out, bounds); + const one = (x, y) => { + const hs = heightsAt(tris, x, y); + return hs.length ? Math.round(Math.max(...hs) * 1e4) / 1e4 : null; + }; + results.drape = { + holes: out.stats.holes, + stemTop: one(20, 5.3), // 벽체 윗면 = 구멍 + footing: one(20, 6.5), // 기초 위 = 묻음 · 상단까지 + band: one(20, 7.4), // 둘레 띠(0.75 m) 안 + outside: one(20, 9), // 띠 밖 = 그대로 + endBand: one(22.5, 5.3), // 구조물 끝 너머 띠 + }; +} + +// ⑥ 3D 방식 등록부(고르기 한 줄) — 자리 넷 · 등록 셋 · 제안 ② 빈자리 · 옛 두 키 옮겨 읽기. +{ + const store = () => { + const proto = { + getItem(k) { + return Object.prototype.hasOwnProperty.call(this, k) ? this[k] : null; + }, + setItem(k, v) { + this[k] = String(v); + }, + removeItem(k) { + delete this[k]; + }, + key(i) { + return Object.keys(this)[i] ?? null; + }, + }; + Object.defineProperty(proto, "length", { + get() { + return Object.keys(this).length; + }, + }); + return Object.create(proto); + }; + globalThis.window = { localStorage: store(), sessionStorage: store() }; + const modes = loadTs(B05("B05_Profile_UI_Corridor_Modes")); + const state = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts")); + const picks = {}; + picks.fresh = modes.corridorModeId(); + state.writeStateRaw("corridor-deform", "0"); + picks.oldDeformOff = modes.corridorModeId(); + state.writeStateRaw("corridor-legacy", "1"); + picks.oldLegacyOn = modes.corridorModeId(); + modes.setCorridorMode("proposal2"); + picks.proposal = modes.corridorModeId(); + picks.proposalDraws = modes.activeCorridorMode().id; + modes.setCorridorMode("new1"); + picks.set = modes.corridorModeId(); + results.modes = { + slots: modes.MODE_SLOTS.map(([id, label]) => `${id}:${label}`), + registered: modes.CORRIDOR_MODES.map((mode) => mode.id), + defaultMode: modes.DEFAULT_MODE, + newHasTerrain: typeof modes.modeOf("new1").terrain === "function", + ...picks, + }; +} + process.stdout.write(JSON.stringify(results)); diff --git a/resources/tester/test_115_4_corridor_legacy.py b/resources/tester/test_115_4_corridor_legacy.py index 339380e4e..39246643c 100644 --- a/resources/tester/test_115_4_corridor_legacy.py +++ b/resources/tester/test_115_4_corridor_legacy.py @@ -62,6 +62,7 @@ def test_legacy_state_key_registered(): registry = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8") assert '"corridor-legacy": { bucket: "pref", scope: "global" }' in registry assert '"corridor-deform": { bucket: "pref", scope: "global" }' in registry - panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Panel_View.ts").read_text(encoding="utf-8") - assert 'toggleButton("옛 방식 A(실루엣)", corridorLegacyOn(), setCorridorLegacy)' in panel - assert 'toggleButton("새 방식", corridorDeformOn(), setCorridorDeform)' in panel + # 115-5 — 단추 둘은 고르기 한 줄 「3D 방식」로 옮겼다(옛 키 값은 읽어서 옮김). + modes = (ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Modes.ts").read_text(encoding="utf-8") + assert '["legacy-a", "옛 A"]' in modes + assert 'readStateRaw("corridor-legacy") === "1"' in modes diff --git a/resources/tester/test_115_5_corridor_improve.py b/resources/tester/test_115_5_corridor_improve.py index 5ffdc4716..bacae7d5d 100644 --- a/resources/tester/test_115_5_corridor_improve.py +++ b/resources/tester/test_115_5_corridor_improve.py @@ -2,12 +2,14 @@ """PLAN 115-5 — B05 3D 제안 ① 새 방식 개선 합성 검사. Node 도우미(helper_115_5_corridor_improve.cjs)가 TS 를 트랜스파일해 돌리고, 여기서는 -결과(JSON)를 단언한다. 비탈 폭주 0 · 조각 규칙 · 행 자리 0 m. +결과(JSON)를 단언한다. 비탈 폭주 0 · 조각 규칙 · 행 자리 0 m · 브레이크라인 T자 균열 0 · +구조물 둘레(윗면 구멍 · 드레이프) · 3D 방식 등록부(고르기 한 줄). """ import json import os import subprocess +from pathlib import Path import pytest @@ -44,3 +46,43 @@ def test_row_place_follows_route_length(results): assert r["alongMax"] < 1e-6 # 행 자리 = 누가거리(0 m) assert r["lateralMax"] < 1e-6 # 노선 위 assert r["folds"] == 0 + + +def test_breakline_no_t_cracks_and_face_colours(results): + r = results["breakline"] + assert r["split"] > 0 and r["fallback"] == 0 + assert r["checked"] > 1000 + assert r["worst"] < 1e-4 # 같은 자리 덮는 삼각형 높이 같음(T자 균열 0) + assert r["mixed"] == 0 # 색은 면 단위 — 한 삼각형에 칠 · 원지반 섞임 없음 + assert r["toeGap"] < 0.02 # 비탈 끝 선 위 = 원지반 + + +def test_structure_top_hole_and_drape(results): + r = results["drape"] + assert r["holes"] == 1 + assert r["stemTop"] is None # 벽체 윗면만 구멍 + assert r["footing"] == 99.5 # 기초 위는 묻고 벽 상단까지 내림 + assert r["band"] == 99.5 # 둘레 띠 안 + assert r["endBand"] == 99.5 # 끝 너머 띠 + assert r["outside"] == 100 # 띠 밖은 그대로 + + +def test_mode_registry_and_migration(results): + r = results["modes"] + assert r["slots"] == ["current:지금", "legacy-a:옛 A", "new1:새 ①", "proposal2:제안 ②"] + assert r["registered"] == ["current", "legacy-a", "new1"] # 제안 ② 는 다른 창 몫(빈자리) + assert r["defaultMode"] == "new1" and r["fresh"] == "new1" + assert r["newHasTerrain"] is True + assert r["oldDeformOff"] == "current" # 옛 「새 방식」 끔 → 지금 + assert r["oldLegacyOn"] == "legacy-a" # 옛 「옛 방식 A」 켬 → 옛 A + assert r["proposal"] == "proposal2" and r["proposalDraws"] == "new1" # 미등록 → 기본으로 그림 + assert r["set"] == "new1" + + +def test_mode_row_replaces_old_toggles(): + root = Path(HERE).parents[1] + registry = (root / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8") + assert '"corridor-mode": { bucket: "pref", scope: "global" }' in registry + panel = (root / "B05_Profile" / "B05_Profile_UI_Panel_View.ts").read_text(encoding="utf-8") + assert "MODE_SLOTS.map" in panel and '"3D 방식"' in panel + assert 'toggleButton("새 방식"' not in panel and "옛 방식 A(실루엣)" not in panel diff --git a/resources/tester/test_b05_corridor_envelope_flags.py b/resources/tester/test_b05_corridor_envelope_flags.py index 3bb2c3e21..d61e02b00 100644 --- a/resources/tester/test_b05_corridor_envelope_flags.py +++ b/resources/tester/test_b05_corridor_envelope_flags.py @@ -22,7 +22,8 @@ if str(PROJECT_ROOT) not in sys.path: # 저장 형식(serialize/deserialize)은 브라우저·서버 공용 모듈로 옮겼다(2026-09-04 분리). CORRIDOR = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Envelope.ts" # 지형 스냅 가드는 뷰어에서 코리도 조각으로 옮겼다(계획서 24-3a · 700줄 나눔). -VIEWER = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Viewer_Corridor.ts" +# 115-5 — 심 보정(snapCorridorEdges)은 방식 등록부로 옮겼다. +VIEWER = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Modes.ts" CARVE_WING = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Carve_Wing.ts" CARVE = PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Corridor_Carve.ts"