Files
Aislo/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts
T
eomsangdonandClaude Opus 5.5 f6f0f74be5 feat(b06): 보라 설계선 고름 — 직선 조각 · 가림 뺌 · 치수 라벨 · 구역 판정 · 고름 하나만(137-2 · 137-3)
- 새 B06_Section_UI_Cross_Design_Pick.ts — 조각 합침 · 벽 · 본체 밖 조각 · 투명 판정 선 · 치수 · crossZoneAt(139-6 용) · 페이지 한 칸 고름
- 폴리곤 자르기 = A00_Common/b_polygon_clip.ts 공용(B07 Cross_Overlay 에서 옮김 · 바깥 조각 갈래)
- 카드 배선: 선 고르면 벽 · 본체 · 면적 풂 + 카드 고름 · 면적 고름도 본체 · 선 풂
- 시험 test_137_design_pick.py + helper_137_design_pick.cjs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-09 15:00:12 +09:00

545 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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";
import { clipPointsToPolygon, clipSegment, pointInPolygon } from "../A00_Common/b_polygon_clip";
const SVG_NS = "http://www.w3.org/2000/svg";
/** 구조물 엔티티가 들어갈 레이어·색 — 서버 도면이 이미 선언해 둔 그 레이어다. */
const STRUCTURE_LAYER_ID = "b08-structure";
const STRUCTURE_COLOR = "#f6d55c";
/** 설계선 도면층·색 — 서버 도면(`B07_DesignDetail_Engine_Cad`)이 쓰는 그 값이다. */
export const DESIGN_LAYER_ID = "b08-design";
const DESIGN_COLOR = "#b794f6";
/** 글자 크기(종이 mm) — SVG는 CSS로 크기를 잡아 오프스크린에서는 읽을 수 없다. */
const LABEL_FONT_MM = 2.0;
/** 정의부(해칭 패턴·클립)는 도형이 아니다 — 클립 경계 자체는 그림이 아니라 잘라 낼 자다. */
const SKIP_SELECTOR = "defs, clipPath, pattern";
/** 원을 폴리선으로 바꿀 때 쓰는 분할 수 — 클립 안에서만 쓴다(밖은 Circle 그대로). */
const CIRCLE_SEGMENTS = 36;
export type Entity = Record<string, unknown>;
type XY = [number, number];
/** 수확한 도형을 어느 도면층·색으로 넣을지. */
interface Style {
layerId: string;
color: string;
}
const STRUCTURE_STYLE: Style = { layerId: STRUCTURE_LAYER_ID, color: STRUCTURE_COLOR };
const DESIGN_STYLE: Style = { layerId: DESIGN_LAYER_ID, color: DESIGN_COLOR };
function baseEntity(style: Style, type: string, shapeData: unknown): Entity {
return {
id: crypto.randomUUID(),
type,
lineColor: style.color,
lineWidth: 1,
layerId: style.layerId,
shapeData,
};
}
function lineEntity(style: Style, start: XY, end: XY): Entity {
return baseEntity(style, "Line", {
startPoint: { x: start[0], y: start[1] },
endPoint: { x: end[0], y: end[1] },
});
}
/** 점열 → PolyLine(자식 Line 묶음). 서버 도면의 폴리라인과 같은 직렬화다. */
function polyEntity(style: Style, points: XY[]): Entity | null {
if (points.length < 2) return null;
const children: Entity[] = [];
for (let index = 0; index < points.length - 1; index += 1) {
children.push(lineEntity(style, points[index], points[index + 1]));
}
const poly = baseEntity(style, "PolyLine", null);
poly.children = children;
return poly;
}
function textEntity(style: Style, label: string, at: XY, align: string): Entity {
return baseEntity(style, "Text", {
label,
basePoint: { x: at[0], y: at[1] },
options: {
textDirection: { x: 1, y: 0 },
textAlign: align,
textColor: style.color,
fontSize: LABEL_FONT_MM,
fontFamily: "sans-serif",
},
});
}
/**
* 공선점 제거(Ramer–Douglas–Peucker) — 설계선은 지반 샘플마다 점을 갖고 있어(측점 하나에
* 83점) 곧은 사면이 수십 조각으로 쪼개진다. CAD에서 한 변은 한 선이어야 잡고 고칠 수 있다.
* 허용 오차는 **종이 mm**라, 1/100에서 0.05mm = 실거리 5mm — 도면상 같은 직선이다.
*/
function simplify(points: XY[], tolerance = 0.05): XY[] {
if (points.length < 3) return points;
const first = points[0];
const last = points[points.length - 1];
const [dx, dy] = [last[0] - first[0], last[1] - first[1]];
const span = Math.hypot(dx, dy);
let worst = 0;
let index = 0;
for (let i = 1; i < points.length - 1; i += 1) {
const [px, py] = points[i];
const distance =
span > 1e-9
? Math.abs(dy * px - dx * py + last[0] * first[1] - last[1] * first[0]) / span
: Math.hypot(px - first[0], py - first[1]);
if (distance > worst) {
worst = distance;
index = i;
}
}
if (worst <= tolerance) return [first, last];
return [
...simplify(points.slice(0, index + 1), tolerance).slice(0, -1),
...simplify(points.slice(index), tolerance),
];
}
/**
* 끝점이 맞물리는 선분들을 하나의 점열로 잇는다.
*
* B06 설계선 그리기는 **점 사이마다 `<line>` 하나**를 만든다(_Cross_Design 603행) —
* 그대로 옮기면 곧은 사면 한 변이 CAD에서 선 열댓 개가 된다. 이어 붙인 뒤 공선점을
* 지우면 한 변이 한 선이 된다(2026-08-30 사용자 지적).
*/
function chain(segments: XY[][], tolerance = 1e-3): XY[][] {
const used = new Array(segments.length).fill(false);
const near = (a: XY, b: XY): boolean =>
Math.abs(a[0] - b[0]) <= tolerance && Math.abs(a[1] - b[1]) <= tolerance;
const chains: XY[][] = [];
for (let seed = 0; seed < segments.length; seed += 1) {
if (used[seed]) continue;
used[seed] = true;
const points: XY[] = [segments[seed][0], segments[seed][1]];
let grew = true;
while (grew) {
grew = false;
for (let i = 0; i < segments.length; i += 1) {
if (used[i]) continue;
const [a, b] = segments[i];
const head = points[0];
const tail = points[points.length - 1];
if (near(tail, a)) points.push(b);
else if (near(tail, b)) points.push(a);
else if (near(head, b)) points.unshift(a);
else if (near(head, a)) points.unshift(b);
else continue;
used[i] = true;
grew = true;
}
}
chains.push(points);
}
return chains;
}
/** 점열을 종이 x범위로 자른다 — 경계는 선형보간으로 새 점을 만든다(서버 _clip_polyline과 같은 규칙). */
function clipX(points: XY[], x0: number, x1: number): XY[] {
const clipped: XY[] = [];
for (let index = 0; index < points.length; index += 1) {
const [x, y] = points[index];
if (index > 0) {
const [px, py] = points[index - 1];
for (const edge of [x0, x1]) {
if ((px < edge && edge < x) || (x < edge && edge < px)) {
const ratio = (edge - px) / (x - px);
clipped.push([edge, py + (y - py) * ratio]);
}
}
}
if (x >= x0 && x <= x1) clipped.push([x, y]);
}
return clipped;
}
function attr(element: SVGElement, name: string): number {
return Number(element.getAttribute(name) ?? 0);
}
/** SVG 좌표(y 아래로 증가) → 도면 좌표(y 위로 증가). */
function flip(x: number, y: number): XY {
return [x, -y];
}
function parsePoints(element: SVGElement): XY[] {
const raw = (element.getAttribute("points") ?? "").trim();
if (!raw) return [];
const numbers = raw.split(/[\s,]+/).map(Number);
const points: XY[] = [];
for (let index = 0; index + 1 < numbers.length; index += 2) {
points.push(flip(numbers[index], numbers[index + 1]));
}
return points;
}
/**
* 이 도형에 걸린 clipPath 폴리곤(도면 좌표). 없으면 null.
*
* 기슭막이 형태 해칭(`B06_Section_UI_Cross_Wall_Hatch`)은 벽 폴리곤 clip 안에서 그린다.
* 종전 수확기는 그 그룹을 통째로 건너뛰어 **돌쌓기·콘크리트 해칭이 CAD 도면에 하나도
* 실리지 않았다**. 클립을 쓰는 대신 경계로 **잘라서** 싣는다(2026-09-03 사용자 결정).
*/
function clipPolygonOf(element: SVGElement): XY[] | null {
const group = element.closest("g[clip-path]");
const reference = group?.getAttribute("clip-path") ?? "";
const id = reference.match(/url\(#([^)]+)\)/)?.[1];
if (!id) return null;
const shape = element.ownerDocument?.getElementById(id)?.querySelector("polygon");
if (!shape) return null;
const points = parsePoints(shape as unknown as SVGElement);
return points.length >= 3 ? points : null;
}
function 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);
});
}
/** pattern 한 칸의 도형(SVG 좌표) — 선(path M/L) · 원/타원. 바탕 rect 는 그림이 아니라 뺌. */
export interface PatternTile {
width: number;
height: number;
paths: XY[][];
rings: Array<{ cx: number; cy: number; rx: number; ry: number }>;
}
/** 무늬 칸은 화면 px 로 그린 것 — 도면(종이 mm)에선 같은 촘촘함이 되게 px → mm. */
const PATTERN_MM_PER_PX = 25.4 / 96;
/** 무늬 칸 수 상한 — 넘으면 무늬를 버리고 외곽선만(ponytail: 큰 다각형이면 칸 크기를 키울 것). */
const PATTERN_TILE_LIMIT = 20000;
/**
* pattern 채움을 다각형(도면 좌표) 안으로 펴서 자른다(88-4) — CAD 에는 무늬 채움이 없다.
* 칸은 SVG 사용자 좌표 원점 기준(`patternUnits="userSpaceOnUse"`). 선은 이어 붙여 한 줄로,
* 다각형 안에 온전히 든 원은 원 그대로 · 걸친 원/타원은 잘린 폴리선으로.
*/
export function patternMarks(
polygon: XY[],
tile: PatternTile,
): { polylines: XY[][]; circles: Array<{ center: XY; radius: number }> } {
const polylines: XY[][] = [];
const circles: Array<{ center: XY; radius: number }> = [];
const { width, height } = tile;
if (polygon.length < 3 || !(width > 0) || !(height > 0)) return { polylines, circles };
const xs = polygon.map((point) => point[0]);
const ys = polygon.map((point) => -point[1]);
const [i0, i1] = [Math.floor(Math.min(...xs) / width), Math.floor(Math.max(...xs) / width)];
const [j0, j1] = [Math.floor(Math.min(...ys) / height), Math.floor(Math.max(...ys) / height)];
if ((i1 - i0 + 1) * (j1 - j0 + 1) > PATTERN_TILE_LIMIT) return { polylines, circles };
const lines: XY[][] = [];
const arcs: XY[][] = [];
for (let i = i0; i <= i1; i += 1) {
for (let j = j0; j <= j1; j += 1) {
const [dx, dy] = [i * width, j * height];
for (const path of tile.paths) {
lines.push(
...clipPointsToPolygon(
path.map(([x, y]) => flip(x + dx, y + dy)),
polygon,
),
);
}
for (const { cx, cy, rx, ry } of tile.rings) {
const center = flip(cx + dx, cy + dy);
const ring: XY[] = circlePoints([0, 0], 1).map(([x, y]) => [
center[0] + x * rx,
center[1] + y * ry,
]);
if (rx === ry && ring.every((point) => pointInPolygon(point, polygon))) {
circles.push({ center, radius: rx });
} else {
arcs.push(...clipPointsToPolygon(ring, polygon));
}
}
}
}
polylines.push(...chain(lines).map((points) => simplify(points)), ...chain(arcs));
return { polylines, circles };
}
/** 다각형 채움이 가리키는 pattern 을 칸 도형으로 읽는다(채움 = fill 속성 또는 style). 없으면 null. */
function patternTileOf(element: SVGElement, root: SVGElement): PatternTile | null {
const fill = element.getAttribute("fill") ?? String(element.style?.fill ?? "");
const id = /url\(["']?#([^"')]+)/.exec(fill)?.[1];
if (!id) return null;
const pattern = Array.from(root.querySelectorAll<SVGElement>("pattern")).find(
(node) => node.getAttribute("id") === id,
);
if (!pattern) return null;
const k = PATTERN_MM_PER_PX;
const tile: PatternTile = {
width: attr(pattern, "width") * k,
height: attr(pattern, "height") * k,
paths: [],
rings: [],
};
for (const child of Array.from(pattern.children) as SVGElement[]) {
const tag = child.tagName.toLowerCase();
if (tag === "path") {
// M/L 절대 좌표만(무늬 빗금 꼴) — M 마다 새 줄
for (const run of (child.getAttribute("d") ?? "").split(/(?=M)/i)) {
const numbers = run.match(/-?[\d.]+/g)?.map(Number) ?? [];
const points: XY[] = [];
for (let index = 0; index + 1 < numbers.length; index += 2) {
points.push([numbers[index] * k, numbers[index + 1] * k]);
}
if (points.length > 1) tile.paths.push(points);
}
} else if (tag === "circle") {
const r = attr(child, "r") * k;
tile.rings.push({ cx: attr(child, "cx") * k, cy: attr(child, "cy") * k, rx: r, ry: r });
} else if (tag === "ellipse") {
tile.rings.push({
cx: attr(child, "cx") * k,
cy: attr(child, "cy") * k,
rx: attr(child, "rx") * k,
ry: attr(child, "ry") * k,
});
}
}
return tile;
}
/** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다 (블록 테두리 안으로 자른다). */
function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] {
const [fx0, , fx1] = frame;
const entities: Entity[] = [];
const segments: XY[][] = [];
const inside = (px: number): boolean => px >= fx0 && px <= fx1;
const nodes = root.querySelectorAll<SVGElement>("polygon, polyline, line, circle, rect, text");
for (const element of Array.from(nodes)) {
if (element.closest(SKIP_SELECTOR)) continue;
const tag = element.tagName.toLowerCase();
// 클립 그룹 안의 해칭은 경계로 **잘라서** 싣는다 — CAD 에는 클립이 없다.
const clip = clipPolygonOf(element);
if (tag === "polygon" || tag === "polyline" || tag === "rect") {
const points = tag === "rect" ? rectPoints(element) : parsePoints(element);
if (tag === "polygon" && points.length > 2) points.push(points[0]);
const runs = clip ? clipPointsToPolygon(points, clip) : [points];
for (const run of runs) {
const poly = polyEntity(style, simplify(clipX(run, fx0, fx1)));
if (poly) entities.push(poly);
}
// 무늬 채움(노면공 · 물넘이포장) — 무늬 선 · 원을 다각형 안으로 펴서 같은 층 · 색으로(88-4)
const tile = tag === "polygon" && !clip ? patternTileOf(element, root) : null;
if (tile) {
const marks = patternMarks(points, tile);
for (const run of marks.polylines) {
const poly = polyEntity(style, clipX(run, fx0, fx1));
if (poly) entities.push(poly);
}
for (const { center, radius } of marks.circles) {
if (!inside(center[0])) continue;
entities.push(
baseEntity(style, "Circle", { center: { x: center[0], y: center[1] }, radius }),
);
}
}
} else if (tag === "line") {
const start = flip(attr(element, "x1"), attr(element, "y1"));
const end = flip(attr(element, "x2"), attr(element, "y2"));
for (const run of clip ? clipSegment(start, end, clip) : [[start, end]]) {
const cut = clipX(run, fx0, fx1);
if (cut.length === 2) segments.push(cut);
}
} else if (tag === "circle") {
const center = flip(attr(element, "cx"), attr(element, "cy"));
if (!inside(center[0])) continue;
const radius = attr(element, "r");
if (!clip) {
entities.push(
baseEntity(style, "Circle", {
center: { x: center[0], y: center[1] },
radius,
}),
);
continue;
}
// 클립 안 원(통나무 마구리 등)은 폴리선으로 바꿔 경계에서 자른다.
for (const run of clipPointsToPolygon(circlePoints(center, radius), clip)) {
const poly = polyEntity(style, simplify(clipX(run, fx0, fx1)));
if (poly) entities.push(poly);
}
} else if (tag === "text") {
const label = (element.textContent ?? "").trim();
const at = flip(attr(element, "x"), attr(element, "y"));
if (!label || !inside(at[0])) continue;
if (clip && !pointInPolygon(at, clip)) continue;
const anchor = element.getAttribute("text-anchor");
const align = anchor === "start" ? "left" : anchor === "end" ? "right" : "center";
entities.push(textEntity(style, label, at, align));
}
}
// 낱개 선분은 이어 붙인 뒤 공선점을 지워 한 변을 한 선으로 만든다.
for (const points of chain(segments)) {
const poly = polyEntity(style, simplify(points));
if (poly) entities.push(poly);
}
return entities;
}
/** 엔티티가 차지하는 종이 좌표 사각형 (없으면 null). */
export function entityBox(entity: Record<string, unknown>): number[] | null {
const xs: number[] = [];
const ys: number[] = [];
const walk = (node: Record<string, unknown>): void => {
const shape = node.shapeData as Record<string, Record<string, number>> | null;
for (const key of ["startPoint", "endPoint", "basePoint", "center"]) {
const point = shape?.[key];
if (point) {
xs.push(point.x);
ys.push(point.y);
}
}
for (const child of (node.children as Record<string, unknown>[]) ?? []) walk(child);
};
walk(entity);
return xs.length ? [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] : null;
}
type Layouts = StoredLayouts;
/**
* 설계선(+포장층)을 그린다. **구조물이 깎아 낸 설계선**(designTrim)을 B06 카드와 같은
* 우선순위로 넘긴다 — 정본에는 트림 전 원본만 있어서, 이걸 안 태우면 벽이 서 있어도
* 설계선이 원래대로 지나간다(2026-08-30 사용자 지적).
*/
function drawDesign(
svg: SVGElement,
section: CrossSection,
layouts: Layouts,
x: (offset: number) => number,
y: (elevation: number) => number,
): void {
const { design, culvert, box, ford, own } = layouts;
const paved = appendFordPavementOverlay(svg, section.ford_pavement, design, x, y);
if (!paved) appendPavementOverlay(svg, design, x, y, section.pavement);
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,
linked, // 연동 카드 — 도수로 · 돌붙임 띠는 주인 측점만(90-2 · B06 카드와 같음)
);
}
}
/**
* 한 측점의 덧그림 엔티티 — 설계선(구조물 트림 반영) + 구조물. 가로는 `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),
];
}