diff --git a/.gitignore b/.gitignore index cfc42947f..36a98043f 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,8 @@ config/formula_node/ config/spreadsheet_node/ # 횡단도 덧그림 범위 번들 — `npm run build:b07-extent` 산출물. config/b07_extent_node/ +# 횡단도 표 위 CAD 칸 옵션 줄 번들 — `npm run build:b07-note` 산출물(166-1). +config/b07_note_node/ # graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름 # (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」). diff --git a/B05_Profile/B05_Profile_UI_Structures_Balloon.ts b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts index 9c49247d6..0cebd0aa0 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Balloon.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts @@ -8,9 +8,12 @@ * 인스턴스마다) · 그 밖 종류는 정의 옵션 그룹(`StructureType.groups`) * · 첫 옵션 = 지금 보이는 첫 입력 칸(자동 칸 · 갈래가 없어 가린 칸 · 끈 칸은 건너뜀) * · 값 `auto` = 「(자동)」 · 빈 값은 건너뜀(기본값으로 안 바꿈 · 기본값은 새 구조물 칸에 실제 값) + * · `structureOptionLines`(166-1) = 같은 컨테이너의 **보이는 칸 전부** 「제목: 이름 값 · …」 — B06 [상세] CAD 칸 + * 미리 넣음 · B07 짝(서버 Node `B07_DesignDetail_Note_Options_Node`)이 같이 씀 * ========================================================================== */ import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; +import { shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; import { bodyOf, boxOptionsIn } from "../A00_Common/design/design_structure_box"; import { withLegacyGuards } from "../A00_Common/design/design_structure_ford"; @@ -18,6 +21,8 @@ import { branchOf, choiceLabel, choicesOf, + containerTitle, + itemLabel, readState, type Instance, type SpecContainer, @@ -46,6 +51,14 @@ export function balloonLines( return values.length ? [first, values.join(SEP)] : [first]; } +/** 명세 이름 바꿈 표 — 배관은 정의 파일의 옛 이름 「흙막이 추가」보다 명세 이름 「흙막이」가 앞섬(54-1). */ +function specNames(structure: StructureInstance, type: StructureType | undefined) { + const names = type?.spec_names ?? {}; + if (structure.type_id !== "pipe") return names; + const { soil_add: _old, ...pipeNames } = names; + return pipeNames; +} + /** 명세 컨테이너 값 — 폼(`Drainage_Facility.setFacility` · `paintStructureSpec`)과 같은 값 · 이름(빈 값 = ""). */ function specLines( spec: StructureSpec, @@ -53,12 +66,7 @@ function specLines( type: StructureType | undefined, count?: ContainerCount, ): string[] { - let names = type?.spec_names ?? {}; - if (structure.type_id === "pipe") { - // 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1) - const { soil_add: _old, ...pipeNames } = names; - names = pipeNames; - } + const names = specNames(structure, type); const state = formState(spec, structure.type_id, structure.options ?? {}); return spec.containers.flatMap((c) => instancesOf(c, state[c.id] ?? [], count).map((values) => { @@ -129,3 +137,76 @@ function groupLines( return raw !== undefined && raw !== "" ? `${raw}${option.unit ?? ""}` : ""; }); } + +/** 「이름 (단위)」 칸 이름 + 값 → 「이름 값단위」(예 높이 (m) + 1.3 = 높이 1.3m) · 단위 없는 이름은 그대로 띄움. */ +function nameValue(label: string, shown: string): string { + const hit = /^(.*?)\s*\(([^()]+)\)$/.exec(label); + return hit && shown !== AUTO_MARK ? `${hit[1]} ${shown}${hit[2]}` : `${label} ${shown}`; +} + +/** + * CAD 칸 미리 넣음 옵션 줄(166-1) — 좌측 패널 폼과 같은 컨테이너 · 이름 · 보이는 칸 전부 · 빈 칸 뺌. + * 명세 종류 = 컨테이너(선택 컨테이너는 저장된 인스턴스마다 · 뒤에 번호)마다 「제목: 이름 값 · 이름 값」 · + * 그 밖 종류 = 정의 그룹마다(그룹 형상 칸 포함) + 그룹 밖 칸은 종류 이름 줄 · 값 하나 없는 상자는 줄 없음. + */ +export function structureOptionLines( + structure: Pick, + type: StructureType | undefined, +): string[] { + const options = structure.options ?? {}; + const line = (title: string, parts: string[]): string[] => { + const kept = parts.filter(Boolean); + return kept.length ? [`${title}: ${kept.join(SEP)}`] : []; + }; + const spec = STRUCTURE_SPECS[structure.type_id]; + if (spec) { + const names = specNames(structure as StructureInstance, type); + const state = formState(spec, structure.type_id, options); + return spec.containers.flatMap((c) => + (state[c.id] ?? []).flatMap((values, index) => { + const group = type?.groups?.find((g) => g.id === c.link)?.name; + const title = containerTitle(c, names, group) + (c.required ? "" : ` ${index + 1}`); + const parts = c.items + .filter((item) => item.kind !== "auto" && (!item.by || branchOf(item, values))) + .map((item) => { + // 필수 상자 빈 칸 = 폼이 채워 보이는 기본값(177-2 · 예 동물이동형 유) · 선택 상자는 안내뿐이라 뺌 + const empty = !values[item.id]?.trim() && c.required && item.default; + const filled = empty ? { ...values, [item.id]: item.default ?? "" } : values; + const shown = specValue(c, item, filled, names); + return shown ? nameValue(itemLabel(c, item, names), shown) : ""; + }); + return line(title, parts); + }), + ); + } + const byKey = new Map((type?.options ?? []).map((option) => [option.key, option])); + const valueOf = (key: string, unit?: string | null): string => { + const raw = options[key]; + if (raw === undefined || raw === null || raw === "") return ""; + return raw === "auto" ? AUTO_MARK : `${raw}${unit ?? ""}`; + }; + const optionParts = (keys: string[]): string[] => + keys.map((key) => { + const option = byKey.get(key); + if (!option || option.visible === false || option.enabled === false) return ""; + const shown = valueOf(key, option.unit); + return shown ? `${option.label} ${shown}` : ""; + }); + const groups = type?.groups ?? []; + const grouped = new Set(groups.flatMap((group) => group.options)); + return [ + ...groups.flatMap((group) => + line(group.name, [ + ...optionParts(group.options), + ...shapeFields(group.shape).map((field) => { + const shown = valueOf(shapeKey(group.id, field.suffix)); + return shown ? nameValue(field.label, shown) : ""; + }), + ]), + ), + ...line( + type?.name ?? structure.type_id, + optionParts((type?.options ?? []).map((o) => o.key).filter((key) => !grouped.has(key))), + ), + ]; +} diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts index d7edda981..cee03db92 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Cad.ts @@ -7,7 +7,7 @@ * (`_Engine_Cad_Table.cross_note_cad_entities`). * 처음 열 때 칸이 없으면 — 옛 글(`structure_note`)이 있으면 줄마다 Text 로 옮김 · 없으면 미리 넣음 * (265-2 · 관 주인 측점 = 원형 마크 · 그 측점 구조물마다 지정된 구조물 조합 이름 줄 · 미지정 = - * 「이름 조합 미지정」) · 그 밖 = 빈 도면. + * 「이름 조합 미지정」 · 이어서 좌측 패널 옵션 줄 = `structureOptionLines` · 166-1) · 그 밖 = 빈 도면. * ⚠ 짝(①) = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py` — `noteEntities` · * `structureNoteLines` · `stationNoLabel` 와 같은 입력 → 같은 엔티티(id 만 다름) · 모달을 안 연 * 측점은 B07 이 그것으로 그림 · 거울 시험 `test_260_note_cad.py` · `test_265_note_cad.py`. @@ -24,6 +24,7 @@ import { fetchStructureTypes, readPendingStructures, } from "../B05_Profile/B05_Profile_Api_Structures"; +import { structureOptionLines } from "../B05_Profile/B05_Profile_UI_Structures_Balloon"; import { readGroupItems, readSummaryItem, @@ -144,6 +145,8 @@ export interface NoteThing { at: number; summary_item?: unknown; group_items?: unknown; + /** 좌측 패널 옵션 줄(166-1 · `structureOptionLines` · B07 짝은 서버 Node 가 같은 함수로 채움). */ + optionLines?: string[]; } /** 관 본체 잇기 자리(`design_structure_pipe` 「관」 link) — 옛 본체 `summary_item` 도 같은 자리. */ @@ -156,18 +159,22 @@ export const thingsAt = (things: readonly NoteThing[], chainageM: number): NoteT /** * 구조물 조합 줄 — 구조물마다 지정된 조합(본체 → 그룹 차례) 이름 한 줄씩(이름 = 일위대가 이름 + * 조합 경로 옵션 값) · 같은 구조물의 같은 이름은 한 줄 · 미지정 = 「{구조물 이름} 조합 미지정」 · - * 배관은 본체 줄 없음(원형 마크) — 보호공 · 흙막이 · 도수로 조합만. + * 배관은 본체 줄 없음(원형 마크) — 보호공 · 흙막이 · 도수로 조합만 · 이어서 그 구조물 좌측 패널 옵션 줄(166-1). */ export function structureNoteLines(things: readonly NoteThing[], chainageM: number): string[] { return thingsAt(things, chainageM).flatMap((thing) => { const own = readSummaryItem(thing.summary_item); const slots: Array<[string, SummaryItem]> = Object.entries(readGroupItems(thing.group_items)); if (own) slots.unshift(["", own]); - if (!slots.length) return thing.pipe ? [] : [`${thing.name} 조합 미지정`]; const names = slots .filter(([gid]) => !(thing.pipe && (gid === "" || gid === PIPE_BODY_LINK))) .map(([, item]) => summaryLabel(item)); - return [...new Set(names)]; + const combos = slots.length + ? [...new Set(names)] + : thing.pipe + ? [] + : [`${thing.name} 조합 미지정`]; + return [...combos, ...(thing.optionLines ?? [])]; }); } @@ -237,8 +244,10 @@ export async function loadNoteThings(projectId: string): Promise { readPendingStructures(projectId) ?? fetchStructures(projectId).then((res) => res.structures), fetchStructureTypes(true), ]); - const names = new Map(types.map((type) => [type.type_id, type.name])); - const nameOf = (kind: string): string => names.get(kind) ?? kind; + const byId = new Map(types.map((type) => [type.type_id, type])); + const nameOf = (kind: string): string => byId.get(kind)?.name ?? kind; + const optionLines = (type_id: string, options?: Record): string[] => + structureOptionLines({ type_id, options: options ?? {} }, byId.get(type_id)); const piped = pipes.map((point): NoteThing => { const kind = point.facility ?? "pipe"; const { summary_item, group_items } = point; @@ -248,13 +257,23 @@ export async function loadNoteThings(projectId: string): Promise { at: point.chainage_m, summary_item, group_items, + optionLines: optionLines(kind, point.options as Record), }; }); const placed = structures.flatMap((item): NoteThing[] => { const at = item.chainage_m ?? item.start_m; if (typeof at !== "number" || !Number.isFinite(at)) return []; const { summary_item, group_items } = item; - return [{ name: nameOf(item.type_id), pipe: false, at, summary_item, group_items }]; + return [ + { + name: nameOf(item.type_id), + pipe: false, + at, + summary_item, + group_items, + optionLines: optionLines(item.type_id, item.options), + }, + ]; }); return [...piped, ...placed]; } catch { diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py index dc2fe6463..8d4f0b923 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_CulvertNote.py @@ -7,20 +7,31 @@ B06 [상세] 모달을 한 번도 안 연 측점(칸 = 없음)에 B07 이 같은 (빈 엔티티)은 덮지 않음 · 옛 글(`structure_note`)이 있으면 옛 글이 먼저 (모달이 처음 열 때 옮기는 차례와 같음). 그림 = 관 주인 측점 원형 마크(배수관 · Φ · L 정수) + 측점 · 그 측점 구조물마다 지정된 -구조물 조합 이름 줄(미지정 = 「이름 조합 미지정」). +구조물 조합 이름 줄(미지정 = 「이름 조합 미지정」) + 그 구조물 좌측 패널 옵션 줄(166-1). +옵션 줄은 파이썬으로 다시 짜지 않음 — 화면과 같은 TS(`structureOptionLines`)를 Node +진입점(`B07_DesignDetail_Note_Options_Node.ts` · CLAUDE.md 5장 ②)으로 돌려 받음 · +실패는 비치명(조합 줄만). 좌표 = 도면 mm(글 2.0 · 줄 3.0) · 칸 왼쪽 위 = (0, 0) · 아래로 음수. """ import json +import logging import math import re 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 load_structure_types +from B05_Profile.B05_Profile_Structures_Schema import load_definition, load_structure_types from B06_Section.B06_Section_Engine_Culvert import _culvert_set, pipe_points_file from common_util.common_util_drainage_pipes import PIPE_FACILITY_PIPE, parse_pipe_points +from common_util.common_util_node_bundle import run_bundle_json + +logger = logging.getLogger(__name__) + +ROOT = Path(__file__).resolve().parents[1] +_OPTIONS_BUNDLE = ROOT / "config" / "b07_note_node" / "B07_DesignDetail_Note_Options_Node.js" +_OPTIONS_NPM = "build:b07-note" # TS `PIPE_MATCH_M`(`design_selection`) — 관 · 구조물 자리와 측점이 같은 자리로 보는 폭 PIPE_MATCH_M = 0.51 @@ -35,7 +46,8 @@ _GROUP_ID = re.compile(r"^g[0-9a-z]{1,16}$") #: (관 누가거리, 세트, 옵션) — 원형 마크 재료 Pipe = tuple[float, dict[str, Any], dict[str, Any]] -#: 글 줄 재료 한 건 `{name, pipe, at, summary_item, group_items}` — TS `NoteThing` +#: 글 줄 재료 한 건 `{name, pipe, at, summary_item, group_items, type_id, options, option_lines}` +#: — TS `NoteThing`(option_lines = `optionLines`) Thing = dict[str, Any] @@ -98,22 +110,23 @@ def things_at(things: list[Thing], chainage_m: float) -> list[Thing]: def structure_note_lines(things: list[Thing], chainage_m: float) -> list[str]: """구조물 조합 줄 — TS `structureNoteLines` 짝. - 본체 → 그룹 차례 · 같은 이름 한 줄 · 미지정 = 「이름 조합 미지정」 · 배관 본체 뺌. + 본체 → 그룹 차례 · 같은 이름 한 줄 · 미지정 = 「이름 조합 미지정」 · 배관 본체 뺌 · + 이어서 그 구조물 좌측 패널 옵션 줄(`option_lines` · 166-1). """ lines: list[str] = [] for thing in things_at(things, chainage_m): own = _summary_item(thing.get("summary_item")) slots = ([("", own)] if own else []) + _group_items(thing.get("group_items")) - if not slots: - if not thing["pipe"]: - lines.append(f"{thing['name']} 조합 미지정") - continue names = [ item["name"] or item["key"] for gid, item in slots if not (thing["pipe"] and gid in ("", _PIPE_BODY_LINK)) ] - lines += list(dict.fromkeys(names)) + if slots: + lines += list(dict.fromkeys(names)) + elif not thing["pipe"]: + lines.append(f"{thing['name']} 조합 미지정") + lines += [str(line) for line in thing.get("option_lines") or []] return lines @@ -207,6 +220,8 @@ def _things(points: list[Any], structures: list[Any], names: dict[str, str]) -> "at": float(point.chainage_m), "summary_item": point.summary_item, "group_items": point.group_items, + "type_id": point.facility, + "options": dict(point.options or {}), } for point in points ] @@ -221,11 +236,46 @@ def _things(points: list[Any], structures: list[Any], names: dict[str, str]) -> "at": float(at), "summary_item": item.summary_item, "group_items": item.group_items, + "type_id": item.type_id, + "options": dict(item.options or {}), } ) return things +def with_option_lines(things: list[Thing]) -> list[Thing]: + """구조물마다 좌측 패널 옵션 줄(166-1) — 화면 TS 를 Node 로 한 번에 · 실패면 그대로.""" + if not things: + return things + try: + # 지연 import — M02 정의 저장소가 B07 엔진 import 차례에 끼지 않게 + from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible + + payload = { + "things": [ + {"type_id": t["type_id"], "options": t.get("options") or {}} for t in things + ], + "types": with_visible().model_dump(mode="json")["types"], + "definition": load_definition(), + } + output = run_bundle_json(_OPTIONS_BUNDLE, _OPTIONS_NPM, payload) + except Exception: + logger.warning("B07 CAD 칸 옵션 줄 계산 실패 — 조합 줄만 넣음", exc_info=True) + return things + rows = output.get("lines") if isinstance(output, dict) else None + if not isinstance(rows, list) or len(rows) != len(things): + return things + return [ + { + **thing, + "option_lines": [line for line in row if isinstance(line, str)] + if isinstance(row, list) + else [], + } + for thing, row in zip(things, rows) + ] + + def load_note_sources(project_root: Path) -> tuple[list[Pipe], list[Thing]]: """정본 재료 — (원형 마크 배수관 · 관경 안 고름 = 없음, 글 줄 재료 = 관 지점 + 구조물 배치).""" try: @@ -242,7 +292,7 @@ def load_note_sources(project_root: Path) -> tuple[list[Pipe], list[Thing]]: pipes.append((float(point.chainage_m), spec, options)) _revision, structures = load_structures(str(project_root)) names = {kind.type_id: kind.name for kind in load_structure_types()} - return pipes, _things(points, structures, names) + return pipes, with_option_lines(_things(points, structures, names)) def with_structure_note( diff --git a/B07_DesignDetail/B07_DesignDetail_Note_Options_Node.ts b/B07_DesignDetail/B07_DesignDetail_Note_Options_Node.ts new file mode 100644 index 000000000..edce66077 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Note_Options_Node.ts @@ -0,0 +1,45 @@ +/* ============================================================================= + * B07_DesignDetail_Note_Options_Node.ts + * B07 횡단도 표 위 CAD 칸 미리 넣음의 **좌측 패널 옵션 줄**을 서버가 내는 Node 진입점(166-1 · + * CLAUDE.md 5장 「계산 자리」 ②). + * + * 화면(B06 [상세] 모달 `loadNoteThings`)과 같은 함수(`structureOptionLines` · 정의 덧칸 `withDefinition`)를 + * 그대로 돌린다 — 파이썬 짝(`B07_DesignDetail_Engine_Cad_CulvertNote`)은 받은 줄을 구조물마다 조합 줄 뒤에 붙인다. + * + * 실행: node <번들> <입력.json> <출력.json> + * 입력 { things: [{ type_id, options }], types: GET /projects/structure-types 의 types, + * definition: 구조물 정의(GET /m02/structure-definition 과 같은 꼴) } + * 출력 { lines: string[][] } — things 차례대로 + * 끝 코드: 0 성공 / 2 인자 오류 + * ========================================================================== */ + +import { readFileSync, writeFileSync } from "node:fs"; +import type { StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; +import { structureOptionLines } from "../B05_Profile/B05_Profile_UI_Structures_Balloon"; +import { + withDefinition, + type StructureDefinition, +} from "../B05_Profile/B05_Profile_UI_Structures_Define"; + +interface Input { + things?: Array<{ type_id: string; options?: Record }>; + types?: StructureType[]; + definition?: StructureDefinition | null; +} + +const [inputPath, outputPath] = process.argv.slice(2); +if (!inputPath || !outputPath) { + console.error("사용: node <번들> <입력.json> <출력.json>"); + process.exit(2); +} +const input = JSON.parse(readFileSync(inputPath, "utf8")) as Input; +const types = new Map( + withDefinition(input.types ?? [], input.definition ?? null).map((type) => [type.type_id, type]), +); +const lines = (input.things ?? []).map((thing) => + structureOptionLines( + { type_id: thing.type_id, options: thing.options ?? {} }, + types.get(thing.type_id), + ), +); +writeFileSync(outputPath, JSON.stringify({ lines })); diff --git a/common_util/common_util_node_bundle.py b/common_util/common_util_node_bundle.py index 7a5a6247e..1a5e6e022 100644 --- a/common_util/common_util_node_bundle.py +++ b/common_util/common_util_node_bundle.py @@ -23,8 +23,9 @@ ROOT = Path(__file__).resolve().parents[1] SOURCE_DIRS = ("B05_Profile", "B06_Section", "B08_Quantity", "common_util") # 번들에 박히는 자료(JSON) — 벽 전면 기울기 표준경사 표·기슭막이 치수 표(2026-09-14 B5). 표만 바뀌어도 다시 빌드. SOURCE_DATA_DIRS = ("resources/data_masonry",) -# 폴더째 훑지 않는 자리 — B07 은 CAD 앱(openwebcad · node_modules)이 함께 살아 맨 위 TS 만 본다. -SOURCE_GLOBS = ("B07_DesignDetail/*.ts",) +# 폴더째 훑지 않는 자리 — B07 은 CAD 앱(openwebcad · node_modules)이 함께 살아 맨 위 TS 만 본다 · +# 구조물 명세(A00 design)는 B07 CAD 칸 옵션 줄 번들이 끌어 씀(166-1). +SOURCE_GLOBS = ("B07_DesignDetail/*.ts", "A00_Common/design/*.ts") # 번들 만들기·실행 상한(초). 실측 번들 실행 0.1초, 빌드 3초 수준이라 넉넉하다. BUILD_TIMEOUT_S = 300 RUN_TIMEOUT_S = 600 diff --git a/package.json b/package.json index 53baeccd0..06f974a03 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,13 @@ "type": "module", "scripts": { "dev": "node ./config/node_modules/vite/bin/vite.js --configLoader runner", - "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:spreadsheet && npm run build:b07-extent && npm run build:b07-cad", + "build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:spreadsheet && npm run build:b07-extent && npm run build:b07-note && npm run build:b07-cad", "build:corridor": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B05_Profile/B05_Profile_Corridor_Node.ts --outDir ../config/corridor_node", "build:server-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B06_Section/B06_Section_Server_Calc_Node.ts --outDir ../config/server_calc_node", "build:formula": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_sheet_recalc_node.ts --outDir ../config/formula_node", "build:spreadsheet": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_spreadsheet_node.ts --outDir ../config/spreadsheet_node", "build:b07-extent": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts --outDir ../config/b07_extent_node", + "build:b07-note": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B07_DesignDetail/B07_DesignDetail_Note_Options_Node.ts --outDir ../config/b07_note_node", "install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install", "build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build", "preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner", diff --git a/resources/tester/helper_166_1_note_options.cjs b/resources/tester/helper_166_1_note_options.cjs new file mode 100644 index 000000000..725dba4cc --- /dev/null +++ b/resources/tester/helper_166_1_note_options.cjs @@ -0,0 +1,45 @@ +/* 166-1 CAD 칸 미리 넣음 옵션 줄 — 진짜 `structureOptionLines`(명세 · 정의 덧칸)를 Node 로 돌림. + * 표준입력 { things: [{type_id, options}], types, definition } → 표준출력 { lines: string[][] } + * (B07 번들 `B07_DesignDetail_Note_Options_Node` 와 같은 입력 · 같은 함수 — 짝 = test_166_1_note_options.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(); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { structureOptionLines } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Balloon.ts"), +); +const { withDefinition } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Define.ts"), +); +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const types = new Map( + withDefinition(input.types ?? [], input.definition ?? null).map((type) => [type.type_id, type]), +); +const lines = (input.things ?? []).map((thing) => + structureOptionLines( + { type_id: thing.type_id, options: thing.options ?? {} }, + types.get(thing.type_id), + ), +); +process.stdout.write(JSON.stringify({ lines })); diff --git a/resources/tester/helper_260_note_cad.cjs b/resources/tester/helper_260_note_cad.cjs index eeceaeff0..9f6f8727b 100644 --- a/resources/tester/helper_260_note_cad.cjs +++ b/resources/tester/helper_260_note_cad.cjs @@ -22,6 +22,8 @@ const STUBS = { fetchStructureTypes: async () => DATA.types, readPendingStructures: () => null, }, + // 좌측 패널 옵션 줄(166-1)은 test_166_1 이 진짜 명세로 봄 — 여기는 조합 줄 · 마크만 + "../B05_Profile/B05_Profile_UI_Structures_Balloon": { structureOptionLines: () => [] }, }; function loadTs(file) { diff --git a/resources/tester/test_166_1_note_options.py b/resources/tester/test_166_1_note_options.py new file mode 100644 index 000000000..94536b3ed --- /dev/null +++ b/resources/tester/test_166_1_note_options.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +"""166-1 횡단 [상세] CAD 칸 · B07 표 위 미리 넣음 = 구조물 조합 이름 + 좌측 패널 옵션 줄. + +사용자 말: 「일단은 좌측패널 옵션과 구조물조합이름을 다 넣어주면 내가 추가로 선정하겠음」. +옵션 줄 = 좌측 패널 폼과 같은 컨테이너 · 이름 · 보이는 칸(`structureOptionLines`) — +관 보호공 구조 갈래에 맞지 않는 옛 값(집수정인데 흙막이 칸 값)은 안 씀 · +필수 상자 빈 칸 = 폼이 채워 보이는 기본값. +B07 은 같은 TS 를 Node 번들(`B07_DesignDetail_Note_Options_Node`)로 돌려 받음. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +sys.path.insert(0, str(ROOT)) + +from B05_Profile.B05_Profile_Structures_Schema import load_definition # noqa: E402 +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_CulvertNote import ( # noqa: E402 + structure_note_lines, + with_option_lines, +) +from M02_MasterTemplete.StructOptions.M02_SO_Options import with_visible # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + +PIPE = { + "pipe_diameter_mm": 800, + "pipe_kind": "파형강관", + "inlet_type": "집수정", + "inlet_structure": "L", + "inlet_basin_height_m": 1.3, + # 구조를 집수정으로 바꾸기 전 흙막이 값 — 폼이 안 보이는 옛 값 + "inlet_revet_form": "돌쌓기(찰)", + "inlet_revet_length_m": 10, + "outlet_type": "기슭막이", + "outlet_revet_form": "돌쌓기(찰)", + "outlet_revet_length_m": 10, + "outlet_revet_before_m": 5, + "outlet_revet_after_m": 5, + "outlet_revet_height_m": 1.3, +} +WALL = {"wall_form": "돌쌓기(찰)", "length_m": 12, "height_m": 2.5, "before_m": 6, "after_m": 6} +THINGS = [ + {"type_id": "pipe", "options": PIPE}, + {"type_id": "retaining_wall", "options": WALL}, + {"type_id": "hazard_sign", "options": {}}, +] + + +@pytest.fixture(scope="module") +def ts_lines() -> list[list[str]]: + payload = { + "things": THINGS, + "types": with_visible().model_dump(mode="json")["types"], + "definition": load_definition(), + } + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_166_1_note_options.cjs")], + input=json.dumps(payload, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout)["lines"] + + +def test_배관_옵션_줄은_폼_컨테이너_그대로(ts_lines): + pipe = ts_lines[0] + assert pipe[0] == "관: 관경 800" + # 집수정 갈래 칸만 · 빈 동물이동형 = 폼 기본 「유」 · 흙막이 옛 값은 안 씀 + assert pipe[1] == "관 보호공(유입): 구조 집수정 · 형태 L형 · 높이 1.3m · 동물이동형 유" + assert "10m" not in pipe[1] + assert pipe[2].startswith("관 보호공(유출): 구조 흙막이 · 형태 돌 쌓기(찰) · 길이 10m") + assert "기준측점 전 5m · 기준측점 후 5m" in pipe[2] + + +def test_정의_그룹_종류는_그룹마다_한_줄_옵션_없으면_줄_없음(ts_lines): + assert ts_lines[1] == [ + "옹벽: 형태 돌쌓기(찰) · 길이 12m · 높이 2.5m · 기준측점 전 6m · 기준측점 후 6m" + ] + assert ts_lines[2] == [] + + +def test_B07_은_같은_TS_를_Node_번들로_받아_같은_줄(ts_lines): + things = [{**thing, "name": thing["type_id"], "pipe": False, "at": 0.0} for thing in THINGS] + got = with_option_lines(things) + assert [thing["option_lines"] for thing in got] == ts_lines + + +def test_조합_줄_뒤에_옵션_줄_미지정도_옵션_줄(): + thing = { + "name": "옹벽", + "pipe": False, + "at": 40.0, + "summary_item": None, + "group_items": None, + "option_lines": ["옹벽: 높이 2.5m"], + } + assert structure_note_lines([thing], 40.0) == ["옹벽 조합 미지정", "옹벽: 높이 2.5m"] + + +def test_화면은_구조물마다_옵션_줄을_붙임(): + cad = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Detail_Cad.ts").read_text("utf-8") + assert "structureOptionLines({ type_id, options: options ?? {} }, byId.get(type_id))" in cad + assert "...(thing.optionLines ?? [])" in cad diff --git a/resources/tester/test_260_note_cad.py b/resources/tester/test_260_note_cad.py index 3124c0dbb..9fedd16ee 100644 --- a/resources/tester/test_260_note_cad.py +++ b/resources/tester/test_260_note_cad.py @@ -448,8 +448,9 @@ def test_칸_없음_만_미리_넣음_지운_칸_옛_글_설계없음_이웃은_ seeded = with_structure_note(design, 1433.0, sources, "NO.71+13") assert seeded is not design and "structure_note_cad" not in design # 저장 설계는 안 바뀜 labels = [e["shapeData"].get("label") for e in seeded["structure_note_cad"]["entities"]] - # 관 조합 미지정 = 원형 마크 · 측점만(배관은 글 줄 없음) - assert labels[2:] == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"] + # 관 조합 미지정 = 원형 마크 · 측점(배관은 조합 글 줄 없음) · 이어서 좌측 패널 옵션 줄(166-1) + assert labels[2:6] == ["배수관", "Φ1200m/m", "L=22m", "NO.71+13"] + assert labels[6].startswith("관: 관경 1200") and all(": " in line for line in labels[6:]) cleared = {**design, "structure_note_cad": {"entities": []}} assert with_structure_note(cleared, 1433.0, sources, "x") is cleared old = {**design, "structure_note": "옛 글"} diff --git a/resources/tester/test_265_note_cad.py b/resources/tester/test_265_note_cad.py index f476d1f74..79696efc9 100644 --- a/resources/tester/test_265_note_cad.py +++ b/resources/tester/test_265_note_cad.py @@ -209,7 +209,10 @@ def test_파이썬_짝_재료는_정본_관_지점_구조물_배치(tmp_path): assert [(t["name"], t["at"]) for t in sources[1]] == [("배관", 40.2), ("옹벽", 40.0)] seeded = with_structure_note({"pipe_length_m": 8.4}, 40.0, sources, "NO.2") labels = _labels(seeded["structure_note_cad"]["entities"]) - assert labels == ["배수관", "Φ800m/m", "L=8m", "NO.2", _A["name"], _C["name"]] + # 구조물마다 조합 줄 → 좌측 패널 옵션 줄(166-1) · 옵션 없는 옹벽은 조합 줄만 + assert labels[:5] == ["배수관", "Φ800m/m", "L=8m", "NO.2", _A["name"]] + assert labels[5].startswith("관: 관경 800") and labels[-1] == _C["name"] + assert all(": " in line for line in labels[5:-1]) # ── 265-3 관 길이 정수 ─────────────────────────────────────────────────