도면을 열 때마다 0.8~1.0초가 걸렸다. 서버가 요청마다 도면을 새로 조립하고(0.3~0.6초), 구조물 모듈이 공유 캐시를 건너뛰고 종횡단 상세를 매번 다시 받았기 때문이다(0.2초). 진입 후 첫 장을 띄우고 나서 목록 전체를 배경에서 받아 캐시에 담고, 구조물 모듈은 B05·B06과 같은 Section_Store를 쓴다. 클릭에서 렌더까지 55~65ms로 줄었다. CAD 알림은 react-toastify라 프로젝트 공용 토스트와 모양이 달랐다. 호출부 30여 곳을 그대로 두려고 vite alias로 라이브러리 자리에 다리를 끼워, 부모가 있으면 메시지로 넘겨 부모 토스트로 띄운다. 백업 되살리기처럼 누르는 안내를 위해 showToast에 onClick을 더했다. 커서가 미묘하게 끌리던 것은 프레임이 느려서가 아니라(16.7ms, 60fps) 합성 파이프라인 때문이다. 화면 캔버스를 desynchronized+alpha:false로 만들어 그 큐를 건너뛴다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
495 lines
19 KiB
TypeScript
495 lines
19 KiB
TypeScript
/* =============================================================================
|
||
* B07_DesignDetail_UI_Cad_Structures.ts
|
||
* 횡단도 **구조물 작도** — 배수관·기슭막이·세월교·BOX암거·물넘이포장.
|
||
*
|
||
* 산식은 새로 만들지 않는다. B06 화면이 쓰는 기하·그리기 함수를 **그대로** 불러
|
||
* 화면에 붙이지 않은 오프스크린 SVG에 그린 뒤, 그 도형을 CAD 엔티티로 옮긴다
|
||
* (2026-08-30 사용자 확정: 프론트에서 B06 산식 재사용 — 파이썬 포팅 금지).
|
||
* 앞으로 B06에 구조물·치수·글자가 붙으면 여기 손대지 않아도 도면에 따라온다.
|
||
*
|
||
* 자리 맞추기는 서버가 도면에 실어 보내는 `cross_placements`가 정한다:
|
||
* x_mm = offset*mm_per_m + ox, y_mm = (elev - dy)*mm_per_m + oy
|
||
* SVG는 y가 아래로 자라므로 그릴 때 부호를 뒤집고 수확할 때 되돌린다.
|
||
* ========================================================================== */
|
||
|
||
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||
import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store";
|
||
import { appendBoxOverlay } from "../B06_Section/B06_Section_UI_Cross_Box";
|
||
import {
|
||
computeBoxLayout,
|
||
DEFAULT_BOX_SIDE_ADJUST,
|
||
} from "../B06_Section/B06_Section_UI_Cross_Box_Geom";
|
||
import { appendCulvertOverlay } from "../B06_Section/B06_Section_UI_Cross_Culvert";
|
||
import {
|
||
DEFAULT_BASIN_ADJUST,
|
||
ZERO_ADJUST,
|
||
} from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
|
||
import type { WallAdjust } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
|
||
import {
|
||
computeCardCulvert,
|
||
culvertLinkFor,
|
||
} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire";
|
||
import type {
|
||
ExtraWallControl,
|
||
InletStructureControl,
|
||
RevetOffsetControl,
|
||
} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire";
|
||
import { appendFordOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford";
|
||
import {
|
||
computeFordLayout,
|
||
DEFAULT_FORD_WALL_ADJUST,
|
||
} from "../B06_Section/B06_Section_UI_Cross_Ford_Geom";
|
||
import { appendFordPavementOverlay } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement";
|
||
import {
|
||
appendCrossDesignOverlay,
|
||
appendPavementOverlay,
|
||
} from "../B06_Section/B06_Section_UI_Cross_Design";
|
||
import {
|
||
appendRevetmentOverlay,
|
||
computeRevetmentLayout,
|
||
} from "../B06_Section/B06_Section_UI_Cross_Revetment";
|
||
|
||
/** 서버가 도면에 실어 보내는 측점별 실좌표(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[];
|
||
}
|
||
|
||
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;
|
||
|
||
/** 정의부(해칭 패턴·클립)는 도형이 아니다. 클립된 해칭은 1차 제외(잘라 낼 수단이 없다). */
|
||
const SKIP_SELECTOR = "defs, clipPath, pattern, g[clip-path]";
|
||
|
||
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;
|
||
}
|
||
|
||
/** 오프스크린 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, text");
|
||
for (const element of Array.from(nodes)) {
|
||
if (element.closest(SKIP_SELECTOR)) continue;
|
||
const tag = element.tagName.toLowerCase();
|
||
if (tag === "polygon" || tag === "polyline") {
|
||
const points = parsePoints(element);
|
||
if (tag === "polygon" && points.length > 2) points.push(points[0]);
|
||
const poly = polyEntity(style, simplify(clipX(points, 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"));
|
||
const cut = clipX([start, end], fx0, fx1);
|
||
if (cut.length === 2) segments.push(cut);
|
||
} else if (tag === "circle") {
|
||
const [cx, cy] = flip(attr(element, "cx"), attr(element, "cy"));
|
||
if (!inside(cx)) continue;
|
||
entities.push(
|
||
baseEntity(style, "Circle", { center: { x: cx, y: cy }, radius: attr(element, "r") }),
|
||
);
|
||
} else if (tag === "text") {
|
||
const label = (element.textContent ?? "").trim();
|
||
const at = flip(attr(element, "x"), attr(element, "y"));
|
||
if (!label || !inside(at[0])) continue;
|
||
const anchor = element.getAttribute("text-anchor");
|
||
const align = anchor === "start" ? "left" : anchor === "end" ? "right" : "center";
|
||
entities.push(textEntity(style, label, at, align));
|
||
}
|
||
}
|
||
// 낱개 선분은 이어 붙인 뒤 공선점을 지워 한 변을 한 선으로 만든다.
|
||
for (const points of chain(segments)) {
|
||
const poly = polyEntity(style, simplify(points));
|
||
if (poly) entities.push(poly);
|
||
}
|
||
return entities;
|
||
}
|
||
|
||
/** 엔티티가 차지하는 종이 좌표 사각형 (없으면 null). */
|
||
function entityBox(entity: Record<string, unknown>): number[] | null {
|
||
const xs: number[] = [];
|
||
const ys: number[] = [];
|
||
const walk = (node: Record<string, unknown>): void => {
|
||
const shape = node.shapeData as Record<string, Record<string, number>> | null;
|
||
for (const key of ["startPoint", "endPoint", "basePoint", "center"]) {
|
||
const point = shape?.[key];
|
||
if (point) {
|
||
xs.push(point.x);
|
||
ys.push(point.y);
|
||
}
|
||
}
|
||
for (const child of (node.children as Record<string, unknown>[]) ?? []) walk(child);
|
||
};
|
||
walk(entity);
|
||
return xs.length ? [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)] : null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 정본(design)만 읽는 조작값 — B07은 편집하지 않으므로 되받기·토스트는 빈 동작이다.
|
||
// ---------------------------------------------------------------------------
|
||
function storedWallAdjust(section: CrossSection, role: string): WallAdjust {
|
||
const stored = section.design?.revet_adjust?.[role];
|
||
return stored ? { ...ZERO_ADJUST, ...(stored as Partial<WallAdjust>) } : { ...ZERO_ADJUST };
|
||
}
|
||
|
||
const revetOffset: RevetOffsetControl = {
|
||
adjustFor: (section, role) => storedWallAdjust(section, role),
|
||
storedAdjustFor: (section, role) =>
|
||
section.design?.revet_adjust?.[role] ? storedWallAdjust(section, role) : null,
|
||
selectedFor: () => null,
|
||
highlightFor: () => null,
|
||
select: () => undefined,
|
||
syncApplied: () => undefined,
|
||
update: () => undefined,
|
||
reset: () => undefined,
|
||
};
|
||
|
||
const extraWalls: ExtraWallControl = {
|
||
countFor: (section, side = "outlet") => section.design?.extra_wall_counts?.[side] ?? 0,
|
||
setCount: () => undefined,
|
||
equalize: () => undefined,
|
||
consumeEqualize: () => false,
|
||
syncCount: () => undefined,
|
||
};
|
||
|
||
const inletStructure: InletStructureControl = {
|
||
valueFor: (section) => section.design?.inlet_structure ?? "auto",
|
||
adjustFor: (section) => ({ ...DEFAULT_BASIN_ADJUST, ...(section.design?.basin_adjust ?? {}) }),
|
||
set: () => undefined,
|
||
updateAdjust: () => undefined,
|
||
resetAdjust: () => undefined,
|
||
};
|
||
|
||
/** 한 측점의 구조물 기하 한 벌 — 설계선 트림과 구조물 그리기가 같은 결과를 나눠 쓴다. */
|
||
function computeLayouts(section: CrossSection, sections: CrossSection[]) {
|
||
const design = section.design;
|
||
if (!design) return null;
|
||
const designZAt = (chainageM: number): number | null => {
|
||
const found = sections.find(
|
||
(item) => Math.abs(item.chainage_m - chainageM) <= CHAINAGE_TOLERANCE_M,
|
||
);
|
||
return found?.design?.design_elevation_m ?? null;
|
||
};
|
||
const link = section.culvert ? undefined : culvertLinkFor(section, sections, designZAt);
|
||
const culvert = computeCardCulvert(
|
||
section,
|
||
section.samples,
|
||
null,
|
||
revetOffset,
|
||
inletStructure,
|
||
extraWalls,
|
||
link,
|
||
);
|
||
const box = computeBoxLayout(section, section.samples, {
|
||
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.left ?? {}) },
|
||
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.right ?? {}) },
|
||
});
|
||
const ford = computeFordLayout(section, section.samples, {
|
||
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.inlet ?? {}) },
|
||
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.outlet ?? {}) },
|
||
});
|
||
// 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다.
|
||
const own =
|
||
!section.culvert && !link ? computeRevetmentLayout(section, design.revet_adjust?.own) : null;
|
||
return { design, link, culvert, box, ford, own };
|
||
}
|
||
|
||
type Layouts = NonNullable<ReturnType<typeof computeLayouts>>;
|
||
|
||
/**
|
||
* 설계선(+포장층)을 그린다. **구조물이 깎아 낸 설계선**(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,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 도면에 구조물 엔티티를 얹는다 (제자리 수정). 배치 메타가 없거나 자료를 못 읽으면
|
||
* 아무것도 하지 않는다 — 구조물이 빠져도 도면 자체는 열려야 한다.
|
||
*/
|
||
export async function appendStructureEntities(
|
||
projectId: string,
|
||
routeId: number,
|
||
drawing: { entities: Record<string, unknown>[]; cross_placements?: CrossPlacement[] },
|
||
): Promise<number> {
|
||
const placements = drawing.cross_placements ?? [];
|
||
if (!placements.length) return 0;
|
||
let sections: CrossSection[];
|
||
try {
|
||
// B05·B06과 같은 공유 캐시를 쓴다 — 도면마다 새로 받으면 장을 넘길 때마다
|
||
// 종횡단 상세를 다시 내려받아 0.2초씩 더 걸린다(2026-08-30 실측).
|
||
sections = (await loadSectionDetail(projectId, routeId)).cross_sections;
|
||
} catch {
|
||
return 0;
|
||
}
|
||
|
||
let added = 0;
|
||
const fresh: Entity[] = [];
|
||
const replaced: number[][] = [];
|
||
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 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),
|
||
];
|
||
} catch {
|
||
continue; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다.
|
||
}
|
||
if (!entities.length) continue;
|
||
// 이 블록 설계선은 우리가 다시 그렸다 — 서버가 낸 트림 전 설계선은 걷어낸다.
|
||
replaced.push(frame);
|
||
fresh.push(...entities);
|
||
added += entities.length;
|
||
}
|
||
if (replaced.length) {
|
||
// 걷어내기는 새 엔티티를 넣기 **전에** 한다 — 뒤에 하면 우리 것까지 같이 지운다.
|
||
drawing.entities = drawing.entities.filter((entity) => {
|
||
if (entity.layerId !== DESIGN_LAYER_ID) 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;
|
||
}
|