Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-10-10 12:05:16 +09:00
8 changed files with 390 additions and 8 deletions
@@ -0,0 +1,34 @@
/* =============================================================================
* design_structure_slope_tier.ts
* 사면안정 흙막이 · 옹벽 「추가 단」 상자(178-3) — 배관 유출 흙막이 상자 양식(`design_structure_guard`)
* 재사용 · 공용 [추가] · [빼기] · 상자 수 = 추가 단.
*
* · 흙막이 = 형태 · 높이 · 옹벽 = 높이만(형태 = 콘크리트) · 저장 = structures.json `options` 키 `tier_{n}_*`
* · 정의 그룹 종류에 덧붙임 — 좌측 폼 선택 컨테이너만(그룹 상자 · 목록 · 벌룬 · M02 는 정의 그룹 그대로 ·
* `STRUCTURE_SPECS` 에 안 넣음)
* · 단 그림 = `common_util_structure_walls`(`tierExtras` · 파이썬 짝) → 횡단 다단
* import = 같은 폴더 명세뿐 — 시험이 이 폴더 파일만 옮겨 돌림.
* ========================================================================== */
import type { SpecContainer, SpecItem, StructureSpec } from "./design_structure_spec";
import { guardItems } from "./design_structure_guard";
const TIER_ITEMS = guardItems().filter((item) => item.id === "form" || item.id === "height");
const tierAdd = (items: SpecItem[]): SpecContainer => ({
id: "tier",
title: "추가 단",
required: false,
prefix: "tier",
link: "gtier",
items,
});
export const ADDON_SPECS: Readonly<Record<string, StructureSpec>> = {
soil_guard: { typeId: "soil_guard", containers: [tierAdd(TIER_ITEMS)], retired: [] },
retaining_wall: {
typeId: "retaining_wall",
containers: [tierAdd(TIER_ITEMS.filter((item) => item.id === "height"))],
retired: [],
},
};
@@ -16,6 +16,7 @@ import {
type SpecLinks,
} from "../A00_Common/design/design_structure_form";
import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe";
import { ADDON_SPECS } from "../A00_Common/design/design_structure_slope_tier";
import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec";
import { optionDefaults } from "./B05_Profile_UI_Structures_Fields";
import type { WideningBox } from "./B05_Profile_UI_Structures_Widening";
@@ -24,9 +25,10 @@ const forms = new WeakMap<HTMLElement, SpecForm>();
/** 범위형 명세(노면공 · 87-3)의 「확폭 구간」 상자 — 옵션 칸 짝. */
const widenings = new WeakMap<HTMLElement, WideningBox>();
/** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌. */
/** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌 · 정의 그룹 종류 덧붙임(178-3 추가 단)도. */
export const structureSpecOf = (type: StructureType | null): StructureSpec | null =>
(type && !type.managed_by && STRUCTURE_SPECS[type.type_id]) || null;
(type && !type.managed_by && (STRUCTURE_SPECS[type.type_id] ?? ADDON_SPECS[type.type_id])) ||
null;
/** 옵션 칸 짝 폼을 세움 — 값이 바뀌면 `onChange`(패널 즉시 반영). */
export function createStructureSpecForm(
@@ -17,6 +17,8 @@
"""
import logging
import math
import re
from pathlib import Path
from typing import Any
@@ -39,6 +41,31 @@ _FORM_BY_TYPE = {
}
# 좌측 폼 「추가 단」 상자 저장 키 머리(178-3) — `tier_{n}_form` · `tier_{n}_height`.
_TIER_KEY = re.compile(r"^tier_(\d+)_")
def _num(value: Any) -> float | int | None:
"""유한한 수만(TS `num` 짝 — 글 · 참거짓은 None)."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return value if math.isfinite(value) else None
def tier_extras(type_id: str, options: dict[str, Any]) -> list[dict[str, Any]]:
"""「추가 단」 상자 → 추가 단 [{형태 · 높이}](n 차례) — 옹벽 = 콘크리트 · TS `tierExtras` 짝."""
ns = sorted({int(m.group(1)) for key in options if (m := _TIER_KEY.match(key))})
return [
{
"form": "콘크리트"
if type_id == "retaining_wall"
else (options.get(f"tier_{n}_form") or None),
"height_m": _num(options.get(f"tier_{n}_height")),
}
for n in ns
]
# 소단 타입 id — 레지스트리와 한 벌이다(C군이지만 벽이 아니다).
BERM_TYPE_ID = "berm"
@@ -60,6 +87,7 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
if start is None or end is None:
continue
options = structure.options or {}
extras = tier_extras(structure.type_id, options)
found.append(
{
"structure_id": structure.structure_id,
@@ -76,7 +104,9 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
# 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다.
# 비어 있으면 화면이 터파기를 안 그린다(근거 없음).
"foundation": options.get("foundation"),
"tiers": options.get("tiers"),
# 단 수 = 1 + 추가 단 상자 수 · 상자 없으면 옛 `tiers`(178-3)
"tiers": len(extras) + 1 if extras else options.get("tiers"),
"extras": extras,
"lift_m": options.get("lift_m"),
"shift_m": options.get("shift_m"),
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키).
@@ -56,6 +56,8 @@ export interface RevetmentSpec {
foundation?: string | null;
/** 단 수(다단). 1이면 단일 벽. */
tiers?: number | null;
/** 추가 단(2단부터) 형태 · 높이 — 좌측 폼 「추가 단」 상자(178-3). 빈 값 = 본 벽 형태 · 기본 높이. */
extras?: ReadonlyArray<{ form: string | null; height_m: number | null }> | null;
/** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */
lift_m?: number | null;
shift_m?: number | null;
@@ -254,7 +256,12 @@ export function computeRevetmentLayout(
outward,
groundAt,
limitOffset: limit,
adjusts: Array.from({ length: requestedTiers - 1 }, () => ({ ...ZERO_ADJUST })),
// 단마다 상자 형태 · 높이(178-3) — 자리(x · d)는 다단 자동 규칙 그대로
adjusts: Array.from({ length: requestedTiers - 1 }, (_, i) => ({
...ZERO_ADJUST,
m: spec.extras?.[i]?.form ?? null,
h: spec.extras?.[i]?.height_m ?? null,
})),
// 단들도 1단과 같은 형태로 선다 — 안 넘기면 형태가 비어 늘 메쌓기로
// 그려졌다(2026-08-30 사용자: 형태별 모양이 누락되는 경우).
ownerForm: spec.form,
+30 -1
View File
@@ -35,6 +35,8 @@ export interface WallSpec {
/** 기초 축 — "기초유" | "기초버림". 저장 칸과 같은 글자(터파기 그림이 이 값으로 갈린다). */
foundation: string | null;
tiers: number | null;
/** 추가 단(2단부터) 형태 · 높이 — 좌측 폼 「추가 단」 상자(178-3 · `tier_{n}_*`). 없으면 빈 목록. */
extras: WallTier[];
lift_m: number | null;
shift_m: number | null;
/** 전면 기울기 판정 칸 — 성토/절토 · 전면 기울기(저장 원본 · B08 과 같은 칸, 2026-09-14 B5). */
@@ -42,6 +44,12 @@ export interface WallSpec {
face_slope_ratio: number | string | null;
}
/** 추가 단 한 벌 — 형태 빈 값 = 본 벽 형태 · 높이 빈 값 = 기본 단 높이(그리는 쪽). */
export interface WallTier {
form: string | null;
height_m: number | null;
}
/** 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 파이썬 `_EDGE_TOLERANCE_M`. */
const EDGE_TOLERANCE_M = 0.02;
@@ -67,6 +75,24 @@ export const FORM_BY_TYPE: Record<string, string> = {
const num = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
const TIER_KEY = /^tier_(\d+)_/;
/** 「추가 단」 상자(`tier_{n}_*` · n 차례) → 추가 단 목록 — 옹벽 = 콘크리트(178-3) · 파이썬 `tier_extras` 짝. */
export function tierExtras(typeId: string, options: Record<string, unknown>): WallTier[] {
const ns = [
...new Set(
Object.keys(options)
.map((key) => TIER_KEY.exec(key)?.[1])
.filter((n): n is string => !!n)
.map(Number),
),
].sort((a, b) => a - b);
return ns.map((n) => ({
form: typeId === "retaining_wall" ? "콘크리트" : (options[`tier_${n}_form`] as string) || null,
height_m: num(options[`tier_${n}_height`]),
}));
}
/**
* C군 벽 구조물을 제원 목록으로 바꾼다. 이름표(`names`)는 타입 레지스트리에서 온다.
* 구간(start·end)이 없는 항목은 건너뛴다 — 벽은 구간형이다.
@@ -83,6 +109,7 @@ export function wallSpecsFrom(
const end = num(structure.end_m);
if (start === null || end === null) continue;
const options = (structure.options ?? {}) as Record<string, unknown>;
const extras = tierExtras(structure.type_id, options);
specs.push({
structure_id: structure.structure_id ?? null,
type_id: structure.type_id,
@@ -95,7 +122,9 @@ export function wallSpecsFrom(
side: (options.side as string) ?? null,
// 기초 축 — 저장 칸과 **같은 글자**. 초안 경로에서 빠지면 터파기가 안 그려진다.
foundation: (options.foundation as string) ?? null,
tiers: num(options.tiers),
// 단 수 = 1 + 추가 단 상자 수 · 상자 없으면 옛 `tiers`(178-3)
tiers: extras.length ? extras.length + 1 : num(options.tiers),
extras,
lift_m: num(options.lift_m),
shift_m: num(options.shift_m),
face_role: (options.face_role as string) ?? null,
@@ -0,0 +1,112 @@
/* 178-3 사면안정 흙막이 · 옹벽 「추가 단」 헬퍼 — 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림.
* ① 명세 상자 저장 왕복(readState → writeOptions) ② 구조물 정본 → 벽 제원(`wallSpecsFrom`) → 횡단 형상
* (`computeRevetmentLayout`) 단 수 · 단마다 형태 · 높이. 입력 측점 = 73-4 픽스처(helper_79 와 같은 180 측점).
* 결과 = 표준출력 JSON 한 벌 · 짝 = `test_178_3_slope_tiers.py`.
*/
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 STUBS = { "@ui/ui_template_toast": { showToast: () => undefined } };
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) {
const data = JSON.parse(fs.readFileSync(full, "utf8"));
cache.set(full, data);
return data;
}
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
for (const [prefix, dir] of Object.entries(alias))
if (request.startsWith(prefix))
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
const design = (f) => path.join(ROOT, "A00_Common", "design", f);
const spec = loadTs(design("design_structure_spec.ts"));
const { ADDON_SPECS } = loadTs(design("design_structure_slope_tier.ts"));
const { STRUCTURE_SPECS } = loadTs(design("design_structure_pipe.ts"));
const { wallSpecsFrom } = loadTs(path.join(ROOT, "common_util", "common_util_structure_walls.ts"));
const { computeRevetmentLayout } = loadTs(
path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Revetment.ts"),
);
const r3 = (v) => Math.round(v * 1000) / 1000;
const out = {};
// ① 저장 왕복 — 상자 둘(흙막이) · 하나(옹벽) · 빈 상자(값 없이 추가만)
const soil = ADDON_SPECS.soil_guard;
const wall = ADDON_SPECS.retaining_wall;
const soilOptions = {
form: "돌쌓기(찰)",
height_m: 2,
tier_1_form: "콘크리트",
tier_1_height: 2,
tier_2_form: "돌망태",
tier_2_height: 1.2,
};
const soilState = spec.readState(soil, soilOptions);
out.roundTrip = {
items: Object.fromEntries(
Object.entries(ADDON_SPECS).map(([k, s]) => [k, s.containers[0].items.map((i) => i.id)]),
),
soilState: soilState.tier,
soilBack: spec.writeOptions(soil, soilState),
wallBack: spec.writeOptions(wall, { tier: [{ height: "1.8" }] }),
emptyBox: spec.writeOptions(soil, { tier: [{}] }),
notMainSpecs: Object.keys(ADDON_SPECS).filter((k) => k in STRUCTURE_SPECS),
};
// ② 형상 — 정본 → 벽 제원 → 횡단 단
const fixture = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"),
);
const base = fixture.cross_sections.find((s) => Math.abs(s.chainage_m - 180) < 0.6);
const names = new Map([
["soil_guard", "흙막이"],
["retaining_wall", "옹벽"],
]);
function shape(typeId, options) {
const [wallSpec] = wallSpecsFrom(
[{ structure_id: "t", type_id: typeId, chainage_m: 180, start_m: 170, end_m: 190, options }],
names,
);
const section = JSON.parse(JSON.stringify(base));
section.revetment = { ...wallSpec, side: "우" };
const layout = computeRevetmentLayout(section, null);
return {
tiers: wallSpec.tiers,
extras: wallSpec.extras,
requested: layout?.requestedTiers ?? null,
walls: (layout?.walls ?? []).map((w) => ({ form: w.form, pure: r3(w.height + 0.5) })),
};
}
out.soil2 = shape("soil_guard", soilOptions);
out.wall1 = shape("retaining_wall", { height_m: 2.5, tier_1_height: 1.8 });
out.emptyBox = shape("soil_guard", { form: "돌망태", height_m: 2, tier_1_form: "" });
out.legacy = shape("soil_guard", { form: "돌망태", height_m: 2, tiers: 2 });
out.none = shape("retaining_wall", { height_m: 2.5 });
process.stdout.write(JSON.stringify(out));
+135
View File
@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
"""178-3 사면안정 흙막이 · 옹벽 「추가 단」 상자 → 여러 단 횡단 형상.
못박는 것
- 상자 = 유출 흙막이 상자 양식(공용 [추가] · [빼기]) · 흙막이 = 형태 · 높이 · 옹벽 = 높이만 · 키 `tier_{n}_*`.
- 명세 저장 왕복 그대로 · 빈 상자도 한 단으로 남음 · 정의 그룹 종류 그대로(`STRUCTURE_SPECS` 에 안 넣음).
- 서버 저장(structures.json) → 벽 제원(파이썬) 단 수 = 1 + 상자 수 · 단마다 형태 · 높이 · 옹벽 단 = 콘크리트.
- 횡단 형상(진짜 `computeRevetmentLayout`) — 단 수 · 단마다 형태 · 높이(빈 값 = 본 벽 형태 · 1.5 m) · 옛 `tiers` 그대로.
Node 헬퍼 `helper_178_3_slope_tiers.cjs`. 화면은 사용자 확인.
"""
import json
import os
import subprocess
import pytest
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
SOIL = {
"form": "돌쌓기(찰)",
"length_m": 20,
"height_m": 2,
"before_m": 10,
"after_m": 10,
"tier_1_form": "콘크리트",
"tier_1_height": 2,
"tier_2_form": "돌망태",
"tier_2_height": 1.2,
}
def _read(*parts: str) -> str:
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
return handle.read()
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_178_3_slope_tiers.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_상자_칸_흙막이_형태_높이_옹벽_높이(out):
assert out["roundTrip"]["items"] == {
"soil_guard": ["form", "height"],
"retaining_wall": ["height"],
}
assert out["roundTrip"]["notMainSpecs"] == []
def test_명세_저장_왕복(out):
trip = out["roundTrip"]
assert trip["soilState"] == [
{"form": "콘크리트", "height": "2"},
{"form": "돌망태", "height": "1.2"},
]
assert trip["soilBack"] == {
"tier_1_form": "콘크리트",
"tier_1_height": 2,
"tier_2_form": "돌망태",
"tier_2_height": 1.2,
}
assert trip["wallBack"] == {"tier_1_height": 1.8}
assert trip["emptyBox"] == {"tier_1_form": ""} # 값 없이 추가만 해도 단 하나
def test_서버_저장_뒤_벽_제원(tmp_path):
from B05_Profile.B05_Profile_Structures_Repository import save_structures
from B06_Section import B06_Section_Engine_Structures_Wall as wall
items = [
StructureInstance(
type_id="soil_guard",
placement="interval",
chainage_m=180,
start_m=170,
end_m=190,
options=SOIL,
),
StructureInstance(
type_id="retaining_wall",
placement="interval",
chainage_m=300,
start_m=290,
end_m=310,
options={"height_m": 2.5, "tier_1_height": 1.8},
),
]
save_structures(str(tmp_path), items, base_revision=0)
got = {spec["type_id"]: spec for spec in wall.load_wall_structures(tmp_path)}
assert got["soil_guard"]["tiers"] == 3
assert got["soil_guard"]["extras"] == [
{"form": "콘크리트", "height_m": 2},
{"form": "돌망태", "height_m": 1.2},
]
assert got["retaining_wall"]["tiers"] == 2
assert got["retaining_wall"]["extras"] == [{"form": "콘크리트", "height_m": 1.8}]
def test_횡단_형상_단_수_단마다_형태_높이(out):
assert out["soil2"]["requested"] == 3
assert out["soil2"]["walls"] == [
{"form": "돌쌓기(찰)", "pure": 2},
{"form": "콘크리트", "pure": 2},
{"form": "돌망태", "pure": 1.2},
]
assert out["wall1"]["walls"] == [
{"form": "콘크리트", "pure": 2.5},
{"form": "콘크리트", "pure": 1.8},
]
# 빈 상자 = 본 벽 형태 · 기본 1.5 m · 상자 없으면 옛 tiers 그대로 · 둘 다 없으면 1단
assert out["emptyBox"]["walls"][1] == {"form": "돌망태", "pure": 1.5}
assert out["legacy"]["requested"] == 2 and out["legacy"]["extras"] == []
assert out["none"]["requested"] == 1
def test_배선():
spec = _read("B05_Profile", "B05_Profile_UI_Structures_Spec.ts")
assert "STRUCTURE_SPECS[type.type_id] ?? ADDON_SPECS[type.type_id]" in spec
walls = _read("common_util", "common_util_structure_walls.ts")
assert "파이썬 `tier_extras` 짝" in walls
engine = _read("B06_Section", "B06_Section_Engine_Structures_Wall.py")
assert "TS `tierExtras` 짝" in engine
@@ -46,6 +46,24 @@ STRUCTURES = [
"end_m": None,
"options": {},
},
{
# 178-3 「추가 단」 상자 둘(둘째 상자 높이 빈 값 · 글 값은 수 아님)
"structure_id": "tiers",
"type_id": "soil_guard",
"placement": "interval",
"chainage_m": 260.0,
"start_m": 250.0,
"end_m": 270.0,
"options": {
"form": "돌망태",
"height_m": 2.0,
"tiers": 5,
"tier_2_form": "",
"tier_2_height": "1.5",
"tier_1_form": "콘크리트",
"tier_1_height": 2.0,
},
},
]
NAMES = {"masonry_wet": "돌쌓기(찰)", "retaining_wall": "옹벽", "soil_guard": "흙막이"}
CHAINAGES = [20.0, 40.0, 45.02, 60.0, 115.0, 200.0]
@@ -62,7 +80,11 @@ writeFileSync(process.argv[3], JSON.stringify({ specs, sections }));
def _python_result() -> dict:
from B06_Section.B06_Section_Engine_Structures_Wall import _FORM_BY_TYPE, attach_wall_structures
from B06_Section.B06_Section_Engine_Structures_Wall import (
_FORM_BY_TYPE,
attach_wall_structures,
tier_extras,
)
specs = []
for item in STRUCTURES:
@@ -71,6 +93,7 @@ def _python_result() -> dict:
if not name or start is None or end is None:
continue
options = item["options"] or {}
extras = tier_extras(item["type_id"], options)
specs.append(
{
"structure_id": item["structure_id"],
@@ -85,7 +108,9 @@ def _python_result() -> dict:
# 기초 축(2026-09-09 신설) — 저장 칸과 같은 글자. 초안 경로에서 빠지면
# 터파기가 안 그려져 「그림이 값에 안 따라간다」가 된다.
"foundation": options.get("foundation"),
"tiers": options.get("tiers"),
# 178-3 단 수 = 1 + 「추가 단」 상자 수 · 상자 없으면 옛 tiers
"tiers": len(extras) + 1 if extras else options.get("tiers"),
"extras": extras,
"lift_m": options.get("lift_m"),
"shift_m": options.get("shift_m"),
# 전면 기울기 판정 칸(2026-09-14 B5) — 저장 원본 그대로.
@@ -145,8 +170,16 @@ def test_wall_specs_mirror():
with tempfile.TemporaryDirectory() as workdir:
ts = _ts_result(Path(workdir))
py = _python_result()
assert len(ts["specs"]) == len(py["specs"]) == 2
assert len(ts["specs"]) == len(py["specs"]) == 3
assert ts["specs"] == py["specs"]
tiered = ts["specs"][2]
assert tiered["tiers"] == 3
assert tiered["extras"] == [
{"form": "콘크리트", "height_m": 2.0},
{"form": None, "height_m": None},
]
assert ts["specs"][1]["extras"] == [] # 상자 없는 옹벽 = 옛 tiers 2 그대로
assert ts["specs"][1]["tiers"] == 2
ts_marks = [
(s["chainage_m"], (s.get("revetment") or {}).get("type_id")) for s in ts["sections"]
]