diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 00ffd48cc..74c9119fe 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -54,7 +54,7 @@ import { computeRevetmentLayout } from "../B06_Section/B06_Section_UI_Cross_Reve import { withoutGuardedFill } from "../B06_Section/B06_Section_UI_Cross_Body_Guard"; import { chuteBands, fordPavementChute } from "./B05_Profile_UI_Corridor_Structures_Chute"; import { isChuteSegment } from "../B06_Section/B06_Section_UI_Cross_Wall_Hatch"; -import { builds, culvertBody } from "./B05_Profile_UI_Corridor_Structures_Pick"; +import { builds, culvertBody, ownsBodies } from "./B05_Profile_UI_Corridor_Structures_Pick"; /** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */ export interface StructureFrame { @@ -279,12 +279,14 @@ export function buildCorridorStructures( ): CorridorStructure[] { const solids: CorridorStructure[] = []; for (const section of crossSections) { - // 몸체 고르기 = 그림 자료 열쇠의 주인 종류 표 3D 부품(322-1) - const layout = culvertBody(section) ? culvertLayoutOf(section) : null; + // 몸체 고르기 = 그림 자료 열쇠의 주인 종류 표 3D 부품(322-1) · 같은 m 카드 둘이면 한 번(323-9) + const own = ownsBodies(section, crossSections); + const layout = own && culvertBody(section) ? culvertLayoutOf(section) : null; // 흙막이가 선 쪽은 횡단과 같은 자리 함수로 — 측벽 걷고 관 · 바닥판이 흙막이까지(89-1) - const fordLayout = builds("ford_bridge", "ford") - ? withoutGuardedFill(fordLayoutOf(section), layout) - : null; + const fordLayout = + own && builds("ford_bridge", "ford") + ? withoutGuardedFill(fordLayoutOf(section), layout) + : null; // 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위 // culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다 // (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙). @@ -297,10 +299,9 @@ export function buildCorridorStructures( builds(section.revetment?.type_id, "wall") ? computeRevetmentLayout(section, section.design?.revet_adjust?.own) : null; - const paveChute = builds("ford_pavement", "chute") - ? fordPavementChute(section, crossSections) - : null; - const box = builds("box_culvert", "box") ? section.box : undefined; + const paveChute = + own && builds("ford_pavement", "chute") ? fordPavementChute(section, crossSections) : null; + const box = own && builds("box_culvert", "box") ? section.box : undefined; if (!layout && !fordLayout && !box && !revetLayout && !paveChute) continue; const chainage = section.chainage_m; const stationFrame: StructureFrame = { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts index 2135085c0..3ac464282 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Pick.ts @@ -12,6 +12,10 @@ import { catalogRow } from "../common_util/common_util_structure_catalog"; export const builds = (typeId: string | null | undefined, body: string): boolean => (catalogRow(typeId ?? "")?.model3d ?? body) === body; +/** 관 몸체를 세울 측점인가 — 같은 m 카드 둘(노선 끝 EP)이면 앞 카드만(관 하나에 한 번 · 323-9). */ +export const ownsBodies = (section: CrossSection, sections: readonly CrossSection[]): boolean => + sections.find((other) => other.chainage_m === section.chainage_m) === section; + /** `culvert` 자리 주인 = 박스 · 세월교 흙막이(body) · 독립 기슭막이(관 숨김) · 배관. */ export function culvertBody(section: CrossSection): boolean { const spec = section.culvert; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Check.py b/M02_MasterTemplete/StructOptions/M02_SO_Check.py index 04c8e392e..90cde5083 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Check.py +++ b/M02_MasterTemplete/StructOptions/M02_SO_Check.py @@ -142,7 +142,7 @@ def _row_errors(doc: dict[str, Any], before: dict[str, Any] | None) -> list[str] place = PLACE_KO.get(row["placement"], row["placement"]) owner = OWNER_KO.get(row["owner"], row["owner"]) if row["placement"] not in part["자리꼴"] or row["owner"] not in part["정본"]: - out.append(f"「{row['name']}」 {branch} 「{name}」 — {place} · {owner} 에 안 맞음") + out.append(f"「{row['name']}」 {branch} 「{name}」 — {place} · {owner}에 안 맞음") for key in part["읽는 칸"]: if key in keys and key in hidden: out.append( diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts index 2433880f6..afbc38e80 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Check.ts @@ -156,7 +156,7 @@ function rowErrors(doc: CheckDef, before: CheckDef | null): string[] { const place = PLACE_KO[row.placement] ?? row.placement; const owner = OWNER_KO[row.owner] ?? row.owner; if (!part.자리꼴.includes(row.placement) || !part.정본.includes(row.owner)) - out.push(`「${row.name}」 ${branch} 「${name}」 — ${place} · ${owner} 에 안 맞음`); + out.push(`「${row.name}」 ${branch} 「${name}」 — ${place} · ${owner}에 안 맞음`); for (const key of part["읽는 칸"]) if (keys.has(key) && hidden.includes(key)) out.push( diff --git a/resources/tester/helper_323_9_twin_cards_3d.cjs b/resources/tester/helper_323_9_twin_cards_3d.cjs new file mode 100644 index 000000000..713b9d76f --- /dev/null +++ b/resources/tester/helper_323_9_twin_cards_3d.cjs @@ -0,0 +1,87 @@ +/* PLAN 323-9 ④-② — 노선 끝(EP)처럼 같은 m 카드 둘이면 3D 관 · 세월교 · BOX 몸체는 한 번. + * 화면 TS(`buildCorridorStructures`)를 그 자리에서 트랜스파일해 Node 로 돌림(로더 = helper_135_3 과 같음). + * 입력 = 73-4 픽스처(세월교 173.087 · BOX 352.14) · 결과 = 표준출력 JSON. 짝 = test_323_9_twin_cards_3d.py. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +const cache = new Map(); +const STUBS = { + "@ui/ui_template_toast": { showToast: () => undefined }, + three: require(path.join(ROOT, "config", "node_modules", "three")), +}; +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; +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); + return box.exports; +} + +const { buildCorridorStructures } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Corridor_Structures.ts"), +); +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"), +); +const clone = (v) => JSON.parse(JSON.stringify(v)); +const at = (c) => clone(fixture.cross_sections.find((s) => Math.abs(s.chainage_m - c) < 0.6)); +/** 직선 노선 — 가장 가까운 측점 프레임에서 앞쪽으로 편다. */ +const frameAtOf = (sections) => (atM) => { + const s = sections.reduce((b, x) => + Math.abs(x.chainage_m - atM) < Math.abs(b.chainage_m - atM) ? x : b, + ); + const [lx, ly] = s.frame.left_xy; + const along = atM - s.chainage_m; + return { + cx: s.center_x - ly * along, + cy: s.center_y + lx * along, + leftX: lx, + leftY: ly, + designZ: s.design?.design_elevation_m ?? null, + }; +}; +/** 부재 차림표 — 종류 · 열쇠 · 측점(같은 몸체를 두 번 세우면 같은 줄이 둘). */ +const kinds = (sections) => + buildCorridorStructures(sections, frameAtOf(sections)) + .map((s) => `${s.kind}:${s.key ?? ""}:${s.chainage_m.toFixed(2)}`) + .sort(); +const pick = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Corridor_Structures_Pick.ts")); +const own = pick.ownsBodies; +const out = {}; +for (const c of [173.087, 352.14]) { + const one = at(c); + const twin = kinds([one, clone(one)]); + pick.ownsBodies = () => true; // 고치기 전(측점마다 몸체) — 기준이 변경을 재는지 + const before = kinds([one, clone(one)]); + pick.ownsBodies = own; + out[c] = { one: kinds([one]), twin, before }; +} +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_322_2_check_mirror.py b/resources/tester/test_322_2_check_mirror.py index 4cc5e3673..819de5f30 100644 --- a/resources/tester/test_322_2_check_mirror.py +++ b/resources/tester/test_322_2_check_mirror.py @@ -127,16 +127,16 @@ def test_check_rules(ts: dict) -> None: "부품 「끔」 — 없는 부품", ] assert got["rule218"] == ["「개거(겉도랑)」 형상이 「없음」이면 횡단도도 「안 만듦」"] - assert got["mismatch"] == ["「옹벽」 형상 「배관 세트」 — 구간 · 구조물 에 안 맞음"] + assert got["mismatch"] == ["「옹벽」 형상 「배관 세트」 — 구간 · 구조물에 안 맞음"] assert got["hide_read"] == [ "「배관」 형상 「배관 세트」 이 쓰는 칸 「흙막이 기초」 — 숨길 수 없음" ] assert got["hide_no_before"] == [] # 앞 판 없음 = 새로 숨긴 것 없음 assert got["fixed"] == [ # 표 줄도 함께 걸림 — 자리꼴 · 정본을 바꾸면 그 종류 부품이 안 맞음 - "「BOX암거」 형상 「BOX 본체 · 날개벽」 — 구간 · 관 지점 에 안 맞음", - "「BOX암거」 3D 「BOX 본체 · 날개벽」 — 구간 · 관 지점 에 안 맞음", - "「기슭막이」 형상 「독립 기슭막이」 — 구간 · 구조물 에 안 맞음", + "「BOX암거」 형상 「BOX 본체 · 날개벽」 — 구간 · 관 지점에 안 맞음", + "「BOX암거」 3D 「BOX 본체 · 날개벽」 — 구간 · 관 지점에 안 맞음", + "「기슭막이」 형상 「독립 기슭막이」 — 구간 · 구조물에 안 맞음", "「배관」 「관경」 선택지 값 — 고칠 수 없음(이름만 고침)", "「BOX암거」 자리꼴 — 만든 뒤 고정", "「기슭막이」 정본 — 만든 뒤 고정", diff --git a/resources/tester/test_323_9_twin_cards_3d.py b/resources/tester/test_323_9_twin_cards_3d.py new file mode 100644 index 000000000..7c72ea64b --- /dev/null +++ b/resources/tester/test_323_9_twin_cards_3d.py @@ -0,0 +1,42 @@ +"""PLAN 323-9 ④-② — 노선 끝(EP)처럼 같은 m 카드 둘이면 3D 관 · 세월교 · BOX 몸체가 둘 서던 흠. + +3D 몸체는 구조물(관) 하나에 한 번 — 같은 m 카드 둘이면 앞 카드만 세움(`ownsBodies`). +Node 헬퍼 = `helper_323_9_twin_cards_3d.cjs`(73-4 픽스처 · 진짜 `buildCorridorStructures`). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( + ["node", str(HERE / "helper_323_9_twin_cards_3d.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(run.stdout) + + +@pytest.mark.parametrize("at", ["173.087", "352.14"]) +def test_twin_cards_build_bodies_once(out: dict, at: str) -> None: + got = out[at] + assert got["one"] and got["twin"] == got["one"] + assert len(got["before"]) == 2 * len(got["one"]) # 고치기 전 = 두 번(시험이 변경을 잼) + if at == "173.087": # 세월교 관 두 련 — 관 몸체도 한 번 + assert sum(k.startswith("pipe:") for k in got["twin"]) == 2