Files
Aislo/B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts
T
eomsangdonandClaude Opus 5.5 8230788e96 refactor(b07): 횡단 구조물 얹기 몸통 = Cross_Overlay.placeStructureEntities · Node 가짜 DOM = Node_Dom 파일 (302-2 앞)
- 화면 appendStructureEntities = 정본 상세 읽기 + placeStructureEntities 부름 · 동작 그대로
- 가짜 문서에 addEventListener · removeEventListener 빈 함수 — 화면 모듈이 불러올 때 거는 문서 이벤트로 덧그림 범위 Node 번들이 시작부터 멈추던 흠 (10-04 무렵부터 서버 장 배치가 덧그림 범위 없이 섬)
- 시험 212 = 옮긴 자리에서 글 찾음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-13 08:01:33 +09:00

113 lines
4.9 KiB
TypeScript

/* =============================================================================
* 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 "./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];
/* ── 범위 재기 ─────────────────────────────────────────────────────────── */
/**
* 글자 폭(종이 mm) 어림 — Node 에는 글꼴이 없다. 한글은 글자 크기만큼, 나머지는 0.6 배로
* 잡는다(sans-serif 실측: 한글 0.92 · 숫자 0.56 · 기호 0.28~0.56 · m 0.93). 넉넉한 쪽이라 글자가 박스
* 끝에 걸리면 간격이 1 mm 보다 조금 넓게 나온다.
*/
function textWidth(label: string, fontSize: number): number {
let width = 0;
for (const char of label) width += /[ㄱ-힣]/.test(char) ? fontSize : fontSize * 0.6;
return width;
}
function extentOf(entities: Entity[]): [number, number, number, number] | null {
const xs: number[] = [];
const ys: number[] = [];
const walk = (node: Entity): void => {
const shape = (node.shapeData ?? null) as Record<string, unknown> | null;
const label = shape?.label;
const base = shape?.basePoint as { x: number; y: number } | undefined;
if (typeof label === "string" && base) {
// CAD 는 글자를 기준점 세로 가운데(textBaseline middle)에 정렬 쪽으로 그린다.
const options = (shape?.options ?? {}) as { fontSize?: number; textAlign?: string };
const size = Number(options.fontSize ?? 2);
const width = textWidth(label, size);
const left =
options.textAlign === "center"
? base.x - width / 2
: options.textAlign === "right"
? base.x - width
: base.x;
xs.push(left, left + width);
ys.push(base.y - size / 2, base.y + size / 2);
}
for (const key of ["startPoint", "endPoint", "basePoint"]) {
const point = shape?.[key] as { x: number; y: number } | undefined;
if (point) {
xs.push(point.x);
ys.push(point.y);
}
}
const center = shape?.center as { x: number; y: number } | undefined;
if (center) {
const radius = Number(shape?.radius ?? 0);
xs.push(center.x - radius, center.x + radius);
ys.push(center.y - radius, center.y + radius);
}
for (const child of (node.children as Entity[] | undefined) ?? []) walk(child);
};
entities.forEach(walk);
if (!xs.length) return null;
return [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
}
const [inputPath, outputPath] = process.argv.slice(2);
if (!inputPath || !outputPath) {
console.error("사용법: node <번들> <입력.json> <출력.json>");
process.exit(2);
}
const input = JSON.parse(readFileSync(inputPath, "utf8")) as { detail?: SectionDetailResponse };
const sections = input.detail?.cross_sections ?? [];
const extents: Record<string, number>[] = [];
for (const section of sections) {
let entities: Entity[] | null = null;
try {
entities = overlayEntities(
section,
sections,
(offset) => offset * MM_PER_M,
(elevation) => -(elevation * MM_PER_M),
OPEN_FRAME,
);
} catch (error) {
// 한 측점의 기하 실패가 전체를 막지 않는다 — 화면도 그 측점만 건너뛴다.
console.error(`측점 ${section.chainage_m} 덧그림 실패: ${String(error)}`);
}
const extent = entities?.length ? extentOf(entities) : null;
if (!extent) continue;
const [xMin, xMax, yMin, yMax] = extent.map((value) => value / MM_PER_M);
extents.push({
chainage_m: section.chainage_m,
x_min: xMin,
x_max: xMax,
y_min: yMin,
y_max: yMax,
});
}
writeFileSync(outputPath, JSON.stringify({ extents }), "utf8");