diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py index a28569b5..d85f6f37 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad.py @@ -523,6 +523,14 @@ def build_cross_drawing( "mm_per_m": CROSS_MM, "x0": x0, "x1": x1, + # 블록 테두리(종이 mm). 프론트가 자기 그림을 이 안으로 자르고, 갈아 끼울 + # 서버 설계선을 이 안에서만 골라내는 데 쓴다. + "frame": [ + center_x - frame_x, + frame_bottom, + center_x + frame_x, + frame_top, + ], } ], "layers": [ diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts index 48f0f421..a8bd7e01 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts @@ -40,6 +40,10 @@ import { 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, @@ -54,6 +58,8 @@ export interface CrossPlacement { mm_per_m: number; x0: number; x1: number; + /** 블록 테두리(종이 mm) [x0, y0, x1, y1] — 그림을 이 안으로 자른다. */ + frame?: number[]; } const SVG_NS = "http://www.w3.org/2000/svg"; @@ -61,6 +67,9 @@ 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로 끊어 쓴다. */ @@ -71,51 +80,147 @@ const SKIP_SELECTOR = "defs, clipPath, pattern, g[clip-path]"; type Entity = Record; type XY = [number, number]; +/** 수확한 도형을 어느 도면층·색으로 넣을지. */ +interface Style { + layerId: string; + color: string; +} -function baseEntity(type: string, shapeData: unknown): Entity { +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: STRUCTURE_COLOR, + lineColor: style.color, lineWidth: 1, - layerId: STRUCTURE_LAYER_ID, + layerId: style.layerId, shapeData, }; } -function lineEntity(start: XY, end: XY): Entity { - return baseEntity("Line", { +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(points: XY[]): Entity | null { +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(points[index], points[index + 1])); + children.push(lineEntity(style, points[index], points[index + 1])); } - const poly = baseEntity("PolyLine", null); + const poly = baseEntity(style, "PolyLine", null); poly.children = children; return poly; } -function textEntity(label: string, at: XY, align: string): Entity { - return baseEntity("Text", { +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: STRUCTURE_COLOR, + 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); } @@ -136,9 +241,12 @@ function parsePoints(element: SVGElement): XY[] { return points; } -/** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다. */ -function harvest(root: SVGElement): Entity[] { +/** 오프스크린 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, text"); for (const element of Array.from(nodes)) { if (element.closest(SKIP_SELECTOR)) continue; @@ -146,29 +254,55 @@ function harvest(root: SVGElement): Entity[] { if (tag === "polygon" || tag === "polyline") { const points = parsePoints(element); if (tag === "polygon" && points.length > 2) points.push(points[0]); - const poly = polyEntity(points); + const poly = polyEntity(style, simplify(clipX(points, fx0, fx1))); if (poly) entities.push(poly); } else if (tag === "line") { - entities.push( - lineEntity( - flip(attr(element, "x1"), attr(element, "y1")), - flip(attr(element, "x2"), attr(element, "y2")), - ), - ); + 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")); - entities.push(baseEntity("Circle", { center: { x: cx, y: cy }, radius: attr(element, "r") })); + 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(); - if (!label) continue; + 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(label, flip(attr(element, "x"), attr(element, "y")), align)); + 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; +} + // --------------------------------------------------------------------------- // 정본(design)만 읽는 조작값 — B07은 편집하지 않으므로 되받기·토스트는 빈 동작이다. // --------------------------------------------------------------------------- @@ -205,16 +339,10 @@ const inletStructure: InletStructureControl = { resetAdjust: () => undefined, }; -/** 한 측점의 구조물을 오프스크린 SVG에 그린다. 그리는 순서는 B06 카드와 같다. */ -function drawStructures( - svg: SVGElement, - section: CrossSection, - sections: CrossSection[], - x: (offset: number) => number, - y: (elevation: number) => number, -): void { +/** 한 측점의 구조물 기하 한 벌 — 설계선 트림과 구조물 그리기가 같은 결과를 나눠 쓴다. */ +function computeLayouts(section: CrossSection, sections: CrossSection[]) { const design = section.design; - if (!design) return; + if (!design) return null; const designZAt = (chainageM: number): number | null => { const found = sections.find( (item) => Math.abs(item.chainage_m - chainageM) <= CHAINAGE_TOLERANCE_M, @@ -222,7 +350,7 @@ function drawStructures( return found?.design?.design_elevation_m ?? null; }; const link = section.culvert ? undefined : culvertLinkFor(section, sections, designZAt); - const culvertLayout = computeCardCulvert( + const culvert = computeCardCulvert( section, section.samples, null, @@ -231,31 +359,68 @@ function drawStructures( extraWalls, link, ); - const boxLayout = computeBoxLayout(section, section.samples, { + 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 fordLayout = computeFordLayout(section, section.samples, { + const ford = computeFordLayout(section, section.samples, { inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.inlet ?? {}) }, outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.outlet ?? {}) }, }); - - appendFordPavementOverlay(svg, section.ford_pavement, design, x, y); // 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다. - if (!section.culvert && !link) { - appendRevetmentOverlay(svg, computeRevetmentLayout(section, design.revet_adjust?.own), x, y); - } - if (boxLayout) appendBoxOverlay(svg, boxLayout, x, y); - if (fordLayout) appendFordOverlay(svg, fordLayout, x, y); - if (culvertLayout) { + const own = + !section.culvert && !link ? computeRevetmentLayout(section, design.revet_adjust?.own) : null; + return { design, link, culvert, box, ford, own }; +} + +type Layouts = NonNullable>; + +/** + * 설계선(+포장층)을 그린다. **구조물이 깎아 낸 설계선**(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, - culvertLayout, + culvert, x, y, undefined, - linked || culvertLayout.culvert.hidden_pipe === true, + linked || culvert.culvert.hidden_pipe === true, ); } } @@ -279,27 +444,49 @@ export async function appendStructureEntities( } 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, ); - if (!section) continue; - if (!section.culvert && !section.ford && !section.box && !section.ford_pavement) { - // 연동으로 옆에서 이어져 온 기슭막이는 세트가 없어도 그려야 하므로 정본만 더 본다. - if (!section.revetment && !sections.some((item) => item.culvert)) continue; - } + 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); - const svg = document.createElementNS(SVG_NS, "svg"); + let entities: Entity[]; try { - drawStructures(svg, section, sections, x, y); + 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; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다. } - const entities = harvest(svg); - drawing.entities.push(...entities); + 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; }