diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 5700382d..18a98a20 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -108,7 +108,7 @@ function fnv1a(text: string): string { // 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것. // 56 = 측벽에서 **노견 쪽·성토 끝단 쪽 리본 경계 벽을 뺀다** — 거기서 잘린 게 // 아니라 리본이 끝난 자리다(2026-08-27 사용자). -const BUILD_VERSION = 57; +const BUILD_VERSION = 58; /** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Cut.ts b/B05_Profile/B05_Profile_UI_Corridor_Cut.ts index 8b4dedd6..e0d9dd13 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Cut.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Cut.ts @@ -287,93 +287,81 @@ function dropCollinear(loop: ReadonlyArray<[number, number]>): Array<[number, nu const SKIRT_MARGIN_M = 1; /** - * 잘린 자리 **측벽**(2026-08-27 사용자) — 절취 경계를 z로 밀어내 성토면에서 원지반 - * 아래(`SKIRT_MARGIN_M`)까지 세운다. 리본 메시에 섞지 않고 **구조물처럼 별도 서피스**로 - * 낸다 — UV·법선이 리본에 묶이지 않아 다음 단계(바닥면·트림)에서 따로 다룰 수 있다. + * 잘린 자리 **측벽**(2026-08-27 사용자) — **평면 스케치에서 바로** 세운다. * - * 벽이 서는 자리는 **실제로 잘린 선**이다(2026-08-27 사용자 지적으로 정정): - * · 걸친 셀 → 그 셀을 자른 **교점 직선**(면 조각의 경계와 같은 선). - * · 통째로 지운 셀 → 이웃이 **안 지워진 셀**인 쪽의 셀 모서리. - * 종전에는 마스크 격자 윤곽(셀 계단)을 그대로 밀어내 면 경계와 최대 0.3m 어긋났다. + * 벽이 설 자리는 Plan이 `cut-wall` 커브로 넘겨준다(스트립 양 끝 횡단선 + 날개 + * 사다리꼴의 [끝선→날개→끝선]). 노견선·성토 끝선 변은 애초에 빠져 있다. + * 셀을 훑지 않으므로 커브 마디 하나당 판 하나다 — 종전에는 셀마다 한 장씩 나와 + * 세월교 한 세트에 96~111조각(길이 5~20cm)이 깔렸다(2026-08-27 실측). + * + * 표고만 성토 리본에서 읽는다 — 행 하나는 XY상 직선이라 점을 그 선분에 투영해 + * 열 위치를 얻고, 서피스 z와 원지반 z를 같은 자리에서 보간한다. */ -class SkirtBuilder { - private readonly tris: number[] = []; - - constructor( - private readonly ribbon: CorridorRibbon, - private readonly ground: Float64Array, - ) {} - - /** 셀 안 상대좌표(a: 열, b: 행)로 원지반 표고를 이중선형 보간한다. */ - private groundAt(row: number, col: number, corner: Corner): number { - const cols = this.ribbon.colCount; - const g = (r: number, c: number): number => this.ground[r * cols + c]; - const a = Math.min(1, Math.max(0, corner.a - col)); - const b = Math.min(1, Math.max(0, corner.b)); - const top = g(row, col) + (g(row, col + 1) - g(row, col)) * a; - const bottom = g(row + 1, col) + (g(row + 1, col + 1) - g(row + 1, col)) * a; - return top + (bottom - top) * b - SKIRT_MARGIN_M; - } - - private quad( - x1: number, - y1: number, - top1: number, - bottom1: number, - x2: number, - y2: number, - top2: number, - bottom2: number, - ): void { - if (top1 - bottom1 < 1e-6 && top2 - bottom2 < 1e-6) return; // 높이 0이면 벽이 없다. - if (Math.hypot(x2 - x1, y2 - y1) < 1e-9) return; - this.tris.push(x1, y1, top1, x2, y2, top2, x2, y2, bottom2); - this.tris.push(x1, y1, top1, x2, y2, bottom2, x1, y1, bottom1); - } - - /** 걸친 셀 — 그 셀을 자른 교점 직선에 벽을 세운다. */ - addChord(row: number, col: number, chord: readonly [Corner, Corner]): void { - const [h0, h1] = chord; - // 교점 직선이 리본 **안쪽 끝(노견)·바깥 끝(성토 끝선)** 위를 그대로 달리면 건너뛴다 — - // 투영 커브의 바깥 변이 곧 성토 끝선이라 거기서는 자른 게 아니다(2026-08-27 사용자). - // 판정은 **열 단위**로 한다 — 커브가 끝선과 나란히 지나면 교점이 가로 모서리에 - // 걸려 열 좌표가 끝값에서 조금 빗나간다(실측 3~5mm). 셀 폭의 10%를 여유로 둔다. - const last = this.ribbon.colCount - 1; - const TOL = 0.1; - if (h0.a < TOL && h1.a < TOL) return; - if (h0.a > last - TOL && h1.a > last - TOL) return; - this.quad( - h0.p[0], - h0.p[1], - h0.p[2], - this.groundAt(row, col, h0), - h1.p[0], - h1.p[1], - h1.p[2], - this.groundAt(row, col, h1), +function sampleRibbon( + ribbon: CorridorRibbon, + x: number, + y: number, +): { top: number; bottom: number } | null { + const ground = ribbon.groundZ; + if (!ground) return null; + const cols = ribbon.colCount; + const rows = ribbon.chainages.length; + let best: { distance: number; row: number; t: number } | null = null; + for (let row = 0; row < rows; row += 1) { + const a = row * cols * 3; + const b = (row * cols + cols - 1) * 3; + const dx = ribbon.positions[b] - ribbon.positions[a]; + const dy = ribbon.positions[b + 1] - ribbon.positions[a + 1]; + const lengthSq = dx * dx + dy * dy; + const t = + lengthSq > 1e-12 + ? Math.min( + 1, + Math.max( + 0, + ((x - ribbon.positions[a]) * dx + (y - ribbon.positions[a + 1]) * dy) / lengthSq, + ), + ) + : 0; + const distance = Math.hypot( + x - (ribbon.positions[a] + dx * t), + y - (ribbon.positions[a + 1] + dy * t), ); + if (!best || distance < best.distance) best = { distance, row, t }; } + if (!best) return null; + const span = best.t * (cols - 1); + const col = Math.min(cols - 2, Math.floor(span)); + const f = span - col; + const i = (best.row * cols + col) * 3; + const j = i + 3; + const g = best.row * cols + col; + return { + top: ribbon.positions[i + 2] + (ribbon.positions[j + 2] - ribbon.positions[i + 2]) * f, + bottom: ground[g] + (ground[g + 1] - ground[g]) * f - SKIRT_MARGIN_M, + }; +} - /** 통째로 지운 셀 — 안 지워진 이웃 쪽 셀 모서리에 벽을 세운다. */ - addEdge(rowA: number, colA: number, rowB: number, colB: number): void { - const cols = this.ribbon.colCount; - const at = (row: number, col: number): [number, number, number, number] => { - const i = (row * cols + col) * 3; - return [ - this.ribbon.positions[i], - this.ribbon.positions[i + 1], - this.ribbon.positions[i + 2], - this.ground[row * cols + col] - SKIRT_MARGIN_M, - ]; - }; - const [x1, y1, top1, bottom1] = at(rowA, colA); - const [x2, y2, top2, bottom2] = at(rowB, colB); - this.quad(x1, y1, top1, bottom1, x2, y2, top2, bottom2); - } - - result(): Float64Array | null { - return this.tris.length ? new Float64Array(this.tris) : null; +/** `cut-wall` 커브를 성토면↔원지반 사이 판으로 세운다. */ +function buildWall( + ribbon: CorridorRibbon, + lines: ReadonlyArray>, +): Float64Array | null { + const tris: number[] = []; + for (const line of lines) { + for (let i = 0; i < line.length - 1; i += 1) { + const [x1, y1] = line[i]; + const [x2, y2] = line[i + 1]; + if (Math.hypot(x2 - x1, y2 - y1) < 1e-6) continue; + const a = sampleRibbon(ribbon, x1, y1); + const b = sampleRibbon(ribbon, x2, y2); + if (!a || !b) continue; + if (a.top - a.bottom < 1e-6 && b.top - b.bottom < 1e-6) continue; + tris.push(x1, y1, a.top, x2, y2, b.top, x2, y2, b.bottom); + tris.push(x1, y1, a.top, x2, y2, b.bottom, x1, y1, a.bottom); + } } + return tris.length ? new Float64Array(tris) : null; } /** @@ -405,6 +393,16 @@ export function maskFillByPlanCurves( const merged: PlanCurve[] = []; const walls: CorridorCutWall[] = []; + // 측벽이 설 자리 — Plan이 커브로 넘겨준다(셀을 훑지 않는다). + const wallLinesBySide = new Map>>(); + for (const curve of curves) { + if (curve.source !== "cut-wall") continue; + const bucket = wallLinesBySide.get(curve.side) ?? []; + curve.loops.forEach((line) => { + if (line.length >= 2) bucket.push(line); + }); + wallLinesBySide.set(curve.side, bucket); + } const cut = ribbons.map((ribbon) => { const areas = areasBySide.get(ribbon.side); if (ribbon.kind !== "fill" || ribbon.patch || !areas?.length) return ribbon; @@ -412,13 +410,9 @@ export function maskFillByPlanCurves( const cols = ribbon.colCount; if (rows < 2 || cols < 2) return ribbon; const mask = new Uint8Array((rows - 1) * (cols - 1)); - /** 교점 직선으로 잘린 셀 — 그 셀의 벽은 이미 섰으니 모서리 벽을 또 세우지 않는다. */ - const chorded = new Uint8Array((rows - 1) * (cols - 1)); let masked = 0; const tris: number[] = []; const uvs: number[] = []; - // 측벽은 리본 메시가 아니라 **별도 서피스**로 나간다(2026-08-27 사용자). - const skirt = ribbon.groundZ ? new SkirtBuilder(ribbon, ribbon.groundZ) : null; const startChainage = ribbon.chainages[0]; const point = (row: number, col: number): [number, number, number] => { const i = (row * cols + col) * 3; @@ -459,9 +453,7 @@ export function maskFillByPlanCurves( continue; } mask[row * (cols - 1) + col] = 1; - chorded[row * (cols - 1) + col] = 1; masked += 1; - skirt?.addChord(row, col, clip.chord); const kept = clip.kept; for (let k = 1; k < kept.length - 1; k += 1) { for (const corner of [kept[0], kept[k], kept[k + 1]]) { @@ -473,28 +465,6 @@ export function maskFillByPlanCurves( } } if (!masked) return ribbon; - // 통째로 지운 셀은 **안 지워진 이웃** 쪽 모서리에만 벽을 세운다. 걸친 셀은 위에서 - // 교점 직선으로 이미 세웠고, 그 셀의 남은 조각이 모서리까지 덮으므로 건드리지 않는다. - if (skirt) { - const filled = (row: number, col: number): boolean => - row >= 0 && col >= 0 && row < rows - 1 && col < cols - 1 - ? mask[row * (cols - 1) + col] === 0 - : true; - for (let row = 0; row < rows - 1; row += 1) { - for (let col = 0; col < cols - 1; col += 1) { - const i = row * (cols - 1) + col; - if (!mask[i] || chorded[i]) continue; - if (filled(row - 1, col)) skirt.addEdge(row, col, row, col + 1); - if (filled(row + 1, col)) skirt.addEdge(row + 1, col + 1, row + 1, col); - // 리본의 **안쪽 끝(노견)·바깥 끝(성토 끝단)**에는 벽을 세우지 않는다 — - // 거기서 잘린 게 아니라 리본이 끝난 것이다(2026-08-27 사용자). - if (col > 0 && filled(row, col - 1)) skirt.addEdge(row + 1, col, row, col); - if (col < cols - 2 && filled(row, col + 1)) { - skirt.addEdge(row, col + 1, row + 1, col + 1); - } - } - } - } // 잘린 자리 윤곽을 **새 데이텀**에 얹어 따로 낸다 — 어디가 잘리는지 눈으로 본다. // 성토 리본은 좌·우뿐이라 커브의 측 표기와 그대로 맞는다. const side: PlanCurve["side"] = ribbon.side === "right" ? "right" : "left"; @@ -522,7 +492,7 @@ export function maskFillByPlanCurves( ], }); }); - const wall = skirt?.result(); + const wall = buildWall(ribbon, wallLinesBySide.get(ribbon.side) ?? []); if (wall) walls.push({ side, positions: wall }); return { ...ribbon, diff --git a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts index cabd6c54..12cfb2c8 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Mesh.ts @@ -46,6 +46,8 @@ const PLAN_CURVE_COLORS: Record = { "slope-projected": 0xff3b30, // 실제로 잘린 자리 윤곽(2026-08-27 사용자) — 새 데이텀(+3m)에 얹어 라임으로 낸다. "cut-merged": 0x84cc16, + // 측벽이 설 자리 — 벽 서피스로만 쓰고 선으로는 안 그린다(아래에서 걸러진다). + "cut-wall": 0x000000, }; /** 유출측 파선 눈금(m) — 유입은 실선이다. */ @@ -390,6 +392,8 @@ export function createCorridorGroup(build: CorridorBuildResult, bounds: ModelBou // 평면에 눕힌 닫힌 고리다. 종류는 색(구조물 노랑 / 변형 성토선 분홍 / 원본 하늘), // 유입·유출은 실선·파선으로 나눈다. 표기 전용 — 서피스는 안 건드린다. (build.planCurves ?? []).forEach((curve) => { + // 벽 자리 커브는 표시용이 아니다 — 벽 서피스로만 나간다(2026-08-27 사용자). + if (curve.source === "cut-wall") return; const color = PLAN_CURVE_COLORS[curve.source]; const dashed = curve.role === "outlet"; curve.loops.forEach((loop, index) => { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts index a534c915..6ddd47d9 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Plan.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Plan.ts @@ -36,6 +36,10 @@ export type PlanCurveSource = /** 파란 스케치 커브(`slope-original`)를 절·성토면에 탑뷰 투영한 **빨간 선** * (2026-08-26 사용자). 스케치는 평면에 그대로 남고 이 커브만 서피스에 얹힌다. */ | "slope-projected" + /** 측벽이 설 자리(2026-08-27 사용자) — 절취 커브에서 **노견선·성토 끝선 변을 뺀** + * 열린 폴리라인. 스트립은 양 끝 횡단선, 날개 사다리꼴은 [끝선→날개→끝선]이다. + * 표시는 하지 않고 벽 세우기에만 쓴다. */ + | "cut-wall" /** 실제로 잘린 자리 윤곽(2026-08-27 사용자) — 비탈 투영선 ∪ 날개벽 사다리꼴을 합쳐 * 셀 마스크에서 되짚은 커브. **현 평면 대비 +3m 새 데이텀**에 얹는다. */ | "cut-merged"; @@ -389,7 +393,10 @@ export function buildPlanCurves( // 노선 전 구간 폴리라인이라 세트 근처만 잘라 넘긴다 — 안 자르면 헤어핀 // 건너편 성토선에 광선이 얻어걸린다(패치 리본이 겪은 것과 같은 함정). const toe = nearToe(outline, side, section.chainage_m); - push("wing-box", side, wingBoxLoops(set, side, planZ, axis, toe)); + const wing = wingBoxLoops(set, side, planZ, axis, toe); + push("wing-box", side, wing.loops); + // 날개 사다리꼴의 벽 자리 — 성토 끝선 변은 빠져 있다(2026-08-27 사용자). + push("cut-wall", side, wing.walls); }); for (const silhouette of structureSilhouettes(section)) { @@ -498,6 +505,17 @@ export function buildPlanCurves( }); push("slope-original", side, stripLoops(rows, planZ)); push("slope-projected", side, projectedLoops(projected)); + // 측벽이 설 자리 = 스트립의 **양 끝 횡단선**뿐. 긴 변(노견선·성토 끝선)은 뺀다 + // (거기서 잘린 게 아니라 리본이 끝난 자리다 — 2026-08-27 사용자). + const spans = projected.filter( + (pair): pair is [[number, number, number], [number, number, number]] => pair !== null, + ); + if (spans.length >= 2) { + push("cut-wall", side, [ + [spans[0][0], spans[0][1]], + [spans[spans.length - 1][0], spans[spans.length - 1][1]], + ]); + } } } return curves; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts b/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts index f7370089..0394434e 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Plan_Footprint.ts @@ -206,10 +206,16 @@ export function wingBoxLoops( planZ: number, axis: StationAxis, toe: ReadonlyArray, -): Array> { - if (toe.length < 2) return []; +): { + loops: Array>; + /** 측벽이 설 자리 — [끝선 교점 → 날개 시작 → 날개 끝 → 끝선 교점]. 성토 끝선 구간은 + * 뺀다(거기서 잘린 게 아니라 리본이 끝난 자리다 — 2026-08-27 사용자). */ + walls: Array>; +} { + if (toe.length < 2) return { loops: [], walls: [] }; const sign = side === "left" ? 1 : -1; const loops: Array> = []; + const walls: Array> = []; for (const structure of set) { const rings = structure.rings; // 날개벽(revet)만 — 세월교 바닥 에이프런(basin)도 `wing` 표식을 달지만 박스는 없다. @@ -242,6 +248,12 @@ export function wingBoxLoops( loop.push([startHit.x, startHit.y, planZ]); loop.push(loop[0]); loops.push(loop); + walls.push([ + [startHit.x, startHit.y, planZ], + [start.cx, start.cy, planZ], + [end.cx, end.cy, planZ], + [endHit.x, endHit.y, planZ], + ]); } - return loops; + return { loops, walls }; }