feat(B05·B06): BOX암거 횡단도 · 3D 중공 셸·날개벽·관통 보어

BOX암거를 세월교와 같은 계류 방향 절단면으로 그리고, 3D는 속이 빈 서피스 셸로
만든다. 관통 컷·중공 튜브는 배수관 세트와 세월교에도 함께 적용한다.

B06 횡단도
- `_box_set()` — 내공 폭·높이(B05 옵션), 부재 두께 세월교 승계(측벽 0.2·상하판 0.3),
  복토 0.5m(별표2 교차 참조), 날개벽 투영 저판 연장
- 상판 윗면 = 노면 − 복토, 구체는 수평 설치(교본 3장). 계류 하상과의 고저차는 툴팁 표기
- 구체가 노면 아래 묻히므로 계획선은 자르지 않는다(세월교와 다른 점)

3D
- 로프트가 **링별 폴리곤**을 받는다 — 구조물을 위·아래 조각으로 갈라 링마다 틈을
  벌려 빈 공간을 만든다. 틈 0이면 속 찬 단면 그대로라 토폴로지가 유지된다
- BOX = 내공 구간만 틈을 벌려 측벽이 저절로 생기는 셸 + 날개벽 4매(날개 축 프레임)
- 관통 보어 = 링 y에서 틈 높이 2·√(r²−(y−y₀)²) → 배관 외측 기준 원형 구멍.
  기슭막이·집수정·세월교 측벽에 적용
- 배관은 중공 튜브(바깥·안쪽 원통 + 양 끝 고리)로 내부 유로를 보인다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-25 12:45:32 +09:00
co-authored by Claude Opus 5
parent 98380056cc
commit d4c8de738a
10 changed files with 741 additions and 38 deletions
+43 -12
View File
@@ -206,14 +206,17 @@ function structureLoftGeometry(
structure: CorridorStructure,
origin: SceneOrigin,
): THREE.BufferGeometry | null {
const polygon = structure.polygon;
const rings = structure.rings;
// 링별 폴리곤이 오면 그것을, 아니면 공통 폴리곤 하나를 모든 링에 쓴다(2026-08-25).
const perRing = structure.polygons;
const polygon = perRing?.[0] ?? structure.polygon;
if (!polygon || polygon.length < 3 || !rings || rings.length < 2) return null;
if (perRing && perRing.length !== rings.length) return null;
const count = polygon.length;
const ringCount = rings.length;
const positions = new Float32Array(ringCount * count * 3);
rings.forEach((frame, ring) => {
polygon.forEach(([offset, elevation], index) => {
(perRing?.[ring] ?? polygon).forEach(([offset, elevation], index) => {
const x = frame.cx + frame.leftX * offset;
const y = frame.cy + frame.leftY * offset;
const i = (ring * count + index) * 3;
@@ -248,8 +251,14 @@ function structureLoftGeometry(
return flat;
}
/** 배관 — 관 하단 시·끝점(횡단면 안)을 축으로 하는 원통(관경 지름). */
function structurePipeMesh(structure: CorridorStructure, origin: SceneOrigin): THREE.Mesh | null {
/**
* 배관 — 관 하단 시·끝점(횡단면 안)을 축으로 하는 **중공 튜브**(2026-08-25 사용자:
* 내부 유로 반영). 바깥·안쪽 원통을 열린 채로 겹치고 양 끝을 고리로 막는다.
*/
function structurePipeMesh(
structure: CorridorStructure,
origin: SceneOrigin,
): THREE.Object3D | null {
const pipe = structure.pipe;
const frame = structure.frame;
if (!pipe || !frame) return null;
@@ -267,15 +276,37 @@ function structurePipeMesh(structure: CorridorStructure, origin: SceneOrigin): T
if (!(length > 0.1)) return null;
// 관 하단선이 축이므로 반지름만큼 위로 올려 관 중심을 맞춘다.
const radius = pipe.diameterM / 2;
const geometry = new THREE.CylinderGeometry(radius, radius, length, 16);
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshLambertMaterial({ color: STRUCTURE_COLORS.pipe }),
// 관벽 두께가 없으면 관경의 2%를 임시로 준다 — 면이 겹쳐 z-파이팅이 나지 않을 정도.
const wall = Math.min(Math.max(pipe.wallThicknessM ?? pipe.diameterM * 0.02, 0.01), radius * 0.9);
const inner = radius - wall;
const material = (side: THREE.Side): THREE.Material =>
new THREE.MeshLambertMaterial({ color: STRUCTURE_COLORS.pipe, side });
const group = new THREE.Group();
group.add(
new THREE.Mesh(
new THREE.CylinderGeometry(radius, radius, length, 20, 1, true),
material(THREE.FrontSide),
),
// 안쪽 벽은 관 속에서 보이므로 법선을 뒤집는다(내부 유로 면).
new THREE.Mesh(
new THREE.CylinderGeometry(inner, inner, length, 20, 1, true),
material(THREE.BackSide),
),
);
mesh.position.copy(start.clone().add(end).multiplyScalar(0.5));
mesh.position.y += radius;
mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), axis.normalize());
return mesh;
// 양 끝 관벽 단면 — 고리로 막아 튜브가 열린 파이프로 보이게 한다.
for (const sign of [1, -1]) {
const ring = new THREE.Mesh(
new THREE.RingGeometry(inner, radius, 20),
material(THREE.DoubleSide),
);
ring.rotation.x = Math.PI / 2;
ring.position.y = (sign * length) / 2;
group.add(ring);
}
group.position.copy(start.clone().add(end).multiplyScalar(0.5));
group.position.y += radius;
group.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), axis.normalize());
return group;
}
/** 빌드 결과 → 씬에 넣을 그룹. 호출부가 dispose(disposeObject)를 책임진다. */
@@ -21,12 +21,20 @@ import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_A
import type { WallAdjust } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import { ZERO_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import { pipeWallThicknessM } from "../B06_Section/B06_Section_UI_Cross_Culvert_Const";
import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import {
computeFordLayout,
DEFAULT_FORD_WALL_ADJUST,
} from "../B06_Section/B06_Section_UI_Cross_Ford";
import type { FordLayout } from "../B06_Section/B06_Section_UI_Cross_Ford";
import {
boreBandAt,
boreRingChainages,
boxSolids,
splitByBand,
} from "./B05_Profile_UI_Corridor_Structures_Box";
import type { SectionPolygon } from "./B05_Profile_UI_Corridor_Structures_Box";
/** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */
export interface StructureFrame {
@@ -53,10 +61,22 @@ export interface CorridorStructure {
kind: "revet" | "basin" | "pipe";
/** 단면 폴리곤 [offset, elevation][] — 스윕형(벽·집수정). pipe는 없음. */
polygon?: Array<[number, number]>;
/**
* 링마다 다른 단면 폴리곤(길이 = rings.length, 꼭짓점 수는 링끼리 같아야 한다).
* 있으면 `polygon` 대신 쓴다 — 속 빈 구조물과 관 보어를 이 하나로 만든다
* (2026-08-25 사용자: 중공 서피스 셸 + 배관 외측 기준 관통 컷).
* 빈 공간은 폴리곤을 **면적 0으로 눌러** 표현하므로 링 토폴로지가 항상 같다.
*/
polygons?: Array<Array<[number, number]>>;
/** 스윕 프레임 열(누가거리 오름차순) — 폴리곤을 각 프레임에 앉혀 로프트한다. */
rings?: StructureFrame[];
/** 배관 전용 — 관 하단 시·끝점(offset/표고)과 관경(m). */
pipe?: { start: [number, number]; end: [number, number]; diameterM: number };
/** 배관 전용 — 관 하단 시·끝점(offset/표고)과 관경(m), 관벽 두께(m·중공 튜브). */
pipe?: {
start: [number, number];
end: [number, number];
diameterM: number;
wallThicknessM?: number;
};
/** 배관이 앉는 측점 프레임(pipe 전용). */
frame?: StructureFrame;
}
@@ -64,6 +84,30 @@ export interface CorridorStructure {
/** 스윕 프레임 간격(m) — 곡선 구간에서 벽이 노선을 따라 휘는 해상도. */
const SWEEP_STEP_M = 1.0;
/** 관 하단 두 점으로 보어 제원(반지름·중심 표고·유효 구간)을 만든다. */
function pipeBore(
diameterM: number,
start: { offset: number; elevation: number },
end: { offset: number; elevation: number },
): {
radius: number;
centerAt: (offset: number) => number;
minOffset: number;
maxOffset: number;
} {
const radius = diameterM / 2;
const span = end.offset - start.offset;
return {
radius,
centerAt: (offset) => {
const t = Math.abs(span) < 1e-9 ? 0 : (offset - start.offset) / span;
return start.elevation + (end.elevation - start.elevation) * t + radius;
},
minOffset: Math.min(start.offset, end.offset),
maxOffset: Math.max(start.offset, end.offset),
};
}
/** 전/후 몫 확정 — 저장분 없으면 길이 절반씩(패널 기본과 동일). */
function splitOf(spec: CulvertSideSpec | undefined, fallbackLength: number): [number, number] {
const length = spec?.revet_length_m ?? fallbackLength;
@@ -176,7 +220,7 @@ export function buildCorridorStructures(
for (const section of crossSections) {
const layout = culvertLayoutOf(section);
const fordLayout = fordLayoutOf(section);
if (!layout && !fordLayout) continue;
if (!layout && !fordLayout && !section.box) continue;
const chainage = section.chainage_m;
const stationFrame: StructureFrame = {
cx: section.center_x,
@@ -228,11 +272,82 @@ export function buildCorridorStructures(
});
};
/** 링 누가거리 목록으로 프레임을 만든다(보어처럼 촘촘한 간격이 필요할 때). */
const ringsAt = (chainages: number[]): StructureFrame[] =>
chainages.map((at) => {
const frame = frameAt(at);
if (!frame) return stationFrame;
return {
cx: frame.cx,
cy: frame.cy,
leftX: frame.leftX,
leftY: frame.leftY,
dz: baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0,
};
});
/**
* 관이 뚫고 지나는 구조물 — 단면을 위·아래 조각으로 갈라 링마다 관 지름만큼
* 벌린다(2026-08-25 사용자: 배관 외측 서피스 기준 관통 컷). 관이 닿지 않는
* 조각은 그대로 남는다(틈이 폴리곤 밖이면 자르기가 원본을 돌려준다).
*/
const pushPierced = (
kind: "revet" | "basin",
points: Array<{ offset: number; elevation: number }>,
beforeM: number,
afterM: number,
bore: {
radius: number;
centerAt: (offset: number) => number;
minOffset: number;
maxOffset: number;
},
): void => {
if (points.length < 3) return;
const polygon: SectionPolygon = points.map((point) => [point.offset, point.elevation]);
const left = Math.max(...polygon.map(([offset]) => offset));
const right = Math.min(...polygon.map(([offset]) => offset));
// 관 진행 구간과 겹치지 않는 벽(다단 등)은 손대지 않는다.
if (left < bore.minOffset || right > bore.maxOffset) {
pushSwept(kind, points, beforeM, afterM);
return;
}
const chainages = boreRingChainages(
chainage - beforeM,
chainage + afterM,
SWEEP_STEP_M,
chainage,
bore.radius,
);
const rings = ringsAt(chainages);
const center = bore.centerAt((left + right) / 2);
const upper: SectionPolygon[] = [];
const lower: SectionPolygon[] = [];
for (const at of chainages) {
const [top, bottom] = splitByBand(polygon, boreBandAt(at, chainage, bore.radius, center));
upper.push(top);
lower.push(bottom);
}
for (const pieces of [upper, lower]) {
// 어느 링에서든 비면 그 조각은 통째로 버린다 — 링 토폴로지를 맞춰야 한다.
if (pieces.some((piece) => piece.length < 3)) continue;
solids.push({ chainage_m: chainage, kind, polygons: pieces, rings });
}
};
if (section.box) solids.push(...boxSolids(section, frameAt, stationFrame));
if (fordLayout) {
// 구체는 월류 폭만큼 도로 방향으로 이어지고 기준 측점 전후로 절반씩 걸친다.
const half = Math.max(fordLayout.ford.span_m, 0.5) / 2;
const invertPoints = fordLayout.pipe.outer;
const fordBore = pipeBore(
fordLayout.ford.diameter_m,
{ offset: invertPoints[3].offset, elevation: invertPoints[3].elevation },
{ offset: invertPoints[2].offset, elevation: invertPoints[2].elevation },
);
for (const side of fordLayout.sides) {
for (const part of side.parts) pushSwept("basin", part.points, half, half);
for (const part of side.parts) pushPierced("basin", part.points, half, half, fordBore);
}
pushSwept("basin", fordLayout.slabBridge, half, half);
// 관은 련수만큼 폭 안에 등간격으로 놓는다(단면엔 1개만 보이지만 실물은 여러 련).
@@ -248,6 +363,10 @@ export function buildCorridorStructures(
start: [invert[3].offset, invert[3].elevation],
end: [invert[2].offset, invert[2].elevation],
diameterM: fordLayout.ford.diameter_m,
wallThicknessM: pipeWallThicknessM(
fordLayout.ford.pipe_kind,
fordLayout.ford.diameter_m,
),
},
frame: frame
? {
@@ -265,9 +384,10 @@ export function buildCorridorStructures(
const inletSpan = wallSpanOf(section, "inlet");
const outletSpan = wallSpanOf(section, "outlet");
const basinSpan = basinSpanOf(section);
const culvertBore = pipeBore(layout.culvert.diameter_m, layout.pipe.inlet, layout.pipe.outlet);
for (const wall of layout.walls) {
const span = wall.role === "inlet" ? inletSpan : outletSpan;
pushSwept("revet", wall.points, span.beforeM, span.afterM);
pushPierced("revet", wall.points, span.beforeM, span.afterM, culvertBore);
}
// 다단(성토부) 벽은 유출측 연장을, 집수정 계류측 다단은 집수정 연장을 따른다.
for (const wall of layout.extraWalls) {
@@ -292,7 +412,8 @@ export function buildCorridorStructures(
}
}
if (minOffset < maxOffset && minElevation < maxElevation) {
pushSwept(
// 집수정도 관이 뚫고 들어오므로 같은 규칙으로 보어를 낸다(2026-08-25 사용자).
pushPierced(
"basin",
[
{ offset: minOffset, elevation: minElevation },
@@ -302,6 +423,7 @@ export function buildCorridorStructures(
],
basinSpan.beforeM,
basinSpan.afterM,
culvertBore,
);
}
}
@@ -313,6 +435,7 @@ export function buildCorridorStructures(
start: [layout.pipe.inlet.offset, layout.pipe.inlet.elevation],
end: [layout.pipe.outlet.offset, layout.pipe.outlet.elevation],
diameterM: layout.culvert.diameter_m,
wallThicknessM: pipeWallThicknessM(layout.culvert.pipe_kind, layout.culvert.diameter_m),
},
frame: stationFrame,
});
@@ -322,6 +445,20 @@ export function buildCorridorStructures(
/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */
export function structureHashParts(section: CrossSection): Array<string | number> {
const box = section.box;
if (box) {
return [
"bx",
box.inner_width_m,
box.inner_height_m,
box.span_m,
box.cover_m,
box.wing_in.slab_extend_m,
box.wing_in.height_m ?? "",
box.wing_out.slab_extend_m,
box.wing_out.height_m ?? "",
];
}
const ford = section.ford;
if (ford) {
const adjust = section.design?.ford_adjust;
@@ -0,0 +1,252 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Structures_Box.ts
* BOX암거 3D 솔리드와 **관통 보어(구멍)** 계산 — 구조물 본체
* (`_UI_Corridor_Structures.ts`)에서 700줄 제한으로 분리했다(2026-08-25).
*
* 공통 수법(사용자 확정: 중공 서피스 셸 + 배관 외측 기준 관통 컷):
* 구조물을 **위·아래 두 조각**으로 나눠 각각 로프트하고, 두 조각 사이를 링마다
* 벌리면 그 틈이 곧 빈 공간이다. 틈을 0으로 눌러 두면 원래의 속 찬 단면이 된다.
* · BOX 구체 = 측벽 구간은 틈 0(속 참) → 내공 구간만 벌림 ⇒ 측벽이 저절로 생긴다.
* · 관통 보어 = 링(누가거리) y에서 틈 높이 2·√(r²−(y−y₀)²) ⇒ 원형 구멍.
* 링 토폴로지가 항상 같아 스티칭이 단순하고, 눌린 구간은 면적 0이라 겹침이 없다.
* ========================================================================== */
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import { computeBoxLayout } from "../B06_Section/B06_Section_UI_Cross_Box";
import type {
CorridorStructure,
RouteFrame,
StructureFrame,
} from "./B05_Profile_UI_Corridor_Structures";
/** 단면 폴리곤 한 장 — [offset, elevation] 목록. */
export type SectionPolygon = Array<[number, number]>;
/** 링마다의 빈 틈(표고 아래·위). 같으면 그 링은 속이 차 있다. */
export interface VoidBand {
bottom: number;
top: number;
}
/** 조각 폴리곤 꼭짓점 수 — 잘린 조각이 3~5점이 되므로 최대치로 맞춰 둔다. */
const PIECE_VERTICES = 6;
/** 꼭짓점 수를 맞춘다 — 모자라면 마지막 점을 겹쳐 채운다(면적 0 삼각형). */
function padTo(points: SectionPolygon, count: number): SectionPolygon {
if (points.length < 3) return [];
const padded = points.slice(0, count);
while (padded.length < count) padded.push(padded[padded.length - 1]);
return padded;
}
/** 폴리곤을 수평선으로 자른다. `keepAbove`면 위쪽 조각, 아니면 아래쪽 조각. */
export function clipAt(polygon: SectionPolygon, z: number, keepAbove: boolean): SectionPolygon {
const inside = ([, elevation]: [number, number]): boolean =>
keepAbove ? elevation >= z : elevation <= z;
const cut: SectionPolygon = [];
for (let i = 0; i < polygon.length; i += 1) {
const current = polygon[i];
const next = polygon[(i + 1) % polygon.length];
const currentIn = inside(current);
if (currentIn) cut.push(current);
if (currentIn === inside(next)) continue;
const span = next[1] - current[1];
const t = Math.abs(span) < 1e-9 ? 0 : (z - current[1]) / span;
cut.push([current[0] + (next[0] - current[0]) * t, z]);
}
return cut;
}
/**
* 폴리곤을 위·아래 조각으로 나눈다(링마다 다른 틈). 틈이 0이면 경계면이 겹치므로
* 두 조각이 원래 단면을 그대로 채운다.
*/
export function splitByBand(
polygon: SectionPolygon,
band: VoidBand,
): [SectionPolygon, SectionPolygon] {
return [
padTo(clipAt(polygon, band.top, true), PIECE_VERTICES),
padTo(clipAt(polygon, band.bottom, false), PIECE_VERTICES),
];
}
/** 관이 뚫는 구멍 — 링 누가거리에서의 반현(半弦)으로 틈을 만든다. */
export function boreBandAt(
ringChainage: number,
pipeChainage: number,
radius: number,
centerElevation: number,
): VoidBand {
const offset = Math.abs(ringChainage - pipeChainage);
const half = offset >= radius ? 0 : Math.sqrt(radius * radius - offset * offset);
return { bottom: centerElevation - half, top: centerElevation + half };
}
/**
* 보어를 담을 링 누가거리 — 성긴 간격에 관 구간만 촘촘하게 끼워 넣는다.
* 관 지름이 1m 안팎이라 성긴 1m 간격으로는 원형이 사라진다.
*/
export function boreRingChainages(
from: number,
to: number,
coarseStep: number,
pipeChainage: number,
radius: number,
): number[] {
const values = new Set<number>();
const push = (value: number): void => {
if (value >= from - 1e-6 && value <= to + 1e-6) values.add(Number(value.toFixed(3)));
};
push(from);
for (let at = from; at < to; at += coarseStep) push(at);
push(to);
const fine = Math.max(radius / 6, 0.05);
for (let at = pipeChainage - radius; at <= pipeChainage + radius + 1e-6; at += fine) push(at);
push(pipeChainage - radius);
push(pipeChainage + radius);
return [...values].sort((a, b) => a - b);
}
/** BOX암거 3D 솔리드(구체 셸 + 날개벽 4매). 기하가 부족하면 빈 배열. */
export function boxSolids(
section: CrossSection,
frameAt: (chainageM: number) => RouteFrame | null,
stationFrame: StructureFrame,
): CorridorStructure[] {
const box = section.box;
const layout = computeBoxLayout(section, section.samples);
if (!box || !layout) return [];
const chainage = section.chainage_m;
const halfSpan = box.span_m / 2;
const innerHalf = box.inner_width_m / 2;
const barrelTop = layout.topElevation - box.top_thickness_m;
const barrelBottom = barrelTop - box.inner_height_m;
const leftEdge = layout.topSlab[0].offset;
const rightEdge = layout.topSlab[1].offset;
const slabLeft = layout.bottomSlab[0].offset;
const slabRight = layout.bottomSlab[1].offset;
// 측벽 자리는 틈 0(속 참), 내공 구간만 벌린다. 벽면을 수직으로 세우려고 경계
// 누가거리마다 링을 두 장 겹쳐 둔다(길이 0 구간 = 벽 안쪽 면).
const ringPlan: Array<{ at: number; open: boolean }> = [
{ at: chainage - halfSpan, open: false },
{ at: chainage - innerHalf, open: false },
{ at: chainage - innerHalf, open: true },
{ at: chainage + innerHalf, open: true },
{ at: chainage + innerHalf, open: false },
{ at: chainage + halfSpan, open: false },
];
const rings = ringPlan.map(({ at }) => {
const frame = frameAt(at);
return frame
? { cx: frame.cx, cy: frame.cy, leftX: frame.leftX, leftY: frame.leftY, dz: 0 }
: stationFrame;
});
const upper: SectionPolygon[] = [];
const lower: SectionPolygon[] = [];
for (const { open } of ringPlan) {
const band: VoidBand = open
? { bottom: barrelBottom, top: barrelTop }
: { bottom: barrelTop, top: barrelTop };
upper.push([
[leftEdge, layout.topElevation],
[rightEdge, layout.topElevation],
[rightEdge, band.top],
[leftEdge, band.top],
]);
lower.push([
[slabLeft, band.bottom],
[slabRight, band.bottom],
[slabRight, layout.bottomElevation],
[slabLeft, layout.bottomElevation],
]);
}
const solids: CorridorStructure[] = [
{ chainage_m: chainage, kind: "basin", polygons: upper, rings },
{ chainage_m: chainage, kind: "basin", polygons: lower, rings },
];
solids.push(...wingSolids(section, layout, frameAt, stationFrame));
return solids;
}
/**
* 날개벽 4매 — 박스 네 모서리에서 각도만큼 벌어져 나간다(2026-08-25 사용자: 3D에
* 날개벽과 바닥이 있어야 한다). 노선이 아니라 **날개 축**을 따라 프레임을 만들어
* 같은 로프트에 태우고, 높이는 구체 높이에서 짧은쪽 높이로 체감시킨다.
*/
function wingSolids(
section: CrossSection,
layout: NonNullable<ReturnType<typeof computeBoxLayout>>,
frameAt: (chainageM: number) => RouteFrame | null,
stationFrame: StructureFrame,
): CorridorStructure[] {
const box = section.box;
if (!box) return [];
const chainage = section.chainage_m;
const here = frameAt(chainage);
const ahead = frameAt(chainage + 1) ?? here;
if (!here || !ahead) return [];
// 노선 진행 방향 — 앞 측점 프레임과의 차이. 실패하면 좌향의 법선으로 대체한다.
let dirX = ahead.cx - here.cx;
let dirY = ahead.cy - here.cy;
const norm = Math.hypot(dirX, dirY);
if (norm < 1e-6) {
dirX = -here.leftY;
dirY = here.leftX;
} else {
dirX /= norm;
dirY /= norm;
}
const inletOnLeft = (section.uphill_side ?? "left") === "left";
const halfSpan = box.span_m / 2;
const thickness = box.wall_thickness_m;
const solids: CorridorStructure[] = [];
for (const side of ["left", "right"] as const) {
const wing = (side === "left") === inletOnLeft ? box.wing_in : box.wing_out;
if (!wing.installed) continue;
const length = Math.max(wing.length_m ?? 0, 0);
if (length <= 0.05) continue;
const angle = ((wing.angle_deg ?? 45) * Math.PI) / 180;
// 구체 축(= 좌향) 기준으로 벌어진다. 박스 양 끝에서 도로 앞·뒤로 한 장씩.
const axisSign = side === "left" ? 1 : -1;
const edgeOffset = side === "left" ? layout.topSlab[0].offset : layout.topSlab[1].offset;
const height0 = layout.topElevation - layout.bottomElevation;
const height1 = Math.max(wing.height_m ?? height0, 0.3);
for (const along of [1, -1]) {
const startX = here.cx + here.leftX * edgeOffset + dirX * along * halfSpan;
const startY = here.cy + here.leftY * edgeOffset + dirY * along * halfSpan;
// 날개 방향 = 구체 축을 도로 방향으로 각도만큼 튼 단위벡터.
const wingX = here.leftX * axisSign * Math.cos(angle) + dirX * along * Math.sin(angle);
const wingY = here.leftY * axisSign * Math.cos(angle) + dirY * along * Math.sin(angle);
const wingNorm = Math.hypot(wingX, wingY) || 1;
const ux = wingX / wingNorm;
const uy = wingY / wingNorm;
const rings: StructureFrame[] = [];
const polygons: SectionPolygon[] = [];
const steps = 4;
for (let i = 0; i <= steps; i += 1) {
const t = (length * i) / steps;
rings.push({
cx: startX + ux * t,
cy: startY + uy * t,
// 패널 두께 방향 = 날개 축의 법선.
leftX: -uy,
leftY: ux,
dz: 0,
});
const top = layout.bottomElevation + height0 + ((height1 - height0) * i) / steps;
polygons.push([
[-thickness / 2, top],
[thickness / 2, top],
[thickness / 2, layout.bottomElevation],
[-thickness / 2, layout.bottomElevation],
]);
}
solids.push({ chainage_m: chainage, kind: "revet", polygons, rings });
}
}
if (!solids.length && stationFrame) return [];
return solids;
}
+20
View File
@@ -248,6 +248,24 @@ export interface FordSet {
wing_out: FordWingSpec;
}
/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */
export interface BoxSet {
type: "box";
/** 사용자 입력 내공(유로) 폭·높이(m). */
inner_width_m: number;
inner_height_m: number;
/** 부재 두께(m) — 세월교 승계(2026-08-25 사용자 확정). */
wall_thickness_m: number;
slab_thickness_m: number;
top_thickness_m: number;
/** 암거 위 복토(m) — 별표2 "복토 흙 두께 50㎝ 이상" 교차 참조. */
cover_m: number;
/** 도로 진행 방향 길이(m) = 내공 폭 + 측벽 2장. 기준 측점 전후로 절반씩 걸친다. */
span_m: number;
wing_in: FordWingSpec;
wing_out: FordWingSpec;
}
export interface CrossSection extends SectionStation {
samples: SectionSample[];
/** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */
@@ -256,6 +274,8 @@ export interface CrossSection extends SectionStation {
culvert?: CulvertSet;
/** 세월교 구체가 걸치는 측점의 세트 제원(있을 때만). 구체 폭 안이면 여러 측점에 붙는다. */
ford?: FordSet;
/** BOX암거 구체가 걸치는 측점의 세트 제원(있을 때만). */
box?: BoxSet;
}
export interface SectionDetailResponse {
+41 -4
View File
@@ -26,6 +26,7 @@ from typing import Any
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from common_util.common_util_drainage_pipes import (
PIPE_FACILITY_BOX,
PIPE_FACILITY_FORD_BRIDGE,
PIPE_FACILITY_PIPE,
parse_pipe_points,
@@ -68,6 +69,11 @@ FORD_WALL_THICKNESS_M = 0.2
# 월류 폭 기본값(m) — B05 폼이 세월교에 채우는 값과 같다.
FORD_DEFAULT_WIDTH_M = 10.0
# BOX암거 부재 두께(m) — 지식DB에 암거 부재 두께 기준이 없어 세월교 값을 승계한다
# (2026-08-25 사용자 확정). 측벽·상판·저판은 각각 벽 두께·바닥판 두께를 그대로 쓴다.
# ⚠ 교차 참조 ④ 암거 위 복토(m) — 별표2 교량·암거 "복토 시 흙 두께 50㎝ 이상".
BOX_COVER_M = 0.5
def pipe_points_file(project_root: Path) -> Path:
"""프로젝트 저장소 안의 관 지점 정본 경로."""
@@ -248,6 +254,34 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
}
def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""BOX암거 1개소의 세트 제원(구체 + 날개벽 연장).
구체 축은 계류 방향이라 횡단도에는 종단 절단면(상판·내공·저판)이 보인다.
상판 윗면은 노면에서 복토 0.5m 아래이고, 저판은 날개벽 투영만큼 계류 상·하류로
길어진다 — 연장 산식은 세월교와 같다.
"""
defaults = _registry_defaults("box_culvert")
values = dict(options or {})
inner_width = _number(values.get("body_width_m"), _number(defaults.get("body_width_m"), 2.0))
inner_height = _number(values.get("body_height_m"), _number(defaults.get("body_height_m"), 2.0))
wall = FORD_WALL_THICKNESS_M
slab = FORD_SLAB_THICKNESS_M
return {
"type": "box",
"inner_width_m": inner_width or 2.0,
"inner_height_m": inner_height or 2.0,
"wall_thickness_m": wall,
"slab_thickness_m": slab,
"top_thickness_m": slab,
"cover_m": BOX_COVER_M,
# 도로 진행 방향 길이 = 내공 폭 + 측벽 두 장. 이 폭만큼 측점에 걸친다.
"span_m": (inner_width or 2.0) + 2 * wall,
"wing_in": _wing_spec(values, defaults, "in"),
"wing_out": _wing_spec(values, defaults, "out"),
}
def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]:
"""관 지점 정본을 읽어 누가거리별 세트 제원을 돌려준다.
@@ -264,11 +298,13 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]:
return {}
sets: dict[float, dict[str, Any]] = {}
for point in parse_pipe_points(document.get("points")):
# BOX암거·물넘이포장은 그림이 다르다 — 배관세월교만 그린다.
# 물넘이포장은 그림이 다르다 — 배관·세월교·BOX암거만 그린다.
if point.facility == PIPE_FACILITY_PIPE:
spec = _culvert_set(point.options)
elif point.facility == PIPE_FACILITY_FORD_BRIDGE:
spec = _ford_set(point.options)
elif point.facility == PIPE_FACILITY_BOX:
spec = _box_set(point.options)
else:
continue
sets[round(float(point.chainage_m), 2)] = spec
@@ -286,11 +322,12 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
if chainage is None:
continue
for pipe_chainage, spec in sets.items():
# 세월교 구체는 월류 폭만큼 이어지므로 기준 측점 전후 절반까지 같은 단면이다.
# 세월교 구체·BOX암거는 폭만큼 이어지므로 기준 측점 전후 절반까지 같은 단면이다.
reach = _CHAINAGE_TOLERANCE_M + (_number(spec.get("span_m"), 0.0) or 0.0) / 2
if abs(chainage - pipe_chainage) <= reach:
# 세월교는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
section["ford" if spec.get("type") == "ford" else "culvert"] = spec
# 세월교·BOX암거는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
kind = spec.get("type")
section["ford" if kind == "ford" else "box" if kind == "box" else "culvert"] = spec
attached += 1
break
return attached
+93
View File
@@ -0,0 +1,93 @@
/* =============================================================================
* B06_Section_UI_Cross_Box.ts
* BOX암거 측점 횡단 카드의 구체(상판·저판·내공) **오버레이 그리기**.
*
* 기하 계산은 `B06_Section_UI_Cross_Box_Geom.ts`가 맡는다(700줄 제한 분리).
* 여기서는 계산 결과(`BoxLayout`)를 SVG 도형으로 옮기기만 한다 — 치수 결정 금지.
*
* 그리는 순서 = 시공 순서: ① 저판 → ② 상판 → ③ 내공 윤곽·라벨.
* 콘크리트 색은 세월교 바닥판 클래스를 그대로 쓴다.
* ========================================================================== */
import type { BoxLayout } from "./B06_Section_UI_Cross_Box_Geom";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
export { computeBoxLayout } from "./B06_Section_UI_Cross_Box_Geom";
export type { BoxLayout } from "./B06_Section_UI_Cross_Box_Geom";
const SVG_NS = "http://www.w3.org/2000/svg";
/** BOX암거 구체 오버레이 — computeBoxLayout 결과를 그린다. */
export function appendBoxOverlay(
layer: SVGElement,
layout: BoxLayout,
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
): void {
const { box } = layout;
const toXY = (point: OffsetPoint): [number, number] => [
x(point.offset),
toDisplayY(point.elevation),
];
const polygon = (
points: OffsetPoint[],
className: string,
tooltip: string,
): SVGPolygonElement => {
const shape = document.createElementNS(SVG_NS, "polygon");
shape.setAttribute("points", points.map((point) => toXY(point).join(",")).join(" "));
shape.setAttribute("class", className);
const title = document.createElementNS(SVG_NS, "title");
title.textContent = tooltip;
shape.append(title);
return shape;
};
const wingText = (wing: {
installed: boolean;
length_m: number | null;
angle_deg: number | null;
}): string => (wing.installed ? `${wing.length_m ?? 0}${wing.angle_deg ?? 0}°` : "없음");
// ① 저판 — 날개벽 투영만큼 계류 상·하류로 길어진다.
layer.append(
polygon(
layout.bottomSlab,
"b06-chart__ford-slab",
`BOX암거 저판 — 길이 ${layout.slabLengthM.toFixed(2)}m · 두께 ${box.slab_thickness_m.toFixed(2)}m` +
` (날개벽 유입 ${wingText(box.wing_in)} / 유출 ${wingText(box.wing_out)} 투영 연장 반영)` +
(layout.bedGapM > 0.05
? ` · 계류 하상보다 ${layout.bedGapM.toFixed(2)}m 높음`
: layout.bedGapM < -0.05
? ` · 계류 하상보다 ${Math.abs(layout.bedGapM).toFixed(2)}m 낮음(터파기)`
: ""),
),
);
// ② 상판 — 윗면이 노면 아래 복토 두께만큼 내려온다.
layer.append(
polygon(
layout.topSlab,
"b06-chart__ford-slab",
`BOX암거 상판 — 두께 ${box.top_thickness_m.toFixed(2)}m · 윗면이 노면 ${box.cover_m.toFixed(2)}m(복토)`,
),
);
// ③ 내공(유로) — 면은 비우고 윤곽만 점선으로 두른다.
const barrel = polygon(
layout.barrel,
"b06-chart__box-barrel",
`BOX암거 내공(유로) ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}m` +
` · 측벽 두께 ${box.wall_thickness_m.toFixed(2)}m`,
);
layer.append(barrel);
const label = document.createElementNS(SVG_NS, "text");
const [labelX, labelY] = toXY(layout.label.at);
label.setAttribute("x", String(labelX));
label.setAttribute("y", String(labelY));
label.setAttribute("text-anchor", "middle");
label.setAttribute("class", "b06-chart__culvert-label");
label.textContent = layout.label.text;
layer.append(label);
}
@@ -0,0 +1,116 @@
/* =============================================================================
* B06_Section_UI_Cross_Box_Geom.ts
* BOX암거 구체 **기하 계산** — 그리기(`_Cross_Box.ts`)와 분리(700줄 제한).
* 백엔드 `section.box` 제원을 좌표로 옮긴다(치수 결정 금지).
*
* 구성(2026-08-25 사용자 확정):
* · 박스 축 = 계류 방향이라 횡단도에는 **종단 절단면**(상판·내공·저판)이 보인다.
* · 상판 윗면 = **노면 − 복토 0.5m**(별표2 교차 참조), 구체는 수평 설치(교본 3장).
* · 저판은 **날개벽 투영**(길이×cos각)만큼 계류 상·하류로 길어진다 — 세월교와 같은 산식.
* · 내공(유로)은 비워 둔다 — 관 위 성토 채움처럼 면을 칠하지 않는다.
* · 구체가 노면 아래 묻히므로 **계획선은 자르지 않는다** — 노견 밖 성토 비탈이 실제로
* 존재한다(세월교는 측벽이 비탈을 대신해 잘랐다).
* 부재 두께는 세월교 승계(측벽 0.2·상판 0.3·저판 0.3)이며 백엔드가 실어 보낸다.
* ========================================================================== */
import type { BoxSet, CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
export interface BoxLayout {
box: BoxSet;
/** 상판 폴리곤(노견~노견). */
topSlab: OffsetPoint[];
/** 저판 폴리곤 — 날개벽 투영만큼 각 측 연장. */
bottomSlab: OffsetPoint[];
/** 내공(유로) 사각 — 비워 두고 윤곽선·라벨만 쓴다. */
barrel: OffsetPoint[];
/** 상판 윗면·저판 밑면 표고. */
topElevation: number;
bottomElevation: number;
/** 저판 전체 길이(m) — 노폭 + 유입·유출 날개벽 투영. */
slabLengthM: number;
/** 구체 바닥과 계류 하상(원지반 최저)의 고저차(m). +면 구체가 하상 위로 떠 있다. */
bedGapM: number;
label: { at: OffsetPoint; text: string };
}
/** 노면 표고 — 설계선이 있으면 그 곡선, 없으면 노견 직선(세월교와 같은 규칙). */
function roadTopFactory(
section: CrossSection,
left: { offset_m: number; elevation_m: number },
right: { offset_m: number; elevation_m: number },
): (offset: number) => number {
const designAt = designInterpolator(section.design?.design_line);
if (designAt) return designAt;
const span = left.offset_m - right.offset_m;
return (offset: number): number => {
if (Math.abs(span) < 1e-6) return left.elevation_m;
const t = (offset - right.offset_m) / span;
return right.elevation_m + (left.elevation_m - right.elevation_m) * t;
};
}
/** 사각 폴리곤(좌상 → 우상 → 우하 → 좌하). */
function rect(left: number, right: number, top: number, bottom: number): OffsetPoint[] {
return [
{ offset: left, elevation: top },
{ offset: right, elevation: top },
{ offset: right, elevation: bottom },
{ offset: left, elevation: bottom },
];
}
/** BOX암거 구체 기하. 제원·설계선·지반이 부족하면 null. */
export function computeBoxLayout(
section: CrossSection,
groundSamples: SectionSample[],
): BoxLayout | null {
const box = section.box;
if (!box) return null;
const edges = section.design?.road_edges;
if (!edges) return null;
const groundAt = groundInterpolator(groundSamples);
if (!groundAt) return null;
// 좌표 규약: +offset = 좌측. 유입 = 상단측(미상이면 좌측 폴백).
const inletOnLeft = (section.uphill_side ?? "left") === "left";
const roadTopAt = roadTopFactory(section, edges.left, edges.right);
const leftEdge = edges.left.offset_m;
const rightEdge = edges.right.offset_m;
if (!(leftEdge > rightEdge)) return null;
// 구체는 수평 설치(교본 3장) — 노면이 가장 낮은 자리에서도 복토를 확보한다.
const roadLow = Math.min(roadTopAt(leftEdge), roadTopAt(rightEdge), roadTopAt(0));
const topElevation = roadLow - box.cover_m;
const barrelTop = topElevation - box.top_thickness_m;
const barrelBottom = barrelTop - box.inner_height_m;
const bottomElevation = barrelBottom - box.slab_thickness_m;
// 저판 연장 — 유입·유출 날개벽 각도가 만든 투영량(백엔드 계산값).
const leftExtend = Math.max((inletOnLeft ? box.wing_in : box.wing_out).slab_extend_m, 0);
const rightExtend = Math.max((inletOnLeft ? box.wing_out : box.wing_in).slab_extend_m, 0);
const slabLeft = leftEdge + leftExtend;
const slabRight = rightEdge - rightExtend;
// 계류 하상과의 고저차 — 터파기·성토 판정은 후속이라 값만 넘긴다.
let bed = Math.min(groundAt(leftEdge), groundAt(rightEdge));
for (let offset = rightEdge; offset <= leftEdge; offset += 0.25) {
bed = Math.min(bed, groundAt(offset));
}
return {
box,
topSlab: rect(leftEdge, rightEdge, topElevation, barrelTop),
bottomSlab: rect(slabLeft, slabRight, barrelBottom, bottomElevation),
barrel: rect(leftEdge, rightEdge, barrelTop, barrelBottom),
topElevation,
bottomElevation,
slabLengthM: Math.abs(slabLeft - slabRight),
bedGapM: Number((bottomElevation - bed).toFixed(3)),
label: {
at: { offset: (leftEdge + rightEdge) / 2, elevation: (barrelTop + barrelBottom) / 2 },
text: `BOX ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}`,
},
};
}
+12 -15
View File
@@ -24,9 +24,10 @@ import {
type RockBoundaryControl,
} from "./B06_Section_UI_Cross_Design";
import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert";
import { appendBoxOverlay, computeBoxLayout } from "./B06_Section_UI_Cross_Box";
import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford";
import { buildFordPanel } from "./B06_Section_UI_Cross_Ford_Panel";
import { fordPanelDeps } from "./B06_Section_UI_Cross_View_Ford";
import { fordCardState, fordPanelDeps } from "./B06_Section_UI_Cross_View_Ford";
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
import type { FordHighlightSetter, FordWallRole } from "./B06_Section_UI_Cross_Ford";
import { computeCardCulvert, culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire";
@@ -183,8 +184,7 @@ export function createCrossSectionCard(
let showFordPanel = (_visible: boolean): void => {};
let setRevetActive: RevetHighlightSetter = () => undefined;
let showRevetControl: (visible: boolean) => void = () => undefined;
/** 지금 그린 관 길이(m) — 조정창 표기용(배수관 측점 아니면 null). */
let culvertPipeLengthM: number | null = null;
let culvertPipeLengthM: number | null = null; // 관 길이(m) — 조정창 표기용
let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기
/** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */
let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true };
@@ -442,12 +442,10 @@ export function createCrossSectionCard(
culvertLink,
revetLink,
);
// 세월교 구체 기하 — 배수관과 그림이 달라 계산·그리기를 따로 탄다(2026-08-25).
const fordLayout = computeFordLayout(
section,
section.samples,
ford?.adjustFor(section.chainage_m),
);
// 세월교·BOX암거 구체 — 배수관과 그림이 달라 계산·그리기를 따로 탄다(2026-08-25).
const boxLayout = computeBoxLayout(section, section.samples);
const adjust = ford?.adjustFor(section.chainage_m);
const fordLayout = computeFordLayout(section, section.samples, adjust);
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
appendCrossDesignOverlay(
@@ -458,7 +456,7 @@ export function createCrossSectionCard(
drawSamples,
culvertLayout?.designTrim ?? fordLayout?.designTrim ?? undefined,
);
// 암 경계선 지면선(지반선) 복사 + 오프셋계획선 기준 아님에 유의.
// 암 경계선 = 지면선 복사 + 오프셋(계획선 기준 아님).
if (rockBoundary && section.design.geometry_preset === "rock") {
appendRockBoundaryOverlay(
plotLayer,
@@ -468,9 +466,8 @@ export function createCrossSectionCard(
toDisplayY,
);
}
// 배수관 세트 — 조정창이 쓸 카드 상태를 여기서 받아 둔다(2026-08-19).
// 관은 소유 측점 전용이라 링크 카드에서는 길이를 비운다. 나머지 상태는 링크
// 카드도 채운다 — 조정창을 열어 위치·연동을 만져야 한다(2026-08-24 사용자).
// 배수관 세트 — 조정창이 쓸 카드 상태를 받아 둔다(2026-08-19). 관은 소유 측점
// 전용이라 링크 카드에서는 길이를 비우고, 나머지는 링크 카드도 채운다(2026-08-24).
culvertPipeLengthM = isLinkedCulvert ? null : (culvertLayout?.pipe.lengthM ?? null);
culvertInletIsBasin = !!culvertLayout?.basin;
if (culvertLayout) {
@@ -481,6 +478,7 @@ export function createCrossSectionCard(
culvertWallSpecs = state.wallSpecs;
culvertAppliedD = state.appliedD;
}
if (boxLayout) appendBoxOverlay(plotLayer, boxLayout, x, toDisplayY);
if (fordLayout) {
setFordActive = appendFordOverlay(
plotLayer,
@@ -490,8 +488,7 @@ export function createCrossSectionCard(
ford ? toggleFordWall : undefined,
);
if (activeFordWall) setFordActive(activeFordWall);
fordSlabLengthM = fordLayout.slabLengthM;
fordWallHeights = new Map(fordLayout.sides.map((side) => [side.role, side.heightM]));
({ slabLengthM: fordSlabLengthM, heights: fordWallHeights } = fordCardState(fordLayout));
}
if (culvertLayout) {
setRevetActive = appendCulvertOverlay(
+12 -1
View File
@@ -7,7 +7,7 @@
import type { CrossSection } from "./B06_Section_Api_Fetch";
import type { FordPanelDeps, FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
import type { FordWallRole } from "./B06_Section_UI_Cross_Ford";
import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford";
/** 조정창 배선에 필요한 카드 상태. 계산 결과는 그릴 때마다 바뀌므로 함수로 받는다. */
export interface FordPanelContext {
@@ -50,3 +50,14 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps {
close: context.close,
};
}
/** 조정창이 쓸 세월교 카드 상태 — 마지막 기하 결과에서 뽑는다(카드 렌더러 700줄 분리). */
export function fordCardState(layout: FordLayout): {
slabLengthM: number;
heights: Map<FordWallRole, number>;
} {
return {
slabLengthM: layout.slabLengthM,
heights: new Map(layout.sides.map((side) => [side.role, side.heightM])),
};
}
@@ -309,3 +309,12 @@
stroke-width: 1.2;
stroke-linejoin: round;
}
/* BOX암거 내공(유로) — 면은 비우고 윤곽만. 물이 지나는 빈 공간이라 채우지 않는다. */
.b06-chart__box-barrel {
fill: none;
stroke: var(--color-text-secondary);
stroke-width: 1;
stroke-dasharray: 4 3;
stroke-linejoin: round;
}