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

This commit is contained in:
2026-10-16 20:52:53 +09:00
14 changed files with 1079 additions and 116 deletions
@@ -10,6 +10,7 @@
import * as THREE from "three";
import { contourAssetUrl, fetchCachedJson } from "../A00_Common/b_asset_cache";
import { contourMaterials, isMajorContour } from "@ui/ui_template_contour3d";
/** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다(분리 전 상수 그대로). */
const MAX_CONTOUR_LABELS = 40;
@@ -93,7 +94,7 @@ export async function loadContourLinesInto(
const points = transform(c.coordinates);
if (points.length < 2) return;
const isMajor = c.level % (interval * 5) === 0;
const isMajor = isMajorContour(c.level);
const bucket = isMajor ? majorPoints : minorPoints;
for (let i = 0; i < points.length - 1; i++) {
bucket.push(points[i], points[i + 1]);
@@ -156,18 +157,15 @@ export async function loadContourLinesInto(
ctx.setLabelsDirty(true);
}
// 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다.
// 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다 — 주곡선 = 5 m 고정 · 1 m 는
// 옅게(평면 그림과 같은 색 토큰 · 353-3).
const materials = contourMaterials();
[
{ points: minorPoints, color: 0xf59e0b },
{ points: majorPoints, color: 0xd97706 },
].forEach(({ points, color }) => {
{ points: minorPoints, material: materials.minor },
{ points: majorPoints, material: materials.major },
].forEach(({ points, material }) => {
if (points.length === 0) return;
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({
color,
transparent: true,
opacity: 0.8,
});
ctx.contourGroup.add(new THREE.LineSegments(geometry, material));
});
+9 -2
View File
@@ -28,6 +28,7 @@ import {
loadRouteEditBackground,
type RouteEditContours,
} from "./B05_Profile_UI_RouteEdit_Contour";
import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock";
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
import {
analysisTerrain,
@@ -189,6 +190,12 @@ export async function mountRouteEditor(
let otherSheets: PreparedLayer[] = [];
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
let pickedContour = -1;
/** 등고선 자물쇠(353-2) — 잠긴 동안 빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜. */
const contourLock = createContourLock(
canvas,
() => contours && { layer: contours.layer, view, stepM: contourStepM(), rotation },
(index) => ((pickedContour = index), draw()),
);
/** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */
const crossPreview = createCrossPreview({
projectId,
@@ -521,7 +528,7 @@ export async function mountRouteEditor(
picked = index;
},
setPickedContour: (index) => {
pickedContour = index;
if (contourLock.canPick()) pickedContour = index;
},
toScreen,
toMetric,
@@ -652,7 +659,7 @@ export async function mountRouteEditor(
const useBackground = (background: Awaited<ReturnType<typeof loadBackground>>): void => {
otherSheets = background.otherSheets;
contours = background.contours;
pickedContour = -1;
pickedContour = contourLock.reattach(contours?.layer ?? null);
// 1m 선이 있는 자료(LAS)일 때만 단추를 연다 — 도엽에는 1m 선이 없다.
minorContourButton.disabled = !contours?.minor;
minorContourButton.title = contours?.minor
@@ -81,7 +81,7 @@ export function createRouteEditChrome(
aria-pressed="false"
title="곡선(호) 안 측점 하나를 곡선 중점(MC)으로 옮깁니다 — [저장] 때 저장">측점 스냅</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="footprint"
title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 파랑)을 평면에 겹칩니다">발자국</button>
title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 파랑)을 평면에 겹칩니다">절/성토선</button>
<i class="b05-routeedit__divider" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--ghost" data-act="undo"
title="되돌리기 (Ctrl+Z)" disabled>↶ 되돌리기</button>
@@ -0,0 +1,151 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_ContourLock.ts
* 평면 등고선 자물쇠(353-2) — 등고선 위를 지나거나 고른 가닥 위에 뜨는 작은 단추.
*
* 누르면 그 높이를 고정(빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜) · 다시 누르면 풂.
* 높이값으로 쥐어 지형을 바꿔도 같은 높이를 다시 짚음 · 페이지 동안만(저장 안 함).
* 규칙은 `logic/B05_Profile_ContourLock` · 호버 판정은 집기와 같은 `hitPreparedLayer`.
* ========================================================================== */
import { pickPx } from "../A00_Common/touch_bridge";
import {
hitPreparedLayer,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import {
canPickContour,
featureOfLevel,
lockButtonState,
toggleLock,
} from "./logic/B05_Profile_ContourLock";
import "./B05_Profile_UI_Style_RouteEdit_ContourLock.css";
/** 집기(`_Pointer` CONTOUR_HIT_PX)와 같은 거리(px). */
const HOVER_HIT_PX = 6;
/** 커서가 선을 떠난 뒤 단추가 남는 시간(ms) — 단추로 옮겨 갈 틈. */
const HIDE_DELAY_MS = 700;
/** 단추는 커서 오른쪽 위로 이만큼(px) — 선과 커서를 안 가림. */
const OFFSET_PX = 12;
const LOCK_SVG = `<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<rect x="3" y="7" width="10" height="7" rx="1.5" fill="currentColor"/>
<path class="b05-contourlock__shackle" d="M5 7V5a3 3 0 0 1 6 0v2" fill="none"
stroke="currentColor" stroke-width="1.6"/></svg>`;
/** 호버 판정에 쓰는 지금 화면 — 등고선이 없으면 null. */
export interface ContourLockScene {
layer: PreparedLayer;
view: ViewState;
/** 지금 화면 등고선 간격 — 그리지 않은 줄은 호버도 안 됨. */
stepM: number;
rotation: { unrotate: (px: number, py: number) => [number, number] };
}
export interface ContourLock {
/** 고른 가닥을 바꿔도 되는지(잠긴 동안 false). */
canPick: () => boolean;
/** 지형을 바꿔 새 가닥들이 왔다 — 잠근 높이의 가닥 번호(없으면 -1). */
reattach: (layer: PreparedLayer | null) => number;
}
/** 자물쇠 단추를 캔버스 칸 위에 세움 · `pick` = 잠글 때 그 가닥을 고른 것으로(다시 그리기까지). */
export function createContourLock(
canvas: HTMLCanvasElement,
scene: () => ContourLockScene | null,
pick: (index: number) => void,
): ContourLock {
const wrap = canvas.parentElement ?? document.body;
const button = document.createElement("button");
button.type = "button";
button.className = "ui-btn ui-btn--glass b05-contourlock";
button.dataset.act = "contour-lock";
button.hidden = true;
button.innerHTML = LOCK_SVG;
wrap.append(button);
let lockedLevel: number | null = null;
/** 단추가 가리키는 가닥 — 누르면 이 가닥을 잠금. */
let target = -1;
let hideTimer = 0;
let frame = 0;
let last: PointerEvent | null = null;
const levelOf = (index: number): number | null =>
index >= 0 ? (scene()?.layer.features[index]?.labelValue ?? null) : null;
const paint = (): void => {
const pressed = lockedLevel !== null;
button.setAttribute("aria-pressed", String(pressed));
const label = pressed
? `등고선 ${lockedLevel}m 고정 풀기 — 풀면 다른 등고선을 고를 수 있음`
: `등고선 ${levelOf(target) ?? ""}m 고정 — 빈 곳 · 다른 등고선을 눌러도 안 바뀜`;
button.setAttribute("aria-label", label);
button.title = label;
};
const hideLater = (): void => {
if (hideTimer) return;
hideTimer = window.setTimeout(() => {
hideTimer = 0;
button.hidden = true;
}, HIDE_DELAY_MS);
};
const keep = (): void => {
window.clearTimeout(hideTimer);
hideTimer = 0;
};
const hover = (event: PointerEvent): void => {
const now = scene();
// 단추를 누른 채(노드 · 손잡이 끌기 · 화면 이동) 지나가면 안 봄
if (!now || event.buttons !== 0) return hideLater();
const rect = canvas.getBoundingClientRect();
const [px, py] = now.rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
const hitPx = pickPx(HOVER_HIT_PX);
const index = hitPreparedLayer(now.layer, now.view, px, py, hitPx, now.stepM);
const state = lockButtonState(levelOf(index), lockedLevel);
if (!state.visible) return hideLater();
keep();
target = index;
const origin = wrap.getBoundingClientRect();
button.style.left = `${Math.round(event.clientX - origin.left + OFFSET_PX)}px`;
button.style.top = `${Math.round(event.clientY - origin.top - OFFSET_PX)}px`;
button.hidden = false;
paint();
};
// 한 프레임에 한 번만 잼 — 도엽 등고선은 수천 가닥.
canvas.addEventListener("pointermove", (event) => {
last = event;
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
if (last) hover(last);
});
});
// 고른 직후(누름을 뗀 자리)에도 띄움 — 터치는 호버가 없음.
canvas.addEventListener("pointerup", hover);
canvas.addEventListener("pointerleave", hideLater);
button.addEventListener("pointerenter", keep);
button.addEventListener("pointerleave", hideLater);
button.addEventListener("pointerdown", (event) => event.stopPropagation());
button.addEventListener("click", (event) => {
event.stopPropagation();
lockedLevel = toggleLock(lockedLevel, levelOf(target));
if (lockedLevel !== null) pick(target);
paint();
});
return {
canPick: () => canPickContour(lockedLevel),
reattach: (layer) => {
target = -1;
button.hidden = true;
return featureOfLevel(
layer?.features.map((feature) => feature.labelValue) ?? [],
lockedLevel,
);
},
};
}
@@ -0,0 +1,28 @@
/* 평면 등고선 자물쇠(353-2) — 커서 옆에 뜨는 작은 단추 · 자리는 `_ContourLock.ts` 가 잡음. */
.b05-contourlock {
position: absolute;
z-index: 3;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border-radius: 999px;
}
.b05-contourlock[hidden] {
display: none;
}
/* 풀림 = 고리가 들림 · 잠김 = 고른 등고선 색(보라)으로 채움 */
.b05-contourlock[aria-pressed="false"] .b05-contourlock__shackle {
transform: translate(2.5px, -1px) rotate(18deg);
transform-origin: 11px 7px;
}
.b05-contourlock[aria-pressed="true"] {
border-color: var(--map-flow-arrow);
background-color: var(--map-flow-arrow);
color: #fff;
}
+8 -8
View File
@@ -1,6 +1,7 @@
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { createTerrainCompass } from "@ui/ui_template_compass";
import { contourMaterials, isMajorContour } from "@ui/ui_template_contour3d";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
import { API_BASE_URL } from "@config/config_frontend";
@@ -339,21 +340,20 @@ export function createRouteViewer(): RouteViewer {
([x, y, z]) => new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)),
);
if (points.length < 2) return;
const bucket = contour.level % (interval * 5) === 0 ? majorPoints : minorPoints;
const bucket = isMajorContour(contour.level) ? majorPoints : minorPoints;
for (let index = 0; index < points.length - 1; index += 1) {
bucket.push(points[index], points[index + 1]);
}
});
// 주곡선 = 5 m 고정 · 1 m 는 옅게(평면 그림과 같은 색 토큰 · 353-3)
const materials = contourMaterials();
[
{ points: minorPoints, color: 0xf59e0b },
{ points: majorPoints, color: 0xd97706 },
].forEach(({ points, color }) => {
{ points: minorPoints, material: materials.minor },
{ points: majorPoints, material: materials.major },
].forEach(({ points, material }) => {
if (points.length === 0) return;
contours.add(
new THREE.LineSegments(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.75 }),
),
new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(points), material),
);
});
}
@@ -0,0 +1,39 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_ContourLock.ts
* 평면 등고선 고정(353-2) — 고른 높이 하나를 쥐고, 잠긴 동안 다른 지정을 막는 규칙.
*
* 높이값으로 쥔다(가닥 번호는 지형을 바꾸면 달라짐) · 페이지 동안만(저장 안 함).
* 순수 계산 — 화면 · three · fetch · sessionStorage 없음. 단추는 `B05_Profile_UI_RouteEdit_ContourLock`.
* ========================================================================== */
/** 가닥마다 높이(모르면 null). */
export type ContourLevels = ReadonlyArray<number | null>;
/** 자물쇠 단추 — 보일지 · 눌린 모양인지. */
export interface LockButtonState {
visible: boolean;
pressed: boolean;
}
/** 그 높이의 첫 가닥 번호 — 없으면 -1(지형을 바꾼 뒤 잠근 높이 다시 짚기). */
export function featureOfLevel(levels: ContourLevels, level: number | null): number {
if (level === null) return -1;
return levels.findIndex((value) => value === level);
}
/** 커서 아래 가닥 높이 · 잠근 높이 → 단추 — 안 잠김 = 높이 있는 가닥 위에서 · 잠김 = 그 높이 위에서만. */
export function lockButtonState(
hoverLevel: number | null,
lockedLevel: number | null,
): LockButtonState {
if (lockedLevel !== null) return { visible: hoverLevel === lockedLevel, pressed: true };
return { visible: hoverLevel !== null, pressed: false };
}
/** 단추 누름 — 잠김이면 풂(null) · 아니면 그 가닥 높이로 잠금. */
export function toggleLock(lockedLevel: number | null, targetLevel: number | null): number | null {
return lockedLevel !== null ? null : targetLevel;
}
/** 잠긴 동안은 고른 가닥이 안 바뀜(다른 가닥 · 빈 곳 눌러도). */
export const canPickContour = (lockedLevel: number | null): boolean => lockedLevel === null;
+253 -45
View File
@@ -3,7 +3,8 @@
* 평면 발자국 — 측점 횡단 설계 결과를 평면 선으로(도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금) (346-2)
*
* ⚠⚠ 짝: `B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py`
* (`route_frame` · `side_is_cut` · `slope_end` · `slope_pieces`) — 한쪽만 고치면
* (`route_frame` · `route_normal` · `side_is_cut` · `slope_end` · `slope_pieces` 와 그 아래
* 단조 3차 보간 · 촘촘히 · 접힘 버림 · 353-1) — 한쪽만 고치면
* 종단 · 횡단 편집 중 평면과 B07 계획평면도가 갈린다.
* `footprintRecord` = `B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄.
* 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py`
@@ -105,18 +106,6 @@ export function routeFrame(route: Array<[number, number, number]>, chainage: num
return [x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length];
}
function interp(records: Array<Record<string, number>>, chainage: number, key: string): number {
const chainages = records.map((record) => record.chainage_m);
const index = bisectLeft(chainages, chainage);
if (index <= 0) return records[0][key];
if (index >= records.length) return records[records.length - 1][key];
const before = records[index - 1];
const after = records[index];
const span = after.chainage_m - before.chainage_m;
const ratio = span > 0 ? (chainage - before.chainage_m) / span : 0.0;
return before[key] + (after[key] - before[key]) * ratio;
}
function groundOf(samples: FootprintPoint[]): Array<[number, number]> {
return samples
.filter(
@@ -216,13 +205,187 @@ function splitRuns(points: Line, flags: boolean[]): [Line[], Line[]] {
return [runs.true.filter((run) => run.length >= 2), runs.false.filter((run) => run.length >= 2)];
}
/** 절/성토선 매끄럽게(353-1) — 네 선 중 하나라도 사이 가운데 점에서 이보다 꺾이면 반으로 나눔(°). 짝 같은 이름. */
export const SPLIT_TURN_DEG = 2.0;
/** 나눔 깊이 상한(2 m → 12.5 cm). */
export const SPLIT_DEPTH = 4;
/** 사이 점 법선을 재는 앞뒤 거리(m) — 측점 횡단 틀(`stationFrameAt`)과 같은 값. */
export const NORMAL_PROBE_M = 1.0;
type Frame = [number, number, number, number];
/** 노선 위 그 자리와 좌측 법선 — 법선은 앞뒤 ±1 m 두 점을 잇는 방향. 짝: `route_normal`. */
export function routeNormal(route: Array<[number, number, number]>, chainage: number): Frame {
const [x, y, nx, ny] = routeFrame(route, chainage);
const first = route[0][0];
const last = route[route.length - 1][0];
let before = Math.max(first, chainage - NORMAL_PROBE_M);
let after = Math.min(last, chainage + NORMAL_PROBE_M);
if (after - before <= 1e-9) {
before = Math.max(first, chainage - 1e-3);
after = Math.min(last, chainage + 1e-3);
}
const [x0, y0] = routeFrame(route, before);
const [x1, y1] = routeFrame(route, after);
const length = Math.hypot(x1 - x0, y1 - y0);
if (length <= 1e-9) return [x, y, nx, ny];
return [x, y, -(y1 - y0) / length, (x1 - x0) / length];
}
function bisectRight(values: number[], target: number): number {
let low = 0;
let high = values.length;
while (low < high) {
const middle = (low + high) >> 1;
if (target < values[middle]) high = middle;
else low = middle + 1;
}
return low;
}
type Knots = [number[], number[], number[]];
/** 단조 3차(Fritsch–Carlson) 꺾임점 기울기 — 이웃 값 사이를 넘지 않음. 짝: `_pchip_slopes`. */
function pchipSlopes(xs: number[], ys: number[]): number[] {
const n = xs.length;
if (n < 2) return xs.map(() => 0);
const h = xs.slice(1).map((x, i) => x - xs[i]);
const delta = h.map((span, i) => (ys[i + 1] - ys[i]) / span);
const slopes = [delta[0], ...new Array<number>(n - 2).fill(0), delta[n - 2]];
for (let k = 1; k < n - 1; k += 1) {
if (delta[k - 1] * delta[k] > 0) {
const w1 = 2 * h[k] + h[k - 1];
const w2 = h[k] + 2 * h[k - 1];
slopes[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k]);
}
}
return slopes;
}
/** 꺾임점 값을 그대로 지나는 단조 3차 보간 — 범위 밖은 끝값. 짝: `_pchip`. */
function pchip([xs, ys, slopes]: Knots, x: number): number {
if (x <= xs[0]) return ys[0];
if (x >= xs[xs.length - 1]) return ys[ys.length - 1];
const i = bisectRight(xs, x) - 1;
const h = xs[i + 1] - xs[i];
const t = (x - xs[i]) / h;
return (
(1 + 2 * t) * (1 - t) ** 2 * ys[i] +
t * (1 - t) ** 2 * h * slopes[i] +
t * t * (3 - 2 * t) * ys[i + 1] +
t * t * (t - 1) * h * slopes[i + 1]
);
}
const knots = (xs: number[], ys: number[]): Knots => [xs, ys, pchipSlopes(xs, ys)];
interface SideCurves {
chainages: number[];
flags: boolean[];
switches: number[];
edge: Knots;
width: Knots;
}
/** 한 쪽 도로 끝 · 비탈 폭 보간 + 깎기↔쌓기 바뀜 자리(폭 0 = 비탈 끝이 도로 끝으로 모임). 짝: `_side_curves`. */
function sideCurves(records: FootprintRecord[], side: Side): SideCurves {
const chainages = records.map((record) => record.chainage_m);
const edges = records.map((record) => record[`${side}_edge_m`]);
const widths = records.map((record) => record[`${side}_end_m`] - record[`${side}_edge_m`]);
const flags = records.map((record) => Boolean(record[`${side}_cut`]));
const switches: number[] = [];
const widthX = [chainages[0]];
const widthY = [widths[0]];
for (let i = 1; i < records.length; i += 1) {
const c0 = chainages[i - 1];
const c1 = chainages[i];
const w0 = Math.abs(widths[i - 1]);
const w1 = Math.abs(widths[i]);
const at = c0 + (c1 - c0) * (w0 + w1 > 1e-9 ? w0 / (w0 + w1) : 0.5);
switches.push(at);
if (flags[i - 1] !== flags[i] && c0 + 1e-9 < at && at < c1 - 1e-9) {
widthX.push(at);
widthY.push(0.0);
}
widthX.push(c1);
widthY.push(widths[i]);
}
return {
chainages,
flags,
switches,
edge: knots(chainages, edges),
width: knots(widthX, widthY),
};
}
/** 그 자리 깎기 여부 — 바뀜 자리부터 뒤 측점 것(바뀌는 점은 앞뒤 조각이 나눠 가짐). 짝: `_cut_at`. */
function cutAt(curves: SideCurves, chainage: number): boolean {
const { flags } = curves;
const i = bisectRight(curves.chainages, chainage);
if (i === 0) return flags[0];
if (i === flags.length) return flags[flags.length - 1];
if (flags[i - 1] === flags[i]) return flags[i];
return chainage < curves.switches[i - 1] ? flags[i - 1] : flags[i];
}
/** 진행을 거스르는 점(안쪽 비탈 끝이 곡선 중심을 넘어 접힘)을 버린 남는 점 번호. 짝: `_forward_only`. */
function forwardOnly(points: Line, frames: Frame[]): number[] {
const kept = points.length ? [0] : [];
for (let i = 1; i < points.length; i += 1) {
const last = points[kept[kept.length - 1]];
const [, , nx, ny] = frames[i];
if ((points[i][0] - last[0]) * ny - (points[i][1] - last[1]) * nx > 1e-6) kept.push(i);
}
return kept;
}
function turnDeg(a: number[], m: number[], b: number[]): number {
const u = [m[0] - a[0], m[1] - a[1]];
const v = [b[0] - m[0], b[1] - m[1]];
return Math.abs(
(Math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]) * 180) / Math.PI,
);
}
/** 곡선 · 폭이 가파르게 바뀌는 자리 — 가운데 점에서 많이 꺾이는 사이를 반씩 나눔. 짝: `_densify`. */
function densify(
chainages: number[],
frameOf: (chainage: number) => Frame,
place: (chainage: number, frame: Frame) => Line,
): [number[], Frame[]] {
const split = (c0: number, p0: Line, c1: number, p1: Line, depth: number): number[] => {
if (depth >= SPLIT_DEPTH) return [];
const middle = (c0 + c1) / 2.0;
const pm = place(middle, frameOf(middle));
// 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음
const bends = pm.some((m, k) => {
const turn = turnDeg(p0[k], m, p1[k]);
return SPLIT_TURN_DEG < turn && turn < 90.0;
});
if (!bends) return [];
return [
...split(c0, p0, middle, pm, depth + 1),
middle,
...split(middle, pm, c1, p1, depth + 1),
];
};
const points = chainages.map((chainage) => place(chainage, frameOf(chainage)));
const out = chainages.slice(0, 1);
for (let i = 1; i < chainages.length; i += 1) {
out.push(...split(chainages[i - 1], points[i - 1], chainages[i], points[i], 0));
out.push(chainages[i]);
}
return [out, out.map(frameOf)];
}
interface Sampled {
lines: Record<(typeof KEYS)[number], Line>;
cut: Record<Side, boolean[]>;
ticks: Line[];
}
/** 측점 줄 → 측점 · 눈금 간격마다 네 선 · 쪽마다 깎기 여부 · 눈금. `slope_pieces` 의 몸통. */
/** 측점 줄 → 네 선 · 쪽마다 깎기 여부 · 눈금. `slope_pieces` 의 몸통. */
function sampleLines(
route: Array<[number, number, number]>,
input: FootprintRecord[],
@@ -230,49 +393,94 @@ function sampleLines(
endM: number,
stepM: number,
): Sampled | null {
const records = input
const sorted = input
.map((record) => ({
...record,
left_cut: Number(Boolean(record.left_cut)),
right_cut: Number(Boolean(record.right_cut)),
left_cut: Boolean(record.left_cut),
right_cut: Boolean(record.right_cut),
}))
.sort((a, b) => a.chainage_m - b.chainage_m) as unknown as Array<Record<string, number>>;
if (route.length < 2 || !records.length) return null;
const byChainage = new Map<number, Record<string, number>>();
.sort((a, b) => a.chainage_m - b.chainage_m);
if (route.length < 2 || !sorted.length) return null;
// 같은 누가거리 측점은 뒤 것 하나만
const records: FootprintRecord[] = [];
for (const record of sorted) {
const last = records[records.length - 1];
if (last && record.chainage_m - last.chainage_m <= 1e-9) records[records.length - 1] = record;
else records.push(record);
}
const curves = { left: sideCurves(records, "left"), right: sideCurves(records, "right") };
const byChainage = new Map<number, FootprintRecord>();
for (const record of records) byChainage.set(roundTo(record.chainage_m, 3), record);
const samples = new Set<number>();
const base = new Set<number>();
for (const record of records) {
if (startM <= record.chainage_m && record.chainage_m <= endM)
samples.add(roundTo(record.chainage_m, 3));
base.add(roundTo(record.chainage_m, 3));
}
for (let step = startM; step <= endM; step += stepM) samples.add(roundTo(step, 3));
const lines = Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as Sampled["lines"];
const cut: Record<Side, boolean[]> = { left: [], right: [] };
for (let step = startM; step <= endM; step += stepM) base.add(roundTo(step, 3));
const all = new Set(base);
for (const side of SIDES) {
const { flags, switches } = curves[side];
switches.forEach((at, i) => {
if (flags[i] !== flags[i + 1] && startM <= at && at <= endM) all.add(at);
});
}
const frameOf = (chainage: number): Frame => {
const record = byChainage.get(roundTo(chainage, 3));
const { x, y, nx, ny } = record ?? {};
if (x !== undefined && y !== undefined && nx !== undefined && ny !== undefined)
return [x, y, nx, ny];
return routeNormal(route, chainage);
};
const valuesAt = (chainage: number): Record<string, number> => {
const values: Record<string, number> = {};
for (const side of SIDES) {
const edge = pchip(curves[side].edge, chainage);
values[`${side}_edge_m`] = edge;
values[`${side}_end_m`] = edge + pchip(curves[side].width, chainage);
}
return values;
};
const place = (chainage: number, [x, y, nx, ny]: Frame): Line => {
const values = valuesAt(chainage);
return KEYS.map((key) => [x + nx * values[key], y + ny * values[key]]);
};
const [chainages, frames] = densify(
[...all].sort((a, b) => a - b),
frameOf,
place,
);
const offsets = chainages.map(valuesAt);
const raw = Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as Sampled["lines"];
frames.forEach(([x, y, nx, ny], i) => {
for (const key of KEYS)
raw[key].push([roundTo(x + nx * offsets[i][key], 4), roundTo(y + ny * offsets[i][key], 4)]);
});
const kept = Object.fromEntries(
KEYS.map((key) => [key, forwardOnly(raw[key], frames)]),
) as unknown as Record<(typeof KEYS)[number], number[]>;
const lines = Object.fromEntries(
KEYS.map((key) => [key, kept[key].map((i) => raw[key][i])]),
) as unknown as Sampled["lines"];
const cut: Record<Side, boolean[]> = {
left: kept.left_end_m.map((i) => cutAt(curves.left, chainages[i])),
right: kept.right_end_m.map((i) => cutAt(curves.right, chainages[i])),
};
// 눈금은 도로 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아(납품
// 도면 관례) · 접혀 버린 자리는 뺌.
const ticks: Line[] = [];
[...samples]
const keptSets = { left: new Set(kept.left_end_m), right: new Set(kept.right_end_m) };
const position = new Map(chainages.map((chainage, i) => [chainage, i]));
[...base]
.sort((a, b) => a - b)
.forEach((chainage, index) => {
const record = byChainage.get(chainage);
const [x, y, nx, ny] =
record && ["x", "y", "nx", "ny"].every((k) => record[k] !== undefined)
? [record.x, record.y, record.nx, record.ny]
: routeFrame(route, chainage);
const offsets = Object.fromEntries(KEYS.map((key) => [key, interp(records, chainage, key)]));
const points = Object.fromEntries(
KEYS.map((key) => [
key,
[roundTo(x + nx * offsets[key], 4), roundTo(y + ny * offsets[key], 4)],
]),
);
for (const key of KEYS) lines[key].push(points[key]);
for (const side of SIDES) cut[side].push(interp(records, chainage, `${side}_cut`) > 0.5);
// 눈금은 도로 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아(납품 도면 관례).
const i = position.get(chainage) ?? 0;
const [x, y, nx, ny] = frames[i];
for (const side of SIDES) {
const edge = offsets[`${side}_edge_m`];
const end = offsets[`${side}_end_m`];
if (Math.abs(end - edge) < 0.05) continue;
const edge = offsets[i][`${side}_edge_m`];
const end = offsets[i][`${side}_end_m`];
if (Math.abs(end - edge) < 0.05 || !keptSets[side].has(i)) continue;
const reach = index % 2 === 0 ? end : edge + (end - edge) / 2.0;
ticks.push([points[`${side}_edge_m`], [x + nx * reach, y + ny * reach]]);
ticks.push([raw[`${side}_edge_m`][i], [x + nx * reach, y + ny * reach]]);
}
});
return { lines, cut, ticks };
@@ -2,13 +2,14 @@
`_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m.
⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · side_is_cut · slope_end ·
slope_pieces) — 종단 · 횡단 편집 중 평면 발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림.
⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · route_normal · side_is_cut ·
slope_end · slope_pieces 와 그 아래 보간 · 촘촘히 · 접힘 버림) — 종단 · 횡단 편집 중 평면
발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림.
거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py`
"""
import math
from bisect import bisect_left
from bisect import bisect_left, bisect_right
from typing import Any
from config.config_system import DRAWING_SCALE_PLAN
@@ -39,19 +40,6 @@ def route_frame(
return (x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length)
def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float:
chainages = [record["chainage_m"] for record in records]
index = bisect_left(chainages, chainage)
if index <= 0:
return records[0][key]
if index >= len(records):
return records[-1][key]
before, after = records[index - 1], records[index]
span = after["chainage_m"] - before["chainage_m"]
ratio = (chainage - before["chainage_m"]) / span if span > 0 else 0.0
return before[key] + (after[key] - before[key]) * ratio
def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]:
return sorted(
(float(item["offset_m"]), float(item["elevation_m"]))
@@ -120,6 +108,149 @@ def _split_runs(
)
#: 절/성토선 매끄럽게(353-1) — 네 선 중 하나라도 사이 가운데 점에서 이보다 꺾이면 반으로 나눔(°)
SPLIT_TURN_DEG = 2.0
#: 나눔 깊이 상한(2 m → 12.5 cm)
SPLIT_DEPTH = 4
#: 사이 점 법선을 재는 앞뒤 거리(m) — 측점 횡단 틀(`_tangent_at`)과 같은 값
NORMAL_PROBE_M = 1.0
def route_normal(
route: list[tuple[float, float, float]], chainage: float
) -> tuple[float, float, float, float]:
"""노선 위 그 자리와 좌측 법선 — 법선은 앞뒤 ±1 m 두 점을 잇는 방향(측점 횡단 틀과 같은 식)."""
x, y, nx, ny = route_frame(route, chainage)
first, last = route[0][0], route[-1][0]
before, after = max(first, chainage - NORMAL_PROBE_M), min(last, chainage + NORMAL_PROBE_M)
if after - before <= 1e-9:
before, after = max(first, chainage - 1e-3), min(last, chainage + 1e-3)
x0, y0, _nx, _ny = route_frame(route, before)
x1, y1, _nx, _ny = route_frame(route, after)
length = math.hypot(x1 - x0, y1 - y0)
if length <= 1e-9:
return x, y, nx, ny
return x, y, -(y1 - y0) / length, (x1 - x0) / length
def _pchip_slopes(xs: list[float], ys: list[float]) -> list[float]:
"""단조 3차(Fritsch–Carlson) 꺾임점 기울기 — 이웃 값 사이를 넘지 않는다."""
n = len(xs)
if n < 2:
return [0.0] * n
h = [xs[i + 1] - xs[i] for i in range(n - 1)]
delta = [(ys[i + 1] - ys[i]) / h[i] for i in range(n - 1)]
slopes = [delta[0]] + [0.0] * (n - 2) + [delta[-1]]
for k in range(1, n - 1):
if delta[k - 1] * delta[k] > 0:
w1, w2 = 2 * h[k] + h[k - 1], h[k] + 2 * h[k - 1]
slopes[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k])
return slopes
def _pchip(knots: tuple[list[float], list[float], list[float]], x: float) -> float:
"""꺾임점 값을 그대로 지나는 단조 3차 보간 — 범위 밖은 끝값."""
xs, ys, slopes = knots
if x <= xs[0]:
return ys[0]
if x >= xs[-1]:
return ys[-1]
i = bisect_right(xs, x) - 1
h = xs[i + 1] - xs[i]
t = (x - xs[i]) / h
return (
(1 + 2 * t) * (1 - t) ** 2 * ys[i]
+ t * (1 - t) ** 2 * h * slopes[i]
+ t * t * (3 - 2 * t) * ys[i + 1]
+ t * t * (t - 1) * h * slopes[i + 1]
)
def _knots(xs: list[float], ys: list[float]) -> tuple[list[float], list[float], list[float]]:
return xs, ys, _pchip_slopes(xs, ys)
def _side_curves(records: list[dict[str, Any]], side: str) -> dict[str, Any]:
"""한 쪽 도로 끝 · 비탈 폭 보간 + 깎기↔쌓기 바뀜 자리(폭 0 = 비탈 끝이 도로 끝으로 모임)."""
chainages = [record["chainage_m"] for record in records]
edges = [record[f"{side}_edge_m"] for record in records]
widths = [record[f"{side}_end_m"] - record[f"{side}_edge_m"] for record in records]
flags = [bool(record[f"{side}_cut"]) for record in records]
switches: list[float] = []
width_x, width_y = [chainages[0]], [widths[0]]
for i in range(1, len(records)):
c0, c1 = chainages[i - 1], chainages[i]
w0, w1 = abs(widths[i - 1]), abs(widths[i])
switch = c0 + (c1 - c0) * (w0 / (w0 + w1) if w0 + w1 > 1e-9 else 0.5)
switches.append(switch)
if flags[i - 1] != flags[i] and c0 + 1e-9 < switch < c1 - 1e-9:
width_x.append(switch)
width_y.append(0.0)
width_x.append(c1)
width_y.append(widths[i])
return {
"chainages": chainages,
"flags": flags,
"switches": switches,
"edge": _knots(chainages, edges),
"width": _knots(width_x, width_y),
}
def _cut_at(curves: dict[str, Any], chainage: float) -> bool:
"""그 자리 깎기 여부 — 바뀜 자리부터 뒤 측점 것(바뀌는 점은 앞뒤 조각이 나눠 가짐)."""
flags = curves["flags"]
i = bisect_right(curves["chainages"], chainage)
if i == 0:
return flags[0]
if i == len(flags):
return flags[-1]
if flags[i - 1] == flags[i]:
return flags[i]
return flags[i - 1] if chainage < curves["switches"][i - 1] else flags[i]
def _forward_only(points: list[list[float]], frames: list[tuple[float, ...]]) -> list[int]:
"""진행을 거스르는 점(안쪽 비탈 끝이 곡선 중심을 넘어 접힘)을 버린 남는 점 번호."""
kept = [0] if points else []
for i in range(1, len(points)):
last = points[kept[-1]]
_x, _y, nx, ny = frames[i]
if (points[i][0] - last[0]) * ny - (points[i][1] - last[1]) * nx > 1e-6:
kept.append(i)
return kept
def _turn_deg(a: list[float], m: list[float], b: list[float]) -> float:
u, v = (m[0] - a[0], m[1] - a[1]), (b[0] - m[0], b[1] - m[1])
return abs(math.degrees(math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1])))
def _densify(
chainages: list[float], frame_of: Any, place: Any
) -> tuple[list[float], list[tuple[float, float, float, float]]]:
"""곡선 · 폭이 가파르게 바뀌는 자리 — 가운데 점에서 많이 꺾이는 사이를 반씩 나눔."""
def split(c0: float, p0: list, c1: float, p1: list, depth: int) -> list[float]:
if depth >= SPLIT_DEPTH:
return []
middle = (c0 + c1) / 2.0
pm = place(middle, frame_of(middle))
# 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음
if not any(SPLIT_TURN_DEG < _turn_deg(a, m, b) < 90.0 for a, m, b in zip(p0, pm, p1)):
return []
return (
split(c0, p0, middle, pm, depth + 1) + [middle] + split(middle, pm, c1, p1, depth + 1)
)
points = [place(chainage, frame_of(chainage)) for chainage in chainages]
out = chainages[:1]
for i in range(1, len(chainages)):
out += split(chainages[i - 1], points[i - 1], chainages[i], points[i], 0)
out.append(chainages[i])
return out, [frame_of(chainage) for chainage in out]
def slope_pieces(
route: list[tuple[float, float, float]],
records: list[dict[str, Any]],
@@ -131,60 +262,94 @@ def slope_pieces(
`records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m,
left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의
중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을
따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다.
중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 단조 3차
보간(측점 값 그대로 지남) · 법선은 측점과 같은 ±1 m 식 · 곡선은 촘촘히 · 진행을 거스르는
점은 버림 · 깎기↔쌓기 바뀜 자리에서 비탈 끝이 도로 끝으로 모인다(353-1).
비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다.
"""
records = sorted(
sorted_records = sorted(
(
{
**record,
**{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")},
}
{**record, **{f"{s}_cut": bool(record.get(f"{s}_cut")) for s in ("left", "right")}}
for record in records
),
key=lambda record: record["chainage_m"],
)
if len(route) < 2 or not records:
if len(route) < 2 or not sorted_records:
return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []}
# 같은 누가거리 측점은 뒤 것 하나만
records = []
for record in sorted_records:
if records and record["chainage_m"] - records[-1]["chainage_m"] <= 1e-9:
records[-1] = record
else:
records.append(record)
sides = ("left", "right")
keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m")
curves = {side: _side_curves(records, side) for side in sides}
by_chainage = {round(record["chainage_m"], 3): record for record in records}
samples = {
base = {
round(record["chainage_m"], 3)
for record in records
if start_m <= record["chainage_m"] <= end_m
}
step = start_m
while step <= end_m:
samples.add(round(step, 3))
base.add(round(step, 3))
step += step_m
lines: dict[str, list[list[float]]] = {key: [] for key in keys}
cut: dict[str, list[bool]] = {"left": [], "right": []}
ticks: list[list[list[float]]] = []
for index, chainage in enumerate(sorted(samples)):
record = by_chainage.get(chainage)
switches = {
at
for side in sides
for i, at in enumerate(curves[side]["switches"])
if curves[side]["flags"][i] != curves[side]["flags"][i + 1] and start_m <= at <= end_m
}
def frame_of(chainage: float) -> tuple[float, float, float, float]:
record = by_chainage.get(round(chainage, 3))
if record and all(k in record for k in ("x", "y", "nx", "ny")):
x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"]
else:
x, y, nx, ny = route_frame(route, chainage)
offsets = {key: _interp(records, chainage, key) for key in keys}
points = {
key: [round(x + nx * value, 4), round(y + ny * value, 4)]
for key, value in offsets.items()
}
return record["x"], record["y"], record["nx"], record["ny"]
return route_normal(route, chainage)
def values_at(chainage: float) -> dict[str, float]:
values: dict[str, float] = {}
for side in sides:
edge = _pchip(curves[side]["edge"], chainage)
values[f"{side}_edge_m"] = edge
values[f"{side}_end_m"] = edge + _pchip(curves[side]["width"], chainage)
return values
def place(chainage: float, frame: tuple[float, float, float, float]) -> list[list[float]]:
x, y, nx, ny = frame
values = values_at(chainage)
return [[x + nx * values[key], y + ny * values[key]] for key in keys]
chainages, frames = _densify(sorted(base | switches), frame_of, place)
offsets = [values_at(chainage) for chainage in chainages]
raw: dict[str, list[list[float]]] = {key: [] for key in keys}
for (x, y, nx, ny), values in zip(frames, offsets):
for key in keys:
lines[key].append(points[key])
for side in cut:
cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5)
# 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례).
for side in ("left", "right"):
edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"]
if abs(end - edge) < 0.05:
raw[key].append([round(x + nx * values[key], 4), round(y + ny * values[key], 4)])
kept = {key: _forward_only(raw[key], frames) for key in keys}
lines = {key: [raw[key][i] for i in kept[key]] for key in keys}
cut = {
side: [_cut_at(curves[side], chainages[i]) for i in kept[f"{side}_end_m"]] for side in sides
}
# 눈금은 노견 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아
# 선다(납품 도면 관례) · 접혀 버린 자리는 뺌.
ticks: list[list[list[float]]] = []
kept_sets = {key: set(kept[key]) for key in keys}
position = {chainage: i for i, chainage in enumerate(chainages)}
for index, chainage in enumerate(sorted(base)):
i = position[chainage]
x, y, nx, ny = frames[i]
for side in sides:
edge, end = offsets[i][f"{side}_edge_m"], offsets[i][f"{side}_end_m"]
if abs(end - edge) < 0.05 or i not in kept_sets[f"{side}_end_m"]:
continue
reach = end if index % 2 == 0 else edge + (end - edge) / 2.0
ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]])
ticks.append([raw[f"{side}_edge_m"][i], [x + nx * reach, y + ny * reach]])
cut_lines: list[list[list[float]]] = []
fill_lines: list[list[list[float]]] = []
for side in cut:
for side in sides:
cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side])
cut_lines += cut_runs
fill_lines += fill_runs
+10 -2
View File
@@ -165,11 +165,19 @@ def test_계약_꼴_선(result) -> None:
first = result["first"]
for name in ("roadLeft", "roadRight", "slopeLeft", "slopeRight"):
line = first[name]
assert len(line) == 51 # 0 ~ 100 m · 2 m
# 0 ~ 100 m · 2 m + 깎기↔쌓기 바뀜 자리(353-1 · 비탈 끝이 도로 끝으로 모임)
assert {round(p[0], 3) for p in line} >= {float(2 * i) for i in range(51)}
assert [p[0] for p in line] == sorted(p[0] for p in line) # 시점 → 종점
assert all(p[1] == pytest.approx(2.5) for p in first["roadLeft"])
assert all(p[1] == pytest.approx(-2.5) for p in first["roadRight"])
assert len(first["slopeLeftCut"]) == 51 and len(first["slopeRightCut"]) == 51
assert len(first["slopeLeftCut"]) == len(first["slopeLeft"])
assert len(first["slopeRightCut"]) == len(first["slopeRight"])
for side in ("slopeLeft", "slopeRight"): # 바뀜 자리 = 도로 끝 위
flags, points = first[f"{side}Cut"], first[side]
edge = 2.5 if side == "slopeLeft" else -2.5
for i in range(1, len(flags)):
if flags[i] != flags[i - 1]:
assert points[i][1] == pytest.approx(edge, abs=1e-3)
assert any(first["slopeLeftCut"]) and not all(first["slopeLeftCut"]) # 깎기 · 쌓기 섞임
@@ -0,0 +1,199 @@
"""353-1 절/성토선 매끄럽게 — TS(`B05_Profile/logic/B05_Profile_PlanFootprint.ts`) ↔ 파이썬
(`B07_DesignDetail_Engine_Cad_PlanSlope.py`) 거울 + 측점 폭 = 횡단 값 · 꺾임 각 상한 · 접힘 버림 ·
깎기↔쌓기 바뀜 자리 = 도로 끝.
./venv/Scripts/python.exe -m pytest resources/tester/test_353_1_cut_fill_line_smooth.py -q
"""
from __future__ import annotations
import json
import math
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import ( # noqa: E402
route_normal,
slope_pieces,
)
TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_PlanFootprint.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
TOL = 2e-4
RADIUS = 20.0
#: 꺾임 각 상한(°) — 이웃 선분 사이 방향 차
MAX_TURN_DEG = 10.0
def _route() -> list[tuple[float, float, float]]:
"""+x 직선 50 m → 좌회전 90° 원호(R 20 · 5° 편) → +y 직선 40 m."""
points = [(0.0, 0.0)]
points += [
(50.0 + RADIUS * math.sin(math.radians(a)), RADIUS - RADIUS * math.cos(math.radians(a)))
for a in range(0, 91, 5)
]
points.append((70.0, 60.0))
route, chain = [], 0.0
for index, (x, y) in enumerate(points):
if index:
step = math.hypot(x - points[index - 1][0], y - points[index - 1][1])
if step < 1e-9:
continue
chain += step
route.append((chain, x, y))
return route
def _records() -> list[dict]:
"""20 m 측점 — 좌(곡선 안쪽) 비탈 폭이 곡선에서 R 보다 커져 접힘 · 우는 깎기↔쌓기 번갈아."""
route = _route()
total = route[-1][0]
records = []
for i in range(int(total // 10) + 1):
chainage = 10.0 * i
x, y, nx, ny = route_normal(route, chainage)
inside_curve = 50.0 <= chainage <= 50.0 + RADIUS * math.pi / 2
records.append(
{
"chainage_m": chainage,
"left_edge_m": 2.5,
"left_end_m": 2.5 + (22.0 if inside_curve else 3.0 + 2.0 * math.sin(chainage / 7)),
"right_edge_m": -2.5 - 0.3 * (i % 2),
"right_end_m": -6.0 - 2.0 * math.cos(chainage / 11),
"left_cut": True,
"right_cut": (i // 2) % 2 == 0,
"x": x,
"y": y,
"nx": nx,
"ny": ny,
}
)
return records
DRIVER = """
import { readFileSync, writeFileSync } from "node:fs";
import { slopePieces } from "./B05_Profile_PlanFootprint.js";
const c = JSON.parse(readFileSync(process.argv[2], "utf-8"));
writeFileSync(process.argv[3], JSON.stringify(slopePieces(c.route, c.records, c.start, c.end)));
"""
@pytest.fixture(scope="module")
def pieces() -> dict:
route = _route()
edge, fill, cut, ticks = slope_pieces(route, _records(), 0.0, route[-1][0])
return {
"edge": edge["lines"],
"fill": fill["lines"],
"cut": cut["lines"],
"ticks": ticks["ticks"],
}
@pytest.fixture(scope="module")
def ts_pieces(tmp_path_factory: pytest.TempPathFactory) -> dict:
out = tmp_path_factory.mktemp("ts_353_1")
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext",
"--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck",
"--noResolve"],
cwd=str(ROOT), check=True, capture_output=True,
) # fmt: skip
(out / "driver.mjs").write_text(DRIVER, encoding="utf-8")
route = _route()
payload = {"route": route, "records": _records(), "start": 0.0, "end": route[-1][0]}
(out / "in.json").write_text(json.dumps(payload), encoding="utf-8")
run = subprocess.run( # noqa: S603
["node", str(out / "driver.mjs"), str(out / "in.json"), str(out / "out.json")],
capture_output=True, text=True, cwd=str(out),
) # fmt: skip
assert run.returncode == 0, run.stderr
return json.loads((out / "out.json").read_text(encoding="utf-8"))
def test_두_쪽이_같다(pieces, ts_pieces) -> None:
for name in ("edge", "fill", "cut", "ticks"):
want, got = pieces[name], ts_pieces[name]
assert len(want) == len(got), name
for line_w, line_g in zip(want, got):
assert len(line_w) == len(line_g), name
for pw, pg in zip(line_w, line_g):
assert abs(pw[0] - pg[0]) < TOL and abs(pw[1] - pg[1]) < TOL, (name, pw, pg)
def _nearest(lines: list, point: tuple[float, float]) -> float:
return min(math.hypot(p[0] - point[0], p[1] - point[1]) for line in lines for p in line)
def test_측점_폭은_횡단_값_그대로(pieces) -> None:
left_edge, right_edge = pieces["edge"]
ends = pieces["fill"] + pieces["cut"]
for record in _records():
x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"]
for key, lines in (("left_edge_m", [left_edge]), ("right_edge_m", [right_edge]),
("right_end_m", ends)): # fmt: skip
value = record[key]
assert _nearest(lines, (x + nx * value, y + ny * value)) < 1e-3, (record, key)
if not 50.0 <= record["chainage_m"] <= 50.0 + RADIUS * math.pi / 2: # 접히지 않는 자리
value = record["left_end_m"]
assert _nearest(ends, (x + nx * value, y + ny * value)) < 1e-3, record
def _turns(line: list) -> list[float]:
turns = []
for a, b, c in zip(line, line[1:], line[2:]):
u = (b[0] - a[0], b[1] - a[1])
v = (c[0] - b[0], c[1] - b[1])
turns.append(
abs(math.degrees(math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1])))
)
return turns
def _right_runs(pieces: dict) -> list:
"""우측 비탈 끝 조각 — 좌측은 늘 깎기라 깎기 첫 조각이 좌측 한 줄."""
return pieces["fill"] + pieces["cut"][1:]
def test_꺾임_각_상한(pieces) -> None:
# 도로 끝 · 곡선 바깥(우) 비탈 끝 — 원호 5° 편 위에서도 톱니 없이 고름
for line in [*pieces["edge"], *_right_runs(pieces)]:
assert max(_turns(line), default=0.0) < MAX_TURN_DEG
def _crosses(p1, p2, p3, p4) -> bool:
def side(a, b, c) -> float:
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
return side(p1, p2, p3) * side(p1, p2, p4) < 0 and side(p3, p4, p1) * side(p3, p4, p2) < 0
def test_안쪽_접힘은_버림(pieces) -> None:
left_end = pieces["cut"][0]
segments = list(zip(left_end, left_end[1:]))
for i, (a, b) in enumerate(segments): # 스스로 꼬이지 않음
for c, d in segments[i + 2 :]:
assert not _crosses(a, b, c, d), (a, b, c, d)
def test_깎기_쌓기_바뀜_자리는_도로_끝(pieces) -> None:
right_edge = pieces["edge"][1]
runs = _right_runs(pieces)
assert len(runs) >= 4
ends = sorted((p for line in runs for p in (line[0], line[-1])), key=lambda p: p[0] + p[1])
for point in ends[1:-1]: # 시점 · 종점 빼고 조각 끝 = 바뀜 자리
assert _nearest([right_edge], point) < 0.05, point
def test_단추_이름은_절성토선() -> None:
chrome = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text("utf-8")
at = chrome.index('data-act="footprint"')
assert ">절/성토선</button>" in chrome[at : at + 200] and ">발자국<" not in chrome
@@ -0,0 +1,81 @@
"""353-2 평면 등고선 자물쇠 — 규칙(`logic/B05_Profile_ContourLock.ts`)을 Node 로 돌려 판정 +
편집기 이음(고르기 막음 · 지형 바꿔도 같은 높이 · 호버 = 집기와 같은 hitPreparedLayer).
./venv/Scripts/python.exe -m pytest resources/tester/test_353_2_contour_lock.py -q
"""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
LOGIC = B05 / "logic" / "B05_Profile_ContourLock.ts"
TS = ROOT / "config" / "node_modules" / "typescript"
DRIVER = r"""
const fs = require("fs");
const ts = require(process.argv[1]);
const js = ts.transpileModule(fs.readFileSync(process.argv[2], "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const m = { exports: {} };
new Function("exports", "module", js)(m.exports, m);
const L = m.exports;
const levels = [101, 102, 105, 102, null];
console.log(JSON.stringify({
hoverFree: L.lockButtonState(102, null),
hoverNone: L.lockButtonState(null, null),
hoverLocked: L.lockButtonState(102, 102),
hoverOther: L.lockButtonState(105, 102),
lock: L.toggleLock(null, 102),
unlock: L.toggleLock(102, 105),
pickFree: L.canPickContour(null),
pickLocked: L.canPickContour(102),
again: L.featureOfLevel(levels, 102),
gone: L.featureOfLevel(levels, 110),
none: L.featureOfLevel(levels, null),
}));
"""
@pytest.fixture(scope="module")
def got() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", "-e", DRIVER, str(TS), str(LOGIC)], capture_output=True, text=True, timeout=60
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_안_잠김_단추는_높이_있는_가닥_위에서(got) -> None:
assert got["hoverFree"] == {"visible": True, "pressed": False}
assert got["hoverNone"]["visible"] is False
def test_잠김_단추는_그_높이_위에서만_눌린_모양(got) -> None:
assert got["hoverLocked"] == {"visible": True, "pressed": True}
assert got["hoverOther"]["visible"] is False
def test_누르면_잠금_다시_누르면_풂(got) -> None:
assert got["lock"] == 102 and got["unlock"] is None
def test_잠긴_동안_고르기_막음(got) -> None:
assert got["pickFree"] is True and got["pickLocked"] is False
def test_지형_바꿔도_같은_높이를_다시_짚음(got) -> None:
assert got["again"] == 1 and got["gone"] == -1 and got["none"] == -1
def test_편집기_이음() -> None:
edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text("utf-8")
assert "if (contourLock.canPick()) pickedContour = index;" in edit
assert "pickedContour = contourLock.reattach(contours?.layer ?? null);" in edit
lock = (B05 / "B05_Profile_UI_RouteEdit_ContourLock.ts").read_text("utf-8")
assert "hitPreparedLayer(" in lock and 'setAttribute("aria-pressed"' in lock
assert "sessionStorage" not in lock and "writeState" not in lock # 페이지 동안만
@@ -0,0 +1,30 @@
"""353-3 3D 등고선 — 주곡선 5 m 고정 · 1 m 옅게 · 평면 색 토큰(B05 · B04 3D 같은 재질).
./venv/Scripts/python.exe -m pytest resources/tester/test_353_3_contour3d_style.py -q
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
STYLE = ROOT / "ui_template" / "ui_template_contour3d.ts"
VIEWERS = [
ROOT / "B05_Profile" / "B05_Profile_UI_Viewer.ts",
ROOT / "B04_PreProcess" / "B04_PreProcess_UI_TerrainViewer_Contours.ts",
]
def test_재질은_평면_색_토큰과_5m_고정() -> None:
src = STYLE.read_text("utf-8")
assert '"--map-contour-major"' in src and '"--map-contour-minor"' in src
assert "CONTOUR_MAJOR_STEP_M = 5;" in src
# 1 m 선 불투명 = 토큰 알파(0.45) · 주곡선은 진하게
assert "opacity: minor.alpha" in src and "opacity: 0.95 * major.alpha" in src
def test_두_3D_뷰어가_같은_재질을_씀() -> None:
for path in VIEWERS:
src = path.read_text("utf-8")
assert 'from "@ui/ui_template_contour3d"' in src, path.name
assert "isMajorContour(" in src and "contourMaterials()" in src, path.name
# 옛 갈래(간격 × 5 · 주황 두 색) 없음
assert "interval * 5" not in src and "0xf59e0b" not in src, path.name
+49
View File
@@ -0,0 +1,49 @@
/* =============================================================================
* ui_template_contour3d.ts
* 3D 등고선 선 재질 — 평면 그림(노선 편집)과 같은 갈래 · 같은 색 토큰(353-3).
*
* 주곡선 = 5 m 배수 고정(간격 무관 · 평면 `B05_Profile_UI_RouteEdit_Render` 와 같음) ·
* 색 `--map-contour-major` 진하게 · 나머지(1 m) = `--map-contour-minor` 옅게 · 낮은 불투명.
* B05 3D(`B05_Profile_UI_Viewer`) · B04 3D(`B04_PreProcess_UI_TerrainViewer_Contours`) 가 함께 씀.
* ========================================================================== */
import * as THREE from "three";
import { themeColor } from "./ui_template_palette";
/** 주곡선 간격(m) — 평면 그림과 같은 5 m. */
export const CONTOUR_MAJOR_STEP_M = 5;
/** 5 m 배수 높이면 주곡선. */
export function isMajorContour(level: number): boolean {
return Math.abs(level - Math.round(level / CONTOUR_MAJOR_STEP_M) * CONTOUR_MAJOR_STEP_M) < 1e-6;
}
/** CSS 색(#rrggbb · rgba(...)) → 색 + 불투명(알파 없으면 1). */
function parseColor(value: string): { color: THREE.Color; alpha: number } {
const rgba = value.match(/rgba?\(([^)]+)\)/);
if (!rgba) return { color: new THREE.Color(value), alpha: 1 };
const [r, g, b, a] = rgba[1].split(",").map((part) => Number(part.trim()));
return { color: new THREE.Color(r / 255, g / 255, b / 255), alpha: Number.isFinite(a) ? a : 1 };
}
/** 주곡선 · 1 m 선 재질 — 1 m 는 주곡선보다 옅게. */
export function contourMaterials(): {
major: THREE.LineBasicMaterial;
minor: THREE.LineBasicMaterial;
} {
const major = parseColor(themeColor("--map-contour-major", "#818cf8"));
const minor = parseColor(themeColor("--map-contour-minor", "rgba(165, 180, 252, 0.45)"));
return {
major: new THREE.LineBasicMaterial({
color: major.color,
transparent: true,
opacity: 0.95 * major.alpha,
}),
minor: new THREE.LineBasicMaterial({
color: minor.color,
transparent: true,
opacity: minor.alpha,
depthWrite: false,
}),
};
}