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:
@@ -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 # 바로 바깥은 남는다
|
||||
|
||||
Reference in New Issue
Block a user