feat(B07): 횡단도에 구조물을 그리고, 장을 빈틈없이 채운다
구조물(배수관·기슭막이·세월교·BOX암거·물넘이포장)이 횡단도에서 빠져 있었다. 기하 산식이 전부 B06 화면(TS)에 있어 서버가 도형을 만들 수 없었기 때문이다. 파이썬으로 옮겨 적는 대신, 화면에 붙이지 않은 SVG에 B06 그리기 함수를 그대로 불러 그린 뒤 그 도형을 CAD 엔티티로 옮긴다. 산식은 한 벌로 남고, 앞으로 B06에 붙는 구조물·치수·글자는 손대지 않아도 도면에 따라온다. 자리를 맞추려면 실좌표(m)를 종이(mm)로 옮긴 값이 필요하다 — 서버가 측점별 cross_placements로 실어 보낸다. 장 배치는 전체 블록의 최대치로 칸을 통일하던 것을 버리고, 블록 크기를 먼저 재서 가장 많이 담기는 행 수를 고른다. 열폭은 그 열의 최대폭, 행높이는 그 행의 최대높이라 행·열은 그대로 맞는다. 같은 노선에서 4장이 3장이 됐다(12/6/5). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,16 @@ export interface DesignDrawingItem {
|
||||
|
||||
export interface CadDrawing {
|
||||
entities: Record<string, unknown>[];
|
||||
/** 횡단도 측점별 실좌표(m) → 종이(mm) 변환값. 구조물을 같은 자리에 얹는 데 쓴다. */
|
||||
cross_placements?: {
|
||||
chainage_m: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
dy: number;
|
||||
mm_per_m: number;
|
||||
x0: number;
|
||||
x1: number;
|
||||
}[];
|
||||
layers: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -512,6 +512,19 @@ def build_cross_drawing(
|
||||
return {
|
||||
"format": DRAWING_FORMAT,
|
||||
"entities": entities,
|
||||
# 실좌표(m) -> 종이(mm) 변환값. 프론트가 B06 산식으로 만든 구조물을 같은 자리에
|
||||
# 얹는 데 쓴다 — x_mm = offset*mm_per_m + ox, y_mm = (elev - dy)*mm_per_m + oy.
|
||||
"cross_placements": [
|
||||
{
|
||||
"chainage_m": float(source.get("chainage_m", 0.0)),
|
||||
"ox": ox,
|
||||
"oy": oy,
|
||||
"dy": dy,
|
||||
"mm_per_m": CROSS_MM,
|
||||
"x0": x0,
|
||||
"x1": x1,
|
||||
}
|
||||
],
|
||||
"layers": [
|
||||
_layer(GROUND_LAYER_ID, "Existing Ground", locked=True),
|
||||
_layer(DESIGN_LAYER_ID, "Design Plan"),
|
||||
|
||||
@@ -81,55 +81,90 @@ def section_block_size(
|
||||
return (width, top + below)
|
||||
|
||||
|
||||
def _pack(
|
||||
blocks: list[tuple[int, float, float]], start: int, rows: int
|
||||
) -> tuple[int, list[float], list[float]]:
|
||||
"""blocks[start:]를 rows행 **열 우선**으로 담아 (담은 개수, 열폭, 행높이)를 낸다.
|
||||
|
||||
열폭은 그 열에 든 블록의 최대폭, 행높이는 그 행에 든 블록의 최대높이다 — 칸을
|
||||
전체 최대치로 통일하지 않으면서 행·열은 맞춘다(2026-08-30 사용자 확정).
|
||||
"""
|
||||
usable_w, usable_h = usable_area()
|
||||
col_widths: list[float] = []
|
||||
row_heights: list[float] = [0.0] * rows
|
||||
count = 0
|
||||
for index, (_chainage, width, height) in enumerate(blocks[start:]):
|
||||
column, row = divmod(index, rows)
|
||||
current = col_widths[column] if column < len(col_widths) else 0.0
|
||||
new_col = max(current, width + _BLOCK_GAP_MM)
|
||||
new_row = max(row_heights[row], height + _BLOCK_GAP_MM)
|
||||
if sum(col_widths[:column]) + new_col > usable_w:
|
||||
break
|
||||
if sum(row_heights) - row_heights[row] + new_row > usable_h:
|
||||
break
|
||||
if column < len(col_widths):
|
||||
col_widths[column] = new_col
|
||||
else:
|
||||
col_widths.append(new_col)
|
||||
row_heights[row] = new_row
|
||||
count = index + 1
|
||||
return count, col_widths, row_heights
|
||||
|
||||
|
||||
def _slots(col_widths: list[float], row_heights: list[float], count: int) -> list[list[float]]:
|
||||
"""칸 중심 좌표 — 좌하단부터 아래→위로 채우고, 열이 차면 오른쪽 열로 넘어간다."""
|
||||
usable_w, usable_h = usable_area()
|
||||
rows = len(row_heights)
|
||||
slots: list[list[float]] = []
|
||||
for index in range(count):
|
||||
column, row = divmod(index, rows)
|
||||
x = -usable_w / 2.0 + sum(col_widths[:column]) + col_widths[column] / 2.0
|
||||
y = -usable_h / 2.0 + sum(row_heights[:row]) + row_heights[row] / 2.0
|
||||
slots.append([x, y])
|
||||
return slots
|
||||
|
||||
|
||||
def plan_cross_sheets(blocks: list[tuple[int, float, float]]) -> list[dict[str, Any]]:
|
||||
"""(측점, 폭, 높이) 목록을 A1 장으로 나눈다.
|
||||
|
||||
칸 크기는 전체 블록의 최대 폭·높이로 통일한다 — 장마다 칸이 달라지면
|
||||
도면끼리 비교가 안 되기 때문이다. 한 장에 cols x rows개가 들어간다.
|
||||
블록 크기를 먼저 재서 **가장 많이 담기는 행 수**를 고르고, 그 행·열 격자에
|
||||
담는다(2026-08-30 사용자 지시 — 전체 최대치 통일은 여백이 너무 많았다).
|
||||
"""
|
||||
if not blocks:
|
||||
return []
|
||||
usable_w, usable_h = usable_area()
|
||||
cell_w = max(w for _c, w, _h in blocks) + _BLOCK_GAP_MM
|
||||
cell_h = max(h for _c, _w, h in blocks) + _BLOCK_GAP_MM
|
||||
cols = max(1, int(usable_w // cell_w))
|
||||
rows = max(1, int(usable_h // cell_h))
|
||||
per_sheet = cols * rows
|
||||
|
||||
sheets: list[dict[str, Any]] = []
|
||||
for index in range(0, len(blocks), per_sheet):
|
||||
group = blocks[index : index + per_sheet]
|
||||
number = index // per_sheet + 1
|
||||
start = 0
|
||||
while start < len(blocks):
|
||||
best: tuple[int, list[float], list[float]] = (0, [], [])
|
||||
for rows in range(1, len(blocks) - start + 1):
|
||||
packed = _pack(blocks, start, rows)
|
||||
if packed[0] > best[0]:
|
||||
best = packed
|
||||
count, col_widths, row_heights = best
|
||||
if count == 0: # 한 칸도 못 담을 만큼 큰 블록 — 그래도 한 장에 하나는 놓는다.
|
||||
count, col_widths, row_heights = 1, [blocks[start][1]], [blocks[start][2]]
|
||||
group = blocks[start : start + count]
|
||||
number = len(sheets) + 1
|
||||
sheets.append(
|
||||
{
|
||||
"id": f"cross_s{number:02d}",
|
||||
"number": number,
|
||||
"chainages": [chainage for chainage, _w, _h in group],
|
||||
"cols": cols,
|
||||
"rows": rows,
|
||||
"cell": (cell_w, cell_h),
|
||||
"rows": len(row_heights),
|
||||
"slots": _slots(col_widths, row_heights, count),
|
||||
}
|
||||
)
|
||||
start += count
|
||||
return sheets
|
||||
|
||||
|
||||
def _cell_center(index: int, rows: int, cell: tuple[float, float]) -> tuple[float, float]:
|
||||
"""좌하단부터 아래→위로 채우고, 열이 차면 오른쪽 열로 넘어간다."""
|
||||
cell_w, cell_h = cell
|
||||
usable_w, usable_h = usable_area()
|
||||
column, row = divmod(index, rows)
|
||||
x = -usable_w / 2.0 + cell_w * (column + 0.5)
|
||||
y = -usable_h / 2.0 + cell_h * (row + 0.5)
|
||||
return (x, y)
|
||||
|
||||
|
||||
def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""한 장을 만든다. sections는 이 장에 담을 측점 입력들(배치 순서대로)."""
|
||||
entities: list[dict[str, Any]] = []
|
||||
rows = int(sheet.get("rows", 1))
|
||||
cell = tuple(sheet.get("cell", (100.0, 100.0))) # type: ignore[arg-type]
|
||||
placements: list[dict[str, Any]] = []
|
||||
slots = sheet.get("slots") or []
|
||||
|
||||
for index, section in enumerate(sections):
|
||||
if index >= len(slots):
|
||||
break
|
||||
seed_id = f"{sheet['id']}:{section['chainage']}"
|
||||
# 1) 원점(0,0)에 한 번 만들어 블록이 원점 대비 어디에 놓이는지 잰다.
|
||||
probe = build_cross_drawing(
|
||||
@@ -144,7 +179,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
|
||||
if bbox is None:
|
||||
continue
|
||||
min_x, min_y, max_x, max_y = bbox
|
||||
center_x, center_y = _cell_center(index, rows, cell)
|
||||
center_x, center_y = slots[index]
|
||||
# 2) 칸 가운데에 오도록 원점을 옮겨 다시 만든다.
|
||||
origin = (
|
||||
center_x - (min_x + max_x) / 2.0,
|
||||
@@ -160,6 +195,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
|
||||
origin=origin,
|
||||
)
|
||||
entities.extend(placed["entities"])
|
||||
placements.extend(placed.get("cross_placements") or [])
|
||||
|
||||
bbox = entities_bbox(entities)
|
||||
if bbox:
|
||||
@@ -168,6 +204,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
|
||||
return {
|
||||
"format": DRAWING_FORMAT,
|
||||
"entities": entities,
|
||||
"cross_placements": placements,
|
||||
"layers": [
|
||||
_layer(GROUND_LAYER_ID, "Existing Ground", locked=True),
|
||||
_layer(DESIGN_LAYER_ID, "Design Plan"),
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
/* =============================================================================
|
||||
* 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 { fetchSectionDetail } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** 구조물 엔티티가 들어갈 레이어·색 — 서버 도면이 이미 선언해 둔 그 레이어다. */
|
||||
const STRUCTURE_LAYER_ID = "b08-structure";
|
||||
const STRUCTURE_COLOR = "#f6d55c";
|
||||
/** 글자 크기(종이 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];
|
||||
|
||||
function baseEntity(type: string, shapeData: unknown): Entity {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type,
|
||||
lineColor: STRUCTURE_COLOR,
|
||||
lineWidth: 1,
|
||||
layerId: STRUCTURE_LAYER_ID,
|
||||
shapeData,
|
||||
};
|
||||
}
|
||||
|
||||
function lineEntity(start: XY, end: XY): Entity {
|
||||
return baseEntity("Line", {
|
||||
startPoint: { x: start[0], y: start[1] },
|
||||
endPoint: { x: end[0], y: end[1] },
|
||||
});
|
||||
}
|
||||
|
||||
/** 점열 → PolyLine(자식 Line 묶음). 서버 도면의 폴리라인과 같은 직렬화다. */
|
||||
function polyEntity(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]));
|
||||
}
|
||||
const poly = baseEntity("PolyLine", null);
|
||||
poly.children = children;
|
||||
return poly;
|
||||
}
|
||||
|
||||
function textEntity(label: string, at: XY, align: string): Entity {
|
||||
return baseEntity("Text", {
|
||||
label,
|
||||
basePoint: { x: at[0], y: at[1] },
|
||||
options: {
|
||||
textDirection: { x: 1, y: 0 },
|
||||
textAlign: align,
|
||||
textColor: STRUCTURE_COLOR,
|
||||
fontSize: LABEL_FONT_MM,
|
||||
fontFamily: "sans-serif",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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): Entity[] {
|
||||
const entities: Entity[] = [];
|
||||
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(points);
|
||||
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")),
|
||||
),
|
||||
);
|
||||
} 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") }));
|
||||
} else if (tag === "text") {
|
||||
const label = (element.textContent ?? "").trim();
|
||||
if (!label) 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));
|
||||
}
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 정본(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,
|
||||
};
|
||||
|
||||
/** 한 측점의 구조물을 오프스크린 SVG에 그린다. 그리는 순서는 B06 카드와 같다. */
|
||||
function drawStructures(
|
||||
svg: SVGElement,
|
||||
section: CrossSection,
|
||||
sections: CrossSection[],
|
||||
x: (offset: number) => number,
|
||||
y: (elevation: number) => number,
|
||||
): void {
|
||||
const design = section.design;
|
||||
if (!design) return;
|
||||
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 culvertLayout = computeCardCulvert(
|
||||
section,
|
||||
section.samples,
|
||||
null,
|
||||
revetOffset,
|
||||
inletStructure,
|
||||
extraWalls,
|
||||
link,
|
||||
);
|
||||
const boxLayout = 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, {
|
||||
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 linked = !section.culvert && !!link;
|
||||
appendCulvertOverlay(
|
||||
svg,
|
||||
culvertLayout,
|
||||
x,
|
||||
y,
|
||||
undefined,
|
||||
linked || culvertLayout.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 {
|
||||
sections = (await fetchSectionDetail(projectId, routeId)).cross_sections;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let added = 0;
|
||||
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 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");
|
||||
try {
|
||||
drawStructures(svg, section, sections, x, y);
|
||||
} catch {
|
||||
continue; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다.
|
||||
}
|
||||
const entities = harvest(svg);
|
||||
drawing.entities.push(...entities);
|
||||
added += entities.length;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type DesignDrawingResponse,
|
||||
type QuantityTable,
|
||||
} from "./B07_DesignDetail_Api_Fetch";
|
||||
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
|
||||
|
||||
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
|
||||
interface DesignMeta {
|
||||
@@ -370,6 +371,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
cadHost.dataset.loading = "true";
|
||||
try {
|
||||
const response = await fetchDesignDrawing(projectId, drawing.id);
|
||||
// 구조물(배수관·기슭막이·세월교·BOX·물넘이포장)은 B06 산식이 프론트에 있어
|
||||
// 여기서 얹는다. 확정본은 이미 구조물이 담겨 저장돼 있으므로 건드리지 않는다.
|
||||
if (drawing.kind === "cross" && !response.confirmed) {
|
||||
await appendStructureEntities(projectId, response.route_id, response.drawing);
|
||||
}
|
||||
currentDrawing = drawing;
|
||||
currentIndex = index;
|
||||
currentConfirmed = response.confirmed;
|
||||
|
||||
Reference in New Issue
Block a user