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

This commit is contained in:
2026-10-17 12:07:29 +09:00
8 changed files with 611 additions and 45 deletions
@@ -103,6 +103,16 @@ class _SectionGeometry:
low_left = ground_at(self.left_extent) < ground_at(-self.right_extent)
self.slope_per_offset = -slope if low_left else slope
# 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 노견(폭 고정) · 측구 · 사면이
# 같이 바깥으로 · 늘어난 폭은 차도 · 단면유형 · 측구 판정은 **옮긴 뒤** 노견에서(아래 ·
# 370-1 — 마루를 넘으면 그 쪽 사면 기울기로 원지반까지). ⚠ TS 짝 `SectionGeometry`.
self.shoulder_extend_left = max(shoulder_extend_left_m, 0.0)
self.shoulder_extend_right = max(shoulder_extend_right_m, 0.0)
self.half_road_left += self.shoulder_extend_left
self.half_road_right += self.shoulder_extend_right
self.left_extent += self.shoulder_extend_left
self.right_extent += self.shoulder_extend_right
# 단면 유형 자동 판정(D-2): 각 측 절/성토 역할을 노면 끝 지반이 설계면보다
# 높은지(절토)/낮은지(성토)로 결정한다. 좌절/우절/양절/양성이 모두 지형에서
# 자연 도출된다. 사용자 입력 section_mode는 측구 방향(ditch_side) 기본값에만 쓰고
@@ -156,16 +166,6 @@ class _SectionGeometry:
if tilted:
self.has_ditch = False
# 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전**
# 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도.
# ⚠ TS 짝 `SectionGeometry`.
self.shoulder_extend_left = max(shoulder_extend_left_m, 0.0)
self.shoulder_extend_right = max(shoulder_extend_right_m, 0.0)
self.half_road_left += self.shoulder_extend_left
self.half_road_right += self.shoulder_extend_right
self.left_extent += self.shoulder_extend_left
self.right_extent += self.shoulder_extend_right
# 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
self.ditch_points: list[tuple[float, float]] = []
edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent
+72 -24
View File
@@ -12,6 +12,8 @@
* 구조물은 노견 끝에 서서 한 벌로 감) · 도로측(노면 · 노견) = 높이만(▲▼ = 종단 계획고).
* 소단 수평 단(194) = 그 쪽 깊이만(▲▼ · ◀▶ 막음 · 노면 끝 위 금지) · 좌측 소단 상자 켬.
* 고름은 조각 키(갈래 + 쪽 + 차례)로 기억 — 옮겨 다시 그려도 같은 조각(194-2).
* 교차점 손잡이(370-2) — 쪽마다 설계선 ∩ 원지반 원(키 `toe:left|right` · 늘 보임 · ◀▶) · ◀▶ 가 노견 끝 =
* 원지반 자리를 지나면 한 번 섬(`extendStop`) · 사면 키가 절 ↔ 성으로 바뀌면 같은 쪽 손잡이가 이어받음.
* ========================================================================== */
import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch";
@@ -27,10 +29,13 @@ import {
makeRow,
} from "./B06_Section_UI_Cross_Panel_Base";
import { bindCenterShift, centerHit, centerPreviewText } from "./B06_Section_UI_Cross_Center";
import { extendStop, toePoints } from "./logic/B06_Section_UI_Cross_Fit";
const SVG_NS = "http://www.w3.org/2000/svg";
const DESIGN_CLASS = "b06-chart__design-cross";
const HIT_CLASS = "b06-chart__design-hit";
/** 교차점 손잡이 원(370-2) — 중심점 판정 원 클래스에 덧붙임(고르기 차례 같음). */
const TOE_CLASS = "b06-chart__toe-hit";
/** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */
export const DESIGN_LINE_ATTR = "data-design-line";
/** 보라 선을 덮는 벽 · 본체 폴리곤. */
@@ -282,7 +287,10 @@ const keysByStation = new Map<string, string[]>();
export function pickedLineAt(stationId: string): number | null {
if (picked?.stationId !== stationId) return null;
const keys = keysByStation.get(stationId);
const index = keys ? keys.indexOf(picked.key) : Number(picked.key);
let index = keys ? keys.indexOf(picked.key) : Number(picked.key);
// 사면이 절 ↔ 성으로 넘어가 키가 바뀌거나 길이 0 으로 사라지면 같은 쪽 교차점 손잡이(370-2)
const slope = /^(?:cut|fill)(?:\+s)?:(left|right):/.exec(picked.key);
if (index < 0 && keys && slope) index = keys.indexOf(`toe:${slope[1]}`);
return index >= 0 ? index : null;
}
export function setPickedLine(stationId: string, index: number | null): void {
@@ -312,7 +320,12 @@ const pointsOf = (element: Element): XY[] => {
export interface ShoulderShift {
valueOf: (side: CrossSide) => number;
set: (side: CrossSide, valueM: number) => void;
/** 한 걸음 now → next 사이 노견 끝 = 원지반 자리가 있으면 그 값(370-2 · 없으면 next). */
stopAt?: (side: CrossSide, now: number, next: number) => number;
}
/** 이동값 글 — 0.1 m 눈금이면 한 자리 · 멈춤 자리(370-2)면 두 자리. */
const extendText = (valueM: number): string =>
valueM.toFixed(Math.abs(valueM * 10 - Math.round(valueM * 10)) < 1e-6 ? 1 : 2);
/** 쉬프트 걸음(m). */
export const SHIFT_STEP_M = 0.1;
@@ -345,9 +358,15 @@ function tellBermHost(chainageM: number | null): void {
/** 카드 → 고름 이동 창구 — 노견 확장(139-6) · 소단 깊이(194-1) 둘 다 카드 버튼과 같은 창구. */
export function designLineMoves(
section: Pick<CrossSection, "station_id" | "chainage_m" | "design">,
section: Pick<CrossSection, "station_id" | "chainage_m" | "design" | "samples">,
onDesignChange?: (chainageM: number, change: CrossDesignChange) => void,
): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl; center?: number } {
): {
stationId: string;
shift?: ShoulderShift;
berm?: BermDepthControl;
center?: number;
toes?: ReturnType<typeof toePoints>;
} {
const design = section.design;
if (!onDesignChange || !design) return { stationId: section.station_id };
const send = (change: Partial<CrossDesignChange>) =>
@@ -361,7 +380,9 @@ export function designLineMoves(
shift: {
valueOf: (side) => design[`shoulder_extend_${side}_m`] ?? 0,
set: (side, valueM) => send({ shoulder_extend: { side, valueM } }),
stopAt: (side, now, next) => extendStop(section.samples, design.road_edges, side, now, next),
},
toes: toePoints(section), // 교차점 손잡이(370-2)
berm: {
set: (side, valueM) => send({ berm_depth: { side, valueM } }),
// 끔은 이 측점이 켠 때만 — 다른 카드 소단을 고르며 옛 카드가 풀릴 때 새 켬을 안 지움
@@ -400,6 +421,8 @@ export function attachDesignLinePick(input: {
stationId?: string;
/** 있으면 중심점 고름(끝 조각 · 키 `center`) — 그 측점 누가거리(356-3). */
center?: number;
/** 있으면 쪽마다 교차점 손잡이(설계선 ∩ 원지반 · 키 `toe:left|right` · 늘 보임 · 370-2). */
toes?: ReturnType<typeof toePoints>;
}): DesignLinePick {
const { layer, design, x, toDisplayY } = input;
const x0 = x(0);
@@ -429,7 +452,12 @@ export function attachDesignLinePick(input: {
: edges.right.elevation_m -
((edges.left.elevation_m - edges.right.elevation_m) * edges.right.offset_m) /
(edges.left.offset_m - edges.right.offset_m);
const keys = [...pieceKeys(design, pieces), ...(centerZ === null ? [] : ["center"])];
const toeSides = (["left", "right"] as const).filter((at) => input.toes?.[at]);
const keys = [
...pieceKeys(design, pieces),
...(centerZ === null ? [] : ["center"]),
...toeSides.map((at) => `toe:${at}`),
];
if (input.stationId) keysByStation.set(input.stationId, keys);
// 판정 선은 구조물 판정면 아래에 꽂음 — 카드가 끝에 `stackPickTargets` 로 맨 위에 다시 세움(295-1).
@@ -448,6 +476,14 @@ export function attachDesignLinePick(input: {
// 중심점(356-3) = 끝 조각 · 판정 원은 고르기 차례가 보라 선 위로(`stackPickTargets`)
const center = centerZ === null ? null : centerHit(x0, toDisplayY(centerZ));
if (center) hits.push(center);
// 교차점 손잡이(370-2) — 쪽마다 원 · 늘 보임 · 고르면 그 쪽 사면 ◀▶(중심점 원과 같은 고르기 차례)
for (const at of toeSides) {
const toe = input.toes?.[at] as { offset_m: number; elevation_m: number };
const hit = centerHit(x(toe.offset_m), toDisplayY(toe.elevation_m));
hit.classList.add(TOE_CLASS);
hit.setAttribute("r", "5");
hits.push(hit);
}
hits.forEach((hit, index) => {
hit.addEventListener("click", (event) => {
event.stopPropagation(); // 카드 고름 · 면적 띠로 번지지 않음
@@ -486,7 +522,11 @@ export function attachDesignLinePick(input: {
: null;
const step = (dir: -1 | 1) => () => {
if (axis === "center") return centerShift?.step(dir);
if (side && shift) shift.set(side, shiftedExtend(shift.valueOf(side), side, dir, kx));
if (!side || !shift) return;
// 노견 끝 = 원지반(절도 성도 아닌 점)을 지나면 그 자리에 한 번 섬(370-2)
const now = shift.valueOf(side);
const next = shiftedExtend(now, side, dir, kx);
shift.set(side, shift.stopAt?.(side, now, next) ?? next);
};
// 툴팁 = 고른 것의 실제 동작(364-7 · 사면 = 노견째 이동 · 중심점 = 그 측점 노선)
const stepTitle = (screen: "왼쪽" | "오른쪽"): string =>
@@ -546,22 +586,29 @@ export function attachDesignLinePick(input: {
label,
panel: shell.root,
setActive: (index) => {
const onCenter = !!centerShift && index === pieces.length;
const piece = index === null ? undefined : pieces[index];
hits.forEach((hit, i) =>
hit.classList.toggle("is-active", (!!piece || onCenter) && i === index),
);
label.hidden = !piece && !onCenter;
active = piece || onCenter ? index : null;
side = piece ? pieceZone(design, piece).side : null;
const key = index === null ? undefined : keys[index];
const onCenter = !!centerShift && key === "center";
const toeSide = key?.startsWith("toe:") ? (key.slice(4) as CrossSide) : null;
const piece = index === null || index >= pieces.length ? undefined : pieces[index];
const chosen = !!piece || onCenter || !!toeSide;
hits.forEach((hit, i) => hit.classList.toggle("is-active", chosen && i === index));
label.hidden = !chosen;
active = chosen ? index : null;
side = piece ? pieceZone(design, piece).side : toeSide;
axis = onCenter ? "center" : piece ? pieceMoveAxis(design, piece) : "x";
const extend = side && shift && axis === "x" ? shift.valueOf(side) : 0;
const sideName = side === "left" ? "(좌)" : "(우)";
const moved = extend > 0 ? ` · 이동 +${extendText(extend)}m` : "";
const toe = toeSide ? input.toes?.[toeSide] : null;
label.textContent = onCenter
? "중심점"
: piece
? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "")
: "";
const open = onCenter || !!(piece && (axis === "depth" ? berm : shift));
: toe
? `교차점 ${sideName} · 노선에서 ${Math.abs(toe.offset_m).toFixed(2)}m${moved}`
: piece
? pieceLabel(design, piece) + moved
: "";
const open =
onCenter || (!!toeSide && !!shift) || !!(piece && (axis === "depth" ? berm : shift));
shell.root.classList.toggle("is-hidden", !open);
// 이동 셋 분리(194-2) — 사면 = 이동 줄(◀▶) · 소단 = 높이 줄(▲▼) · 노면 = 둘 다 숨김(▲▼ 종단)
// 중심점 = 이동 줄 ◀▶(노선 좌우 · ↺ 없음) · ▲▼ 는 종단도로(356-3)
@@ -573,15 +620,16 @@ export function attachDesignLinePick(input: {
[toLeft.title, toRight.title] = [stepTitle("왼쪽"), stepTitle("오른쪽")];
lift.row.classList.toggle("is-hidden", axis !== "depth");
lift.controls.classList.toggle("is-hidden", axis !== "depth");
const sideName = side === "left" ? "(좌)" : "(우)";
shell.title.textContent =
axis === "center"
? "중심점"
: axis === "y"
? "노면 높이"
: axis === "depth"
? `소단 높이 ${sideName}`
: `사면 이동 ${sideName}`;
: toeSide
? `교차점 이동 ${sideName}`
: axis === "y"
? "노면 높이"
: axis === "depth"
? `소단 높이 ${sideName}`
: `사면 이동 ${sideName}`;
readout.textContent =
axis === "center"
? centerPreviewText(0)
@@ -589,7 +637,7 @@ export function attachDesignLinePick(input: {
? "방향키 ▲▼ = 종단 계획고(종단도 함께)"
: axis === "depth" && side
? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m`
: `이동 ${extend.toFixed(1)}m`;
: `이동 ${extendText(extend)}m`;
berm?.focus(axis === "depth" && !!piece); // 좌측 소단 상자 켬 · 끔(194-2)
arrows.detach();
document.removeEventListener("keydown", blockOffAxis);
@@ -193,6 +193,18 @@
cursor: ew-resize;
}
/* 교차점 손잡이(370-2) — 사면 ∩ 원지반 작은 원 · 늘 보임 · 고르면 채움. */
.b06-chart__toe-hit {
fill: var(--color-surface);
stroke: var(--color-royal-amethyst);
stroke-width: 1.5;
}
.b06-chart__toe-hit.is-active {
fill: var(--color-royal-amethyst);
cursor: pointer;
}
/* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */
.b06-chart__carriageway-tick {
stroke: var(--color-royal-amethyst);
@@ -10,6 +10,8 @@
*
* 설계선이 지반과 만나기 전에 끝나면(계산 반폭 밖) **추세로 외삽**한다 — 끝단의 사면
* 기울기와 지반 기울기 차이로 만나는 자리를 추정한다.
*
* 교차점 손잡이 자리(`toePoints`) · 사면 이동 멈춤(`extendStop`)도 여기(370-2).
* ========================================================================== */
import type { CrossDesign, CrossSection } from "../B06_Section_Api_Fetch";
@@ -56,6 +58,60 @@ export function toeOffsets(section: CrossSection): { left: number | null; right:
return out;
}
/** 교차점 손잡이 자리(370-2) — `toeOffsets` 자리 · 표고 = 원지반. 못 만난 쪽은 null. */
export function toePoints(
section: Pick<CrossSection, "design" | "samples">,
): Record<"left" | "right", { offset_m: number; elevation_m: number } | null> {
if (!section.samples?.length) return { left: null, right: null };
const toes = toeOffsets(section as CrossSection);
const groundAt = groundInterpolator(section.samples);
const at = (offset: number | null) =>
offset === null || !groundAt ? null : { offset_m: offset, elevation_m: groundAt(offset) };
return { left: at(toes.left), right: at(toes.right) };
}
/** 멈춤 탐색 간격(m) — 사면 이동 걸음(0.1 m)보다 촘촘하게. */
const STOP_SCAN_M = 0.01;
/**
* 사면 이동 멈춤(370-2) — 그 쪽 노견 확장 `now` → `next` 사이에서 옮긴 노견 끝이 원지반과 딱 만나는
* (절토도 성토도 아닌) 확장값. 원지반 − 노면 평면(`road_edges` 두 점을 이은 선) 부호가 바뀌는 자리를
* 보간 · 못 찾거나 지금 그 자리면 `next` 그대로. 0.001 m 반올림.
*/
export function extendStop(
samples: CrossSection["samples"],
edges: CrossDesign["road_edges"] | undefined,
side: "left" | "right",
now: number,
next: number,
): number {
const groundAt = groundInterpolator(samples ?? []);
if (!groundAt || !edges || edges.left.offset_m === edges.right.offset_m) return next;
const { left, right } = edges;
const plane = (o: number) =>
right.elevation_m +
((left.elevation_m - right.elevation_m) * (o - right.offset_m)) /
(left.offset_m - right.offset_m);
const sign = side === "left" ? 1 : -1;
const base = Math.abs(edges[side].offset_m) - now; // 확장 0 노견 끝 거리
const gapAt = (extend: number) => {
const o = sign * (base + extend);
return groundAt(o) - plane(o);
};
let previous = gapAt(now);
if (Math.abs(previous) < 1e-4) return next;
const steps = Math.max(1, Math.ceil(Math.abs(next - now) / STOP_SCAN_M));
for (let i = 1; i <= steps; i += 1) {
const a = now + ((next - now) * (i - 1)) / steps;
const b = now + ((next - now) * i) / steps;
const gap = gapAt(b);
if (gap === 0 || Math.sign(gap) !== Math.sign(previous))
return Math.round((a + ((b - a) * previous) / (previous - gap)) * 1000) / 1000;
previous = gap;
}
return next;
}
/**
* 성토측 사면의 **본래** 경사길이(m) — 성토가 아닌 측은 null (2026-09-03 사용자 지시).
*
@@ -180,6 +180,16 @@ export class SectionGeometry {
this.slopePerOffset = lowLeft ? -slope : slope;
}
// 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 노견(폭 고정) · 측구 · 사면이 같이
// 바깥으로 · 늘어난 폭은 차도 · 단면유형 · 측구 판정은 **옮긴 뒤** 노견에서(아래 · 370-1 — 마루를
// 넘으면 그 쪽 사면 기울기로 원지반까지). ⚠ 파이썬 짝 `_SectionGeometry`.
this.shoulderExtendLeft = Math.max(params.shoulderExtendLeftM ?? 0, 0);
this.shoulderExtendRight = Math.max(params.shoulderExtendRightM ?? 0, 0);
this.halfRoadLeft += this.shoulderExtendLeft;
this.halfRoadRight += this.shoulderExtendRight;
this.leftExtent += this.shoulderExtendLeft;
this.rightExtent += this.shoulderExtendRight;
// 단면유형 자동 판정(D-2) — 노면 끝 지반이 설계면보다 높으면 절토, 낮으면 성토.
const groundAt = params.groundAt;
if (groundAt !== null) {
@@ -226,15 +236,6 @@ export class SectionGeometry {
// 물넘이포장 · 맹암거 자리(286-2 · 288-1) — 측구가 안 섬 · 선택은 그대로(파이썬 짝)
if (params.tilted) this.hasDitch = false;
// 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전**
// 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. ⚠ 파이썬 짝 `_SectionGeometry`.
this.shoulderExtendLeft = Math.max(params.shoulderExtendLeftM ?? 0, 0);
this.shoulderExtendRight = Math.max(params.shoulderExtendRightM ?? 0, 0);
this.halfRoadLeft += this.shoulderExtendLeft;
this.halfRoadRight += this.shoulderExtendRight;
this.leftExtent += this.shoulderExtendLeft;
this.rightExtent += this.shoulderExtendRight;
// 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
const edgeOffset = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent;
const outward = params.ditchSide === "left" ? 1 : -1;
+300
View File
@@ -0,0 +1,300 @@
/* PLAN 370-2 단위검증 헬퍼 — B06 사면 이동 멈춤 · 교차점 손잡이 · 고름 이어받기.
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
* 입력 = argv[2] 표준단면 JSON(파이썬 config) · 마루 지반(우측 −4 m 너머 내리막). 짝 = `test_370_2_toe_stop.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
const STUBS = {
"@ui/ui_template_toast": { showToast: () => undefined },
"@ui/ui_template_elements": { showToast: () => undefined },
};
class FakeNode {
constructor(tag) {
this.tagName = String(tag);
this.children = [];
this.attrs = {};
this.listeners = {};
this.hidden = false;
this.title = "";
this._text = "";
this._classes = new Set();
const self = this;
this.classList = {
add: (...names) => names.forEach((n) => self._classes.add(n)),
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
toggle: (name, force) => {
const on = force === undefined ? !self._classes.has(name) : !!force;
on ? self._classes.add(name) : self._classes.delete(name);
return on;
},
contains: (name) => self._classes.has(name),
};
this.style = { setProperty: () => {} };
}
set className(value) {
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
}
get className() {
return [...this._classes].join(" ");
}
set textContent(value) {
this._text = String(value);
this.children = [];
}
get textContent() {
return this._text + this.children.map((c) => c.textContent).join("");
}
get childElementCount() {
return this.children.length;
}
get isConnected() {
return true;
}
_detach(kid) {
if (kid.parent) kid.parent.children = kid.parent.children.filter((k) => k !== kid);
kid.parent = this;
}
append(...kids) {
kids.forEach((k) => {
if (typeof k === "string") return;
this._detach(k);
this.children.push(k);
});
}
prepend(...kids) {
kids.reverse().forEach((k) => {
this._detach(k);
this.children.unshift(k);
});
}
appendChild(kid) {
this.append(kid);
return kid;
}
insertBefore(kid, ref) {
this._detach(kid);
const at = ref ? this.children.indexOf(ref) : -1;
if (at < 0) this.children.push(kid);
else this.children.splice(at, 0, kid);
return kid;
}
remove() {
if (this.parent) this.parent.children = this.parent.children.filter((k) => k !== this);
}
querySelector() {
return null;
}
setAttribute(name, value) {
this.attrs[name] = String(value);
if (name === "class") this.className = value;
}
getAttribute(name) {
return name === "class" ? this.className : (this.attrs[name] ?? null);
}
hasAttribute(name) {
return name in this.attrs;
}
addEventListener(type, fn) {
(this.listeners[type] ??= []).push(fn);
}
click() {
const event = { stopPropagation: () => {}, preventDefault: () => {} };
(this.listeners.click ?? []).forEach((fn) => fn(event));
}
}
globalThis.document = {
createElement: (tag) => new FakeNode(tag),
createElementNS: (_ns, tag) => new FakeNode(tag),
addEventListener: () => {},
removeEventListener: () => {},
};
globalThis.window = { setTimeout, clearTimeout, addEventListener: () => {} };
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) {
const data = JSON.parse(fs.readFileSync(full, "utf8"));
cache.set(full, data);
return data;
}
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.endsWith(".css")) return {};
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
for (const [prefix, dir] of Object.entries(alias))
if (request.startsWith(prefix))
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
const pick = loadTs(B06("B06_Section_UI_Cross_Design_Pick"));
const fit = loadTs(B06("logic/B06_Section_UI_Cross_Fit"));
const { appendCrossDesignOverlay } = loadTs(B06("B06_Section_UI_Cross_Design"));
const { computeCrossDesign } = loadTs(
path.join(ROOT, "common_util", "common_util_cross_design.ts"),
);
const standard = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const out = {};
// 마루 지반 — 우측 −4 m 까지 평지(노면보다 높음 = 절토) · 그 너머 1:0.6 내리막
const samples = Array.from({ length: 49 }, (_, i) => {
const o = -12 + i * 0.5;
return { offset_m: o, elevation_m: 100.3 - 0.6 * Math.max(0, -o - 4), valid: true };
});
const groundAt = (o) => 100.3 - 0.6 * Math.max(0, -o - 4);
const designOf = (extendRight) => {
const result = computeCrossDesign(samples, 100, {
standard,
groundType: "soil",
sectionMode: "right_cut",
ditchSide: "right",
shoulderExtendRightM: extendRight,
});
// 화면 design 칸 이름(저장 · 응답과 같음)
return { ...result, shoulder_extend_right_m: extendRight, shoulder_extend_left_m: 0 };
};
const section = (extendRight) => ({
station_id: "S1",
chainage_m: 20,
samples,
design: designOf(extendRight),
});
// ① 멈춤 — 우측 노견 끝이 원지반을 지나는 한 걸음 사이 → 그 자리
const gapAt = (design, e) => {
const { left, right } = design.road_edges;
const o = -(Math.abs(right.offset_m) - (design.shoulder_extend_right_m ?? 0) + e);
const plane =
right.elevation_m +
((left.elevation_m - right.elevation_m) * (o - right.offset_m)) /
(left.offset_m - right.offset_m);
return groundAt(o) - plane;
};
const base = designOf(0);
let crossing = null;
for (let e = 0; e < 6; e = Math.round((e + 0.1) * 10) / 10) {
if (Math.sign(gapAt(base, e)) !== Math.sign(gapAt(base, e + 0.1))) {
crossing = [e, Math.round((e + 0.1) * 10) / 10];
break;
}
}
const stop = fit.extendStop(
samples,
designOf(crossing[0]).road_edges,
"right",
crossing[0],
crossing[1],
);
out.stop = {
crossing,
value: stop,
gap: Math.abs(gapAt(base, stop)),
noCross: fit.extendStop(samples, base.road_edges, "right", 0, 0.1),
// 멈춘 자리에서 다음 걸음은 그대로 감(다시 안 섬)
fromStop: fit.extendStop(
samples,
designOf(stop).road_edges,
"right",
stop,
Math.round((stop + 0.1) * 10) / 10,
),
leftNoGround: fit.extendStop([], base.road_edges, "left", 0, 0.1),
};
// ② 교차점 손잡이 — 쪽마다 원 · 늘 보임 · 키 toe:left|right · 고르면 ◀▶ 가 멈춤 자리로 보냄
const x = (o) => 300 - o * 20;
const y = (e) => 400 - (e - 100) * 20;
const attach = (sec, changes) => {
const layer = new FakeNode("g");
const from = layer.childElementCount;
appendCrossDesignOverlay(layer, sec.design, x, y, sec.samples, undefined);
layer.children.slice(from).forEach((el) => el.setAttribute(pick.DESIGN_LINE_ATTR, ""));
const picks = [];
const handle = pick.attachDesignLinePick({
layer,
design: sec.design,
x,
toDisplayY: y,
onPick: (i) => picks.push(i),
...pick.designLineMoves(sec, (_chainage, change) => changes.push(change)),
});
return { layer, handle, picks };
};
const changes = [];
const near = section(crossing[0]);
const { layer, handle, picks } = attach(near, changes);
const toes = layer.children.filter((k) => k.classList.contains("b06-chart__toe-hit"));
const keys = pick.designLineMoves(near, () => undefined).toes;
out.toe = {
count: toes.length,
centerClass: toes.every((t) => t.classList.contains("b06-chart__center-hit")),
sides: ["left", "right"].filter((s) => keys[s]),
rightOnGround:
keys.right && Math.abs(keys.right.elevation_m - groundAt(keys.right.offset_m)) < 1e-9,
rightCx: toes.length === 2 ? Number(toes[1].getAttribute("cx")) : null,
rightX: keys.right ? x(keys.right.offset_m) : null,
};
toes[1].click();
handle.setActive(picks[0]);
const right = handle.panel.children.find((k) =>
k.classList?.contains("b06-structure-panel__struct"),
);
const buttons = right.children[1].children;
const rightButton = x(-1) > x(0) ? buttons[2] : buttons[0]; // 화면 → 바깥(우 = −offset)
rightButton.click();
out.toePick = {
picked: picks,
active: toes[1].classList.contains("is-active"),
label: handle.label.textContent,
title: handle.panel.children[0].children[0].textContent,
panelOpen: !handle.panel.classList.contains("is-hidden"),
sent: changes.map((c) => c.shoulder_extend),
};
// ③ 고름 이어받기 — 우 절토 사면을 고른 뒤 마루 너머로 옮기면(키 cut → fill) 같은 쪽 손잡이
const cutIndex = handle.pieces.findIndex(
(p) =>
pick.pieceZone(near.design, p).zone === "cut" &&
pick.pieceZone(near.design, p).side === "right",
);
pick.setPickedLine("S1", null);
attach(near, []); // 키 적음
pick.setPickedLine("S1", cutIndex);
const before = pick.pickedLineAt("S1");
const far = section(crossing[1] + 1.0);
const after = attach(far, []);
const afterIndex = pick.pickedLineAt("S1");
out.inherit = {
cutIndex,
before,
farMode: far.design.section_mode,
afterIndex,
};
after.handle.setActive(afterIndex);
out.inherit.label = after.handle.label.textContent;
process.stdout.write(JSON.stringify(out));
+79
View File
@@ -0,0 +1,79 @@
"""PLAN 370-2 — B06 사면 이동 멈춤 · 교차점 손잡이 · 고름 이어받기(요구 240-14).
짝: `B06_Section/logic/B06_Section_UI_Cross_Fit.ts`(`extendStop` · `toePoints`) ·
`B06_Section/B06_Section_UI_Cross_Design_Pick.ts`(손잡이 · 키 이어받기). 실제 TS 를
`helper_370_2_toe_stop.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import pytest
HERE = os.path.dirname(__file__)
ROOT = os.path.join(HERE, "..", "..")
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
pytestmark = pytest.mark.skipif(
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
reason="프론트엔드 의존성 미설치",
)
@pytest.fixture(scope="module")
def out(tmp_path_factory) -> dict:
standard = tmp_path_factory.mktemp("std") / "standard.json"
standard.write_text(json.dumps(STANDARD_CROSS_SECTION), encoding="utf-8")
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_370_2_toe_stop.cjs"), str(standard)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_노견_끝이_원지반을_지나면_그_자리에_한_번_섬(out) -> None:
stop = out["stop"]
low, high = stop["crossing"]
assert low < stop["value"] < high
assert stop["gap"] < 2e-3 # 노견 끝 = 원지반
assert stop["noCross"] == 0.1 # 안 지나면 걸음 그대로
assert abs(stop["fromStop"] - round(stop["value"] + 0.1, 1)) < 1e-9 # 멈춘 뒤엔 다시 안 섬
assert stop["leftNoGround"] == 0.1 # 지반 없으면 그대로
def test_교차점_손잡이_쪽마다_늘_보임(out) -> None:
toe = out["toe"]
assert toe["count"] == len(toe["sides"]) == 2
assert toe["centerClass"] is True # 중심점 원과 같은 고르기 차례
assert toe["rightOnGround"] is True
assert abs(toe["rightCx"] - toe["rightX"]) < 1e-9
def test_손잡이를_고르면_그_쪽_사면_좌우_이동(out) -> None:
got = out["toePick"]
assert got["active"] is True and got["panelOpen"] is True
assert got["label"].startswith("교차점 (우) · 노선에서 ")
assert got["title"] == "교차점 이동 (우)"
assert len(got["sent"]) == 1 and got["sent"][0]["side"] == "right"
low, high = out["stop"]["crossing"]
assert abs(got["sent"][0]["valueM"] - out["stop"]["value"]) < 1e-9 # ◀▶ 도 멈춤 자리
assert low < got["sent"][0]["valueM"] < high
def test_절에서_성으로_넘어가도_고름_유지(out) -> None:
inherit = out["inherit"]
assert inherit["cutIndex"] >= 0 and inherit["before"] == inherit["cutIndex"]
assert inherit["farMode"] == "left_cut" # 우 = 성토로 넘어감
assert inherit["afterIndex"] is not None
assert inherit["label"].startswith("교차점 (우)")
@@ -61,6 +61,22 @@ def _dip_ground() -> list[dict]:
return samples
def _ridge_ground() -> list[dict]:
"""우측 −4 m 까지 노면보다 높은 평지(절토) · 그 너머 급히 내려가는 마루(370-1)."""
return [
{"offset_m": o, "elevation_m": round(100.3 - 0.6 * max(0.0, -o - 4.0), 4), "valid": True}
for o in (-12.0 + i * 0.5 for i in range(49))
]
def _rise_ground() -> list[dict]:
"""좌측 +3 m 까지 노면보다 낮은 평지(성토) · 그 너머 오르막(370-1)."""
return [
{"offset_m": o, "elevation_m": round(99.6 + 0.4 * max(0.0, o - 3.0), 4), "valid": True}
for o in (-12.0 + i * 0.5 for i in range(49))
]
# 갈래를 골고루 태운다 — 지반유형 셋 · 단면유형 · 측구 형식 · 포장 · 2단 경사 · 세월교
# 노면 하강 · 곡선부 확폭(바깥쪽 좌/우) · 지반선 결측 구간.
CASES = [
@@ -214,8 +230,8 @@ CASES = [
},
},
{
# 노견 끝 근처에서 지반이 설계면을 넘는 자리 — 단면유형 · 측구 판정은 옮기기 전 노견에서
"name": "노견 확장 — 판정은 옮기기 전 노견",
# 노견 끝 근처에서 지반이 설계면을 넘는 자리 — 단면유형 · 측구 판정은 옮긴 노견에서(370-1)
"name": "노견 확장 — 판정은 옮긴 노견",
"samples": _ground(-0.05),
"design_elevation_m": 100.0,
"options": {
@@ -226,6 +242,30 @@ CASES = [
"shoulder_extend_left_m": 2.5,
},
},
{
# 370-1 — 우 절토 마루를 넘겨 옮김 = 그 쪽 성토 1:n 으로 원지반까지(원지반 직선 아님)
"name": "마루 너머 = 성토 1:n",
"samples": _ridge_ground(),
"design_elevation_m": 100.0,
"options": {
"ground_type": "soil",
"section_mode": "right_cut",
"ditch_side": "right",
"shoulder_extend_right_m": 3.0,
},
},
{
# 370-1 — 좌 성토를 오르막으로 옮김 = 그 쪽 절토(측구 자동 판정 따름)
"name": "성 → 절 넘어감",
"samples": _rise_ground(),
"design_elevation_m": 100.0,
"options": {
"ground_type": "soil",
"section_mode": "left_cut",
"ditch_side": "left",
"shoulder_extend_left_m": 2.5,
},
},
{
# 151-2 — 곡선부 확폭과 사면 이동이 같은 쪽에 겹침(확폭 출력엔 이동 몫 안 섞임)
"name": "확폭 좌 + 사면 이동 좌 0.4",
@@ -490,3 +530,33 @@ def test_사면_이동은_노견째_평행이동_늘어난_폭은_차도() -> No
both = by_name["확폭 좌 + 사면 이동 좌 0.4"]
assert both["widening_left_m"] == curve["widening_left_m"] > 0
assert abs(both["carriageway_width_m"] - (curve["carriageway_width_m"] + 0.4)) < 1e-9
def _outer_slope(result: dict, side: str, skip: float = 0.0) -> float:
"""노면 끝에서 `skip`(측구 폭) 바깥 설계선 한 마디의 기울기(dz / 바깥 거리)."""
sign = 1.0 if side == "left" else -1.0
start_offset = result["road_edges"][side]["offset_m"] + sign * skip
line = sorted(result["design_line"], key=lambda p: sign * p["offset_m"])
index = next(i for i, p in enumerate(line) if abs(p["offset_m"] - start_offset) < 1e-6)
start, nxt = line[index], line[index + 1]
return (nxt["elevation_m"] - start["elevation_m"]) / abs(nxt["offset_m"] - start["offset_m"])
def test_절성_넘어가면_그_쪽_사면_기울기로_원지반까지() -> None:
"""370-1 — 옮긴 노견에서 절/성 판정 · 마루 너머 = 성토 1:n · 성 → 절 = 절토 1:n + 측구 자동."""
by_name = dict(zip((case["name"] for case in CASES), _python_results(), strict=True))
soil = STANDARD_CROSS_SECTION["soil"]
ridge = by_name["마루 너머 = 성토 1:n"]
assert ridge["section_mode"] == "left_cut" # 우 = 성토
assert ridge["ditch_enabled"] is False # 우 측구 자동 판정 따라 빠짐
assert ridge["fill_area_m2"] > 0
assert abs(_outer_slope(ridge, "right") + 1.0 / soil["fill_slope_ratio"]) < 1e-3
rise = by_name["성 → 절 넘어감"]
assert rise["section_mode"] == "left_cut" # 좌 = 절토
assert rise["ditch_enabled"] is True and rise["ditch_side"] == "left"
assert (
abs(
_outer_slope(rise, "left", soil["ditch"]["top_width_m"]) - 1.0 / soil["cut_slope_ratio"]
)
< 1e-3
)