From f44789c33c97582480287e9ecdb7c4e70f5b75ec Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 18:26:34 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(m02):=20=EC=9D=B4=EB=A6=84=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=98=9B=20=EB=B3=B4=EB=82=B8=20=EB=B2=94=EC=9C=84?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0=20=E2=80=94=20=EB=A9=B4=EC=A0=81=20?= =?UTF-8?q?=EC=97=B4=20=EB=95=8C=20=EB=8F=84=EB=A9=B4=5F{=EC=97=B0?= =?UTF-8?q?=EA=B2=B0}=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=EB=A9=B4=EC=A0=81?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EB=95=8C=20=EC=97=B0=EA=B2=B0=ED=91=9C?= =?UTF-8?q?=20=EC=A4=84=EC=97=90=20=EC=9D=B4=EB=A6=84(114-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 면적 상세를 열 때 화면이 이름 없는 보낸 범위마다 책에 이름정의를 둠(고침 표시 없음) · 그 이름으로 점선 - 면적 [저장] 때 서버 refresh_drawing 이 책에 도면_{연결} 이 있으면 연결표 줄에 이름 적음 - 옛 줄 시험 기대 뒤집음 · 새 시험(옛 연결 → 열기 → 행 넣기 → 저장 → 도면 표 값 따라감) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- M02_MasterTemplete/M02_Area_Table.py | 19 +++ .../M02_MasterTemplete_AreaToDrawing.ts | 30 +++- .../M02_MasterTemplete_UI_Main.ts | 3 +- resources/tester/test_m02_area_old_links.py | 136 ++++++++++++++++++ resources/tester/test_m02_area_table.py | 13 +- 5 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 resources/tester/test_m02_area_old_links.py diff --git a/M02_MasterTemplete/M02_Area_Table.py b/M02_MasterTemplete/M02_Area_Table.py index 50c5139db..f89081ecf 100644 --- a/M02_MasterTemplete/M02_Area_Table.py +++ b/M02_MasterTemplete/M02_Area_Table.py @@ -25,6 +25,18 @@ DROPPED = "지운연결" CANCELS = "보내기취소" # 면적 [저장] 문서의 임시 칸 — 저장 안 함(112-2) # spreadsheet_area_table.ts `NAME_GONE` 과 같은 글자 — 이름이 지워졌을 때 까닭 NAME_GONE = "이름없음" +NAME = "도면_" # 보낸 범위 이름 앞머리 — AreaToDrawing.ts `NAME` 과 같은 글자 + + +def _book_names(book: Any) -> set[str]: + """책 모든 시트 이름정의 이름(작은 글자 · 화면 `resolveName` 과 같이 대소문자 무시).""" + return { + str(n.get("이름", "")).lower() + for s in (book.get("시트") or [] if isinstance(book, dict) else []) + if isinstance(s, dict) + for n in s.get("이름정의") or [] + if isinstance(n, dict) + } def to_table( @@ -119,6 +131,7 @@ def refresh_drawing(column: str, unlink: Any = ()) -> list[str]: """지금 층 그 열 도면(모든 장)의 연결표마다 칸 글 다시 · 마지막 값 갱신 → 칸을 간 표의 `연결` 목록. `unlink` = 면적 [저장] 때 [보내기 취소]한 연결 — 그 줄을 연결표에서 뺌 (도면 표는 남음 · 알림 없음 · 112-2). + 이름 없는 옛 줄은 책에 `도면_{연결}` 이름이 있으면 그 이름을 줄에 적음(114-1 · 면적 화면이 열 때 둠). 이름(104-1)이 있으면 그 이름이 지금 가리키는 범위로 `범위` 도 갱신 — 이름이 지워졌으면(범위가 통째로 지워짐) 그 연결을 끊고(도면 표는 남음) `지운연결` 목록에 적어 다음에 열 때 알림.""" try: @@ -137,6 +150,12 @@ def refresh_drawing(column: str, unlink: Any = ()) -> list[str]: atomic_write_json(drawing, doc) return [] book = json.loads(area.read_bytes()) + names = _book_names(book) + for ( + x + ) in links: # 이름 없는 옛 연결(114-1) — 면적 화면이 책에 둔 `도면_{연결}` 이 있으면 줄에 적음 + if not x.get("이름") and f"{NAME}{x['연결']}".lower() in names: + x["이름"] = f"{NAME}{x['연결']}" asks = [ { "book": book, diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts index 873c8c2a6..920783a61 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts @@ -31,6 +31,7 @@ import { type StructureDoc, } from "./M02_MasterTemplete_Api_Fetch"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet"; +import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types"; import type { Selection } from "./M02_MasterTemplete_UI_Main"; /** 표 칸 하나(CAD `TableCell` 꼴에서 쓰는 것만) */ @@ -141,18 +142,41 @@ export const sameValues = ( return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]); }; -/** 면적 시트에 겹쳐 보일 「도면으로 보낸 범위」 — 같은 열 도면 `연결표` 에서(받기 실패 = 없음 · 조용히) */ +/** 이름 없는 옛 연결(114-1) — 책에 `도면_{연결}` 이름정의를 적힌 범위로 둠(이미 있으면 그대로) → 그 이름. + * 책을 바로 고침(화면에 싣기 전 · 「저장 안 한 고침」 아님) · 서버는 면적 [저장] 때 그 이름을 연결표 줄에 적음 */ +async function nameOld(book: Workbook, row: TableLink): Promise { + const name = `${NAME}${row.연결}`; + const low = name.toLowerCase(); + if (book.시트.some((s) => s.이름정의?.some((n) => n.이름.toLowerCase() === low))) return name; + const { parseRange } = await import("../A00_Common/spreadsheet/spreadsheet_address"); + const sheet = book.시트.find((s) => s.id === row.시트); + const g = parseRange(row.범위); + if (!sheet || !g) return undefined; // 못 풂 — 적힌 범위로(옛 그대로) + sheet.이름정의 = [...(sheet.이름정의 ?? []), { 이름: name, 범위: g }]; + return name; +} + +/** 면적 시트에 겹쳐 보일 「도면으로 보낸 범위」 — 같은 열 도면 `연결표` 에서(받기 실패 = 없음 · 조용히) · + * `book`(면적 책 · 화면에 싣기 전) 있으면 이름 없는 옛 연결에 이름을 둠(114-1 · 행열 넣기를 따라감) */ export async function sentRanges( at: Selection, onCancel: (연결: string) => void, + book?: Workbook, ): Promise> { try { const got = await readTemplate(at.layer, "structure", at.name, at.projectId); const rows = (got.문서 as StructureDoc | undefined)?.연결표 ?? []; - return rows.map((row) => ({ + const names = await Promise.all( + rows.map((row) => + row.이름 || !book || !Array.isArray(book.시트) + ? row.이름 + : nameOld(book, row).catch(() => undefined), + ), + ); + return rows.map((row, i) => ({ 시트: row.시트, 범위: row.범위, - 범위이름: row.이름, + 범위이름: names[i], 이름: fill(TEXT.tag, "id", row.연결), 설명: fill(fill(fill(TEXT.tip, "id", row.연결), "range", row.범위), "size", row.글자크기), 취소: async () => (onCancel(row.연결), true), // 화면에서만 — 서버는 면적 [저장] 때 diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 4c8f5d10d..180b36cbf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -298,7 +298,8 @@ export function createMain( const [{ createSpreadsheet }, pills, sent] = await Promise.all([ import("../A00_Common/spreadsheet/spreadsheet"), pillsFor(kind, at.name), - sentRanges(at, toDrawing.cancel), // 도면으로 보낸 범위 — 시트 위 점선 테두리 + // 도면으로 보낸 범위 — 시트 위 점선 테두리 · 이름 없는 옛 연결은 책에 이름을 둠(114-1) + sentRanges(at, toDrawing.cancel, doc as Parameters[2]), ]); if (mine !== seq) return; if (pills.notice) showNotice([pills.notice]); diff --git a/resources/tester/test_m02_area_old_links.py b/resources/tester/test_m02_area_old_links.py new file mode 100644 index 000000000..fbb18d232 --- /dev/null +++ b/resources/tester/test_m02_area_old_links.py @@ -0,0 +1,136 @@ +"""114 — 이름 없는 옛 보낸 범위 연결이 행 넣기를 따라감(화면 · 서버 한 바퀴). + + - 114-1 면적 상세를 열 때 화면(`sentRanges` + 책)이 `도면_{연결}` 이름정의를 둠 → 행 넣기 → 면적 [저장] + → 서버 `refresh_drawing` 이 그 이름을 연결표 줄에 적고 도면 표 칸 글 = 옮겨진 칸. +TS 는 Node 로(`AreaToDrawing` 은 그 자리 변환 · 스프레드시트 모듈은 `helper_spreadsheet_d_bundle.cjs`). +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from common_util import common_util_spreadsheet as sheet_node +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_m02_area_table import world # noqa: F401 — 같은 임시 층 + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = r""" +const fs = require("fs"); +const path = require("path"); +const ROOT = process.argv[1]; +const { load, SS } = require(path.join(ROOT, "resources/tester/spreadsheet/helper_spreadsheet_d_bundle.cjs")); +const ts = require(path.join(ROOT, "config/node_modules/typescript")); +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const ss = (name) => load(path.join(SS, name + ".ts")); +const tsLoad = (file, stubs) => { + const src = fs.readFileSync(path.join(ROOT, file), "utf8"); + const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, (n) => { + if (!(n in stubs)) throw new Error("모르는 가져오기 " + n); + return stubs[n]; + }); + return box.exports; +}; +const toDrawing = tsLoad("M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts", { + "@config/config_frontend": { API_BASE_URL: "/api" }, + "@ui/ui_template_elements": {}, + "@ui/ui_template_modal": {}, + "./M02_MasterTemplete_Api_Fetch": { + getActiveOwner: () => null, + readTemplate: async () => ({ 문서: input.drawing }), + }, + "../A00_Common/spreadsheet/spreadsheet_address": ss("spreadsheet_address"), + "../A00_Common/spreadsheet/spreadsheet_area_table": ss("spreadsheet_area_table"), +}); +const { applyCommand } = ss("spreadsheet_commands"); + +(async () => { + const book = input.book; + const area = { layer: "system", projectId: null, kind: "area", name: "rv", label: "x" }; + const before = JSON.stringify(book); + const noBook = await toDrawing.sentRanges(area, () => {}); + const unchanged = JSON.stringify(book) === before; + const marks = await toDrawing.sentRanges(area, () => {}, book); + const opened = { + marks: marks.map((m) => [m.이름, m.범위이름 ?? null]), + noBook: noBook.map((m) => m.범위이름 ?? null), + unchanged, + names: book.시트[0].이름정의, + }; + applyCommand(book, { 종류: "행넣기", 시트: "s1", at: 0, 수: 1 }); // 1행 위에 한 행 + console.log(JSON.stringify({ opened, book })); +})().catch((e) => { + console.error(e); + process.exit(1); +}); +""" + +T1_TABLE = { + "type": "Table", + "shapeData": { + "origin": {"x": 0, "y": 0}, + "columnWidths": [9, 9], + "rowHeights": [3, 3], + "cells": [[{"text": "낡음"}, {"text": "낡음"}], [None, {"text": "낡음"}]], + "style": {"fontSize": 2.5, "연결": "T1"}, + }, +} +OLD_ROW = {"연결": "T1", "시트": "s1", "범위": "A1:B2", "글자크기": 2.5, "값": {}} + + +def _node(payload: dict) -> dict: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + proc = subprocess.run( + ["node", "-e", SCRIPT, str(ROOT)], + input=json.dumps(payload, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +def _texts(cells: list) -> list: + return [[c and c.get("text") for c in row] for row in cells] + + +def test_옛_연결_열기_행_넣기_저장_도면_표_값_따라감_114_1(world): # noqa: F811 + system = world["system"] + with store.at(system): + got = store.read("structure", "rv") + drawing = {**got["문서"], "연결표": [OLD_ROW]} + drawing["도면"] = {**drawing["도면"], "entities": [T1_TABLE]} + store.write("structure", "rv", got["판"], drawing) + area = store.read("area", "rv") + want = sheet_node.area_tables([{"book": area["문서"], "시트": "s1", "범위": "A1:B2"}])[0] + out = _node({"book": area["문서"], "drawing": drawing}) + opened = out["opened"] + assert opened["noBook"] == [None] and opened["unchanged"] # 책 없으면 안 고침(옛 길) + assert opened["marks"] == [["T1 도면", "도면_T1"]] # 그 이름으로 점선 + assert opened["names"] == [{"이름": "도면_T1", "범위": {"r0": 0, "c0": 0, "r1": 1, "c1": 1}}] + book = out["book"] + assert book["시트"][0]["이름정의"][0]["범위"] == { + "r0": 1, + "c0": 0, + "r1": 2, + "c1": 1, + } # 행 넣기 따라감 + with store.at(system): + store.write("area", "rv", area["판"], book) # 면적 [저장] → refresh_drawing + doc = store.read("structure", "rv")["문서"] + row = doc["연결표"][0] + assert (row["이름"], row["범위"]) == ("도면_T1", "A2:B3") + cells = doc["도면"]["entities"][0]["shapeData"]["cells"] + assert _texts(cells) == _texts(want["표"]["shapeData"]["cells"]) # 옮겨진 칸 = 보낸 그 칸 + assert _texts(cells)[0] == ["제목", "1,234.50"] diff --git a/resources/tester/test_m02_area_table.py b/resources/tester/test_m02_area_table.py index 84412ebce..8a699f868 100644 --- a/resources/tester/test_m02_area_table.py +++ b/resources/tester/test_m02_area_table.py @@ -300,11 +300,20 @@ def test_판_충돌로_도면_저장이_막히면_이름도_안_생김_112_2(wor assert (system / "area" / "rv.json").read_bytes() == before -def test_이름_없는_옛_연결_줄은_이름을_안_둠_112_2(world): +def test_이름_없는_옛_연결_줄은_면적_저장_때_책에_이름_있으면_줄에_적음_114_1(world): system = world["system"] with store.at(system): _save_links([{"연결": "T1", "시트": "s1", "범위": "A1:B2", "값": {}}]) - assert _area_names(system) == [] + assert _area_names(system) == [] # 도면 저장은 옛 줄 이름을 안 만듦(112-2 그대로) + drawing = store.read("structure", "rv")["문서"] + assert "이름" not in drawing["연결표"][0] # 책에 이름 없으면 적힌 범위 그대로 + got = store.read( + "area", "rv" + ) # 면적 화면이 열 때 둔 이름(114-1) — 이름이 가리키는 범위는 B1:C2 + got["문서"]["시트"][0]["이름정의"] = [T1_NAME] + store.write("area", "rv", got["판"], got["문서"]) + row = store.read("structure", "rv")["문서"]["연결표"][0] + assert (row["이름"], row["범위"]) == ("도면_T1", "B1:C2") def test_면적_저장_보내기취소_줄과_이름을_뺌_도면_표는_남음_112_2(world): From e53f8d4effc4e8132599bb8f295b315baeda3059 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 18:27:34 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EF=BB=BFfeat(b06):=20=EB=B0=B0=EA=B4=80=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=EB=8F=84=20=EA=B4=80=20=EA=B3=A0=EB=A6=84=20?= =?UTF-8?q?=EA=B0=95=EC=A1=B0=20=C2=B7=20=EC=A2=8C=EC=B8=A1=20=E3=80=8C?= =?UTF-8?q?=EA=B4=80=E3=80=8D=20=EC=83=81=EC=9E=90=20=EA=B0=95=EC=A1=B0(11?= =?UTF-8?q?8-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관 판정면(벽 판정면 아래 — 겹친 자리는 벽) · 누르면 관 두 겹 is-active - 고름 전용 키 RevetPick(RevetKey | "pipe") — 조정 함수는 RevetKey 그대로 - 조정창 없음(show "pipe" = 숨김 · side "pipe") · 좌측 fieldset[data-spec-container="pipe"] 강조 + 스크롤 - 좌측 「관」 상자 칸 고침 = 관 고름 · B05 3D pipe 키 → B06 관 고름 - 107-1 벽 · 본체 동시 고름 금지 그대로 · 관 숨긴 카드(링크 · 세월교 · 박스)엔 판정면 없음 - 시험 test_118_pipe_pick(helper_118_pipe_pick.cjs) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- B06_Section/B06_Section_UI_Adjust_Dock.ts | 11 +- B06_Section/B06_Section_UI_Cross_Culvert.ts | 40 ++- .../B06_Section_UI_Cross_Culvert_Wire.ts | 15 +- .../B06_Section_UI_Cross_Structure_Panel.ts | 9 +- B06_Section/B06_Section_UI_Cross_View.ts | 12 +- .../B06_Section_UI_Cross_View_Structure.ts | 8 +- .../B06_Section_UI_Page_Station_Controls.ts | 4 +- .../B06_Section_UI_Page_Structure_Pick.ts | 10 +- .../B06_Section_UI_Page_Structures_Panel.ts | 6 +- resources/tester/helper_118_pipe_pick.cjs | 309 ++++++++++++++++++ resources/tester/test_118_pipe_pick.py | 116 +++++++ 11 files changed, 494 insertions(+), 46 deletions(-) create mode 100644 resources/tester/helper_118_pipe_pick.cjs create mode 100644 resources/tester/test_118_pipe_pick.py diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index 28fcf846a..3815cd3c4 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -154,11 +154,14 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void { clearSideSlots(); const side = panelRoot.dataset.side; // 세월교 · BOX 본체 = 옮길 줄 없이 그 명세의 본체 상자만 강조(66-1) — 날개벽 칸이 든 명세 폼에서 찾음 - if (side === "body") { - const body = sideSlots?.wingInlet - .closest(".b05-structure__spec") + // 관 = 배관 명세 폼의 「관」 상자만 강조(118-1) + if (side === "body" || side === "pipe") { + const body = (side === "pipe" ? sideSlots?.inlet : sideSlots?.wingInlet) + ?.closest(".b05-structure__spec") ?.querySelector( - 'fieldset[data-spec-container="ford"], fieldset[data-spec-container="body"]', + side === "pipe" + ? 'fieldset[data-spec-container="pipe"]' + : 'fieldset[data-spec-container="ford"], fieldset[data-spec-container="body"]', ); if (!body || body.closest("[hidden]")) return; setGroupLabel(body, ""); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 35bbbbbe0..874f65baf 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -56,9 +56,11 @@ function polygon( // "own" = 배관과 무관한 **독립 기슭막이**(구조물 정본 D군). 조정창·조작값 저장은 // 배관 벽과 같은 체계를 그대로 쓴다(2026-08-28 사용자: 배관측점처럼 이동). export type RevetKey = "inlet" | "outlet" | "own" | `extra${number}` | `bextra${number}`; +/** 고름 전용 키 — 벽 키 + 관(118-1 · 조정창 없음 · 강조만). 조정 함수에는 `RevetKey` 만. */ +export type RevetPick = RevetKey | "pipe"; /** 벽 강조를 카드 재생성 없이 갈아 끼우는 setter. */ -export type RevetHighlightSetter = (key: RevetKey | null) => void; +export type RevetHighlightSetter = (key: RevetPick | null) => void; /** * 배수관 세트 오버레이 — computeCulvertLayout 결과를 그린다. @@ -72,7 +74,7 @@ export function appendCulvertOverlay( layout: CulvertLayout, x: (offset: number) => number, toDisplayY: (elevation: number) => number, - onSelectRevet?: (key: RevetKey) => void, + onSelectRevet?: (key: RevetPick) => void, /** 관을 그리지 않는다 — 관은 한 측점에만 있고, 연장이 넘어온 옆 측점 카드에는 * 기슭막이만 링크로 보인다(2026-08-24 사용자). */ hidePipe?: boolean, @@ -311,6 +313,8 @@ export function appendCulvertOverlay( elevation: corner.top.elevation + normal.elevation * delta, }, }); + const pipeShapes: SVGPolygonElement[] = []; // 관 두 겹 — 관 고름 강조(118-1) + let pipeHitPoints: Array<[number, number]> = []; const pipeLayers = hidePipe ? [] : ([ @@ -320,18 +324,16 @@ export function appendCulvertOverlay( for (const [delta, cls] of pipeLayers) { const a = shifted(inletFinal, delta); const b = shifted(outletFinal, delta); - layer.append( - polygon( - [ - [x(a.bottom.offset), toDisplayY(a.bottom.elevation)], - [x(b.bottom.offset), toDisplayY(b.bottom.elevation)], - [x(b.top.offset), toDisplayY(b.top.elevation)], - [x(a.top.offset), toDisplayY(a.top.elevation)], - ], - cls, - tip, - ), - ); + const points: Array<[number, number]> = [ + [x(a.bottom.offset), toDisplayY(a.bottom.elevation)], + [x(b.bottom.offset), toDisplayY(b.bottom.elevation)], + [x(b.top.offset), toDisplayY(b.top.elevation)], + [x(a.top.offset), toDisplayY(a.top.elevation)], + ]; + if (!pipeHitPoints.length) pipeHitPoints = points; // 외경 = 판정면 + const shape = polygon(points, cls, tip); + pipeShapes.push(shape); + layer.append(shape); } drawOutletFill(); @@ -340,6 +342,15 @@ export function appendCulvertOverlay( // 먼저 먹는다(2026-08-21 화면 확인). 그리기 순서(벽 → 관 → 보호공)는 그대로 두고 // 판정면만 위로 올린다. 관 자체의 툴팁은 벽 밖 구간에서 그대로 뜬다. if (onSelectRevet) { + // 관 판정면은 벽 판정면 **아래** — 겹친 자리는 벽이 이김(118-1). + if (pipeHitPoints.length) { + const hit = polygon(pipeHitPoints, "b06-chart__culvert-revet-hit", `${tip} — 관 선택`); + hit.addEventListener("click", (event) => { + event.stopPropagation(); + onSelectRevet("pipe"); + }); + layer.append(hit); + } for (const { wall, key: wallKey } of wallsInOrder) { const hit = polygon( wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), @@ -375,5 +386,6 @@ export function appendCulvertOverlay( for (const [role, shape] of revetShapes) shape.classList.toggle("is-active", role === key); // 유입이 집수정이면 부재 전체를 함께 강조한다(2026-08-22 사용자 ③). for (const shape of basinShapes) shape.classList.toggle("is-active", key === "inlet"); + for (const shape of pipeShapes) shape.classList.toggle("is-active", key === "pipe"); }; } diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index f22cb5c36..4d1bf1d74 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -7,7 +7,12 @@ import { bodyAbsolute } from "./B06_Section_UI_Cross_Body_Guard"; import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch"; import { computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert"; -import type { CulvertLayout, InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert"; +import type { + CulvertLayout, + InletStructureChoice, + RevetKey, + RevetPick, +} from "./B06_Section_UI_Cross_Culvert"; import type { BasinAdjust, CulvertDesignTrim } from "./B06_Section_UI_Cross_Culvert_Types"; import { basinSpanOfSpec, @@ -31,12 +36,12 @@ export interface RevetOffsetControl { adjustFor: (section: CrossSection, role: RevetKey) => WallAdjust; /** 이 측점이 **실제로 손댄** 값만 — 없으면 null(자동·상속과 구분해야 한다). */ storedAdjustFor: (section: CrossSection, role: RevetKey) => WallAdjust | null; - selectedFor: (section: CrossSection) => RevetKey | null; + selectedFor: (section: CrossSection) => RevetPick | null; /** 이 카드에서 **강조**할 벽 — 연동으로 이어진 옆 측점이 고른 벽도 켠다 * (2026-08-30 사용자: 한 기슭막이는 그 연장 안 모든 횡단도에서 같이 강조된다). * 조정창은 고른 카드 하나에만 뜨므로 `selectedFor`와 따로 둔다. */ - highlightFor: (section: CrossSection) => RevetKey | null; - select: (chainageM: number, key: RevetKey | null) => void; + highlightFor: (section: CrossSection) => RevetPick | null; + select: (chainageM: number, key: RevetPick | null) => void; /** 선택이 바뀌면 화면이 연동 카드 강조를 다시 칠한다 — 목록 쪽이 꽂는다. */ onSelectionChange?: () => void; /** 기하가 실제로 적용한 조작값을 되받아 담는다(한계에 걸린 요청값을 잘라 낸다). */ @@ -382,7 +387,7 @@ export function restrictToSide(layout: CulvertLayout, side: string | undefined): export function computeCardCulvert( section: CrossSection, sourceSamples: SectionSample[], - activeRevet: RevetKey | null, + activeRevet: RevetPick | null, revetOffset?: RevetOffsetControl, inletStructure?: InletStructureControl, extraWalls?: ExtraWallControl, diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 1bc6abb03..89460db24 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -14,7 +14,7 @@ * 폼 칸에 옮겨 붙인다(`_Adjust_Dock`). 폼과 같은 값(형태 · 높이 · 길이 · 전 · 후)은 만들지 않는다. * ========================================================================== */ -import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert"; +import type { InletStructureChoice, RevetKey, RevetPick } from "./B06_Section_UI_Cross_Culvert"; import { REVET_FORMS } from "./B06_Section_UI_Cross_Culvert_Const"; import type { BasinAdjust, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import type { SpanValues } from "./B06_Section_UI_Cross_Culvert_Wire"; @@ -97,7 +97,7 @@ export interface StructurePanelDeps { export interface StructurePanelHandle { root: HTMLElement; /** null이면 숨긴다. 값이 오면 그 구조물 기준으로 다시 그린다. */ - show: (key: RevetKey | null) => void; + show: (key: RevetPick | null) => void; } /** @@ -494,9 +494,12 @@ export function buildStructurePanel( return { root, - show: (key) => { + show: (pick) => { + // 관 = 조정창 없음 · 좌측 「관」 상자만 강조(118-1 · 세월교 본체 꼴) + const key = pick === "pipe" ? null : pick; current = key; root.classList.toggle("is-hidden", key === null); + root.dataset.side = pick === "pipe" ? "pipe" : ""; document.removeEventListener("keydown", onArrowKey); drag.detach(); if (!key) return; diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index c6f8a34f7..755d162fc 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -55,7 +55,7 @@ import { attachZoomPan, buildZoomControls, cardZoomStates } from "./B06_Section_ import type { CrossWidthActions } from "./B06_Section_UI_Cross_View_Zoom"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes"; -import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert"; +import type { RevetHighlightSetter, RevetKey, RevetPick } from "./B06_Section_UI_Cross_Culvert"; import { REVET_FORM_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const"; import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import { @@ -82,7 +82,7 @@ export interface CrossCardElement extends HTMLElement { applySelection?: (selected: boolean, areaKey: CrossAreaKey | null) => void; /** 벽 강조만 갈아 끼운다 — 연동으로 이어진 카드도 같이 켜진다(2026-08-30 사용자). * 카드를 다시 만들지 않으므로 줌·팬이 살아남는다. */ - applyRevetHighlight?: (key: RevetKey | null) => void; + applyRevetHighlight?: (key: RevetPick | null) => void; } /** @@ -180,10 +180,10 @@ export function createCrossSectionCard( let activeArea: CrossAreaKey | null = selected ? (initialAreaKey ?? null) : null; let setBandActive: AreaHighlightSetter = () => undefined; let setChipActive: AreaHighlightSetter = () => undefined; - let activeRevet: RevetKey | null = revetOffset?.selectedFor(section) ?? null; + let activeRevet: RevetPick | null = revetOffset?.selectedFor(section) ?? null; /** 이 카드에서 **강조**할 벽. 연동으로 이어진 옆 측점이 고른 벽도 켠다 — * 조정창(`activeRevet`)은 고른 카드 하나에만 뜬다(2026-08-30 사용자). */ - let highlightRevet: RevetKey | null = revetOffset?.highlightFor(section) ?? activeRevet; + let highlightRevet: RevetPick | null = revetOffset?.highlightFor(section) ?? activeRevet; let setRevetActive: RevetHighlightSetter = () => undefined; let showRevetControl: (visible: boolean) => void = () => undefined; let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기 @@ -222,7 +222,7 @@ export function createCrossSectionCard( }, selectCard: () => onSelect(section.station_id), }); - const toggleRevet = (key: RevetKey): void => { + const toggleRevet = (key: RevetPick): void => { const wasSelected = isSelected; activeRevet = activeRevet === key ? null : key; showRevetControl(activeRevet !== null); @@ -654,7 +654,7 @@ export function createCrossSectionCard( extraState: () => culvertExtraState, basinExtraState: () => culvertBasinExtraState, activeRevet: () => activeRevet, - setActiveRevet: (key: RevetKey | null) => { + setActiveRevet: (key: RevetPick | null) => { activeRevet = key; }, toggleRevet, diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index 067951139..d88057f65 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -6,7 +6,7 @@ * ========================================================================== */ import type { CrossSection } from "./B06_Section_Api_Fetch"; -import type { RevetKey } from "./B06_Section_UI_Cross_Culvert"; +import type { RevetKey, RevetPick } from "./B06_Section_UI_Cross_Culvert"; import { FILL_SLOPE_RATIO_MIN, PIPE_WALL_DEFAULT_RUN_M, @@ -81,9 +81,9 @@ export interface StructurePanelContext { inletOptions: () => { revetAllowed: boolean; basinLUAllowed: boolean }; extraState: () => { canAdd: boolean; count: number }; basinExtraState: () => { canAdd: boolean; count: number }; - activeRevet: () => RevetKey | null; - setActiveRevet: (key: RevetKey | null) => void; - toggleRevet: (key: RevetKey) => void; + activeRevet: () => RevetPick | null; + setActiveRevet: (key: RevetPick | null) => void; + toggleRevet: (key: RevetPick) => void; /** 창 스크롤 자리를 기억할 카드 키(측점 id). */ scrollKey: string; } diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index c75854fb8..fd3fc9e73 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -11,7 +11,7 @@ import type { SectionDetailResponse, StoredWallSpan, } from "./B06_Section_Api_Fetch"; -import type { InletStructureChoice, RevetKey } from "./B06_Section_UI_Cross_Culvert"; +import type { InletStructureChoice, RevetKey, RevetPick } from "./B06_Section_UI_Cross_Culvert"; import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; import type { BasinAdjust, WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import type { @@ -180,7 +180,7 @@ export function createStationControls(deps: StationControlDeps): StationControls const revetShifts = revetSession.values; /** 지금 고른 벽 **하나** — 조정창이 뜬 측점(누가거리 키)과 벽 키(2026-08-30 개편: * 강조는 연동으로 이어진 카드 전부, 조정창은 고른 카드 하나). */ - let revetSelection: { at: string; key: RevetKey } | null = null; + let revetSelection: { at: string; key: RevetPick } | null = null; const revetKey = (chainageM: number, role: RevetKey): string => `${chainageM.toFixed(2)}:${role}`; function loadRevetShifts(): void { diff --git a/B06_Section/B06_Section_UI_Page_Structure_Pick.ts b/B06_Section/B06_Section_UI_Page_Structure_Pick.ts index 79037c928..97698499b 100644 --- a/B06_Section/B06_Section_UI_Page_Structure_Pick.ts +++ b/B06_Section/B06_Section_UI_Page_Structure_Pick.ts @@ -15,7 +15,7 @@ * ========================================================================== */ import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import type { RevetKey } from "./B06_Section_UI_Cross_Culvert"; +import type { RevetPick } from "./B06_Section_UI_Cross_Culvert"; import type { StructurePickHandoff } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { coversChainage, @@ -23,13 +23,13 @@ import { type SelectableStructure, } from "../A00_Common/design/design_selection"; -/** 기슭막이 조정창이 받는 부재키 — 집수정·배관·본체는 여기서 빠진다. */ -const REVET_KEYS = /^(inlet|outlet|own|extra\d+|bextra\d+)$/; +/** 벽 고름이 받는 부재키 — 관은 강조만(118-1) · 집수정·본체는 여기서 빠진다. */ +const REVET_KEYS = /^(inlet|outlet|own|extra\d+|bextra\d+|pipe)$/; interface PickTargets { focusStation: (stationId: string) => void; refreshCard: (chainageM: number) => void; - revetSelect: (chainageM: number, key: RevetKey) => void; + revetSelect: (chainageM: number, key: RevetPick) => void; fordSelect: (chainageM: number, role: "inlet" | "outlet" | "body") => void; boxSelect: (chainageM: number, role: "left" | "right" | "body") => void; } @@ -73,7 +73,7 @@ export function applyStructurePick( targets.boxSelect(target.chainage_m, key); } else { if (!REVET_KEYS.test(key)) return; - targets.revetSelect(target.chainage_m, key as RevetKey); + targets.revetSelect(target.chainage_m, key as RevetPick); } targets.refreshCard(target.chainage_m); }); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 387a15a63..73332a1c9 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -55,7 +55,7 @@ import { } from "./B06_Section_UI_Adjust_Dock"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { StationControls } from "./B06_Section_UI_Page_Station_Controls"; -import type { RevetKey } from "./B06_Section_UI_Cross_Culvert"; +import type { RevetPick } from "./B06_Section_UI_Cross_Culvert"; import { withSpecDefaults } from "./B06_Section_UI_Page_Structures_Spec"; const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요."; @@ -616,9 +616,9 @@ export function wireStructureSelection( }; // 관 상자 칸을 고치거나 고르면 그 부분을 고름 — 횡단에서 누른 것과 같은 길(54-30) - const keyOf = (box: HTMLElement): RevetKey | null => { + const keyOf = (box: HTMLElement): RevetPick | null => { const id = box.dataset.specContainer; - if (id === "inlet" || id === "outlet") return id; + if (id === "inlet" || id === "outlet" || id === "pipe") return id; return id === "soil_add" ? `extra${Number(box.dataset.specIndex)}` : null; }; const pickBox = (box: HTMLElement): void => { diff --git a/resources/tester/helper_118_pipe_pick.cjs b/resources/tester/helper_118_pipe_pick.cjs new file mode 100644 index 000000000..40779c370 --- /dev/null +++ b/resources/tester/helper_118_pipe_pick.cjs @@ -0,0 +1,309 @@ +/* PLAN 118-1 단위검증 헬퍼 — B06 배관 횡단도 관 고름 · 강조 · 조정창 숨김 · 3D pipe 키. + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. + * 입력 = `fixtures/b07_cross_overlay_sections.json` 배관 측점 85.052(관 Φ1.0). 짝 = `test_118_pipe_pick.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(); +let STUBS = { + "@ui/ui_template_toast": { showToast: () => undefined }, + "@ui/ui_template_elements": { showToast: () => undefined }, +}; + +// ── 화면 그릇 흉내 — 오버레이 · 조정창이 쓰는 만큼만 ───────────────────────── +class FakeNode { + constructor(tag) { + this.tagName = String(tag).toUpperCase(); + this.children = []; + this.attrs = {}; + this.dataset = {}; + this.listeners = {}; + this._text = ""; + this._classes = new Set(); + const self = this; + this.classList = { + add: (...names) => names.forEach((n) => self._classes.add(n)), + remove: (...names) => names.forEach((n) => self._classes.delete(n)), + toggle: (name, force) => { + const on = force === undefined ? !self._classes.has(name) : !!force; + on ? self._classes.add(name) : self._classes.delete(name); + return on; + }, + contains: (name) => self._classes.has(name), + }; + this.style = { setProperty: () => {} }; + } + set className(value) { + this._classes = new Set(String(value).split(/\s+/).filter(Boolean)); + } + get className() { + return [...this._classes].join(" "); + } + set textContent(value) { + this._text = String(value); + this.children = []; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(""); + } + append(...kids) { + kids.forEach((k) => this.children.push(k)); + } + appendChild(kid) { + this.children.push(kid); + return kid; + } + replaceChildren(...kids) { + this.children = kids; + } + setAttribute(name, value) { + this.attrs[name] = String(value); + if (name === "class") this.className = value; + } + getAttribute(name) { + return name === "class" ? this.className : (this.attrs[name] ?? null); + } + addEventListener(type, fn) { + (this.listeners[type] ??= []).push(fn); + } + removeEventListener() {} + click() { + let stopped = false; + const event = { stopPropagation: () => (stopped = true), preventDefault: () => {} }; + (this.listeners.click ?? []).forEach((fn) => fn(event)); + return stopped; + } + querySelectorAll(selector) { + const found = []; + const walk = (node) => + node.children.forEach((kid) => { + if (kid.classList && kid.classList.contains(selector.slice(1))) found.push(kid); + walk(kid); + }); + walk(this); + return found; + } + querySelector(selector) { + return this.querySelectorAll(selector)[0] ?? null; + } + getClientRects() { + return []; + } +} +globalThis.document = { + createElement: (tag) => new FakeNode(tag), + createElementNS: (_ns, tag) => new FakeNode(tag), + createComment: () => new FakeNode("#comment"), + addEventListener: () => {}, + removeEventListener: () => {}, +}; +globalThis.window = { addEventListener: () => {}, removeEventListener: () => {} }; +globalThis.requestAnimationFrame = (fn) => fn(); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) { + const data = JSON.parse(fs.readFileSync(full, "utf8")); + cache.set(full, data); + return data; + } + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; + +const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`); +const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts")); +const { appendCulvertOverlay } = loadTs(B06("B06_Section_UI_Cross_Culvert")); +const { wallStandsAt } = loadTs(B06("B06_Section_UI_Cross_Culvert_Wire")); +const { applyStructurePick } = loadTs(B06("B06_Section_UI_Page_Structure_Pick")); + +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"), +); +const sections = fixture.cross_sections; +const pipeSection = sections.find((s) => s.culvert && !s.culvert.hidden_pipe); +const layout = computeStoredLayouts(pipeSection, sections).culvert; +const out = {}; + +// ① 오버레이 — 판정면 차례 · 관 누름 · 강조 +function overlay(hidePipe) { + const layer = new FakeNode("g"); + const picked = []; + const setter = appendCulvertOverlay( + layer, + layout, + (o) => -o * 10, + (e) => -e * 10, + (key) => picked.push(key), + hidePipe, + ); + const hits = layer.children.filter((n) => n.classList.contains("b06-chart__culvert-revet-hit")); + const pipes = layer.children.filter((n) => n.classList.contains("b06-chart__culvert-pipe")); + const walls = layer.children.filter((n) => n.classList.contains("b06-chart__culvert-revet")); + return { layer, picked, setter, hits, pipes, walls }; +} +const drawn = overlay(false); +const firstHit = drawn.hits[0]; +const stopped = firstHit.click(); +const active = () => ({ + pipes: drawn.pipes.map((p) => p.classList.contains("is-active")), + walls: drawn.walls.map((w) => w.classList.contains("is-active")), +}); +drawn.setter("pipe"); +const onPipe = active(); +drawn.setter("outlet"); +const onOutlet = active(); +drawn.setter(null); +const onNone = active(); +// 판정면 차례 — 관 판정면이 벽 판정면들보다 먼저(아래) 얹힘 +const order = drawn.layer.children + .filter((n) => n.classList.contains("b06-chart__culvert-revet-hit")) + .map((n) => (n === firstHit ? "pipe" : "wall")); +// 관 판정면 = 외경 겹 꼭짓점과 같음 +const outer = drawn.pipes.find((p) => p.classList.contains("b06-chart__culvert-pipe--outer")); +out.overlay = { + pipeCount: drawn.pipes.length, + wallCount: drawn.walls.length, + hitOrder: order, + firstPicked: drawn.picked, + stopped, + hitMatchesOuter: firstHit.attrs.points === outer.attrs.points, + onPipe, + onOutlet, + onNone, +}; +// 관을 숨긴 카드(링크 · 독립 흙막이 · 세월교 · 박스) — 관 판정면 없음 +const hidden = overlay(true); +hidden.hits.forEach((h) => h.click()); +out.hiddenPipe = { pipes: hidden.pipes.length, picked: hidden.picked }; + +// ② 연동 카드 — 관은 주인 측점 카드에만 +const neighbour = { ...pipeSection, chainage_m: pipeSection.chainage_m + 2 }; +out.stands = { + self: wallStandsAt(pipeSection, pipeSection, "pipe"), + neighbour: wallStandsAt(pipeSection, neighbour, "pipe"), + outletNeighbour: wallStandsAt(pipeSection, neighbour, "outlet"), +}; + +// ③ B05 3D 관 고름(pipe 키) → B06 벽 고름 길로 +function pick(key, extra = {}) { + const calls = []; + const target = { ...pipeSection, ...extra }; + applyStructurePick( + { at: target.chainage_m, key }, + { cross_sections: [target] }, + { + focusStation: (id) => calls.push(["focus", id]), + refreshCard: (c) => calls.push(["refresh", c]), + revetSelect: (c, k) => calls.push(["revet", k]), + fordSelect: (c, k) => calls.push(["ford", k]), + boxSelect: (c, k) => calls.push(["box", k]), + }, + ); + return calls.filter(([kind]) => kind !== "focus" && kind !== "refresh"); +} +out.pick = { + pipe: pick("pipe"), + basin: pick("basin"), + fordPipe: pick("pipe", { ford: {} }), +}; + +// ④ 조정창 — show("pipe") = 숨김 · side "pipe" · 조정 함수 안 불림 +const base = loadTs(B06("B06_Section_UI_Cross_Panel_Base")); +STUBS = { + ...STUBS, + "./B06_Section_UI_Section_Common": { L: (key) => key }, + "./B06_Section_UI_Cross_Panel_Base": { + ...base, + bindDragHandle: () => ({ attach: () => {}, detach: () => {} }), + }, +}; +const { buildStructurePanel } = loadTs(B06("B06_Section_UI_Cross_Structure_Panel")); +const touched = []; +const spy = (name, value) => (key) => { + touched.push([name, key]); + return value; +}; +const panelDeps = { + scrollKey: "t118", + adjustFor: spy("adjustFor", { x: 0, d: null, h: null, m: null }), + nudge: spy("nudge"), + nudgeSlope: spy("nudgeSlope"), + nudgeHeight: spy("nudgeHeight"), + heightFor: spy("heightFor", 2), + formFor: spy("formFor", "돌쌓기(메)"), + setForm: spy("setForm"), + reset: spy("reset"), + close: () => {}, + structureFor: () => "revet", + basinAdjustFor: () => ({ innerWidthM: 1, innerHeightM: 1.2, lateralM: 0, slopeM: 0 }), + updateBasin: () => {}, + nudgeBasinLateral: () => {}, + resetBasin: () => {}, + canNudge: spy("canNudge", true), + fineMove: spy("fineMove", false), + dragAxes: spy("dragAxes", null), + extraState: () => ({ canAdd: false, count: 0 }), + isLinkedCard: () => false, + basinExtraState: () => ({ canAdd: false, count: 0 }), + hiddenPipe: () => false, + bodyGuard: () => false, + setExtraCount: () => {}, + setBasinExtraCount: () => {}, + equalizeExtras: () => {}, + spanFor: spy("spanFor", null), + setSpan: () => {}, + linkState: () => null, + setLinked: () => {}, + followGrade: () => true, + setFollowGrade: () => {}, +}; +const panel = buildStructurePanel(panelDeps); +panel.show("outlet"); +const outletState = { + hidden: panel.root.classList.contains("is-hidden"), + side: panel.root.dataset.side, +}; +touched.length = 0; +panel.show("pipe"); +out.panel = { + outlet: outletState, + pipe: { + hidden: panel.root.classList.contains("is-hidden"), + side: panel.root.dataset.side, + touched: touched.map(([name]) => name), + }, +}; +panel.show(null); +out.panel.none = { + hidden: panel.root.classList.contains("is-hidden"), + side: panel.root.dataset.side, +}; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_118_pipe_pick.py b/resources/tester/test_118_pipe_pick.py new file mode 100644 index 000000000..161d0e4f3 --- /dev/null +++ b/resources/tester/test_118_pipe_pick.py @@ -0,0 +1,116 @@ +"""PLAN 118-1 — B06 배관 횡단도 관 고름 → 관 두 겹 강조 · 좌측 「관」 상자 강조 · 조정창 없음. + +짝: `B06_Section/B06_Section_UI_Cross_Culvert.ts`(관 판정면 · 강조) · `_Cross_Structure_Panel.ts`(show "pipe") · +`_Adjust_Dock.ts`(side "pipe") · `_Page_Structures_Panel.ts`(`keyOf`) · `_Page_Structure_Pick.ts`(3D pipe 키). +실제 TS 를 `helper_118_pipe_pick.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_118_pipe_pick.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _src(rel: str) -> str: + with open(os.path.join(ROOT, rel), encoding="utf-8") as f: + return f.read() + + +def test_관을_누르면_관을_고름(out) -> None: + overlay = out["overlay"] + assert overlay["firstPicked"] == ["pipe"] + assert overlay["stopped"] is True # 카드 고름으로 안 번짐 + assert overlay["hitMatchesOuter"] is True # 판정면 = 외경 + + +def test_관_판정면은_벽_아래(out) -> None: + """겹친 자리는 벽이 이김 — 관 판정면이 벽 · 집수정 판정면보다 먼저 얹힘.""" + order = out["overlay"]["hitOrder"] + assert order[0] == "pipe" + assert order.count("pipe") == 1 and len(order) > 1 + + +def test_관_고름은_관_두_겹만_강조(out) -> None: + overlay = out["overlay"] + assert overlay["pipeCount"] == 2 + assert overlay["onPipe"]["pipes"] == [True, True] + assert not any(overlay["onPipe"]["walls"]) + # 벽을 고르면 관 강조는 꺼짐 — 동시 강조 없음 + assert not any(overlay["onOutlet"]["pipes"]) and any(overlay["onOutlet"]["walls"]) + assert not any(overlay["onNone"]["pipes"] + overlay["onNone"]["walls"]) + + +def test_관을_숨긴_카드엔_관_판정면_없음(out) -> None: + """링크 · 독립 흙막이 · 세월교 · 박스 측점(hidden_pipe) — 관은 세월교 오버레이 몫.""" + assert out["hiddenPipe"]["pipes"] == 0 + assert "pipe" not in out["hiddenPipe"]["picked"] + + +def test_관_강조는_주인_측점_카드만(out) -> None: + stands = out["stands"] + assert stands["self"] is True + assert stands["neighbour"] is False + assert stands["outletNeighbour"] is True # 벽 연동은 그대로 + + +def test_3D_관_고름이_B06_으로_이어짐(out) -> None: + assert out["pick"]["pipe"] == [["revet", "pipe"]] + assert out["pick"]["basin"] == [] + assert out["pick"]["fordPipe"] == [] # 세월교 측점 = 세월교 창 몫 + + +def test_관은_조정창_없음(out) -> None: + panel = out["panel"] + assert panel["outlet"] == {"hidden": False, "side": "outlet"} + assert panel["pipe"] == {"hidden": True, "side": "pipe", "touched": []} + assert panel["none"] == {"hidden": True, "side": ""} + + +def test_좌측_관_상자_강조_스크롤() -> None: + dock = _src("B06_Section/B06_Section_UI_Adjust_Dock.ts") + assert 'if (side === "body" || side === "pipe") {' in dock + assert "'fieldset[data-spec-container=\"pipe\"]'" in dock + assert 'body.classList.add("is-active");' in dock + assert 'if (scroll) body.scrollIntoView({ block: "nearest" });' in dock + # 관 상자 칸을 고치면 관을 고름 — 횡단에서 누른 것과 같은 길 + page = _src("B06_Section/B06_Section_UI_Page_Structures_Panel.ts") + assert 'if (id === "inlet" || id === "outlet" || id === "pipe") return id;' in page + css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css") + assert ".b06-chart__culvert-pipe.is-active {" in css + + +def test_벽_본체_동시_고름_금지_그대로() -> None: + """107-1 — 관도 같은 고름 저장소라 세월교 · 박스 고름과 함께 서지 않음.""" + station = _src("B06_Section/B06_Section_UI_Page_Station_Controls.ts") + assert "let revetSelection: { at: string; key: RevetPick } | null = null;" in station + assert ( + "if (key && ford.control.selectedFor(chainageM)) ford.control.select(chainageM, null);" + in station + ) + view = _src("B06_Section/B06_Section_UI_Cross_View.ts") + assert "const toggleRevet = (key: RevetPick): void => {" in view + assert "if (activeRevet !== null) toggleRevet(activeRevet);" in view