feat(B05): 측벽을 셀이 아니라 평면 스케치에서 바로 세운다

사용자 지적: 벽이 셀 단위 조각으로 깔린다(세월교 한 세트에 96~111조각, 길이
5~20cm). 절취가 평면 스케치 기준이었으니 벽도 스케치에서 바로 만들면 된다.

- Plan: `cut-wall` 소스 신설 — 벽이 설 자리만 담은 **열린 폴리라인**이다.
  · 비탈 스트립은 **양 끝 횡단선** 2개(긴 변은 노견선·성토 끝선이라 뺀다).
  · 날개 사다리꼴은 [끝선 교점 → 날개 시작 → 날개 끝 → 끝선 교점] 하나
    (`wingBoxLoops`가 `{loops, walls}`를 돌려준다).
- Cut: `SkirtBuilder`(셀 훑기) 제거. `cut-wall` 커브 마디마다 판 하나를 세우고,
  표고는 성토 리본에서 읽는다(`sampleRibbon` — 행은 XY상 직선이라 점을 그 선분에
  투영해 서피스 z·원지반 z를 같은 자리에서 보간). 밑선은 원지반 − 1m 유지.
- Mesh: `cut-wall` 커브는 선으로 안 그린다(벽 서피스로만 나간다).
- BUILD_VERSION 57 → 58.

검증(실빌드 데이터):
- 벽 삼각형 852/824 → **40/40**(판 20장씩). 판 길이 0.17~11.52m, 중앙 2.0~2.4m
  (종전 세월교 조각 길이 중앙 5~10cm).
- 노견선·성토 끝선 위를 달리는 판 **0**(양측). 벽 최대 높이 3.18m.
- ISO 근접 캡처: BOX암거·세월교 절취부 옆면이 큰 판 몇 장으로 막힌다. 바닥은 열림.
- npm run typecheck 통과, prettier 적용.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 12:01:50 +09:00
co-authored by Claude Opus 5
parent b9938dfa18
commit 93945d356c
5 changed files with 119 additions and 115 deletions
+1 -1
View File
@@ -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 {
+80 -110
View File
@@ -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<ReadonlyArray<[number, number, number]>>,
): 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<string, Array<Array<[number, number, number]>>>();
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,
@@ -46,6 +46,8 @@ const PLAN_CURVE_COLORS: Record<PlanCurve["source"], number> = {
"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) => {
+19 -1
View File
@@ -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;
@@ -206,10 +206,16 @@ export function wingBoxLoops(
planZ: number,
axis: StationAxis,
toe: ReadonlyArray<readonly [number, number]>,
): Array<Array<[number, number, number]>> {
if (toe.length < 2) return [];
): {
loops: Array<Array<[number, number, number]>>;
/** 측벽이 설 자리 — [끝선 교점 → 날개 시작 → 날개 끝 → 끝선 교점]. 성토 끝선 구간은
* 뺀다(거기서 잘린 게 아니라 리본이 끝난 자리다 — 2026-08-27 사용자). */
walls: Array<Array<[number, number, number]>>;
} {
if (toe.length < 2) return { loops: [], walls: [] };
const sign = side === "left" ? 1 : -1;
const loops: Array<Array<[number, number, number]>> = [];
const walls: Array<Array<[number, number, number]>> = [];
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 };
}