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

This commit is contained in:
2026-10-06 23:34:38 +09:00
13 changed files with 986 additions and 464 deletions
+2
View File
@@ -69,6 +69,8 @@ config/server_calc_node/
config/formula_node/
# 산출근거 스프레드시트 재계산 번들 — `npm run build:spreadsheet` 산출물.
config/spreadsheet_node/
# 횡단도 덧그림 범위 번들 — `npm run build:b07-extent` 산출물.
config/b07_extent_node/
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
@@ -0,0 +1,262 @@
/* =============================================================================
* B07_DesignDetail_Cross_Extent_Node.ts
* 횡단도 **덧그림 범위**를 서버가 재는 Node 진입점(68-3, CLAUDE.md 5장 「계산 자리」 ②).
*
* 화면은 B06 산식으로 설계선·구조물을 덧그린다(`_Cross_Overlay`). 서버 장 배치가 그
* 범위를 모르면 박스가 그림보다 작아 가장자리에서 잘렸다. 화면과 **같은 코드**를
* 오프스크린 SVG 대신 아래 최소 DOM 위에서 돌려 측점마다 범위(m)를 낸다.
*
* 실행: node <번들> <입력.json> <출력.json>
* 입력 { detail: 종횡단 상세(API와 같은 꼴) }
* 출력 { extents: [{ chainage_m, x_min, x_max, y_min, y_max }] } — 오프셋·표고(m)
* 끝 코드: 0 성공 / 2 인자 오류
* ========================================================================== */
import { readFileSync, writeFileSync } from "node:fs";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
import { overlayEntities, type Entity } from "./B07_DesignDetail_Cross_Overlay";
/** 화면과 같은 종이 축척(1/100 → 1 m = 10 mm) — 공선점 허용 오차가 종이 mm 라 같아야 한다. */
const MM_PER_M = 10;
const OPEN_FRAME = [-1e12, -1e12, 1e12, 1e12];
/* ── 최소 DOM: 그리기 코드가 쓰는 만큼만 ─────────────────────────────────── */
type Child = FakeElement | string;
/** 선택자 하나(`tag`, `tag[attr]`)가 이 요소에 맞나 — 그리기·수확이 쓰는 꼴만 안다. */
function matches(element: FakeElement, selectors: string): boolean {
return selectors.split(",").some((raw) => {
const found = /^([\w-]*)(?:\[([\w-]+)\])?$/.exec(raw.trim());
if (!found) return false;
const [, tag, attribute] = found;
if (tag && element.tagName !== tag) return false;
return !attribute || element.attributes.has(attribute);
});
}
class FakeElement {
readonly attributes = new Map<string, string>();
readonly childNodes: Child[] = [];
parentNode: FakeElement | null = null;
readonly dataset: Record<string, string> = {};
readonly style: Record<string, unknown> = { setProperty: () => undefined };
readonly classList = {
add: (...names: string[]) => this.setClasses([...this.classes(), ...names]),
remove: (...names: string[]) =>
this.setClasses(this.classes().filter((name) => !names.includes(name))),
toggle: (name: string, force?: boolean) => {
const on = force ?? !this.classes().includes(name);
this.setClasses(on ? [...this.classes(), name] : this.classes().filter((c) => c !== name));
return on;
},
contains: (name: string) => this.classes().includes(name),
};
constructor(readonly tagName: string) {}
get ownerDocument(): typeof fakeDocument {
return fakeDocument;
}
get parentElement(): FakeElement | null {
return this.parentNode;
}
get children(): FakeElement[] {
return this.childNodes.filter((child): child is FakeElement => typeof child !== "string");
}
get firstChild(): Child | null {
return this.childNodes[0] ?? null;
}
get lastChild(): Child | null {
return this.childNodes[this.childNodes.length - 1] ?? null;
}
get textContent(): string {
return this.childNodes
.map((child) => (typeof child === "string" ? child : child.textContent))
.join("");
}
set textContent(value: string | null) {
this.replaceChildren(...(value ? [String(value)] : []));
}
private classes(): string[] {
return (this.attributes.get("class") ?? "").split(/\s+/).filter(Boolean);
}
private setClasses(names: string[]): void {
this.attributes.set("class", [...new Set(names)].join(" "));
}
private adopt(child: Child): Child {
if (typeof child !== "string") {
child.parentNode?.removeChild(child);
child.parentNode = this;
}
return child;
}
setAttribute(name: string, value: unknown): void {
this.attributes.set(name, String(value));
}
setAttributeNS(_ns: string | null, name: string, value: unknown): void {
this.setAttribute(name, value);
}
getAttribute(name: string): string | null {
return this.attributes.get(name) ?? null;
}
hasAttribute(name: string): boolean {
return this.attributes.has(name);
}
removeAttribute(name: string): void {
this.attributes.delete(name);
}
append(...nodes: Child[]): void {
for (const node of nodes) this.childNodes.push(this.adopt(node));
}
appendChild<T extends Child>(node: T): T {
this.append(node);
return node;
}
prepend(...nodes: Child[]): void {
this.childNodes.unshift(...nodes.map((node) => this.adopt(node)));
}
insertBefore<T extends Child>(node: T, reference: Child | null): T {
const index = reference ? this.childNodes.indexOf(reference) : -1;
if (index < 0) return this.appendChild(node);
this.childNodes.splice(index, 0, this.adopt(node));
return node;
}
removeChild<T extends Child>(node: T): T {
const index = this.childNodes.indexOf(node);
if (index >= 0) this.childNodes.splice(index, 1);
if (typeof node !== "string") node.parentNode = null;
return node;
}
replaceChildren(...nodes: Child[]): void {
for (const child of [...this.childNodes]) this.removeChild(child);
this.append(...nodes);
}
remove(): void {
this.parentNode?.removeChild(this);
}
closest(selectors: string): FakeElement | null {
for (let node: FakeElement | null = this; node; node = node.parentNode) {
if (matches(node, selectors)) return node;
}
return null;
}
querySelectorAll(selectors: string): FakeElement[] {
const found: FakeElement[] = [];
const walk = (node: FakeElement): void => {
for (const child of node.children) {
if (matches(child, selectors)) found.push(child);
walk(child);
}
};
walk(this);
return found;
}
querySelector(selectors: string): FakeElement | null {
return this.querySelectorAll(selectors)[0] ?? null;
}
addEventListener(): void {}
removeEventListener(): void {}
}
const fakeDocument = {
createElementNS: (_ns: string, tag: string) => new FakeElement(tag),
createElement: (tag: string) => new FakeElement(tag),
createTextNode: (text: string) => text,
// 오프스크린 SVG 는 문서에 안 붙어 있다 — 브라우저도 여기서 null 을 낸다(클립 해칭은 안 자름).
getElementById: () => null,
};
(globalThis as Record<string, unknown>).document = fakeDocument;
/* ── 범위 재기 ─────────────────────────────────────────────────────────── */
/**
* 글자 폭(종이 mm) 어림 — Node 에는 글꼴이 없다. 한글은 글자 크기만큼, 나머지는 0.6 배로
* 잡는다(sans-serif 실측: 한글 0.92 · 숫자 0.56 · 기호 0.28~0.56 · m 0.93). 넉넉한 쪽이라 글자가 박스
* 끝에 걸리면 간격이 1 mm 보다 조금 넓게 나온다.
*/
function textWidth(label: string, fontSize: number): number {
let width = 0;
for (const char of label) width += /[ㄱ-힣]/.test(char) ? fontSize : fontSize * 0.6;
return width;
}
function extentOf(entities: Entity[]): [number, number, number, number] | null {
const xs: number[] = [];
const ys: number[] = [];
const walk = (node: Entity): void => {
const shape = (node.shapeData ?? null) as Record<string, unknown> | null;
const label = shape?.label;
const base = shape?.basePoint as { x: number; y: number } | undefined;
if (typeof label === "string" && base) {
// CAD 는 글자를 기준점 세로 가운데(textBaseline middle)에 정렬 쪽으로 그린다.
const options = (shape?.options ?? {}) as { fontSize?: number; textAlign?: string };
const size = Number(options.fontSize ?? 2);
const width = textWidth(label, size);
const left =
options.textAlign === "center"
? base.x - width / 2
: options.textAlign === "right"
? base.x - width
: base.x;
xs.push(left, left + width);
ys.push(base.y - size / 2, base.y + size / 2);
}
for (const key of ["startPoint", "endPoint", "basePoint"]) {
const point = shape?.[key] as { x: number; y: number } | undefined;
if (point) {
xs.push(point.x);
ys.push(point.y);
}
}
const center = shape?.center as { x: number; y: number } | undefined;
if (center) {
const radius = Number(shape?.radius ?? 0);
xs.push(center.x - radius, center.x + radius);
ys.push(center.y - radius, center.y + radius);
}
for (const child of (node.children as Entity[] | undefined) ?? []) walk(child);
};
entities.forEach(walk);
if (!xs.length) return null;
return [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
}
const [inputPath, outputPath] = process.argv.slice(2);
if (!inputPath || !outputPath) {
console.error("사용법: node <번들> <입력.json> <출력.json>");
process.exit(2);
}
const input = JSON.parse(readFileSync(inputPath, "utf8")) as { detail?: SectionDetailResponse };
const sections = input.detail?.cross_sections ?? [];
const extents: Record<string, number>[] = [];
for (const section of sections) {
let entities: Entity[] | null = null;
try {
entities = overlayEntities(
section,
sections,
(offset) => offset * MM_PER_M,
(elevation) => -(elevation * MM_PER_M),
OPEN_FRAME,
);
} catch (error) {
// 한 측점의 기하 실패가 전체를 막지 않는다 — 화면도 그 측점만 건너뛴다.
console.error(`측점 ${section.chainage_m} 덧그림 실패: ${String(error)}`);
}
const extent = entities?.length ? extentOf(entities) : null;
if (!extent) continue;
const [xMin, xMax, yMin, yMax] = extent.map((value) => value / MM_PER_M);
extents.push({
chainage_m: section.chainage_m,
x_min: xMin,
x_max: xMax,
y_min: yMin,
y_max: yMax,
});
}
writeFileSync(outputPath, JSON.stringify({ extents }), "utf8");
@@ -0,0 +1,475 @@
/* =============================================================================
* B07_DesignDetail_Cross_Overlay.ts
* 횡단도 **덧그림**(B06 설계선·구조물) → CAD 엔티티. 화면(`_UI_Cad_Structures`)과
* 서버 Node(`_Cross_Extent_Node`)가 같이 쓴다 — 서버는 이 범위로 블록 박스를 잡는다(68-3).
*
* 산식은 새로 만들지 않는다. B06 화면이 쓰는 기하·그리기 함수를 **그대로** 불러
* 화면에 붙이지 않은 오프스크린 SVG에 그린 뒤, 그 도형을 CAD 엔티티로 옮긴다
* (2026-08-30 사용자 확정: 프론트에서 B06 산식 재사용 — 파이썬 포팅 금지).
* ========================================================================== */
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import {
computeStoredLayouts as computeLayouts,
type StoredLayouts,
} from "../B06_Section/B06_Section_Structure_Layouts";
import { appendBoxOverlay } from "../B06_Section/B06_Section_UI_Cross_Box";
import { appendCulvertOverlay } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import { appendFordOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford";
import { appendFordPavementOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
import {
appendCrossDesignOverlay,
appendPavementOverlay,
} from "../B06_Section/B06_Section_UI_Cross_Design";
import { appendRevetmentOverlay } from "../B06_Section/B06_Section_UI_Cross_Revetment";
const SVG_NS = "http://www.w3.org/2000/svg";
/** 구조물 엔티티가 들어갈 레이어·색 — 서버 도면이 이미 선언해 둔 그 레이어다. */
const STRUCTURE_LAYER_ID = "b08-structure";
const STRUCTURE_COLOR = "#f6d55c";
/** 설계선 도면층·색 — 서버 도면(`B07_DesignDetail_Engine_Cad`)이 쓰는 그 값이다. */
export const DESIGN_LAYER_ID = "b08-design";
const DESIGN_COLOR = "#b794f6";
/** 글자 크기(종이 mm) — SVG는 CSS로 크기를 잡아 오프스크린에서는 읽을 수 없다. */
const LABEL_FONT_MM = 2.0;
/** 정의부(해칭 패턴·클립)는 도형이 아니다 — 클립 경계 자체는 그림이 아니라 잘라 낼 자다. */
const SKIP_SELECTOR = "defs, clipPath, pattern";
/** 원을 폴리선으로 바꿀 때 쓰는 분할 수 — 클립 안에서만 쓴다(밖은 Circle 그대로). */
const CIRCLE_SEGMENTS = 36;
export type Entity = Record<string, unknown>;
type XY = [number, number];
/** 수확한 도형을 어느 도면층·색으로 넣을지. */
interface Style {
layerId: string;
color: string;
}
const STRUCTURE_STYLE: Style = { layerId: STRUCTURE_LAYER_ID, color: STRUCTURE_COLOR };
const DESIGN_STYLE: Style = { layerId: DESIGN_LAYER_ID, color: DESIGN_COLOR };
function baseEntity(style: Style, type: string, shapeData: unknown): Entity {
return {
id: crypto.randomUUID(),
type,
lineColor: style.color,
lineWidth: 1,
layerId: style.layerId,
shapeData,
};
}
function lineEntity(style: Style, start: XY, end: XY): Entity {
return baseEntity(style, "Line", {
startPoint: { x: start[0], y: start[1] },
endPoint: { x: end[0], y: end[1] },
});
}
/** 점열 → PolyLine(자식 Line 묶음). 서버 도면의 폴리라인과 같은 직렬화다. */
function polyEntity(style: Style, points: XY[]): Entity | null {
if (points.length < 2) return null;
const children: Entity[] = [];
for (let index = 0; index < points.length - 1; index += 1) {
children.push(lineEntity(style, points[index], points[index + 1]));
}
const poly = baseEntity(style, "PolyLine", null);
poly.children = children;
return poly;
}
function textEntity(style: Style, label: string, at: XY, align: string): Entity {
return baseEntity(style, "Text", {
label,
basePoint: { x: at[0], y: at[1] },
options: {
textDirection: { x: 1, y: 0 },
textAlign: align,
textColor: style.color,
fontSize: LABEL_FONT_MM,
fontFamily: "sans-serif",
},
});
}
/**
* 공선점 제거(Ramer–Douglas–Peucker) — 설계선은 지반 샘플마다 점을 갖고 있어(측점 하나에
* 83점) 곧은 사면이 수십 조각으로 쪼개진다. CAD에서 한 변은 한 선이어야 잡고 고칠 수 있다.
* 허용 오차는 **종이 mm**라, 1/100에서 0.05mm = 실거리 5mm — 도면상 같은 직선이다.
*/
function simplify(points: XY[], tolerance = 0.05): XY[] {
if (points.length < 3) return points;
const first = points[0];
const last = points[points.length - 1];
const [dx, dy] = [last[0] - first[0], last[1] - first[1]];
const span = Math.hypot(dx, dy);
let worst = 0;
let index = 0;
for (let i = 1; i < points.length - 1; i += 1) {
const [px, py] = points[i];
const distance =
span > 1e-9
? Math.abs(dy * px - dx * py + last[0] * first[1] - last[1] * first[0]) / span
: Math.hypot(px - first[0], py - first[1]);
if (distance > worst) {
worst = distance;
index = i;
}
}
if (worst <= tolerance) return [first, last];
return [
...simplify(points.slice(0, index + 1), tolerance).slice(0, -1),
...simplify(points.slice(index), tolerance),
];
}
/**
* 끝점이 맞물리는 선분들을 하나의 점열로 잇는다.
*
* B06 설계선 그리기는 **점 사이마다 `<line>` 하나**를 만든다(_Cross_Design 603행) —
* 그대로 옮기면 곧은 사면 한 변이 CAD에서 선 열댓 개가 된다. 이어 붙인 뒤 공선점을
* 지우면 한 변이 한 선이 된다(2026-08-30 사용자 지적).
*/
function chain(segments: XY[][], tolerance = 1e-3): XY[][] {
const used = new Array(segments.length).fill(false);
const near = (a: XY, b: XY): boolean =>
Math.abs(a[0] - b[0]) <= tolerance && Math.abs(a[1] - b[1]) <= tolerance;
const chains: XY[][] = [];
for (let seed = 0; seed < segments.length; seed += 1) {
if (used[seed]) continue;
used[seed] = true;
const points: XY[] = [segments[seed][0], segments[seed][1]];
let grew = true;
while (grew) {
grew = false;
for (let i = 0; i < segments.length; i += 1) {
if (used[i]) continue;
const [a, b] = segments[i];
const head = points[0];
const tail = points[points.length - 1];
if (near(tail, a)) points.push(b);
else if (near(tail, b)) points.push(a);
else if (near(head, b)) points.unshift(a);
else if (near(head, a)) points.unshift(b);
else continue;
used[i] = true;
grew = true;
}
}
chains.push(points);
}
return chains;
}
/** 점열을 종이 x범위로 자른다 — 경계는 선형보간으로 새 점을 만든다(서버 _clip_polyline과 같은 규칙). */
function clipX(points: XY[], x0: number, x1: number): XY[] {
const clipped: XY[] = [];
for (let index = 0; index < points.length; index += 1) {
const [x, y] = points[index];
if (index > 0) {
const [px, py] = points[index - 1];
for (const edge of [x0, x1]) {
if ((px < edge && edge < x) || (x < edge && edge < px)) {
const ratio = (edge - px) / (x - px);
clipped.push([edge, py + (y - py) * ratio]);
}
}
}
if (x >= x0 && x <= x1) clipped.push([x, y]);
}
return clipped;
}
function attr(element: SVGElement, name: string): number {
return Number(element.getAttribute(name) ?? 0);
}
/** SVG 좌표(y 아래로 증가) → 도면 좌표(y 위로 증가). */
function flip(x: number, y: number): XY {
return [x, -y];
}
function parsePoints(element: SVGElement): XY[] {
const raw = (element.getAttribute("points") ?? "").trim();
if (!raw) return [];
const numbers = raw.split(/[\s,]+/).map(Number);
const points: XY[] = [];
for (let index = 0; index + 1 < numbers.length; index += 2) {
points.push(flip(numbers[index], numbers[index + 1]));
}
return points;
}
/**
* 이 도형에 걸린 clipPath 폴리곤(도면 좌표). 없으면 null.
*
* 기슭막이 형태 해칭(`B06_Section_UI_Cross_Wall_Hatch`)은 벽 폴리곤 clip 안에서 그린다.
* 종전 수확기는 그 그룹을 통째로 건너뛰어 **돌쌓기·콘크리트 해칭이 CAD 도면에 하나도
* 실리지 않았다**. 클립을 쓰는 대신 경계로 **잘라서** 싣는다(2026-09-03 사용자 결정).
*/
function clipPolygonOf(element: SVGElement): XY[] | null {
const group = element.closest("g[clip-path]");
const reference = group?.getAttribute("clip-path") ?? "";
const id = reference.match(/url\(#([^)]+)\)/)?.[1];
if (!id) return null;
const shape = element.ownerDocument?.getElementById(id)?.querySelector("polygon");
if (!shape) return null;
const points = parsePoints(shape as unknown as SVGElement);
return points.length >= 3 ? points : null;
}
/** 점이 폴리곤 안인가 — 오목한 벽 단면도 되도록 광선 교차 홀짝으로 본다. */
function pointInPolygon(point: XY, polygon: XY[]): boolean {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
const [xi, yi] = polygon[i];
const [xj, yj] = polygon[j];
const straddles = yi > point[1] !== yj > point[1];
if (straddles && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/** 선분을 폴리곤 경계에서 잘라 **안쪽 조각들**만 돌려준다. */
function clipSegment(start: XY, end: XY, polygon: XY[]): XY[][] {
const [x0, y0] = start;
const [x1, y1] = end;
const dx = x1 - x0;
const dy = y1 - y0;
const cuts = [0, 1];
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
const [ex, ey] = polygon[j];
const [fx, fy] = polygon[i];
const denominator = dx * (fy - ey) - dy * (fx - ex);
if (Math.abs(denominator) < 1e-12) continue;
const t = ((ex - x0) * (fy - ey) - (ey - y0) * (fx - ex)) / denominator;
const u = ((ex - x0) * dy - (ey - y0) * dx) / denominator;
if (t > 0 && t < 1 && u >= 0 && u <= 1) cuts.push(t);
}
cuts.sort((a, b) => a - b);
const runs: XY[][] = [];
for (let index = 0; index + 1 < cuts.length; index += 1) {
const from = cuts[index];
const to = cuts[index + 1];
if (to - from < 1e-9) continue;
const mid = (from + to) / 2;
if (!pointInPolygon([x0 + dx * mid, y0 + dy * mid], polygon)) continue;
runs.push([
[x0 + dx * from, y0 + dy * from],
[x0 + dx * to, y0 + dy * to],
]);
}
return runs;
}
/** 점열을 폴리곤 안으로 자른다 — 조각마다 한 줄. */
function clipPointsToPolygon(points: XY[], polygon: XY[]): XY[][] {
const runs: XY[][] = [];
for (let index = 0; index + 1 < points.length; index += 1) {
runs.push(...clipSegment(points[index], points[index + 1], polygon));
}
return runs;
}
function circlePoints(center: XY, radius: number): XY[] {
const points: XY[] = [];
for (let index = 0; index <= CIRCLE_SEGMENTS; index += 1) {
const angle = (2 * Math.PI * index) / CIRCLE_SEGMENTS;
points.push([center[0] + radius * Math.cos(angle), center[1] + radius * Math.sin(angle)]);
}
return points;
}
/**
* `rect` → 닫힌 네 모서리 점열(회전 transform 적용).
*
* 돌쌓기 돌·돌망태 칸(`B06_Section_UI_Cross_Wall_Hatch.cell()`)은 `rect` 하나에
* `rotate(벽기울기 cx cy)` 를 걸어 그린다. 수확기가 `rect` 를 안 보던 때는 형태 해칭
* 80개 중 **49개(돌·칸 전부)가 CAD 에 안 실렸다**(2026-09-03 실측). 모서리 반경(`rx`)은
* 무시한다 — 도면에서는 각진 칸으로 충분하다.
*/
function rectPoints(element: SVGElement): XY[] {
const x = attr(element, "x");
const y = attr(element, "y");
const width = attr(element, "width");
const height = attr(element, "height");
const corners: XY[] = [
[x, y],
[x + width, y],
[x + width, y + height],
[x, y + height],
[x, y],
];
const rotate = /rotate\(\s*(-?[\d.]+)[\s,]+(-?[\d.]+)[\s,]+(-?[\d.]+)\s*\)/.exec(
element.getAttribute("transform") ?? "",
);
if (!rotate) return corners.map(([cx, cy]) => flip(cx, cy));
const angle = (Number(rotate[1]) * Math.PI) / 180;
const [ox, oy] = [Number(rotate[2]), Number(rotate[3])];
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return corners.map(([cx, cy]) => {
const dx = cx - ox;
const dy = cy - oy;
return flip(ox + dx * cos - dy * sin, oy + dx * sin + dy * cos);
});
}
/** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다 (블록 테두리 안으로 자른다). */
function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] {
const [fx0, , fx1] = frame;
const entities: Entity[] = [];
const segments: XY[][] = [];
const inside = (px: number): boolean => px >= fx0 && px <= fx1;
const nodes = root.querySelectorAll<SVGElement>("polygon, polyline, line, circle, rect, text");
for (const element of Array.from(nodes)) {
if (element.closest(SKIP_SELECTOR)) continue;
const tag = element.tagName.toLowerCase();
// 클립 그룹 안의 해칭은 경계로 **잘라서** 싣는다 — CAD 에는 클립이 없다.
const clip = clipPolygonOf(element);
if (tag === "polygon" || tag === "polyline" || tag === "rect") {
const points = tag === "rect" ? rectPoints(element) : parsePoints(element);
if (tag === "polygon" && points.length > 2) points.push(points[0]);
const runs = clip ? clipPointsToPolygon(points, clip) : [points];
for (const run of runs) {
const poly = polyEntity(style, simplify(clipX(run, fx0, fx1)));
if (poly) entities.push(poly);
}
} else if (tag === "line") {
const start = flip(attr(element, "x1"), attr(element, "y1"));
const end = flip(attr(element, "x2"), attr(element, "y2"));
for (const run of clip ? clipSegment(start, end, clip) : [[start, end]]) {
const cut = clipX(run, fx0, fx1);
if (cut.length === 2) segments.push(cut);
}
} else if (tag === "circle") {
const center = flip(attr(element, "cx"), attr(element, "cy"));
if (!inside(center[0])) continue;
const radius = attr(element, "r");
if (!clip) {
entities.push(
baseEntity(style, "Circle", {
center: { x: center[0], y: center[1] },
radius,
}),
);
continue;
}
// 클립 안 원(통나무 마구리 등)은 폴리선으로 바꿔 경계에서 자른다.
for (const run of clipPointsToPolygon(circlePoints(center, radius), clip)) {
const poly = polyEntity(style, simplify(clipX(run, fx0, fx1)));
if (poly) entities.push(poly);
}
} else if (tag === "text") {
const label = (element.textContent ?? "").trim();
const at = flip(attr(element, "x"), attr(element, "y"));
if (!label || !inside(at[0])) continue;
if (clip && !pointInPolygon(at, clip)) continue;
const anchor = element.getAttribute("text-anchor");
const align = anchor === "start" ? "left" : anchor === "end" ? "right" : "center";
entities.push(textEntity(style, label, at, align));
}
}
// 낱개 선분은 이어 붙인 뒤 공선점을 지워 한 변을 한 선으로 만든다.
for (const points of chain(segments)) {
const poly = polyEntity(style, simplify(points));
if (poly) entities.push(poly);
}
return entities;
}
/** 엔티티가 차지하는 종이 좌표 사각형 (없으면 null). */
export function entityBox(entity: Record<string, unknown>): number[] | null {
const xs: number[] = [];
const ys: number[] = [];
const walk = (node: Record<string, unknown>): void => {
const shape = node.shapeData as Record<string, Record<string, number>> | null;
for (const key of ["startPoint", "endPoint", "basePoint", "center"]) {
const point = shape?.[key];
if (point) {
xs.push(point.x);
ys.push(point.y);
}
}
for (const child of (node.children as Record<string, unknown>[]) ?? []) walk(child);
};
walk(entity);
return xs.length ? [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] : null;
}
type Layouts = StoredLayouts;
/**
* 설계선(+포장층)을 그린다. **구조물이 깎아 낸 설계선**(designTrim)을 B06 카드와 같은
* 우선순위로 넘긴다 — 정본에는 트림 전 원본만 있어서, 이걸 안 태우면 벽이 서 있어도
* 설계선이 원래대로 지나간다(2026-08-30 사용자 지적).
*/
function drawDesign(
svg: SVGElement,
section: CrossSection,
layouts: Layouts,
x: (offset: number) => number,
y: (elevation: number) => number,
): void {
const { design, culvert, box, ford, own } = layouts;
const paved = appendFordPavementOverlay(svg, section.ford_pavement, design, x, y);
if (!paved) appendPavementOverlay(svg, design, x, y);
appendCrossDesignOverlay(
svg,
design,
x,
y,
section.samples,
culvert?.designTrim ?? ford?.designTrim ?? box?.designTrim ?? own?.designTrim,
);
}
/** 구조물을 그린다. 그리는 순서는 B06 카드와 같다. */
function drawStructures(
svg: SVGElement,
section: CrossSection,
layouts: Layouts,
x: (offset: number) => number,
y: (elevation: number) => number,
): void {
const { link, culvert, box, ford, own } = layouts;
if (own) appendRevetmentOverlay(svg, own, x, y);
if (box) appendBoxOverlay(svg, box, x, y);
if (ford) appendFordOverlay(svg, ford, x, y);
if (culvert) {
const linked = !section.culvert && !!link;
appendCulvertOverlay(
svg,
culvert,
x,
y,
undefined,
linked || culvert.culvert.hidden_pipe === true,
);
}
}
/**
* 한 측점의 덧그림 엔티티 — 설계선(구조물 트림 반영) + 구조물. 가로는 `frame`
* [x0, y0, x1, y1] 안으로 자른다. 기하가 없으면(layouts 없음) null.
*/
export function overlayEntities(
section: CrossSection,
sections: CrossSection[],
x: (offset: number) => number,
y: (elevation: number) => number,
frame: number[],
): Entity[] | null {
const layouts = computeLayouts(section, sections);
if (!layouts) return null;
const designSvg = document.createElementNS(SVG_NS, "svg");
drawDesign(designSvg, section, layouts, x, y);
const structureSvg = document.createElementNS(SVG_NS, "svg");
drawStructures(structureSvg, section, layouts, x, y);
return [
...harvest(designSvg, DESIGN_STYLE, frame),
...harvest(structureSvg, STRUCTURE_STYLE, frame),
];
}
@@ -434,11 +434,14 @@ def _section_window(
ground: list[tuple[float, float]],
design: list[tuple[float, float]],
margin_m: float = CROSS_SECTION_MARGIN_M,
overlay: tuple[float, float, float, float] | None = None,
) -> tuple[float, float]:
"""설계선이 원지반에서 갈라졌다가 다시 만나는 가로 범위(+여유)를 구한다.
B06 설계선은 절·성토 구간 바깥에서 원지반을 그대로 따라가므로, 표고가
갈라지는 구간이 곧 "그려야 할 단면"이다 (2026-08-29 사용자 기준).
overlay(오프셋 최소·최대, 표고 최소·최대 m)는 화면이 덧그리는 B06 설계선·구조물
범위다 — 범위가 그것까지 덮어야 박스가 덧그림을 자르지 않는다(68-3).
"""
left = right = None
for x, y in design:
@@ -452,6 +455,11 @@ def _section_window(
left, right = min(xs), max(xs)
# 여유는 선이 있는 데까지만 — 원지반 자료가 짧으면 빈 여유가 테두리를 밀어낸다(68-1).
drawn = [x for x, _y in ground] + [x for x, _y in design]
if overlay is not None:
drawn.extend(overlay[:2])
low = min(max(left - margin_m, min(drawn)), overlay[0])
high = max(min(right + margin_m, max(drawn)), overlay[1])
return (low, high)
if drawn:
return (max(left - margin_m, min(drawn)), min(right + margin_m, max(drawn)))
return (left - margin_m, right + margin_m)
@@ -487,6 +495,7 @@ def build_cross_drawing(
design_elevation_m: float | None = None,
frame: dict[str, float] | None = None,
origin: tuple[float, float] = (0.0, 0.0),
overlay: tuple[float, float, float, float] | None = None,
) -> dict[str, Any]:
"""횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다.
@@ -515,13 +524,15 @@ def build_cross_drawing(
)
# 그릴 가로 범위: 설계선이 원지반과 갈라지는 구간 + 여유.
x0, x1 = _section_window(raw_ground, raw_design)
x0, x1 = _section_window(raw_ground, raw_design, overlay=overlay)
ground_clipped = _clip_polyline(raw_ground, x0, x1)
# 세로 정렬: 이 범위 안의 선(지표+설계+암 경계) bbox 중심을 0으로.
# 세로 정렬: 이 범위 안의 선(지표+설계+암 경계+덧그림) bbox 중심을 0으로.
all_ys = [y for _x, y in ground_clipped] + [y for x, y in raw_design if x0 <= x <= x1]
if isinstance(rock_offset, (int, float)):
all_ys.extend(y + float(rock_offset) for _x, y in ground_clipped)
if overlay is not None:
all_ys.extend(overlay[2:])
dy = (min(all_ys) + max(all_ys)) / 2.0 if all_ys else 0.0
own_half_height = (max(all_ys) - min(all_ys)) / 2.0 if all_ys else 5.0
@@ -63,6 +63,7 @@ def section_block_size(
design_line: list[Any] | None,
with_table: bool = True,
design: dict[str, Any] | None = None,
overlay: tuple[float, float, float, float] | None = None,
) -> tuple[float, float]:
"""단면 블록 하나의 종이 크기(mm) — 엔티티를 만들지 않고 치수만 잰다.
@@ -72,12 +73,14 @@ def section_block_size(
design_spec = design if isinstance(design, dict) else None
ground = points_from_samples(source.get("samples", []), "offset_m")
design = _design_points(design_line)
x0, x1 = _section_window(ground, design)
x0, x1 = _section_window(ground, design, overlay=overlay)
ground_clipped = _clip_polyline(ground, x0, x1)
ys = [y for _x, y in ground_clipped] + [y for x, y in design if x0 <= x <= x1]
rock_offset = (design_spec or {}).get("rock_boundary_offset_m")
if isinstance(rock_offset, (int, float)):
ys.extend(y + float(rock_offset) for _x, y in ground_clipped)
if overlay is not None:
ys.extend(overlay[2:])
half_height = ((max(ys) - min(ys)) / 2.0 if ys else 5.0) * CROSS_MM
width = 2.0 * max(
@@ -218,6 +221,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
section.get("design"),
section.get("quantity_table"),
section.get("title", ""),
overlay=section.get("overlay"),
)
bbox = entities_bbox(probe["entities"])
if bbox is None:
@@ -235,6 +239,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
section.get("quantity_table"),
section.get("title", ""),
origin=origin,
overlay=section.get("overlay"),
)
entities.extend(placed["entities"])
placements.extend(placed.get("cross_placements") or [])
+18 -5
View File
@@ -50,6 +50,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import (
landuse_source,
watershed_source,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import PLAN_KIND_ID, plan_kind_source
from B07_DesignDetail.B07_DesignDetail_Schema import (
DesignDrawingConfirmRequest,
@@ -271,7 +272,10 @@ async def get_design_drawing_list(
try:
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
designs = await _designs_by_chainage(route_id)
drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs)
overlays = await cross_overlay_extents(project_id, route_id)
drawings = await asyncio.to_thread(
_drawing_list, project_root, longitudinal_path, designs, overlays
)
return DesignDrawingListResponse(
project_id=str(project_id), route_id=route_id, drawings=drawings, dev_bypass=bypass
)
@@ -301,6 +305,7 @@ async def get_design_drawing(
# 횡단도는 B06 지정 설계를 먼저 읽어 CAD 계획선(design_line)과 응답에 함께 쓴다.
design: dict[str, Any] | None = None
source_design: Any = None
overlays: dict[Any, Any] | None = None
cross_match = _CROSS_ID.fullmatch(drawing_id)
if cross_match:
pool = get_db_pool()
@@ -312,6 +317,7 @@ async def get_design_drawing(
elif CROSS_SHEET_ID.fullmatch(drawing_id):
# 장은 여러 측점을 담으므로 노선 전체 지정을 한 번에 읽어 넘긴다.
source_design = await _designs_by_chainage(route_id)
overlays = await cross_overlay_extents(project_id, route_id)
elif drawing_id == MASS_HAUL_ID:
# 유토곡선은 B06 확정 시 종단 레코드에 저장해 둔 산출물을 그대로 쓴다.
pool = get_db_pool()
@@ -364,6 +370,7 @@ async def get_design_drawing(
longitudinal_path,
drawing_id,
source_design,
overlays,
)
return DesignDrawingResponse(
project_id=str(project_id),
@@ -406,14 +413,17 @@ async def confirm_design_drawing(
try:
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
designs = await _designs_by_chainage(route_id)
items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs)
overlays = await cross_overlay_extents(project_id, route_id)
items = await asyncio.to_thread(
_drawing_list, project_root, longitudinal_path, designs, overlays
)
item = next((candidate for candidate in items if candidate.id == drawing_id), None)
if not item:
raise FileNotFoundError("확정할 도면을 찾을 수 없습니다.")
sheet = None
if CROSS_SHEET_ID.fullmatch(drawing_id):
plan = await asyncio.to_thread(
_cross_sheet_plan, project_root, longitudinal_path, designs
_cross_sheet_plan, project_root, longitudinal_path, designs, overlays
)
sheet = next((entry for entry in plan if entry["id"] == drawing_id), None)
# 수량표는 CAD 테이블(Text 엔티티)에서 역추출을 우선하고, 없으면 요청 본문 폴백.
@@ -511,7 +521,10 @@ async def invalidate_design_drawing(
# 설계 없이 만들면 방금 확정한 장 id 가 목록에 없어 [수정]이 404 로 막힌다
# (2026-09-03 실측: `cross_s00220m` 확정 후 확정 해제 불가).
designs = await _designs_by_chainage(route_id)
items = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs)
overlays = await cross_overlay_extents(project_id, route_id)
items = await asyncio.to_thread(
_drawing_list, project_root, longitudinal_path, designs, overlays
)
if drawing_id not in {item.id for item in items}:
raise FileNotFoundError("변경된 도면을 찾을 수 없습니다.")
await asyncio.to_thread(_invalidate_drawing, project_root, drawing_id)
@@ -522,7 +535,7 @@ async def invalidate_design_drawing(
stale = [parse_cross_key(cross_match.group(1))]
elif CROSS_SHEET_ID.fullmatch(drawing_id):
plan = await asyncio.to_thread(
_cross_sheet_plan, project_root, longitudinal_path, designs
_cross_sheet_plan, project_root, longitudinal_path, designs, overlays
)
sheet = next((entry for entry in plan if entry["id"] == drawing_id), None)
stale = list(sheet["chainages"]) if sheet else []
@@ -111,6 +111,7 @@ def _drawing_list(
project_root: Path,
longitudinal_path: Path,
designs: dict[int | float, dict[str, Any]] | None = None,
overlays: dict[int | float, tuple[float, float, float, float]] | None = None,
) -> list[DesignDrawingItem]:
longitudinal = _read_json(longitudinal_path)
station_by_chainage = _station_map(longitudinal)
@@ -128,7 +129,7 @@ def _drawing_list(
# 횡단 장 계획이 실패해도 나머지 도면은 목록에 남긴다 — 예전에는 `cross_sections/`
# 하나가 없으면 표지·종단면도·토적도·유역도까지 함께 사라졌다(2026-09-01 지적).
try:
sheets = _cross_sheet_plan(project_root, longitudinal_path, designs)
sheets = _cross_sheet_plan(project_root, longitudinal_path, designs, overlays)
except (FileNotFoundError, ValueError, OSError) as exc:
logger.warning("B07 횡단 장 계획 실패 — 나머지 도면만 싣는다: %s", exc)
sheets = []
@@ -214,6 +215,7 @@ def _cross_sheet_plan(
project_root: Path,
longitudinal_path: Path,
designs: dict[int | float, dict[str, Any]] | None = None,
overlays: dict[int | float, tuple[float, float, float, float]] | None = None,
) -> list[dict[str, Any]]:
"""측점 도면을 A1 장으로 나눈 계획 — 목록과 도면 생성이 같은 결과를 쓴다.
@@ -231,7 +233,8 @@ def _cross_sheet_plan(
chainage = cross_station(source.get("chainage_m") or parse_cross_key(match.group(1)))
design = (designs or {}).get(chainage)
design_line = _cross_design_line(longitudinal_path, source, design)
width, height = section_block_size(source, design_line, design=design)
overlay = (overlays or {}).get(chainage) # 화면 덧그림 범위(68-3)
width, height = section_block_size(source, design_line, design=design, overlay=overlay)
blocks.append((chainage, width, height))
return plan_cross_sheets(blocks)
@@ -342,6 +345,7 @@ def _cross_section_input(
longitudinal: dict[str, Any],
chainage: int | float,
design: dict[str, Any] | None,
overlay: tuple[float, float, float, float] | None = None,
) -> dict[str, Any] | None:
"""한 측점의 장 배치 입력(원본·계획선·수량표·제목)을 만든다."""
path = find_cross_file(longitudinal_path.parent.parent / "cross_sections", chainage)
@@ -356,6 +360,7 @@ def _cross_section_input(
"design_line": _cross_design_line(longitudinal_path, source, design),
"quantity_table": _quantity_table(source, design),
"title": station_no_label(float(source.get("chainage_m", chainage)), interval),
"overlay": overlay,
}
@@ -364,10 +369,12 @@ def _read_cross_sheet(
longitudinal_path: Path,
drawing_id: str,
stored_designs: dict[str, Any] | None,
overlays: dict[int | float, tuple[float, float, float, float]] | None = None,
) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]:
"""횡단 장 하나를 만든다. stored_designs는 {측점: 설계 지정} 묶음이다."""
"""횡단 장 하나를 만든다. stored_designs는 {측점: 설계 지정}, overlays는 {측점: 덧그림 범위}."""
designs = stored_designs if isinstance(stored_designs, dict) else {}
plan = _cross_sheet_plan(project_root, longitudinal_path, designs)
overlays = overlays or {}
plan = _cross_sheet_plan(project_root, longitudinal_path, designs, overlays)
sheet = next((item for item in plan if item["id"] == drawing_id), None)
if sheet is None:
raise FileNotFoundError("요청한 횡단 장을 찾을 수 없습니다.")
@@ -375,7 +382,13 @@ def _read_cross_sheet(
sections = [
section
for section in (
_cross_section_input(longitudinal_path, longitudinal, chainage, designs.get(chainage))
_cross_section_input(
longitudinal_path,
longitudinal,
chainage,
designs.get(chainage),
overlays.get(chainage),
)
for chainage in sheet["chainages"]
)
if section is not None
@@ -389,6 +402,7 @@ def _read_drawing(
longitudinal_path: Path,
drawing_id: str,
stored_design: dict[str, Any] | None = None,
overlays: dict[int | float, tuple[float, float, float, float]] | None = None,
) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]:
"""(kind, label, drawing, confirmed, quantity_table)를 반환한다.
@@ -522,7 +536,9 @@ def _read_drawing(
)
if CROSS_SHEET_ID.fullmatch(drawing_id):
return _read_cross_sheet(project_root, longitudinal_path, drawing_id, stored_design)
return _read_cross_sheet(
project_root, longitudinal_path, drawing_id, stored_design, overlays
)
cross_match = _CROSS_ID.fullmatch(drawing_id)
if not cross_match:
@@ -0,0 +1,80 @@
"""B07 횡단 장 배치용 **덧그림 범위** — 화면이 B06 산식으로 덧그리는 설계선·구조물이
측점마다 차지하는 범위(오프셋·표고 m)를 서버가 잰다(68-3).
계산은 새로 짜지 않는다 — 화면이 쓰는 TS(`B07_DesignDetail_Cross_Overlay`)를 Node
진입점(`B07_DesignDetail_Cross_Extent_Node.ts`)으로 그대로 돌린다(CLAUDE.md 5장 ②).
입력은 화면이 받는 것과 같은 종횡단 상세(`get_section_detail`)다.
실패는 비치명적이다 — 빈 범위를 내면 장 배치는 서버 그림만으로 선다(68-1 동작).
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
from B05_Profile.B05_Profile_Engine_Sections import cross_station
from common_util.common_util_node_bundle import bundle_stale, run_bundle_json
logger = logging.getLogger(__name__)
ROOT = Path(__file__).resolve().parents[1]
BUNDLE = ROOT / "config" / "b07_extent_node" / "B07_DesignDetail_Cross_Extent_Node.js"
_NPM_SCRIPT = "build:b07-extent"
Extent = tuple[float, float, float, float]
# 같은 상세면 같은 범위 — 목록 · 장마다 다시 부르므로 상세 내용으로 기억한다.
# ponytail: 프로세스 메모리 · 최근 몇 벌만. 서버 여러 대면 파일 캐시로.
_CACHE: dict[str, dict[int | float, Extent]] = {}
_CACHE_LIMIT = 8
def _extents_from(output: Any) -> dict[int | float, Extent]:
extents: dict[int | float, Extent] = {}
rows = output.get("extents") if isinstance(output, dict) else None
for row in rows if isinstance(rows, list) else []:
try:
extents[cross_station(float(row["chainage_m"]))] = (
float(row["x_min"]),
float(row["x_max"]),
float(row["y_min"]),
float(row["y_max"]),
)
except (KeyError, TypeError, ValueError):
continue
return extents
async def cross_overlay_extents(project_id: UUID | str, route_id: int) -> dict[int | float, Extent]:
"""{측점: (오프셋 최소, 최대, 표고 최소, 최대)} — 덧그림이 없는 측점은 빠진다."""
from B06_Section.B06_Section_Router import get_section_detail
try:
response = await get_section_detail(UUID(str(project_id)), route_id)
dump = getattr(response, "model_dump", None)
if dump is None: # JSONResponse = 실패
return {}
detail = dump(mode="json")
key = hashlib.sha1(
json.dumps(detail, sort_keys=True, default=str).encode("utf-8")
).hexdigest()
# 번들이 낡았으면(화면 TS 가 바뀜) 기억을 버리고 다시 잰다 — 다시 빌드도 함께.
if key in _CACHE and not await asyncio.to_thread(bundle_stale, BUNDLE):
return _CACHE[key]
output = await asyncio.to_thread(run_bundle_json, BUNDLE, _NPM_SCRIPT, {"detail": detail})
except Exception:
logger.warning("B07 덧그림 범위 계산 실패 — 서버 그림만으로 배치", exc_info=True)
return {}
if output is None:
return {}
extents = _extents_from(output)
if len(_CACHE) >= _CACHE_LIMIT:
_CACHE.pop(next(iter(_CACHE)))
_CACHE[key] = extents
return extents
@@ -15,18 +15,11 @@
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store";
import {
computeStoredLayouts as computeLayouts,
type StoredLayouts,
} from "../B06_Section/B06_Section_Structure_Layouts";
import { appendBoxOverlay } from "../B06_Section/B06_Section_UI_Cross_Box";
import { appendCulvertOverlay } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import { appendFordOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford";
import { appendFordPavementOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
import {
appendCrossDesignOverlay,
appendPavementOverlay,
} from "../B06_Section/B06_Section_UI_Cross_Design";
import { appendRevetmentOverlay } from "../B06_Section/B06_Section_UI_Cross_Revetment";
DESIGN_LAYER_ID,
entityBox,
overlayEntities,
type Entity,
} from "./B07_DesignDetail_Cross_Overlay";
/** 서버가 도면에 실어 보내는 측점별 실좌표(m) → 종이(mm) 변환값. */
export interface CrossPlacement {
@@ -41,436 +34,9 @@ export interface CrossPlacement {
frame?: number[];
}
const SVG_NS = "http://www.w3.org/2000/svg";
/** 구조물 엔티티가 들어갈 레이어·색 — 서버 도면이 이미 선언해 둔 그 레이어다. */
const STRUCTURE_LAYER_ID = "b08-structure";
const STRUCTURE_COLOR = "#f6d55c";
/** 설계선 도면층·색 — 서버 도면(`B07_DesignDetail_Engine_Cad`)이 쓰는 그 값이다. */
const DESIGN_LAYER_ID = "b08-design";
const DESIGN_COLOR = "#b794f6";
/** 글자 크기(종이 mm) — SVG는 CSS로 크기를 잡아 오프스크린에서는 읽을 수 없다. */
const LABEL_FONT_MM = 2.0;
/** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */
const CHAINAGE_TOLERANCE_M = 0.02;
/** 정의부(해칭 패턴·클립)는 도형이 아니다 — 클립 경계 자체는 그림이 아니라 잘라 낼 자다. */
const SKIP_SELECTOR = "defs, clipPath, pattern";
/** 원을 폴리선으로 바꿀 때 쓰는 분할 수 — 클립 안에서만 쓴다(밖은 Circle 그대로). */
const CIRCLE_SEGMENTS = 36;
type Entity = Record<string, unknown>;
type XY = [number, number];
/** 수확한 도형을 어느 도면층·색으로 넣을지. */
interface Style {
layerId: string;
color: string;
}
const STRUCTURE_STYLE: Style = { layerId: STRUCTURE_LAYER_ID, color: STRUCTURE_COLOR };
const DESIGN_STYLE: Style = { layerId: DESIGN_LAYER_ID, color: DESIGN_COLOR };
function baseEntity(style: Style, type: string, shapeData: unknown): Entity {
return {
id: crypto.randomUUID(),
type,
lineColor: style.color,
lineWidth: 1,
layerId: style.layerId,
shapeData,
};
}
function lineEntity(style: Style, start: XY, end: XY): Entity {
return baseEntity(style, "Line", {
startPoint: { x: start[0], y: start[1] },
endPoint: { x: end[0], y: end[1] },
});
}
/** 점열 → PolyLine(자식 Line 묶음). 서버 도면의 폴리라인과 같은 직렬화다. */
function polyEntity(style: Style, points: XY[]): Entity | null {
if (points.length < 2) return null;
const children: Entity[] = [];
for (let index = 0; index < points.length - 1; index += 1) {
children.push(lineEntity(style, points[index], points[index + 1]));
}
const poly = baseEntity(style, "PolyLine", null);
poly.children = children;
return poly;
}
function textEntity(style: Style, label: string, at: XY, align: string): Entity {
return baseEntity(style, "Text", {
label,
basePoint: { x: at[0], y: at[1] },
options: {
textDirection: { x: 1, y: 0 },
textAlign: align,
textColor: style.color,
fontSize: LABEL_FONT_MM,
fontFamily: "sans-serif",
},
});
}
/**
* 공선점 제거(Ramer–Douglas–Peucker) — 설계선은 지반 샘플마다 점을 갖고 있어(측점 하나에
* 83점) 곧은 사면이 수십 조각으로 쪼개진다. CAD에서 한 변은 한 선이어야 잡고 고칠 수 있다.
* 허용 오차는 **종이 mm**라, 1/100에서 0.05mm = 실거리 5mm — 도면상 같은 직선이다.
*/
function simplify(points: XY[], tolerance = 0.05): XY[] {
if (points.length < 3) return points;
const first = points[0];
const last = points[points.length - 1];
const [dx, dy] = [last[0] - first[0], last[1] - first[1]];
const span = Math.hypot(dx, dy);
let worst = 0;
let index = 0;
for (let i = 1; i < points.length - 1; i += 1) {
const [px, py] = points[i];
const distance =
span > 1e-9
? Math.abs(dy * px - dx * py + last[0] * first[1] - last[1] * first[0]) / span
: Math.hypot(px - first[0], py - first[1]);
if (distance > worst) {
worst = distance;
index = i;
}
}
if (worst <= tolerance) return [first, last];
return [
...simplify(points.slice(0, index + 1), tolerance).slice(0, -1),
...simplify(points.slice(index), tolerance),
];
}
/**
* 끝점이 맞물리는 선분들을 하나의 점열로 잇는다.
*
* B06 설계선 그리기는 **점 사이마다 `<line>` 하나**를 만든다(_Cross_Design 603행) —
* 그대로 옮기면 곧은 사면 한 변이 CAD에서 선 열댓 개가 된다. 이어 붙인 뒤 공선점을
* 지우면 한 변이 한 선이 된다(2026-08-30 사용자 지적).
*/
function chain(segments: XY[][], tolerance = 1e-3): XY[][] {
const used = new Array(segments.length).fill(false);
const near = (a: XY, b: XY): boolean =>
Math.abs(a[0] - b[0]) <= tolerance && Math.abs(a[1] - b[1]) <= tolerance;
const chains: XY[][] = [];
for (let seed = 0; seed < segments.length; seed += 1) {
if (used[seed]) continue;
used[seed] = true;
const points: XY[] = [segments[seed][0], segments[seed][1]];
let grew = true;
while (grew) {
grew = false;
for (let i = 0; i < segments.length; i += 1) {
if (used[i]) continue;
const [a, b] = segments[i];
const head = points[0];
const tail = points[points.length - 1];
if (near(tail, a)) points.push(b);
else if (near(tail, b)) points.push(a);
else if (near(head, b)) points.unshift(a);
else if (near(head, a)) points.unshift(b);
else continue;
used[i] = true;
grew = true;
}
}
chains.push(points);
}
return chains;
}
/** 점열을 종이 x범위로 자른다 — 경계는 선형보간으로 새 점을 만든다(서버 _clip_polyline과 같은 규칙). */
function clipX(points: XY[], x0: number, x1: number): XY[] {
const clipped: XY[] = [];
for (let index = 0; index < points.length; index += 1) {
const [x, y] = points[index];
if (index > 0) {
const [px, py] = points[index - 1];
for (const edge of [x0, x1]) {
if ((px < edge && edge < x) || (x < edge && edge < px)) {
const ratio = (edge - px) / (x - px);
clipped.push([edge, py + (y - py) * ratio]);
}
}
}
if (x >= x0 && x <= x1) clipped.push([x, y]);
}
return clipped;
}
function attr(element: SVGElement, name: string): number {
return Number(element.getAttribute(name) ?? 0);
}
/** SVG 좌표(y 아래로 증가) → 도면 좌표(y 위로 증가). */
function flip(x: number, y: number): XY {
return [x, -y];
}
function parsePoints(element: SVGElement): XY[] {
const raw = (element.getAttribute("points") ?? "").trim();
if (!raw) return [];
const numbers = raw.split(/[\s,]+/).map(Number);
const points: XY[] = [];
for (let index = 0; index + 1 < numbers.length; index += 2) {
points.push(flip(numbers[index], numbers[index + 1]));
}
return points;
}
/**
* 이 도형에 걸린 clipPath 폴리곤(도면 좌표). 없으면 null.
*
* 기슭막이 형태 해칭(`B06_Section_UI_Cross_Wall_Hatch`)은 벽 폴리곤 clip 안에서 그린다.
* 종전 수확기는 그 그룹을 통째로 건너뛰어 **돌쌓기·콘크리트 해칭이 CAD 도면에 하나도
* 실리지 않았다**. 클립을 쓰는 대신 경계로 **잘라서** 싣는다(2026-09-03 사용자 결정).
*/
function clipPolygonOf(element: SVGElement): XY[] | null {
const group = element.closest("g[clip-path]");
const reference = group?.getAttribute("clip-path") ?? "";
const id = reference.match(/url\(#([^)]+)\)/)?.[1];
if (!id) return null;
const shape = element.ownerDocument?.getElementById(id)?.querySelector("polygon");
if (!shape) return null;
const points = parsePoints(shape as unknown as SVGElement);
return points.length >= 3 ? points : null;
}
/** 점이 폴리곤 안인가 — 오목한 벽 단면도 되도록 광선 교차 홀짝으로 본다. */
function pointInPolygon(point: XY, polygon: XY[]): boolean {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
const [xi, yi] = polygon[i];
const [xj, yj] = polygon[j];
const straddles = yi > point[1] !== yj > point[1];
if (straddles && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/** 선분을 폴리곤 경계에서 잘라 **안쪽 조각들**만 돌려준다. */
function clipSegment(start: XY, end: XY, polygon: XY[]): XY[][] {
const [x0, y0] = start;
const [x1, y1] = end;
const dx = x1 - x0;
const dy = y1 - y0;
const cuts = [0, 1];
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
const [ex, ey] = polygon[j];
const [fx, fy] = polygon[i];
const denominator = dx * (fy - ey) - dy * (fx - ex);
if (Math.abs(denominator) < 1e-12) continue;
const t = ((ex - x0) * (fy - ey) - (ey - y0) * (fx - ex)) / denominator;
const u = ((ex - x0) * dy - (ey - y0) * dx) / denominator;
if (t > 0 && t < 1 && u >= 0 && u <= 1) cuts.push(t);
}
cuts.sort((a, b) => a - b);
const runs: XY[][] = [];
for (let index = 0; index + 1 < cuts.length; index += 1) {
const from = cuts[index];
const to = cuts[index + 1];
if (to - from < 1e-9) continue;
const mid = (from + to) / 2;
if (!pointInPolygon([x0 + dx * mid, y0 + dy * mid], polygon)) continue;
runs.push([
[x0 + dx * from, y0 + dy * from],
[x0 + dx * to, y0 + dy * to],
]);
}
return runs;
}
/** 점열을 폴리곤 안으로 자른다 — 조각마다 한 줄. */
function clipPointsToPolygon(points: XY[], polygon: XY[]): XY[][] {
const runs: XY[][] = [];
for (let index = 0; index + 1 < points.length; index += 1) {
runs.push(...clipSegment(points[index], points[index + 1], polygon));
}
return runs;
}
function circlePoints(center: XY, radius: number): XY[] {
const points: XY[] = [];
for (let index = 0; index <= CIRCLE_SEGMENTS; index += 1) {
const angle = (2 * Math.PI * index) / CIRCLE_SEGMENTS;
points.push([center[0] + radius * Math.cos(angle), center[1] + radius * Math.sin(angle)]);
}
return points;
}
/**
* `rect` → 닫힌 네 모서리 점열(회전 transform 적용).
*
* 돌쌓기 돌·돌망태 칸(`B06_Section_UI_Cross_Wall_Hatch.cell()`)은 `rect` 하나에
* `rotate(벽기울기 cx cy)` 를 걸어 그린다. 수확기가 `rect` 를 안 보던 때는 형태 해칭
* 80개 중 **49개(돌·칸 전부)가 CAD 에 안 실렸다**(2026-09-03 실측). 모서리 반경(`rx`)은
* 무시한다 — 도면에서는 각진 칸으로 충분하다.
*/
function rectPoints(element: SVGElement): XY[] {
const x = attr(element, "x");
const y = attr(element, "y");
const width = attr(element, "width");
const height = attr(element, "height");
const corners: XY[] = [
[x, y],
[x + width, y],
[x + width, y + height],
[x, y + height],
[x, y],
];
const rotate = /rotate\(\s*(-?[\d.]+)[\s,]+(-?[\d.]+)[\s,]+(-?[\d.]+)\s*\)/.exec(
element.getAttribute("transform") ?? "",
);
if (!rotate) return corners.map(([cx, cy]) => flip(cx, cy));
const angle = (Number(rotate[1]) * Math.PI) / 180;
const [ox, oy] = [Number(rotate[2]), Number(rotate[3])];
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return corners.map(([cx, cy]) => {
const dx = cx - ox;
const dy = cy - oy;
return flip(ox + dx * cos - dy * sin, oy + dx * sin + dy * cos);
});
}
/** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다 (블록 테두리 안으로 자른다). */
function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] {
const [fx0, , fx1] = frame;
const entities: Entity[] = [];
const segments: XY[][] = [];
const inside = (px: number): boolean => px >= fx0 && px <= fx1;
const nodes = root.querySelectorAll<SVGElement>("polygon, polyline, line, circle, rect, text");
for (const element of Array.from(nodes)) {
if (element.closest(SKIP_SELECTOR)) continue;
const tag = element.tagName.toLowerCase();
// 클립 그룹 안의 해칭은 경계로 **잘라서** 싣는다 — CAD 에는 클립이 없다.
const clip = clipPolygonOf(element);
if (tag === "polygon" || tag === "polyline" || tag === "rect") {
const points = tag === "rect" ? rectPoints(element) : parsePoints(element);
if (tag === "polygon" && points.length > 2) points.push(points[0]);
const runs = clip ? clipPointsToPolygon(points, clip) : [points];
for (const run of runs) {
const poly = polyEntity(style, simplify(clipX(run, fx0, fx1)));
if (poly) entities.push(poly);
}
} else if (tag === "line") {
const start = flip(attr(element, "x1"), attr(element, "y1"));
const end = flip(attr(element, "x2"), attr(element, "y2"));
for (const run of clip ? clipSegment(start, end, clip) : [[start, end]]) {
const cut = clipX(run, fx0, fx1);
if (cut.length === 2) segments.push(cut);
}
} else if (tag === "circle") {
const center = flip(attr(element, "cx"), attr(element, "cy"));
if (!inside(center[0])) continue;
const radius = attr(element, "r");
if (!clip) {
entities.push(
baseEntity(style, "Circle", {
center: { x: center[0], y: center[1] },
radius,
}),
);
continue;
}
// 클립 안 원(통나무 마구리 등)은 폴리선으로 바꿔 경계에서 자른다.
for (const run of clipPointsToPolygon(circlePoints(center, radius), clip)) {
const poly = polyEntity(style, simplify(clipX(run, fx0, fx1)));
if (poly) entities.push(poly);
}
} else if (tag === "text") {
const label = (element.textContent ?? "").trim();
const at = flip(attr(element, "x"), attr(element, "y"));
if (!label || !inside(at[0])) continue;
if (clip && !pointInPolygon(at, clip)) continue;
const anchor = element.getAttribute("text-anchor");
const align = anchor === "start" ? "left" : anchor === "end" ? "right" : "center";
entities.push(textEntity(style, label, at, align));
}
}
// 낱개 선분은 이어 붙인 뒤 공선점을 지워 한 변을 한 선으로 만든다.
for (const points of chain(segments)) {
const poly = polyEntity(style, simplify(points));
if (poly) entities.push(poly);
}
return entities;
}
/** 엔티티가 차지하는 종이 좌표 사각형 (없으면 null). */
function entityBox(entity: Record<string, unknown>): number[] | null {
const xs: number[] = [];
const ys: number[] = [];
const walk = (node: Record<string, unknown>): void => {
const shape = node.shapeData as Record<string, Record<string, number>> | null;
for (const key of ["startPoint", "endPoint", "basePoint", "center"]) {
const point = shape?.[key];
if (point) {
xs.push(point.x);
ys.push(point.y);
}
}
for (const child of (node.children as Record<string, unknown>[]) ?? []) walk(child);
};
walk(entity);
return xs.length ? [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] : null;
}
type Layouts = StoredLayouts;
/**
* 설계선(+포장층)을 그린다. **구조물이 깎아 낸 설계선**(designTrim)을 B06 카드와 같은
* 우선순위로 넘긴다 — 정본에는 트림 전 원본만 있어서, 이걸 안 태우면 벽이 서 있어도
* 설계선이 원래대로 지나간다(2026-08-30 사용자 지적).
*/
function drawDesign(
svg: SVGElement,
section: CrossSection,
layouts: Layouts,
x: (offset: number) => number,
y: (elevation: number) => number,
): void {
const { design, culvert, box, ford, own } = layouts;
const paved = appendFordPavementOverlay(svg, section.ford_pavement, design, x, y);
if (!paved) appendPavementOverlay(svg, design, x, y);
appendCrossDesignOverlay(
svg,
design,
x,
y,
section.samples,
culvert?.designTrim ?? ford?.designTrim ?? box?.designTrim ?? own?.designTrim,
);
}
/** 구조물을 그린다. 그리는 순서는 B06 카드와 같다. */
function drawStructures(
svg: SVGElement,
section: CrossSection,
layouts: Layouts,
x: (offset: number) => number,
y: (elevation: number) => number,
): void {
const { link, culvert, box, ford, own } = layouts;
if (own) appendRevetmentOverlay(svg, own, x, y);
if (box) appendBoxOverlay(svg, box, x, y);
if (ford) appendFordOverlay(svg, ford, x, y);
if (culvert) {
const linked = !section.culvert && !!link;
appendCulvertOverlay(
svg,
culvert,
x,
y,
undefined,
linked || culvert.culvert.hidden_pipe === true,
);
}
}
/**
* 도면에 구조물 엔티티를 얹는다 (제자리 수정). 배치 메타가 없거나 자료를 못 읽으면
* 아무것도 하지 않는다 — 구조물이 빠져도 도면 자체는 열려야 한다.
@@ -505,16 +71,9 @@ export async function appendStructureEntities(
-((elevation - placement.dy) * placement.mm_per_m + placement.oy);
let entities: Entity[];
try {
const layouts = computeLayouts(section, sections);
if (!layouts) continue;
const designSvg = document.createElementNS(SVG_NS, "svg");
drawDesign(designSvg, section, layouts, x, y);
const structureSvg = document.createElementNS(SVG_NS, "svg");
drawStructures(structureSvg, section, layouts, x, y);
entities = [
...harvest(designSvg, DESIGN_STYLE, frame),
...harvest(structureSvg, STRUCTURE_STYLE, frame),
];
const drawn = overlayEntities(section, sections, x, y, frame);
if (!drawn) continue;
entities = drawn;
} catch {
continue; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다.
}
+6
View File
@@ -23,6 +23,8 @@ ROOT = Path(__file__).resolve().parents[1]
SOURCE_DIRS = ("B05_Profile", "B06_Section", "B08_Quantity", "common_util")
# 번들에 박히는 자료(JSON) — 벽 전면 기울기 표준경사 표·기슭막이 치수 표(2026-09-14 B5). 표만 바뀌어도 다시 빌드.
SOURCE_DATA_DIRS = ("resources/data_masonry",)
# 폴더째 훑지 않는 자리 — B07 은 CAD 앱(openwebcad · node_modules)이 함께 살아 맨 위 TS 만 본다.
SOURCE_GLOBS = ("B07_DesignDetail/*.ts",)
# 번들 만들기·실행 상한(초). 실측 번들 실행 0.1초, 빌드 3초 수준이라 넉넉하다.
BUILD_TIMEOUT_S = 300
RUN_TIMEOUT_S = 600
@@ -54,6 +56,10 @@ def bundle_stale(bundle: Path) -> bool:
for path in (ROOT / directory).glob("*.json"):
if path.stat().st_mtime > built_at:
return True
for pattern in SOURCE_GLOBS:
for path in ROOT.glob(pattern):
if path.stat().st_mtime > built_at:
return True
return False
+2 -1
View File
@@ -5,11 +5,12 @@
"type": "module",
"scripts": {
"dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner",
"build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:spreadsheet && npm run build:b07-cad",
"build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:spreadsheet && npm run build:b07-extent && npm run build:b07-cad",
"build:corridor": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B05_Profile/B05_Profile_Corridor_Node.ts --outDir ../config/corridor_node",
"build:server-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B06_Section/B06_Section_Server_Calc_Node.ts --outDir ../config/server_calc_node",
"build:formula": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_sheet_recalc_node.ts --outDir ../config/formula_node",
"build:spreadsheet": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_spreadsheet_node.ts --outDir ../config/spreadsheet_node",
"build:b07-extent": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts --outDir ../config/b07_extent_node",
"install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install",
"build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build",
"preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner",
File diff suppressed because one or more lines are too long
@@ -0,0 +1,91 @@
"""B07 횡단면도 — 화면이 덧그리는 설계선·구조물까지 박스 안 1 mm (68-3).
서버가 화면과 같은 TS 를 Node 로 돌려 덧그림 범위를 재고(`_Cross_Extent_Node`),
장 배치 · 블록 테두리가 그 범위를 덮는다.
"""
import json
from pathlib import Path
import pytest
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
CROSS_MM,
FRAME_LAYER_ID,
build_cross_drawing,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import section_block_size
from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox
ROOT = Path(__file__).resolve().parents[2]
FIXTURE = Path(__file__).parent / "fixtures" / "b07_cross_overlay_sections.json"
NODE_MODULES = ROOT / "config" / "node_modules"
_SOURCE = {
"chainage_m": 0.0,
"samples": [
{"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.2 * x}
for x in range(-10, 11)
],
}
_DESIGN_LINE = [
{"offset_m": -10.0, "elevation_m": 98.0},
{"offset_m": -2.0, "elevation_m": 99.6},
{"offset_m": 2.0, "elevation_m": 99.6},
{"offset_m": 10.0, "elevation_m": 102.0},
]
# 오른쪽으로 8 m 더 뻗고 위로 3 m 솟는 덧그림(관 · 날개벽 따위)
_OVERLAY = (-3.0, 18.0, 97.0, 105.0)
def _frame(drawing: dict) -> list[float]:
return drawing["cross_placements"][0]["frame"]
def test_frame_covers_overlay_by_one_mm() -> None:
drawing = build_cross_drawing(
_SOURCE, "cross_s00000m", _DESIGN_LINE, None, None, "", overlay=_OVERLAY
)
placement = drawing["cross_placements"][0]
left, bottom, right, top = _frame(drawing)
to_x = lambda offset: offset * CROSS_MM + placement["ox"] # noqa: E731
to_y = lambda elev: (elev - placement["dy"]) * CROSS_MM + placement["oy"] # noqa: E731
assert right - to_x(_OVERLAY[1]) == pytest.approx(1.0)
assert top - to_y(_OVERLAY[3]) == pytest.approx(1.0)
# 왼쪽 · 아래는 서버 선(원지반 끝 · 설계선)이 더 바깥 — 그쪽에 1 mm
lines = [e for e in drawing["entities"] if e.get("layerId") != FRAME_LAYER_ID]
min_x, _min_y, _max_x, _max_y = entities_bbox(lines)
assert min_x - left == pytest.approx(1.0)
# 장 배치용 크기도 같은 규칙
width, height = section_block_size(_SOURCE, _DESIGN_LINE, with_table=False, overlay=_OVERLAY)
assert width == pytest.approx(right - left)
assert height == pytest.approx(top - bottom)
def test_no_overlay_keeps_68_1_frame() -> None:
plain = build_cross_drawing(_SOURCE, "cross_s00000m", _DESIGN_LINE, None, None, "")
inside = build_cross_drawing(
_SOURCE, "cross_s00000m", _DESIGN_LINE, None, None, "", overlay=(1.0, 3.0, 99.7, 99.8)
)
assert _frame(plain) == pytest.approx(_frame(inside))
@pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
def test_node_bundle_measures_screen_overlay() -> None:
"""화면 코드를 Node 로 — 배수관 측점(85.052)은 서버 범위(+4.0 m)보다 13.8 m 까지 뻗는다."""
from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import (
_NPM_SCRIPT,
BUNDLE,
_extents_from,
)
from common_util.common_util_node_bundle import run_bundle_json
detail = json.loads(FIXTURE.read_text(encoding="utf-8"))
extents = _extents_from(run_bundle_json(BUNDLE, _NPM_SCRIPT, {"detail": detail}))
assert set(extents) == {0, 85.05} # 측점 키는 0.01 m 로 끊는다(cross_station)
x_min, x_max, y_min, y_max = extents[85.05]
assert x_max == pytest.approx(13.8216, abs=1e-3)
assert x_min == pytest.approx(-10.4371, abs=1e-3)
assert y_max - y_min == pytest.approx(16.9505, abs=1e-3)
# 0 m: 노폭 글자(2 mm, 세로 가운데 정렬)가 맨 위 — 글자 반높이 0.1 m 까지
assert extents[0][3] == pytest.approx(879.8831, abs=1e-3)