diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index f0845d294..47ee633c5 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -20,7 +20,7 @@ * 폴리곤을 갈아 끼워(`polygons`) 측점 사이를 Catmull-Rom으로 잇는다. 벽은 여전히 하나다. * ========================================================================== */ -import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures_Hash"; +import { structureHashParts, wallSweepAt } from "./B05_Profile_UI_Corridor_Structures_Hash"; import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch"; import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert"; import { @@ -286,7 +286,10 @@ export function buildCorridorStructures( // 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위 // culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다 // (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙). + // C군 벽 = 덮는 측점 중 한 곳에서만 · 시작 ~ 끝 스윕(323-4 · 측점마다 통째로 겹치던 것) + const wallSweep = wallSweepAt(section, crossSections); const revetLayout = + wallSweep && ownWallsDrawAt(section, culvertOwnerFor(section, crossSections)) && builds(section.revetment?.type_id, "wall") ? computeRevetmentLayout(section, section.design?.revet_adjust?.own) @@ -364,18 +367,10 @@ export function buildCorridorStructures( }); }; - if (revetLayout) { + if (revetLayout && wallSweep) { // 다단이면 단마다 솔리드 하나 — 횡단 카드와 같은 폴리곤을 그대로 스윕한다. - for (const tier of revetLayout.tiers) { - pushSwept( - "revet", - tier.polygon, - revetLayout.span.beforeM, - revetLayout.span.afterM, - null, - "own", - ); - } + for (const tier of revetLayout.tiers) + pushSwept("revet", tier.polygon, wallSweep.beforeM, wallSweep.afterM, null, "own"); } /** diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts index 6cbfcd72f..464815174 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts @@ -1,141 +1,79 @@ /* ============================================================================= * B05_Profile_UI_Corridor_Structures_Hash.ts - * 저장 코리도 만료 판정용 **구조물 해시 요약**. + * 저장 코리도 만료 판정용 **구조물 지문** · C군 벽 3D 한 번(323-4). * - * `B05_Profile_UI_Corridor_Structures` 에서 떼어냈다(700줄 제한, 2026-09-02). - * 값이 바뀌면 저장본이 만료되도록 **구조물에 영향을 주는 값만** 이어 붙인다 — - * 빠뜨린 값은 곧 "고쳐도 3D가 안 바뀌는" 버그가 되므로 항목 추가에 주의할 것. - * 원래 파일이 그대로 다시 내보내므로 기존 import 경로는 유효하다. + * 지문 = 그 측점 그림 자료(형상 얹기 한 벌 `SHAPE_KEYS` · 카드 · 상세 창 · B07 · 3D 가 같은 목록) + 형상을 + * 바꾸는 조작값 통째를 키 차례 고정 글로 — 손으로 고른 칸 목록(빠뜨린 칸 = 「고쳐도 3D 가 안 바뀜」)을 걷음. + * 원래 파일(`B05_Profile_UI_Corridor_Structures`)이 그대로 다시 내보내므로 기존 import 경로는 유효하다. * ========================================================================== */ -import type { - ChuteSpec, - CrossSection, - CulvertSideSpec, -} from "../B06_Section/B06_Section_Api_Fetch"; +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import { SHAPE_KEYS } from "../common_util/common_util_structure_shape_keys"; -/** 도수로 · 돌붙임 띠(135-3) — 고른 때만 붙인다(안 고른 측점은 해시 그대로 · 저장본 안 만료). */ -function chuteParts(chute: ChuteSpec | null | undefined): Array { - if (!chute) return []; - // 길이(294-4)는 넣은 때만 — 옛 저장본 해시 그대로 - const base = ["ch", chute.structure, chute.thickness_m]; - return chute.length_m ? [...base, chute.length_m] : base; -} +/** 형상을 바꾸는 조작값(정본 `design` 칸) — 조정창 · 다단 · 연동 · 유입 구조. */ +const ADJUST_KEYS = [ + "revet_adjust", + "box_adjust", + "ford_adjust", + "basin_adjust", + "extra_wall_counts", + "extra_spans", + "revet_follow_grade", + "revet_link_detached", + "inlet_structure", +] as const; -/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */ -export function structureHashParts(section: CrossSection): Array { - // 물넘이 파임·독립 기슭막이는 다른 세트와 함께 설 수 있으니 앞에 이어 붙인다 — - // 빼먹으면 값을 고쳐도 저장 코리도가 만료되지 않는다(2026-08-28). - const extras: Array = []; - const fordPave = section.ford_pavement; - if (fordPave) { - extras.push("fp", fordPave.span_m, fordPave.depth_m ?? "", fordPave.slope_pct ?? ""); - extras.push(...chuteParts(fordPave.chute)); - } - const ownRevet = section.revetment; - if (ownRevet) { - extras.push( - "rv", - ownRevet.start_m, - ownRevet.end_m, - ownRevet.anchor_m, - ownRevet.height_m ?? "", - ownRevet.side ?? "", - ownRevet.form ?? "", - ownRevet.tiers ?? "", - ownRevet.lift_m ?? "", - ownRevet.shift_m ?? "", - // 조정창 조작값이 바뀌면 저장 코리도를 만료시킨다. - JSON.stringify(section.design?.revet_adjust?.own ?? ""), - ); - } - const box = section.box; - // 세월교 · 박스에 흙막이(`culvert`)가 함께 서면 그 값도 이어 붙임(73-4) — 일찍 돌려주지 않음. - if (box) { - extras.push( - "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 ?? "", - // 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다. - ...(["left", "right"] as const).map((side) => { - const value = section.design?.box_adjust?.[side]; - return value ? `${side}:${value.lengthM},${value.riseM}` : ""; - }), - ...chuteParts(box.chute), - ); - } - const ford = section.ford; - if (ford) { - const adjust = section.design?.ford_adjust; - const wall = (role: "inlet" | "outlet"): string => { - const value = adjust?.[role]; - return value ? `${value.heightM ?? ""},${value.lateralM},${value.slopeM}` : ""; - }; - extras.push(`dr:${adjust?.dropM ?? ""}`); - extras.push( - "fd", - ford.diameter_m, - ford.pipe_count, - ford.span_m, - ford.wing_in.slab_extend_m, - ford.wing_out.slab_extend_m, - wall("inlet"), - wall("outlet"), - ...chuteParts(ford.chute), - ); - } - const culvert = section.culvert; - if (!culvert) return extras; - const side = (spec: CulvertSideSpec): string => - [ - spec.structure, - spec.revet_form ?? "", - spec.revet_height_m ?? "", - spec.revet_length_m ?? "", - spec.revet_before_m ?? "", - spec.revet_after_m ?? "", - spec.basin_length_m ?? "", - spec.basin_before_m ?? "", - spec.basin_after_m ?? "", - spec.basin_height_m ?? "", - ].join(","); - const basin = section.design?.basin_adjust; - const revet = section.design?.revet_adjust; - const counts = section.design?.extra_wall_counts; +/** 키 차례를 고정한 JSON — 같은 값이면 같은 글. */ +const stable = (value: unknown): string => + JSON.stringify(value, (_key, item: unknown) => + item && typeof item === "object" && !Array.isArray(item) + ? Object.fromEntries( + Object.keys(item) + .sort() + .map((key) => [key, (item as Record)[key]]), + ) + : item, + ); + +/** 해시 입력 — 구조물이 없는 측점은 빈 목록(저장본 지문 그대로). */ +export function structureHashParts(section: CrossSection): string[] { + const record = section as unknown as Record; + const shapes = SHAPE_KEYS.filter((key) => record[key] != null); + if (!shapes.length) return []; + const design = (section.design ?? {}) as unknown as Record; return [ - ...extras, - "cv", - culvert.diameter_m, - culvert.pipe_kind ?? "", - side(culvert.inlet), - side(culvert.outlet), - section.design?.inlet_structure ?? "", - basin ? `${basin.innerWidthM},${basin.innerHeightM},${basin.lateralM},${basin.slopeM}` : "", - // 조작값이 정본에 실리므로 해시에도 넣는다 — 안 넣으면 저장 코리도가 안 만료된다. - revet - ? Object.keys(revet) - .sort() - .map((role) => { - const value = revet[role]; - return `${role}:${value.x},${value.d ?? ""},${value.h ?? ""},${value.m ?? ""}`; - }) - .join("|") - : "", - counts ? `${counts.outlet},${counts.basin}` : "", - section.design?.revet_follow_grade === false ? "flatZ" : "", - // 다단 단별 구간값·연동 해제도 형상을 바꾼다 — 빠지면 저장 코리도가 안 만료돼 - // 값만 바뀌고 3D는 옛 모양 그대로다(2026-08-30). - Object.entries(section.design?.extra_spans ?? {}) - .sort(([a], [b]) => (a < b ? -1 : 1)) - .map(([wall, span]) => `${wall}:${span.length_m},${span.before_m},${span.after_m}`) - .join("|"), - section.design?.revet_link_detached === true ? "detached" : "", - ...chuteParts(culvert.chute), + stable({ + ...Object.fromEntries(shapes.map((key) => [key, record[key]])), + adjust: Object.fromEntries(ADJUST_KEYS.map((key) => [key, design[key] ?? null])), + }), ]; } + +/** + * C군 벽 3D 한 번 — 그 벽을 얹은 측점 중 기준 측점과 가장 가까운 것(목록 앞쪽 먼저)만 스윕 · + * 스윕 = 시작 ~ 끝(그 측점 기준 전 · 후). 다른 측점은 null(덮는 측점마다 통째로 겹치던 것). + */ +export function wallSweepAt( + section: CrossSection, + sections: readonly CrossSection[], +): { beforeM: number; afterM: number } | null { + const wall = section.revetment; + if (!wall) return null; + const same = (other: CrossSection): boolean => + !!other.revetment && + (wall.structure_id + ? other.revetment.structure_id === wall.structure_id + : other.revetment.start_m === wall.start_m && other.revetment.end_m === wall.end_m); + const gap = (other: CrossSection): number => Math.abs(other.chainage_m - wall.anchor_m); + const owner = sections + .filter(same) + .reduce( + (best, other) => (!best || gap(other) < gap(best) ? other : best), + null, + ); + if (owner !== section) return null; + return { + beforeM: Math.max(section.chainage_m - wall.start_m, 0), + afterM: Math.max(wall.end_m - section.chainage_m, 0), + }; +} diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 9a19ca636..817804c38 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -339,6 +339,8 @@ export interface CrossSection extends SectionStation { pavement?: PavementEntry[]; /** 맹암거 — 기준 측점(288-1). 높이 · 경사 · 측구 없음 · 노면 기울임 · 돌 무늬 형상. */ underdrain?: UnderdrainSpec; + /** 빈칸 경고(323-4 · 읽기 칸 · 저장 안 함) — 「종류 칸」(형상 필수 칸이 빈 관 · 구조물 · 측점 계획 자리). */ + blank?: string[]; } export interface SectionDetailResponse { diff --git a/B06_Section/B06_Section_Engine_Attach.py b/B06_Section/B06_Section_Engine_Attach.py new file mode 100644 index 000000000..6ceebed9c --- /dev/null +++ b/B06_Section/B06_Section_Engine_Attach.py @@ -0,0 +1,105 @@ +"""형상 얹기 한 벌(323-4) — 측점(횡단 dict) + 현황(관 · 구조물) + 구조물 표 → 그 측점 그림 자료. + +구조물 열쇠(`SHAPE_KEYS` · 빈칸 경고 `blank`)를 걷고 차례대로 다시 얹음 — 관 세트 → 벽 → 포장 → 맹암거 → +빈칸 경고. 고르기 = 표 형상(322-1). 서버 상세 읽기(`_read_section_detail`) · 재생성 응답이 이것 하나를 씀. +TS 짝 = `common_util/common_util_structure_attach.ts` `attachShapes`(화면 = 초안 ?? 저장분 현황) · +거울 시험 = `resources/tester/test_323_4_attach_mirror.py`. +""" + +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 B05_Profile.B05_Profile_Structures_Schema import structure_type_map +from B06_Section.B06_Section_Engine_Culvert import ( + attach_culvert_specs, + culvert_sets_from, + pipe_points_file, +) +from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_specs, wall_specs_from +from B06_Section.B06_Section_Engine_Tilt import attach_underdrain_specs, underdrains_from +from common_util.common_util_drainage_pipes import read_pipe_points_file +from common_util.common_util_structure_catalog import catalog_row, field_name, type_ids_with +from common_util.common_util_structure_pavement import ( + attach_pavement_specs, + pavement_specs_from, +) +from common_util.common_util_structure_station import ( + SAME_STATION_M, + blank_keys, + plan_stations, +) + +logger = logging.getLogger(__name__) + +#: 측점 그림 자료 열쇠 — TS `common_util_structure_shape_keys.SHAPE_KEYS` 짝. +SHAPE_KEYS = ("culvert", "ford", "box", "ford_pavement", "revetment", "pavement", "underdrain") + + +def _blank_labels(type_id: str, options: dict | None, types: dict[str, Any]) -> list[str]: + """빈 필수 칸 → 「종류 이름 칸 이름」(TS `blankLabels` 짝).""" + name = (catalog_row(type_id) or {}).get("name") or type_id + ordered = [types[type_id]] if type_id in types else [] + ordered += list(types.values()) + out = [] + for key in blank_keys(type_id, options): + label = next( + (o.label for t in ordered for o in t.options if o.key == key and o.label), None + ) + out.append(f"{name} {label or field_name(key) or key}") + return out + + +def _attach_blanks(sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]) -> None: + """빈칸 경고 — 측점 계획 자리마다 덮는 관 · 구조물의 빈 필수 칸(TS `attachBlanks` 짝).""" + types = structure_type_map() + owners: dict[str, tuple[str, dict | None]] = {} + for pipe in pipes: + owner = getattr(pipe, "id", None) or f"pipe-{float(pipe.chainage_m):.2f}" + owners[owner] = (pipe.facility or "pipe", pipe.options) + for item in structures: + if item.structure_id: + owners[item.structure_id] = (item.type_id, item.options) + for station in plan_stations(pipes, structures): + if not station["blank"]: + continue + labels: list[str] = [] + for owner in station["owners"]: + hit = owners.get(owner) + for label in _blank_labels(hit[0], hit[1], types) if hit else []: + if label not in labels: + labels.append(label) + for section in sections: + at = section.get("chainage_m") + if isinstance(at, (int, float)) and abs(at - station["chainage_m"]) < SAME_STATION_M: + section["blank"] = labels + + +def attach_shapes(sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]) -> None: + """측점마다 그림 자료를 걷고 현황으로 다시 얹음(TS `attachShapes` 짝 · 제자리 고침).""" + for section in sections: + for key in (*SHAPE_KEYS, "blank"): + section.pop(key, None) + attach_culvert_specs(sections, culvert_sets_from(pipes)) + # 관 세트가 선 측점은 벽을 안 얹음(`attach_wall_specs`) — 차례 = 화면과 같음 + attach_wall_specs(sections, wall_specs_from(structures)) + attach_pavement_specs( + sections, + pavement_specs_from([s.model_dump() for s in structures], type_ids_with(shape="pavement")), + ) + attach_underdrain_specs(sections, underdrains_from(structures)) + _attach_blanks(sections, pipes, structures) + + +def attach_saved_shapes(project_root: Path, sections: list[dict[str, Any]]) -> None: + """저장분 현황(관 지점 · 구조물 정본)으로 얹음 — 구조물 정본을 못 읽으면 관만(횡단 조회는 이어 감).""" + pipes = read_pipe_points_file(pipe_points_file(project_root)) + try: + structures = load_structures(str(project_root))[1] + except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다 + logger.exception("B06 구조물 정본을 읽지 못했습니다 (관만 얹음)") + structures = [] + attach_shapes(sections, pipes, structures) diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index a7477cb60..48e1f4b56 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -604,8 +604,13 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]: except (OSError, json.JSONDecodeError): logger.warning("B06 배수관 세트: 관 지점 파일을 읽지 못했습니다 (%s)", path) return {} + return culvert_sets_from(parse_pipe_points(document.get("points"))) + + +def culvert_sets_from(points: list[Any]) -> dict[float, dict[str, Any]]: + """관 지점 목록 → 누가거리(소수 2자리)별 세트 제원(TS `buildCulvertSets` 짝 · 형상 얹기 한 벌 323-4).""" sets: dict[float, dict[str, Any]] = {} - for point in parse_pipe_points(document.get("points")): + for point in points: # 그릴 세트 = 구조물 표 형상(322-1 · TS `buildCulvertSets` 짝) shape = (catalog_row(point.facility) or {}).get("shape") if shape == "pipe_set": @@ -631,7 +636,13 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]: def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]) -> int: """배관이 놓인 측점의 횡단 dict에 `culvert` 키를 얹는다. 얹은 개수를 돌려준다.""" - sets = load_culvert_sets(project_root) + return attach_culvert_specs(cross_sections, load_culvert_sets(project_root)) + + +def attach_culvert_specs( + cross_sections: list[dict[str, Any]], sets: dict[float, dict[str, Any]] +) -> int: + """세트를 측점에 얹는다(TS `attachCulvertSets` 짝). 얹은 개수를 돌려준다.""" if not sets: return 0 attached = 0 diff --git a/B06_Section/B06_Section_Engine_Structures_Wall.py b/B06_Section/B06_Section_Engine_Structures_Wall.py index 2b7087c15..ad661e1df 100644 --- a/B06_Section/B06_Section_Engine_Structures_Wall.py +++ b/B06_Section/B06_Section_Engine_Structures_Wall.py @@ -66,59 +66,60 @@ def tier_extras(type_id: str, options: dict[str, Any]) -> list[dict[str, Any]]: ] -# 소단 타입 id — 레지스트리와 한 벌이다(C군이지만 벽이 아니다). -BERM_TYPE_ID = "berm" - - def load_wall_structures(project_root: Path) -> list[dict[str, Any]]: """구조물 정본에서 벽(표 형상 wall · 322-1) 목록을 읽는다. 실패하면 빈 목록. 소단은 형상 berm — 사면을 계단으로 끊는 시설이라 벽 제원 자리에 안 얹음(계획서 3-9). """ try: - types = structure_type_map() - found: list[dict[str, Any]] = [] - for structure in load_structures(str(project_root))[1]: - definition = types.get(structure.type_id) - if definition is None or (catalog_row(structure.type_id) or {}).get("shape") != "wall": - continue - start, end = structure.start_m, structure.end_m - 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, - "type_id": structure.type_id, - "name": definition.name, - "start_m": float(min(start, end)), - "end_m": float(max(start, end)), - "anchor_m": float(structure.anchor_m()), - "form": options.get("form") or _wall_form(structure.type_id), - "height_m": options.get("height_m"), - # C군 폼에는 설치 측 칸이 없다 — 비워 두면 화면이 **성토가 나는 쪽**으로 - # 세운다(`computeRevetmentLayout`). 사용자가 정하고 싶어지면 그때 칸을 낸다. - "side": options.get("side"), - # 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다. - # 비어 있으면 화면이 터파기를 안 그린다(근거 없음). - "foundation": options.get("foundation"), - # 단 수 = 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 짝 같은 키). - "face_role": options.get("face_role"), - "face_slope_ratio": options.get("face_slope_ratio"), - } - ) - return found + return wall_specs_from(load_structures(str(project_root))[1]) except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다 logger.exception("B06 구조물 벽 정본을 읽지 못했습니다 (없는 것으로 본다)") return [] +def wall_specs_from(structures: list[Any]) -> list[dict[str, Any]]: + """구조물 목록 → 벽 제원(TS `wallSpecsFrom` 짝 · 형상 얹기 한 벌 323-4).""" + types = structure_type_map() + found: list[dict[str, Any]] = [] + for structure in structures: + definition = types.get(structure.type_id) + if definition is None or (catalog_row(structure.type_id) or {}).get("shape") != "wall": + continue + start, end = structure.start_m, structure.end_m + 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, + "type_id": structure.type_id, + "name": definition.name, + "start_m": float(min(start, end)), + "end_m": float(max(start, end)), + "anchor_m": float(structure.anchor_m()), + "form": options.get("form") or _wall_form(structure.type_id), + "height_m": options.get("height_m"), + # C군 폼에는 설치 측 칸이 없다 — 비워 두면 화면이 **성토가 나는 쪽**으로 + # 세운다(`computeRevetmentLayout`). 사용자가 정하고 싶어지면 그때 칸을 낸다. + "side": options.get("side"), + # 기초 축(기초유/기초버림) — 터파기 그림·물량이 같이 쓴다. + # 비어 있으면 화면이 터파기를 안 그린다(근거 없음). + "foundation": options.get("foundation"), + # 단 수 = 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 짝 같은 키). + "face_role": options.get("face_role"), + "face_slope_ratio": options.get("face_slope_ratio"), + } + ) + return found + + def load_berm_spans(project_root: Path) -> list[tuple[float, float, dict[str, float]]] | None: """구조물 정본의 소단 구간 `[(시작, 끝, 제원)]` — [저장] 서버 다시 셈의 소단 원천(194-1). @@ -142,7 +143,7 @@ def load_berm_spans(project_root: Path) -> list[tuple[float, float, dict[str, fl try: spans: list[tuple[float, float, dict[str, float]]] = [] for item in load_structures(str(project_root))[1]: - if item.type_id != BERM_TYPE_ID: + if (catalog_row(item.type_id) or {}).get("shape") != "berm": # 표 형상(323-4) continue anchor = item.chainage_m if item.chainage_m is not None else item.start_m start = item.start_m if item.start_m is not None else anchor @@ -190,7 +191,14 @@ def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, An 관 유입·유출 벽과 구조물 벽이 한 자리에 겹치면 어느 쪽 그림인지 읽히지 않는다. 한 측점에 여러 개가 겹치면 먼저 시작한 것을 쓴다(겹침 정리는 사용자 몫). """ - walls = load_wall_structures(project_root) + attached = attach_wall_specs(cross_sections, load_wall_structures(project_root)) + if attached: + logger.info("B06 구조물 벽 %d개 측점에 얹음", attached) + return attached + + +def attach_wall_specs(cross_sections: list[dict[str, Any]], walls: list[dict[str, Any]]) -> int: + """구간 안 측점에 벽 제원을 얹는다(TS `attachWallSpecs` 짝). 얹은 개수를 돌려준다.""" if not walls: return 0 attached = 0 @@ -209,6 +217,4 @@ def attach_wall_structures(project_root: Path, cross_sections: list[dict[str, An section["revetment"] = spec attached += 1 break - if attached: - logger.info("B06 구조물 벽 %d개 측점에 얹음 (구조물 %d건)", attached, len(walls)) return attached diff --git a/B06_Section/B06_Section_Engine_Tilt.py b/B06_Section/B06_Section_Engine_Tilt.py index ef57abf16..fb0152f13 100644 --- a/B06_Section/B06_Section_Engine_Tilt.py +++ b/B06_Section/B06_Section_Engine_Tilt.py @@ -42,6 +42,11 @@ def load_underdrains(project_root: Path) -> list[dict[str, Any]]: except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다 logger.exception("B06 맹암거를 읽지 못했습니다 (없음으로 본다)") return [] + return underdrains_from(structures) + + +def underdrains_from(structures: list[Any]) -> list[dict[str, Any]]: + """구조물 목록의 맹암거 제원(TS `underdrainSpecsFrom` 짝 · 형상 얹기 한 벌 323-4).""" found: list[dict[str, Any]] = [] for structure in structures: if (catalog_row(structure.type_id) or {}).get("shape") != "underdrain": @@ -85,7 +90,13 @@ def tilt_args(chainage_m: float, ranges: list[TiltRange]) -> dict[str, Any]: def attach_underdrains(project_root: Path, cross_sections: list[dict[str, Any]]) -> int: """맹암거 기준 측점의 횡단 dict 에 `underdrain` 을 얹는다. 얹은 개수를 돌려준다.""" - drains = load_underdrains(project_root) + return attach_underdrain_specs(cross_sections, load_underdrains(project_root)) + + +def attach_underdrain_specs( + cross_sections: list[dict[str, Any]], drains: list[dict[str, Any]] +) -> int: + """기준 측점에 맹암거 제원을 얹는다(TS `attachUnderdrainSpecs` 짝). 얹은 개수를 돌려준다.""" attached = 0 for section in cross_sections: chainage = _number(section.get("chainage_m")) diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 7c9256058..f80a2a120 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -19,14 +19,13 @@ from B05_Profile.B05_Profile_Engine_Sections import ( run_section_generation, ) from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions -from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets +from B06_Section.B06_Section_Engine_Attach import attach_saved_shapes from B06_Section.B06_Section_Engine_Design import ( compute_cross_design, curve_widening_args, 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_Engine_Tilt import tilt_args, tilt_ranges from B06_Section.B06_Section_Repository import ( count_cross_sections, create_longitudinal_section, @@ -87,7 +86,6 @@ from B06_Section.B06_Section_Schema import ( from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for, haul_limits_for from common_util.common_util_auth import verify_session from common_util.common_util_storage import resolve_stored_project_path -from common_util.common_util_structure_pavement import attach_pavement_structures from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_workflow_state import get_workflow_state from config.config_db import get_db_pool, run_with_connection @@ -278,15 +276,9 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic isinstance(section, dict) for section in cross_sections ): raise ValueError("종횡단 상세 파일 형식이 올바르지 않습니다.") - # 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다. - # 독립 기슭막이도 2026-08-28 이관으로 **관 숨김 세트**로 여기 함께 얹힌다(pipe_points). - attach_culvert_sets(root, cross_sections) + # 측점 그림 자료(관 세트 · 벽 · 포장 · 맹암거 · 빈칸 경고) = 형상 얹기 한 벌(323-4 · 화면 짝 attachShapes) + attach_saved_shapes(root, cross_sections) relabel_pipe_stations(root, cross_sections) # 저장으로 종류만 바꾼 관 — 카드 제목(54-4) - # 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 — - # 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(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} @@ -446,11 +438,8 @@ async def regenerate_sections( await connection.rollback() raise result = sections["result"] - # 재생성 응답도 상세 조회와 같은 배수관 세트·구조물 벽 정보를 실어야 화면이 어긋나지 않는다. - 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"]) + # 재생성 응답도 상세 조회와 같은 형상 얹기 한 벌(323-4) — 화면이 어긋나지 않게. + attach_saved_shapes(project_root, result["cross_sections"]) return SectionDetailResponse( longitudinal=result["longitudinal"], cross_sections=result["cross_sections"] ) diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index 9eb580c75..633c9d7a9 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -8,8 +8,8 @@ * 안 씀) — [저장] 때 서버가 같은 계산으로 정본에 씀. 한 번 뜬 자리는 기억해 다시 안 물음. * * 심는 자리 · 카드 이름 = 측점 계획 한 벌(`planStations` · 서버 `resolve_extra_stations` 짝 · 322-3) · - * 관 그림 = 짝 산식 `common_util_culvert_sets`. 일반 측점 자리(No.N+0 · BP · EP)에 새로 놓은 구조물도 - * 그 자리 구조물 없는 사본에 이름 · 형상(219 흠 · 322-3). + * 그림 자료 = 형상 얹기 한 벌(`withDraftShapes` → `attachShapes` · 서버 `attach_shapes` 짝 · 323-4). 일반 측점 + * 자리(No.N+0 · BP · EP)에 새로 놓은 구조물도 그 자리 구조물 없는 사본에 이름 · 형상(219 흠 · 322-3). * ========================================================================== */ import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; @@ -21,11 +21,8 @@ import { type StructureType, } from "../B05_Profile/B05_Profile_Api_Structures"; import { readPendingPipes, type PendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; -import { - attachCulvertSets, - buildCulvertSets, - registryDefaults, -} from "@util/common_util_culvert_sets"; +import { registryDefaults } from "@util/common_util_culvert_sets"; +import { attachShapes } from "@util/common_util_structure_attach"; import { pipeStationMarks, planStations, @@ -119,14 +116,8 @@ function bareOf(section: CrossSection): CrossSection { return copy; } -/** 미리 뜬 횡단 · 일반 측점 자리 사본에 카드 이름 · 관 그림 — 서버가 [저장] 때 붙이는 것과 같은 규칙. */ -function decorate( - fresh: CrossSection[], - pipes: Pipes, - planned: readonly PlannedStation[], - types: StructureType[], -): void { - const registry = registryDefaults(types); +/** 미리 뜬 횡단 · 일반 측점 자리 사본에 카드 이름(그림 자료는 `withDraftShapes` 가 모든 측점에 한 번). */ +function decorate(fresh: CrossSection[], planned: readonly PlannedStation[]): void { for (const section of fresh) { section.preview = true; // 카드 이름 = 측점 계획 이름(322-3 · 같은 m 는 관이 먼저) @@ -134,18 +125,41 @@ function decorate( if (hit) section.structure = hit.title; else delete section.structure; // 구조물이 다 빠진 일반 측점 자리(217) } - const own = pipes.filter((pipe) => - fresh.some((section) => near(section.chainage_m, pipe.chainage_m)), - ); - // 미리 뜬 횡단은 한 벌을 기억해 다시 씀 — 얹기 전에 관 계열 제원을 비움(앞 종류 제원이 남아 - // 겹치던 것 · 서버가 얹어 온 저장분 종류도 · 열쇠 = `Section_Store` FACILITY_KEYS 와 같음 · 317-3) - for (const section of fresh as unknown as Array>) { - for (const key of ["culvert", "ford", "box", "ford_pavement"]) delete section[key]; - } - attachCulvertSets( - fresh as unknown as Array>, - buildCulvertSets(own, registry), - ); +} + +/** 현황 = 종류마다 초안 ?? 저장분 + 종류 표 — 측점 계획 · 형상 얹기가 같은 것(못 받으면 null). */ +function statusOf( + projectId: string, + pipesDraft: Pipes | null, + structuresDraft: StructureInstance[] | null, +): Promise<[Pipes, StructureInstance[], StructureType[]] | null> { + return Promise.all([ + pipesDraft ?? savedOf(projectId).then(([pipes]) => pipes), + structuresDraft ?? savedOf(projectId).then(([, structures]) => structures), + fetchStructureTypes(true), + ]).catch(() => null); +} + +/** 초안으로 그림 자료를 얹은 상세 — 초안이 없어지면 저장분으로 한 번 다시 얹음. */ +const drafted = new WeakSet(); + +/** + * 모든 측점 그림 자료 = 형상 얹기 한 벌(323-4 · 서버 `attach_shapes` 짝) — 현황 = 초안 ?? 저장분 · + * 저장 전 그림 = 저장 뒤 그림. 초안이 없고 앞서 초안으로 얹은 적도 없으면 서버가 얹은 그대로. + */ +export async function withDraftShapes( + detail: SectionDetailResponse, + projectId: string, +): Promise { + const pipesDraft = readPendingPipes(projectId); + const structuresDraft = readPendingStructures(projectId); + const drafts = !!(pipesDraft || structuresDraft); + if (!drafts && !drafted.has(detail)) return; + const inputs = await statusOf(projectId, pipesDraft, structuresDraft); + if (!inputs) return; + attachShapes(detail.cross_sections as unknown as Array>, ...inputs); + if (drafts) drafted.add(detail); + else drafted.delete(detail); } /** @@ -170,11 +184,7 @@ export async function withDraftStations( let stale: CrossSection[] = []; let fresh: CrossSection[] = []; if (pipesDraft || structuresDraft) { - const inputs = await Promise.all([ - pipesDraft ?? savedOf(projectId).then(([pipes]) => pipes), - structuresDraft ?? savedOf(projectId).then(([, structures]) => structures), - fetchStructureTypes(true), - ]).catch(() => null); + const inputs = await statusOf(projectId, pipesDraft, structuresDraft); if (inputs) { const [pipes, structures, types] = inputs; const planned = planStations(pipes, structures, types, registryDefaults(types)); @@ -226,7 +236,7 @@ export async function withDraftStations( live.every((section) => !near(section.chainage_m, at)), ); fresh = missing.length ? await previewOf(projectId, routeId, missing).catch(() => []) : []; - if (fresh.length || bare.length) decorate([...fresh, ...bare], pipes, planned, types); + if (fresh.length || bare.length) decorate([...fresh, ...bare], planned); } } if (turns.get(detail) !== turn) return; // 더 새 맞춤이 돎 — 그쪽이 얹음 diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 0767293a2..a752b2bce 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -18,35 +18,13 @@ * ========================================================================== */ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; -import { - fetchStructureTypes, - readPendingStructures, -} from "../B05_Profile/B05_Profile_Api_Structures"; -import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; -import { - attachCulvertSets, - buildCulvertSets, - registryDefaults, -} from "@util/common_util_culvert_sets"; -import { - attachWallSpecs, - wallSpecsFrom, - type WallStructureInput, -} from "@util/common_util_structure_walls"; -import { - attachPavementSpecs, - pavementSpecsFrom, - type PavementStructureInput, -} from "@util/common_util_structure_pavement"; import { setGeneralStations } from "../common_util/common_util_structure_station"; -import { cardTitle, catalogRow } from "../common_util/common_util_structure_catalog"; import { - attachUnderdrainSpecs, - underdrainSpecsFrom, - type UnderdrainInput, -} from "../common_util/common_util_structure_underdrain"; -import { forgetDraftStations, withDraftStations } from "./B06_Section_Section_Drafts"; + forgetDraftStations, + withDraftShapes, + withDraftStations, +} from "./B06_Section_Section_Drafts"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; import type { CrossSectionPatch, SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { fetchSectionDetail, saveSections } from "./B06_Section_Api_Fetch"; @@ -61,12 +39,11 @@ function keyOf(projectId: string, routeId: number): string { } /** - * 아직 저장하지 않은 구조물(C군 벽)을 상세에 얹는다 — **여기가 유일한 자리**다. + * 초안(관 · 구조물)을 상세에 얹는다 — **여기가 유일한 자리**다. * - * 서버는 저장분만 얹어 준다(`attach_wall_structures`). 초안이 있으면 그것이 화면의 - * 정본이므로, 캐시·세션·새 요청 어느 길로 들어오든 이 함수를 지나 같은 상태가 된다. - * 페이지마다 따로 얹으면 새로고침 뒤 세션 스냅샷이 저장분 기준으로 되돌아간다 - * (2026-09-06 실측). 산식은 서버와 짝(`common_util_structure_walls`). + * 서버는 저장분만 얹어 준다. 초안이 있으면 그것이 화면의 정본이므로, 캐시·세션·새 요청 어느 길로 + * 들어오든 이 함수를 지나 같은 상태가 된다(2026-09-06 실측). 측점 = 측점 계획 한 벌(`withDraftStations`) · + * 그림 자료 = 형상 얹기 한 벌(`withDraftShapes` · 서버 `attach_shapes` 짝 · 323-4). */ async function withDraftWalls( detail: SectionDetailResponse, @@ -75,109 +52,10 @@ async function withDraftWalls( ): Promise { setGeneralStations(detail.longitudinal?.stations); // 구조물 기준 측점이 붙을 일반 측점(234-1) await withDraftStations(detail, projectId, routeId); // 옛 자리 숨김 · 새 자리 미리 뜬 횡단(273-2) - const drafts = readPendingStructures(projectId); - // 빈 목록 = 사용자가 다 지움 → 저장분 벽 · 노면 · 맹암거도 걷음(목록 `??` 규칙과 같음 · 312). - // 옛 「놓아도 안 보임」(09-09)은 옛 배열 참조가 `[]` 를 알리던 것 — 지금 `setStructures` 는 - // 배열 안을 채우고(갈아끼우지 않음) 알리지 않음 · 시험 `test_312_empty_pipes` 가 지킴. - await withDraftPipeKinds(detail, projectId); - if (!drafts) return withStructureAreas(detail); - // 맹암거(288-1) — 초안 기준 측점으로 제원을 다시 얹음(짝 `common_util_structure_underdrain`) - attachUnderdrainSpecs( - detail.cross_sections as unknown as Array>, - underdrainSpecsFrom(drafts as unknown as UnderdrainInput[]), - ); - // 표 형상(322-1) — 「사용 중」 거르기 없음(꺼진 종류도 그 그림대로 · 쇄석 포장층) - const types = await fetchStructureTypes(true).catch(() => []); - const shaped = (shape: string) => types.filter((t) => catalogRow(t.type_id)?.shape === shape); - // 노면공(88) — 초안 범위로 포장 제원을 다시 얹음(짝 `common_util_structure_pavement`) - const paveTypes = new Set(shaped("pavement").map((type) => type.type_id)); - if (paveTypes.size) { - for (const section of detail.cross_sections) delete section.pavement; - attachPavementSpecs( - detail.cross_sections as unknown as Array>, - pavementSpecsFrom(drafts as unknown as PavementStructureInput[], paveTypes), - ); - } - // 벽 = 표 형상 wall(소단은 형상 berm — 사면을 계단으로 끊는 시설이라 벽 자리에 안 얹음 · 계획서 3-9) - const names = new Map(shaped("wall").map((type) => [type.type_id, type.name] as const)); - if (!names.size) return detail; - for (const section of detail.cross_sections) { - if (section.revetment) delete (section as { revetment?: unknown }).revetment; - } - attachWallSpecs( - detail.cross_sections as unknown as Array>, - wallSpecsFrom(drafts as unknown as WallStructureInput[], names), - ); + await withDraftShapes(detail, projectId); return withStructureAreas(detail); } -/** 관 지점 시설 → 횡단 자리 키 = 표 형상(파이썬 `_SECTION_KEYS` · 배관 세트 · 독립 기슭막이 = culvert). */ -const FACILITY_KEYS = ["culvert", "ford", "box", "ford_pavement"] as const; -const SHAPE_KEY: Record = { - box: "box", - ford: "ford", - ford_pavement: "ford_pavement", -}; -const shapeOf = (facility: string | null | undefined): string => - catalogRow(facility ?? "pipe")?.shape ?? "pipe_set"; -const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] => - SHAPE_KEY[shapeOf(facility)] ?? "culvert"; - -/** - * 저장 전에 **종류를 바꾼 관**(배관 ↔ BOX암거 ↔ 세월교 ↔ 물넘이포장 · 54-4)을 상세에 얹는다. - * 서버는 저장분 종류로만 얹어 준다 — 초안 종류와 다른 관만 옛 자리를 걷고 짝 산식 - * (`common_util_culvert_sets`)으로 새 종류 제원을 얹는다. 옛 종류 칸 값은 섞지 않는다. - */ -async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: string): Promise { - const pipes = readPendingPipes(projectId); - if (!pipes) return; - const sections = detail.cross_sections as unknown as Array>; - const near = (spec: Record, section: Record, at: number) => - Math.abs(Number(spec.chainage_m ?? section.chainage_m) - at) < PIPE_MATCH_M; - // 물넘이포장(구간형 · 걸친 측점마다 얹힘) — 옮기거나 지운 옛 자리는 걷음 · 새 자리는 아래에서 얹음(314) - for (const section of sections) { - const spec = section.ford_pavement as Record | undefined; - const kept = (pipe: (typeof pipes)[number]) => - shapeOf(pipe.facility) === "ford_pavement" && !!spec && near(spec, section, pipe.chainage_m); - if (spec && !pipes.some(kept)) delete section.ford_pavement; - } - /** 그 관 자리에 얹힌 시설 키가 이 종류와 맞는가 — 얹힌 것이 없으면(새 관 · 옮긴 관) 대상 밖. */ - const fits = (pipe: (typeof pipes)[number], key: string, spec: Record) => - key === facilityKey(pipe.facility ?? "pipe") && - (key !== "culvert" || - (spec.hidden_pipe === true) === (shapeOf(pipe.facility) === "hidden_revet")); - const stale = pipes.filter((pipe) => { - const here = sections.flatMap((section) => - FACILITY_KEYS.flatMap((key) => { - const spec = section[key] as Record | undefined; - return spec && near(spec, section, pipe.chainage_m) ? [[key, spec] as const] : []; - }), - ); - // 얹힌 것이 없는 관(새 관 · 옮긴 관)은 미리 뜬 횡단이 맡음 — 물넘이포장만 걸친 기존 측점까지 여기서(314) - if (!here.length) return shapeOf(pipe.facility) === "ford_pavement"; - return !here.some(([key, spec]) => fits(pipe, key, spec)); - }); - if (!stale.length) return; - const types = await fetchStructureTypes(true).catch(() => []); - const registry = registryDefaults(types); - for (const section of sections) { - for (const key of FACILITY_KEYS) { - const spec = section[key] as Record | undefined; - if (spec && stale.some((pipe) => near(spec, section, pipe.chainage_m))) delete section[key]; - } - // 카드 제목 구조물 이름 = 표 카드 이름(서버 `pipe_station_label` 짝 · 배관 = 관종 D관경 · 그 밖 = 종류 이름) - // 라벨 자리 = 이름 있는 측점 · 관 자리 그 측점 - const pipe = stale.find((entry) => { - const gap = Math.abs(entry.chainage_m - Number(section.chainage_m)); - return section.structure ? gap < PIPE_MATCH_M : gap < 0.01; - }); - if (!pipe) continue; - const facility = pipe.facility ?? "pipe"; - section.structure = cardTitle(facility, pipe.options, registry[facility]); - } - attachCulvertSets(sections, buildCulvertSets(stale, registry)); -} - /** * 구조물이 선 측점의 절·성토 면적을 **읽어 오는 자리에서 한 번** 다시 얹는다. * @@ -225,17 +103,6 @@ export async function loadSectionDetail( return request; } -/** 서버가 새 상세를 통째로 돌려준 조작(재생성 등) 뒤 캐시를 그 값으로 바꾼다. */ -export function replaceSectionDetail( - projectId: string, - routeId: number, - detail: SectionDetailResponse, -): void { - cache.set(keyOf(projectId, routeId), detail); - writeState("section-detail", detail, projectId, routeId); - setGeneralStations(detail.longitudinal?.stations); -} - /** * 캐시를 비운다. routeId를 생략하면 그 프로젝트 전부 — * 서버 쪽 정본이 바뀌었는데 새 상세를 손에 못 쥔 조작(계획선 편집 저장 등) 뒤에 쓴다. diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index e3e67923c..0fdf3e559 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -67,20 +67,13 @@ function facilityNotes(section: CrossSection): Array<[string, string]> { `B05 시설 칸에 안 적어 기본값으로 그림 — ${items.join(" · ")} · 적으면 그 값으로 그림`, ]); } - // 세월교 · 박스 흙막이는 높이 빈 칸 = 본체 끝 높이(79-7)라 안내 안 함 - const own = section.culvert?.hidden_pipe && !section.culvert.body ? section.culvert : null; - if (own && [own.inlet, own.outlet].some((side) => side.structure && !side.revet_height_m)) { + // 빈칸 경고 한 꼴(323-4) — 형상 필수 칸이 빈 관 · 구조물(관경 · 벽 · 기슭막이 높이 · 맹암거 높이 · 월류 + // 높이 · 형상 얹기가 측점 계획 자리에 얹음). 세월교 · 박스 흙막이 높이 빈 칸 = 본체 끝 높이(79-7)라 안 셈 + for (const item of section.blank ?? []) notes.push([ - "⚠ 기슭막이 높이 없음 — 근입만 그림", - "독립 기슭막이 높이는 설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 비우면 수량도 미확정 · B05 시설 칸에 적으면 그림", + `⚠ ${item} 빈칸`, + `${item}을 안 적어 그 그림을 안 그림(수치를 지어내지 않음 · 수량도 미확정) — 시설 칸에 적으면 그림`, ]); - } - if (section.ford_pavement && !section.ford_pavement.depth_m) { - notes.push([ - "⚠ 월류 높이 없음 — 파임 안 그림", - "물넘이포장 월류 높이를 안 적어 파인 노면을 안 그림(수치를 지어내지 않음) — B05 시설 칸에 월류 높이를 적으면 그림", - ]); - } return notes; } diff --git a/B06_Section/B06_Section_UI_Page_Common.ts b/B06_Section/B06_Section_UI_Page_Common.ts index 97e343e27..5f4f7f01b 100644 --- a/B06_Section/B06_Section_UI_Page_Common.ts +++ b/B06_Section/B06_Section_UI_Page_Common.ts @@ -1,11 +1,7 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements"; -import { - fetchSectionDetail, - regenerateSections, - type SectionDetailResponse, -} from "./B06_Section_Api_Fetch"; -import { replaceSectionDetail } from "./B06_Section_Section_Store"; +import { regenerateSections, type SectionDetailResponse } from "./B06_Section_Api_Fetch"; +import { loadSectionDetail } from "./B06_Section_Section_Store"; export function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -38,10 +34,9 @@ export function createSampleWidener(deps: SampleWidenerDeps): (widthM: number) = showLoadingOverlay(); try { await regenerateSections(target.projectId, target.routeId, widthM); - // 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다. - const fresh = await fetchSectionDetail(target.projectId, target.routeId); - // 공유 캐시도 새 상세로 바꿔 B05가 옛 값을 못 보게 한다. - replaceSectionDetail(target.projectId, target.routeId, fresh); + // 재생성 응답에는 설계가 없다 — 상세를 다시 받아 기본 설계 프리뷰까지 얹는다 · 공유 캐시도 + // 새 상세로(B05 가 옛 값을 못 보게) · 초안 측점 · 그림 자료도 같은 길(323-4) + const fresh = await loadSectionDetail(target.projectId, target.routeId, { force: true }); deps.applyDetail(fresh); showToast(L("B06_Profile_Regenerate_Success"), "success"); return true; diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 0ae24a0b0..152a6fb81 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -48,6 +48,7 @@ import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; +import { catalogRow } from "../common_util/common_util_structure_catalog"; /** * 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체. @@ -104,8 +105,8 @@ export function writeBermSpans(projectId: string, routeId: number, spans: BermSp writeState("berm", spans, projectId, routeId); } -/** 소단 타입 id — 레지스트리(C군 사면안정)와 한 벌이다. */ -export const BERM_TYPE_ID = "berm"; +/** 소단 = 표 형상 berm(323-4 · 파이썬 `load_berm_spans` 짝) — 종류 키로 안 가름. */ +export const isBermType = (typeId: string): boolean => catalogRow(typeId)?.shape === "berm"; /** * 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다 @@ -126,7 +127,7 @@ export function bermSpansFromStructures( ): BermSpan[] { const spans: BermSpan[] = []; for (const item of structures) { - if (item.type_id !== BERM_TYPE_ID) continue; + if (!isBermType(item.type_id)) continue; const anchor = item.chainage_m ?? item.start_m ?? null; const start = item.start_m ?? anchor; const end = item.end_m ?? anchor; diff --git a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts index 954eb5130..810b76db7 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Selection.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Selection.ts @@ -10,7 +10,7 @@ import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; import type { RevetPick } from "./B06_Section_UI_Cross_Culvert"; import type { B06StructuresPanel } from "./B06_Section_UI_Page_Structures_Panel"; import { setBermPickHost } from "./B06_Section_UI_Cross_Design_Pick"; -import { BERM_TYPE_ID } from "./B06_Section_UI_Page_Persist"; +import { isBermType } from "./B06_Section_UI_Page_Persist"; /** * 횡단도 선택 → 좌측 폼 연동 배선. 벽(기슭막이)·세월교·BOX 선택이 일어나면 그 @@ -200,7 +200,7 @@ export function wireStructureSelection( .forEach((box) => box.classList.remove("is-active")); if (chainageM === null) return; const berm = panel.getStructures().find((item) => { - if (item.type_id !== BERM_TYPE_ID) return false; + if (!isBermType(item.type_id)) return false; const start = item.start_m ?? item.chainage_m ?? -1; const end = item.end_m ?? item.chainage_m ?? -1; return chainageM >= Math.min(start, end) - 1e-6 && chainageM <= Math.max(start, end) + 1e-6; diff --git a/common_util/common_util_structure_attach.ts b/common_util/common_util_structure_attach.ts new file mode 100644 index 000000000..4aa882008 --- /dev/null +++ b/common_util/common_util_structure_attach.ts @@ -0,0 +1,124 @@ +/* ============================================================================= + * common_util/common_util_structure_attach.ts + * 형상 얹기 한 벌(323-4) — 측점(횡단) + 현황(관 · 구조물) + 구조물 표 → 그 측점 그림 자료. + * + * 구조물 열쇠(`SHAPE_KEYS` · 빈칸 경고 `blank`)를 걷고 서버와 같은 차례로 다시 얹음 — 관 세트 → 벽 → + * 포장 → 맹암거 → 빈칸 경고. 고르기 = 표 형상(322-1). 화면은 초안 ?? 저장분 현황으로 이것 하나를 씀 + * (`B06_Section_Section_Drafts.withDraftShapes`) — 저장 전 그림 = 저장 뒤 그림. + * 파이썬 짝 = `B06_Section/B06_Section_Engine_Attach.py` `attach_shapes`(서버 상세 읽기 · 재생성 응답) · + * 거울 시험 = `resources/tester/test_323_4_attach_mirror.py`. + * ========================================================================== */ + +import { + attachCulvertSets, + buildCulvertSets, + registryDefaults, + type CulvertPipePoint, +} from "./common_util_culvert_sets"; +import { + attachWallSpecs, + wallSpecsFrom, + type WallStructureInput, +} from "./common_util_structure_walls"; +import { + attachPavementSpecs, + pavementSpecsFrom, + type PavementStructureInput, +} from "./common_util_structure_pavement"; +import { + attachUnderdrainSpecs, + underdrainSpecsFrom, + type UnderdrainInput, +} from "./common_util_structure_underdrain"; +import { catalogRow, fieldName } from "./common_util_structure_catalog"; +import { + blankKeys, + planStations, + SAME_STATION_M, + type PlanPipe, + type PlanStructure, + type PlanType, +} from "./common_util_structure_station"; +import { SHAPE_KEYS } from "./common_util_structure_shape_keys"; + +export { SHAPE_KEYS }; + +export interface AttachType extends PlanType { + name: string; + options: ReadonlyArray<{ key: string; label?: string; default: unknown }>; +} + +/** 빈 필수 칸 → 「종류 이름 칸 이름」(칸 이름 = 그 종류 정의 → 다른 종류 정의 → 부품 상자 → 키). */ +function blankLabels( + typeId: string, + options: Record | null | undefined, + types: ReadonlyArray, +): string[] { + const name = catalogRow(typeId)?.name ?? typeId; + const ordered = [...types.filter((t) => t.type_id === typeId), ...types]; + return blankKeys(typeId, options).map((key) => { + const option = ordered.flatMap((t) => t.options).find((o) => o.key === key); + return `${name} ${option?.label ?? fieldName(key) ?? key}`; + }); +} + +/** 빈칸 경고 — 측점 계획 자리마다 덮는 관 · 구조물의 빈 필수 칸(파이썬 `_attach_blanks` 짝). */ +function attachBlanks( + sections: Array>, + pipes: ReadonlyArray, + structures: ReadonlyArray, + types: ReadonlyArray, +): void { + const owners = new Map | null | undefined]>(); + for (const pipe of pipes) + owners.set(pipe.id ?? `pipe-${pipe.chainage_m.toFixed(2)}`, [ + pipe.facility ?? "pipe", + pipe.options, + ]); + for (const item of structures) + if (item.structure_id) owners.set(item.structure_id, [item.type_id, item.options]); + for (const station of planStations(pipes, structures, types)) { + if (!station.blank.length) continue; + const labels = [ + ...new Set( + station.owners.flatMap((owner) => { + const hit = owners.get(owner); + return hit ? blankLabels(hit[0], hit[1], types) : []; + }), + ), + ]; + for (const section of sections) + if (Math.abs(Number(section.chainage_m) - station.chainage_m) < SAME_STATION_M) + section.blank = labels; + } +} + +/** 측점마다 그림 자료를 걷고 현황으로 다시 얹음(파이썬 `attach_shapes` 짝 · 제자리 고침). */ +export function attachShapes( + sections: Array>, + pipes: ReadonlyArray, + structures: ReadonlyArray, + types: ReadonlyArray, +): void { + for (const section of sections) for (const key of [...SHAPE_KEYS, "blank"]) delete section[key]; + const registry = registryDefaults(types); + attachCulvertSets(sections, buildCulvertSets(pipes as readonly CulvertPipePoint[], registry)); + const shaped = (shape: string) => types.filter((t) => catalogRow(t.type_id)?.shape === shape); + // 관 세트가 선 측점은 벽을 안 얹음(`attachWallSpecs`) — 차례 = 서버와 같음 + attachWallSpecs( + sections, + wallSpecsFrom( + structures as unknown as WallStructureInput[], + new Map(shaped("wall").map((t) => [t.type_id, t.name] as const)), + ), + ); + attachPavementSpecs( + sections, + pavementSpecsFrom( + structures as unknown as PavementStructureInput[], + new Set(shaped("pavement").map((t) => t.type_id)), + ), + ); + attachUnderdrainSpecs(sections, underdrainSpecsFrom(structures as UnderdrainInput[])); + attachBlanks(sections, pipes, structures, types); +} diff --git a/common_util/common_util_structure_catalog.py b/common_util/common_util_structure_catalog.py index c58199897..3ed7ea502 100644 --- a/common_util/common_util_structure_catalog.py +++ b/common_util/common_util_structure_catalog.py @@ -99,6 +99,11 @@ def type_ids_with(**match: str) -> set[str]: return {key for key, row in catalog().items() if all(row[k] == v for k, v in match.items())} +def field_name(key: str) -> str | None: + """어느 종류 정의에도 없는 읽는 칸의 화면 글(부품 상자 `칸 이름` · 빈칸 경고 323-4 · TS `fieldName` 짝).""" + return (load_parts().get("칸 이름") or {}).get(key) + + def shape_needs(type_id: str) -> list[str]: """형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — TS `shapeNeeds` 짝.""" row = catalog_row(type_id) diff --git a/common_util/common_util_structure_catalog.ts b/common_util/common_util_structure_catalog.ts index cd0a0a0c4..9c0dfccd3 100644 --- a/common_util/common_util_structure_catalog.ts +++ b/common_util/common_util_structure_catalog.ts @@ -171,6 +171,10 @@ export function cardTitle( } /** 종류가 지금 쓰는 형상 부품의 필수 칸(비면 그 그림이 안 섬 · 빈칸 경고 · 322-3) — 없으면 빈 목록. */ +/** 어느 종류 정의에도 없는 읽는 칸의 화면 글(부품 상자 `칸 이름` · 빈칸 경고 323-4 · 파이썬 `field_name` 짝). */ +export const fieldName = (key: string): string | undefined => + (PARTS as unknown as { "칸 이름"?: Record })["칸 이름"]?.[key]; + export function shapeNeeds(typeId: string): readonly string[] { const shape = catalogRow(typeId)?.shape ?? "none"; const box = (PARTS as unknown as Parts).parts.형상 as Record; diff --git a/common_util/common_util_structure_parts.json b/common_util/common_util_structure_parts.json index 067c63f89..9b3cbd800 100644 --- a/common_util/common_util_structure_parts.json +++ b/common_util/common_util_structure_parts.json @@ -1,5 +1,5 @@ { - "comment": "구조물 부품 상자(PLAN 322-1) — 코드가 그리는 방법 한 벌. TS(import) · 파이썬(json.load)이 같은 파일을 읽음. parts = 갈래(횡단도 · 카드이름 · 형상 · 3D)마다 부품 id → {이름 · 읽는 칸(옵션 키) · 자리꼴 · 정본 · 필수 칸(비면 그 그림이 안 섬 = 빈칸 경고 · 형상만)}. 처음값 = 종류마다 정의 `반영` 이 없을 때 쓰는 값(지금 동작 · ★ = PLAN_구조물표 4장). 표 읽기 = common_util_structure_catalog.ts · .py(짝).", + "comment": "구조물 부품 상자(PLAN 322-1) — 코드가 그리는 방법 한 벌. TS(import) · 파이썬(json.load)이 같은 파일을 읽음. parts = 갈래(횡단도 · 카드이름 · 형상 · 3D)마다 부품 id → {이름 · 읽는 칸(옵션 키) · 자리꼴 · 정본 · 필수 칸(비면 그 그림이 안 섬 = 빈칸 경고 · 형상만)}. 처음값 = 종류마다 정의 `반영` 이 없을 때 쓰는 값(지금 동작 · ★ = PLAN_구조물표 4장). 칸 이름 = 어느 종류 정의에도 없는 읽는 칸의 화면 글(빈칸 경고 · 323-4). 표 읽기 = common_util_structure_catalog.ts · .py(짝).", "parts": { "횡단도": { "anchor": { @@ -197,6 +197,7 @@ } } }, + "칸 이름": { "ford_height_m": "월류 높이" }, "처음값": { "pipe": { "목록": "both", diff --git a/common_util/common_util_structure_shape_keys.ts b/common_util/common_util_structure_shape_keys.ts new file mode 100644 index 000000000..7b3995390 --- /dev/null +++ b/common_util/common_util_structure_shape_keys.ts @@ -0,0 +1,16 @@ +/* ============================================================================= + * common_util/common_util_structure_shape_keys.ts + * 측점 그림 자료 열쇠(323-4) — 형상 얹기 한 벌(`common_util_structure_attach.ts`)이 걷고 얹는 것 = 카드 · + * 상세 창 · B07 · 3D 가 읽는 것 = 3D 지문(`B05_Profile_UI_Corridor_Structures_Hash.ts`). 가져오는 것 없음 + * (3D 도움 파일 로더도 그대로 읽게). 파이썬 짝 = `B06_Section_Engine_Attach.SHAPE_KEYS`. + * ========================================================================== */ + +export const SHAPE_KEYS = [ + "culvert", + "ford", + "box", + "ford_pavement", + "revetment", + "pavement", + "underdrain", +] as const; diff --git a/common_util/common_util_structure_station.py b/common_util/common_util_structure_station.py index de0a50429..898de975f 100644 --- a/common_util/common_util_structure_station.py +++ b/common_util/common_util_structure_station.py @@ -80,6 +80,13 @@ def _empty(value: object) -> bool: return value is None or value == "" +def blank_keys(type_id: str, options: dict | None) -> list[str]: + """형상 부품 필수 칸 중 값이 빈 키(정의 기본값으로 안 채움 · TS `blankKeys` 짝).""" + from common_util.common_util_structure_catalog import shape_needs + + return [key for key in shape_needs(type_id) if _empty((options or {}).get(key))] + + def _station_marks( start: object, anchor: object, end: object, span3: bool ) -> list[tuple[str, float]]: @@ -99,11 +106,9 @@ def plan_stations(pipes: Sequence[object], structures: Sequence[object]) -> list · 덮는 구조물 id(관 = 자리 이름 `pipe-{누가거리}` · 구조물 = structure_id) · 빈칸 경고(형상 부품 필수 칸 중 값이 빈 옵션 키). 표에 없는 종류는 뺌. """ - from common_util.common_util_structure_catalog import card_title, catalog_row, shape_needs + from common_util.common_util_structure_catalog import card_title, catalog_row - def blank(type_id: str, options: dict | None) -> list[str]: - """형상 부품 필수 칸 중 값이 빈 키(정의 기본값으로 안 채움 · TS `blankKeys` 짝).""" - return [key for key in shape_needs(type_id) if _empty((options or {}).get(key))] + blank = blank_keys marks: list[dict[str, object]] = [] for pipe in pipes: diff --git a/common_util/common_util_structure_station.ts b/common_util/common_util_structure_station.ts index 80c594972..9cec8d6b5 100644 --- a/common_util/common_util_structure_station.ts +++ b/common_util/common_util_structure_station.ts @@ -170,8 +170,10 @@ export interface PlannedStation { const empty = (value: unknown): boolean => value === undefined || value === null || value === ""; /** 형상 부품 필수 칸 중 값이 빈 키(관경 · 벽 높이 · 맹암거 높이 · 물넘이 높이 — 정의 기본값으로 안 채움). */ -const blankKeys = (typeId: string, options: Record | null | undefined): string[] => - shapeNeeds(typeId).filter((key) => empty(options?.[key])); +export const blankKeys = ( + typeId: string, + options: Record | null | undefined, +): string[] => shapeNeeds(typeId).filter((key) => empty(options?.[key])); /** 관 · 구조물 → 측점 계획(오름차순 · 같은 m 하나). 표에 없는 종류는 뺌. `registry` = 종류 → 옵션 * 기본값(카드 이름 빈칸 · 없으면 `types` 정의 기본값 · 횡단 관 세트 `registryDefaults` 와 같은 표). */ diff --git a/resources/tester/golden_322_structures.json b/resources/tester/golden_322_structures.json index c40adad68..05815ecc9 100644 --- a/resources/tester/golden_322_structures.json +++ b/resources/tester/golden_322_structures.json @@ -260,6 +260,52 @@ } }, "까닭": "★5 관 공식 한 벌 — 표시 이름도 정의 관종을 앎" + }, + { + "part": "samples", + "key": "ford_pavement/main", + "column": "cards", + "단계": 4, + "다음": { + "100.00": { + "title": null, + "display": null, + "shape": { + "ford_pavement": true + } + }, + "101.00": { + "title": "물넘이포장", + "display": "물넘이포장", + "shape": { + "ford_pavement": true + } + } + }, + "까닭": "323-4 저장 전 그림 = 저장 뒤 그림 — 물넘이포장 폭 절반 걸친 일반 측점(100)도 얹음(서버 attach_shapes 와 같음)" + }, + { + "part": "samples", + "key": "ford_pavement/blank", + "column": "cards", + "단계": 4, + "다음": { + "100.00": { + "title": null, + "display": null, + "shape": { + "ford_pavement": true + } + }, + "101.00": { + "title": "물넘이포장", + "display": "물넘이포장", + "shape": { + "ford_pavement": true + } + } + }, + "까닭": "323-4 저장 전 그림 = 저장 뒤 그림 — 물넘이포장 폭 절반 걸친 일반 측점(100)도 얹음(서버 attach_shapes 와 같음)" } ], "samples": { diff --git a/resources/tester/helper_173_6_half_width.cjs b/resources/tester/helper_173_6_half_width.cjs index 766c4b026..bec0471b2 100644 --- a/resources/tester/helper_173_6_half_width.cjs +++ b/resources/tester/helper_173_6_half_width.cjs @@ -20,9 +20,9 @@ const STUBS = { }, "./B06_Section_Api_Fetch": { regenerateSections: async (_p, _r, width) => calls.regenerate.push(width), - fetchSectionDetail: async () => ({ cross_sections: [] }), }, - "./B06_Section_Section_Store": { replaceSectionDetail: () => {} }, + // 다시 받기 = 공유 캐시 길(초안 측점 · 그림 자료 · 323-4) + "./B06_Section_Section_Store": { loadSectionDetail: async () => ({ cross_sections: [] }) }, }; const file = path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Common.ts"); diff --git a/resources/tester/helper_273_2_preview_stations.cjs b/resources/tester/helper_273_2_preview_stations.cjs index 48c397c6f..7f3b15bce 100644 --- a/resources/tester/helper_273_2_preview_stations.cjs +++ b/resources/tester/helper_273_2_preview_stations.cjs @@ -56,6 +56,15 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", { }, }, "../common_util/common_util_structure_station": station, + // 형상 얹기 한 벌(323-4) 흉내 — 관 자리 측점에 관 그림 + "@util/common_util_structure_attach": { + attachShapes: (sections, pipes) => { + for (const pipe of pipes) { + const hit = sections.find((s) => Math.abs(s.chainage_m - pipe.chainage_m) < 0.005); + if (hit) hit.culvert = true; + } + }, + }, "./B06_Section_Api_Fetch": { previewStructureSections: async (_projectId, chainages) => { asked.push(chainages); @@ -86,6 +95,7 @@ const chainages = (detail) => detail.cross_sections.map((s) => s.chainage_m); const detail = detailOf(); draftPipes = [{ chainage_m: 95, facility: "pipe", source: "user" }]; // 95.38 → 95 await drafts.withDraftStations(detail, "P", 1); + await drafts.withDraftShapes(detail, "P"); // 그림 자료(323-4 · `Section_Store` 차례) const preview = detail.cross_sections.find((s) => s.chainage_m === 95); out.moved = { chainages: chainages(detail), diff --git a/resources/tester/helper_294_7_ford_slab_pick.cjs b/resources/tester/helper_294_7_ford_slab_pick.cjs index b09b33ccb..b1e235a3e 100644 --- a/resources/tester/helper_294_7_ford_slab_pick.cjs +++ b/resources/tester/helper_294_7_ford_slab_pick.cjs @@ -27,7 +27,7 @@ globalThis.requestAnimationFrame = (fn) => fn(); const STUBS = { "./B06_Section_UI_Adjust_Dock": { refreshAdjustSlots: () => {} }, "./B06_Section_UI_Cross_Design_Pick": { setBermPickHost: () => {} }, - "./B06_Section_UI_Page_Persist": { BERM_TYPE_ID: "berm" }, + "./B06_Section_UI_Page_Persist": { isBermType: (typeId) => typeId === "berm" }, }; const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); function loadTs(full) { diff --git a/resources/tester/helper_317_kind_change_station.cjs b/resources/tester/helper_317_kind_change_station.cjs index 6b4a74352..8b17b7e77 100644 --- a/resources/tester/helper_317_kind_change_station.cjs +++ b/resources/tester/helper_317_kind_change_station.cjs @@ -43,9 +43,13 @@ const load = (file) => { return box.exports; }; -const { withDraftStations, forgetDraftStations } = load( - path.join(ROOT, "B06_Section", "B06_Section_Section_Drafts.ts"), -); +const drafts = load(path.join(ROOT, "B06_Section", "B06_Section_Section_Drafts.ts")); +const { forgetDraftStations } = drafts; +// 상세 맞춤 = 측점 계획 → 형상 얹기(`Section_Store.withDraftWalls` 와 같은 차례 · 323-4) +const withDraftStations = async (detail, projectId, routeId) => { + await drafts.withDraftStations(detail, projectId, routeId); + await drafts.withDraftShapes(detail, projectId); +}; const detail = { cross_sections: [ diff --git a/resources/tester/helper_319_kind_swap.cjs b/resources/tester/helper_319_kind_swap.cjs index 88bdd05af..d59153035 100644 --- a/resources/tester/helper_319_kind_swap.cjs +++ b/resources/tester/helper_319_kind_swap.cjs @@ -229,6 +229,10 @@ const state = { draftPipes: null, draftStructures: null, savedPipes: [], savedSt B06("B06_Section_Section_Drafts"), path.join(ROOT, "common_util", "common_util_culvert_sets.ts"), path.join(ROOT, "common_util", "common_util_structure_station.ts"), + // 그림 자료 = 형상 얹기 한 벌(323-4) + ...["attach", "shape_keys", "walls", "pavement", "underdrain"].map((name) => + path.join(ROOT, "common_util", `common_util_structure_${name}.ts`), + ), ], { "../B04_PreProcess/B04_PreProcess_Api_Fetch": { @@ -246,7 +250,13 @@ const state = { draftPipes: null, draftStructures: null, savedPipes: [], savedSt }, }, ); - const { withDraftStations, forgetDraftStations } = load(B06("B06_Section_Section_Drafts")); + const drafts = load(B06("B06_Section_Section_Drafts")); + const { forgetDraftStations } = drafts; + // 상세 맞춤 = 측점 계획 → 형상 얹기(`Section_Store.withDraftWalls` 와 같은 차례) + const withDraftStations = async (detail, projectId, routeId) => { + await drafts.withDraftStations(detail, projectId, routeId); + await drafts.withDraftShapes(detail, projectId); + }; const D800 = { pipe_kind: "파형강관", pipe_diameter_mm: "800" }; const detailWith = (spec) => ({ cross_sections: [ diff --git a/resources/tester/helper_322_3_flow.cjs b/resources/tester/helper_322_3_flow.cjs index ca2a6039d..cb9753b12 100644 --- a/resources/tester/helper_322_3_flow.cjs +++ b/resources/tester/helper_322_3_flow.cjs @@ -128,6 +128,7 @@ async function run(setup, detail) { drafts.forgetDraftStations("P"); route += 1; await drafts.withDraftStations(detail, "P", route); + await drafts.withDraftShapes(detail, "P"); // 그림 자료 = 형상 얹기 한 벌(323-4) return summary(detail); } diff --git a/resources/tester/helper_323_4_attach.cjs b/resources/tester/helper_323_4_attach.cjs new file mode 100644 index 000000000..c476f931d --- /dev/null +++ b/resources/tester/helper_323_4_attach.cjs @@ -0,0 +1,64 @@ +/* PLAN 323-4 형상 얹기 한 벌 헬퍼 — 진짜 `common_util_structure_attach.ts`(attachShapes) · 3D 지문 · + * 벽 한 번(`B05_Profile_UI_Corridor_Structures_Hash.ts`)을 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON. + * 입력 = argv[2] JSON `{definition, types, cases: {이름: {sections, pipes, structures}}}`. 짝 = `test_323_4_attach_mirror.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(); +function load(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8")); + 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 need = (spec) => { + const base = path.join(path.dirname(full), spec); + const hit = [`${base}.ts`, base.replace(/\.js$/, ".ts"), base].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ); + if (!hit) throw new Error(`모름: ${spec} (${full})`); + return load(hit); + }; + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const catalog = load(path.join(ROOT, "common_util", "common_util_structure_catalog.ts")); +const attach = load(path.join(ROOT, "common_util", "common_util_structure_attach.ts")); +const hash = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Corridor_Structures_Hash.ts")); +catalog.useCatalogDefinition({ types: input.types, ...input.definition }); + +const out = { cases: {} }; +for (const [name, { sections, pipes, structures }] of Object.entries(input.cases)) { + attach.attachShapes(sections, pipes, structures, input.types); + out.cases[name] = sections; +} + +// 3D 벽 한 번 — 옹벽 93 ~ 113(기준 101)이 덮는 측점 넷 중 기준 측점만 · 스윕 = 시작 ~ 끝 +const wall = { structure_id: "w1", start_m: 93, anchor_m: 101, end_m: 113 }; +const covered = [93, 100, 101, 113].map((at) => ({ chainage_m: at, revetment: { ...wall } })); +out.sweep = covered.map((section) => hash.wallSweepAt(section, covered)); + +// 3D 지문 — 손 해시가 빠뜨린 칸(벽 기초 · 포장 · 맹암거)도 바뀌면 다름 · 구조물 없는 측점 = 빈 목록 +const base = { chainage_m: 0, revetment: { ...wall, foundation: null } }; +const fp = (section) => JSON.stringify(hash.structureHashParts(section)); +out.fingerprint = { + same: fp(base) === fp({ chainage_m: 0, revetment: { ...wall, foundation: null } }), + foundation: fp(base) !== fp({ ...base, revetment: { ...wall, foundation: "기초유" } }), + pavement: fp(base) !== fp({ ...base, pavement: [{ type_id: "pavement_concrete" }] }), + underdrain: fp(base) !== fp({ ...base, underdrain: { height_m: 0.5 } }), + adjust: fp(base) !== fp({ ...base, design: { revet_adjust: { own: { x: 1 } } } }), + plain: hash.structureHashParts({ chainage_m: 0 }).length === 0, +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_312_empty_pipes.py b/resources/tester/test_312_empty_pipes.py index d41809943..1b632311b 100644 --- a/resources/tester/test_312_empty_pipes.py +++ b/resources/tester/test_312_empty_pipes.py @@ -158,11 +158,11 @@ def test_화면_초안_빈_목록은_저장분으로_안_넘어감() -> None: def test_횡단_벽_초안_빈_목록은_다_지움_그리고_옛_결함_막이() -> None: - store = _body( - "B06_Section/B06_Section_Section_Store.ts", "async function withDraftWalls(", "\n}\n" - ) - assert "if (!drafts) return withStructureAreas(detail);" in store - assert "!drafts.length" not in store + # 빈 목록 = 다 지움 — 현황 = 초안 `??` 저장분(빈 목록도 초안) · 형상 얹기가 모든 열쇠를 걷고 다시(323-4) + drafts = _read("B06_Section/B06_Section_Section_Drafts.ts") + assert "structuresDraft ?? savedOf(projectId)" in drafts + assert "const drafts = !!(pipesDraft || structuresDraft);" in drafts + assert "!drafts.length" not in drafts # 옛 「놓아도 안 보임」(09-09) — 옛 배열 참조가 `[]` 를 알리던 것. 정본 주입은 배열 안을 # 채우고(갈아끼우지 않음) onChange 를 울리지 않아야 함. inject = _body( diff --git a/resources/tester/test_314_stale_structure.py b/resources/tester/test_314_stale_structure.py index 302515919..41be0ff25 100644 --- a/resources/tester/test_314_stale_structure.py +++ b/resources/tester/test_314_stale_structure.py @@ -74,8 +74,9 @@ def test_structure_change_reconciles_and_ford_reattach() -> None: assert "void reconcileStaleDesigns();" in body refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts") assert "Number(section.ford?.overflow_depth_m) > 0" in refresh - store = _read("B06_Section", "B06_Section_Section_Store.ts") - kinds = store.split("async function withDraftPipeKinds", 1)[1].split("\n}\n", 1)[0] - assert "if (!pipes) return;" in kinds - assert "delete section.ford_pavement" in kinds - assert 'if (!here.length) return shapeOf(pipe.facility) === "ford_pavement";' in kinds + # 물넘이포장 옛 자리 걷고 새 자리(걸친 측점까지) 얹기 = 형상 얹기 한 벌(323-4 · 모든 열쇠 걷고 다시 · + # 동작 시험 = test_323_4_attach_mirror) + drafts = _read("B06_Section", "B06_Section_Section_Drafts.ts") + assert "attachShapes(" in drafts + keys = _read("common_util", "common_util_structure_shape_keys.ts") + assert '"ford_pavement",' in keys diff --git a/resources/tester/test_322_0_golden.py b/resources/tester/test_322_0_golden.py index 9b5cabd9e..545bbde74 100644 --- a/resources/tester/test_322_0_golden.py +++ b/resources/tester/test_322_0_golden.py @@ -36,7 +36,7 @@ ROOT = HERE.parent.parent GOLDEN = HERE / "golden_322_structures.json" FIXTURE = HERE / "fixtures" / "b06_73_4_body_guard_sections.json" #: 지금 단계 — ★ 칸은 `단계` 가 이 값 이하면 「다음」 값과 맞춤 -STAGE = 1 +STAGE = 4 GRID = [float(at) for at in range(0, 201, 20)] SHAPE_KEYS = ("culvert", "ford", "box", "ford_pavement", "revetment", "pavement", "underdrain") diff --git a/resources/tester/test_323_4_attach_mirror.py b/resources/tester/test_323_4_attach_mirror.py new file mode 100644 index 000000000..a74a5ed55 --- /dev/null +++ b/resources/tester/test_323_4_attach_mirror.py @@ -0,0 +1,178 @@ +"""PLAN 323-4 형상 얹기 한 벌 — 파이썬 `attach_shapes` = TS `attachShapes`(거울) · 저장 전 그림 = 저장 뒤 그림. + +- 42종 견본(관 지점 · 구조물 · 정의 기본값) + 빈칸 견본 → 측점마다 그림 자료(관 세트 · 벽 · 포장 · 맹암거) · + 빈칸 경고 `blank`(「종류 칸」 · 측점 계획 자리)가 서버 = 화면. +- 옛 열쇠는 걷고 다시(물넘이포장을 옮기면 옛 자리 파임이 빠짐 · 걸친 측점에 새로). +- 3D — C군 벽은 덮는 측점 중 기준 측점에서만 시작 ~ 끝 스윕 · 지문 = 그림 자료 통째(손 해시가 빠뜨린 칸도). +TS 쪽 = `helper_323_4_attach.cjs`(Node). +""" + +from __future__ import annotations + +import copy +import json +import subprocess +import tempfile +from pathlib import Path + +import pytest + +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance, load_definition +from B06_Section.B06_Section_Engine_Attach import attach_shapes +from common_util.common_util_drainage_pipes import parse_pipe_points +from common_util.common_util_structure_catalog import catalog +from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible + +HERE = Path(__file__).parent +ROOT = HERE.parent.parent + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + +TYPES = with_visible().model_dump()["types"] + + +def _defaults(type_id: str) -> dict: + """정의 기본값(빈 값 뺌) — 그림이 서는 견본.""" + options = next(t for t in TYPES if t["type_id"] == type_id)["options"] + return {o["key"]: o["default"] for o in options if o["default"] not in (None, "")} + + +def _samples() -> tuple[list[dict], list[dict]]: + """42종 하나씩(30 m 간격 · 구간형 = 기준 ± 5) + 빈칸 셋(관경 · 옹벽 높이 · 맹암거 높이).""" + pipes: list[dict] = [] + structures: list[dict] = [] + rows = sorted(catalog().values(), key=lambda row: row["typeId"]) + for index, row in enumerate(rows): + at = 15.0 + 30.0 * index + options = _defaults(row["typeId"]) + if row["owner"] == "pipe_points": + pipes.append({"chainage_m": at, "facility": row["typeId"], "options": options}) + continue + span = {"start_m": at - 5, "end_m": at + 5} if row["placement"] == "interval" else {} + structures.append( + { + "structure_id": f"s{index}", + "type_id": row["typeId"], + "placement": row["placement"], + "chainage_m": at, + "options": options, + **span, + } + ) + end = 15.0 + 30.0 * len(rows) + pipes.append({"chainage_m": end, "facility": "pipe", "options": {"pipe_diameter_mm": ""}}) + structures.append( + { + "structure_id": "blank-wall", + "type_id": "retaining_wall", + "placement": "interval", + "start_m": end + 25, + "chainage_m": end + 30, + "end_m": end + 35, + "options": {}, + } + ) + structures.append( + { + "structure_id": "blank-drain", + "type_id": "cross_underdrain", + "placement": "point", + "chainage_m": end + 60, + "options": {}, + } + ) + return pipes, structures + + +def _sections(until: float) -> list[dict]: + """5 m 마다 측점(모든 기준 · 시작 · 끝 자리) — 옛 열쇠를 미리 얹어 걷힘도 봄.""" + out = [] + for step in range(int(until // 5) + 2): + section = {"chainage_m": float(step * 5)} + if step == 1: + section["ford_pavement"] = {"type": "ford_pavement", "chainage_m": 5.0} # 옛 자리 + out.append(section) + return out + + +PIPES, STRUCTURES = _samples() +UNTIL = 15.0 + 30.0 * len(catalog()) + 60 +CASES = { + "all": {"sections": _sections(UNTIL), "pipes": PIPES, "structures": STRUCTURES}, + "empty": {"sections": _sections(30), "pipes": [], "structures": []}, +} + + +@pytest.fixture(scope="module") +def ts() -> dict: + payload = {"definition": load_definition(), "types": TYPES, "cases": CASES} + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "input.json" + path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + out = subprocess.run( + ["node", str(HERE / "helper_323_4_attach.cjs"), str(path)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + return json.loads(out.stdout) + + +def _py(case: dict) -> list[dict]: + sections = copy.deepcopy(case["sections"]) + pipes = parse_pipe_points(case["pipes"]) + structures = [StructureInstance.model_validate(s) for s in case["structures"]] + attach_shapes(sections, pipes, structures) + return json.loads(json.dumps(sections)) + + +def test_mirror_python_equals_ts(ts: dict) -> None: + for name, case in CASES.items(): + py, js = _py(case), ts["cases"][name] + assert len(py) == len(js), name + for left, right in zip(py, js): + assert left == right, (name, left["chainage_m"]) + + +def test_every_shape_lands(ts: dict) -> None: + keys = {key for section in ts["cases"]["all"] for key in section if key != "chainage_m"} + assert { + "culvert", + "ford", + "box", + "ford_pavement", + "revetment", + "pavement", + "underdrain", + } <= keys + old = ts["cases"]["all"][1] # 옛 물넘이 자리(5 m) — 걷힘 + assert "ford_pavement" not in old + assert all(len(section) == 1 for section in ts["cases"]["empty"]) + + +def test_blank_chips(ts: dict) -> None: + end = 15.0 + 30.0 * len(catalog()) + by_at = {s["chainage_m"]: s.get("blank") for s in ts["cases"]["all"]} + assert by_at[end] == ["배관 관경"] + assert by_at[end + 25] == by_at[end + 30] == by_at[end + 35] == ["옹벽 높이"] + assert by_at[end + 60] == ["맹암거 암거 높이"] + assert by_at[end + 5] is None # 계획 자리만 + + +def test_wall_sweeps_once(ts: dict) -> None: + assert ts["sweep"] == [None, None, {"beforeM": 8, "afterM": 12}, None] + + +def test_fingerprint_covers_drawing(ts: dict) -> None: + assert ts["fingerprint"] == { + "same": True, + "foundation": True, + "pavement": True, + "underdrain": True, + "adjust": True, + "plain": True, + } diff --git a/resources/tester/test_b06_facility_default_notes.py b/resources/tester/test_b06_facility_default_notes.py index c675338eb..8fb824afa 100644 --- a/resources/tester/test_b06_facility_default_notes.py +++ b/resources/tester/test_b06_facility_default_notes.py @@ -57,6 +57,7 @@ def test_물넘이_월류_높이가_비면_파임_없음() -> None: def test_카드_머리가_목록과_까닭을_보인다() -> None: assert "기본값(미확정)" in CHROME - assert "월류 높이 없음 — 파임 안 그림" in CHROME + # 월류 높이 · 기슭막이 높이 등 필수 칸 빈칸 = 빈칸 경고 한 꼴(323-4 · 형상 얹기가 얹은 `blank`) + assert "section.blank" in CHROME and "빈칸`" in CHROME # 조작으로 값이 오면 목록에서 뺌 — 캐시를 바로 고치는 길. assert CONTROLS.count("dropDefaulted(spec,") == 4