Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_5

This commit is contained in:
2026-10-30 14:54:36 +09:00
10 changed files with 602 additions and 91 deletions
+10 -7
View File
@@ -42,13 +42,15 @@ const GRID_M = 2;
* 잇는 띠를 1.5~2.5 m 로 늘림. 변 판정(boundary.touches)은 그대로. */
export const SEAM_M = 1.5;
/** 노면부 띠 — 행마다 좌우 노면 끝 사이 사각형(리본이 덮는 자리 · 망에서 뺀다). */
/** 노면부 띠 — 행마다 좌우 노면 끝 사이 사각형(리본이 덮는 자리 · 망에서 뺀다) · 여러 벌이면 띠 전부. */
class RoadStrip extends CellRegion {
constructor(road: TinDesign["road"]) {
constructor(roads: ReadonlyArray<TinDesign["road"]>) {
super();
const p = ([x, z]: [number, number]) => ({ x, z });
for (let i = 0; i + 1 < road.left.length; i += 1) {
this.addCell(p(road.left[i]), p(road.right[i]), p(road.right[i + 1]), p(road.left[i + 1]));
for (const road of roads) {
for (let i = 0; i + 1 < road.left.length; i += 1) {
this.addCell(p(road.left[i]), p(road.right[i]), p(road.right[i + 1]), p(road.left[i + 1]));
}
}
this.buildIndex();
}
@@ -199,12 +201,13 @@ export function tinTerrain(
const design = designer(build, groundAt, bounds);
if (!design) return null;
const outer = new LoopIndex([design.outer]);
const outers = design.outers ?? [design.outer];
const outer = new LoopIndex(outers);
const holes = new LoopIndex(design.holes);
const inRegion = (x: number, z: number): boolean => outer.contains(x, z) || holes.contains(x, z);
const boundary = new SegGrid(GRID_M);
let segment = 0;
[design.outer, ...design.holes].forEach((loop) => {
[...outers, ...design.holes].forEach((loop) => {
for (let i = 0, j = loop.length - 1; i < loop.length; j = i, i += 1) {
boundary.add(segment, loop[j][0], loop[j][1], loop[i][0], loop[i][1]);
segment += 1;
@@ -276,7 +279,7 @@ export function tinTerrain(
}
const { triangles, failed } = pslg.triangulate();
const road = new RoadStrip(design.road);
const road = new RoadStrip(design.roads ?? [design.road]);
const positions: number[] = [];
const colors: number[] = [];
const keep: number[] = [];
+14
View File
@@ -253,6 +253,19 @@ export function createRouteMarkers(
warnings: Array<{ polyline_start_index: number; polyline_end_index: number }> = [],
): void {
disposeGroup(routeGroup);
drawRoute(polyline, warnings);
}
/** 여러 선(D03 노선망 · PLAN 601-2) — 앞 선들을 버리고 한꺼번에 · 경고 구간 없음. */
function renderRoutes(polylines: Array<Array<{ x: number; y: number; z?: number }>>): void {
disposeGroup(routeGroup);
polylines.forEach((polyline) => drawRoute(polyline, []));
}
function drawRoute(
polyline: Array<{ x: number; y: number; z?: number }>,
warnings: Array<{ polyline_start_index: number; polyline_end_index: number }>,
): void {
const bounds = getBounds();
if (!bounds || polyline.length < 2) return;
// 정점 사이를 잘게 나눠 **각 점의 지형고를 찍어** 선을 지면에 붙인다(2026-09-06).
@@ -551,6 +564,7 @@ export function createRouteMarkers(
},
renderMarkers,
renderRoute,
renderRoutes,
renderStationLines,
selectStation,
setStationLinesVisible(visible: boolean) {
+3
View File
@@ -82,6 +82,8 @@ export interface RouteViewer {
* 이 호출 뒤에 할 것. null이면 코리도 제거.
*/
setCorridor: (build: CorridorBuildResult | null) => void;
/** 여러 벌(D03 노선망 · PLAN 601-2) — 벌마다 노면 · 지형 잘림 한 번 · 빈 배열 = 지움. */
setCorridors: (builds: CorridorBuildResult[]) => void;
/** [예상형상] 토글 — ON: 클리핑 지형+코리도(공사 후), OFF: 원본 지형 완전체. */
setCorridorVisible: (visible: boolean) => void;
renderStationLines: (stations: SectionStationMarker[], halfWidth: number) => void;
@@ -494,6 +496,7 @@ export function createRouteViewer(): RouteViewer {
corridor.setSurfaceVisible(visible);
},
setCorridor: corridor.set,
setCorridors: corridor.setMany,
setCorridorVisible: corridor.setVisible,
setSurfaceGrayscale(grayscale) {
if (surfaceGrayscale === grayscale) return;
+97 -29
View File
@@ -3,6 +3,8 @@
* 3D 뷰어의 예상형상(코리도) 조각 — 원본/예상형상 지형 스왑 + 계획 서피스 조합.
* 예상형상 = 제안 ③(측점 횡단 기반 사면 · `_Loft.ts`) 하나 · 노면부만 코리도로 그린다.
* ③ 을 못 만들면(높이 색인 없음 · 측점 부족) 원지형 그대로.
* 여러 벌(D03 노선망 · PLAN 601-2 `setMany`) = 벌마다 노면 그룹 · 지형 잘림은 벌마다 설계를 한 PSLG 로 합쳐 한 번에.
* 한 벌(`set`)은 옛 길 그대로(B05 화면 동작 같음).
*
* 뷰어 본체(`B05_Profile_UI_Viewer.ts`)가 700줄을 넘어 이 부분만 뗐다(계획서 24-3a).
* 지형 · 범위 · 높이 색인은 뷰어가 쥐고, 여기서는 받아 읽기만 한다.
@@ -15,7 +17,11 @@ import { createCorridorGroup } from "./B05_Profile_UI_Corridor_Mesh";
import { setCorridorBuildSummary } from "./B05_Profile_UI_Viewer_Debug";
import type { TerrainHeightIndex } from "./B05_Profile_UI_Corridor_Terrain";
import { BAND_MARGIN_M, TerrainBandSplit, type SceneBox } from "./B05_Profile_UI_Corridor_Split";
import { loftTerrain } from "./B05_Profile_UI_Corridor_Loft";
import { loftDesign, loftTerrain } from "./B05_Profile_UI_Corridor_Loft";
import { smoothSlopeNormals } from "./B05_Profile_UI_Corridor_Loft_Shade";
import { tinTerrain, type TinDesigner } from "./B05_Profile_UI_Corridor_Tin";
import type { TinDesign } from "./logic/B05_Profile_UI_Corridor_Tin_Design";
import { Pslg } from "./logic/B05_Profile_UI_Corridor_Tin_Pslg";
/** 서피스 삼각형 수 — 예상형상 지형이 실제로 바뀌었는지 확인하는 계측용. */
function countTriangles(root: THREE.Object3D | null): number {
@@ -81,6 +87,8 @@ export interface ViewerCorridorDeps {
export interface ViewerCorridor {
set: (build: CorridorBuildResult | null) => void;
/** 여러 벌 — 빈 배열 = 지움 · 한 벌 = `set` 과 같음. */
setMany: (builds: CorridorBuildResult[]) => void;
setVisible: (visible: boolean) => void;
setSurfaceVisible: (visible: boolean) => void;
/** 지형이 바뀌기 전 — 밴드 분할본을 버린다. */
@@ -100,9 +108,57 @@ function roadOnly(build: CorridorBuildResult): CorridorBuildResult {
};
}
/** 벌마다 설계를 한 PSLG 로 — 점 · 변 옮겨 담고 벌 사이 교차를 다시 나눔 · 둘레 · 띠 = 벌마다(합집합). */
function mergeDesigns(designs: TinDesign[]): TinDesign | null {
if (designs.length <= 1) return designs[0] ?? null;
const pslg = new Pslg();
const stats = { rows: 0, sections: 0, bowtie: 0, bendLines: 0, holes: 0, splits: 0 };
for (const design of designs) {
const from = design.pslg;
const ids = from.x.map((x, i) => pslg.addPoint(x, from.z[i], from.y[i], from.tag[i]));
from.kinds.forEach((kind, e) =>
pslg.addEdge(ids[from.edges[e * 2]], ids[from.edges[e * 2 + 1]], kind),
);
(Object.keys(stats) as Array<keyof typeof stats>).forEach((key) => {
stats[key] += design.stats[key];
});
}
stats.splits += pslg.splitCrossings();
return {
pslg,
outer: designs[0].outer,
holes: designs.flatMap((design) => design.holes),
road: designs[0].road,
outers: designs.map((design) => design.outer),
roads: designs.map((design) => design.road),
stats,
};
}
/** 여러 벌 제안 ③ 지형 — 벌마다 `loftDesign` → 합침 → TIN 한 번. */
function loftTerrainMany(
split: TerrainBandSplit,
builds: CorridorBuildResult[],
bounds: ModelBounds,
heightIndex: TerrainHeightIndex,
): ReturnType<typeof loftTerrain> {
const usable = builds.filter((build) => build.sections?.length);
if (!usable.length) return null;
const designer: TinDesigner = (_build, groundAt, box) =>
mergeDesigns(
usable.flatMap((build) => {
const design = loftDesign(build, groundAt, box);
return design ? [design] : [];
}),
);
return tinTerrain(split, usable[0], bounds, heightIndex, designer, (mesh) =>
smoothSlopeNormals(mesh),
);
}
export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
const { scene } = deps;
let corridorBuild: CorridorBuildResult | null = null;
let corridorBuilds: CorridorBuildResult[] = [];
let corridorGroup: THREE.Group | null = null;
let clippedTerrain: THREE.Object3D | null = null;
/** 지형 near/far 분할본 — 편집마다 노선 주변만 재트림하려고 1회만 만든다. */
@@ -110,7 +166,7 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
let corridorOn = true; // [예상형상] 기본 ON — 계획서피스가 보이는 게 기본값.
let surfaceOn = true; // 기존 [지표면] 토글 상태(코리도 스왑과 조합).
/** 코리도 그룹에 그린 빌드(노면부만). */
let drawnBuild: CorridorBuildResult | null = null;
let drawnBuilds: CorridorBuildResult[] = [];
/** 표시 상태 일괄 적용 — 클리핑본이 준비되기 전에는 원본 지형을 그대로 둔다. */
function applyCorridorVisibility(): void {
@@ -133,8 +189,8 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
clippedTerrain = null;
}
/** 코리도를 넉넉히 감싼 밴드(씬 좌표) — 이 안쪽만 편집마다 다시 트림한다. */
function corridorBand(build: CorridorBuildResult): SceneBox | null {
/** 코리도를 넉넉히 감싼 밴드(씬 좌표) — 이 안쪽만 편집마다 다시 트림한다(여러 벌 = 모두 감쌈). */
function corridorBand(builds: CorridorBuildResult[]): SceneBox | null {
const bounds = deps.bounds();
if (!bounds) return null;
const ox = (bounds.x[0] + bounds.x[1]) / 2;
@@ -143,23 +199,25 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
let maxX = -Infinity;
let minZ = Infinity;
let maxZ = -Infinity;
[...build.outline.left, ...build.outline.right].forEach(([mx, my]) => {
const x = mx - ox;
const z = -(my - oy);
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (z < minZ) minZ = z;
if (z > maxZ) maxZ = z;
});
builds
.flatMap((build) => [...build.outline.left, ...build.outline.right])
.forEach(([mx, my]) => {
const x = mx - ox;
const z = -(my - oy);
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (z < minZ) minZ = z;
if (z > maxZ) maxZ = z;
});
if (!Number.isFinite(minX)) return null;
return { minX, maxX, minZ, maxZ };
}
/** 밴드 분할본 확보 — 코리도가 밴드를 벗어났을 때만 다시 가른다. */
function ensureBandSplit(build: CorridorBuildResult): TerrainBandSplit | null {
function ensureBandSplit(builds: CorridorBuildResult[]): TerrainBandSplit | null {
const terrain = deps.terrain();
if (!terrain) return null;
const box = corridorBand(build);
const box = corridorBand(builds);
if (!box) return null;
if (bandSplit?.covers(box)) return bandSplit;
bandSplit?.dispose();
@@ -182,21 +240,26 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
/** 클리핑본 재생성 — 무거우므로 코리도 표시 후 다음 프레임에 수행(비동기 스왑). */
function scheduleTerrainClip(): void {
disposeClippedTerrain();
if (!corridorBuild || !deps.terrain() || !deps.bounds()) {
if (!corridorBuilds.length || !deps.terrain() || !deps.bounds()) {
applyCorridorVisibility();
return;
}
const buildAtSchedule = corridorBuild;
const buildsAtSchedule = corridorBuilds;
requestAnimationFrame(() => {
const terrain = deps.terrain();
const bounds = deps.bounds();
// 예약 사이에 코리도가 교체·제거됐으면 이 클립은 폐기한다.
if (corridorBuild !== buildAtSchedule || !terrain || !bounds) return;
const drawn = drawnBuild ?? buildAtSchedule;
if (corridorBuilds !== buildsAtSchedule || !terrain || !bounds) return;
const [buildAtSchedule] = buildsAtSchedule;
const drawn = drawnBuilds.length ? drawnBuilds : buildsAtSchedule;
const split = ensureBandSplit(drawn);
if (!split) return;
const heightIndex = deps.heightIndex();
const shaped = heightIndex ? loftTerrain(split, buildAtSchedule, bounds, heightIndex) : null;
const shaped = !heightIndex
? null
: buildsAtSchedule.length === 1
? loftTerrain(split, buildAtSchedule, bounds, heightIndex)
: loftTerrainMany(split, buildsAtSchedule, bounds, heightIndex);
(window as unknown as { __corridorDeform?: unknown }).__corridorDeform =
shaped?.stats ?? null;
disposeClippedTerrain();
@@ -221,14 +284,18 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
applyCorridorVisibility();
}
function set(build: CorridorBuildResult | null): void {
function setMany(builds: CorridorBuildResult[]): void {
disposeCorridorGroup();
corridorBuild = build;
corridorBuilds = builds;
const bounds = deps.bounds();
drawnBuild = build && bounds ? roadOnly(build) : null;
setCorridorBuildSummary(drawnBuild ?? build);
if (drawnBuild && bounds) {
corridorGroup = createCorridorGroup(drawnBuild, bounds);
drawnBuilds = bounds ? builds.map(roadOnly) : [];
setCorridorBuildSummary(drawnBuilds[0] ?? builds[0] ?? null);
if (drawnBuilds.length && bounds) {
if (drawnBuilds.length === 1) corridorGroup = createCorridorGroup(drawnBuilds[0], bounds);
else {
corridorGroup = new THREE.Group();
drawnBuilds.forEach((drawn) => corridorGroup!.add(createCorridorGroup(drawn, bounds)));
}
scene.add(corridorGroup);
deps.onGroup();
}
@@ -236,7 +303,8 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
}
return {
set,
set: (build) => setMany(build ? [build] : []),
setMany,
setVisible(visible) {
corridorOn = visible;
applyCorridorVisibility();
@@ -250,12 +318,12 @@ export function createViewerCorridor(deps: ViewerCorridorDeps): ViewerCorridor {
bandSplit = null;
},
rebuild() {
if (corridorBuild) set(corridorBuild);
if (corridorBuilds.length) setMany(corridorBuilds);
},
group: () => corridorGroup,
clipped: () => clippedTerrain,
dispose() {
corridorBuild = null; // 예약된 클립 콜백 무효화.
corridorBuilds = []; // 예약된 클립 콜백 무효화.
disposeCorridorGroup();
disposeClippedTerrain();
bandSplit?.dispose();
@@ -24,6 +24,9 @@ export interface TinDesign {
holes: Array<Array<[number, number]>>;
/** 노면부 띠 행(씬 평면) — 좌 · 우 노면 끝. */
road: { left: Array<[number, number]>; right: Array<[number, number]> };
/** 여러 벌(D03 노선망 · PLAN 601-2) — 있으면 `outer` · `road` 대신 이 둘레 · 띠 전부(합집합). */
outers?: Array<Array<[number, number]>>;
roads?: Array<TinDesign["road"]>;
stats: {
rows: number;
sections: number;
+97 -50
View File
@@ -1,25 +1,47 @@
/* =============================================================================
* D03_PlanAnalysis_UI_3D.ts
* D03 [3D] 큰 모달(PLAN 530-3 · 563-1) — 사업 전체 DEM + 그은 선 하나(지반에 붙임) + 측점 막대.
* 선 고름 = 모달 위 칸(본선 · 갈래) · 뷰어 = B05 `createRouteViewer` 그대로 · 닫으면 버림(WebGL 문맥까지).
* 예상 코리도 없음(꺾은선 · 계획고 = 지반고라 절성토 0 — 요구서 389 3장 11).
* D03 [3D] 큰 모달(PLAN 530-3 · 563-1 · 601-3 · 요구 463-21) — 사업 전체 DEM + 노선망 전체(본선 · 갈래) 한 번에.
* 선마다 예상 코리도 = 측점 예상 단면(`logic/D03_PlanAnalysis_Expect` · 리핑암 · 산쪽 절토 · 일반 측구 ·
* 계획고 = 지반고) → B05 `buildCorridor` → 뷰어 `setCorridors`(벌마다 노면 · 지형 잘림 한 번) ·
* 갈래 머리 = 부모 노면만큼 잘라 냄 · 뷰어 = B05 `createRouteViewer` · 닫으면 버림(WebGL 문맥까지) · 보기만(저장 없음).
* ========================================================================== */
import "../B05_Profile/B05_Profile_UI_Style.css";
import { createSelectField } from "@ui/ui_template_input";
import { openModal } from "@ui/ui_template_modal";
import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements";
import type { DtmGrid } from "../common_util/common_util_surface_sampler";
import { createRouteViewer, type RouteViewer } from "../B05_Profile/B05_Profile_UI_Viewer";
import { sectionsGround } from "../B05_Profile/logic/B05_Profile_Sections_Ground";
import { buildCorridor } from "../B05_Profile/logic/B05_Profile_Corridor_Build";
import type { CorridorBuildResult } from "../B05_Profile/logic/B05_Profile_Corridor_Build";
import type { SectionStationMarker } from "../B05_Profile/B05_Profile_UI_Markers";
import { fetchSectionContext } from "../B06_Section/B06_Section_Api_Context";
import type { CrossSection } from "../B06_Section/B06_Section_Api_Types";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
import {
branchTrimM,
expectLine,
expectSections,
type ExpectStandard,
} from "./logic/D03_PlanAnalysis_Expect";
export interface Plan3dLine {
name: string;
line: Vertex[];
/** 선 위 측점 누가(선마다 No.0 부터). */
stations: number[];
/** 갈래(다른 선에 붙은 선) — 머리를 부모 노면만큼 잘라 지음. */
branch: boolean;
}
/** 표준 횡단 기본값(config · 서버) — 못 받으면 null(선만). */
function loadExpectStandard(projectId: string): Promise<ExpectStandard | null> {
return fetchSectionContext(projectId).then(
(context) => ({
standard: context.standard_cross_section as ExpectStandard["standard"],
rockBoundaryM: context.rock_boundary_default_offset_m,
}),
() => null,
);
}
/** 격자 → 뷰어 상자(지형에 붙는 선 · 막대는 높이 원점과 무관 · x · y 만 메시와 맞으면 됨). */
@@ -46,58 +68,84 @@ export function openPlan3d(
projectId: string,
surface: { modelId: number; grid: DtmGrid },
lines: Plan3dLine[],
intervalM: number,
): void {
if (!lines.length) return void showToast("3D 로 볼 선이 없습니다.", "warning");
let viewer: RouteViewer | null = createRouteViewer();
let loaded = false;
const pick = createSelectField({
options: lines.map((item, i) => ({ value: String(i), text: item.name })),
value: "0",
size: 28,
ariaLabel: "선",
}).select;
pick.classList.add("d03-plan__3d-year");
const top = document.createElement("div");
top.className = "d03-plan__3d-top";
top.append(pick);
const standard = loadExpectStandard(projectId);
const load = async (index: number): Promise<void> => {
const item = lines[index] ?? lines[0];
const load = async (): Promise<void> => {
if (!viewer) return;
const progress = createProgressCircle({ label: "3D 지형을 불러오는 중…", overlay: true });
progress.set(null);
viewer.root.append(progress.root);
const started = performance.now();
const stat: Record<string, unknown> = { line: item.name, done: false };
const stat: Record<string, unknown> = { lines: lines.length, done: false };
(window as unknown as { __d03Plan3d?: unknown }).__d03Plan3d = stat;
try {
if (!loaded) {
await viewer.loadSurface(
projectId,
surface.modelId,
"dtm",
true,
5,
gridBounds(surface.grid),
);
loaded = true;
}
const [std] = await Promise.all([
standard,
viewer.loadSurface(projectId, surface.modelId, "dtm", true, 5, gridBounds(surface.grid)),
]);
if (!viewer) return;
viewer.markers.renderRoute(item.line.map(([x, y]) => ({ x, y })));
const ground = sectionsGround(item.line, item.stations, surface.grid);
viewer.renderStationLines(
ground.stations.map((station, i) => ({
station_id: String(item.stations[i] ?? station.chainage_m),
center_x: station.center_x,
center_y: station.center_y,
center_z: station.center_z,
frame: { left_xy: station.left_xy },
kind: "regular",
chainage_m: item.stations[i] ?? station.chainage_m,
})),
2.5,
);
stat.stations = ground.stations.length;
stat.surfaceMs = Math.round(performance.now() - started);
viewer.markers.renderRoutes(lines.map((item) => item.line.map(([x, y]) => ({ x, y }))));
const built = performance.now();
const trim = std ? branchTrimM(std) : 0;
const markers: SectionStationMarker[] = [];
const builds: CorridorBuildResult[] = [];
const perLine: Array<Record<string, unknown>> = [];
lines.forEach((item, index) => {
const plan = expectLine(item.line, item.branch ? trim : 0, intervalM);
if (!plan) return;
const ground = sectionsGround(plan.line, plan.stations, surface.grid);
ground.stations.forEach((station, i) => {
const chainage = (plan.stations[i] ?? station.chainage_m) + plan.offsetM;
markers.push({
station_id: `${index}:${chainage}`,
center_x: station.center_x,
center_y: station.center_y,
center_z: station.center_z,
frame: { left_xy: station.left_xy },
kind: "regular",
chainage_m: chainage,
});
});
if (!std) return;
const sections = expectSections(
ground.stations,
std,
surface.grid,
) as unknown as CrossSection[];
const build = buildCorridor(
sections,
plan.line.map(([x, y]) => ({ x, y })),
);
if (build) {
build.sections = sections; // 지형 잘림이 읽음
builds.push(build);
}
perLine.push({
name: item.name,
branch: item.branch,
offsetM: plan.offsetM,
sections: sections.length,
corridor: !!build,
// 노면 중심(설계선 오프셋 0) − 측점 지반고
centerDelta: sections.map((c) =>
c.center_z === null ? null : (c.design?.design_elevation_m ?? NaN) - c.center_z,
),
});
});
viewer.renderStationLines(markers, 2.5);
viewer.setCorridors(builds);
Object.assign(stat, {
trimM: trim,
corridors: builds.length,
stations: markers.length,
buildMs: Math.round(performance.now() - built),
perLine,
});
viewer.setView("iso");
} catch (error) {
showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error");
@@ -107,16 +155,15 @@ export function openPlan3d(
progress.remove();
}
};
pick.addEventListener("change", () => void load(Number(pick.value)));
openModal({
title: "3D 보기 — 지형 + 그은 선",
title: "3D 보기 — 지형 + 노선망(표준 횡단)",
closeLabel: "닫기",
wide: true,
dialogClass: "d03-plan__3d-modal",
mount: (body) => {
body.classList.add("d03-plan__3d-body");
body.append(top, viewer!.root);
body.append(viewer!.root);
viewer!.setActive(true);
},
beforeClose: () => {
@@ -125,5 +172,5 @@ export function openPlan3d(
return true;
},
});
void load(0);
void load();
}
+2 -3
View File
@@ -59,7 +59,6 @@ import { createPlanMenu } from "./D03_PlanAnalysis_UI_Menu";
import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points";
import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces";
import { createPlanProps } from "./D03_PlanAnalysis_UI_Props";
import { lineLength, stationChainages } from "./logic/D03_PlanAnalysis_Draw";
import {
emptyNetwork,
fromServer,
@@ -519,10 +518,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
.map((line, index) => ({
name: names[index],
line: line.vertices,
stations: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M),
branch: line.branch !== null,
}))
.filter((item) => item.line.length > 1);
openPlan3d(projectId, surface, lines);
openPlan3d(projectId, surface, lines, PLAN_STATION_INTERVAL_M);
}
// 검증 창구 — 꼴 · 미리보기 · 제약 · 화면 자리 · 재기 · 회전 · 저장 시간(ORCA 판정)
@@ -0,0 +1,162 @@
/* =============================================================================
* D03_PlanAnalysis_Expect.ts
* D03 예상 횡단(PLAN 544-1 · 601-1 · 요구 363-1 · 463-21) — 측점 지반 → 표준 횡단 기본값 설계선 · 절 · 성토 ㎡.
* 셈 = 공용 `computeCrossDesign`(① 짝 `compute_cross_design` · 거울 `test_b06_cross_design_mirror.py`)
* · 기본 = B 초기 설계와 같음(리핑암 · 산쪽 절토 · 일반 측구 · 암 경계 = 서버 기본) · 계획고 = 측점 중심 지반고
* · 미교차 = `extendUnclosed`(격자 있을 때) · 곡선 확폭 없음 · 보기만(저장 없음).
* 3D = 같은 단면을 B05 `buildCorridor` 가 읽는 측점 꼴로(`expectSections`) · 노선망 전체 = 선마다(`expectLine`) ·
* 갈래 머리 = 부모 노면(차도 반 + 노견)만큼 잘라 냄(두 노면 겹침 막음).
* 시험 `resources/tester/test_544_1_plan_expect.py`.
* ========================================================================== */
import {
computeCrossDesign,
type CrossDesignOptions,
type CrossDesignResult,
type StandardCrossSectionSpec,
} from "../../common_util/common_util_cross_design";
import type { DtmGrid } from "../../common_util/common_util_surface_sampler";
import {
extendUnclosed,
type CrossGroundPoint,
type StationGround,
} from "../../B05_Profile/logic/B05_Profile_Sections_Ground";
import { lineLength, sliceLine, stationChainages, type Vertex } from "./D03_PlanAnalysis_Draw";
/** 서버 `sections/context` 의 표준 횡단(config) · 암 경계 기본 오프셋. */
export interface ExpectStandard {
standard: StandardCrossSectionSpec;
rockBoundaryM: number | null;
}
export interface ExpectCross {
design: CrossDesignResult;
/** 설계에 쓴 지반 표본(미교차로 넓혔으면 넓힌 것). */
samples: CrossGroundPoint[];
}
/** B 초기 설계 기본 — 리핑암 · 산쪽 절토(모르면 좌) · 일반 측구. */
export function expectOptions(
uphill: "left" | "right" | null,
std: ExpectStandard,
): CrossDesignOptions {
return {
groundType: "ripping_rock",
sectionMode: `${uphill ?? "left"}_cut`,
ditchType: "standard",
standard: std.standard,
rockBoundaryOffsetM: std.rockBoundaryM,
};
}
/** 측점 하나 예상 단면 — 지반고 · 표본이 없으면 null. */
export function expectCross(
station: StationGround,
std: ExpectStandard,
grid?: DtmGrid | null,
): ExpectCross | null {
const elevation = station.center_z;
if (elevation === null) return null;
const options = expectOptions(station.uphill_side, std);
try {
let samples = station.samples;
let design = computeCrossDesign(samples, elevation, options);
const wide =
design.slope_unclosed && grid
? extendUnclosed(
samples,
[station.center_x, station.center_y],
station.left_xy,
(next) => computeCrossDesign(next, elevation, options),
grid,
)
: null;
if (wide) {
samples = wide.samples as CrossGroundPoint[];
design = computeCrossDesign(samples, elevation, options);
}
return { design, samples };
} catch {
return null; // 표본 부족 — B 와 같은 태도(그 측점 건너뜀)
}
}
/** B06 `CrossSection` 중 `buildCorridor` 가 읽는 칸(B06 형 파일은 별칭 import 라 여기 안 끌어옴). */
export interface ExpectSection {
station_id: string;
chainage_m: number;
label: string;
kind: "regular";
center_z: number | null;
azimuth_deg: null;
center_x: number;
center_y: number;
uphill_side: "left" | "right" | null;
frame: { left_xy: [number, number] };
samples: CrossGroundPoint[];
design: CrossDesignResult;
}
/** 3D 코리도 입력 — 측점마다 예상 단면(못 세운 측점 뺌) · 누가 = 연도 노선 위 누가. */
export function expectSections(
stations: StationGround[],
std: ExpectStandard,
grid?: DtmGrid | null,
): ExpectSection[] {
return stations.flatMap((station) => {
const cross = expectCross(station, std, grid);
if (!cross) return [];
return [
{
station_id: String(station.chainage_m),
chainage_m: station.chainage_m,
label: "",
kind: "regular",
center_z: station.center_z,
azimuth_deg: null,
center_x: station.center_x,
center_y: station.center_y,
uphill_side: station.uphill_side,
frame: { left_xy: station.left_xy },
samples: cross.samples,
design: cross.design,
},
];
});
}
/** 갈래 머리 잘라 낼 길이(m) — 리핑암 표준 차도 반 + 넓은 노견(부모 노면 안). */
export function branchTrimM(std: ExpectStandard): number {
const rock = std.standard.rock;
if (!rock) return 0;
return (
(rock.road_width_m ?? 0) / 2 + Math.max(rock.shoulder_left_m ?? 0, rock.shoulder_right_m ?? 0)
);
}
/** 3D 한 선 — 그릴 꺾은선 · 그 위 측점(꺾은선 첫 점 = 0) · 원래 누가 = 측점 + `offsetM`. */
export interface ExpectLine {
line: Vertex[];
offsetM: number;
stations: number[];
}
/** 선 하나 3D 입력 — 측점 = 원래 누가(`intervalM`) · `trimM` > 0(갈래)이면 머리를 잘라 낸 자리부터.
* 남는 길이가 1 m 안 되면 null. */
export function expectLine(
vertices: ReadonlyArray<Vertex>,
trimM: number,
intervalM: number,
): ExpectLine | null {
const length = lineLength(vertices);
const offsetM = Math.max(0, trimM);
if (vertices.length < 2 || length - offsetM < 1) return null;
const line = offsetM > 0 ? sliceLine(vertices, offsetM, length) : [...vertices];
const stations = [
0,
...stationChainages(length, intervalM)
.filter((m) => m > offsetM + 1e-6)
.map((m) => m - offsetM),
];
return { line, offsetM, stations };
}
+212
View File
@@ -0,0 +1,212 @@
"""D03 예상 횡단(PLAN 544-1 · 601-1) — TS `logic/D03_PlanAnalysis_Expect.ts` 값 = 파이썬 `compute_cross_design`.
기본 = 리핑암 · 산쪽 절토(모르면 좌) · 일반 측구 · 암 경계 = config 기본 · 계획고 = 측점 중심 지반고.
미교차 = 격자로 넓힌 표본 · 넓힌 표본으로 파이썬이 다시 셈한 값과 같음 · 코리도 측점 꼴.
노선망 3D(601) = 선마다 측점 · 갈래 머리 = 리핑암 차도 반 + 노견만큼 잘라 냄.
./venv/Scripts/python.exe -m pytest resources/tester/test_544_1_plan_expect.py -q
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
from config.config_system_design import ( # noqa: E402
STANDARD_CROSS_SECTION,
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Expect.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import * as expect from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.js";
const job = JSON.parse(readFileSync(process.argv[2], "utf8"));
const std = { standard: job.standard, rockBoundaryM: job.rock };
// 평면 격자 — z = 100 + slope * y(좌 축 = +y) · 1 m · ±100 m
const n = 201;
const axis = Float64Array.from({ length: n }, (_, i) => i - 100);
const z = new Float64Array(n * n);
for (let iy = 0; iy < n; iy += 1)
for (let ix = 0; ix < n; ix += 1) z[iy * n + ix] = 100 + job.wideSlope * axis[iy];
const grid = { x: axis, y: axis, z, validBits: new Uint8Array(Math.ceil((n * n) / 8)).fill(255) };
const cross = job.stations.map((s) => expect.expectCross(s, std));
const wide = expect.expectCross(job.wide, std, grid);
const sections = expect.expectSections([...job.stations, job.wide], std, grid);
const trim = expect.branchTrimM(std);
const lines = [
expect.expectLine([[0, 0], [50, 0]], 0, 20),
expect.expectLine([[0, 0], [30, 0], [30, 20]], trim, 20),
expect.expectLine([[0, 0], [1.5, 0]], trim, 20),
];
writeFileSync(process.argv[3], JSON.stringify({ cross, wide, sections, trim, lines }));
"""
def _station(slope: float, uphill: str | None, *, half: float = 20.0) -> dict:
samples = []
count = int(half * 2 / 0.5) + 1
for i in range(count):
offset = -half + i * 0.5
z = round(100.0 + slope * offset, 6)
samples.append(
{"offset_m": offset, "x": 0.0, "y": offset, "z": z, "elevation_m": z, "valid": True}
)
return {
"chainage_m": 40.0,
"center_x": 0.0,
"center_y": 0.0,
"center_z": 100.0,
"tangent_xy": [1.0, 0.0],
"left_xy": [0.0, 1.0],
"uphill_side": uphill,
"samples": samples,
}
_STATIONS = [
_station(0.30, "left"),
_station(-0.45, "right"),
_station(0.05, None),
{**_station(0.2, "left"), "center_z": None},
]
_WIDE_SLOPE = 0.8
def _run_ts(tmp_path: Path) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(TS_FILE),
"--outDir",
str(out),
"--rootDir",
str(ROOT),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
job = tmp_path / "job.json"
result = tmp_path / "out.json"
job.write_text(
json.dumps(
{
"standard": STANDARD_CROSS_SECTION,
"rock": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
"stations": _STATIONS,
"wide": _station(_WIDE_SLOPE, "left"),
"wideSlope": _WIDE_SLOPE,
}
),
encoding="utf-8",
)
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(job), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def _python(samples: list[dict], elevation: float, uphill: str | None) -> dict:
return compute_cross_design(
samples,
elevation,
ground_type="ripping_rock",
section_mode=f"{uphill or 'left'}_cut",
ditch_type="standard",
standard=STANDARD_CROSS_SECTION,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
def _same(ts: dict, py: dict) -> None:
assert ts["section_mode"] == py["section_mode"]
assert ts["ground_type"] == "ripping_rock" and ts["ditch_type"] == "standard"
assert abs(ts["cut_area_m2"] - py["cut_area_m2"]) < 1e-9
assert abs(ts["fill_area_m2"] - py["fill_area_m2"]) < 1e-9
assert len(ts["design_line"]) == len(py["design_line"])
for a, b in zip(ts["design_line"], py["design_line"]):
assert abs(a["offset_m"] - b["offset_m"]) < 1e-9
assert abs(a["elevation_m"] - b["elevation_m"]) < 1e-9
def test_expect_matches_python(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
for station, ts in zip(_STATIONS, got["cross"]):
if station["center_z"] is None:
assert ts is None # 지반고 없음 = 못 세움
continue
py = _python(station["samples"], station["center_z"], station["uphill_side"])
_same(ts["design"], py)
assert ts["design"]["design_elevation_m"] == station["center_z"]
# 산쪽 절토 · 모르면 좌
assert [c and c["design"]["section_mode"] for c in got["cross"]] == [
"left_cut",
"right_cut",
"left_cut",
None,
]
# 비탈 표본 — 절토 · 성토 둘 다 섬
assert got["cross"][0]["design"]["cut_area_m2"] > 0
assert got["cross"][0]["design"]["fill_area_m2"] > 0
def test_unclosed_extends_and_sections(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
wide = got["wide"]
narrow = _python(_station(_WIDE_SLOPE, "left")["samples"], 100.0, "left")
assert narrow["slope_unclosed"] is True # ±20 m 안에선 안 닫힘
assert wide["design"]["slope_unclosed"] is False
assert min(s["offset_m"] for s in wide["samples"]) < -20.0
_same(wide["design"], _python(wide["samples"], 100.0, "left"))
# 코리도 측점 = 세운 측점만(지반고 없는 측점 뺌) · 좌 축 · 설계 실음
sections = got["sections"]
assert len(sections) == 4
assert all(s["frame"]["left_xy"] == [0.0, 1.0] and s["design"]["design_line"] for s in sections)
assert sections[-1]["samples"] == wide["samples"]
def test_network_lines_trim_branch_head(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
rock = STANDARD_CROSS_SECTION["rock"]
trim = rock["road_width_m"] / 2 + max(rock["shoulder_left_m"], rock["shoulder_right_m"])
assert abs(got["trim"] - trim) < 1e-12 # 3.0 / 2 + 0.5 = 2.0
main, branch, short = got["lines"]
# 본선 — 그대로 · 측점 0 · 20 · 40 · 끝 50
assert main["offsetM"] == 0 and main["line"] == [[0, 0], [50, 0]]
assert main["stations"] == [0, 20, 40, 50]
# 갈래 — 머리 2 m 잘라 냄 · 원래 누가 20 · 40 · 50 → 18 · 38 · 48 · 꺾임점 남음
assert branch["offsetM"] == trim
assert branch["line"][0] == [trim, 0] and branch["line"][-1] == [30, 20]
assert [30, 0] in branch["line"]
assert [round(m + trim, 9) for m in branch["stations"]] == [trim, 20, 40, 50]
# 남는 길이 1 m 안 됨 = 못 그림
assert short is None
+2 -2
View File
@@ -81,8 +81,8 @@ def test_d03_parts():
props = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts") # 585-2 조각 연도 = 속성 판
assert "createSelectField(" in props and "createFloatPanel(" in props and "createKv(" in props
assert 'createElement("button")' not in props and 'createElement("select")' not in props
assert 'createElement("select")' not in three and "createSelectField" in three
assert "createCheckbox" not in three and "buildCorridor" not in three # 예상 코리도 걷음
assert 'createElement("select")' not in three and "createSelectField" not in three # 601 선 고름 걷음
assert "createCheckbox" not in three and "setCorridors(" in three # 601 노선망 예상 코리도 한 번에
for dead in (".d03-plan__bar", ".d03-plan__mode", "is-picked"):
assert dead not in css, dead
assert "b05-route__3d-modal" not in three and "b05-route__3d-body" not in three # 빌림 끊음