Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1
This commit is contained in:
@@ -61,6 +61,7 @@ export type {
|
||||
import type { FordPavementSpec } from "./B06_Section_UI_Cross_Ford_Pavement";
|
||||
import type { RevetmentSpec } from "./B06_Section_UI_Cross_Revetment";
|
||||
import type { PavementEntry } from "@util/common_util_structure_pavement";
|
||||
import type { UnderdrainSpec } from "@util/common_util_structure_underdrain";
|
||||
export type { FordPavementSpec, RevetmentSpec };
|
||||
|
||||
export interface SectionContextResponse {
|
||||
@@ -336,6 +337,8 @@ export interface CrossSection extends SectionStation {
|
||||
revetment?: RevetmentSpec;
|
||||
/** 노면공(G군) 포장 — 범위 안 측점마다(88). 두께 · 무늬 · 확폭 켬. */
|
||||
pavement?: PavementEntry[];
|
||||
/** 맹암거 — 기준 측점(288-1). 높이 · 경사 · 측구 없음 · 노면 기울임 · 돌 무늬 형상. */
|
||||
underdrain?: UnderdrainSpec;
|
||||
}
|
||||
|
||||
export interface SectionDetailResponse {
|
||||
|
||||
@@ -360,8 +360,8 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
|
||||
paved: pavedBySpecs(section),
|
||||
// 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원)
|
||||
tilted: Boolean(section.ford_pavement),
|
||||
tiltPct: section.ford_pavement?.slope_pct ?? null,
|
||||
tilted: Boolean(section.ford_pavement || section.underdrain),
|
||||
tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null,
|
||||
standard,
|
||||
berm: bermAt(section.chainage_m, design),
|
||||
rockBoundaryOffsetM,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""구조물 자리 기울기 — 물넘이포장 · 맹암거 측점의 노면(287-4 · 288-1).
|
||||
|
||||
그 자리 횡단 설계 = 측구 없음 · 노면 = 한 경사로 지대 낮은 쪽(`compute_cross_design(tilted=…,
|
||||
tilt_pct=…)`). 경사 = 물넘이 경사각도(%) · 맹암거 암거 경사(%) · 비면 표준 횡단경사.
|
||||
· 물넘이포장 = 관 지점 정본(pipe_points) 범위(기준 ± 월류 폭/2).
|
||||
· 맹암거 = 구조물 정본(structures.json) A군 점형 · 기준 측점 한 곳 — 횡단 dict 에
|
||||
`underdrain`(높이 · 경사)을 얹어 횡단도가 돌 무늬 형상을 그림(TS 짝
|
||||
`common_util_structure_underdrain.ts`).
|
||||
판정 폭 = 포장 범위와 같은 0.02 m(`paved_at`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNDERDRAIN_TYPE_ID = "cross_underdrain"
|
||||
_EDGE_TOLERANCE_M = 0.02
|
||||
|
||||
TiltRange = tuple[float, float, float | None]
|
||||
|
||||
|
||||
def _number(value: Any) -> float | None:
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return parsed if parsed == parsed else None
|
||||
|
||||
|
||||
def load_underdrains(project_root: Path) -> list[dict[str, Any]]:
|
||||
"""구조물 정본의 맹암거 — `{structure_id, chainage_m, height_m, slope_pct}` ·
|
||||
실패하면 빈 목록."""
|
||||
try:
|
||||
structures = load_structures(str(project_root))[1]
|
||||
except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다
|
||||
logger.exception("B06 맹암거를 읽지 못했습니다 (없음으로 본다)")
|
||||
return []
|
||||
found: list[dict[str, Any]] = []
|
||||
for structure in structures:
|
||||
if structure.type_id != UNDERDRAIN_TYPE_ID:
|
||||
continue
|
||||
options = structure.options or {}
|
||||
height = _number(options.get("culvert_height_m"))
|
||||
slope = _number(options.get("culvert_slope_pct"))
|
||||
found.append(
|
||||
{
|
||||
"structure_id": structure.structure_id,
|
||||
"chainage_m": float(structure.anchor_m()),
|
||||
"height_m": height if height and height > 0 else None,
|
||||
"slope_pct": slope if slope and slope > 0 else None,
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def tilt_ranges(project_root: Path | None) -> list[TiltRange]:
|
||||
"""측구 없이 노면을 지대 낮은 쪽 한 경사로 기울이는 자리 — (시작, 끝, 경사 %)."""
|
||||
if project_root is None:
|
||||
return []
|
||||
out: list[TiltRange] = []
|
||||
for at, spec in load_culvert_sets(project_root).items():
|
||||
if spec.get("type") == "ford_pavement":
|
||||
half = float(spec.get("span_m") or 0.0) / 2.0
|
||||
out.append((at - half, at + half, spec.get("slope_pct")))
|
||||
for drain in load_underdrains(project_root):
|
||||
out.append((drain["chainage_m"], drain["chainage_m"], drain["slope_pct"]))
|
||||
return out
|
||||
|
||||
|
||||
def tilt_args(chainage_m: float, ranges: list[TiltRange]) -> dict[str, Any]:
|
||||
"""그 측점의 구조물 자리 기울기 — `compute_cross_design(**...)` 로 넘김 ·
|
||||
저장분 짝 `stored_tilt`."""
|
||||
tol = _EDGE_TOLERANCE_M
|
||||
hit = next((r for r in ranges if r[0] - tol <= chainage_m <= r[1] + tol), None)
|
||||
pct = hit[2] if hit else None
|
||||
return {"tilted": hit is not None, "tilt_pct": float(pct) if pct is not None else None}
|
||||
|
||||
|
||||
def attach_underdrains(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
|
||||
"""맹암거 기준 측점의 횡단 dict 에 `underdrain` 을 얹는다. 얹은 개수를 돌려준다."""
|
||||
drains = load_underdrains(project_root)
|
||||
attached = 0
|
||||
for section in cross_sections:
|
||||
chainage = _number(section.get("chainage_m"))
|
||||
if chainage is None:
|
||||
continue
|
||||
hit = next(
|
||||
(d for d in drains if abs(d["chainage_m"] - chainage) <= _EDGE_TOLERANCE_M), None
|
||||
)
|
||||
if hit:
|
||||
section["underdrain"] = dict(hit)
|
||||
attached += 1
|
||||
return attached
|
||||
@@ -26,6 +26,7 @@ from B06_Section.B06_Section_Engine_Design import (
|
||||
stored_shoulder_extend,
|
||||
)
|
||||
from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_structures
|
||||
from B06_Section.B06_Section_Engine_Tilt import attach_underdrains, tilt_args, tilt_ranges
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
count_cross_sections,
|
||||
create_longitudinal_section,
|
||||
@@ -51,8 +52,6 @@ from B06_Section.B06_Section_Router_Design import (
|
||||
pavement_ranges,
|
||||
stored_berm,
|
||||
stored_cut_slope,
|
||||
tilt_args,
|
||||
tilt_ranges,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
attach_default_designs as _attach_default_designs,
|
||||
@@ -287,6 +286,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
|
||||
# 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정).
|
||||
attach_wall_structures(root, cross_sections)
|
||||
attach_pavement_structures(root, cross_sections) # 노면공 형상(88)
|
||||
attach_underdrains(root, cross_sections) # 맹암거 형상(288-1)
|
||||
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
|
||||
|
||||
|
||||
@@ -450,6 +450,7 @@ async def regenerate_sections(
|
||||
attach_culvert_sets(project_root, result["cross_sections"])
|
||||
attach_wall_structures(project_root, result["cross_sections"])
|
||||
attach_pavement_structures(project_root, result["cross_sections"])
|
||||
attach_underdrains(project_root, result["cross_sections"])
|
||||
return SectionDetailResponse(
|
||||
longitudinal=result["longitudinal"], cross_sections=result["cross_sections"]
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ from B06_Section.B06_Section_Engine_Design import (
|
||||
stored_shoulder_extend,
|
||||
stored_tilt,
|
||||
)
|
||||
from B06_Section.B06_Section_Engine_Tilt import tilt_args, tilt_ranges
|
||||
from common_util.common_util_cross_berm import (
|
||||
BERM_DEFAULT_DEPTH_M,
|
||||
BERM_DEFAULT_INTERVAL_M,
|
||||
@@ -94,27 +95,6 @@ def ford_pavement_ranges(project_root: Path | None) -> list[tuple[float, float]]
|
||||
return [(at - half, at + half) for at, half in halves]
|
||||
|
||||
|
||||
def tilt_ranges(project_root: Path | None) -> list[tuple[float, float, float | None]]:
|
||||
"""측구 없이 노면을 지대 낮은 쪽 한 경사로 기울이는 자리(287-4) — 물넘이포장 범위 ·
|
||||
경사 = 경사각도(%)."""
|
||||
if project_root is None:
|
||||
return []
|
||||
out: list[tuple[float, float, float | None]] = []
|
||||
for at, spec in load_culvert_sets(project_root).items():
|
||||
if spec.get("type") == "ford_pavement":
|
||||
half = float(spec.get("span_m") or 0.0) / 2.0
|
||||
out.append((at - half, at + half, spec.get("slope_pct")))
|
||||
return out
|
||||
|
||||
|
||||
def tilt_args(chainage_m: float, ranges: list[tuple[float, float, float | None]]) -> dict[str, Any]:
|
||||
"""그 측점의 구조물 자리 기울기 — `compute_cross_design(**...)` 로 넘김 ·
|
||||
저장분 짝 `stored_tilt`."""
|
||||
hit = next((r for r in ranges if paved_at(chainage_m, [(r[0], r[1])])), None)
|
||||
pct = hit[2] if hit else None
|
||||
return {"tilted": hit is not None, "tilt_pct": float(pct) if pct is not None else None}
|
||||
|
||||
|
||||
def ford_surface_drops(project_root: Path | None) -> list[tuple[float, float]]:
|
||||
"""세월교가 앉은 측점의 노면 하강량 — [(누가거리, 월류 높이 m)].
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@ import {
|
||||
type PavementStructureInput,
|
||||
} from "@util/common_util_structure_pavement";
|
||||
import { setGeneralStations } from "../common_util/common_util_structure_station";
|
||||
import {
|
||||
attachUnderdrainSpecs,
|
||||
underdrainSpecsFrom,
|
||||
type UnderdrainInput,
|
||||
} from "../common_util/common_util_structure_underdrain";
|
||||
import { forgetDraftStations, withDraftStations } from "./B06_Section_Section_Drafts";
|
||||
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
|
||||
import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
@@ -74,6 +79,11 @@ async function withDraftWalls(
|
||||
// 횡단도에 아무것도 안 보이던 결함의 원인이다(창 둘에서 같은 증상, 실측 확인).
|
||||
await withDraftPipeKinds(detail, projectId);
|
||||
if (!drafts || !drafts.length) return withStructureAreas(detail);
|
||||
// 맹암거(288-1) — 초안 기준 측점으로 제원을 다시 얹음(짝 `common_util_structure_underdrain`)
|
||||
attachUnderdrainSpecs(
|
||||
detail.cross_sections as unknown as Array<Record<string, unknown>>,
|
||||
underdrainSpecsFrom(drafts as unknown as UnderdrainInput[]),
|
||||
);
|
||||
const types = await fetchStructureTypes().catch(() => []);
|
||||
// 노면공(88) — 초안 범위로 포장 제원을 다시 얹음(짝 `common_util_structure_pavement`)
|
||||
const paveTypes = new Set(types.filter((type) => type.group === "G").map((type) => type.type_id));
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
* ========================================================================== */
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** 관 지점이 아닌 구조물(맹암거) 형상 누름 — 카드 밖(구조물 목록)이 id 로 고름(288-2). */
|
||||
export const SHAPE_PICK_EVENT = "b06-structure-shape-pick";
|
||||
/** 끌기로 볼 움직임(px) — 이보다 크면 고름 아님. */
|
||||
export const SHAPE_DRAG_PX = 3;
|
||||
|
||||
@@ -49,6 +51,15 @@ export function attachShapePick(
|
||||
return (active) => shapes.forEach((shape) => shape.classList.toggle("is-active", active));
|
||||
}
|
||||
|
||||
/** 형상 누름 창구(287-5 · 288-2) — 관 지점(`pipe-…` · 물넘이포장) = 그 카드 시설 고름 · 그 밖(맹암거) = id 를
|
||||
* 실은 알림을 위로 띄움(구조물 목록이 받음). */
|
||||
export function shapePickRoute(from: Element, pickPipe: () => void): (structureId: string) => void {
|
||||
return (structureId) => {
|
||||
if (structureId.startsWith("pipe-")) return pickPipe();
|
||||
from.dispatchEvent(new CustomEvent(SHAPE_PICK_EVENT, { bubbles: true, detail: structureId }));
|
||||
};
|
||||
}
|
||||
|
||||
/** 카드 안 형상 강조 — 고른 구조물 id 와 같은 형상만(목록 · 알약 · 폼 고름이 따라옴). */
|
||||
export function highlightShapes(root: ParentNode, structureId: string | null): void {
|
||||
for (const shape of root.querySelectorAll<SVGElement>("[data-structure-id]")) {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Underdrain.ts
|
||||
* 맹암거 횡단 형상(288-1 · 요구 183-3).
|
||||
*
|
||||
* · 노면 = 설계 노면 — 서버 · TS 짝이 암거 경사로 지대 낮은 쪽 한 경사로 기울여 냄 · 측구 없음.
|
||||
* · 옵셋선 = 노면을 암거 높이만큼 아래로 같은 각도.
|
||||
* · 성토 쪽 = 옵셋선을 성토 사면과 만날 때까지 · 절토 쪽 = 절토 사면을 아래로 늘여 옵셋선과 만남.
|
||||
* · 노면 · 양쪽 선 · 옵셋선이 둘러싼 안 = 돌 무늬(흙막이 돌 그리기 `drawStones`).
|
||||
* 높이가 비면 그리지 않음(수치를 지어내지 않음).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign } from "./B06_Section_Api_Fetch";
|
||||
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { drawStones } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
import type { UnderdrainSpec } from "@util/common_util_structure_underdrain";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
let clipSeq = 0;
|
||||
|
||||
type Edge = { offset_m: number; elevation_m: number };
|
||||
|
||||
/** 한쪽 둘레(노면 끝 → 옵셋선과 만나는 자리) — `outward` = 바깥 부호(+1 좌 · −1 우). */
|
||||
function sidePath(
|
||||
edge: Edge,
|
||||
outward: 1 | -1,
|
||||
line: readonly Edge[],
|
||||
roadGrade: number,
|
||||
heightM: number,
|
||||
): OffsetPoint[] {
|
||||
const start = { offset: edge.offset_m, elevation: edge.elevation_m };
|
||||
const offZ = (o: number) => edge.elevation_m + roadGrade * (o - edge.offset_m) - heightM;
|
||||
const outside = line
|
||||
.filter((p) => (p.offset_m - edge.offset_m) * outward > 1e-6)
|
||||
.sort((a, b) => (a.offset_m - b.offset_m) * outward);
|
||||
const first = outside[0];
|
||||
if (!first) return [start, { offset: start.offset, elevation: offZ(start.offset) }];
|
||||
if (first.elevation_m >= edge.elevation_m) {
|
||||
// 절토 — 사면을 안쪽 아래로 늘여 옵셋선과 만남
|
||||
const k = (first.elevation_m - edge.elevation_m) / (first.offset_m - edge.offset_m);
|
||||
const d = Math.abs(k - roadGrade) < 1e-9 ? 0 : -heightM / (k - roadGrade);
|
||||
const offset = edge.offset_m + d;
|
||||
return [start, { offset, elevation: offZ(offset) }];
|
||||
}
|
||||
// 성토 — 사면을 따라 내려가다 옵셋선을 만나는 자리
|
||||
const path: OffsetPoint[] = [start];
|
||||
let prev = edge;
|
||||
for (const point of outside) {
|
||||
const fa = prev.elevation_m - offZ(prev.offset_m);
|
||||
const fb = point.elevation_m - offZ(point.offset_m);
|
||||
if (fb <= 0) {
|
||||
const t = fa / (fa - fb);
|
||||
const offset = prev.offset_m + (point.offset_m - prev.offset_m) * t;
|
||||
path.push({ offset, elevation: offZ(offset) });
|
||||
return path;
|
||||
}
|
||||
path.push({ offset: point.offset_m, elevation: point.elevation_m });
|
||||
prev = point;
|
||||
}
|
||||
path.push({ offset: prev.offset_m, elevation: offZ(prev.offset_m) });
|
||||
return path;
|
||||
}
|
||||
|
||||
/** 맹암거 형상 둘레 — 좌 노면 끝 → 우 노면 끝 → 우 둘레 → 옵셋선 → 좌 둘레(위로). 높이 없으면 null. */
|
||||
export function underdrainOutline(
|
||||
design: CrossDesign,
|
||||
heightM: number | null,
|
||||
): OffsetPoint[] | null {
|
||||
const edges = design.road_edges;
|
||||
if (!edges || !(heightM && heightM > 0)) return null;
|
||||
const { left, right } = edges;
|
||||
const span = left.offset_m - right.offset_m;
|
||||
if (!(Math.abs(span) > 1e-9)) return null;
|
||||
const grade = (left.elevation_m - right.elevation_m) / span;
|
||||
const line = design.design_line ?? [];
|
||||
const leftPath = sidePath(left, 1, line, grade, heightM);
|
||||
const rightPath = sidePath(right, -1, line, grade, heightM);
|
||||
return [
|
||||
{ offset: left.offset_m, elevation: left.elevation_m },
|
||||
...rightPath,
|
||||
...leftPath.slice(1).reverse(),
|
||||
];
|
||||
}
|
||||
|
||||
/** 맹암거 형상을 그린다(돌 무늬 · 둘레 클립). 그렸으면 그 무리 — 둘레에 고름 표식(`data-structure-id` · 288-2). */
|
||||
export function appendUnderdrainOverlay(
|
||||
svg: SVGElement,
|
||||
spec: UnderdrainSpec | undefined,
|
||||
design: CrossDesign,
|
||||
x: (offset: number) => number,
|
||||
y: (elevation: number) => number,
|
||||
): SVGGElement | null {
|
||||
const outline = spec ? underdrainOutline(design, spec.height_m) : null;
|
||||
if (!spec || !outline) return null;
|
||||
const px = (p: OffsetPoint): [number, number] => [x(p.offset), y(p.elevation)];
|
||||
const points = outline.map((p) => px(p).join(",")).join(" ");
|
||||
const group = document.createElementNS(SVG_NS, "g") as SVGGElement;
|
||||
group.setAttribute("class", "b06-chart__underdrain");
|
||||
const body = document.createElementNS(SVG_NS, "polygon");
|
||||
body.setAttribute("points", points);
|
||||
body.setAttribute("class", "b06-chart__underdrain-body");
|
||||
if (spec.structure_id) body.setAttribute("data-structure-id", spec.structure_id);
|
||||
clipSeq += 1;
|
||||
const clipId = `b06-underdrain-clip-${clipSeq}`;
|
||||
const clip = document.createElementNS(SVG_NS, "clipPath");
|
||||
clip.setAttribute("id", clipId);
|
||||
const clipShape = document.createElementNS(SVG_NS, "polygon");
|
||||
clipShape.setAttribute("points", points);
|
||||
clip.append(clipShape);
|
||||
const stones = document.createElementNS(SVG_NS, "g");
|
||||
stones.setAttribute("clip-path", `url(#${clipId})`);
|
||||
const height = spec.height_m!;
|
||||
const offsets = outline.map((p) => p.offset);
|
||||
const [lo, hi] = [Math.min(...offsets), Math.max(...offsets)];
|
||||
const { left, right } = design.road_edges;
|
||||
const grade = (left.elevation_m - right.elevation_m) / (left.offset_m - right.offset_m);
|
||||
const roadZ = (o: number) => left.elevation_m + grade * (o - left.offset_m);
|
||||
const at = (u: number, v: number): OffsetPoint => {
|
||||
const offset = lo + (hi - lo) * u;
|
||||
return { offset, elevation: roadZ(offset) - height * (v + 0.5) };
|
||||
};
|
||||
const [ax, ay] = px(at(0, 0));
|
||||
const [bx, by] = px(at(1, 0));
|
||||
drawStones(
|
||||
stones,
|
||||
{
|
||||
spanM: Math.hypot(hi - lo, grade * (hi - lo)),
|
||||
pixelsPerMeter: Math.abs(x(1) - x(0)) || 1,
|
||||
point: (u, v) => px(at(u, v)),
|
||||
leanDegrees: (Math.atan2(bx - ax, -(by - ay)) * 180) / Math.PI,
|
||||
},
|
||||
false,
|
||||
height,
|
||||
);
|
||||
group.append(body, clip, stones);
|
||||
svg.append(group);
|
||||
return group;
|
||||
}
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
appendFordSurfaceDropPlan,
|
||||
} from "./B06_Section_UI_Cross_Ford_Pavement";
|
||||
import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { attachShapePick } from "./B06_Section_UI_Cross_Shape_Pick";
|
||||
import { attachShapePick, shapePickRoute } from "./B06_Section_UI_Cross_Shape_Pick";
|
||||
import { appendUnderdrainOverlay } from "./B06_Section_UI_Cross_Underdrain";
|
||||
import { raiseWalls } from "./B06_Section_UI_Cross_Wall";
|
||||
import { attachChuteBandPick } from "./B06_Section_UI_Cross_Wall_Hatch";
|
||||
import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill";
|
||||
@@ -454,6 +455,8 @@ export function createCrossSectionCard(
|
||||
);
|
||||
if (!fordPaved)
|
||||
appendPavementOverlay(plotLayer, section.design, x, toDisplayY, section.pavement);
|
||||
// 맹암거(288-1) — 노면 아래 암거 높이만큼 돌 무늬 형상
|
||||
appendUnderdrainOverlay(plotLayer, section.underdrain, section.design, x, toDisplayY);
|
||||
// 세월교 측점 — 계획고는 이미 월류 높이만큼 내려와 있다. "월류가 없었다면"
|
||||
// 노면을 점선으로 남겨 얼마나 내려앉았는지 읽히게 한다(2026-08-30 사용자).
|
||||
appendFordSurfaceDropPlan(plotLayer, section.design, x, toDisplayY, fordLayout);
|
||||
@@ -576,7 +579,10 @@ export function createCrossSectionCard(
|
||||
setChuteActive(highlightRevet === "chute");
|
||||
}
|
||||
// 물넘이포장 · 맹암거 형상 고름(287-5 · 288-2) — 다른 시설과 같은 창구 · 보라 손잡이 아래 · 누름만
|
||||
const setShapeActive = attachShapePick(plotLayer, revetOffset && (() => toggleRevet("pipe")));
|
||||
const setShapeActive = attachShapePick(
|
||||
plotLayer,
|
||||
revetOffset && shapePickRoute(svg, () => toggleRevet("pipe")),
|
||||
);
|
||||
if (setShapeActive) {
|
||||
const baseSetActive = setRevetActive;
|
||||
setRevetActive = (key) => {
|
||||
|
||||
@@ -69,7 +69,7 @@ import {
|
||||
import type { SectionViewController } from "./B06_Section_UI_Section_View_Types";
|
||||
import { createFillSlopeNotice } from "./B06_Section_UI_Cross_FillSlope_Notice";
|
||||
import { highlightPavement, pavementAt } from "./B06_Section_UI_Cross_Design_Surface";
|
||||
import { highlightShapes } from "./B06_Section_UI_Cross_Shape_Pick";
|
||||
import { highlightShapes, SHAPE_PICK_EVENT } from "./B06_Section_UI_Cross_Shape_Pick";
|
||||
import { createSectionProfile } from "./B06_Section_UI_Section_View_Profile";
|
||||
import type { DesignPageShared } from "../A00_Common/design/design_shared";
|
||||
|
||||
@@ -440,6 +440,11 @@ export function createSectionView(
|
||||
// 렌더 행과 달라져 카드 내부가 뒤죽박죽된다(N-2-6). 열 수 · 폭을 JS가 명시해 일치시킨다.
|
||||
grid.style.gridTemplateColumns = `repeat(${columnCount}, ${cachedCardWidth}px)`;
|
||||
grid.dataset.columns = String(columnCount); // 4열부터 면적표 「계」만(173-3 · CSS)
|
||||
// 맹암거 형상 누름 = 그 구조물 id 고름(288-2 · 노면공 포장 범위 표식과 따로 · 같은 id 고름 창구)
|
||||
grid.addEventListener(SHAPE_PICK_EVENT, (event) => {
|
||||
const id = (event as CustomEvent<string>).detail;
|
||||
if (id) pavementSelectListener?.(id);
|
||||
});
|
||||
cachedRowHeight.clear();
|
||||
if (detail.cross_sections.length) {
|
||||
// 1차: 각 카드의 자연 높이(250px 바닥 적용) 측정 → 같은 행(columnCount 단위) 최댓값을 행 높이로.
|
||||
|
||||
@@ -267,6 +267,18 @@
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/* 맹암거(288-1): 노면 아래 돌 무늬 형상 */
|
||||
.b06-chart__underdrain-body {
|
||||
fill: color-mix(in srgb, #3aa0a8 16%, transparent);
|
||||
stroke: #3aa0a8;
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.b06-chart__underdrain-body.is-active {
|
||||
fill: color-mix(in srgb, #3aa0a8 34%, transparent);
|
||||
stroke-width: 2.2;
|
||||
}
|
||||
|
||||
/* 배수관 세트(2026-08-19): 배관·기슭막이·돌붙임 — 분리 도형 3종 + 집수정 라벨 */
|
||||
/* 관은 실무 도면처럼 점선 윤곽 2줄 느낌 — 채움은 식별용으로만 아주 옅게. */
|
||||
.b06-chart__culvert-pipe {
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 { appendUnderdrainOverlay } from "../B06_Section/B06_Section_UI_Cross_Underdrain";
|
||||
import {
|
||||
appendCrossDesignOverlay,
|
||||
appendPavementOverlay,
|
||||
@@ -487,6 +488,7 @@ function drawDesign(
|
||||
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);
|
||||
appendUnderdrainOverlay(svg, section.underdrain, design, x, y); // 맹암거(288-1)
|
||||
appendCrossDesignOverlay(
|
||||
svg,
|
||||
design,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/* =============================================================================
|
||||
* common_util/common_util_structure_underdrain.ts
|
||||
* 맹암거(cross_underdrain · A군 점형) 제원을 횡단 측점에 얹는다(288-1).
|
||||
*
|
||||
* 기준 측점(0.02 m 안) 횡단 dict 에 `underdrain` = 높이 · 경사 — 횡단도가 돌 무늬 형상을 그리고,
|
||||
* 설계 계산은 그 측점을 측구 없이 지대 낮은 쪽 한 경사로 기울임(`tilted`).
|
||||
* 파이썬 짝 = `B06_Section/B06_Section_Engine_Tilt.py`(`load_underdrains` · `attach_underdrains`).
|
||||
* ========================================================================== */
|
||||
|
||||
export const UNDERDRAIN_TYPE_ID = "cross_underdrain";
|
||||
const EDGE_TOLERANCE_M = 0.02;
|
||||
|
||||
/** 맹암거 제원 — 높이 · 경사가 비면 null(높이 없으면 형상 없음 · 경사 없으면 표준 횡단경사). */
|
||||
export interface UnderdrainSpec {
|
||||
structure_id: string | null;
|
||||
chainage_m: number;
|
||||
height_m: number | null;
|
||||
slope_pct: number | null;
|
||||
}
|
||||
|
||||
export interface UnderdrainInput {
|
||||
structure_id?: string | null;
|
||||
type_id: string;
|
||||
chainage_m?: number | null;
|
||||
start_m?: number | null;
|
||||
options?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const positive = (value: unknown): number | null => {
|
||||
const parsed = value === "" || value === null || value === undefined ? NaN : Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
};
|
||||
|
||||
/** 구조물 목록의 맹암거 제원(파이썬 `load_underdrains` 짝). */
|
||||
export function underdrainSpecsFrom(structures: readonly UnderdrainInput[]): UnderdrainSpec[] {
|
||||
return structures.flatMap((structure) => {
|
||||
const at = structure.chainage_m ?? structure.start_m;
|
||||
if (structure.type_id !== UNDERDRAIN_TYPE_ID || typeof at !== "number") return [];
|
||||
const options = structure.options ?? {};
|
||||
return [
|
||||
{
|
||||
structure_id: structure.structure_id ?? null,
|
||||
chainage_m: at,
|
||||
height_m: positive(options.culvert_height_m),
|
||||
slope_pct: positive(options.culvert_slope_pct),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** 기준 측점 횡단 dict 에 `underdrain` 을 얹는다(옛 값은 걷음) — 얹은 개수(파이썬 `attach_underdrains` 짝). */
|
||||
export function attachUnderdrainSpecs(
|
||||
sections: Array<Record<string, unknown>>,
|
||||
specs: readonly UnderdrainSpec[],
|
||||
): number {
|
||||
let attached = 0;
|
||||
for (const section of sections) {
|
||||
delete section.underdrain;
|
||||
const chainage = Number(section.chainage_m);
|
||||
const hit = specs.find((spec) => Math.abs(spec.chainage_m - chainage) <= EDGE_TOLERANCE_M);
|
||||
if (!hit) continue;
|
||||
section.underdrain = { ...hit };
|
||||
attached += 1;
|
||||
}
|
||||
return attached;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/* 288 맹암거 횡단 헬퍼 — 진짜 TS 를 Node 로 돌려 표준출력 JSON 한 벌로.
|
||||
* 입력 = argv[2] JSON 파일 { structures, sections }.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
const load = (file) => {
|
||||
if (cache.has(file)) return cache.get(file);
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(file, box.exports);
|
||||
const near = (m) => {
|
||||
if (m.startsWith("@config/")) return load(path.join(ROOT, "config", `${m.slice(8)}.ts`));
|
||||
if (m.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${m.slice(6)}.ts`));
|
||||
if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`));
|
||||
return new Proxy({}, { get: () => () => {} });
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, near);
|
||||
return box.exports;
|
||||
};
|
||||
|
||||
class Node {
|
||||
constructor(tag) {
|
||||
this.tag = tag;
|
||||
this.attrs = {};
|
||||
this.children = [];
|
||||
}
|
||||
setAttribute(k, v) {
|
||||
this.attrs[k] = String(v);
|
||||
}
|
||||
getAttribute(k) {
|
||||
return this.attrs[k] ?? null;
|
||||
}
|
||||
append(...kids) {
|
||||
this.children.push(...kids);
|
||||
}
|
||||
}
|
||||
globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) };
|
||||
|
||||
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const util = load(path.join(ROOT, "common_util", "common_util_structure_underdrain.ts"));
|
||||
const draw = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Underdrain.ts"));
|
||||
|
||||
const specs = util.underdrainSpecsFrom(input.structures);
|
||||
const sections = input.sections.map((s) => ({ ...s }));
|
||||
const attached = util.attachUnderdrainSpecs(sections, specs);
|
||||
|
||||
// 지대 낮은 쪽(우)으로 5 % 기울인 노면 · 좌 = 절토(오름) · 우 = 성토(내림) · 암거 높이 1 m
|
||||
const design = {
|
||||
road_edges: {
|
||||
left: { offset_m: 3, elevation_m: 100.15 },
|
||||
right: { offset_m: -3, elevation_m: 99.85 },
|
||||
},
|
||||
design_line: [
|
||||
{ offset_m: 6, elevation_m: 102.9 },
|
||||
{ offset_m: 3, elevation_m: 100.15 },
|
||||
{ offset_m: -3, elevation_m: 99.85 },
|
||||
{ offset_m: -7, elevation_m: 97.2 },
|
||||
],
|
||||
};
|
||||
const svg = new Node("g");
|
||||
const group = draw.appendUnderdrainOverlay(
|
||||
svg,
|
||||
{ structure_id: "ud-1", chainage_m: 40, height_m: 1, slope_pct: 5 },
|
||||
design,
|
||||
(o) => o,
|
||||
(e) => e,
|
||||
);
|
||||
const body = group?.children.find((k) => k.attrs.class === "b06-chart__underdrain-body");
|
||||
|
||||
// 288-2 형상 누름 창구 — 물넘이(pipe-…) = 시설 고름 · 맹암거 = id 알림(목록이 고름)
|
||||
globalThis.CustomEvent = class {
|
||||
constructor(type, init) {
|
||||
this.type = type;
|
||||
this.detail = init?.detail;
|
||||
this.bubbles = init?.bubbles;
|
||||
}
|
||||
};
|
||||
const shapePick = load(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts"));
|
||||
const routed = [];
|
||||
const from = { dispatchEvent: (event) => routed.push([event.type, event.detail, event.bubbles]) };
|
||||
const route = shapePick.shapePickRoute(from, () => routed.push(["pipe"]));
|
||||
route("pipe-230.00");
|
||||
route("ud-1");
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
specs,
|
||||
attached,
|
||||
sections: sections.map((s) => s.underdrain ?? null),
|
||||
outline: draw.underdrainOutline(design, 1),
|
||||
noHeight: draw.underdrainOutline(design, null),
|
||||
drew: !!group,
|
||||
bodyId: body?.attrs["data-structure-id"] ?? null,
|
||||
routed,
|
||||
eventName: shapePick.SHAPE_PICK_EVENT,
|
||||
stones: group?.children.filter((k) => k.tag === "g").flatMap((g) => g.children).length ?? 0,
|
||||
}),
|
||||
);
|
||||
@@ -138,7 +138,7 @@ def test_저장_뒤_서버가_물넘이_측구를_맞춤():
|
||||
router = _read("B06_Section", "B06_Section_Router_Design.py")
|
||||
assert "stored_tilt(design) == tilt" in router
|
||||
refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts")
|
||||
assert "tilted: Boolean(section.ford_pavement)," in refresh
|
||||
assert "tilted: Boolean(section.ford_pavement || section.underdrain)," in refresh
|
||||
|
||||
|
||||
CHUTE = {"ford_chute_1_structure": "돌붙임", "ford_chute_1_thickness": 30}
|
||||
|
||||
@@ -121,8 +121,8 @@ def test_형상_강조와_표식(ts):
|
||||
def test_보라_손잡이는_계획고만_형상_누름은_고름만():
|
||||
view = _read("B06_Section", "B06_Section_UI_Cross_View.ts")
|
||||
# 형상 누름 = 시설 고름 창구만(설계 · 계획고를 안 건드림) · 보라 선 고름보다 먼저 꽂음(손잡이가 위)
|
||||
assert 'attachShapePick(plotLayer, revetOffset && (() => toggleRevet("pipe")))' in view
|
||||
assert view.index("attachShapePick(plotLayer") < view.index("attachDesignLinePick({")
|
||||
assert 'shapePickRoute(svg, () => toggleRevet("pipe"))' in view
|
||||
assert view.index("attachShapePick(") < view.index("attachDesignLinePick({")
|
||||
pick = _read("B06_Section", "B06_Section_UI_Cross_Shape_Pick.ts")
|
||||
assert "onDesignChange" not in pick and "design_elevation" not in pick
|
||||
assert '"b06-chart__culvert-revet-hit"' in pick # 구조물 판정면 앞(아래)에 꽂을 기준만
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""288 맹암거 횡단 형상(요구 183-3 · 9).
|
||||
|
||||
못박는 것
|
||||
- 288-1 맹암거 기준 측점 = 측구 없음 · 노면 = 암거 경사로 지대 낮은 쪽 한 경사(물넘이 286 · 287 길 —
|
||||
서버 `tilt_ranges` · `tilt_args` · TS 짝 `tilted`) · 제원 얹기 서버 `attach_underdrains` ↔ TS
|
||||
`attachUnderdrainSpecs` 같은 값 · 형상 = 노면을 암거 높이만큼 아래로 옵셋 → 성토 쪽은 성토 사면과
|
||||
만남 · 절토 쪽은 절토 사면을 늘여 만남 · 그 안 돌 무늬 · 높이 비면 안 그림.
|
||||
- 288-2 맹암거 형상 누름 = 그 구조물 id 고름(구조물 목록 창구 · 알약 · 폼 · 측점선 따라옴) · 물넘이와 같은
|
||||
판정면(보라 손잡이 아래 · 끌기 = 고름 아님 · 287-5) · 노면공 포장 범위 고름과 따로.
|
||||
|
||||
진짜 TS 는 `helper_288_underdrain.cjs` 가 Node 로 돌림. 화면은 사용자 확인.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import B06_Section.B06_Section_Engine_Tilt as tilt # noqa: E402
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
|
||||
|
||||
STRUCTURES = [
|
||||
{
|
||||
"structure_id": "ud-1",
|
||||
"type_id": "cross_underdrain",
|
||||
"placement": "point",
|
||||
"chainage_m": 40.0,
|
||||
"options": {"culvert_height_m": 0.8, "culvert_slope_pct": 4},
|
||||
},
|
||||
{ # 경사 · 높이 빈 맹암거 — 표준 횡단경사 · 형상 없음
|
||||
"structure_id": "ud-2",
|
||||
"type_id": "cross_underdrain",
|
||||
"placement": "point",
|
||||
"chainage_m": 80.0,
|
||||
"options": {},
|
||||
},
|
||||
{
|
||||
"structure_id": "x-1",
|
||||
"type_id": "cross_drain_exposed",
|
||||
"placement": "point",
|
||||
"chainage_m": 60.0,
|
||||
},
|
||||
]
|
||||
SECTIONS = [{"chainage_m": c} for c in (20.0, 40.0, 60.0, 80.0)]
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def project(monkeypatch, tmp_path) -> Path:
|
||||
items = [StructureInstance(**s) for s in STRUCTURES]
|
||||
monkeypatch.setattr(tilt, "load_structures", lambda _root: (1, items))
|
||||
monkeypatch.setattr(tilt, "load_culvert_sets", lambda _root: {})
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts(tmp_path_factory) -> dict:
|
||||
source = tmp_path_factory.mktemp("s288") / "in.json"
|
||||
source.write_text(
|
||||
json.dumps({"structures": STRUCTURES, "sections": SECTIONS}), encoding="utf-8"
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_288_underdrain.cjs"), str(source)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_맹암거_자리_기울기_서버(project):
|
||||
ranges = tilt.tilt_ranges(project)
|
||||
assert ranges == [(40.0, 40.0, 4.0), (80.0, 80.0, None)]
|
||||
assert tilt.tilt_args(40.0, ranges) == {"tilted": True, "tilt_pct": 4.0}
|
||||
assert tilt.tilt_args(80.01, ranges) == {"tilted": True, "tilt_pct": None}
|
||||
assert tilt.tilt_args(60.0, ranges) == {"tilted": False, "tilt_pct": None}
|
||||
|
||||
|
||||
def test_제원_얹기_서버_TS_같은_값(project, ts):
|
||||
sections = [dict(s) for s in SECTIONS]
|
||||
assert tilt.attach_underdrains(project, sections) == 2 == ts["attached"]
|
||||
assert [s.get("underdrain") for s in sections] == ts["sections"]
|
||||
assert ts["sections"][1] == {
|
||||
"structure_id": "ud-1",
|
||||
"chainage_m": 40.0,
|
||||
"height_m": 0.8,
|
||||
"slope_pct": 4.0,
|
||||
}
|
||||
|
||||
|
||||
def test_맹암거_측점_설계_측구_없음_낮은_쪽_기울기():
|
||||
samples = [
|
||||
{"offset_m": o / 2, "elevation_m": round(100.0 + 0.35 * o / 2, 4), "valid": True}
|
||||
for o in range(-24, 25)
|
||||
]
|
||||
kwargs = {"ground_type": "soil", "section_mode": "left_cut", "ditch_side": "left"}
|
||||
got = compute_cross_design(samples, 100.4, tilted=True, tilt_pct=4.0, **kwargs)
|
||||
assert got["ditch_enabled"] is False and got["cross_slope_pct"] == 4.0
|
||||
left, right = (got["road_edges"][k]["elevation_m"] for k in ("left", "right"))
|
||||
assert left > right # 지반이 좌로 오름 = 우가 낮음
|
||||
|
||||
|
||||
def test_형상_옵셋선_성토_절토와_만남(ts):
|
||||
outline = [(p["offset"], p["elevation"]) for p in ts["outline"]]
|
||||
expected = [
|
||||
(3, 100.15), # 좌 노면 끝
|
||||
(-3, 99.85), # 우 노면 끝
|
||||
(-4.632653, 98.768367), # 우 성토 사면 ∩ 옵셋선
|
||||
(1.846154, 99.092308), # 좌 절토 사면을 늘인 선 ∩ 옵셋선
|
||||
]
|
||||
assert len(outline) == len(expected)
|
||||
for got, want in zip(outline, expected):
|
||||
assert got == pytest.approx(want, abs=1e-5)
|
||||
assert ts["noHeight"] is None
|
||||
assert ts["drew"] is True and ts["stones"] > 0
|
||||
|
||||
|
||||
def test_횡단도_B07_이_맹암거를_그림():
|
||||
view = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8")
|
||||
assert (
|
||||
"appendUnderdrainOverlay(plotLayer, section.underdrain, section.design, x, toDisplayY);"
|
||||
in view
|
||||
)
|
||||
b07 = (ROOT / "B07_DesignDetail" / "B07_DesignDetail_Cross_Overlay.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "appendUnderdrainOverlay(svg, section.underdrain, design, x, y);" in b07
|
||||
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
|
||||
assert "tilted: Boolean(section.ford_pavement || section.underdrain)," in refresh
|
||||
|
||||
|
||||
def test_맹암거_형상_누름은_그_구조물_고름(ts):
|
||||
assert ts["bodyId"] == "ud-1" # 둘레 = 형상 고름 표식
|
||||
assert ts["routed"] == [["pipe"], [ts["eventName"], "ud-1", True]]
|
||||
view = (ROOT / "B06_Section" / "B06_Section_UI_Section_View.ts").read_text(encoding="utf-8")
|
||||
assert "grid.addEventListener(SHAPE_PICK_EVENT, (event) => {" in view
|
||||
assert "if (id) pavementSelectListener?.(id);" in view # = structuresPanel.selectStructure(id)
|
||||
cross = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View.ts").read_text(encoding="utf-8")
|
||||
assert 'revetOffset && shapePickRoute(svg, () => toggleRevet("pipe")),' in cross
|
||||
Reference in New Issue
Block a user