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

# Conflicts:
#	B06_Section/B06_Section_UI_Cross_Areas.ts
#	B06_Section/B06_Section_UI_Style_Cross_Areas.css
This commit is contained in:
2026-10-17 15:29:53 +09:00
42 changed files with 354 additions and 1774 deletions
+1 -123
View File
@@ -1,13 +1,11 @@
/* =============================================================================
* B06_Section_Cross_Enforce.ts
* 계산 묶음 cross 단계의 [확정] 보정(PLAN 349-0n) — 서버 `_enforce_stored_designs` 차례 그대로
* (미교차 넓힘 → 포장 구간 → 세월교 노면 하강 → 사토장 맨 뒤). 넓힘은 `B06_Section_Cross_Stage`.
* (미교차 넓힘 → 포장 구간 → 세월교 노면 하강). 넓힘은 `B06_Section_Cross_Stage`.
*
* facilityRanges · facilityAt 새 노선 관 · 구조물 자리 → 측점마다 포장 · 기울임 · 노면 하강.
* ⚠ ① 짝 — `B06_Section_Router_Design.py` `pavement_ranges` · `ford_surface_drops` · `paved_at` ·
* `ford_drop_at` + `B06_Section_Engine_Tilt.py` `tilt_ranges` · `tilt_args`.
* spoilSites · enforceSpoilFills 사토장 기준 측점 하나 · 용량에서 폭 하나(이분법).
* ⚠ ① 짝 — `B06_Section_Engine_SpoilFill.py` `spoil_sites` · `enforce_spoil_fills`.
* 거울 시험 `resources/tester/test_349_0n_cross_enforce_mirror.py`.
* 세트 · 포장 · 맹암거 제원은 이미 있는 짝(`buildCulvertSets` · `pavementSpecsFrom` · `underdrainSpecsFrom`).
* 순수 — 화면 · fetch · 세션 없음.
@@ -81,123 +79,3 @@ export function facilityAt(ranges: FacilityRanges, chainageM: number) {
surfaceDropM: ranges.drops.find(([at]) => Math.abs(chainageM - at) <= EDGE_M)?.[1] ?? 0,
};
}
/* ── 사토장 ───────────────────────────────────────────────────────────── */
const SOLVE_STEPS = 24;
const MAX_PROBE_WIDTH_M = 1000;
const SIDE_AUTO = "자동(성토 쪽)";
/** 단면 유형 → [좌, 우] 성토면인가(파이썬 `structure_face_role` 표). */
const FILL_SIDES: Record<string, [boolean, boolean]> = {
left_cut: [false, true],
right_cut: [true, false],
both_cut: [false, false],
both_fill: [true, true],
};
export interface SpoilSite {
structureId: string | null;
chainageM: number;
capacityM3: number;
sideOption: unknown;
slopeRatioN: unknown;
extraDistanceM: unknown;
}
/** 배치된 사토장만 — 기준 측점 · 용량 > 0(서버 정본 읽기 = 구조물 객체 길 · 시작 측점으로 갈음 안 함). */
export function spoilSites(structures: readonly FacilityStructure[]): SpoilSite[] {
return structures.flatMap((item) => {
if (catalogRow(item.type_id)?.shape !== "spoil") return [];
const options = (item.options ?? {}) as Record<string, unknown>;
const chainage = num(item.chainage_m);
const capacity = num(options.capacity_m3);
if (chainage === null || capacity === null || capacity <= 0) return [];
return [
{
structureId: item.structure_id ?? null,
chainageM: chainage,
capacityM3: capacity,
sideOption: options.side,
slopeRatioN: options.fill_slope_ratio,
extraDistanceM: options.extra_distance_m,
},
];
});
}
/** 쌓는 쪽 — 「좌 · 우」 그대로 · 「자동」 · 빈칸 = 그 측점 성토면(좌 먼저) · 못 가르면 null. */
export function spoilSideOf(
design: Record<string, unknown>,
option: unknown,
): "left" | "right" | null {
const word = String(option ?? "").trim();
if (word === "좌") return "left";
if (word === "우") return "right";
if (word && word !== SIDE_AUTO) return null;
const sides = FILL_SIDES[String(design.section_mode ?? "")];
if (!sides) return null;
return sides[0] ? "left" : sides[1] ? "right" : null;
}
type Design = Record<string, unknown>;
/** 측점 하나 — 설계 + 사토장 폭으로 다시 셈(못 세면 null). */
export interface SpoilStation {
chainageM: number;
design: Design | null;
recompute: (side: "left" | "right", widthM: number, slopeRatioN: unknown) => Design | null;
}
const round4 = (value: number) => Math.round(value * 1e4) / 1e4;
/** 사토장 측점 설계를 다시 셈 — `stations[i].design` 을 갈아 끼움 · 바뀐 측점 수. */
export function enforceSpoilFills(stations: SpoilStation[], sites: readonly SpoilSite[]): number {
const chainages = [...new Set(stations.map((s) => s.chainageM))].sort((a, b) => a - b);
const spanOf = (here: number) => {
const index = chainages.indexOf(here);
const left = index ? chainages[index - 1] : here;
const right = index + 1 < chainages.length ? chainages[index + 1] : here;
return (right - left) / 2;
};
let changed = 0;
for (const site of sites) {
if (!stations.length) continue;
// 기준 측점에 가장 가까운 측점 하나(가운데면 앞 측점)
const station = stations.reduce((best, s) => {
const gap = Math.abs(s.chainageM - site.chainageM);
const bestGap = Math.abs(best.chainageM - site.chainageM);
return gap < bestGap || (gap === bestGap && s.chainageM < best.chainageM) ? s : best;
});
const span = spanOf(station.chainageM);
const side = spoilSideOf(station.design ?? {}, site.sideOption);
const volume = (widthM: number): [number, Design | null] => {
if (side === null || widthM <= 0 || !station.design) return [0, null];
const design = station.recompute(side, widthM, site.slopeRatioN);
return [design ? (num(design.spoil_fill_area_m2) ?? 0) * span : 0, design];
};
const limit = num(volume(MAX_PROBE_WIDTH_M)[1]?.spoil_fill_max_width_m) ?? 0;
if (limit <= 0) continue;
let [total, design] = volume(limit);
if (total > site.capacityM3) {
let low = 0;
let high = limit;
for (let step = 0; step < SOLVE_STEPS; step += 1) {
const mid = (low + high) / 2;
if (volume(mid)[0] < site.capacityM3) low = mid;
else high = mid;
}
[total, design] = volume(round4(high));
}
if (!design) continue;
station.design = {
...design,
spoil_fill_capacity_m3: round4(site.capacityM3),
spoil_fill_placed_m3: round4(total),
spoil_fill_unplaced_m3: round4(Math.max(site.capacityM3 - total, 0)),
spoil_fill_structure_id: site.structureId,
spoil_fill_extra_distance_m: site.extraDistanceM,
};
changed += 1;
}
return changed;
}
+4 -46
View File
@@ -9,8 +9,8 @@
* · 국부 연산 = 측점 열쇠마다 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 확폭 · 표준값) — 같으면 앞 결과 그대로.
* · 바뀐 범위(`ctx.range`) 밖이고 앞 출력(`ctx.prev`)에 있고 계획고가 그대로면 셈 없이 재사용 — 노선 초안의
* 첫 계획선은 바뀐 구간 앞 꼭짓점까지 움직이므로 범위만 보면 낡은 설계가 남음(349-0f).
* · [확정] 보정 ② 포장 구간 ③ 세월교 노면 하강 = 새 노선 관 · 구조물 자리(입력 pipes · structures)로 판정 ·
* ④ 사토장 = 맨 뒤(`B06_Section_Cross_Enforce` · 서버 `_enforce_stored_designs` 차례 · 349-0n).
* · [확정] 보정 ② 포장 구간 ③ 세월교 노면 하강 = 새 노선 관 · 구조물 자리(입력 pipes · structures)로 판정
* (`B06_Section_Cross_Enforce` · 서버 `_enforce_stored_designs` 차례 · 349-0n).
* · 단계는 자기 출력만 돌려줌(입력 · 앞 단계 출력은 읽기만). DOM · fetch · 세션 없음.
* input.cross 모양 = `CrossStageInput`(부르는 쪽이 세션 선택 · 저장분 · 구조물 맥락을 측점 열쇠로 모아 넘김).
* ========================================================================== */
@@ -28,13 +28,7 @@ import { groundSamples } from "./B06_Section_Station_Swap";
import { extendUnclosed } from "../../B05_Profile/logic/B05_Profile_Sections_Ground";
import type { DtmGrid } from "@util/common_util_surface_sampler";
import type { CulvertPipePoint } from "@util/common_util_culvert_sets";
import {
enforceSpoilFills,
facilityAt,
facilityRanges,
spoilSites,
type FacilityStructure,
} from "./B06_Section_Cross_Enforce";
import { facilityAt, facilityRanges, type FacilityStructure } from "./B06_Section_Cross_Enforce";
export { groundSamples };
@@ -122,6 +116,7 @@ export function createCrossStage(deps: CrossStageDeps = {}) {
for (const station of ground.stations) {
const kept = ctx.prev?.byKey[station.key];
const elevation = planElevationAt(profile, station.chainageM);
// 옛 사토장 값이 남은 앞 출력은 다시 셈(사토 단면 셈 지움 · 380-1)
const spoiled =
Number((kept?.detail as Record<string, unknown> | null)?.spoil_fill_width_m) > 0;
if (kept && !spoiled && !inRange(station, ctx) && sameSpot(kept, station, elevation)) {
@@ -169,43 +164,6 @@ export function createCrossStage(deps: CrossStageDeps = {}) {
last.computed += 1;
}
for (const key of [...memo.keys()]) if (!byKey[key]) memo.delete(key); // 사라진 측점
// ④ 사토장 — 맨 뒤 · 기준 측점 하나만 다시 셈(단계 기억은 사토장 전 설계)
const sites = Array.isArray(structures) ? spoilSites(structures) : [];
if (sites.length) {
const list = ground.stations.map((station) => {
const entry = byKey[station.key] as CrossStageResult | undefined;
const recompute = (side: "left" | "right", widthM: number, ratio: unknown) => {
try {
const samples = (entry?.samples ?? groundSamples(station)) as ReturnType<
typeof groundSamples
>;
return computeCrossDesign(samples, planElevationAt(profile, station.chainageM), {
...optionsOf(station),
spoilFill: { side, widthM, slopeRatioN: ratio as number | null },
}) as unknown as Record<string, unknown>;
} catch {
return null;
}
};
return {
chainageM: station.chainageM,
design: (entry?.detail as Record<string, unknown> | undefined) ?? null,
recompute,
};
});
enforceSpoilFills(list, sites);
list.forEach(({ design }, index) => {
const key = ground.stations[index].key;
const entry = byKey[key];
if (!entry || !design || design === entry.detail) return;
byKey[key] = {
...entry,
cutAreaM2: Number(design.cut_area_m2),
fillAreaM2: Number(design.fill_area_m2),
detail: design,
};
});
}
return { byKey };
}
@@ -64,7 +64,6 @@ export function pickLines(
return {
design: [polyline(design?.design_line)],
ground: groundRuns(samples),
spoil: [polyline(design?.spoil_fill_line)],
rock: rockRuns(design, samples),
};
}