From 8230788e967defa7efc67bc622bb4a9ffb4569c8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 08:01:33 +0900 Subject: [PATCH] =?UTF-8?q?refactor(b07):=20=ED=9A=A1=EB=8B=A8=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EC=96=B9=EA=B8=B0=20=EB=AA=B8=ED=86=B5=20?= =?UTF-8?q?=3D=20Cross=5FOverlay.placeStructureEntities=20=C2=B7=20Node=20?= =?UTF-8?q?=EA=B0=80=EC=A7=9C=20DOM=20=3D=20Node=5FDom=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20(302-2=20=EC=95=9E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 화면 appendStructureEntities = 정본 상세 읽기 + placeStructureEntities 부름 · 동작 그대로 - 가짜 문서에 addEventListener · removeEventListener 빈 함수 — 화면 모듈이 불러올 때 거는 문서 이벤트로 덧그림 범위 Node 번들이 시작부터 멈추던 흠 (10-04 무렵부터 서버 장 배치가 덧그림 범위 없이 섬) - 시험 212 = 옮긴 자리에서 글 찾음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../B07_DesignDetail_Cross_Extent_Node.ts | 152 +---------------- .../B07_DesignDetail_Cross_Overlay.ts | 82 +++++++++ B07_DesignDetail/B07_DesignDetail_Node_Dom.ts | 161 ++++++++++++++++++ .../B07_DesignDetail_UI_Cad_Structures.ts | 81 +-------- .../test_212_b07_cross_station_layers.py | 3 +- 5 files changed, 250 insertions(+), 229 deletions(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Node_Dom.ts diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts index 8ed1dab83..9a4917a1c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts @@ -14,163 +14,13 @@ import { readFileSync, writeFileSync } from "node:fs"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; +import "./B07_DesignDetail_Node_Dom"; // 최소 DOM(globalThis.document) — 그리기 코드보다 먼저 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; - /* ── 범위 재기 ─────────────────────────────────────────────────────────── */ /** diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts index dbce9f48d..8fd7616f8 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts @@ -552,3 +552,85 @@ export function overlayEntities( ...harvest(pipeSvg, PIPE_STYLE, frame), ]; } + +/** 서버가 도면에 실어 보내는 측점별 실좌표(m) → 종이(mm) 변환값. */ +export interface CrossPlacement { + chainage_m: number; + ox: number; + oy: number; + dy: number; + mm_per_m: number; + x0: number; + x1: number; + /** 블록 테두리(종이 mm) [x0, y0, x1, y1] — 그림을 이 안으로 자른다. */ + frame?: number[]; + /** 측점 한 벌 묶음(180-1) — 덧그림도 같은 묶음으로 함께 골라짐. */ + group_id?: string; + /** 측점 도면층(212-2) — 덧그림도 그 층에. */ + layer_id?: string; + /** 갈아 끼울 서버 설계선 id(212-2) — 측점 층이면 층으로는 못 가림. */ + design_ids?: string[]; +} + +/** 개체(자식 선까지)를 측점 층으로(212-2). */ +function moveToLayer(entity: Entity, layerId: string): void { + entity.layerId = layerId; + for (const child of (entity.children as Entity[] | undefined) ?? []) moveToLayer(child, layerId); +} + +/** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */ +const CHAINAGE_TOLERANCE_M = 0.02; + +/** + * 도면에 구조물 엔티티를 얹는다 (제자리 수정) — 화면(`_UI_Cad_Structures`)과 인쇄 Node(302-2)가 같이 씀. + * 배치 메타가 없으면 아무것도 하지 않는다 — 구조물이 빠져도 도면 자체는 열려야 한다. + */ +export function placeStructureEntities( + sections: CrossSection[], + drawing: { entities: Record[]; cross_placements?: CrossPlacement[] }, +): number { + const placements = drawing.cross_placements ?? []; + let added = 0; + const fresh: Entity[] = []; + const replaced: number[][] = []; + const designIds = new Set(); + for (const placement of placements) { + const section = sections.find( + (item) => Math.abs(item.chainage_m - placement.chainage_m) <= CHAINAGE_TOLERANCE_M, + ); + const frame = placement.frame; + if (!section || !frame) continue; + const x = (offset: number): number => offset * placement.mm_per_m + placement.ox; + const y = (elevation: number): number => + -((elevation - placement.dy) * placement.mm_per_m + placement.oy); + let entities: Entity[]; + try { + const drawn = overlayEntities(section, sections, x, y, frame); + if (!drawn) continue; + entities = drawn; + } catch { + continue; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다. + } + if (!entities.length) continue; + if (placement.group_id) for (const entity of entities) entity.groupId = placement.group_id; + if (placement.layer_id) for (const entity of entities) moveToLayer(entity, placement.layer_id); + for (const id of placement.design_ids ?? []) designIds.add(id); + // 이 블록 설계선은 우리가 다시 그렸다 — 서버가 낸 트림 전 설계선은 걷어낸다. + replaced.push(frame); + fresh.push(...entities); + added += entities.length; + } + if (replaced.length) { + // 걷어내기는 새 엔티티를 넣기 **전에** 한다 — 뒤에 하면 우리 것까지 같이 지운다. + drawing.entities = drawing.entities.filter((entity) => { + if (entity.layerId !== DESIGN_LAYER_ID && !designIds.has(entity.id as string)) return true; + const box = entityBox(entity); + if (!box) return true; + return !replaced.some( + ([fx0, fy0, fx1, fy1]) => box[0] >= fx0 && box[2] <= fx1 && box[1] >= fy0 && box[3] <= fy1, + ); + }); + } + drawing.entities.push(...fresh); + return added; +} diff --git a/B07_DesignDetail/B07_DesignDetail_Node_Dom.ts b/B07_DesignDetail/B07_DesignDetail_Node_Dom.ts new file mode 100644 index 000000000..c9183de79 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Node_Dom.ts @@ -0,0 +1,161 @@ +/* ============================================================================= + * B07_DesignDetail_Node_Dom.ts + * 서버 Node 진입점이 쓰는 **최소 DOM** — 덧그림 그리기 코드가 오프스크린 SVG 에 쓰는 만큼만. + * 불러오기만 하면 `globalThis.document` 가 선다(`_Cross_Extent_Node` · 인쇄 302-2 `B09_Draft_Print_Drawings_Node`). + * ========================================================================== */ + +/* ── 최소 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, + // 화면 모듈이 불러올 때 거는 문서 이벤트(누름 등) — 서버에는 사람이 없어 빈 함수 + addEventListener: () => undefined, + removeEventListener: () => undefined, +}; + +(globalThis as Record).document = fakeDocument; + +export {}; // 모듈로 둠 — 이름이 전역에 새지 않게 diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts index c4e446c65..20180f015 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts @@ -14,40 +14,9 @@ import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store"; -import { - DESIGN_LAYER_ID, - entityBox, - overlayEntities, - type Entity, -} from "./B07_DesignDetail_Cross_Overlay"; +import { placeStructureEntities, type CrossPlacement } from "./B07_DesignDetail_Cross_Overlay"; -/** 서버가 도면에 실어 보내는 측점별 실좌표(m) → 종이(mm) 변환값. */ -export interface CrossPlacement { - chainage_m: number; - ox: number; - oy: number; - dy: number; - mm_per_m: number; - x0: number; - x1: number; - /** 블록 테두리(종이 mm) [x0, y0, x1, y1] — 그림을 이 안으로 자른다. */ - frame?: number[]; - /** 측점 한 벌 묶음(180-1) — 덧그림도 같은 묶음으로 함께 골라짐. */ - group_id?: string; - /** 측점 도면층(212-2) — 덧그림도 그 층에. */ - layer_id?: string; - /** 갈아 끼울 서버 설계선 id(212-2) — 측점 층이면 층으로는 못 가림. */ - design_ids?: string[]; -} - -/** 개체(자식 선까지)를 측점 층으로(212-2). */ -function moveToLayer(entity: Entity, layerId: string): void { - entity.layerId = layerId; - for (const child of (entity.children as Entity[] | undefined) ?? []) moveToLayer(child, layerId); -} - -/** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */ -const CHAINAGE_TOLERANCE_M = 0.02; +export type { CrossPlacement }; /** * 도면에 구조물 엔티티를 얹는다 (제자리 수정). 배치 메타가 없거나 자료를 못 읽으면 @@ -68,48 +37,6 @@ export async function appendStructureEntities( } catch { return 0; } - - let added = 0; - const fresh: Entity[] = []; - const replaced: number[][] = []; - const designIds = new Set(); - for (const placement of placements) { - const section = sections.find( - (item) => Math.abs(item.chainage_m - placement.chainage_m) <= CHAINAGE_TOLERANCE_M, - ); - const frame = placement.frame; - if (!section || !frame) continue; - const x = (offset: number): number => offset * placement.mm_per_m + placement.ox; - const y = (elevation: number): number => - -((elevation - placement.dy) * placement.mm_per_m + placement.oy); - let entities: Entity[]; - try { - const drawn = overlayEntities(section, sections, x, y, frame); - if (!drawn) continue; - entities = drawn; - } catch { - continue; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다. - } - if (!entities.length) continue; - if (placement.group_id) for (const entity of entities) entity.groupId = placement.group_id; - if (placement.layer_id) for (const entity of entities) moveToLayer(entity, placement.layer_id); - for (const id of placement.design_ids ?? []) designIds.add(id); - // 이 블록 설계선은 우리가 다시 그렸다 — 서버가 낸 트림 전 설계선은 걷어낸다. - replaced.push(frame); - fresh.push(...entities); - added += entities.length; - } - if (replaced.length) { - // 걷어내기는 새 엔티티를 넣기 **전에** 한다 — 뒤에 하면 우리 것까지 같이 지운다. - drawing.entities = drawing.entities.filter((entity) => { - if (entity.layerId !== DESIGN_LAYER_ID && !designIds.has(entity.id as string)) return true; - const box = entityBox(entity); - if (!box) return true; - return !replaced.some( - ([fx0, fy0, fx1, fy1]) => box[0] >= fx0 && box[2] <= fx1 && box[1] >= fy0 && box[3] <= fy1, - ); - }); - } - drawing.entities.push(...fresh); - return added; + // 얹는 몸통 = `_Cross_Overlay.placeStructureEntities`(인쇄 Node 302-2 와 한 벌) + return placeStructureEntities(sections, drawing); } diff --git a/resources/tester/test_212_b07_cross_station_layers.py b/resources/tester/test_212_b07_cross_station_layers.py index baa293a31..b875abb81 100644 --- a/resources/tester/test_212_b07_cross_station_layers.py +++ b/resources/tester/test_212_b07_cross_station_layers.py @@ -175,7 +175,8 @@ def test_옛_확정_장_원지반_잠금_풂_측점_층_안내(tmp_path: Path): def test_화면_덧그림_측점_층_design_ids(): - struct = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts").read_text( + # 얹는 몸통 = Cross_Overlay.placeStructureEntities(302-2 · 화면 · 인쇄 Node 한 벌) + struct = (ROOT / "B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts").read_text( encoding="utf-8" ) assert "layer_id?: string;" in struct and "design_ids?: string[];" in struct