feat(B05): 3D 새 방식 구조물 마스크 — 풋프린트 · 아령 외곽 구멍 · 날개 안쪽 성토 금지 · 구간 단면 상부 포락선 + 이웃 측점 테이퍼(115-2)

- 구조물 구간 단면 = 소유 측점 실루엣 상부 포락선(앵커 기준) · 구간 안 고정 · 끝 → 이웃 측점 테이퍼
- BOX(구조물에서 끝나는 실루엣) 구간은 단면 끝 너머 연장 없음 = 본체 밖 원지반
- 지형 구멍 = 구조물 풋프린트 · BOX 아령 외곽(솔리드가 메움 · 솔리드 코드는 그대로)
- 날개 안쪽 유로 · 에이프런 성토 금지 고리 — 원지반(세월교는 바닥판 상단)까지만

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
2026-10-08 20:20:45 +09:00
co-authored by Claude Opus 5.5
parent 01c2a5983b
commit a92e1768ca
5 changed files with 562 additions and 14 deletions
+80 -9
View File
@@ -65,9 +65,31 @@ export interface RowProfiles {
noExtend?: boolean;
}
/** 행 · 측마다 단면을 내주는 자리 — 115-2 구조물 단면이 이 자리를 갈아 끼운다. */
/** 행 · 측마다 단면을 내주는 자리 — 측점 단면 + 구조물 구간 단면(115-2). */
export type ProfileSource = (chainageM: number, side: DeformSide) => RowProfiles;
/**
* 구조물 구간 단면(115-2) — 소유 측점 실루엣의 상부 포락선(앵커 기준 상대 · 측점 단면과
* 같은 꼴 · 같은 열 수). 구간(전/후 몫) 안은 이 단면 그대로, 밖은 이웃 측점까지 테이퍼.
*/
export interface StructureSlope {
chainage_m: number;
side: DeformSide;
kind: DeformKind;
span: { beforeM: number; afterM: number };
points: OffsetPoint[];
/** 실루엣이 구조물에서 끝난다(BOX) — 구간 안은 단면 끝 너머를 원지반으로 둔다. */
endsAtStructure: boolean;
}
/** 성토 금지 고리(115-2 · 날개 안쪽 유로 · 에이프런) — 안쪽 정점은 원지반(또는 천장) 위로 못 올린다. */
export interface DeformLimit {
/** 모델 좌표 닫힌 고리. */
loop: Array<[number, number]>;
/** 바닥판 상단 등 — 원지반이 이보다 낮으면 여기까지는 올린다. null 이면 원지반. */
ceiling: number | null;
}
/** 정점 하나의 역투영 결과. */
export interface DeformLocation {
row: number;
@@ -172,19 +194,38 @@ export function roadRows(build: CorridorBuildResult): DeformRow[] {
);
}
/** 측점 비탈 단면으로 내는 기본 출처 — 측점 사이는 제어점 보간(`_Frames.ts`). */
export function stationProfileSource(sections: ReadonlyArray<CrossSection>): ProfileSource {
/**
* 측점 비탈 단면으로 내는 출처 — 측점 사이는 제어점 보간(`_Frames.ts`). 구조물 단면을 주면
* 그 측점의 그 측 · 그 종류는 구조물 단면으로 갈아 끼우고 전/후 몫을 고정 구간으로 세운다
* (구간 끝 → 이웃 측점 테이퍼). 고정 구간 안은 실루엣 종류만 남긴다.
*/
export function stationProfileSource(
sections: ReadonlyArray<CrossSection>,
slopes: ReadonlyArray<StructureSlope> = [],
): ProfileSource {
const stations = sections
.map(classifyStation)
.filter((s): s is StationPieces => s !== null)
.sort((a, b) => a.chainage_m - b.chainage_m);
const slopeOf = (station: StationPieces, side: DeformSide, kind: DeformKind) =>
slopes.find(
(slope) =>
slope.side === side &&
slope.kind === kind &&
Math.abs(slope.chainage_m - station.chainage_m) < 1e-6,
);
const controls = new Map<string, PieceControl[] | null>();
SIDES.forEach((side) =>
KINDS.forEach((kind) => {
const key = pieceKey(kind, side);
controls.set(
key,
buildPieceControls(stations, side, (station) => station.slopeRelative.get(key)),
buildPieceControls(
stations,
side,
(station) => slopeOf(station, side, kind)?.points ?? station.slopeRelative.get(key),
(station) => slopeOf(station, side, kind)?.span,
),
);
}),
);
@@ -198,10 +239,33 @@ export function stationProfileSource(sections: ReadonlyArray<CrossSection>): Pro
const points = a === b ? a.points : lerpPoints(a.points, b.points, t);
return outwardProfile(points, own ? side : other);
};
return (chainageM, side) => ({
fill: at(side, "fill", chainageM),
cut: at(side, "cut", chainageM),
});
return (chainageM, side) => {
const fill = at(side, "fill", chainageM);
const cut = at(side, "cut", chainageM);
const fixed = slopes.find(
(slope) =>
slope.side === side &&
chainageM >= slope.chainage_m - slope.span.beforeM - 1e-9 &&
chainageM <= slope.chainage_m + slope.span.afterM + 1e-9,
);
if (!fixed) return { fill, cut };
return {
fill: fixed.kind === "fill" ? fill : null,
cut: fixed.kind === "cut" ? cut : null,
noExtend: fixed.endsAtStructure,
};
};
}
/** 점이 고리 안인가(광선 투사). */
function insideLoop(loop: ReadonlyArray<[number, number]>, x: number, y: number): boolean {
let hit = false;
for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) {
const [xi, yi] = loop[i];
const [xj, yj] = loop[j];
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit;
}
return hit;
}
/**
@@ -212,6 +276,7 @@ export function buildDeformField(
build: CorridorBuildResult,
source: ProfileSource,
groundAt: (x: number, y: number) => number | null,
limits: ReadonlyArray<DeformLimit> = [],
): DeformField | null {
const rows = roadRows(build);
const n = rows.length;
@@ -378,7 +443,13 @@ export function buildDeformField(
if (!at || at.outward > reachMax) return z;
const si = at.side === "left" ? 0 : 1;
const s = Math.max(0, at.outward);
return rowZ(at.row, si, s, z) * (1 - at.t) + rowZ(at.row + 1, si, s, z) * at.t;
let out = rowZ(at.row, si, s, z) * (1 - at.t) + rowZ(at.row + 1, si, s, z) * at.t;
for (const limit of limits) {
if (out > z && insideLoop(limit.loop, x, y)) {
out = Math.min(out, Math.max(z, limit.ceiling ?? z));
}
}
return out;
}
return { rows, locate, deformZ };
@@ -0,0 +1,243 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Deform_Structure.ts
* 새 방식 지형 변형의 **구조물 마스크**(계획서 115-2) — 순수 기하.
*
* · 구조물 구간 단면 = 소유 측점 실루엣(`_Station_Structure.ts`)의 상부 포락선.
* 구간 안은 고정, 구간 끝 → 이웃 측점까지 제어점 테이퍼(`_Frames.ts`).
* · 지형 구멍 = 구조물 풋프린트 · BOX 아령 외곽(`_Plan_Footprint.ts`). 솔리드가 메운다.
* · 성토 금지 고리 = 날개 안쪽 유로 · 에이프런 — 원지반(세월교는 바닥판 상단) 위로 안 올린다.
*
* 구조물 솔리드(`_Structures*.ts`)는 읽기만 한다.
* ========================================================================== */
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import type { CorridorStructure } from "./B05_Profile_UI_Corridor_Structures";
import {
classifyStation,
PIECE_COLS,
pieceKey,
resample,
type OffsetPoint,
type StationPieces,
} from "./B05_Profile_UI_Corridor_Station";
import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure";
import {
structureFootprintLoops,
type StationAxis,
} from "./B05_Profile_UI_Corridor_Plan_Footprint";
import type {
DeformKind,
DeformLimit,
DeformSide,
StructureSlope,
} from "./B05_Profile_UI_Corridor_Deform";
/**
* 상부 포락선 — 바깥거리(`offset_m` = s ≥ 0)마다 실루엣 마디 중 가장 높은 표고.
* 벽 앞면(거의 수직) · 다단 계단처럼 바깥으로 갔다 돌아오는 줄을 높이장 한 줄로 편다.
*/
export function upperEnvelope(points: ReadonlyArray<OffsetPoint>): OffsetPoint[] {
if (points.length < 2) return points.map((point) => ({ ...point }));
const xs = [...new Set(points.map((point) => point.offset_m))].sort((a, b) => a - b);
return xs.map((x) => {
let best = -Infinity;
for (let i = 0; i < points.length - 1; i += 1) {
const a = points[i];
const b = points[i + 1];
const lo = Math.min(a.offset_m, b.offset_m);
const hi = Math.max(a.offset_m, b.offset_m);
if (x < lo - 1e-9 || x > hi + 1e-9) continue;
const z =
hi - lo < 1e-9
? Math.max(a.elevation_m, b.elevation_m)
: a.elevation_m +
((b.elevation_m - a.elevation_m) * (x - a.offset_m)) / (b.offset_m - a.offset_m);
if (z > best) best = z;
}
return { offset_m: x, elevation_m: best };
});
}
/** 측점의 노면부 바깥 끝(측구 > 노견) — 행 앵커와 같은 자리. */
function anchorOf(station: StationPieces, side: DeformSide): OffsetPoint | null {
const piece =
station.pieces.get(pieceKey("ditch", side)) ?? station.pieces.get(pieceKey("shoulder", side));
if (!piece || piece.length < 2) return null;
return side === "left" ? piece[piece.length - 1] : piece[0];
}
/**
* 실루엣 → 앵커 기준 상대 단면(측점 단면 `slopeRelative` 와 같은 꼴 · 같은 열 수).
* 앵커 안쪽(노면부)은 잘라 내고, 앵커 자리 교차점을 첫 점으로 세운다.
*/
function silhouetteSlope(
points: ReadonlyArray<OffsetPoint>,
side: DeformSide,
anchor: OffsetPoint,
kind: DeformKind,
): OffsetPoint[] | null {
const sign = side === "left" ? 1 : -1;
const outward = points.map((point) => ({
offset_m: (point.offset_m - anchor.offset_m) * sign,
elevation_m: point.elevation_m - anchor.elevation_m,
}));
const kept: OffsetPoint[] = [];
outward.forEach((point, i) => {
if (point.offset_m < 0) return;
const previous = outward[i - 1];
if (!kept.length && previous && previous.offset_m < 0) {
const t = -previous.offset_m / (point.offset_m - previous.offset_m);
kept.push({
offset_m: 0,
elevation_m: previous.elevation_m + (point.elevation_m - previous.elevation_m) * t,
});
}
kept.push(point);
});
if (kept.length && kept[0].offset_m > 1e-9) kept.unshift({ offset_m: 0, elevation_m: 0 });
const envelope = upperEnvelope(kept);
if (envelope.length < 2 || envelope[envelope.length - 1].offset_m < 1e-3) return null;
const sampled = resample(envelope, PIECE_COLS[kind]);
// 측점 단면 꼴로 — 좌측은 바깥거리 그대로, 우측은 부호를 뒤집어 오름차순(도로측 원점이 끝).
return side === "left"
? sampled
: sampled
.map((point) => ({ offset_m: -point.offset_m, elevation_m: point.elevation_m }))
.reverse();
}
/** 구조물 측점마다 그 측 실루엣을 구간 단면으로 — `stationProfileSource` 두 번째 인자. */
export function structureSlopes(sections: ReadonlyArray<CrossSection>): StructureSlope[] {
const slopes: StructureSlope[] = [];
for (const section of sections) {
const silhouettes = structureSilhouettes(section);
if (!silhouettes.length) continue;
const station = classifyStation(section);
const mode = section.design?.section_mode;
if (!station || !mode) continue;
for (const silhouette of silhouettes) {
const side = silhouette.side;
const role: DeformKind =
mode === "both_cut" || mode === (side === "left" ? "left_cut" : "right_cut")
? "cut"
: "fill";
const kind = silhouette.kind === "auto" ? role : silhouette.kind;
const anchor = anchorOf(station, side);
if (!anchor) continue;
const points = silhouetteSlope(silhouette.points, side, anchor, kind);
if (!points) continue;
slopes.push({
chainage_m: section.chainage_m,
side,
kind,
span: silhouette.span,
points,
endsAtStructure: silhouette.endsAtStructure === true,
});
}
}
return slopes;
}
/** 측점 프레임 축. */
function axisOf(section: CrossSection): StationAxis {
return {
cx: section.center_x,
cy: section.center_y,
leftX: section.frame.left_xy[0],
leftY: section.frame.left_xy[1],
};
}
/** 그 측점에 선 구조물 한 벌. */
function setOf(
section: CrossSection,
structures: ReadonlyArray<CorridorStructure>,
): CorridorStructure[] {
return structures.filter((s) => Math.abs(s.chainage_m - section.chainage_m) < 1e-6);
}
/** 지형 구멍 고리(모델 좌표) — 구조물 풋프린트 · BOX 아령 외곽. 도로부 안쪽은 뺀다. */
export function structureHoleLoops(
sections: ReadonlyArray<CrossSection>,
structures: ReadonlyArray<CorridorStructure>,
): Array<Array<[number, number]>> {
const loops: Array<Array<[number, number]>> = [];
for (const section of sections) {
const set = setOf(section, structures);
const edges = section.design?.road_edges;
if (!set.length || !edges) continue;
(["left", "right"] as const).forEach((side) => {
const roadEdge = side === "left" ? edges.left.offset_m : edges.right.offset_m;
structureFootprintLoops(set, side, roadEdge, 0, axisOf(section)).forEach((loop) =>
loops.push(loop.map(([x, y]) => [x, y] as [number, number])),
);
});
}
return loops;
}
/** 볼록 껍질(단조 사슬) — 날개 두 장의 링 중심을 감싸 날개 안쪽 유로 자리를 낸다. */
function convexHull(points: Array<[number, number]>): Array<[number, number]> {
const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
if (sorted.length < 3) return sorted;
const cross = (o: [number, number], a: [number, number], b: [number, number]): number =>
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const lower: Array<[number, number]> = [];
for (const point of sorted) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0)
lower.pop();
lower.push(point);
}
const upper: Array<[number, number]> = [];
for (let i = sorted.length - 1; i >= 0; i -= 1) {
const point = sorted[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0)
upper.pop();
upper.push(point);
}
return [...lower.slice(0, -1), ...upper.slice(0, -1)];
}
function polygonArea(loop: ReadonlyArray<[number, number]>): number {
let area = 0;
for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) {
area += loop[j][0] * loop[i][1] - loop[i][0] * loop[j][1];
}
return Math.abs(area) / 2;
}
/**
* 성토 금지 고리 — 측마다 날개(날개벽 · 날개 에이프런) 링 중심을 감싼 볼록 껍질.
* 천장 = 세월교 바닥판 상단(그 측 실루엣) · BOX 는 원지반.
*/
export function noFillLimits(
sections: ReadonlyArray<CrossSection>,
structures: ReadonlyArray<CorridorStructure>,
): DeformLimit[] {
const limits: DeformLimit[] = [];
for (const section of sections) {
const set = setOf(section, structures);
if (!set.length) continue;
const axis = axisOf(section);
const floors = new Map<DeformSide, number>();
structureSilhouettes(section).forEach((silhouette) => {
if (silhouette.floorTopZ !== undefined) floors.set(silhouette.side, silhouette.floorTopZ);
});
(["left", "right"] as const).forEach((side) => {
const centers: Array<[number, number]> = [];
for (const structure of set) {
const rings = structure.rings;
if (!structure.wing || structure.kind === "pipe" || !rings || rings.length < 2) continue;
const middle = rings[Math.floor(rings.length / 2)];
const offset = (middle.cx - axis.cx) * axis.leftX + (middle.cy - axis.cy) * axis.leftY;
if ((offset >= 0 ? "left" : "right") !== side) continue;
rings.forEach((ring) => centers.push([ring.cx, ring.cy]));
}
const loop = convexHull(centers);
if (loop.length < 3 || polygonArea(loop) < 0.01) return;
limits.push({ loop, ceiling: floors.get(side) ?? null });
});
}
return limits;
}
@@ -15,13 +15,23 @@ 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, scenePlanMapper, type PlanRegion } from "./B05_Profile_UI_Corridor_Region";
import {
CellRegion,
PlanLoopRegion,
scenePlanMapper,
type PlanRegion,
} from "./B05_Profile_UI_Corridor_Region";
import {
buildDeformField,
stationProfileSource,
type DeformField,
type DeformRow,
} from "./B05_Profile_UI_Corridor_Deform";
import {
noFillLimits,
structureHoleLoops,
structureSlopes,
} from "./B05_Profile_UI_Corridor_Deform_Structure";
/** 절토 · 성토 색 — 코리도 리본 색(`_Mesh.ts` KIND_COLORS)과 같은 값. */
const CUT_COLOR = new THREE.Color(0xb5179e);
@@ -50,6 +60,7 @@ class RoadStrip extends CellRegion {
/** 지형 변형 요약 — 화면 밖 수치 확인용(`__corridorDeform`). */
export interface DeformStats {
rows: number;
holes: number;
moved: number;
raised: number;
lowered: number;
@@ -110,9 +121,10 @@ export function deformTerrain(
build: CorridorBuildResult,
bounds: ModelBounds,
heightIndex: TerrainHeightIndex,
extraRegions: ReadonlyArray<PlanRegion> = [],
): { root: THREE.Object3D; stats: DeformStats } | null {
if (!build.sections?.length) return null;
const sections = build.sections;
if (!sections?.length) return null;
const structures = build.structures ?? [];
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;
@@ -120,11 +132,17 @@ export function deformTerrain(
const height = heightIndex.heightAt(x - ox, -(y - oy));
return height === null ? null : height + oz;
};
const field = buildDeformField(build, stationProfileSource(build.sections), groundAt);
// 구조물 구간 = 실루엣 상부 포락선 + 이웃 측점 테이퍼 · 날개 안쪽 성토 금지(115-2).
const source = stationProfileSource(sections, structureSlopes(sections));
const field = buildDeformField(build, source, groundAt, noFillLimits(sections, structures));
if (!field) return null;
const regions: PlanRegion[] = [new RoadStrip(field.rows, bounds), ...extraRegions];
// 지형 구멍 = 노면부 띠 + 구조물 풋프린트 · BOX 아령 외곽 — 솔리드가 그 자리를 메운다.
const regions: PlanRegion[] = [new RoadStrip(field.rows, bounds)];
const holes = new PlanLoopRegion(structureHoleLoops(sections, structures), bounds);
if (!holes.isEmpty) regions.push(holes);
const stats: DeformStats = {
rows: field.rows.length,
holes: regions.length - 1,
moved: 0,
raised: 0,
lowered: 0,
@@ -243,4 +243,182 @@ const results = {};
};
}
// ⑦ 115-2 상부 포락선 — 벽 앞면(수직)에서 안쪽으로 되돌아오는 계단은 위쪽만 남는다.
{
const { upperEnvelope } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Structure"));
const env = upperEnvelope(
[
[0, 0],
[2, 0],
[2.1, -3],
[1.8, -3.2],
[5, -4],
].map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
);
const at = (x) => env.find((p) => Math.abs(p.offset_m - x) < 1e-9)?.elevation_m;
results.envelope = {
monotone: env.every((p, i) => i === 0 || p.offset_m > env[i - 1].offset_m),
at18: r4(at(1.8)),
at21: r4(at(2.1)),
at5: r4(at(5)),
};
}
/** 구조물 구간 단면(앵커 기준 상대 · 좌측) — 48 열로 다시 뽑는다. */
function slopeOf(points, opts = {}) {
const { resample } = loadTs(B05("B05_Profile_UI_Corridor_Station"));
return {
chainage_m: 20,
side: "left",
kind: "fill",
span: { beforeM: 2, afterM: 2 },
points: resample(
points.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
48,
),
endsAtStructure: false,
...opts,
};
}
// ⑧ 115-2 구조물 구간 고정 + 이웃 측점 테이퍼 — 20 측점 좌측에 선반 단면(4 m 수평 뒤 1:0.5).
{
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
const build = buildCorridor(sections, ROUTE);
const shelf = slopeOf([
[0, 0],
[4, 0],
[6, -4],
]);
const source = deform.stationProfileSource(sections, [shelf]);
const field = deform.buildDeformField(build, source, () => 96);
results.span = {
in20: r4(field.deformZ(20, HALF + 2, 96)),
in19: r4(field.deformZ(19, HALF + 2, 96)),
taper10: r4(field.deformZ(10, HALF + 2, 96)),
taper30: r4(field.deformZ(30, HALF + 2, 96)),
right20: r4(field.deformZ(20, -HALF - 2, 96)),
};
}
// ⑨ 115-2 BOX 「본체 밖 원지반」 — 구조물에서 끝나는 단면은 구간 안에서 끝 너머를 늘리지 않는다.
{
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
const build = buildCorridor(sections, ROUTE);
const box = slopeOf(
[
[0, 0],
[3, -1],
],
{ endsAtStructure: true },
);
const field = deform.buildDeformField(
build,
deform.stationProfileSource(sections, [box]),
() => 96,
);
results.box = {
inside: r4(field.deformZ(20, HALF + 2, 96)),
outside: r4(field.deformZ(20, HALF + 5, 96)),
normal: r4(field.deformZ(5, HALF + 5, 96)),
};
}
// ⑩ 115-2 날개 안쪽 성토 금지 — 고리 안은 원지반(천장 있으면 천장까지)만.
{
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
const build = buildCorridor(sections, ROUTE);
const source = deform.stationProfileSource(sections);
const loop = [
[8, 4],
[12, 4],
[12, 8],
[8, 8],
];
const none = deform.buildDeformField(build, source, () => 96, [{ loop, ceiling: null }]);
const ceiling = deform.buildDeformField(build, source, () => 96, [{ loop, ceiling: 97 }]);
const { noFillLimits } = loadTs(B05("B05_Profile_UI_Corridor_Deform_Structure"));
const ring = (cx, cy) => ({ cx, cy, leftX: 0, leftY: 1, dz: 0 });
const wings = [
{ chainage_m: 20, kind: "revet", wing: "route", rings: [ring(18, 4), ring(16, 7)] },
{ chainage_m: 20, kind: "revet", wing: "route", rings: [ring(22, 4), ring(24, 7)] },
];
const limits = noFillLimits(sections, wings);
results.noFill = {
blocked: r4(none.deformZ(10, HALF + 2, 96)),
ceiling: r4(ceiling.deformZ(10, HALF + 2, 96)),
outsideLoop: r4(none.deformZ(20, HALF + 2, 96)),
limits: limits.length,
hullArea: limits.length ? limits[0].loop.length : 0,
};
}
// ⑪ 115-2 구조물 풋프린트 구멍 — 20 측점 좌측 벽(편거리 5~6 · 19~21 m) 자리 삼각형 없음.
{
const sections = [0, 20, 40].map((c) => station(c, "both_fill", 96, 1.5));
const build = buildCorridor(sections, ROUTE);
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],
[6, 95],
[6, 100],
[5, 100],
],
rings: [ring(19), ring(20), ring(21)],
},
];
const step = 0.5;
const cx = 20;
const cz = 100;
const positions = [];
const nx = 121;
const ny = 81;
for (let j = 0; j < ny; j += 1) {
for (let i = 0; i < nx; i += 1) positions.push(-10 + i * step - cx, 96 - cz, -(-20 + j * step));
}
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.Float32BufferAttribute(positions, 3));
geometry.setIndex(index);
const terrain = new THREE.Mesh(geometry, new THREE.MeshLambertMaterial());
const split = new TerrainBandSplit(terrain, { minX: -25, maxX: 25, minZ: -15, maxZ: 15 });
const out = deformTerrain(
split,
build,
{ x: [-10, 50], y: [-20, 20], z: [90, 110] },
new TerrainHeightIndex(terrain),
);
let inHole = 0;
let nearHole = 0;
out.root.traverse((child) => {
if (!(child instanceof THREE.Mesh) || !child.userData.owned) return;
const p = child.geometry.getAttribute("position");
const idx = child.geometry.getIndex();
for (let t = 0; t < idx.count; t += 3) {
let sx = 0;
let sy = 0;
for (let k = 0; k < 3; k += 1) {
sx += p.getX(idx.getX(t + k)) + cx;
sy += -p.getZ(idx.getX(t + k));
}
sx /= 3;
sy /= 3;
if (sx > 19.05 && sx < 20.95 && sy > 5.05 && sy < 5.95) inHole += 1;
if (sx > 19.05 && sx < 20.95 && sy > 6.05 && sy < 6.6) nearHole += 1;
}
});
results.hole = { holes: out.stats.holes, inHole, nearHole };
}
process.stdout.write(JSON.stringify(results));
@@ -74,3 +74,41 @@ def test_three_copy_only_xy_fixed_hole_and_tint(results):
assert r["probe"] == FILL_2M
assert r["moved"] > 0 and r["raised"] == r["moved"]
assert r["keepCount"] > 0 and r["tinted"] == r["keepCount"]
def test_upper_envelope_keeps_top_of_wall_steps(results):
r = results["envelope"]
assert r["monotone"] is True
assert r["at18"] == 0 # 안쪽으로 되돌아온 계단(-3.2)보다 위쪽 마디(0)
assert r["at21"] == -3
assert r["at5"] == -4
def test_structure_span_fixed_and_tapers_to_neighbours(results):
r = results["span"]
assert r["in20"] == 100 and r["in19"] == 100 # 구간(18~22) 안 = 선반 단면 고정
assert FILL_2M < r["taper10"] < 100 # 구간 끝 → 이웃 측점 테이퍼
assert r["taper30"] == r["taper10"]
assert r["right20"] == FILL_2M # 반대 측은 측점 단면 그대로
def test_box_outside_body_stays_original_ground(results):
r = results["box"]
assert r["inside"] == round(100 - 2 / 3, 4)
assert r["outside"] == 96 # 단면 끝 너머 = 원지반(연장 안 함)
assert 96 < r["normal"] < 100 # 구간 밖 테이퍼 행은 그대로 변형
def test_no_fill_ring_inside_wings(results):
r = results["noFill"]
assert r["blocked"] == 96
assert r["ceiling"] == 97
assert r["outsideLoop"] == FILL_2M
assert r["limits"] == 1 and r["hullArea"] == 4
def test_structure_footprint_hole(results):
r = results["hole"]
assert r["holes"] == 1
assert r["inHole"] == 0 # 풋프린트 안 지형 삼각형 없음
assert r["nearHole"] > 0 # 바로 바깥은 남는다