From e4fa14564f0fdd88afcf2a33e9806d38535b6f66 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 23:33:53 +0900 Subject: [PATCH] =?UTF-8?q?fix(B07):=20=ED=9A=A1=EB=8B=A8=EB=A9=B4?= =?UTF-8?q?=EB=8F=84=20=EB=B0=95=EC=8A=A4=EA=B0=80=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=8D=A7=EA=B7=B8=EB=A6=BC(B06=20=EC=84=A4=EA=B3=84=EC=84=A0?= =?UTF-8?q?=C2=B7=EA=B5=AC=EC=A1=B0=EB=AC=BC)=EA=B9=8C=EC=A7=80=201=20mm?= =?UTF-8?q?=20=EB=A1=9C=20=EA=B0=90=EC=8C=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 덧그림 범위를 서버가 화면과 같은 TS 로 셈 — Node 진입점 B07_DesignDetail_Cross_Extent_Node(최소 DOM 위에서 오프스크린 SVG 그리기 그대로) · 번들 build:b07-extent - 화면 그리기·수확 부분을 B07_DesignDetail_Cross_Overlay 로 떼어 화면과 Node 가 한 벌로 씀 - 장 배치·블록 테두리가 측점별 덧그림 범위를 덮음(목록·장 열기·확정·수정 네 길 모두 같은 계획) - 글자는 CAD 그리기대로 세로 가운데 · 폭은 글꼴 어림(한글 1.0 · 나머지 0.6 배) - 번들 낡음 판정에 B07 맨 위 TS 포함 - 검증_sub4: 66개 박스 ↔ 그림(덧그림·글자 실폭 포함) 모두 1.0 mm · 잘림 0 · 겹침 0 · 20장 → 21장 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- .gitignore | 2 + .../B07_DesignDetail_Cross_Extent_Node.ts | 262 ++++++++++ .../B07_DesignDetail_Cross_Overlay.ts | 475 ++++++++++++++++++ .../B07_DesignDetail_Engine_Cad.py | 15 +- .../B07_DesignDetail_Engine_Cad_Sheet.py | 7 +- B07_DesignDetail/B07_DesignDetail_Router.py | 23 +- .../B07_DesignDetail_Router_Support.py | 28 +- ...B07_DesignDetail_Router_Support_Overlay.py | 80 +++ .../B07_DesignDetail_UI_Cad_Structures.ts | 457 +---------------- common_util/common_util_node_bundle.py | 6 + package.json | 3 +- .../fixtures/b07_cross_overlay_sections.json | 1 + .../tester/test_b07_cross_overlay_extent.py | 91 ++++ 13 files changed, 986 insertions(+), 464 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts create mode 100644 B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts create mode 100644 B07_DesignDetail/B07_DesignDetail_Router_Support_Overlay.py create mode 100644 resources/tester/fixtures/b07_cross_overlay_sections.json create mode 100644 resources/tester/test_b07_cross_overlay_extent.py diff --git a/.gitignore b/.gitignore index 918db4041..cfc42947f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 사용자 확정 「출력물 전체를 공유해도 됨」). diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts new file mode 100644 index 000000000..8ed1dab83 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts @@ -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(); + readonly childNodes: Child[] = []; + parentNode: FakeElement | null = null; + readonly dataset: Record = {}; + readonly style: Record = { 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(node: T): T { + this.append(node); + return node; + } + prepend(...nodes: Child[]): void { + this.childNodes.unshift(...nodes.map((node) => this.adopt(node))); + } + insertBefore(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(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).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 | 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[] = []; +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"); diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts new file mode 100644 index 000000000..aa57fc4ea --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts @@ -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; +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 설계선 그리기는 **점 사이마다 `` 하나**를 만든다(_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("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): number[] | null { + const xs: number[] = []; + const ys: number[] = []; + const walk = (node: Record): void => { + const shape = node.shapeData as Record> | 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[]) ?? []) 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), + ]; +} diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index f1f9a5eca..52f2c246e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -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 diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py index 0784e2203..aa460b9be 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py @@ -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 []) diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index c1188e2c9..790e55a86 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -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 [] diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 9edc65c74..3810e114c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -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: diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Overlay.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Overlay.py new file mode 100644 index 000000000..6bfce8124 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Overlay.py @@ -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 diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts index 6fcaff428..e5aeaa014 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts @@ -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; -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 설계선 그리기는 **점 사이마다 `` 하나**를 만든다(_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("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): number[] | null { - const xs: number[] = []; - const ys: number[] = []; - const walk = (node: Record): void => { - const shape = node.shapeData as Record> | 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[]) ?? []) 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; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다. } diff --git a/common_util/common_util_node_bundle.py b/common_util/common_util_node_bundle.py index dcebdbfde..7a5a6247e 100644 --- a/common_util/common_util_node_bundle.py +++ b/common_util/common_util_node_bundle.py @@ -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 diff --git a/package.json b/package.json index f1f0ca819..e520a8041 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/tester/fixtures/b07_cross_overlay_sections.json b/resources/tester/fixtures/b07_cross_overlay_sections.json new file mode 100644 index 000000000..4bd90dfff --- /dev/null +++ b/resources/tester/fixtures/b07_cross_overlay_sections.json @@ -0,0 +1 @@ +{"cross_sections": [{"chainage_m": 0.0, "label": "STA.0+000.000", "kind": "bp", "center_z": 879.138051, "plan_radius_m": null, "curve_outer_side": null, "curve_widening_m": 0.0, "uphill_side": "right", "samples": [{"offset_m": -20.0, "x": 208383.217875, "y": 367919.905615, "z": 885.23, "elevation_m": 885.23, "valid": true}, {"offset_m": -19.5, "x": 208383.717428, "y": 367919.92675, "z": 885.137949, "elevation_m": 885.137949, "valid": true}, {"offset_m": -19.0, "x": 208384.216981, "y": 367919.947884, "z": 884.936885, "elevation_m": 884.936885, "valid": true}, {"offset_m": -18.5, "x": 208384.716534, "y": 367919.969019, "z": 884.607407, "elevation_m": 884.607407, "valid": true}, {"offset_m": -18.0, "x": 208385.216087, "y": 367919.990153, "z": 884.172793, "elevation_m": 884.172793, "valid": true}, {"offset_m": -17.5, "x": 208385.715641, "y": 367920.011288, "z": 883.544555, "elevation_m": 883.544555, "valid": true}, {"offset_m": -17.0, "x": 208386.215194, "y": 367920.032423, "z": 882.916898, "elevation_m": 882.916898, "valid": true}, {"offset_m": -16.5, "x": 208386.714747, "y": 367920.053557, "z": 882.293527, "elevation_m": 882.293527, "valid": true}, {"offset_m": -16.0, "x": 208387.2143, "y": 367920.074692, "z": 881.672325, "elevation_m": 881.672325, "valid": true}, {"offset_m": -15.5, "x": 208387.713853, "y": 367920.095827, "z": 881.060527, "elevation_m": 881.060527, "valid": true}, {"offset_m": -15.0, "x": 208388.213406, "y": 367920.116961, "z": 880.460071, "elevation_m": 880.460071, "valid": true}, {"offset_m": -14.5, "x": 208388.712959, "y": 367920.138096, "z": 880.032179, "elevation_m": 880.032179, "valid": true}, {"offset_m": -14.0, "x": 208389.212512, "y": 367920.15923, "z": 879.643078, "elevation_m": 879.643078, "valid": true}, {"offset_m": -13.5, "x": 208389.712066, "y": 367920.180365, "z": 879.352392, "elevation_m": 879.352392, "valid": true}, {"offset_m": -13.0, "x": 208390.211619, "y": 367920.2015, "z": 879.10389, "elevation_m": 879.10389, "valid": true}, {"offset_m": -12.5, "x": 208390.711172, "y": 367920.222634, "z": 878.969404, "elevation_m": 878.969404, "valid": true}, {"offset_m": -12.0, "x": 208391.210725, "y": 367920.243769, "z": 878.91786, "elevation_m": 878.91786, "valid": true}, {"offset_m": -11.5, "x": 208391.710278, "y": 367920.264904, "z": 878.924527, "elevation_m": 878.924527, "valid": true}, {"offset_m": -11.0, "x": 208392.209831, "y": 367920.286038, "z": 879.002289, "elevation_m": 879.002289, "valid": true}, {"offset_m": -10.5, "x": 208392.709384, "y": 367920.307173, "z": 879.068511, "elevation_m": 879.068511, "valid": true}, {"offset_m": -10.0, "x": 208393.208937, "y": 367920.328307, "z": 879.107244, "elevation_m": 879.107244, "valid": true}, {"offset_m": -9.5, "x": 208393.708491, "y": 367920.349442, "z": 879.139507, "elevation_m": 879.139507, "valid": true}, {"offset_m": -9.0, "x": 208394.208044, "y": 367920.370577, "z": 879.143071, "elevation_m": 879.143071, "valid": true}, {"offset_m": -8.5, "x": 208394.707597, "y": 367920.391711, "z": 879.146353, "elevation_m": 879.146353, "valid": true}, {"offset_m": -8.0, "x": 208395.20715, "y": 367920.412846, "z": 879.146208, "elevation_m": 879.146208, "valid": true}, {"offset_m": -7.5, "x": 208395.706703, "y": 367920.433981, "z": 879.146123, "elevation_m": 879.146123, "valid": true}, {"offset_m": -7.0, "x": 208396.206256, "y": 367920.455115, "z": 879.151755, "elevation_m": 879.151755, "valid": true}, {"offset_m": -6.5, "x": 208396.705809, "y": 367920.47625, "z": 879.1586, "elevation_m": 879.1586, "valid": true}, {"offset_m": -6.0, "x": 208397.205362, "y": 367920.497384, "z": 879.163669, "elevation_m": 879.163669, "valid": true}, {"offset_m": -5.5, "x": 208397.704916, "y": 367920.518519, "z": 879.167835, "elevation_m": 879.167835, "valid": true}, {"offset_m": -5.0, "x": 208398.204469, "y": 367920.539654, "z": 879.165875, "elevation_m": 879.165875, "valid": true}, {"offset_m": -4.5, "x": 208398.704022, "y": 367920.560788, "z": 879.158448, "elevation_m": 879.158448, "valid": true}, {"offset_m": -4.0, "x": 208399.203575, "y": 367920.581923, "z": 879.151793, "elevation_m": 879.151793, "valid": true}, {"offset_m": -3.5, "x": 208399.703128, "y": 367920.603058, "z": 879.146297, "elevation_m": 879.146297, "valid": true}, {"offset_m": -3.0, "x": 208400.202681, "y": 367920.624192, "z": 879.141387, "elevation_m": 879.141387, "valid": true}, {"offset_m": -2.5, "x": 208400.702234, "y": 367920.645327, "z": 879.13926, "elevation_m": 879.13926, "valid": true}, {"offset_m": -2.0, "x": 208401.201787, "y": 367920.666461, "z": 879.137397, "elevation_m": 879.137397, "valid": true}, {"offset_m": -1.5, "x": 208401.701341, "y": 367920.687596, "z": 879.134583, "elevation_m": 879.134583, "valid": true}, {"offset_m": -1.0, "x": 208402.200894, "y": 367920.708731, "z": 879.131634, "elevation_m": 879.131634, "valid": true}, {"offset_m": -0.5, "x": 208402.700447, "y": 367920.729865, "z": 879.134779, "elevation_m": 879.134779, "valid": true}, {"offset_m": 0.0, "x": 208403.2, "y": 367920.751, "z": 879.138051, "elevation_m": 879.138051, "valid": true}, {"offset_m": 0.5, "x": 208403.699553, "y": 367920.772135, "z": 879.140145, "elevation_m": 879.140145, "valid": true}, {"offset_m": 1.0, "x": 208404.199106, "y": 367920.793269, "z": 879.142384, "elevation_m": 879.142384, "valid": true}, {"offset_m": 1.5, "x": 208404.698659, "y": 367920.814404, "z": 879.114977, "elevation_m": 879.114977, "valid": true}, {"offset_m": 2.0, "x": 208405.198213, "y": 367920.835539, "z": 879.075059, "elevation_m": 879.075059, "valid": true}, {"offset_m": 2.5, "x": 208405.697766, "y": 367920.856673, "z": 878.943412, "elevation_m": 878.943412, "valid": true}, {"offset_m": 3.0, "x": 208406.197319, "y": 367920.877808, "z": 878.727318, "elevation_m": 878.727318, "valid": true}, {"offset_m": 3.5, "x": 208406.696872, "y": 367920.898942, "z": 878.466849, "elevation_m": 878.466849, "valid": true}, {"offset_m": 4.0, "x": 208407.196425, "y": 367920.920077, "z": 878.122375, "elevation_m": 878.122375, "valid": true}, {"offset_m": 4.5, "x": 208407.695978, "y": 367920.941212, "z": 877.774177, "elevation_m": 877.774177, "valid": true}, {"offset_m": 5.0, "x": 208408.195531, "y": 367920.962346, "z": 877.412819, "elevation_m": 877.412819, "valid": true}, {"offset_m": 5.5, "x": 208408.695084, "y": 367920.983481, "z": 877.051286, "elevation_m": 877.051286, "valid": true}, {"offset_m": 6.0, "x": 208409.194638, "y": 367921.004616, "z": 876.701775, "elevation_m": 876.701775, "valid": true}, {"offset_m": 6.5, "x": 208409.694191, "y": 367921.02575, "z": 876.350484, "elevation_m": 876.350484, "valid": true}, {"offset_m": 7.0, "x": 208410.193744, "y": 367921.046885, "z": 876.102657, "elevation_m": 876.102657, "valid": true}, {"offset_m": 7.5, "x": 208410.693297, "y": 367921.068019, "z": 875.864323, "elevation_m": 875.864323, "valid": true}, {"offset_m": 8.0, "x": 208411.19285, "y": 367921.089154, "z": 875.609828, "elevation_m": 875.609828, "valid": true}, {"offset_m": 8.5, "x": 208411.692403, "y": 367921.110289, "z": 875.350185, "elevation_m": 875.350185, "valid": true}, {"offset_m": 9.0, "x": 208412.191956, "y": 367921.131423, "z": 875.048284, "elevation_m": 875.048284, "valid": true}, {"offset_m": 9.5, "x": 208412.691509, "y": 367921.152558, "z": 874.72476, "elevation_m": 874.72476, "valid": true}, {"offset_m": 10.0, "x": 208413.191063, "y": 367921.173693, "z": 874.443005, "elevation_m": 874.443005, "valid": true}, {"offset_m": 10.5, "x": 208413.690616, "y": 367921.194827, "z": 874.204095, "elevation_m": 874.204095, "valid": true}, {"offset_m": 11.0, "x": 208414.190169, "y": 367921.215962, "z": 873.970927, "elevation_m": 873.970927, "valid": true}, {"offset_m": 11.5, "x": 208414.689722, "y": 367921.237096, "z": 873.749439, "elevation_m": 873.749439, "valid": true}, {"offset_m": 12.0, "x": 208415.189275, "y": 367921.258231, "z": 873.535493, "elevation_m": 873.535493, "valid": true}, {"offset_m": 12.5, "x": 208415.688828, "y": 367921.279366, "z": 873.359121, "elevation_m": 873.359121, "valid": true}, {"offset_m": 13.0, "x": 208416.188381, "y": 367921.3005, "z": 873.179565, "elevation_m": 873.179565, "valid": true}, {"offset_m": 13.5, "x": 208416.687934, "y": 367921.321635, "z": 872.954601, "elevation_m": 872.954601, "valid": true}, {"offset_m": 14.0, "x": 208417.187488, "y": 367921.34277, "z": 872.729992, "elevation_m": 872.729992, "valid": true}, {"offset_m": 14.5, "x": 208417.687041, "y": 367921.363904, "z": 872.541049, "elevation_m": 872.541049, "valid": true}, {"offset_m": 15.0, "x": 208418.186594, "y": 367921.385039, "z": 872.362613, "elevation_m": 872.362613, "valid": true}, {"offset_m": 15.5, "x": 208418.686147, "y": 367921.406173, "z": 872.190605, "elevation_m": 872.190605, "valid": true}, {"offset_m": 16.0, "x": 208419.1857, "y": 367921.427308, "z": 872.01587, "elevation_m": 872.01587, "valid": true}, {"offset_m": 16.5, "x": 208419.685253, "y": 367921.448443, "z": 871.819112, "elevation_m": 871.819112, "valid": true}, {"offset_m": 17.0, "x": 208420.184806, "y": 367921.469577, "z": 871.610458, "elevation_m": 871.610458, "valid": true}, {"offset_m": 17.5, "x": 208420.684359, "y": 367921.490712, "z": 871.401369, "elevation_m": 871.401369, "valid": true}, {"offset_m": 18.0, "x": 208421.183913, "y": 367921.511847, "z": 871.190051, "elevation_m": 871.190051, "valid": true}, {"offset_m": 18.5, "x": 208421.683466, "y": 367921.532981, "z": 870.99429, "elevation_m": 870.99429, "valid": true}, {"offset_m": 19.0, "x": 208422.183019, "y": 367921.554116, "z": 870.835928, "elevation_m": 870.835928, "valid": true}, {"offset_m": 19.5, "x": 208422.682572, "y": 367921.57525, "z": 870.661899, "elevation_m": 870.661899, "valid": true}, {"offset_m": 20.0, "x": 208423.182125, "y": 367921.596385, "z": 870.434934, "elevation_m": 870.434934, "valid": true}], "design": {"ground_type": "ripping_rock", "geometry_preset": "rock", "section_mode": "right_cut", "ditch_side": "right", "ditch_type": "standard", "cut_slope_ratio": 0.4, "soil_cut_slope_ratio": 1.0, "two_stage_slope": true, "fill_slope_ratio": 1.2, "roadbed_width_m": 4.0, "carriageway_width_m": 3.0, "carriageway_standard_width_m": 3.0, "widening_left_m": 0.0, "widening_right_m": 0.0, "cross_slope_pct": 3.0, "ditch": {"type": "standard", "top_width_m": 0.69, "bottom_width_m": 0.3, "depth_m": 0.3}, "ditch_enabled": true, "ditch_choice": null, "paved": false, "road_edges": {"left": {"offset_m": 2.0, "elevation_m": 879.1981}, "right": {"offset_m": -2.0, "elevation_m": 879.0781}}, "carriageway_edges": {"left": {"offset_m": 1.5, "elevation_m": 879.1831}, "right": {"offset_m": -1.5, "elevation_m": 879.0931}}, "design_elevation_m": 879.1381, "cut_area_m2": 0.2914, "cut_soil_area_m2": 0.2914, "cut_rock_area_m2": 0.0, "cut_rock_kind": "ripping_rock", "fill_area_m2": 0.0994, "spoil_fill_area_m2": 0.0, "spoil_fill_side": null, "spoil_fill_width_m": 0.0, "spoil_fill_max_width_m": 0.0, "spoil_fill_line": [], "spoil_fill_unclosed": false, "spoil_fill_replaced_fill_m2": 0.0, "bench_cut_length_m": 0.2585, "slope_unclosed": false, "fill_ground_slope": 0.2633, "ditch_area_m2": 0.1485, "ditch_soil_area_m2": 0.1485, "ditch_rock_area_m2": 0.0, "ditch_split_basis": "rock_boundary", "design_line": [{"offset_m": -20.0, "elevation_m": 885.23}, {"offset_m": -19.5, "elevation_m": 885.1379}, {"offset_m": -19.0, "elevation_m": 884.9369}, {"offset_m": -18.5, "elevation_m": 884.6074}, {"offset_m": -18.0, "elevation_m": 884.1728}, {"offset_m": -17.5, "elevation_m": 883.5446}, {"offset_m": -17.0, "elevation_m": 882.9169}, {"offset_m": -16.5, "elevation_m": 882.2935}, {"offset_m": -16.0, "elevation_m": 881.6723}, {"offset_m": -15.5, "elevation_m": 881.0605}, {"offset_m": -15.0, "elevation_m": 880.4601}, {"offset_m": -14.5, "elevation_m": 880.0322}, {"offset_m": -14.0, "elevation_m": 879.6431}, {"offset_m": -13.5, "elevation_m": 879.3524}, {"offset_m": -13.0, "elevation_m": 879.1039}, {"offset_m": -12.5, "elevation_m": 878.9694}, {"offset_m": -12.0, "elevation_m": 878.9179}, {"offset_m": -11.5, "elevation_m": 878.9245}, {"offset_m": -11.0, "elevation_m": 879.0023}, {"offset_m": -10.5, "elevation_m": 879.0685}, {"offset_m": -10.0, "elevation_m": 879.1072}, {"offset_m": -9.5, "elevation_m": 879.1395}, {"offset_m": -9.0, "elevation_m": 879.1431}, {"offset_m": -8.5, "elevation_m": 879.1464}, {"offset_m": -8.0, "elevation_m": 879.1462}, {"offset_m": -7.5, "elevation_m": 879.1461}, {"offset_m": -7.0, "elevation_m": 879.1518}, {"offset_m": -6.5, "elevation_m": 879.1586}, {"offset_m": -6.0, "elevation_m": 879.1637}, {"offset_m": -5.5, "elevation_m": 879.1678}, {"offset_m": -5.0, "elevation_m": 879.1659}, {"offset_m": -4.5, "elevation_m": 879.1584}, {"offset_m": -4.0, "elevation_m": 879.1518}, {"offset_m": -3.5, "elevation_m": 879.1463}, {"offset_m": -3.0, "elevation_m": 879.1414}, {"offset_m": -2.79, "elevation_m": 879.1405}, {"offset_m": -2.69, "elevation_m": 879.0781}, {"offset_m": -2.5, "elevation_m": 878.7857}, {"offset_m": -2.495, "elevation_m": 878.7781}, {"offset_m": -2.195, "elevation_m": 878.7781}, {"offset_m": -2.0, "elevation_m": 879.0781}, {"offset_m": -1.5, "elevation_m": 879.0931}, {"offset_m": -1.0, "elevation_m": 879.1081}, {"offset_m": -0.5, "elevation_m": 879.1231}, {"offset_m": 0.0, "elevation_m": 879.1381}, {"offset_m": 0.5, "elevation_m": 879.1531}, {"offset_m": 1.0, "elevation_m": 879.1681}, {"offset_m": 1.5, "elevation_m": 879.1831}, {"offset_m": 2.0, "elevation_m": 879.1981}, {"offset_m": 2.25, "elevation_m": 879.0092}, {"offset_m": 2.5, "elevation_m": 878.9434}, {"offset_m": 3.0, "elevation_m": 878.7273}, {"offset_m": 3.5, "elevation_m": 878.4668}, {"offset_m": 4.0, "elevation_m": 878.1224}, {"offset_m": 4.5, "elevation_m": 877.7742}, {"offset_m": 5.0, "elevation_m": 877.4128}, {"offset_m": 5.5, "elevation_m": 877.0513}, {"offset_m": 6.0, "elevation_m": 876.7018}, {"offset_m": 6.5, "elevation_m": 876.3505}, {"offset_m": 7.0, "elevation_m": 876.1027}, {"offset_m": 7.5, "elevation_m": 875.8643}, {"offset_m": 8.0, "elevation_m": 875.6098}, {"offset_m": 8.5, "elevation_m": 875.3502}, {"offset_m": 9.0, "elevation_m": 875.0483}, {"offset_m": 9.5, "elevation_m": 874.7248}, {"offset_m": 10.0, "elevation_m": 874.443}, {"offset_m": 10.5, "elevation_m": 874.2041}, {"offset_m": 11.0, "elevation_m": 873.9709}, {"offset_m": 11.5, "elevation_m": 873.7494}, {"offset_m": 12.0, "elevation_m": 873.5355}, {"offset_m": 12.5, "elevation_m": 873.3591}, {"offset_m": 13.0, "elevation_m": 873.1796}, {"offset_m": 13.5, "elevation_m": 872.9546}, {"offset_m": 14.0, "elevation_m": 872.73}, {"offset_m": 14.5, "elevation_m": 872.541}, {"offset_m": 15.0, "elevation_m": 872.3626}, {"offset_m": 15.5, "elevation_m": 872.1906}, {"offset_m": 16.0, "elevation_m": 872.0159}, {"offset_m": 16.5, "elevation_m": 871.8191}, {"offset_m": 17.0, "elevation_m": 871.6105}, {"offset_m": 17.5, "elevation_m": 871.4014}, {"offset_m": 18.0, "elevation_m": 871.1901}, {"offset_m": 18.5, "elevation_m": 870.9943}, {"offset_m": 19.0, "elevation_m": 870.8359}, {"offset_m": 19.5, "elevation_m": 870.6619}, {"offset_m": 20.0, "elevation_m": 870.4349}], "cut_slope_segments": [{"side": "right", "ratio": 1.0, "rise_m": 0.1, "run_m": 0.1, "start_offset_m": -2.69, "end_offset_m": -2.79, "material": "soil"}], "rock_boundary_offset_m": -0.5, "status": "provisional", "pavement_suggested": true}}, {"chainage_m": 85.052, "label": "STA.0+085.052", "kind": "irregular", "center_z": 861.942559, "plan_radius_m": null, "curve_outer_side": "right", "curve_widening_m": 2.0412, "uphill_side": "right", "structure": "파형강관 D1000", "samples": [{"offset_m": -20.0, "x": 208389.50205, "y": 367828.126227, "z": 876.814697, "elevation_m": 876.814697, "valid": true}, {"offset_m": -19.5, "x": 208389.961935, "y": 367828.322456, "z": 876.48112, "elevation_m": 876.48112, "valid": true}, {"offset_m": -19.0, "x": 208390.42182, "y": 367828.518685, "z": 876.171719, "elevation_m": 876.171719, "valid": true}, {"offset_m": -18.5, "x": 208390.881705, "y": 367828.714914, "z": 876.041832, "elevation_m": 876.041832, "valid": true}, {"offset_m": -18.0, "x": 208391.341589, "y": 367828.911144, "z": 875.952013, "elevation_m": 875.952013, "valid": true}, {"offset_m": -17.5, "x": 208391.801474, "y": 367829.107373, "z": 875.713224, "elevation_m": 875.713224, "valid": true}, {"offset_m": -17.0, "x": 208392.261359, "y": 367829.303602, "z": 875.456742, "elevation_m": 875.456742, "valid": true}, {"offset_m": -16.5, "x": 208392.721244, "y": 367829.499832, "z": 875.210524, "elevation_m": 875.210524, "valid": true}, {"offset_m": -16.0, "x": 208393.181129, "y": 367829.696061, "z": 874.891976, "elevation_m": 874.891976, "valid": true}, {"offset_m": -15.5, "x": 208393.641014, "y": 367829.89229, "z": 874.543976, "elevation_m": 874.543976, "valid": true}, {"offset_m": -15.0, "x": 208394.100899, "y": 367830.088519, "z": 874.293958, "elevation_m": 874.293958, "valid": true}, {"offset_m": -14.5, "x": 208394.560783, "y": 367830.284749, "z": 874.010544, "elevation_m": 874.010544, "valid": true}, {"offset_m": -14.0, "x": 208395.020668, "y": 367830.480978, "z": 873.790235, "elevation_m": 873.790235, "valid": true}, {"offset_m": -13.5, "x": 208395.480553, "y": 367830.677207, "z": 873.619884, "elevation_m": 873.619884, "valid": true}, {"offset_m": -13.0, "x": 208395.940438, "y": 367830.873437, "z": 873.443397, "elevation_m": 873.443397, "valid": true}, {"offset_m": -12.5, "x": 208396.400323, "y": 367831.069666, "z": 873.213986, "elevation_m": 873.213986, "valid": true}, {"offset_m": -12.0, "x": 208396.860208, "y": 367831.265895, "z": 872.984966, "elevation_m": 872.984966, "valid": true}, {"offset_m": -11.5, "x": 208397.320092, "y": 367831.462124, "z": 872.637815, "elevation_m": 872.637815, "valid": true}, {"offset_m": -11.0, "x": 208397.779977, "y": 367831.658354, "z": 872.276493, "elevation_m": 872.276493, "valid": true}, {"offset_m": -10.5, "x": 208398.239862, "y": 367831.854583, "z": 871.942847, "elevation_m": 871.942847, "valid": true}, {"offset_m": -10.0, "x": 208398.699747, "y": 367832.050812, "z": 871.687652, "elevation_m": 871.687652, "valid": true}, {"offset_m": -9.5, "x": 208399.159632, "y": 367832.247042, "z": 871.533064, "elevation_m": 871.533064, "valid": true}, {"offset_m": -9.0, "x": 208399.619517, "y": 367832.443271, "z": 871.43126, "elevation_m": 871.43126, "valid": true}, {"offset_m": -8.5, "x": 208400.079401, "y": 367832.6395, "z": 871.319131, "elevation_m": 871.319131, "valid": true}, {"offset_m": -8.0, "x": 208400.539286, "y": 367832.83573, "z": 870.94037, "elevation_m": 870.94037, "valid": true}, {"offset_m": -7.5, "x": 208400.999171, "y": 367833.031959, "z": 870.497763, "elevation_m": 870.497763, "valid": true}, {"offset_m": -7.0, "x": 208401.459056, "y": 367833.228188, "z": 869.885497, "elevation_m": 869.885497, "valid": true}, {"offset_m": -6.5, "x": 208401.918941, "y": 367833.424417, "z": 869.245195, "elevation_m": 869.245195, "valid": true}, {"offset_m": -6.0, "x": 208402.378826, "y": 367833.620647, "z": 868.724521, "elevation_m": 868.724521, "valid": true}, {"offset_m": -5.5, "x": 208402.83871, "y": 367833.816876, "z": 868.345981, "elevation_m": 868.345981, "valid": true}, {"offset_m": -5.0, "x": 208403.298595, "y": 367834.013105, "z": 867.905449, "elevation_m": 867.905449, "valid": true}, {"offset_m": -4.5, "x": 208403.75848, "y": 367834.209335, "z": 867.831195, "elevation_m": 867.831195, "valid": true}, {"offset_m": -4.0, "x": 208404.218365, "y": 367834.405564, "z": 867.546738, "elevation_m": 867.546738, "valid": true}, {"offset_m": -3.5, "x": 208404.67825, "y": 367834.601793, "z": 867.076857, "elevation_m": 867.076857, "valid": true}, {"offset_m": -3.0, "x": 208405.138135, "y": 367834.798022, "z": 866.547858, "elevation_m": 866.547858, "valid": true}, {"offset_m": -2.5, "x": 208405.59802, "y": 367834.994252, "z": 865.969237, "elevation_m": 865.969237, "valid": true}, {"offset_m": -2.0, "x": 208406.057904, "y": 367835.190481, "z": 865.484619, "elevation_m": 865.484619, "valid": true}, {"offset_m": -1.5, "x": 208406.517789, "y": 367835.38671, "z": 865.161483, "elevation_m": 865.161483, "valid": true}, {"offset_m": -1.0, "x": 208406.977674, "y": 367835.58294, "z": 863.835193, "elevation_m": 863.835193, "valid": true}, {"offset_m": -0.5, "x": 208407.437559, "y": 367835.779169, "z": 862.526723, "elevation_m": 862.526723, "valid": true}, {"offset_m": 0.0, "x": 208407.897444, "y": 367835.975398, "z": 861.942559, "elevation_m": 861.942559, "valid": true}, {"offset_m": 0.5, "x": 208408.357329, "y": 367836.171627, "z": 861.73096, "elevation_m": 861.73096, "valid": true}, {"offset_m": 1.0, "x": 208408.817213, "y": 367836.367857, "z": 861.597136, "elevation_m": 861.597136, "valid": true}, {"offset_m": 1.5, "x": 208409.277098, "y": 367836.564086, "z": 861.615197, "elevation_m": 861.615197, "valid": true}, {"offset_m": 2.0, "x": 208409.736983, "y": 367836.760315, "z": 861.624445, "elevation_m": 861.624445, "valid": true}, {"offset_m": 2.5, "x": 208410.196868, "y": 367836.956545, "z": 861.492152, "elevation_m": 861.492152, "valid": true}, {"offset_m": 3.0, "x": 208410.656753, "y": 367837.152774, "z": 861.358581, "elevation_m": 861.358581, "valid": true}, {"offset_m": 3.5, "x": 208411.116638, "y": 367837.349003, "z": 860.972417, "elevation_m": 860.972417, "valid": true}, {"offset_m": 4.0, "x": 208411.576522, "y": 367837.545232, "z": 860.440225, "elevation_m": 860.440225, "valid": true}, {"offset_m": 4.5, "x": 208412.036407, "y": 367837.741462, "z": 860.044866, "elevation_m": 860.044866, "valid": true}, {"offset_m": 5.0, "x": 208412.496292, "y": 367837.937691, "z": 859.875748, "elevation_m": 859.875748, "valid": true}, {"offset_m": 5.5, "x": 208412.956177, "y": 367838.13392, "z": 859.710409, "elevation_m": 859.710409, "valid": true}, {"offset_m": 6.0, "x": 208413.416062, "y": 367838.33015, "z": 859.41975, "elevation_m": 859.41975, "valid": true}, {"offset_m": 6.5, "x": 208413.875947, "y": 367838.526379, "z": 859.145619, "elevation_m": 859.145619, "valid": true}, {"offset_m": 7.0, "x": 208414.335832, "y": 367838.722608, "z": 858.972778, "elevation_m": 858.972778, "valid": true}, {"offset_m": 7.5, "x": 208414.795716, "y": 367838.918837, "z": 858.727452, "elevation_m": 858.727452, "valid": true}, {"offset_m": 8.0, "x": 208415.255601, "y": 367839.115067, "z": 858.33725, "elevation_m": 858.33725, "valid": true}, {"offset_m": 8.5, "x": 208415.715486, "y": 367839.311296, "z": 857.951492, "elevation_m": 857.951492, "valid": true}, {"offset_m": 9.0, "x": 208416.175371, "y": 367839.507525, "z": 857.668881, "elevation_m": 857.668881, "valid": true}, {"offset_m": 9.5, "x": 208416.635256, "y": 367839.703755, "z": 857.701612, "elevation_m": 857.701612, "valid": true}, {"offset_m": 10.0, "x": 208417.095141, "y": 367839.899984, "z": 857.739266, "elevation_m": 857.739266, "valid": true}, {"offset_m": 10.5, "x": 208417.555025, "y": 367840.096213, "z": 857.369898, "elevation_m": 857.369898, "valid": true}, {"offset_m": 11.0, "x": 208418.01491, "y": 367840.292442, "z": 856.741221, "elevation_m": 856.741221, "valid": true}, {"offset_m": 11.5, "x": 208418.474795, "y": 367840.488672, "z": 856.145336, "elevation_m": 856.145336, "valid": true}, {"offset_m": 12.0, "x": 208418.93468, "y": 367840.684901, "z": 855.720733, "elevation_m": 855.720733, "valid": true}, {"offset_m": 12.5, "x": 208419.394565, "y": 367840.88113, "z": 855.306101, "elevation_m": 855.306101, "valid": true}, {"offset_m": 13.0, "x": 208419.85445, "y": 367841.07736, "z": 855.14615, "elevation_m": 855.14615, "valid": true}, {"offset_m": 13.5, "x": 208420.314334, "y": 367841.273589, "z": 855.017051, "elevation_m": 855.017051, "valid": true}, {"offset_m": 14.0, "x": 208420.774219, "y": 367841.469818, "z": 854.949041, "elevation_m": 854.949041, "valid": true}, {"offset_m": 14.5, "x": 208421.234104, "y": 367841.666047, "z": 854.908668, "elevation_m": 854.908668, "valid": true}, {"offset_m": 15.0, "x": 208421.693989, "y": 367841.862277, "z": 854.880999, "elevation_m": 854.880999, "valid": true}, {"offset_m": 15.5, "x": 208422.153874, "y": 367842.058506, "z": 854.834323, "elevation_m": 854.834323, "valid": true}, {"offset_m": 16.0, "x": 208422.613759, "y": 367842.254735, "z": 854.745451, "elevation_m": 854.745451, "valid": true}, {"offset_m": 16.5, "x": 208423.073644, "y": 367842.450965, "z": 854.520691, "elevation_m": 854.520691, "valid": true}, {"offset_m": 17.0, "x": 208423.533528, "y": 367842.647194, "z": 854.31567, "elevation_m": 854.31567, "valid": true}, {"offset_m": 17.5, "x": 208423.993413, "y": 367842.843423, "z": 853.946598, "elevation_m": 853.946598, "valid": true}, {"offset_m": 18.0, "x": 208424.453298, "y": 367843.039652, "z": 853.507811, "elevation_m": 853.507811, "valid": true}, {"offset_m": 18.5, "x": 208424.913183, "y": 367843.235882, "z": 853.079751, "elevation_m": 853.079751, "valid": true}, {"offset_m": 19.0, "x": 208425.373068, "y": 367843.432111, "z": 852.689714, "elevation_m": 852.689714, "valid": true}, {"offset_m": 19.5, "x": 208425.832953, "y": 367843.62834, "z": 852.331107, "elevation_m": 852.331107, "valid": true}, {"offset_m": 20.0, "x": 208426.292837, "y": 367843.82457, "z": 852.085689, "elevation_m": 852.085689, "valid": true}], "culvert": {"type": "pipe", "pipe_kind": "파형강관", "diameter_m": 1.0, "min_cover_m": 0.5, "foundation": null, "inlet": {"role": "inlet", "structure": "집수정", "basin_length_m": 2.0, "basin_before_m": 1.0, "basin_after_m": 1.0, "basin_height_m": 1.5, "basin_form": "L"}, "outlet": {"role": "outlet", "structure": "기슭막이", "revet_form": "돌쌓기(찰)", "revet_height_m": 2.0, "revet_length_m": 10.0, "revet_before_m": 5.0, "revet_after_m": 5.0, "face_slope": 0.3, "apron_length_m": 4.0, "apron_thickness_m": 0.45}, "chainage_m": 85.05}, "design": {"ground_type": "ripping_rock", "geometry_preset": "rock", "section_mode": "right_cut", "ditch_side": "right", "ditch_type": "standard", "cut_slope_ratio": 0.4, "soil_cut_slope_ratio": 1.0, "two_stage_slope": true, "fill_slope_ratio": 1.2, "roadbed_width_m": 6.0, "carriageway_width_m": 5.0, "carriageway_standard_width_m": 3.0, "widening_left_m": 0.0, "widening_right_m": 2.0, "cross_slope_pct": 3.0, "ditch": {"type": "standard", "top_width_m": 0.69, "bottom_width_m": 0.3, "depth_m": 0.3}, "ditch_enabled": true, "ditch_choice": null, "paved": false, "road_edges": {"left": {"offset_m": 2.0, "elevation_m": 862.0039}, "right": {"offset_m": -4.0, "elevation_m": 861.8239}}, "carriageway_edges": {"left": {"offset_m": 1.5, "elevation_m": 861.9889}, "right": {"offset_m": -3.5, "elevation_m": 861.8389}}, "design_elevation_m": 861.9439, "cut_area_m2": 13.1377, "cut_soil_area_m2": 1.8951, "cut_rock_area_m2": 11.2426, "cut_rock_kind": "ripping_rock", "fill_area_m2": 9.74, "spoil_fill_area_m2": 0.0, "spoil_fill_side": null, "spoil_fill_width_m": 0.0, "spoil_fill_max_width_m": 0.0, "spoil_fill_line": [], "spoil_fill_unclosed": false, "spoil_fill_replaced_fill_m2": 0.0, "bench_cut_length_m": 1.7865, "slope_unclosed": false, "fill_ground_slope": 0.2653, "ditch_area_m2": 0.1485, "ditch_soil_area_m2": 0.0, "ditch_rock_area_m2": 0.1485, "ditch_split_basis": "rock_boundary", "design_line": [{"offset_m": -20.0, "elevation_m": 876.8147}, {"offset_m": -19.5, "elevation_m": 876.4811}, {"offset_m": -19.0, "elevation_m": 876.1717}, {"offset_m": -18.5, "elevation_m": 876.0418}, {"offset_m": -18.0, "elevation_m": 875.952}, {"offset_m": -17.5, "elevation_m": 875.7132}, {"offset_m": -17.0, "elevation_m": 875.4567}, {"offset_m": -16.5, "elevation_m": 875.2105}, {"offset_m": -16.0, "elevation_m": 874.892}, {"offset_m": -15.5, "elevation_m": 874.544}, {"offset_m": -15.0, "elevation_m": 874.294}, {"offset_m": -14.5, "elevation_m": 874.0105}, {"offset_m": -14.0, "elevation_m": 873.7902}, {"offset_m": -13.5, "elevation_m": 873.6199}, {"offset_m": -13.0, "elevation_m": 873.4434}, {"offset_m": -12.5, "elevation_m": 873.214}, {"offset_m": -12.0, "elevation_m": 872.985}, {"offset_m": -11.5, "elevation_m": 872.6378}, {"offset_m": -11.0, "elevation_m": 872.2765}, {"offset_m": -10.5, "elevation_m": 871.9428}, {"offset_m": -10.0, "elevation_m": 871.6877}, {"offset_m": -9.5, "elevation_m": 871.5331}, {"offset_m": -9.09, "elevation_m": 871.4496}, {"offset_m": -9.0, "elevation_m": 871.3929}, {"offset_m": -8.5, "elevation_m": 870.8929}, {"offset_m": -8.1959, "elevation_m": 870.5888}, {"offset_m": -8.0, "elevation_m": 870.0989}, {"offset_m": -7.5, "elevation_m": 868.8489}, {"offset_m": -7.0, "elevation_m": 867.5989}, {"offset_m": -6.5, "elevation_m": 866.3489}, {"offset_m": -6.0, "elevation_m": 865.0989}, {"offset_m": -5.5, "elevation_m": 863.8489}, {"offset_m": -5.0, "elevation_m": 862.5989}, {"offset_m": -4.69, "elevation_m": 861.8239}, {"offset_m": -4.5, "elevation_m": 861.5316}, {"offset_m": -4.495, "elevation_m": 861.5239}, {"offset_m": -4.195, "elevation_m": 861.5239}, {"offset_m": -4.0, "elevation_m": 861.8239}, {"offset_m": -3.5, "elevation_m": 861.8389}, {"offset_m": -3.0, "elevation_m": 861.8539}, {"offset_m": -2.5, "elevation_m": 861.8689}, {"offset_m": -2.0, "elevation_m": 861.8839}, {"offset_m": -1.5, "elevation_m": 861.8989}, {"offset_m": -1.0, "elevation_m": 861.9139}, {"offset_m": -0.5, "elevation_m": 861.9289}, {"offset_m": 0.0, "elevation_m": 861.9439}, {"offset_m": 0.5, "elevation_m": 861.9589}, {"offset_m": 1.0, "elevation_m": 861.9739}, {"offset_m": 1.5, "elevation_m": 861.9889}, {"offset_m": 2.0, "elevation_m": 862.0039}, {"offset_m": 2.5, "elevation_m": 861.5873}, {"offset_m": 2.7, "elevation_m": 861.4387}, {"offset_m": 3.0, "elevation_m": 861.3586}, {"offset_m": 3.5, "elevation_m": 860.9724}, {"offset_m": 4.0, "elevation_m": 860.4402}, {"offset_m": 4.5, "elevation_m": 860.0449}, {"offset_m": 5.0, "elevation_m": 859.8757}, {"offset_m": 5.5, "elevation_m": 859.7104}, {"offset_m": 6.0, "elevation_m": 859.4198}, {"offset_m": 6.5, "elevation_m": 859.1456}, {"offset_m": 7.0, "elevation_m": 858.9728}, {"offset_m": 7.5, "elevation_m": 858.7275}, {"offset_m": 8.0, "elevation_m": 858.3373}, {"offset_m": 8.5, "elevation_m": 857.9515}, {"offset_m": 9.0, "elevation_m": 857.6689}, {"offset_m": 9.5, "elevation_m": 857.7016}, {"offset_m": 10.0, "elevation_m": 857.7393}, {"offset_m": 10.5, "elevation_m": 857.3699}, {"offset_m": 11.0, "elevation_m": 856.7412}, {"offset_m": 11.5, "elevation_m": 856.1453}, {"offset_m": 12.0, "elevation_m": 855.7207}, {"offset_m": 12.5, "elevation_m": 855.3061}, {"offset_m": 13.0, "elevation_m": 855.1462}, {"offset_m": 13.5, "elevation_m": 855.0171}, {"offset_m": 14.0, "elevation_m": 854.949}, {"offset_m": 14.5, "elevation_m": 854.9087}, {"offset_m": 15.0, "elevation_m": 854.881}, {"offset_m": 15.5, "elevation_m": 854.8343}, {"offset_m": 16.0, "elevation_m": 854.7455}, {"offset_m": 16.5, "elevation_m": 854.5207}, {"offset_m": 17.0, "elevation_m": 854.3157}, {"offset_m": 17.5, "elevation_m": 853.9466}, {"offset_m": 18.0, "elevation_m": 853.5078}, {"offset_m": 18.5, "elevation_m": 853.0798}, {"offset_m": 19.0, "elevation_m": 852.6897}, {"offset_m": 19.5, "elevation_m": 852.3311}, {"offset_m": 20.0, "elevation_m": 852.0857}], "cut_slope_segments": [{"side": "right", "ratio": 0.4, "rise_m": 8.7649, "run_m": 3.5059, "start_offset_m": -4.69, "end_offset_m": -8.1959, "material": "rock"}, {"side": "right", "ratio": 1.0, "rise_m": 0.8941, "run_m": 0.8941, "start_offset_m": -8.1959, "end_offset_m": -9.09, "material": "soil"}], "rock_boundary_offset_m": -0.5, "status": "provisional", "pavement_suggested": true, "pipe_length_m": 9.0, "extra_walls": [{"key": "extra0", "side": "outlet", "form": "돌쌓기(찰)", "height_m": 1.5, "height_set": true, "form_set": true, "before_m": 5, "after_m": 5, "fill_overlap_m2": 1.125}], "wall_fill_overlap": {"outlet": 0.446}, "revet_adjust": {"outlet": {"x": 1.0, "d": 0.3, "h": 2.0, "m": "돌쌓기(찰)"}, "extra0": {"x": 4.5, "d": 0.7, "h": 1.5, "m": "돌쌓기(찰)"}}, "extra_wall_counts": {"outlet": 1, "basin": 0}}}]} \ No newline at end of file diff --git a/resources/tester/test_b07_cross_overlay_extent.py b/resources/tester/test_b07_cross_overlay_extent.py new file mode 100644 index 000000000..86924d51a --- /dev/null +++ b/resources/tester/test_b07_cross_overlay_extent.py @@ -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)