From afedef7efa9d8096c314ae9e14e36485e9ce93e9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:01:08 +0900 Subject: [PATCH 01/47] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EB=8A=94=20=EA=B8=80=20=3D=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EA=B7=9C=EA=B2=A9=20=C2=B7=20=EC=9B=90=EB=AC=B8=EB=B2=88?= =?UTF-8?q?=ED=98=B8=EB=8A=94=20=ED=92=8D=EC=84=A0=20=C2=B7=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B0=92=20=EB=B2=88=ED=98=B8=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C(85-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- M02_MasterTemplete/M02_Labor_Inputs.py | 17 ++++++++++- M02_MasterTemplete/M02_Labor_Values.ts | 17 +++++++++-- M02_MasterTemplete/M02_Labor_Values_Model.ts | 15 ++++++++-- .../M02_MasterTemplete_Api_Fetch.ts | 2 ++ .../M02_MasterTemplete_Labor_Diagram.ts | 5 ++-- resources/tester/test_m02_labor_values.py | 29 +++++++++++++++++++ 6 files changed, 76 insertions(+), 9 deletions(-) diff --git a/M02_MasterTemplete/M02_Labor_Inputs.py b/M02_MasterTemplete/M02_Labor_Inputs.py index 71cf43bae..fd18f0489 100644 --- a/M02_MasterTemplete/M02_Labor_Inputs.py +++ b/M02_MasterTemplete/M02_Labor_Inputs.py @@ -40,8 +40,20 @@ def _logics(codes: list[str]) -> tuple[list[dict], list[str]]: return found, missing +def _labels(whole: Any, spec: dict) -> dict[str, str]: + """고르기 원문번호 → 보이는 글(85-1) — 기계 「이름 규격」 · 직종(입력 이름에 「직종」) 「이름」 · 모르는 번호는 뺌.""" + table = "LB" if "직종" in str(spec.get("이름") or "") else "EQ" + rows = whole.numbers.get(table, {}) + out = {} + for value in spec.get("고르기") or []: + row = rows.get(str(value)) if isinstance(value, str) else None + if row: + out[value] = " ".join(str(row[k]) for k in ("이름", "규격") if row.get(k)) + return out + + def _listed(logics: list[dict]) -> list[dict[str, Any]]: - """로직 줄마다 `{로직, 이름, 결과단위, 출처, 입력: [{이름, 설명, 단위, 고르기?, 범위?, 자동값}]}`.""" + """로직 줄마다 `{로직, 이름, 결과단위, 출처, 입력: [{이름, 설명, 단위, 고르기?, 고르기이름?, 범위?, 자동값}]}`.""" whole = m01.loaded()[1] out = [] for logic in logics: @@ -51,6 +63,9 @@ def _listed(logics: list[dict]) -> list[dict[str, Any]]: one = {k: spec[k] for k in _SPEC if k in spec} one.setdefault("설명", None) one.setdefault("단위", None) + labels = _labels(whole, spec) + if labels: + one["고르기이름"] = labels specs.append({**one, "자동값": auto.get(spec["이름"])}) out.append( { diff --git a/M02_MasterTemplete/M02_Labor_Values.ts b/M02_MasterTemplete/M02_Labor_Values.ts index e70f8b978..b83422733 100644 --- a/M02_MasterTemplete/M02_Labor_Values.ts +++ b/M02_MasterTemplete/M02_Labor_Values.ts @@ -13,6 +13,7 @@ import { choicesOf, inputKey, isOutside, + labelOf, rangeOf, storedOf, type ValueDoc, @@ -65,7 +66,12 @@ export function createValueControl( if (control instanceof HTMLSelectElement) { control.append( ...(auto === null ? [el("option", { attrs: { value: "" }, text: "—" })] : []), - ...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })), + ...choices.map((c) => + el("option", { + attrs: { value: c.값, ...(c.이름 !== c.값 ? { title: c.값 } : {}) }, + text: c.이름, + }), + ), ); } const empty = value === null || value === ""; @@ -194,10 +200,15 @@ export function createInputsValueCard(opts: InputsValueOptions): InputsValueHand }; if (opts.mode === "view") { const text = isEmpty(stored.값) ? auto : stored.값; + const label = text === null ? "—" : labelOf(input, text); const shown = el("span", { className: `m02-inputval__value${isEmpty(stored.값) ? " is-auto" : ""}`, - text: text === null ? "—" : String(text), - attrs: isEmpty(stored.값) ? { title: VALUE_TEXT.auto } : {}, + text: label, + attrs: isEmpty(stored.값) + ? { title: VALUE_TEXT.auto } + : label !== String(text) + ? { title: String(text) } + : {}, }); paintState(); return el("div", { className: "m02-inputval__cell", children: [shown, state] }); diff --git a/M02_MasterTemplete/M02_Labor_Values_Model.ts b/M02_MasterTemplete/M02_Labor_Values_Model.ts index b49ea4771..5d8c61de2 100644 --- a/M02_MasterTemplete/M02_Labor_Values_Model.ts +++ b/M02_MasterTemplete/M02_Labor_Values_Model.ts @@ -14,9 +14,14 @@ const text = (value: unknown): string => ? JSON.stringify(value) : String(value); +/** 값 → 보이는 글(85-1) — 원문번호면 「이름 규격」 · 그 밖은 값 그대로 */ +export const labelOf = (input: ItemInput, value: unknown): string => + input.고르기이름?.[text(value)] ?? text(value); + /** 고르기 · 범위 표시 — 선택지 목록이면 「a / b / c」 · 범위(배열 둘 · {최소, 최대})면 「최소 ~ 최대」 */ export function valueHint(input: ItemInput): string { - if (Array.isArray(input.고르기) && input.고르기.length) return input.고르기.map(text).join(" / "); + if (Array.isArray(input.고르기) && input.고르기.length) + return input.고르기.map((one) => labelOf(input, one)).join(" / "); const range = input.범위; if (Array.isArray(range) && range.length >= 2) return `${text(range[0])} ~ ${text(range[1])}`; if (range && typeof range === "object") { @@ -60,7 +65,11 @@ export function choicesOf(input: ItemInput): { 값: string; 이름: string; raw: const object = typeof one === "object" && one !== null ? (one as Record) : null; const raw = (object ? (object.값 ?? object.이름 ?? "") : one) as string | number; - return { 값: String(raw), 이름: object ? String(object.이름 ?? raw) : String(one), raw }; + return { + 값: String(raw), + 이름: object ? String(object.이름 ?? raw) : labelOf(input, one), + raw, + }; }); } @@ -112,7 +121,7 @@ export function valueLine( for (const input of inputs) { const value = values?.[input.이름]; if (value !== undefined && value !== "") { - parts.push(`${input.이름} ${value}${input.단위 ? input.단위 : ""}`); + parts.push(`${input.이름} ${labelOf(input, value)}${input.단위 ? input.단위 : ""}`); } else if (autoText(input) === "") empty += 1; } return { text: parts.join(" · "), empty }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index a76619d33..c6e60c267 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -319,6 +319,8 @@ export interface ItemInput { 설명?: string; 단위?: string; 고르기?: unknown[]; + /** 고르기 원문번호 → 보이는 글(85-1 · 기계 「이름 규격」 · 직종 이름) */ + 고르기이름?: Record; 범위?: unknown; 자동값?: unknown; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts index 9c2ab01b1..4b464ba3d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts @@ -98,7 +98,7 @@ export interface LaborDiagramSource { 로직: string; 이름: string; 결과단위?: string; - 입력: { 이름: string; 단위?: string; 자동값?: unknown }[]; + 입력: { 이름: string; 단위?: string; 자동값?: unknown; 고르기이름?: Record }[]; }[]; /** calc 답 · 아직이면 null */ calc: { @@ -137,10 +137,11 @@ export function buildLaborDiagram(src: LaborDiagramSource): DiagramData { inputs: (def?.입력 ?? []).map((x) => { const mine = r.입력값[x.이름]; const has = mine !== undefined && mine !== ""; + const raw = has ? String(mine) : autoOf(x.자동값); return { name: x.이름, unit: x.단위 ?? "", - value: has ? String(mine) : autoOf(x.자동값), + value: x.고르기이름?.[raw] ?? raw, // 85-1 원문번호 → 「이름 규격」 ...(has || !autoOf(x.자동값) ? {} : { auto: true }), }; }), diff --git a/resources/tester/test_m02_labor_values.py b/resources/tester/test_m02_labor_values.py index 923e5fe8c..e800b2d63 100644 --- a/resources/tester/test_m02_labor_values.py +++ b/resources/tester/test_m02_labor_values.py @@ -70,6 +70,9 @@ const kind = { 이름: "종류", 고르기: ["막돌", "견치돌"] }; const size = { 이름: "규격", 고르기: [25, 40] }; const area = { 이름: "지역" }; const inputs = [height, kind, size, area]; +const machine = { + 이름: "기계", 고르기: ["0201-0060", "9999"], 고르기이름: { "0201-0060": "굴착기(무한궤도) 0.6㎥" }, +}; console.log(JSON.stringify({ stored: [ m.storedOf(height, ""), m.storedOf(height, "2.5"), m.storedOf(kind, "막돌"), @@ -82,6 +85,11 @@ console.log(JSON.stringify({ known: m.keepKnown(inputs, { 높이: 2, 종류: "", 없앤것: 3 }), line: m.valueLine(inputs, { 높이: 2, 규격: 40 }), lineEmpty: m.valueLine(inputs, {}), + named: [ + m.choicesOf(machine).map((c) => [c.값, c.이름]), m.valueHint(machine), + m.valueLine([machine], { 기계: "0201-0060" }).text, m.labelOf(machine, "9999"), + m.storedOf(machine, "0201-0060"), + ], })); """ @@ -100,6 +108,27 @@ def test_값_셈() -> None: assert got["known"] == {"kept": {"높이": 2}, "lost": ["없앤것"]} assert got["line"] == {"text": "높이 2m · 규격 40", "empty": 2} # 종류 · 지역 = 자동값 없음 assert got["lineEmpty"] == {"text": "", "empty": 3} # 높이는 자동값이 있어 빈 값 아님 + # 85-1 — 보이는 글 = 이름 규격 · 모르는 번호는 그대로 · 저장 값은 번호 + assert got["named"] == [ + [["0201-0060", "굴착기(무한궤도) 0.6㎥"], ["9999", "9999"]], + "굴착기(무한궤도) 0.6㎥ / 9999", + "기계 굴착기(무한궤도) 0.6㎥", + "9999", + "0201-0060", + ] + + +def test_고르기_원문번호에_이름표(client: TestClient) -> None: + """85-1 — 기계 · 집게 = 「이름 규격」 · 직종(입력 이름에 「직종」) = 직종 이름 · 고르기는 번호 그대로.""" + got = client.get("/api/m02/logic-lib/inputs", params={"keys": "GC000260"}).json() + specs = {i["이름"]: i for i in got["로직"][0]["입력"]} + assert specs["기계"]["고르기이름"]["0201-0060"] == "굴착기(무한궤도) 0.6㎥" + assert specs["집게"]["고르기이름"]["7206-0020"] == "부착용 집게 0.2㎥" + assert "0201-0060" in specs["기계"]["고르기"] + assert "고르기이름" not in specs["뒷길이"] and "고르기이름" not in specs["지역"] + whole = labor_inputs.m01.loaded()[1] + job = {"이름": "조종원직종", "고르기": ["1048", "없는번호"]} + assert labor_inputs._labels(whole, job) == {"1048": "건설기계운전사"} ROWS_SCRIPT = r""" From 93fe8876c695f7e02b9ca8d49b7e9a54aebdf1ef Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:01:35 +0900 Subject: [PATCH 02/47] =?UTF-8?q?fix(B06):=20=EC=84=B8=EC=9B=94=EA=B5=90?= =?UTF-8?q?=20=EA=B4=80=20=EB=81=9D=20=EA=BC=AD=EC=A7=93=EC=A0=90=20=3D=20?= =?UTF-8?q?=EA=B7=B8=20=EB=86=92=EC=9D=B4=20=ED=9D=99=EB=A7=89=EC=9D=B4=20?= =?UTF-8?q?=EC=A0=84=EB=A9=B4=20=C2=B7=203D=20=EB=8F=84=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EC=9E=90=EB=A6=AC=20=ED=95=A8=EC=88=98(89-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../B05_Profile_UI_Corridor_Structures.ts | 20 ++++++++-------- .../B06_Section_UI_Cross_Body_Guard.ts | 24 +++++++++++-------- resources/tester/helper_80_ford_pipe.cjs | 9 +++++++ resources/tester/test_80_ford_pipe.py | 11 +++++++++ 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 9ddf07da9..c85bb404a 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -49,6 +49,7 @@ import { type WallPath, } from "./B05_Profile_UI_Corridor_Structures_Path"; import { computeRevetmentLayout } from "../B06_Section/B06_Section_UI_Cross_Revetment"; +import { withoutGuardedFill } from "../B06_Section/B06_Section_UI_Cross_Body_Guard"; /** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */ export interface StructureFrame { @@ -274,7 +275,8 @@ export function buildCorridorStructures( const solids: CorridorStructure[] = []; for (const section of crossSections) { const layout = culvertLayoutOf(section); - const fordLayout = fordLayoutOf(section); + // 흙막이가 선 쪽은 횡단과 같은 자리 함수로 — 측벽 걷고 관 · 바닥판이 흙막이까지(89-1) + const fordLayout = withoutGuardedFill(fordLayoutOf(section), layout); // 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위 // culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다 // (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙). @@ -449,12 +451,11 @@ export function buildCorridorStructures( if (fordLayout) { // 구체는 월류 폭만큼 도로 방향으로 이어지고 기준 측점 전후로 절반씩 걸친다. const half = Math.max(fordLayout.ford.span_m, 0.5) / 2; - const invertPoints = fordLayout.pipe.outer; - const fordBore = pipeBore( - fordLayout.ford.diameter_m, - { offset: invertPoints[3].offset, elevation: invertPoints[3].elevation }, - { offset: invertPoints[2].offset, elevation: invertPoints[2].elevation }, - ); + // 관 끝 = 윗 꼭짓점 자리(흙막이 사면에서 안 튀어나옴 · 89-1) · 높이 = invert + const [topLeft, topRight, invertRight, invertLeft] = fordLayout.pipe.outer; + const pipeStart = { offset: topLeft.offset, elevation: invertLeft.elevation }; + const pipeEnd = { offset: topRight.offset, elevation: invertRight.elevation }; + const fordBore = pipeBore(fordLayout.ford.diameter_m, pipeStart, pipeEnd); // 관은 련수만큼 폭 안에 등간격 — 구멍도 관마다 하나씩 낸다(2026-08-25 사용자 ⑥). const count = Math.max(fordLayout.ford.pipe_count, 1); const pipeChainages = Array.from( @@ -518,7 +519,6 @@ export function buildCorridorStructures( ), ); // 관은 련수만큼 폭 안에 등간격으로 놓는다(단면엔 1개만 보이지만 실물은 여러 련). - const invert = fordLayout.pipe.outer; for (const at of pipeChainages) { const frame = frameAt(at); solids.push({ @@ -526,8 +526,8 @@ export function buildCorridorStructures( kind: "pipe", key: "pipe", pipe: { - start: [invert[3].offset, invert[3].elevation], - end: [invert[2].offset, invert[2].elevation], + start: [pipeStart.offset, pipeStart.elevation], + end: [pipeEnd.offset, pipeEnd.elevation], diameterM: fordLayout.ford.diameter_m, wallThicknessM: pipeWallThicknessM( fordLayout.ford.pipe_kind, diff --git a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts index be2f43718..640af0277 100644 --- a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts +++ b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts @@ -210,8 +210,12 @@ export function withoutGuardedFill( if (!layout || !culvert?.culvert.body) return layout; const guarded = (outward: number): boolean => culvert.walls.some((w) => w.outward === outward); const mid = layout.label.at.offset; - const reach = (points: OffsetPoint[], outward: number, offset: number): OffsetPoint[] => - points.map((p) => ((p.offset - mid) * outward > 0 ? { ...p, offset } : p)); + const reach = ( + points: OffsetPoint[], + outward: number, + offset: (p: OffsetPoint) => number, + ): OffsetPoint[] => + points.map((p) => ((p.offset - mid) * outward > 0 ? { ...p, offset: offset(p) } : p)); if ("box" in layout && "topSlab" in layout) { const sides = layout.sides.map((side) => guarded(side.role === "left" ? 1 : -1) @@ -226,7 +230,7 @@ export function withoutGuardedFill( const to = wall?.bottomBack.offset ?? side.offset; if ((to - side.offset) * outward < 1e-6) continue; [topSlab, bottomSlab, barrel] = [topSlab, bottomSlab, barrel].map((p) => - reach(p, outward, to), + reach(p, outward, () => to), ); } const run = @@ -237,19 +241,19 @@ export function withoutGuardedFill( const sides = ford.sides.map((side) => guarded(side.outward) ? { ...side, parts: [], fillSegments: [], cutLine: null } : side, ); - // 관 · 바닥판이 흙막이까지 따라감 — 관 끝 = 흙막이 전면(관 invert 높이) · 바닥판 끝 = 배면(80) + // 관 · 바닥판이 흙막이까지 따라감 — 관 끝 꼭짓점 = 그 높이의 흙막이 전면(사면 이용 · 89-1) · + // 아래 꼭짓점(invert)은 종전 자리라 1 m 맞춤 그대로 · 바닥판 끝 = 배면(80) let { pipe, slabBridge } = ford; for (const side of ford.sides) { const wall = culvert.walls.find((w) => w.role === side.role && w.outward === side.outward); if (!wall) continue; - const front = - wall.points[2].offset + - wall.outward * wall.lean * (wall.topJoint.elevation - side.slabTopElevation); + const faceAt = (p: OffsetPoint): number => + wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - p.elevation); pipe = { - outer: reach(pipe.outer, side.outward, front), - inner: reach(pipe.inner, side.outward, front), + outer: reach(pipe.outer, side.outward, faceAt), + inner: reach(pipe.inner, side.outward, faceAt), }; - slabBridge = reach(slabBridge, side.outward, wall.bottomBack.offset); + slabBridge = reach(slabBridge, side.outward, () => wall.bottomBack.offset); } if (pipe === ford.pipe) return { ...ford, sides } as T; const [right, left] = pipe.outer.slice(2); diff --git a/resources/tester/helper_80_ford_pipe.cjs b/resources/tester/helper_80_ford_pipe.cjs index 552e817d0..4957556cc 100644 --- a/resources/tester/helper_80_ford_pipe.cjs +++ b/resources/tester/helper_80_ford_pipe.cjs @@ -65,6 +65,8 @@ function pipeFit(edit) { const { ford, culvert } = computeStoredLayouts(section, list); const [right, left] = ford.pipe.outer.slice(2); const ends = { 1: left.offset, [-1]: right.offset }; + const [topLeft, topRight] = ford.pipe.outer; + const tops = { 1: topLeft, [-1]: topRight }; const slab = ford.slabBridge.map((p) => p.offset); const slabEnd = { 1: Math.max(...slab), [-1]: Math.min(...slab) }; return { @@ -80,6 +82,13 @@ function pipeFit(edit) { role: side.role, guard: true, pipeGap: r3(ends[side.outward] - front), + // 관 윗모서리 ↔ 그 높이의 흙막이 전면(사면 이용 · 89-1) + topGap: r3( + tops[side.outward].offset - + (wall.points[2].offset + + wall.outward * wall.lean * (wall.topJoint.elevation - tops[side.outward].elevation)), + ), + lean: wall.lean, slabGap: r3(slabEnd[side.outward] - wall.bottomBack.offset), }; }), diff --git a/resources/tester/test_80_ford_pipe.py b/resources/tester/test_80_ford_pipe.py index eead57fc1..f101a302f 100644 --- a/resources/tester/test_80_ford_pipe.py +++ b/resources/tester/test_80_ford_pipe.py @@ -47,6 +47,17 @@ def test_관은_흙막이_전면까지_바닥판은_배면까지(fit: dict) -> N assert side["slabGap"] == pytest.approx(0, abs=1e-3) +def test_관_윗모서리도_흙막이_전면_사면에(fit: dict) -> None: + """89-1 — 흙막이 사면 이용: 윗모서리가 그 높이 전면 자리라 벽 밖으로 안 튀어나감.""" + leaned = 0 + for case in (fit[k] for k in ("both", "inletPlus1", "outletPlus2", "inletOnly")): + for side in case["sides"]: + if side["guard"]: + assert side["topGap"] == pytest.approx(0, abs=1e-3) + leaned += side["lean"] > 0 + assert leaned + + def test_흙막이를_옮기면_관이_그만큼_따라감(fit: dict) -> None: base = fit["both"]["lengthM"] assert fit["inletPlus1"]["lengthM"] == pytest.approx(base + 1, abs=1e-3) From 245eaa77af75acd14fc72f2af5c0f6134f591a8d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:01:53 +0900 Subject: [PATCH 03/47] =?UTF-8?q?feat(M02):=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=95=88=20=ED=95=9C=20=EA=B3=A0=EC=B9=A8=20=EB=AC=BC=EC=9D=8C?= =?UTF-8?q?=20=EB=8B=A8=EC=B6=94=20=EC=85=8B=20=E2=80=94=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=C2=B7=20=EB=B2=84=EB=A6=AC=EA=B3=A0=20=EB=84=98?= =?UTF-8?q?=EC=96=B4=EA=B0=90=20=C2=B7=20=EC=A0=80=EC=9E=A5=20=ED=9B=84=20?= =?UTF-8?q?=EB=84=98=EC=96=B4=EA=B0=90,=20settle=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=8B=A4=ED=8C=A8=20=ED=9D=A0(86-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_ItemCards.ts | 17 ++- .../M02_MasterTemplete_Structure.ts | 19 ++- .../M02_MasterTemplete_TreePanel_Dialogs.ts | 35 +---- .../M02_MasterTemplete_UI_Main.ts | 45 ++++--- resources/tester/test_m02_card_sheet_edit.py | 7 +- resources/tester/test_m02_drawing_card.py | 2 +- resources/tester/test_m02_leave_dialog.py | 125 ++++++++++++++++++ ui_template/ui_template_leave_dialog.ts | 84 ++++++++++++ ui_template/ui_template_locale_m2.ts | 4 +- 9 files changed, 267 insertions(+), 71 deletions(-) create mode 100644 resources/tester/test_m02_leave_dialog.py create mode 100644 ui_template/ui_template_leave_dialog.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index a0e9eb039..ea605c524 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -12,6 +12,7 @@ * ========================================================================== */ import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; +import { leaveOrStay } from "@ui/ui_template_leave_dialog"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import type { @@ -68,7 +69,7 @@ const TEXT = { noFieldNumber: "{value} 없음", noSheet: "스프레드시트를 못 띄움 — {value}", readFailed: "{value} 파일을 못 읽음 — {why}", - discard: "카드에 저장 안 한 고침이 있음 — 버리고 다른 항목을 열까요?", + discard: "카드에 저장 안 한 고침이 있음 — 버리고 넘어갈까?", pills: "알약", pillsTip: "아래 알약 줄 보임 · 숨김", take: "가져오기", @@ -179,6 +180,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { const numberOf = (kind: CardKind, id: string): string => opts.numberOf?.(kind, id) ?? ""; const anyDirty = (): boolean => cards.some((c) => c.saver.dirty()); + const saveAll = async (): Promise => { + for (const c of cards) if (c.saver.dirty() && !(await c.saver.run())) throw new Error(); + }; const reload = (): void => void load(column ? { id: column, label } : null, true); /** 카드 틀 하나 — 제목 옆 = ? · 제목 줄 오른쪽 = [저장] · [〇〇 수정](빈 단추 한 모양) · 부제 = 번호 · 이름 */ @@ -461,7 +465,12 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { async function load(col: { id: string; label: string } | null, again = false): Promise { // 카드 고침을 저장 안 했으면 버릴지 물음(한 번) — 취소면 보던 열 그대로 - if (!again && anyDirty() && col?.id !== column && !(await showConfirmDialog(TEXT.discard))) + if ( + !again && + anyDirty() && + col?.id !== column && + (await leaveOrStay(TEXT.discard, { save: saveAll, stillDirty: anyDirty })) === "stay" + ) return; const mine = ++seq; drop(); @@ -530,9 +539,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { }, column: () => column, dirty: anyDirty, - saveAll: async () => { - for (const c of cards) if (c.saver.dirty() && !(await c.saver.run())) throw new Error(); - }, + saveAll, discard: () => { drop(); opts.onDirty?.(false); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 8bf976944..d11baa92a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -14,7 +14,8 @@ * 글자는 이 파일에 둠(도면 부품 `M02_MasterTemplete_Drawing.ts` 와 같은 방식). * ========================================================================== */ -import { showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { showToast } from "@ui/ui_template_elements"; +import { leaveOrStay } from "@ui/ui_template_leave_dialog"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; import { clearPart, @@ -252,9 +253,12 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane if ((col?.id ?? null) !== column && cards.dirty()) { // 카드에 저장 안 한 고침 — 버릴지 물음 · 머물면 표의 고른 열을 되돌림 const from = column; - void showConfirmDialog(TEXT.discardCards).then((ok) => { - if (!ok) return void (from && opts.keepColumn?.(from)); - cards.discard(); // 한 번 물었음 — 카드가 다시 묻지 않게 + void leaveOrStay(TEXT.discardCards, { + save: cards.saveAll, + stillDirty: cards.dirty, + discard: cards.discard, // 한 번 물었음 — 카드가 다시 묻지 않게 + }).then((got) => { + if (got === "stay") return void (from && opts.keepColumn?.(from)); void load(col); }); return; @@ -272,7 +276,12 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane const toNo = to && numbers[to.id]?.[PART_FIELD[kind]]; if (!to || !toNo) return void showToast(TEXT.saveFirst, "error"); // 카드에 저장 안 한 고침 — 가져온 뒤 카드를 다시 읽어 버려지니 먼저 물음 - if (cards.dirty() && !(await showConfirmDialog(TEXT.discardCards))) return; + if ( + cards.dirty() && + (await leaveOrStay(TEXT.discardCards, { save: cards.saveAll, stillDirty: cards.dirty })) === + "stay" + ) + return; const target = `${toNo} ${columnHead(to)}`; const here = { layer: getActiveLayer(), owner: getActiveOwner() }; const same = (from: From): boolean => from.layer === here.layer && from.owner === here.owner; diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts index 73c701f68..37442f0e7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M02_MasterTemplete_TreePanel_Dialogs.ts - * 구조물 라이브러리 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기 · 저장/버림 물음. + * 구조물 라이브러리 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기. * 모두 약속(Promise)으로 답함 — 닫기 · Esc · 바깥 누르기 = 취소(null / "cancel"). * ========================================================================== */ @@ -183,36 +183,3 @@ export function askFolder( }); }); } - -export type Settle = "save" | "discard" | "cancel"; - -/** 저장 안 한 표 고침 — 저장하고 계속 · 버리고 계속 · 취소 */ -export function askSettle(): Promise { - return new Promise((resolve) => { - let done = false; - const finish = (value: Settle): void => { - if (done) return; - done = true; - resolve(value); - }; - openModal({ - title: L("M02_TreeUnsaved"), - closeLabel: L("M02_TreeCancel"), - dialogClass: "m02-modal m02-treedlg", - beforeClose: () => (finish("cancel"), true), - mount: (body, close) => { - const choose = (value: Settle) => () => { - finish(value); - close(); - }; - const save = createButton({ label: L("M02_TreeSaveGo"), variant: "filled" }); - const discard = createButton({ label: L("M02_TreeDiscardGo"), variant: "danger" }); - const cancel = createButton({ label: L("M02_TreeCancel"), variant: "ghost" }); - save.addEventListener("click", choose("save")); - discard.addEventListener("click", choose("discard")); - cancel.addEventListener("click", choose("cancel")); - body.append(el("div", { className: "m02-side__row", children: [save, discard, cancel] })); - }, - }); - }); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 8bdf22c42..a319ab1d8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -6,6 +6,7 @@ import { setLeaveGuard } from "../A00_Common/router"; import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { leaveOrStay } from "@ui/ui_template_leave_dialog"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { t as L } from "@ui/ui_template_locale"; import { @@ -26,7 +27,6 @@ import { type StructureColumn, type StructurePanelHandle, } from "./M02_MasterTemplete_Structure"; -import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel"; import { createAreaToDrawing, sentRanges } from "./M02_MasterTemplete_AreaToDrawing"; import { detailHelp, HELP_TITLE } from "./M02_MasterTemplete_CardHelp"; @@ -202,19 +202,26 @@ export function createMain( event.returnValue = ""; }; window.addEventListener("beforeunload", guard); - const confirmLeave = async (): Promise => { - if (!busy()) return true; // 카드 고침(도면 · 엑셀)도 같은 물음 - if (!(await showConfirmDialog(L("M02_DiscardConfirm")))) { - // 취소 — 초점을 시트(고른 칸 입력 자리) · 집계표 · 도면으로 돌려 키보드 그대로 - host - .querySelector(".ss-input, .ui-sheet, iframe") - ?.focus({ preventScroll: true }); - return false; - } - dirty = false; - toDrawing.drop(); - return true; + /** + * 저장 안 한 고침 물음(떠나기 · 나무 고치기 한 몸) — 단추 셋 [취소] [버리고 넘어감] [저장 후 넘어감]. + * "clean" = 고침 없음 · "saved" · "discarded" · false = 머묾(취소 · 저장 실패 · 판 다름 · 카드 저장 실패). + * 열린 양식이 없는 화면(옵션 정의 등)은 저장할 것이 없어 2단추 그대로. + */ + const askUnsaved = async (message: string): Promise => { + if (!busy()) return "clean"; // 카드 고침(도면 · 엑셀)도 같은 물음 + const drop_ = (): void => { + dirty = false; + toDrawing.drop(); + }; + let got: "saved" | "discarded" | "stay"; + if (sel) got = await leaveOrStay(message, { save: doSave, stillDirty: busy, discard: drop_ }); + else got = (await showConfirmDialog(message)) ? (drop_(), "discarded") : "stay"; + if (got !== "stay") return got; + // 머묾 — 초점을 시트(고른 칸 입력 자리) · 집계표 · 도면으로 돌려 키보드 그대로 + host.querySelector(".ss-input, .ui-sheet, iframe")?.focus({ preventScroll: true }); + return false; }; + const confirmLeave = async (): Promise => !!(await askUnsaved(L("M02_DiscardConfirm"))); setLeaveGuard(confirmLeave); // 머리 메뉴 · 뒤로 가기로 떠날 때도 물음 const showNotice = (nodes: (HTMLElement | string)[]): void => { @@ -573,15 +580,9 @@ export function createMain( } async function settle(): Promise { - if (!busy()) return "clean"; - const choice = await askSettle(); - if (choice === "cancel") return false; - if (choice === "save") { - await doSave(); - return dirty ? false : "changed"; // 저장이 막히면(판 다름 등) 나무도 안 고침 - } - dirty = false; // 버림 — 화면을 서버 그대로 다시 읽음 - if (sel) await open(sel); + const got = await askUnsaved(L("M02_TreeUnsaved")); + if (got === false || got === "clean") return got; + if (got === "discarded" && sel) await open(sel); // 버림 — 화면을 서버 그대로 다시 읽음 return "changed"; } diff --git a/resources/tester/test_m02_card_sheet_edit.py b/resources/tester/test_m02_card_sheet_edit.py index 42caf4113..5553e17e8 100644 --- a/resources/tester/test_m02_card_sheet_edit.py +++ b/resources/tester/test_m02_card_sheet_edit.py @@ -66,11 +66,14 @@ def test_알약은_전체_편집기와_같은_부품(): def test_저장_안_한_카드_고침_물음(): struct = _text(M02 / "M02_MasterTemplete_Structure.ts") show = struct.split("const show = ")[1].split("const pickPrice")[0] - assert "cards.dirty()" in show and "showConfirmDialog(TEXT.discardCards)" in show + assert "cards.dirty()" in show and "leaveOrStay(TEXT.discardCards," in show assert "opts.keepColumn?.(from)" in show main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") assert "const busy = (): boolean => dirty || !!panel?.dirty() || toDrawing.waiting();" in main - assert main.count("busy()") >= 3 # 떠나기 · 새로고침 막기 · 나무 고침(settle) + assert main.count("busy()") >= 2 # 떠나기 · 새로고침 막기 + assert ( + 'askUnsaved(L("M02_TreeUnsaved"))' in main + ) # 나무 고침(settle)도 같은 물음 · 카드 저장 실패까지 봄 assert main.index("await panel.saveCards();") < main.index("await panel.reload();") assert "keepColumn: (colId) => sheet?.selectColumn(colId)" in main diff --git a/resources/tester/test_m02_drawing_card.py b/resources/tester/test_m02_drawing_card.py index 7db5d6688..b0a015979 100644 --- a/resources/tester/test_m02_drawing_card.py +++ b/resources/tester/test_m02_drawing_card.py @@ -46,7 +46,7 @@ def test_메뉴는_숨기고_저장은_도면_칸만(): def test_저장_안_한_카드_고침은_물음(): cards = _text("M02_MasterTemplete_ItemCards.ts") # 물음 한 번(31-7) — 어느 카드든(도면 · 엑셀) 고침이 있으면 · 틀의 저장 표시(saver)로 봄 - assert "if (!again && anyDirty() && col?.id !== column && !(await showConfirmDialog(" in cards + assert "(await leaveOrStay(TEXT.discard, { save: saveAll, stillDirty: anyDirty })) === \"stay\"" in cards assert "const anyDirty = (): boolean => cards.some((c) => c.saver.dirty());" in cards assert "dirty: anyDirty," in cards assert "dirty: () => cards.dirty()," in _text("M02_MasterTemplete_Structure.ts") diff --git a/resources/tester/test_m02_leave_dialog.py b/resources/tester/test_m02_leave_dialog.py new file mode 100644 index 000000000..594b1fc7e --- /dev/null +++ b/resources/tester/test_m02_leave_dialog.py @@ -0,0 +1,125 @@ +"""저장 안 한 고침 3단추 물음(PLAN 86-1 · `ui_template_leave_dialog.ts`) — Node 로 TS 를 돌려 판정. + +못박는 것 + - 단추 순서 [취소] [버리고 넘어감] [저장 후 넘어감] · 기본 초점 = 취소 · Esc = 취소. + - 저장 후 넘어감: 저장 성공 + 고침 없음 = saved / 저장 던짐 · 고침 남음 = stay(머묾). + - 버리고 넘어감: discard 갈고리 호출 · 취소: 아무것도 안 부름. + - M02 자리(UI_Main · Structure · ItemCards)가 이 물음을 씀. +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +SCRIPT = r""" +const fs = require("fs"); +const ts = require(process.argv[1] + "/config/node_modules/typescript"); +const src = fs.readFileSync(process.argv[1] + "/ui_template/ui_template_leave_dialog.ts", "utf8"); +const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +let focused = null; +let overlay = null; +let buttons = []; +const node = (tag, o = {}) => ({ + tag, ...o, kids: [], on: {}, + append(...x) { this.kids.push(...x); }, + addEventListener(k, f) { this.on[k] = f; }, + remove() { overlay = null; }, +}); +const stubs = { + "./ui_template_elements": { + el: node, + createButton: (o) => { + const b = node("button", { label: o.label, variant: o.variant }); + b.focus = () => { focused = b.label; }; + b.click = o.onClick; + buttons.push(b); + return b; + }, + }, + "./ui_template_locale": { t: (k) => k }, +}; +globalThis.document = { activeElement: null, body: { append(x) { overlay = x; } } }; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n)); +const { leaveOrStay } = box.exports; + +const run = async (pick, hooks) => { + buttons = []; + focused = null; + const calls = []; + const wrap = { + save: async () => { calls.push("save"); if (hooks.throws) throw new Error("x"); }, + stillDirty: () => !!hooks.left, + discard: () => calls.push("discard"), + }; + const p = leaveOrStay("msg", wrap); + const labels = buttons.map((b) => b.label); + const first = focused; + if (pick === "esc") overlay.on.keydown({ key: "Escape", stopPropagation() {} }); + else buttons[pick].click(); + return { got: await p, calls, labels, first }; +}; + +(async () => { + const out = { + cancel: await run(0, {}), + esc: await run("esc", {}), + discard: await run(1, {}), + save: await run(2, {}), + saveThrows: await run(2, { throws: true }), + saveLeft: await run(2, { left: true }), + }; + process.stdout.write(JSON.stringify(out)); +})(); +""" + + +@pytest.fixture(scope="module") +def got() -> dict: + done = subprocess.run( + ["node", "-e", SCRIPT, str(ROOT)], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout) + + +def test_단추_순서_기본_초점_취소(got): + row = got["cancel"] + assert row["labels"] == ["M02_TreeCancel", "M02_TreeDiscardGo", "M02_TreeSaveGo"] + assert row["first"] == "M02_TreeCancel" + + +def test_취소_Esc는_머묾(got): + assert got["cancel"]["got"] == "stay" and got["cancel"]["calls"] == [] + assert got["esc"]["got"] == "stay" and got["esc"]["calls"] == [] + + +def test_버리고_넘어감(got): + assert got["discard"]["got"] == "discarded" + assert got["discard"]["calls"] == ["discard"] + + +def test_저장_후_넘어감과_실패_머묾(got): + assert got["save"] == {**got["save"], "got": "saved", "calls": ["save"]} + assert got["saveThrows"]["got"] == "stay" # 저장 던짐 + assert got["saveLeft"]["got"] == "stay" # 저장 뒤에도 고침이 남음(카드 저장 실패 등) + + +def test_M02_자리가_3단추_물음을_씀(): + main = (M02 / "M02_MasterTemplete_UI_Main.ts").read_text(encoding="utf-8") + assert "leaveOrStay(message, { save: doSave, stillDirty: busy" in main + assert "askSettle" not in main + assert "!(await showConfirmDialog(L(" not in main + cards = (M02 / "M02_MasterTemplete_ItemCards.ts").read_text(encoding="utf-8") + assert "leaveOrStay(TEXT.discard" in cards + struct = (M02 / "M02_MasterTemplete_Structure.ts").read_text(encoding="utf-8") + assert struct.count("leaveOrStay(TEXT.discardCards") == 2 diff --git a/ui_template/ui_template_leave_dialog.ts b/ui_template/ui_template_leave_dialog.ts new file mode 100644 index 000000000..cf0a72ccb --- /dev/null +++ b/ui_template/ui_template_leave_dialog.ts @@ -0,0 +1,84 @@ +/* ============================================================================= + * ui_template_leave_dialog.ts + * 저장 안 한 고침이 있을 때 넘어가기 전 물음 — 단추 셋: [취소] [버리고 넘어감] [저장 후 넘어감](주 단추). + * 기본 초점 = 취소 · Esc = 취소 · 저장이 실패하거나 고침이 남으면 머묾. + * 몸은 공용 확인 창(`showConfirmDialog`)과 같은 틀(.ui-confirm). + * ========================================================================== */ + +import { createButton, el } from "./ui_template_elements"; +import { t as L } from "./ui_template_locale"; + +export type LeaveChoice = "save" | "discard" | "cancel"; + +/** 3단추 물음 — 닫기 · Esc = "cancel" */ +export function askLeave(message: string): Promise { + return new Promise((resolve) => { + const overlay = el("div", { className: "ui-confirm" }); + const panel = el("div", { className: "ui-confirm__panel" }); + const actions = el("div", { className: "ui-confirm__actions" }); + const back = document.activeElement as HTMLElement | null; // 닫힐 때 초점을 돌려놓을 자리 + const done = (choice: LeaveChoice): void => { + overlay.remove(); + if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true }); + resolve(choice); + }; + const cancel = createButton({ + label: L("M02_TreeCancel"), + variant: "ghost", + onClick: () => done("cancel"), + }); + actions.append( + cancel, + createButton({ + label: L("M02_TreeDiscardGo"), + variant: "danger", + onClick: () => done("discard"), + }), + createButton({ + label: L("M02_TreeSaveGo"), + variant: "filled", + onClick: () => done("save"), + }), + ); + overlay.addEventListener("keydown", (ev) => { + if (ev.key !== "Escape") return; + ev.stopPropagation(); // 뒤 창 · 페이지 Esc 까지 닫지 않음 + done("cancel"); + }); + panel.append(el("p", { className: "ui-confirm__message", text: message }), actions); + overlay.append(panel); + document.body.append(overlay); + cancel.focus(); // 창에 초점 — 뒤 칸에 글이 들어가지 않고 Esc 가 먹음 + }); +} + +export interface LeaveHooks { + /** 저장 한 번 — 실패는 던지거나 `stillDirty` 로 알림(알림 글은 저장 길이 띄움) */ + save: () => Promise; + /** 저장 뒤에도 고침이 남았나 — 남으면 머묾 */ + stillDirty: () => boolean; + /** [버리고 넘어감] 때 */ + discard?: () => void | Promise; +} + +/** + * 물음 → 고름대로 처리. 넘어가도 되면 "saved" | "discarded", 머물면 "stay". + * 저장 실패(던짐) · 저장 뒤 고침 남음 · 취소 = "stay". + */ +export async function leaveOrStay( + message: string, + hooks: LeaveHooks, +): Promise<"saved" | "discarded" | "stay"> { + const choice = await askLeave(message); + if (choice === "cancel") return "stay"; + if (choice === "discard") { + await hooks.discard?.(); + return "discarded"; + } + try { + await hooks.save(); + } catch { + return "stay"; + } + return hooks.stillDirty() ? "stay" : "saved"; +} diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index e7da5b753..39a8a1f48 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -154,8 +154,8 @@ export const ui_locales_m2 = { "저장 안 한 표 고침이 있음 — 나무를 고치기 전에 처리", "Unsaved table edits — settle them before changing the tree", ], - M02_TreeSaveGo: ["저장하고 계속", "Save and continue"], - M02_TreeDiscardGo: ["버리고 계속", "Discard and continue"], + M02_TreeSaveGo: ["저장 후 넘어감", "Save and continue"], + M02_TreeDiscardGo: ["버리고 넘어감", "Discard and continue"], M02_TreeDone: ["나무를 고침", "Tree updated"], M02_PracticeTitle: ["실무문서", "Practice documents"], M02_PracticeRow: ["실무문서 목록", "Practice document list"], From 6f76fe9f318c86df75c60c40cf723aa23a8abc14 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:01:58 +0900 Subject: [PATCH 04/47] =?UTF-8?q?feat(B06):=20=EB=85=B8=EB=A9=B4=EA=B3=B5?= =?UTF-8?q?=20=EC=B9=B4=EB=93=9C=EB=B3=84=20=ED=8F=AC=EC=9E=A5=20=EC=96=B9?= =?UTF-8?q?=EA=B8=B0=20=EC=A7=9D=20section.pavement=20=E2=80=94=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=C2=B7=20=EC=B4=88=EC=95=88=20=C2=B7=20?= =?UTF-8?q?=EA=B1=B0=EC=9A=B8=20=EC=8B=9C=ED=97=98(88-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- B06_Section/B06_Section_Api_Types.ts | 3 + B06_Section/B06_Section_Router.py | 3 + B06_Section/B06_Section_Section_Store.ts | 14 ++ common_util/common_util_structure_pavement.py | 116 ++++++++++++++++ common_util/common_util_structure_pavement.ts | 111 +++++++++++++++ .../test_b06_structure_pavement_mirror.py | 131 ++++++++++++++++++ 6 files changed, 378 insertions(+) create mode 100644 common_util/common_util_structure_pavement.py create mode 100644 common_util/common_util_structure_pavement.ts create mode 100644 resources/tester/test_b06_structure_pavement_mirror.py diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index ae320539e..967019212 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -60,6 +60,7 @@ export type { /** 물넘이포장 제원 — 정의처는 렌더 모듈이다(사본 금지, 타입 전용 import라 순환 없음). */ import type { FordPavementSpec } from "./B06_Section_UI_Cross_Ford_Pavement"; import type { RevetmentSpec } from "./B06_Section_UI_Cross_Revetment"; +import type { PavementEntry } from "@util/common_util_structure_pavement"; export type { FordPavementSpec, RevetmentSpec }; export interface SectionContextResponse { @@ -315,6 +316,8 @@ export interface CrossSection extends SectionStation { ford_pavement?: FordPavementSpec; /** 독립 기슭막이 제원(있을 때만). 구조물 정본 D군 구간 안의 측점 전부에 붙는다. */ revetment?: RevetmentSpec; + /** 노면공(G군) 포장 — 범위 안 측점마다(88). 두께 · 무늬 · 확폭 켬. */ + pavement?: PavementEntry[]; } export interface SectionDetailResponse { diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 5190e78c2..a1963de5a 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -77,6 +77,7 @@ 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 @@ -274,6 +275,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic # 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 — # 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정). attach_wall_structures(root, cross_sections) + attach_pavement_structures(root, cross_sections) # 노면공 형상(88) return {"longitudinal": longitudinal, "cross_sections": cross_sections} @@ -497,6 +499,7 @@ async def regenerate_sections( # 재생성 응답도 상세 조회와 같은 배수관 세트·구조물 벽 정보를 실어야 화면이 어긋나지 않는다. attach_culvert_sets(project_root, result["cross_sections"]) attach_wall_structures(project_root, result["cross_sections"]) + attach_pavement_structures(project_root, result["cross_sections"]) return SectionDetailResponse( longitudinal=result["longitudinal"], cross_sections=result["cross_sections"] ) diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 2eb75a1db..85fd95747 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -34,6 +34,11 @@ import { wallSpecsFrom, type WallStructureInput, } from "@util/common_util_structure_walls"; +import { + attachPavementSpecs, + pavementSpecsFrom, + type PavementStructureInput, +} from "@util/common_util_structure_pavement"; 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"; @@ -65,6 +70,15 @@ async function withDraftWalls( await withDraftPipeKinds(detail, projectId); if (!drafts || !drafts.length) return withStructureAreas(detail); const types = await fetchStructureTypes().catch(() => []); + // 노면공(88) — 초안 범위로 포장 제원을 다시 얹음(짝 `common_util_structure_pavement`) + const paveTypes = new Set(types.filter((type) => type.group === "G").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), + ); + } const names = new Map( types // 소단은 C군 구간형이지만 **벽이 아니다** — 사면을 계단으로 끊는 시설이라 diff --git a/common_util/common_util_structure_pavement.py b/common_util/common_util_structure_pavement.py new file mode 100644 index 000000000..26d12b7d6 --- /dev/null +++ b/common_util/common_util_structure_pavement.py @@ -0,0 +1,116 @@ +"""노면공(G군) 구조물 정본 → 횡단 측점 포장 제원(`section.pavement`). + +범위(start~end · ±0.02 m) 안 측점마다 `[{type_id, spec, thickness_m, start_m, end_m, widening_on}]`. +두께 = `options.height_m` · 무늬 = `options.pave_spec` · 확폭 끈 구간 = `options.widening_off`("s-e,s-e"). +그림 · 정보만 — 토공 면적 · 포장 켜기(`design.paved`)는 건드리지 않음. + +**짝**: `common_util/common_util_structure_pavement.ts` — 브라우저는 저장 전 목록으로 같은 제원을 얹음. +거울 시험 `resources/tester/test_b06_structure_pavement_mirror.py`. +""" + +import logging +import math +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — C군 벽(`common_util_structure_walls`)과 같은 값. +_EDGE_TOLERANCE_M = 0.02 + + +def _num(value: Any) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + return value if math.isfinite(value) else None + + +def widening_off_ranges(text: Any) -> list[tuple[float, float]]: + """「시작-끝,시작-끝」(측점 m) → 구간 목록. 못 읽는 조각은 버림(= 켬).""" + ranges: list[tuple[float, float]] = [] + for part in str(text or "").split(","): + bounds = part.split("-") + if len(bounds) != 2 or not bounds[0].strip() or not bounds[1].strip(): + continue + try: + start, end = float(bounds[0]), float(bounds[1]) + except ValueError: + continue + if math.isfinite(start) and math.isfinite(end): + ranges.append((min(start, end), max(start, end))) + return ranges + + +def pavement_specs_from( + structures: list[dict[str, Any]], type_ids: set[str] +) -> list[dict[str, Any]]: + """G군 구조물 → 구간 제원. 범위가 없으면(시작 · 끝 · 기준 모두 빔) 건너뜀.""" + specs: list[dict[str, Any]] = [] + for item in structures: + if item.get("type_id") not in type_ids: + continue + anchor = _num(item.get("chainage_m")) + start = _num(item.get("start_m")) + end = _num(item.get("end_m")) + start = anchor if start is None else start + end = anchor if end is None else end + if start is None or end is None: + continue + options = item.get("options") or {} + spec = options.get("pave_spec") + specs.append( + { + "type_id": item["type_id"], + "spec": spec if isinstance(spec, str) and spec else None, + "thickness_m": _num(options.get("height_m")), + "start_m": min(start, end), + "end_m": max(start, end), + "widening_off": widening_off_ranges(options.get("widening_off")), + } + ) + return specs + + +def _inside(chainage: float, start: float, end: float) -> bool: + return start - _EDGE_TOLERANCE_M <= chainage <= end + _EDGE_TOLERANCE_M + + +def attach_pavement_specs(sections: list[dict[str, Any]], specs: list[dict[str, Any]]) -> int: + """범위 안 측점에 `pavement` 목록을 얹음. 얹은 측점 수를 돌려줌.""" + attached = 0 + for section in sections: + chainage = _num(section.get("chainage_m")) + if chainage is None: + continue + found = [ + { + "type_id": spec["type_id"], + "spec": spec["spec"], + "thickness_m": spec["thickness_m"], + "start_m": spec["start_m"], + "end_m": spec["end_m"], + "widening_on": not any( + _inside(chainage, start, end) for start, end in spec["widening_off"] + ), + } + for spec in specs + if _inside(chainage, spec["start_m"], spec["end_m"]) + ] + if found: + section["pavement"] = found + attached += 1 + return attached + + +def attach_pavement_structures(project_root: Path, cross_sections: list[dict[str, Any]]) -> int: + """저장된 구조물 정본으로 `section.pavement` 를 얹음. 정본을 못 읽으면 0(비치명).""" + from B05_Profile.B05_Profile_Structures_Repository import load_structures + from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + + try: + type_ids = {key for key, value in structure_type_map().items() if value.group == "G"} + structures = [item.model_dump() for item in load_structures(str(project_root))[1]] + except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 감 + logger.exception("B06 노면공 정본을 읽지 못했습니다 (없는 것으로 본다)") + return 0 + return attach_pavement_specs(cross_sections, pavement_specs_from(structures, type_ids)) diff --git a/common_util/common_util_structure_pavement.ts b/common_util/common_util_structure_pavement.ts new file mode 100644 index 000000000..e4a6a0539 --- /dev/null +++ b/common_util/common_util_structure_pavement.ts @@ -0,0 +1,111 @@ +/* ============================================================================= + * common_util_structure_pavement.ts + * 노면공(G군) 구조물 → 횡단 측점 포장 제원(`section.pavement`). + * 파이썬 `common_util/common_util_structure_pavement.py` 의 **짝** — 거울 시험 + * `resources/tester/test_b06_structure_pavement_mirror.py`. + * + * 서버는 저장분으로 · 브라우저는 저장 전 목록으로 같은 제원을 얹음. + * 두께 = options.height_m · 무늬 = options.pave_spec · 확폭 끈 구간 = options.widening_off. + * ========================================================================== */ + +/** 구조물 정본 한 건(필요한 칸만) — `structures.json` 과 같은 이름. */ +export interface PavementStructureInput { + type_id: string; + chainage_m?: number | null; + start_m?: number | null; + end_m?: number | null; + options?: Record | null; +} + +/** 측점에 얹는 포장 한 건 — `section.pavement[]`. */ +export interface PavementEntry { + type_id: string; + /** 포장사양 — 콘크리트 · 아스콘 · 쇄석(비면 표준 그림). */ + spec: string | null; + /** 두께(m) — 노면선 아래로. 비면 표준 횡단 포장 두께. */ + thickness_m: number | null; + start_m: number; + end_m: number; + /** false = 확폭 끈 구간(규격 폭 끝까지만). */ + widening_on: boolean; +} + +export interface PavementSpec extends Omit { + widening_off: Array<[number, number]>; +} + +/** 파이썬 `_EDGE_TOLERANCE_M` — C군 벽과 같은 값. */ +const EDGE_TOLERANCE_M = 0.02; + +const num = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + +/** 「시작-끝,시작-끝」(측점 m) → 구간 목록. 못 읽는 조각은 버림(= 켬). */ +export function wideningOffRanges(text: unknown): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + for (const part of String(text ?? "").split(",")) { + const bounds = part.split("-"); + if (bounds.length !== 2 || !bounds[0].trim() || !bounds[1].trim()) continue; + const start = Number(bounds[0]); + const end = Number(bounds[1]); + if (Number.isFinite(start) && Number.isFinite(end)) + ranges.push([Math.min(start, end), Math.max(start, end)]); + } + return ranges; +} + +/** G군 구조물 → 구간 제원(파이썬 `pavement_specs_from`). */ +export function pavementSpecsFrom( + structures: readonly PavementStructureInput[], + typeIds: ReadonlySet, +): PavementSpec[] { + const specs: PavementSpec[] = []; + for (const item of structures) { + if (!typeIds.has(item.type_id)) continue; + const anchor = num(item.chainage_m); + const start = num(item.start_m) ?? anchor; + const end = num(item.end_m) ?? anchor; + if (start === null || end === null) continue; + const options = (item.options ?? {}) as Record; + const spec = options.pave_spec; + specs.push({ + type_id: item.type_id, + spec: typeof spec === "string" && spec ? spec : null, + thickness_m: num(options.height_m), + start_m: Math.min(start, end), + end_m: Math.max(start, end), + widening_off: wideningOffRanges(options.widening_off), + }); + } + return specs; +} + +const inside = (chainage: number, start: number, end: number): boolean => + start - EDGE_TOLERANCE_M <= chainage && chainage <= end + EDGE_TOLERANCE_M; + +/** 범위 안 측점에 `pavement` 목록을 얹음(파이썬 `attach_pavement_specs`). 얹은 측점 수. */ +export function attachPavementSpecs( + sections: Array>, + specs: readonly PavementSpec[], +): number { + let attached = 0; + for (const section of sections) { + const chainage = num(section.chainage_m); + if (chainage === null) continue; + const found: PavementEntry[] = specs + .filter((spec) => inside(chainage, spec.start_m, spec.end_m)) + .map((spec) => ({ + type_id: spec.type_id, + spec: spec.spec, + thickness_m: spec.thickness_m, + start_m: spec.start_m, + end_m: spec.end_m, + widening_on: !spec.widening_off.some(([start, end]) => inside(chainage, start, end)), + })); + if (found.length) { + section.pavement = found; + attached += 1; + } + } + return attached; +} diff --git a/resources/tester/test_b06_structure_pavement_mirror.py b/resources/tester/test_b06_structure_pavement_mirror.py new file mode 100644 index 000000000..ad33bded7 --- /dev/null +++ b/resources/tester/test_b06_structure_pavement_mirror.py @@ -0,0 +1,131 @@ +"""노면공 포장 제원(88) — 파이썬·TS 짝 거울 시험. + +`common_util/common_util_structure_pavement.py`(서버 · 저장분) 와 `.ts`(브라우저 · 초안)가 +같은 입력에 같은 `section.pavement` 를 내는지 대조. TS 는 프로젝트 tsc 로 옮겨 실제 코드 그대로 돌림. +""" + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +from common_util.common_util_structure_pavement import ( # noqa: E402 + attach_pavement_specs, + pavement_specs_from, +) + +STRUCTURES = [ + { + "type_id": "pavement_concrete", + "placement": "interval", + "chainage_m": None, + "start_m": 100.0, + "end_m": 160.0, + "options": {"pave_spec": "콘크리트", "height_m": 0.25, "widening_off": "120-140, x-1,,"}, + }, + { + # 뒤집힌 구간 · 두께 빔 · 사양 빔 — 두 벌 다 작은 값을 시작으로 · null 로. + "type_id": "gravel_surfacing", + "placement": "interval", + "start_m": 150.0, + "end_m": 130.0, + "options": {"pave_spec": "", "height_m": "0.3"}, + }, + {"type_id": "pavement_concrete", "start_m": None, "end_m": None, "options": {}}, + {"type_id": "masonry_wet", "start_m": 0.0, "end_m": 500.0, "options": {}}, + { + "type_id": "pavement_concrete", + "chainage_m": 300.0, + "options": {"pave_spec": "아스콘", "height_m": 0.05, "widening_off": "290-310"}, + }, +] +TYPE_IDS = ["pavement_concrete", "gravel_surfacing"] +CHAINAGES = [99.97, 100.0, 119.99, 130.0, 145.0, 160.02, 160.05, 300.0, 400.0] + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { attachPavementSpecs, pavementSpecsFrom } from "./common_util_structure_pavement.js"; +const input = JSON.parse(readFileSync(process.argv[2], "utf8")); +const sections = input.chainages.map((chainage_m) => ({ chainage_m })); +attachPavementSpecs(sections, pavementSpecsFrom(input.structures, new Set(input.type_ids))); +writeFileSync(process.argv[3], JSON.stringify(sections)); +""" + + +def _python_result() -> list[dict]: + sections = [{"chainage_m": value} for value in CHAINAGES] + attach_pavement_specs(sections, pavement_specs_from(STRUCTURES, set(TYPE_IDS))) + return sections + + +def _ts_result(tmp_path: Path) -> list[dict]: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(PROJECT_ROOT / "common_util" / "common_util_structure_pavement.ts"), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + payload = tmp_path / "input.json" + result = tmp_path / "output.json" + payload.write_text( + json.dumps({"structures": STRUCTURES, "type_ids": TYPE_IDS, "chainages": CHAINAGES}), + encoding="utf-8", + ) + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(payload), str(result)], + cwd=str(PROJECT_ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_pavement_specs_mirror(): + with tempfile.TemporaryDirectory() as workdir: + ts = _ts_result(Path(workdir)) + py = _python_result() + assert ts == py + by = {section["chainage_m"]: section.get("pavement") for section in py} + # 범위 밖 · 경계 오차(0.02 m) 안 + assert by[99.97] is None and by[160.05] is None and by[400.0] is None + assert [entry["type_id"] for entry in by[100.0]] == ["pavement_concrete"] + assert by[160.02][0]["thickness_m"] == 0.25 + # 확폭 끈 구간 120~140 (±0.02) — 끔 · 못 읽는 조각은 버림 + assert by[119.99][0]["widening_on"] is False + assert by[100.0][0]["widening_on"] is True + # 겹친 범위 = 둘 다 · 뒤집힌 구간 정렬 · 문자 두께 · 빈 사양 = null + assert [entry["type_id"] for entry in by[145.0]] == ["pavement_concrete", "gravel_surfacing"] + gravel = by[145.0][1] + assert (gravel["start_m"], gravel["end_m"]) == (130.0, 150.0) + assert gravel["thickness_m"] is None and gravel["spec"] is None + # 기준 측점만 있는 옛 항목 = 그 측점 한 점 + assert by[300.0] == [ + { + "type_id": "pavement_concrete", + "spec": "아스콘", + "thickness_m": 0.05, + "start_m": 300.0, + "end_m": 300.0, + "widening_on": False, + } + ] From 7d761e39af673ef75215abadff9281bd71c1f00d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:02:28 +0900 Subject: [PATCH 05/47] =?UTF-8?q?fix(B06):=20=EC=84=B8=EC=9B=94=EA=B5=90?= =?UTF-8?q?=20=EB=B3=B8=EC=B2=B4=20=ED=8C=90=EC=A0=95=EB=A9=B4=20=3D=20?= =?UTF-8?q?=EB=B0=94=EB=8B=A5=ED=8C=90=20=EB=B0=91=20~=20=EC=B8=A1?= =?UTF-8?q?=EB=B2=BD=20=EC=83=81=EB=8B=A8=20=EC=82=AC=EA=B0=81=ED=98=95=20?= =?UTF-8?q?=ED=95=98=EB=82=98(89-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- B06_Section/B06_Section_UI_Cross_Ford.ts | 33 +++++++++++++++++------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index f21cc38ca..c3c4217a3 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -145,15 +145,30 @@ export function appendFordOverlay( // 관·채움이 벽 위에 겹쳐 클릭을 가로챈다 — 투명 겹면을 맨 위에 얹어 벽을 고른다 // (배수관 기슭막이 `culvert-revet-hit`와 같은 수법). if (onSelectWall) { - // 본체(바닥판 · 관) 판정면 — 벽 판정면보다 먼저 깔아 겹친 자리는 벽이 먹는다(66-1). - for (const points of [layout.slabBridge, layout.pipe.outer]) { - const hit = polygon(points, "b06-chart__culvert-revet-hit", "세월교 본체 선택"); - hit.addEventListener("click", (event) => { - event.stopPropagation(); - onSelectWall("body"); - }); - layer.append(hit); - } + // 본체 판정면 = 바닥판 밑 ~ 측벽 상단 최고 사각형 하나(관 위 성토 · 벽-관 사이 포함 · 89-2) — + // 측벽 판정면 · 흙막이(뒤에 그리는 배관 오버레이)가 위에 얹혀 겹친 자리는 그쪽이 먹는다(66-1). + const offsets = [ + ...layout.slabBridge, + ...layout.sides.flatMap((side) => side.parts.flatMap((part) => part.points)), + ].map((point) => point.offset); + const top = Math.max(...layout.sides.map((side) => side.top.elevation)); + const bottom = Math.min(...layout.slabBridge.map((point) => point.elevation)); + const [from, to] = [Math.min(...offsets), Math.max(...offsets)]; + const bodyHit = polygon( + [ + { offset: from, elevation: top }, + { offset: to, elevation: top }, + { offset: to, elevation: bottom }, + { offset: from, elevation: bottom }, + ], + "b06-chart__culvert-revet-hit", + "세월교 본체 선택", + ); + bodyHit.addEventListener("click", (event) => { + event.stopPropagation(); + onSelectWall("body"); + }); + layer.append(bodyHit); // 측벽 판정면 = ㄴ(벽 + 바닥판)을 감싸는 가상 사각형(79-6) — 얇은 벽만 잡던 것을 넓힘. for (const side of layout.sides) { const points = side.parts.flatMap((part) => part.points); From 5b9c804ff5de576234f503f4f7e1eb394e394175 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:02:59 +0900 Subject: [PATCH 06/47] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EB=8F=84=EB=A9=B4=20=EC=97=AC=EB=9F=AC=20=EC=9E=A5=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EA=BC=B4=20=E2=80=94=20=EB=8D=94=ED=95=9C?= =?UTF-8?q?=EC=9E=A5=20=C2=B7=20=EC=97=B0=EA=B2=B0=ED=91=9C=20=EC=9E=A5=20?= =?UTF-8?q?=C2=B7=20=EB=AA=A8=EB=93=A0=20=EC=9E=A5=20=ED=91=9C=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=C2=B7=20=EC=9E=A5=20=EB=8F=84=EB=B2=88=20=EB=8F=84?= =?UTF-8?q?=EA=B0=81(82-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 더한장 = [{장id, 이름, 도면:{format, entities, layers}}] 뼈대 검사 · drawing_sheets - 면적 [저장] 때 안 열린 장 표도 서버가 갱신 - /m02/structure-frame?number= 장 도번을 도각 {{도면번호}} 에 - StructureDoc 더한장 · 연결표 장 타입 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- M02_MasterTemplete/M02_Area_Table.py | 6 +- .../M02_MasterTemplete_Api_Fetch.ts | 11 ++ .../M02_MasterTemplete_Router_Drawing.py | 16 +- M02_MasterTemplete/M02_Template_Layers.py | 38 +++++ resources/tester/test_m02_drawing_sheets.py | 160 ++++++++++++++++++ 5 files changed, 223 insertions(+), 8 deletions(-) create mode 100644 resources/tester/test_m02_drawing_sheets.py diff --git a/M02_MasterTemplete/M02_Area_Table.py b/M02_MasterTemplete/M02_Area_Table.py index 3dcea0a1c..9e2ab79ff 100644 --- a/M02_MasterTemplete/M02_Area_Table.py +++ b/M02_MasterTemplete/M02_Area_Table.py @@ -5,6 +5,7 @@ `shapeData.style.연결` 이 `연결` 과 같은 표를 면적 [저장] 때 칸 글로 다시 적음(자리 · 크기 그대로 · 칸 수가 바뀌면 열 너비 · 행 높이도 새로) · `값` = 마지막 칸 글(`{시트id!A1: 글}` · 다름 알림용). 도면에 그 표가 없으면 기록은 그대로 둠. 층 = `store.at(층 폴더)` · 권한은 부르는 쪽. +여러 장(82-3) — 연결표 줄 `장` = 표가 놓인 장id(1장 = 빈 값) · 맞춤은 모든 장(안 열린 장도 서버가). """ from __future__ import annotations @@ -16,6 +17,7 @@ from typing import Any from common_util import common_util_spreadsheet as sheet_node from common_util.common_util_json import atomic_write_json from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_Template_Layers import drawing_sheets logger = logging.getLogger(__name__) LINKS = "연결표" @@ -40,7 +42,7 @@ def _style_link(entity: Any) -> Any: def refresh_drawing(column: str) -> list[str]: - """지금 층 그 열 도면의 연결표마다 칸 글 다시 · 마지막 값 갱신 → 칸을 간 표의 `연결` 목록.""" + """지금 층 그 열 도면(모든 장)의 연결표마다 칸 글 다시 · 마지막 값 갱신 → 칸을 간 표의 `연결` 목록.""" try: drawing, area = store._path("structure", column), store._path("area", column) except store.StoreError: @@ -61,7 +63,7 @@ def refresh_drawing(column: str) -> list[str]: except sheet_node.RecalcError as e: logger.warning("도면 표 맞춤 건너뜀 %s — %s", column, e) return [] - entities = (doc.get("도면") or {}).get("entities") or [] + entities = [e for _, sheet in drawing_sheets(doc) for e in sheet.get("entities") or []] done: list[str] = [] for link, got in zip(links, results): if not got.get("ok"): diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index a76619d33..b52eb7eac 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -150,9 +150,13 @@ export interface StructureDoc { 열: string; 도번: string; 도면: { entities: unknown[] } & Record; + /** 여러 장(82-2) — `도면` = 1장 · 더한 장 차례대로 · 장 도번 = 도번 + `-n`(둘째 = -2) */ + 더한장?: StructureSheet[]; /** 면적산출 → 도면 표 연결 기록(41 · `AreaToDrawing` · `M02_Area_Table`) — 표 style.연결 = 연결 · 값 = 마지막 칸 글 */ 연결표?: { 연결: string; + /** 표가 놓인 장id(82-3) — 1장 = 빈 값(옛 기록은 칸 없음 = 1장) */ + 장?: string; 시트: string; 범위: string; 글자크기: number; @@ -160,6 +164,13 @@ export interface StructureDoc { }[]; } +/** 구조물 도면 더한 장 하나(82-2) */ +export interface StructureSheet { + 장id: string; + 이름: string; + 도면: { entities: unknown[] } & Record; +} + /** 시스템 층 구조물 도면 목록 */ export const listStructures = async (): Promise => (await listTemplates("system", null)).filter((t) => t.종류 === "structure"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Drawing.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Drawing.py index 871f27967..959d054f1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Drawing.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Drawing.py @@ -104,15 +104,19 @@ def _on_frame_layer(entity: dict) -> dict: } -def structure_frame() -> dict: - """구조물 도면에 얹는 도각 — B07 시스템 양식 1:1 · 외곽 좌하단 (0,0) · 보여주기 층.""" +def structure_frame(number: str | None = None) -> dict: + """구조물 도면에 얹는 도각 — B07 시스템 양식 1:1 · 외곽 좌하단 (0,0) · 보여주기 층 · + `number` = 장 도번(82-2 · 도각 `{{도면번호}}`).""" + fields = {"도면번호": number} if number else None placed = frame_entities("m02-structure", usable_bbox(), fit=False) box = entities_bbox(placed) if not box: return {"entities": [], "layer": None} ox, oy = box[0], box[1] x0, y0, x1, y1 = usable_bbox() - entities = frame_entities("m02-structure", (x0 - ox, y0 - oy, x1 - ox, y1 - oy), fit=False) + entities = frame_entities( + "m02-structure", (x0 - ox, y0 - oy, x1 - ox, y1 - oy), fit=False, fields=fields + ) return { "entities": [_on_frame_layer(entity) for entity in entities], "layer": { @@ -125,9 +129,9 @@ def structure_frame() -> dict: @router.get("/structure-frame") -async def get_structure_frame() -> JSONResponse: - """구조물 도면 상세 · 카드 미리보기가 얹는 도각(56장).""" - return JSONResponse({"status": "success", **structure_frame()}) +async def get_structure_frame(number: str | None = None) -> JSONResponse: + """구조물 도면 상세 · 카드 미리보기가 얹는 도각(56장) · `number` = 장 도번(82-2).""" + return JSONResponse({"status": "success", **structure_frame(number)}) @router.get("/drawing-fields") diff --git a/M02_MasterTemplete/M02_Template_Layers.py b/M02_MasterTemplete/M02_Template_Layers.py index 3b90b61d6..b1ab41fbf 100644 --- a/M02_MasterTemplete/M02_Template_Layers.py +++ b/M02_MasterTemplete/M02_Template_Layers.py @@ -190,6 +190,44 @@ def check_skeleton(kind: str, document: Any) -> None: node = node.get(part) if isinstance(node, dict) else None if not isinstance(node, dict) or not isinstance(node.get(last), list): raise ValueError(f"양식 문서에 `{key}` 목록이 없습니다 — 빈 문서는 저장하지 않습니다.") + if kind == "structure": + _check_more_sheets(document.get(MORE_SHEETS)) + + +#: 구조물 도면 여러 장(82-2) — `도면` = 1장 · `더한장` = [{장id, 이름, 도면:{format, entities, layers}}] +MORE_SHEETS = "더한장" + + +def _check_more_sheets(more: Any) -> None: + """`더한장` 은 없거나 목록 — 장마다 장id(겹침 없음) · 이름 · `도면.entities` 목록.""" + if more is None: + return + if not isinstance(more, list): + raise ValueError("`더한장` 은 목록이어야 합니다.") + seen: set[str] = set() + for sheet in more: + sheet_id = sheet.get("장id") if isinstance(sheet, dict) else None + if not isinstance(sheet_id, str) or not sheet_id or sheet_id in seen: + raise ValueError("`더한장` 장마다 겹치지 않는 `장id` 가 있어야 합니다.") + seen.add(sheet_id) + drawing = sheet.get("도면") + if not isinstance(sheet.get("이름"), str) or not isinstance(drawing, dict): + raise ValueError(f"`더한장` 장 「{sheet_id}」 에 `이름` · `도면` 이 없습니다.") + if not isinstance(drawing.get("entities"), list): + raise ValueError(f"`더한장` 장 「{sheet_id}」 에 `도면.entities` 목록이 없습니다.") + + +def drawing_sheets(document: Any) -> list[tuple[str, dict[str, Any]]]: + """구조물 도면 문서의 장 `[(장id, 도면)]` — 1장 장id = "" · 모양이 틀린 장은 건너뜀.""" + if not isinstance(document, dict): + return [] + first = document.get("도면") + out = [("", first)] if isinstance(first, dict) else [] + for sheet in document.get(MORE_SHEETS) or []: + drawing = sheet.get("도면") if isinstance(sheet, dict) else None + if isinstance(drawing, dict): + out.append((str(sheet.get("장id") or ""), drawing)) + return out def write_template( diff --git a/resources/tester/test_m02_drawing_sheets.py b/resources/tester/test_m02_drawing_sheets.py new file mode 100644 index 000000000..0bae1f668 --- /dev/null +++ b/resources/tester/test_m02_drawing_sheets.py @@ -0,0 +1,160 @@ +"""M02 구조물 도면 여러 장(PLAN 82-2 · 82-3) — 저장 꼴 `더한장` · 뼈대 검사 · 모든 장 표 맞춤 · +복사 · 스냅샷 · 개인 저장이 더한장을 같이 나름 · 장 도번 도각 `{{도면번호}}`. + +시스템 층 = 견본 표(임시 폴더 · `test_m02_area_table.world`) · storage 는 임시. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from M02_MasterTemplete import M02_Item_Personal as personal +from M02_MasterTemplete import M02_Item_Source as source +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import structure_frame +from resources.tester.test_m02_area_table import world # noqa: F401 — 같은 견본 층 + +_ = world + + +def _table(link: str) -> dict[str, Any]: + return { + "type": "Table", + "shapeData": { + "origin": {"x": 0, "y": 0}, + "columnWidths": [9, 9], + "rowHeights": [3, 3], + "cells": [[{"text": "낡음"}, {"text": "낡음"}], [None, {"text": "낡음"}]], + "style": {"fontSize": 2.5, "연결": link}, + }, + } + + +def _sheet(sheet_id: str, name: str, entities: list) -> dict[str, Any]: + return { + "장id": sheet_id, + "이름": name, + "도면": {"format": 6, "entities": entities, "layers": [{"id": "기본", "name": "기본"}]}, + } + + +def _write_sheets(folder: Path) -> dict[str, Any]: + """rv 도면 = 1장(T1 표) + 더한장 둘(s2 = T2 표 · s3 = 빈 장) · 연결표 줄에 `장`.""" + with store.at(folder): + got = store.read("structure", "rv") + doc = got["문서"] + doc["도면"]["entities"] = [_table("T1")] + doc[layers.MORE_SHEETS] = [_sheet("s2", "단면", [_table("T2")]), _sheet("s3", "상세", [])] + doc["연결표"] = [ + {"연결": "T1", "장": "", "시트": "s1", "범위": "A1:B2", "글자크기": 2.5, "값": {}}, + {"연결": "T2", "장": "s2", "시트": "s1", "범위": "A1:B2", "글자크기": 2.5, "값": {}}, + ] + store.write("structure", "rv", got["판"], doc) + return store.read("structure", "rv")["문서"] + + +@pytest.mark.parametrize( + "more", + [ + "장", + [{"이름": "가", "도면": {"entities": []}}], + [_sheet("s2", "가", []), _sheet("s2", "나", [])], + [{"장id": "s2", "이름": "가", "도면": {}}], + [{"장id": "s2", "도면": {"entities": []}}], + ], +) +def test_더한장_모양이_틀리면_거절(more): + doc = {"도면": {"entities": []}, layers.MORE_SHEETS: more} + with pytest.raises(ValueError): + layers.check_skeleton("structure", doc) + + +def test_더한장_없거나_맞으면_통과_장_목록(): + layers.check_skeleton("structure", {"도면": {"entities": []}}) # 옛 문서 그대로 + doc = {"도면": {"entities": [1]}, layers.MORE_SHEETS: [_sheet("s2", "가", [2])]} + layers.check_skeleton("structure", doc) + assert layers.drawing_sheets(doc) == [("", doc["도면"]), ("s2", doc["더한장"][0]["도면"])] + assert layers.drawing_sheets({"도면": {"entities": []}, "더한장": ["틀림"]}) == [ + ("", {"entities": []}) + ] + + +def test_저장_다시_읽기_둘째_장_그대로(world): # noqa: F811 + doc = _write_sheets(world["system"]) + assert [s["장id"] for s in doc["더한장"]] == ["s2", "s3"] + assert doc["더한장"][0]["도면"]["entities"][0]["shapeData"]["style"]["연결"] == "T2" + with store.at(world["system"]), pytest.raises(store.StoreError): + got = store.read("structure", "rv") + store.write("structure", "rv", got["판"], {**got["문서"], "더한장": [{"장id": ""}]}) + + +def test_면적_저장_뒤_안_열린_둘째_장_표도_갱신(world): # noqa: F811 + system = world["system"] + _write_sheets(system) + with store.at(system): + got = store.read("area", "rv") + got["문서"]["시트"][0]["칸"]["B1"] = {"값": 10, "서식": 1} + store.write("area", "rv", got["판"], got["문서"]) + doc = store.read("structure", "rv")["문서"] + first = doc["도면"]["entities"][0]["shapeData"]["cells"] + second = doc["더한장"][0]["도면"]["entities"][0]["shapeData"]["cells"] + assert first[0][1]["text"] == "10.00" and second[1][1]["text"] == "20.00" + assert [row["장"] for row in doc["연결표"]] == ["", "s2"] # 장 칸 그대로 + assert doc["연결표"][1]["값"]["s1!B2"] == "20.00" + + +def test_다른_열로_복사_더한장_같이(world): # noqa: F811 + _write_sheets(world["system"]) + with store.at(world["system"]): + if not store._path("structure", "c1").is_file(): + store.create_structure("c1") + store.copy_from("structure", "c1", "rv") + doc = store.read("structure", "c1")["문서"] + rv = store.read("structure", "rv")["문서"] + assert [s["장id"] for s in doc["더한장"]] == ["s2", "s3"] + assert doc["도번"] != rv["도번"] # 번호는 받는 열 그대로 + + +def _rv_key(system: Path) -> str: + with store.at(system): + return next(r["키"] for r in store.read_combos()["줄"] if r.get("열") == "rv") + + +def test_스냅샷_원본이_더한장_같이_나름(world): # noqa: F811 + _write_sheets(world["system"]) + files = source.build_source({"layer": "system", "key": _rv_key(world["system"])}) + assert len(files["도면.json"]["더한장"]) == 2 + + +def test_개인에_저장_더한장_같이(world, tmp_path): # noqa: F811 + _write_sheets(world["system"]) + files = source.build_source({"layer": "system", "key": _rv_key(world["system"])}) + copy = tmp_path / "copy" + copy.mkdir() + for name in ("집계.json", "도면.json"): + (copy / name).write_text(json.dumps(files[name], ensure_ascii=False), encoding="utf-8") + folder = layers.personal_dir(7, 99) + made = personal.create_item(folder, ["가"], "나", "m", copy=copy) + with store.at(folder): + doc = store.read("structure", made["열"])["문서"] + assert [s["이름"] for s in doc["더한장"]] == ["단면", "상세"] + + +def test_장_도번이_도각_도면번호에(): + texts = [ + (e.get("shapeData") or {}).get("label") + for e in structure_frame("SD-000010-2")["entities"] + if e.get("type") == "Text" + ] + assert "SD-000010-2" in texts + blank = [ + (e.get("shapeData") or {}).get("label") + for e in structure_frame()["entities"] + if e.get("type") == "Text" + ] + assert "SD-000010-2" not in blank and not any("{{" in str(t) for t in blank) From 8c04d520f4832134ae5ef3a8cc77c1a1b6a77e4a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:03:05 +0900 Subject: [PATCH 07/47] =?UTF-8?q?feat(B06):=20=EC=84=B8=EC=9B=94=EA=B5=90?= =?UTF-8?q?=20=EB=B0=94=EB=8B=A5=20=EC=86=90=20=EC=A1=B0=EC=A0=95=20?= =?UTF-8?q?=EC=B9=B8=20=E2=80=94=20=EB=82=A0=EA=B0=9C=EB=B2=BD=20=EC=B9=B8?= =?UTF-8?q?=EC=97=90=20=EC=B8=A1=EB=B2=BD=20=EB=86=92=EC=9D=B4=20=EC=A4=84?= =?UTF-8?q?=20=C2=B7=20=EC=86=90=20=EA=B0=92=EC=9D=80=20=EC=9B=90=EC=A7=80?= =?UTF-8?q?=EB=B0=98=20=EB=B0=94=EB=80=8C=EC=96=B4=EB=8F=84=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C(91-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- B06_Section/B06_Section_UI_Adjust_Dock.ts | 7 +- .../B06_Section_UI_Cross_Ford_Panel.ts | 4 +- resources/tester/helper_91_ground_free.cjs | 100 ++++++++++++++++++ resources/tester/test_65_ford_defaults.py | 8 +- resources/tester/test_91_ground_free.py | 46 ++++++++ 5 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 resources/tester/helper_91_ground_free.cjs create mode 100644 resources/tester/test_91_ground_free.py diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index d1979183c..80bf80461 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -64,11 +64,12 @@ const PIPE_INLET_ROWS = [ ".b06-structure-panel__struct--basin-size", ].join(", "); const PIPE_OUTLET_ROWS = ".b06-structure-panel__struct--basin-size"; +const WALL_HEIGHT_ROW = ".b06-structure-panel__struct--wall-height"; -/** 받을 칸에 맞는 줄 — 배관 유입 · 유출 칸이 아니면 옛 줄 그대로 · 세월교 · 박스 유입 · 유출 칸은 안 받음 - * (높이 · 측벽 좌우 · 상하 · 바닥판 읽기는 칸에 안 둠 · 강조만 · 65-1). */ +/** 받을 칸에 맞는 줄 — 배관 유입 · 유출 칸이 아니면 옛 줄 그대로 · 세월교 · 박스 유입 · 유출 칸은 + * 세월교 측벽 높이(바닥 손 조정 · 91-2)만 받음(좌우 · 상하 · 바닥판 읽기는 칸에 안 둠 · 65-1). */ function rowsFor(slot: HTMLElement): string | null { - if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null; + if (slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return WALL_HEIGHT_ROW; if (slot === sideSlots?.outlet) return PIPE_OUTLET_ROWS; return slot === sideSlots?.inlet ? PIPE_INLET_ROWS : SIDE_ROWS; } diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts index 09e1b9a52..22842406e 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts @@ -152,7 +152,9 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): `높이 ${deps.heightFor(current).toFixed(2)}m` + (adjust.heightM === null ? " (자동)" : ""); moveLine.textContent = `좌우 ${adjust.lateralM.toFixed(1)}m · 상하 ${adjust.slopeM.toFixed(1)}m`; slabLine.textContent = `바닥판 ${deps.slabLengthM().toFixed(2)}m`; - heightValue.textContent = `${deps.heightFor(current).toFixed(1)}m`; + // 자동 = 바닥판이 계류 하상을 따라감 · 손 값 = 원지반이 바뀌어도 그대로(91-2) + heightValue.textContent = + `${deps.heightFor(current).toFixed(1)}m` + (adjust.heightM === null ? " 자동" : ""); } return { diff --git a/resources/tester/helper_91_ground_free.cjs b/resources/tester/helper_91_ground_free.cjs new file mode 100644 index 000000000..fe598aba9 --- /dev/null +++ b/resources/tester/helper_91_ground_free.cjs @@ -0,0 +1,100 @@ +/* 91 원지반 · 암 제약 삭제 · 유출측 복합경사 헬퍼 — 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. + * 입력 = 73-4 픽스처(세월교 173.09 · 박스 352.14 · 관 180) · 결과 = 표준출력 JSON 한 벌. 짝 = `test_91_ground_free.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +// 화면 전용 모듈(토스트 · 글) — 기하 계산엔 안 쓰임 +const STUBS = { + "@ui/ui_template_toast": { showToast: () => undefined }, +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) { + const data = JSON.parse(fs.readFileSync(full, "utf8")); + cache.set(full, data); + return data; + } + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" }; + for (const [prefix, dir] of Object.entries(alias)) + if (request.startsWith(prefix)) + return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} +const resolve = (base) => + [base, `${base}.ts`, path.join(base, "index.ts")].find( + (p) => fs.existsSync(p) && fs.statSync(p).isFile(), + ) ?? `${base}.ts`; + +const B06 = (f) => path.join(ROOT, "B06_Section", f); +const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts.ts")); + +const fixture = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"), +); +const sections = fixture.cross_sections; +const at = (c) => sections.find((s) => Math.abs(s.chainage_m - c) < 0.6); +const clone = (v) => JSON.parse(JSON.stringify(v)); +const r3 = (v) => Math.round(v * 1000) / 1000; +const out = {}; +/** 원지반 사본 — [from, to] 안 샘플을 dz 만큼 옮김. */ +const shiftGround = (s, dz, from = -Infinity, to = Infinity) => { + for (const p of s.samples) + if (p.offset_m >= from && p.offset_m <= to && p.elevation_m != null) p.elevation_m += dz; +}; +const layoutsOf = (s, base) => + computeStoredLayouts( + s, + sections.map((x) => (x === base ? s : x)), + ); + +// ① 91-2 세월교 바닥 — 자동은 하상을 따라감 · 손 값(측벽 높이)은 원지반을 바꿔도 그대로 · 흙막이 기본 높이도 +const FORD = at(173.087); +function fordFloor(edit) { + const s = clone(FORD); + for (const role of ["inlet", "outlet"]) s.culvert[role].revet_height_m = null; + edit(s); + const { ford, culvert } = layoutsOf(s, FORD); + return ford.sides.map((side) => { + const wall = culvert.walls.find((w) => w.role === side.role); + return { + role: side.role, + slabTop: r3(side.slabTopElevation), + wallBottom: wall ? r3(Math.min(...wall.points.map((p) => p.elevation))) : null, + }; + }); +} +const hand = (s) => { + s.design.ford_adjust = { + inlet: { heightM: 2.0, lateralM: 0, slopeM: 0 }, + outlet: { heightM: 2.4, lateralM: 0, slopeM: 0 }, + }; +}; +out.fordAuto = fordFloor(() => undefined); +out.fordAutoBedDown = fordFloor((s) => shiftGround(s, -2, -2, 2)); +out.fordHand = fordFloor(hand); +out.fordHandBedDown = fordFloor((s) => { + hand(s); + shiftGround(s, -2, -2, 2); +}); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_65_ford_defaults.py b/resources/tester/test_65_ford_defaults.py index 052826723..9bb6ad637 100644 --- a/resources/tester/test_65_ford_defaults.py +++ b/resources/tester/test_65_ford_defaults.py @@ -147,8 +147,12 @@ def test_집계표_세월교_흙막이_상자_건(): ] -def test_B06_측벽_읽기_줄은_세월교_박스_칸에_안_옮김(): +def test_B06_세월교_박스_칸은_측벽_높이_줄만_받음(): dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8") - assert "slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return null" in dock + assert ( + "slot === sideSlots?.wingInlet || slot === sideSlots?.wingOutlet) return WALL_HEIGHT_ROW" + in dock + ) + assert 'WALL_HEIGHT_ROW = ".b06-structure-panel__struct--wall-height"' in dock lists = (ROOT / "B05_Profile/B05_Profile_UI_Structures_List.ts").read_text(encoding="utf-8") assert "return isNone(c, state[c.id][0])" in lists diff --git a/resources/tester/test_91_ground_free.py b/resources/tester/test_91_ground_free.py new file mode 100644 index 000000000..c33e96f8e --- /dev/null +++ b/resources/tester/test_91_ground_free.py @@ -0,0 +1,46 @@ +"""91 원지반 · 암 제약 삭제 · 유출측 복합경사. + +화면 TS 를 그 자리에서 돌린 값(`helper_91_ground_free.cjs`) · 픽스처 = 73-4 세월교 173.09 · 박스 352.14 · 관 180. +""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +HELPER = Path(__file__).parent / "helper_91_ground_free.cjs" +NODE_MODULES = ROOT / "config" / "node_modules" + +pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치") + + +@pytest.fixture(scope="module") +def fit() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_세월교_바닥_자동은_하상을_따라감(fit: dict) -> None: + for before, after in zip(fit["fordAuto"], fit["fordAutoBedDown"]): + assert after["slabTop"] < before["slabTop"] - 1.0 + + +def test_세월교_바닥_손_값은_원지반을_바꿔도_그대로(fit: dict) -> None: + assert fit["fordHand"] == fit["fordHandBedDown"] + # 손 값 = 측벽 높이 · 흙막이 바닥 = 바닥판 밑(두께 0.3) + for side in fit["fordHand"]: + assert side["wallBottom"] == pytest.approx(side["slabTop"] - 0.3, abs=1e-3) + + +def test_세월교_박스_칸은_측벽_높이_줄을_받음() -> None: + dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8") + assert "sideSlots?.wingOutlet) return WALL_HEIGHT_ROW" in dock From ebb8eb13020e066a05d307a6b630727745556e2a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:03:31 +0900 Subject: [PATCH 08/47] =?UTF-8?q?feat(B06):=20=EB=85=B8=EB=A9=B4=EA=B3=B5?= =?UTF-8?q?=20=ED=9A=A1=EB=8B=A8=20=ED=98=95=EC=83=81=20=E2=80=94=20?= =?UTF-8?q?=EB=91=90=EA=BB=98=20=C2=B7=20=ED=8F=AC=EC=9E=A5=EC=82=AC?= =?UTF-8?q?=EC=96=91=20=EB=AC=B4=EB=8A=AC=20=EC=85=8B=20=C2=B7=20=ED=99=95?= =?UTF-8?q?=ED=8F=AD=20=EB=81=94=20=EA=B7=9C=EA=B2=A9=20=ED=8F=AD=20=C2=B7?= =?UTF-8?q?=20B07=20=EB=94=B0=EB=9D=BC=EC=98=B4(88-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../B06_Section_UI_Cross_Design_Surface.ts | 85 +++++++++++++++---- B06_Section/B06_Section_UI_Cross_View.ts | 3 +- .../B06_Section_UI_Style_Cross_Areas.css | 16 ++++ .../B07_DesignDetail_Cross_Overlay.ts | 2 +- 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Design_Surface.ts b/B06_Section/B06_Section_UI_Cross_Design_Surface.ts index b35b73912..dca4ed947 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Surface.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Surface.ts @@ -1,3 +1,4 @@ +import type { PavementEntry } from "@util/common_util_structure_pavement"; import type { CrossDesign } from "./B06_Section_Api_Fetch"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -29,26 +30,80 @@ export function appendRockBoundaryOverlay( } } +type Edge = { offset_m: number; elevation_m: number }; + +/** 포장사양 → 무늬(88) — 콘크리트 빗금 · 아스콘 점 · 쇄석 돌. 그 밖 = 채움만. */ +const PAVE_MARKS: Record = { + 콘크리트: { size: 6, marks: '' }, + 아스콘: { + size: 5, + marks: '', + }, + 쇄석: { + size: 9, + marks: + '' + + '', + }, +}; +/** 무늬 id 는 문서 안에서 유일 — 카드 · 다시 그리기마다 새 번호(물넘이 빗금과 같은 방식). */ +let paveSeq = 0; + +/** 확폭 끈 구간 — 규격 폭 끝(좌 = left − 확폭좌 · 우 = right + 확폭우 · 표고는 두 끝 사이 직선). */ +function standardEdges(edges: { left: Edge; right: Edge }, design: CrossDesign) { + const { left, right } = edges; + const span = right.offset_m - left.offset_m || 1; + const at = (offset: number): Edge => ({ + offset_m: offset, + elevation_m: + left.elevation_m + ((right.elevation_m - left.elevation_m) * (offset - left.offset_m)) / span, + }); + return { + left: at(left.offset_m - (design.widening_left_m ?? 0)), + right: at(right.offset_m + (design.widening_right_m ?? 0)), + }; +} + +/** + * 포장층 — 노면선(= 포장 윗면) 아래로 두께만큼. 노면공 범위(`section.pavement`)면 그 두께 · 무늬 · + * 확폭 켬/끔(여러 건은 아래로 차례로 쌓음) · 없으면 카드 포장 켬일 때 표준 두께 채움만. + */ export function appendPavementOverlay( svg: SVGElement, design: CrossDesign, x: (offset: number) => number, y: (elevation: number) => number, + pavement?: readonly PavementEntry[] | null, ): void { const edges = design.carriageway_edges ?? design.road_edges; - if (!design.paved || !edges) return; - const thickness = design.pavement_thickness_m ?? 0.2; - const { left, right } = edges; - const polygon = document.createElementNS(SVG_NS, "polygon"); - polygon.setAttribute( - "points", - [ - `${x(left.offset_m)},${y(left.elevation_m)}`, - `${x(right.offset_m)},${y(right.elevation_m)}`, - `${x(right.offset_m)},${y(right.elevation_m - thickness)}`, - `${x(left.offset_m)},${y(left.elevation_m - thickness)}`, - ].join(" "), - ); - polygon.setAttribute("class", "b06-chart__pavement"); - svg.append(polygon); + if (!edges) return; + const layers = pavement?.length ? pavement : design.paved ? [null] : []; + let depth = 0; + for (const entry of layers) { + const thickness = entry?.thickness_m ?? design.pavement_thickness_m ?? 0.2; + const { left, right } = entry && !entry.widening_on ? standardEdges(edges, design) : edges; + const points = [ + `${x(left.offset_m)},${y(left.elevation_m - depth)}`, + `${x(right.offset_m)},${y(right.elevation_m - depth)}`, + `${x(right.offset_m)},${y(right.elevation_m - depth - thickness)}`, + `${x(left.offset_m)},${y(left.elevation_m - depth - thickness)}`, + ].join(" "); + depth += thickness; + const polygon = document.createElementNS(SVG_NS, "polygon"); + polygon.setAttribute("points", points); + polygon.setAttribute("class", "b06-chart__pavement"); + svg.append(polygon); + const spec = entry?.spec; + const mark = spec ? PAVE_MARKS[spec] : undefined; + if (!spec || !mark) continue; + const id = `b06-pave-hatch-${(paveSeq += 1)}`; + const defs = document.createElementNS(SVG_NS, "defs"); + defs.innerHTML = `${mark.marks}`; + const hatch = document.createElementNS(SVG_NS, "polygon"); + hatch.setAttribute("points", points); + hatch.setAttribute("class", "b06-chart__pavement-hatch"); + hatch.setAttribute("fill", `url(#${id})`); + hatch.dataset.paveSpec = spec; + svg.append(defs, hatch); + } } diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index df688b16b..55bc589db 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -429,7 +429,8 @@ export function createCrossSectionCard( x, toDisplayY, ); - if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY); + if (!fordPaved) + appendPavementOverlay(plotLayer, section.design, x, toDisplayY, section.pavement); // 세월교 측점 — 계획고는 이미 월류 높이만큼 내려와 있다. "월류가 없었다면" // 노면을 점선으로 남겨 얼마나 내려앉았는지 읽히게 한다(2026-08-30 사용자). appendFordSurfaceDropPlan(plotLayer, section.design, x, toDisplayY, fordLayout); diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index d240917ba..390b8c475 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -185,6 +185,22 @@ stroke-width: 1; } +/* 노면공 포장사양 무늬(88): 채움 박스 위에 무늬만 한 겹 — 콘크리트 빗금 · 아스콘 점 · 쇄석 돌 */ +.b06-chart__pavement-hatch { + stroke: none; +} + +.b06-chart__pave-mark { + fill: none; + stroke: color-mix(in srgb, var(--color-text-body) 70%, transparent); + stroke-width: 1; +} + +.b06-chart__pave-mark--dot { + fill: color-mix(in srgb, var(--color-text-body) 70%, transparent); + stroke: none; +} + /* 독립 기슭막이(2026-08-28): 성토면 끝에 서는 벽 — 배관 세트 기슭막이와 같은 색 계열 */ .b06-chart__revetment { fill: color-mix(in srgb, #9b6bdc 22%, transparent); diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts index aa57fc4ea..ff43a5cff 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts @@ -415,7 +415,7 @@ function drawDesign( ): void { const { design, culvert, box, ford, own } = layouts; const paved = appendFordPavementOverlay(svg, section.ford_pavement, design, x, y); - if (!paved) appendPavementOverlay(svg, design, x, y); + if (!paved) appendPavementOverlay(svg, design, x, y, section.pavement); appendCrossDesignOverlay( svg, design, From fc36f0ae4e502ac7314e78059a9f2ea514ede628 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:03:38 +0900 Subject: [PATCH 09/47] =?UTF-8?q?feat(B05):=20=EB=85=B8=EB=A9=B4=EA=B3=B5?= =?UTF-8?q?=20=EC=8B=9C=EC=9E=91=20=C2=B7=20=EC=A2=85=EB=A3=8C=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=C2=B7=20=EA=B8=B8=EC=9D=B4=20=EC=B6=9C=EB=A0=A5=20?= =?UTF-8?q?=C2=B7=20=EA=B8=B0=EC=A4=80=20=C2=B7=20=EC=A0=84/=ED=9B=84=20?= =?UTF-8?q?=EC=97=86=EC=9D=8C(87-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 노면공 명세 byRange: 위치 줄 = 시작 · 종료 측점만(기준 측점 칸 없음 · 알약 = 시점) · 상세 내용 = 포장사양 → 길이(시작~종료 출력) → 높이 - 포장사양 선택지 콘크리트 · 아스콘 · 쇄석 · 높이 기본 0.20 m - 저장 꼴: placement interval · start_m · end_m · options.pave_spec · height_m · 옛 length_m · before_m · after_m 은 [수정] 저장 때 뺌 - 구조물정의 판 20(판_19 이력) · 노면공 길이 · 전 · 후 옵션 빼고 height_m 기본값 - 시험: test_52_10 맞춤 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- A00_Common/design/design_structure_road.ts | 17 +- A00_Common/design/design_structure_spec.ts | 4 + B05_Profile/B05_Profile_UI_Structures_Form.ts | 16 + .../B05_Profile_UI_Structures_Panel.ts | 3 +- .../B05_Profile_UI_Structures_Panel_Commit.ts | 5 +- .../B05_Profile_UI_Structures_Panel_Types.ts | 6 + B05_Profile/B05_Profile_UI_Structures_Spec.ts | 8 +- resources/master_template/구조물정의.json | 25 +- .../구조물정의_이력/판_19.json | 3803 +++++++++++++++++ resources/tester/helper_52_structure_spec.cjs | 3 + .../tester/test_52_10_structure_spec_road.py | 20 +- 11 files changed, 3879 insertions(+), 31 deletions(-) create mode 100644 resources/master_template/구조물정의_이력/판_19.json diff --git a/A00_Common/design/design_structure_road.ts b/A00_Common/design/design_structure_road.ts index a3520dbf2..302caa448 100644 --- a/A00_Common/design/design_structure_road.ts +++ b/A00_Common/design/design_structure_road.ts @@ -2,9 +2,10 @@ * design_structure_road.ts * 노면공 › 콘크리트 포장 명세 — 사용자 구조물 표 08(`docs/raw/구조물정의_표/08_사토장_토취장_노면공_기타.md`). * - * 기준 측점(패널 위치 칸) + 필수 컨테이너 「상세 내용」 한 벌 = 포장사양 고르기 · 높이(m) 손 입력. - * 구간 길이 · 전 · 후는 정의 옵션 칸 그대로(구간 배치) · 표에 없는 옛 상세 칸은 화면에서 빼고 값은 남김. - * 저장 = structures.json `options` — 새 키 `pave_spec` · `height_m`. + * 시작 · 종료 측점(패널 위치 줄 · 기준 측점 · 전 · 후 · 길이 입력 없음) + 필수 컨테이너 「상세 내용」 한 벌 = + * 포장사양 고르기 → 길이(m · 시작~종료 출력) → 높이(m · 두께 손 입력). 표에 없는 옛 상세 칸은 화면에서 빼고 값은 남김. + * 저장 = structures.json `options` — 새 키 `pave_spec` · `height_m` · `widening_off`(확폭 끈 구간 · 87-3) · + * 옛 `length_m` · `before_m` · `after_m` 은 저장 때 뺌(`dropped`). * 기타(52-14) = 기준 측점 + 상세 종류 이름(정의 옵션 칸 `name`) + 선택 컨테이너 「상세 내용」(비고 글) · * 저장 키 `etc_detail_{n}_note` · 잇기 `getcdetail{n}`. * import 없음 — 시험이 이 파일만 옮겨 돌림. @@ -12,8 +13,11 @@ import type { SpecChoice, StructureSpec } from "./design_structure_spec"; -/** 포장사양 선택지 — 표의 나머지 값은 사용자 답 뒤 여기에만 더함. */ -export const PAVE_SPEC_CHOICES: SpecChoice[] = [{ value: "콘크리트", label: "콘크리트" }]; +/** 포장사양 선택지 — 근거 별표2 · 별표3 · 임도기술교본 3장. */ +export const PAVE_SPEC_CHOICES: SpecChoice[] = ["콘크리트", "아스콘", "쇄석"].map((v) => ({ + value: v, + label: v, +})); export const PAVEMENT_SPEC: StructureSpec = { typeId: "pavement_concrete", @@ -31,6 +35,7 @@ export const PAVEMENT_SPEC: StructureSpec = { key: "pave_spec", choices: PAVE_SPEC_CHOICES, }, + { id: "length", label: "길이 (m)", kind: "auto" }, { id: "height", label: "높이 (m)", kind: "number", key: "height_m", step: 0.05 }, ], }, @@ -44,6 +49,8 @@ export const PAVEMENT_SPEC: StructureSpec = { "separation_sheet", "wire_mesh", ], + dropped: ["length_m", "before_m", "after_m"], + byRange: true, }; export const ETC_SPEC: StructureSpec = { diff --git a/A00_Common/design/design_structure_spec.ts b/A00_Common/design/design_structure_spec.ts index 413f8135f..3ba11d1d1 100644 --- a/A00_Common/design/design_structure_spec.ts +++ b/A00_Common/design/design_structure_spec.ts @@ -69,6 +69,10 @@ export interface StructureSpec { containers: SpecContainer[]; /** 명세가 화면에서 뺀 옛 키 — 값은 그대로 남고 「지난 정의 값」으로 보임. */ retired: string[]; + /** 저장 때 지우는 옛 키 — 화면에도 안 그림(옛 항목을 [수정]하면 빠짐). */ + dropped?: string[]; + /** 범위 직접 입력형 — 시작 · 종료 측점만 받고 기준 측점 칸 없음(알약 = 시점 · 길이 · 전 · 후 입력 없음). */ + byRange?: boolean; } /** 글 항목 길이 상한(자) — 칸 · 저장이 함께 자름. */ diff --git a/B05_Profile/B05_Profile_UI_Structures_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Form.ts index 7aa35eb32..7a7c33571 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Form.ts @@ -161,6 +161,22 @@ export function buildStructuresForm(options: { optionRow, { get: groupBoxes.get, pick: groupBoxes.pick, clear: groupBoxes.clear, set: groupBoxes.setOne }, options.onFacilityChange, + // 노면공 길이 칸(87-1) = 시작~종료 출력 — 못 셈이면 빈 글(「—」) + (_c, item) => { + if (item.id !== "length") return ""; + const step = options.getInterval(); + const start = startFields.read(step, false); + const end = endFields.read(step, false); + return start !== null && end !== null && end > start + ? String(Number((end - start).toFixed(2))) + : ""; + }, + ); + // 시작 · 종료를 고치면 길이 출력을 다시 그림 + [startFields, endFields].forEach((fields) => + [fields.station, fields.remainder].forEach((input) => + input.addEventListener("change", () => structureSpec.refresh()), + ), ); groupBoxes.watch(structureSpec.paintLinks); const staleBox = document.createElement("fieldset"); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index ec97561b0..b29842a7e 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -56,6 +56,7 @@ import { DEFAULT_INTERVAL_LENGTH_M, groupOptions, hasComputedRange, + isRangeOnly, typeOptions, readBeforeOption, readLengthOption, @@ -238,7 +239,7 @@ export function createStructuresSection( // [?] 설명서가 먼저 여는 항목 = `구조물_<종류 이름>.md`(54-2 · 종류 안내 글은 그 파일로 옮김) root.dataset.helpTopic = type ? `구조물_${type.name}` : ""; // 정의가 기준 측점을 끈 종류(48-3) — 기준 측점이 곧 위치인 종류(점형 · 범위 계산)는 늘 보임 - anchorFields.wrap.hidden = isInterval && !showsAnchor(type!); + anchorFields.wrap.hidden = isInterval && (!showsAnchor(type!) || isRangeOnly(type)); anchorFields.wrap.querySelector("span")!.textContent = "기준 측점"; // 빈 칸 안내 없음(54-43) syncRangeDisplay(); } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 222588462..5490be851 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -314,7 +314,10 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis return; } // 기준 측점(마킹 위치) — 비우면 시작 측점. 시작~종료 밖은 서버도 거절한다. - anchor = ctx.anchorFields.read(step, false) ?? start; + // 범위 직접 입력형(노면공 · 87-1) = 기준 칸 없음 → 늘 시점. + anchor = STRUCTURE_SPECS[type.type_id]?.byRange + ? start + : (ctx.anchorFields.read(step, false) ?? start); if (anchor < start || anchor > end) { ctx.anchorFields.station.classList.add("is-invalid"); ctx.anchorFields.station.focus(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index fec141a06..f97aaea72 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -20,6 +20,7 @@ import { } from "./B05_Profile_Api_Structures"; import type { DesignSelection, ResolvedSelection } from "../A00_Common/design/design_selection"; import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item"; +import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; /** 구조물군 표시 이름. 리스트 기호(A~G)만으로는 무엇인지 알기 어렵다. * 우클릭 메뉴(종단그래프·배수유역도)도 같은 이름을 쓴다(2026-08-18 일원화). */ @@ -209,6 +210,10 @@ export function readBeforeOption(inputs: OptionEntry[]): number { return length !== null && before > length ? length : before; } +/** 시작 · 종료 측점만 받는 명세 종류인가(노면공 · 87-1) — 기준 측점 칸 없음 · 길이 출력. */ +export const isRangeOnly = (type: { type_id: string } | null): boolean => + !!type && !!STRUCTURE_SPECS[type.type_id]?.byRange; + /** 구간을 직접 입력하지 않는 타입인가 — C군 사면안정 5종처럼 B05 길이 옵션이 * 있으면 시작·종료 입력 칸을 없애고 기준 측점 + 길이로 범위를 계산해 보여만 * 준다(2026-08-19 사용자 지시). */ @@ -217,6 +222,7 @@ export function hasComputedRange(type: StructureType | null): boolean { !!type && !type.managed_by && type.placement === "interval" && + !isRangeOnly(type) && type.options.some((option) => option.key === "length_m" && isB05Option(option)) ); } diff --git a/B05_Profile/B05_Profile_UI_Structures_Spec.ts b/B05_Profile/B05_Profile_UI_Structures_Spec.ts index e03819c49..e5cb9320d 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Spec.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Spec.ts @@ -12,6 +12,7 @@ import type { StructureType } from "./B05_Profile_Api_Structures"; import { createSpecForm, type SpecForm, + type SpecFormHooks, type SpecLinks, } from "../A00_Common/design/design_structure_form"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; @@ -29,8 +30,9 @@ export function createStructureSpecForm( optionRow: HTMLElement, links: SpecLinks, onChange: () => void, + auto?: SpecFormHooks["auto"], ): SpecForm { - const form = createSpecForm(links, { onChange }); + const form = createSpecForm(links, { onChange, auto }); form.root.hidden = true; forms.set(optionRow, form); const lock = (): void => { @@ -49,7 +51,7 @@ export function createStructureSpecForm( /** 정의 옵션 칸으로 안 그리는 키 — 명세 키 · 명세가 뺀 옛 키. */ export function specOwnedKeys(type: StructureType | null): Set { const spec = structureSpecOf(type); - return new Set(spec ? [...specKeys(spec).keys, ...spec.retired] : []); + return new Set(spec ? [...specKeys(spec).keys, ...spec.retired, ...(spec.dropped ?? [])] : []); } /** 종류 · 값으로 명세 폼을 다시 그림(명세 없는 종류면 가림). */ @@ -85,5 +87,5 @@ export function specRenderedKeys( if (!spec) return []; const { keys, prefixes } = specKeys(spec); const own = Object.keys(previous ?? {}).filter((key) => prefixes.some((p) => key.startsWith(p))); - return [...keys, ...own]; + return [...keys, ...own, ...(spec.dropped ?? [])]; } diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 32a69a793..e6e792773 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -1,5 +1,5 @@ { - "판": 19, + "판": 20, "schema_version": 1, "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", "구조물군": { @@ -3177,25 +3177,14 @@ ], "options": [ { - "key": "length_m", - "label": "길이", + "key": "height_m", + "label": "높이", "input": "number", "unit": "m", - "default": 10 - }, - { - "key": "before_m", - "label": "기준측점 전", - "input": "number", - "unit": "m", - "default": 5 - }, - { - "key": "after_m", - "label": "기준측점 후", - "input": "number", - "unit": "m", - "default": 5 + "default": 0.2, + "required": false, + "phase": "detail", + "default_basis": "사용자 확정 10-8 · 임도기술교본 3-2 「두께 0.2m」 기준 · 명세 칸(상세 내용)이 받음" }, { "key": "thickness_cm", diff --git a/resources/master_template/구조물정의_이력/판_19.json b/resources/master_template/구조물정의_이력/판_19.json new file mode 100644 index 000000000..32a69a793 --- /dev/null +++ b/resources/master_template/구조물정의_이력/판_19.json @@ -0,0 +1,3803 @@ +{ + "판": 19, + "schema_version": 1, + "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", + "구조물군": { + "A": "A 횡단배수", + "B": "B 종단배수", + "C": "C 사면안정", + "D": "D 계류·사방", + "E": "E 안전·부대·용지", + "F": "F 생태·녹화", + "G": "G 노면공", + "호환": "기타" + }, + "동작": { + "계곡통과시설": "관 지점 정본(pipe_points.json) 시설 — 부속 옵션 서브폼을 손코딩으로 그림 · 저장은 폼이 아는 칸만 갈아 끼움", + "유입유출_묶음": "관 유입구(집수정 · 기슭막이 택일) · 유출구(기슭막이) 소그룹", + "기슭막이_한벌": "기슭막이 형태 · 길이 · 높이 · 바닥 보호공 한 벌 — 관 유입 · 유출 · 독립 기슭막이가 같은 조각", + "BOX_본체규격": "BOX암거 본체 규격 프리셋 고르기(폭 × 높이 한 칸)", + "날개벽": "날개벽 유입 · 유출 · 짧은쪽 높이 · 길이 · 각도 조각", + "월류단면": "월류 폭 · 높이 · 바닥 경사 · 포장 두께 + 개략 단면 줄 · 필요 수심 미만은 되돌림", + "포장_물넘이_겹침": "포장 구간이 물넘이포장을 품으면 앞 · 뒤로 나눔 · 끝만 걸치면 저장 멈춤" + }, + "types": [ + { + "type_id": "pipe", + "group": "A", + "name": "배관", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#4f8ef7", + "abbr": "배수관" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false, + "enabled": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "inlet_type", + "label": "유입구 구조", + "input": "select", + "choices": [ + "기슭막이", + "집수정" + ], + "default": "집수정", + "required": false, + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "key": "wing_wall_type", + "label": "관보호공 날개벽", + "input": "select", + "choices": [ + "A-TYPE", + "C-TYPE", + "A-TYPE+집수정" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 날개벽을 안 셈 — 고르면 그 형식의 개소당 원단위로 한 줄이 섬(Ø800·Ø1000 만 있음)" + }, + { + "key": "inlet_basin_form", + "label": "집수정 형식", + "input": "select", + "choices": [ + "동물이동형", + "□형(기본형)", + "돌집수정 ㄴ형", + "돌집수정 ㄷ형" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_material", + "label": "집수정 형태", + "input": "select", + "choices": [ + "콘크리트", + "조립식 주철맨홀", + "돌쌓기(찰)", + "돌쌓기(메)" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_length_m", + "label": "집수정 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_before_m", + "label": "집수정 기준측점 전", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_after_m", + "label": "집수정 기준측점 후", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_height_m", + "label": "집수정 높이", + "input": "number", + "unit": "m", + "default": 1.5, + "required": false, + "phase": "detail", + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "key": "inlet_revet_form", + "label": "유입 기슭막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(찰)", + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_length_m", + "label": "유입 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "inlet_revet_before_m", + "label": "유입 기슭막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_after_m", + "label": "유입 기슭막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_height_m", + "label": "유입 기슭막이 높이", + "input": "number", + "unit": "m", + "default": 1.5, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)", + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "key": "outlet_type", + "label": "유출구 구조", + "input": "select", + "choices": [ + "기슭막이" + ], + "default": "기슭막이", + "required": false + }, + { + "key": "outlet_revet_form", + "label": "유출 기슭막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(찰)", + "required": false, + "phase": "detail", + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "key": "outlet_revet_length_m", + "label": "유출 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "outlet_revet_before_m", + "label": "유출 기슭막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_after_m", + "label": "유출 기슭막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_height_m", + "label": "유출 기슭막이 높이", + "input": "number", + "unit": "m", + "default": 1.5, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)", + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + } + ] + }, + { + "type_id": "box_culvert", + "group": "A", + "name": "BOX암거", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#3b6fd4", + "abbr": "암거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "body_width_m", + "label": "본체 폭", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "body_height_m", + "label": "본체 높이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "length_m", + "label": "연장(계류 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 수량이 안 섬 — 구체 길이는 설계자 입력(기본값 없음 · 2026-09-15 브레인 판정 ③)" + }, + { + "key": "wall_thickness_m", + "label": "측벽 두께", + "input": "number", + "unit": "m", + "default": 0.3, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 벽 0.3 · KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "slab_thickness_m", + "label": "상·저판 두께", + "input": "number", + "unit": "m", + "default": 0.25, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 판 0.25 — ⚠ KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ 에 못 미침(임도 예제 0.25 · KDS 도로 최소 300㎜) · 설계자가 고름", + "required": false, + "phase": "detail" + }, + { + "key": "haunch_m", + "label": "헌치(모따기) 크기", + "input": "number", + "unit": "m", + "default": 0.2, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 0.2×0.2 · 실무 울진 기번3 구조도 2×2 도 0.2 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "blinding_thickness_m", + "label": "기초(버림) 두께", + "input": "number", + "unit": "m", + "default": 0.1, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 기초 0.1(양쪽 0.1 여유) · 실무 울진 기번3 구조도 2×2 버림 0.1 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "trench_depth_m", + "label": "평균 터파기고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 구체 터파기·되메우기가 안 섬 — 원지반에서 터파기 바닥(기초잡석 밑)까지 · 토공집계로 감(2026-09-15 브레인 판정 ③)" + }, + { + "key": "wing_in", + "label": "유입 구조", + "input": "select", + "choices": [ + "없음", + "날개벽", + "흙막이" + ], + "default": "날개벽", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "유출 구조", + "input": "select", + "choices": [ + "없음", + "날개벽", + "흙막이" + ], + "default": "날개벽", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "ford_pavement", + "group": "A", + "name": "물넘이포장", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#2f9e8f", + "abbr": "물넘이" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "ford_slope_deg", + "label": "경사각도", + "input": "number", + "unit": "°", + "default": 3, + "required": false, + "default_basis": "물넘이 포장 경사각 기본 3° — 사용자 확정값(PLAN 65-5)" + }, + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "detail", + "default_basis": "물넘이 월류 폭 기본 10 m — 사용자 확정 10-7(엔진 FORD_PAVEMENT_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "ford_slope_pct", + "label": "바닥 경사(유입→유출)", + "input": "number", + "unit": "%", + "default": null, + "required": false + }, + { + "key": "thickness_cm", + "label": "포장 두께", + "input": "number", + "unit": "㎝", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 표가 안 서고 「두께를 적어야 섬」 사유가 뜸 — 두께로 관측 원단위(울진 20㎝)를 고름(2026-09-14 A3)" + }, + { + "key": "length_m", + "label": "포장 길이(노폭 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 면적(월류 폭 × 길이)이 0 이라 표가 안 서고 사유가 뜸 — 노견까지 전폭을 적음(2026-09-14 A3)" + } + ] + }, + { + "type_id": "ford_bridge", + "group": "A", + "name": "세월교", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#28857a", + "abbr": "세월교" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "pipe_count", + "label": "배관 수량", + "input": "number", + "unit": "련", + "default": 2, + "required": false, + "default_basis": "세월교 배관 수량 기본 2련 — 사용자 확정값(PLAN 65-4)" + }, + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "detail", + "default_basis": "세월교 월류 폭 기본 10m — 사용자 확정값(엔진 FORD_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "wing_in", + "label": "날개벽(유입)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "날개벽(유출)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "cross_drain_exposed", + "group": "A", + "name": "노출형 횡단수로", + "placement": "point", + "style": { + "color": "#5aa7e8", + "abbr": "횡수로" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [] + }, + { + "type_id": "open_ditch", + "group": "A", + "name": "개거(겉도랑)", + "placement": "point", + "style": { + "color": "#6d9bd1", + "abbr": "개거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [] + }, + { + "type_id": "cross_underdrain", + "group": "A", + "name": "맹암거", + "placement": "point", + "style": { + "color": "#3aa0a8", + "abbr": "횡맹" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "culvert_height_m", + "label": "암거 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)" + }, + { + "key": "culvert_slope_pct", + "label": "암거 경사", + "input": "number", + "unit": "%", + "default": null, + "required": false, + "empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)" + } + ] + }, + { + "type_id": "ditch_side", + "enabled": false, + "group": "B", + "name": "측구(옆도랑)", + "design_owner": "횡단 설계", + "placement": "interval", + "style": { + "color": "#2eaadc", + "abbr": "측구" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "일반형(제형)", + "L형", + "U형", + "J형" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "depth_cm", + "label": "깊이", + "input": "number", + "unit": "cm", + "default": 30 + } + ] + }, + { + "type_id": "ditch_ridge", + "enabled": false, + "group": "B", + "name": "산마루측구", + "placement": "interval", + "style": { + "color": "#2e8bdc", + "abbr": "산마루" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "콘크리트", + "돌", + "떼" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "ditch_berm", + "enabled": false, + "group": "B", + "name": "소단측구", + "placement": "interval", + "style": { + "color": "#2ec5dc", + "abbr": "소단구" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "사면 쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "chute", + "enabled": false, + "group": "B", + "name": "도수로·산비탈수로", + "placement": "interval", + "style": { + "color": "#1f9bb8", + "abbr": "도수로" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌", + "콘크리트", + "블록판", + "벤치플륨관", + "반원관" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "section", + "label": "단면형", + "input": "select", + "choices": [ + "반달꼴", + "U자형", + "사다리꼴" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "slope_drain", + "enabled": false, + "group": "B", + "name": "절토사면 배수로", + "placement": "interval", + "style": { + "color": "#28b1a1", + "abbr": "사면로" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "underdrain", + "enabled": false, + "group": "B", + "name": "맹암거", + "placement": "interval", + "style": { + "color": "#1f7fb8", + "abbr": "맹암거" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "note", + "label": "규격", + "input": "text", + "default": "" + } + ] + }, + { + "type_id": "retaining_wall", + "group": "C", + "name": "옹벽", + "placement": "interval", + "style": { + "color": "#e07a2e", + "abbr": "옹벽" + }, + "drawing_views": [ + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "wall_form", + "label": "형태", + "input": "select", + "choices": [ + "콘크리트" + ], + "default": "콘크리트", + "default_basis": "표 09 — 선택지 하나(콘크리트)", + "required": false + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2, + "default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음", + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "중력식", + "반중력식", + "캔틸레버식", + "부벽식", + "식생옹벽블럭" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "masonry_wet", + "group": "C", + "name": "돌쌓기(찰)", + "placement": "interval", + "style": { + "color": "#d0692a", + "abbr": "찰쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤3.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "masonry_dry", + "group": "C", + "name": "돌쌓기(메)", + "placement": "interval", + "style": { + "color": "#c25d2c", + "abbr": "메쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)", + "warn_above": 2, + "warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "soil_guard", + "group": "C", + "name": "흙막이", + "placement": "interval", + "style": { + "color": "#b8742e", + "abbr": "흙막이" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(찰)", + "required": false, + "empty_means": "비우면 통나무·목재틀로 그림(횡단 기본 형태) — B05 로 옮긴 칸이라 필수로 안 막음(옛 흙막이 저장 보호)", + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 1.5, + "required": false, + "phase": "b05", + "default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌)" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + } + ] + }, + { + "type_id": "boulder_masonry", + "group": "C", + "name": "큰돌쌓기", + "placement": "interval", + "style": { + "color": "#a86a2a", + "abbr": "큰돌" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "stone_cm", + "label": "돌규격", + "input": "select", + "choices": [ + "40~60", + "60~80", + "80~100" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bond", + "label": "쌓기 방식", + "input": "select", + "choices": [ + "메쌓기", + "찰쌓기" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 채움 콘크리트를 품에 포함 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "berm", + "group": "C", + "name": "소단", + "placement": "interval", + "style": { + "color": "#7f9a63", + "abbr": "소단" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "소단 폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "height_m", + "label": "소단 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비워도 놓임 — 아직 계산(소단 간격 · 횡단)에 안 쓰임(표 09 새 칸)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "안쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "erosion_check", + "group": "D", + "name": "골막이", + "placement": "point", + "style": { + "color": "#7a52c7", + "abbr": "골막이" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "돌", + "돌망태", + "콘크리트", + "통나무", + "바자", + "흙" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "spillway", + "label": "방수로", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이 ℓ3", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "top_length_m", + "label": "상장 ⓐ (윗변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bottom_length_m", + "label": "하장 ⓑ (아랫변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 4.5 + }, + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2 + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "bed_sill", + "group": "D", + "name": "바닥막이", + "placement": "point", + "style": { + "color": "#7c5f3b", + "abbr": "바닥" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌붙임(찰)", + "돌붙임(메)" + ], + "default": "돌붙임(메)", + "required": false, + "phase": "b05" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 평면적(돌붙임 ㎡)이라 기울기 몫이 없음 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 0.3, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "check_dam_small", + "group": "D", + "name": "소형사방댐(복합형)", + "placement": "point", + "style": { + "color": "#6b46b8", + "abbr": "사방댐" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revetment", + "group": "D", + "name": "기슭막이", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#9b6bdc", + "abbr": "기슭" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "양쪽", + "좌", + "우" + ], + "default": "양쪽", + "required": false, + "phase": "b05", + "default_basis": "「좌」는 셀렉트 첫 항목일 뿐 도메인 확정값이 아님 — 측점마다 사용자가 고름" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(메)", + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·찰/메로 · 범위 안 가장 작은 규격 — 예 메쌓기 1.5~3m 는 36~45㎝ 라 45㎝)으로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "b05", + "empty_means": "설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181 · 사방(계류) 교본 기준). 비우면 줄만 서고 미확정이라 금액에 안 들어감 · 옛 기본 2.5 는 근거 없어 걷음(2026-09-14 브레인 판정)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "evac_road", + "group": "E", + "name": "대피로", + "placement": "point", + "style": { + "color": "#2fa84f", + "abbr": "대피로" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "detail", + "label": "상세", + "input": "select", + "choices": [ + "대피소", + "정차/작업장", + "차돌림곳" + ], + "default": null, + "required": false, + "phase": "b05", + "empty_means": "비우면 상세 미정 — 대피로 자리만 잡음(대피소 · 정차/작업장 · 차돌림곳은 뒤에 고름)" + } + ] + }, + { + "type_id": "refuge", + "group": "E", + "name": "대피소", + "placement": "interval", + "style": { + "color": "#2fa84f", + "abbr": "대피소" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 15 + } + ] + }, + { + "type_id": "work_yard", + "group": "E", + "name": "정차·작업장", + "placement": "interval", + "style": { + "color": "#268f43", + "abbr": "작업장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 7 + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 20 + } + ] + }, + { + "type_id": "turnaround", + "group": "E", + "name": "차돌림곳", + "placement": "site", + "style": { + "color": "#37b95a", + "abbr": "차돌림" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 10 + } + ] + }, + { + "type_id": "mirror", + "group": "E", + "name": "반사경", + "placement": "point", + "style": { + "color": "#43c76b", + "abbr": "반사경" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "curb", + "group": "E", + "name": "경계석", + "placement": "point", + "style": { + "color": "#4fae5c", + "abbr": "경계석" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "hazard_sign", + "group": "E", + "name": "위험표시", + "placement": "point", + "style": { + "color": "#5fb83a", + "abbr": "위험" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "guardrail", + "group": "E", + "name": "가드레일", + "placement": "interval", + "style": { + "color": "#1f9e46", + "abbr": "가드" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형태", + "input": "text", + "default": null, + "required": false + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": null, + "empty_means": "비우면 범위를 못 셈 — 길이를 적어야 기준 측점 앞뒤로 구간이 섬" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": null, + "empty_means": "비우면 0 — 기준 측점이 곧 시작(길이 = 전 + 후 맞물림)" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": null, + "empty_means": "비우면 길이 − 전 으로 셈(길이 = 전 + 후 맞물림)" + } + ] + }, + { + "type_id": "position_sign", + "group": "E", + "name": "국가지점번호판", + "placement": "point", + "style": { + "color": "#53a832", + "abbr": "번호판" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "water_intake", + "group": "E", + "name": "취수정", + "placement": "point", + "style": { + "color": "#3a9e78", + "abbr": "취수정" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "barrier_gate", + "group": "E", + "name": "차단기", + "placement": "point", + "style": { + "color": "#3f9e2e", + "abbr": "차단기" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "completion_sign", + "group": "E", + "name": "준공표지판", + "placement": "point", + "style": { + "color": "#66b344", + "abbr": "준공" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "spoil_bank", + "group": "E", + "name": "유용토운반작업장(사토장)", + "placement": "site", + "style": { + "color": "#8c9a2e", + "abbr": "사토장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "capacity_m3", + "label": "사토장 부피", + "input": "number", + "unit": "㎥", + "default": null, + "required": false, + "phase": "b05", + "empty_means": "비우면 B06 사토 배분이 이 사토장을 건너뜀" + } + ] + }, + { + "type_id": "borrow_pit", + "group": "E", + "name": "토사채취장(토취장)", + "placement": "site", + "style": { + "color": "#7d8a2a", + "abbr": "토취장" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "capacity_m3", + "label": "토취장 부피", + "input": "number", + "unit": "㎥", + "default": null, + "required": false, + "phase": "b05", + "empty_means": "비우면 자리만 잡음 — 부피는 설계자가 현장 조사로 손 입력" + } + ] + }, + { + "type_id": "wildlife_path", + "enabled": false, + "group": "F", + "name": "야생동물 이동통로", + "placement": "point", + "style": { + "color": "#3aa88f", + "abbr": "동물길" + }, + "drawing_views": [ + "plan", + "cross_section" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "경사로", + "자연형계단" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revegetation", + "enabled": false, + "group": "F", + "name": "비탈면 녹화", + "placement": "interval", + "style": { + "color": "#4cb84c", + "abbr": "녹화" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "method", + "label": "공법", + "input": "select", + "choices": [ + "씨뿌리기(줄파종)", + "새심기", + "선떼붙이기", + "줄떼", + "평떼", + "비탈덮기", + "녹생토 뿜어붙이기", + "조공", + "편책", + "나무심기" + ], + "default": null, + "default_basis": "「초류종자 살포」는 여기 없음 — 토공집계에서 사면 전체로 섬(품셈 5-24 · 두 곳에 두면 이중계상 · 2026-09-15 브레인)", + "required": true, + "phase": "detail" + }, + { + "key": "face", + "label": "대상 면", + "input": "select", + "choices": [ + "절토면", + "성토면", + "양쪽" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "cover_kind", + "label": "덮개(비탈덮기)", + "input": "select", + "choices": [ + "거적", + "짚망", + "방초·야자섬유매트" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비탈덮기일 때만 고름 — 비면 비탈덮기 줄이 안 섬(품셈 5-25 거적덮기 ㎡ · 5-28-1 짚망 · 5-28-2 매트 · 재료가 공종을 가름)" + }, + { + "key": "step_height_m", + "label": "단 직고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기·조공의 단 사이 직고 — 교본은 범위로만 줌(선떼 직고 1~2m · 조공 1.0~1.2m) · 기본값 없음 · 비면 선떼 연장(품셈 5-16-1 [주]② 대상지 직고 ÷ 단 직고 × 연장)이 안 섬" + }, + { + "key": "sod_grade", + "label": "선떼 급수", + "input": "select", + "choices": [ + "1급", + "2급", + "3급", + "4급", + "5급", + "6급", + "7급", + "8급", + "9급" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기의 급수 — 교본은 1~9급 범위만(6~7급을 많이 씀) · 기본값 없음 · 비면 m당 떼 매수(사방기술교본 표 2-2-6)가 안 섬" + }, + { + "key": "sod_size", + "label": "선떼 떼 크기", + "input": "select", + "choices": [ + "대형 40×40", + "소형 18×18" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "사방기술교본 표 2-2-6 이 대형(40×40)·소형(18×18) 두 줄을 나란히 줌 — 우리가 고를 자리가 아니라 기본값 없음 · 비면 선떼 떼 매수가 안 섬" + }, + { + "key": "cutting_soil", + "label": "단끊기 토질", + "input": "select", + "choices": [ + "보통토사", + "경질ㆍ고사점토 및 자갈섞인 점토", + "호박돌 섞인 토사" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "품셈 5-16-1 단끊기 표의 토질 셋 — B06 지반(토사/암)과 다른 축이라 자동으로 안 고름 · 비면 단끊기 조각만 막힘" + } + ] + }, + { + "type_id": "pavement_concrete", + "group": "G", + "name": "콘크리트 포장", + "placement": "interval", + "style": { + "color": "#8a8f98", + "abbr": "포장" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10 + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5 + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5 + }, + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 포장층 두께(표준 횡단면 설정 · 기본 0.2m = 임도기술교본 3-2 「두께 0.2m」)를 이음 — 적으면 적은 값이 이김" + }, + { + "key": "width_m", + "label": "포장 폭", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 노폭(구간 측점의 차도 표준 폭 · 교본 3-2 「포장 폭 3.0m」 · 실무 관측 3.5~4.0)을 이음 — 적으면 적은 값이 이김" + }, + { + "key": "widening_area_m2", + "label": "확폭 면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 곡선부 확폭을 측점 사이 평균 × 거리로 더함(교본 3-2 「곡선부에는 확폭」) — 확폭이 없으면 0 을 적을 것" + }, + { + "key": "joint_spacing_m", + "label": "수축줄눈 간격", + "input": "number", + "unit": "m", + "default": 6, + "default_basis": "제안값 · 임도기술교본 부록 4-7 7-1 3.5.1 「수축줄눈의 간격은 4-6m를 기준」 · 실무 소광 6m 관측 · 바꿀 수 있음", + "phase": "detail" + }, + { + "key": "separation_sheet", + "label": "비닐(깔기·양생)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 비닐 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「양생 재료비(비닐 등)는 별도 계상」이라 설계자가 고름(기본값 없음)" + }, + { + "key": "wire_mesh", + "label": "철망", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 철망 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「철망재료비는 별도 계상」 · 교본 부록 7-1 3.4 「설계도서에 따라」 · 실무 소광 「철망포함」 관측" + } + ] + }, + { + "type_id": "gravel_surfacing", + "enabled": false, + "group": "G", + "name": "쇄석(자갈) 포설", + "placement": "interval", + "style": { + "color": "#9aa0a8", + "abbr": "쇄석" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "etc", + "group": "호환", + "name": "기타", + "placement": "point", + "style": { + "color": "#98a2b3", + "abbr": "기타" + }, + "drawing_views": [ + "profile" + ], + "options": [ + { + "key": "name", + "label": "상세 종류 이름", + "input": "text", + "default": "기타 구조물" + } + ] + } + ], + "종류": { + "pipe": { + "동작": [ + "계곡통과시설", + "유입유출_묶음", + "기슭막이_한벌" + ], + "이름": { + "pipe.diameter": "관경", + "pipe.diameter=800": "800", + "pipe.diameter=1000": "1000", + "pipe.diameter=1200": "1200", + "pipe.diameter=1500": "1500", + "pipe.length": "관 길이 (m)", + "inlet.structure": "구조", + "inlet.structure=집수정": "집수정", + "inlet.structure=기슭막이": "흙막이", + "inlet.form": "형태", + "inlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "inlet.form=돌쌓기(메)": "돌 쌓기(메)", + "inlet.form=콘크리트": "콘크리트", + "inlet.form=돌망태": "돌망태", + "inlet.form=통나무·목재틀": "통나무/목재틀", + "inlet.form=바자": "바자", + "inlet.form=I": "I형", + "inlet.form=L": "L형", + "inlet.form=U": "ㄷ형", + "inlet.length": "길이 (m)", + "inlet.height": "높이 (m)", + "inlet.before": "기준측점 전 (m)", + "inlet.after": "기준측점 후 (m)", + "outlet.structure": "구조", + "outlet.structure=기슭막이": "흙막이", + "outlet.form": "형태", + "outlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "outlet.form=돌쌓기(메)": "돌 쌓기(메)", + "outlet.form=콘크리트": "콘크리트", + "outlet.form=돌망태": "돌망태", + "outlet.form=통나무·목재틀": "통나무/목재틀", + "outlet.form=바자": "바자", + "outlet.length": "길이 (m)", + "outlet.height": "높이 (m)", + "outlet.before": "기준측점 전 (m)", + "outlet.after": "기준측점 후 (m)", + "soil_add": "흙막이 추가", + "soil_add.form": "형태", + "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", + "soil_add.form=돌쌓기(메)": "돌 쌓기(메)", + "soil_add.form=콘크리트": "콘크리트", + "soil_add.form=돌망태": "돌망태", + "soil_add.form=통나무·목재틀": "통나무/목재틀", + "soil_add.form=바자": "바자", + "soil_add.length": "길이 (m)", + "soil_add.height": "높이 (m)", + "soil_add.before": "기준측점 전 (m)", + "soil_add.after": "기준측점 후 (m)", + "chute_add": "도수로/돌붙임", + "chute_add.structure": "구조", + "chute_add.structure=도수로": "도수로", + "chute_add.structure=돌붙임": "돌붙임" + } + }, + "box_culvert": { + "동작": [ + "계곡통과시설", + "BOX_본체규격", + "날개벽" + ], + "이름": { + "body.size": "본체 규격", + "body.size=2.0×2.0": "2.0 × 2.0", + "body.size=3.0×3.0": "3.0 × 3.0", + "body.size=사용자 입력": "사용자 입력", + "body.width": "가로 (m)", + "body.height": "세로 (m)", + "inlet.structure": "구조", + "inlet.structure=없음": "없음", + "inlet.structure=날개벽": "날개벽", + "inlet.structure=흙막이": "흙막이", + "inlet.form": "형태", + "inlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "inlet.form=돌쌓기(메)": "돌 쌓기(메)", + "inlet.form=콘크리트": "콘크리트", + "inlet.form=돌망태": "돌망태", + "inlet.form=통나무·목재틀": "통나무/목재틀", + "inlet.form=바자": "바자", + "inlet.length": "길이 (m)", + "inlet.height": "높이 (m)", + "inlet.before": "기준측점 전 (m)", + "inlet.after": "기준측점 후 (m)", + "inlet.berm": "수평공간 (m)", + "outlet.structure": "구조", + "outlet.structure=없음": "없음", + "outlet.structure=날개벽": "날개벽", + "outlet.structure=흙막이": "흙막이", + "outlet.form": "형태", + "outlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "outlet.form=돌쌓기(메)": "돌 쌓기(메)", + "outlet.form=콘크리트": "콘크리트", + "outlet.form=돌망태": "돌망태", + "outlet.form=통나무·목재틀": "통나무/목재틀", + "outlet.form=바자": "바자", + "outlet.length": "길이 (m)", + "outlet.height": "높이 (m)", + "outlet.before": "기준측점 전 (m)", + "outlet.after": "기준측점 후 (m)", + "outlet.berm": "수평공간 (m)", + "soil_add": "흙막이", + "soil_add.form": "형태", + "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", + "soil_add.form=돌쌓기(메)": "돌 쌓기(메)", + "soil_add.form=콘크리트": "콘크리트", + "soil_add.form=돌망태": "돌망태", + "soil_add.form=통나무·목재틀": "통나무/목재틀", + "soil_add.form=바자": "바자", + "soil_add.length": "길이 (m)", + "soil_add.height": "높이 (m)", + "soil_add.before": "기준측점 전 (m)", + "soil_add.after": "기준측점 후 (m)", + "chute_add": "도수로/돌붙임", + "chute_add.structure": "구조", + "chute_add.structure=도수로": "도수로", + "chute_add.structure=돌붙임": "돌붙임" + } + }, + "ford_pavement": { + "동작": [ + "계곡통과시설", + "월류단면" + ] + }, + "ford_bridge": { + "동작": [ + "계곡통과시설", + "월류단면", + "날개벽" + ], + "이름": { + "ford.diameter": "관경", + "ford.diameter=800": "800", + "ford.diameter=1000": "1000", + "ford.diameter=1200": "1200", + "ford.diameter=1500": "1500", + "ford.count": "수량 (련)", + "ford.width": "월류 폭 (m)", + "ford.height": "월류 높이 (m)", + "inlet.structure": "구조", + "inlet.structure=없음": "없음", + "inlet.structure=날개벽": "날개벽", + "inlet.structure=흙막이": "흙막이", + "inlet.form": "형태", + "inlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "inlet.form=돌쌓기(메)": "돌 쌓기(메)", + "inlet.form=콘크리트": "콘크리트", + "inlet.form=돌망태": "돌망태", + "inlet.form=통나무·목재틀": "통나무/목재틀", + "inlet.form=바자": "바자", + "inlet.length": "길이 (m)", + "inlet.height": "높이 (m)", + "inlet.before": "기준측점 전 (m)", + "inlet.after": "기준측점 후 (m)", + "inlet.berm": "수평공간 (m)", + "outlet.structure": "구조", + "outlet.structure=없음": "없음", + "outlet.structure=날개벽": "날개벽", + "outlet.structure=흙막이": "흙막이", + "outlet.form": "형태", + "outlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "outlet.form=돌쌓기(메)": "돌 쌓기(메)", + "outlet.form=콘크리트": "콘크리트", + "outlet.form=돌망태": "돌망태", + "outlet.form=통나무·목재틀": "통나무/목재틀", + "outlet.form=바자": "바자", + "outlet.length": "길이 (m)", + "outlet.height": "높이 (m)", + "outlet.before": "기준측점 전 (m)", + "outlet.after": "기준측점 후 (m)", + "outlet.berm": "수평공간 (m)", + "soil_add": "흙막이", + "soil_add.form": "형태", + "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", + "soil_add.form=돌쌓기(메)": "돌 쌓기(메)", + "soil_add.form=콘크리트": "콘크리트", + "soil_add.form=돌망태": "돌망태", + "soil_add.form=통나무·목재틀": "통나무/목재틀", + "soil_add.form=바자": "바자", + "soil_add.length": "길이 (m)", + "soil_add.height": "높이 (m)", + "soil_add.before": "기준측점 전 (m)", + "soil_add.after": "기준측점 후 (m)", + "chute_add": "도수로/돌붙임", + "chute_add.structure": "구조", + "chute_add.structure=도수로": "도수로", + "chute_add.structure=돌붙임": "돌붙임" + } + }, + "revetment": { + "동작": [ + "계곡통과시설", + "기슭막이_한벌" + ] + }, + "pavement_concrete": { + "동작": [ + "포장_물넘이_겹침" + ] + }, + "soil_guard": { + "이름": { + "form=돌쌓기(찰)": "돌 쌓기(찰)", + "form=돌쌓기(메)": "돌 쌓기(메)", + "form=콘크리트": "콘크리트", + "form=돌망태": "돌망태", + "form=통나무·목재틀": "통나무/목재틀", + "form=바자": "바자" + } + } + }, + "표시": { + "pipe": { + "pipe_kind": false + }, + "soil_guard": { + "side": false, + "tiers": false, + "lift_m": false, + "shift_m": false, + "thickness_top_m": false, + "thickness_bottom_m": false, + "back_len_cm": false, + "stone_kind": false, + "stone_supply": false, + "stone_coeff_basis": false, + "fill_concrete_mpa": false, + "face_slope_ratio": false, + "foundation": false, + "weep_hole_diameter_mm": false, + "weep_hole_area_m2": false + }, + "retaining_wall": { + "form": false, + "side": false, + "weep_hole_diameter_mm": false, + "weep_hole_area_m2": false + }, + "berm": { + "length_m": false, + "before_m": false, + "after_m": false, + "interval_m": false, + "slope_deg": false + } + }, + "그룹": { + "pipe": [ + { + "id": "grv6lny", + "이름": "관", + "옵션": [ + "pipe_kind", + "pipe_diameter_mm" + ], + "형상": {} + }, + { + "id": "gnbf9m9", + "이름": "관 보호공(유입)", + "옵션": [ + "inlet_type" + ], + "형상": {} + }, + { + "id": "g9lq9uk", + "이름": "관 보호공(유출)", + "옵션": [], + "형상": {} + } + ], + "evac_road": [ + { + "id": "gevacdetail", + "이름": "상세 내용", + "옵션": [ + "detail" + ], + "형상": {} + } + ], + "guardrail": [ + { + "id": "gguarddetail", + "이름": "상세 내용", + "옵션": [ + "form", + "length_m", + "before_m", + "after_m" + ], + "형상": {} + } + ], + "cross_underdrain": [ + { + "id": "gcrossud", + "이름": "맹암거", + "옵션": [ + "culvert_height_m", + "culvert_slope_pct" + ], + "형상": {} + } + ], + "spoil_bank": [ + { + "id": "gspoildetail", + "이름": "상세 내용", + "옵션": [ + "capacity_m3" + ], + "형상": {} + } + ], + "borrow_pit": [ + { + "id": "gborrowdetail", + "이름": "상세 내용", + "옵션": [ + "capacity_m3" + ], + "형상": {} + } + ], + "box_culvert": [ + { + "id": "gboxbody", + "이름": "본체", + "옵션": [ + "body_width_m", + "body_height_m" + ], + "형상": {} + }, + { + "id": "gboxin", + "이름": "유입 구조", + "옵션": [ + "wing_in" + ], + "형상": {} + }, + { + "id": "gboxout", + "이름": "유출 구조", + "옵션": [ + "wing_out" + ], + "형상": {} + } + ], + "ford_pavement": [ + { + "id": "gfordpaveslope", + "이름": "경사각도", + "옵션": [], + "형상": {} + } + ], + "retaining_wall": [ + { + "id": "gretaindetail", + "이름": "옹벽", + "옵션": [ + "wall_form", + "length_m", + "height_m", + "before_m", + "after_m" + ], + "형상": {} + } + ], + "masonry_wet": [ + { + "id": "gmasonwetdetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "thickness_top_m", + "thickness_bottom_m", + "back_len_cm", + "stone_kind", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "face_role", + "foundation", + "side", + "weep_hole_diameter_mm", + "weep_hole_area_m2", + "blinding_concrete" + ], + "형상": {} + } + ], + "masonry_dry": [ + { + "id": "gmasondrydetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "thickness_top_m", + "thickness_bottom_m", + "back_len_cm", + "stone_kind", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "face_role", + "foundation", + "side", + "weep_hole_diameter_mm", + "weep_hole_area_m2", + "blinding_concrete" + ], + "형상": {} + } + ], + "soil_guard": [ + { + "id": "gsoilgdetail", + "이름": "흙막이", + "옵션": [ + "form", + "length_m", + "height_m", + "before_m", + "after_m" + ], + "형상": {} + } + ], + "boulder_masonry": [ + { + "id": "gboulderdetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "stone_cm", + "bond", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "foundation", + "side", + "weep_hole_diameter_mm", + "weep_hole_area_m2", + "blinding_concrete" + ], + "형상": {} + } + ], + "berm": [ + { + "id": "gbermdetail", + "이름": "상세 내용", + "옵션": [ + "width_m", + "height_m" + ], + "형상": {} + } + ] + }, + "배치": {}, + "규칙": [] +} diff --git a/resources/tester/helper_52_structure_spec.cjs b/resources/tester/helper_52_structure_spec.cjs index e625ac36b..eda160d00 100644 --- a/resources/tester/helper_52_structure_spec.cjs +++ b/resources/tester/helper_52_structure_spec.cjs @@ -46,11 +46,14 @@ if (process.argv[2] === "road") { same: spec === road.PAVEMENT_SPEC, containers: spec.containers.map((c) => [c.id, c.title, c.required, c.link]), choices: road.PAVE_SPEC_CHOICES.map((c) => c.value), + items: spec.containers[0].items.map((i) => [i.id, i.label, i.kind]), state, edited: s.writeOptions(spec, edited), cleared: s.writeOptions(spec, cleared), keys: s.specKeys(spec), retired: spec.retired, + dropped: spec.dropped, + byRange: spec.byRange, }), ); process.exit(0); diff --git a/resources/tester/test_52_10_structure_spec_road.py b/resources/tester/test_52_10_structure_spec_road.py index 395211b1f..5d1424837 100644 --- a/resources/tester/test_52_10_structure_spec_road.py +++ b/resources/tester/test_52_10_structure_spec_road.py @@ -36,7 +36,15 @@ def test_명세_등록_필수_컨테이너_하나(out): def test_포장사양_선택지는_명세_한_곳(out): - assert out["choices"] == ["콘크리트"] + assert out["choices"] == ["콘크리트", "아스콘", "쇄석"] + + +def test_상세내용_차례_포장사양_길이_출력_높이(out): + assert out["items"] == [ + ["spec", "포장사양", "select"], + ["length", "길이 (m)", "auto"], + ["height", "높이 (m)", "number"], + ] def test_저장_키_왕복_옛_칸은_명세_밖(out): @@ -44,7 +52,10 @@ def test_저장_키_왕복_옛_칸은_명세_밖(out): assert out["edited"] == {"pave_spec": "콘크리트", "height_m": 0.35} assert out["cleared"] == {"pave_spec": "콘크리트"} assert out["keys"] == {"keys": ["pave_spec", "height_m"], "prefixes": []} - assert "length_m" not in out["retired"] # 구간 길이 · 전 · 후는 정의 칸 그대로 + # 길이 · 기준측점 전/후는 입력 없음 — 시작 · 종료만 받고 옛 값은 저장 때 뺌(87-1) + assert out["byRange"] is True + assert out["dropped"] == ["length_m", "before_m", "after_m"] + assert "length_m" not in out["retired"] assert set(out["retired"]) == { "thickness_cm", "width_m", @@ -62,4 +73,7 @@ def test_기타_상세_종류_이름_글_칸(): name = next(o for o in etc["options"] if o["key"] == "name") assert name["label"] == "상세 종류 이름" and name["input"] == "text" pave = next(t for t in doc["types"] if t["type_id"] == "pavement_concrete") - assert [o["key"] for o in pave["options"]][:3] == ["length_m", "before_m", "after_m"] + keys = [o["key"] for o in pave["options"]] + assert not {"length_m", "before_m", "after_m"} & set(keys) # 87-1 시작 · 종료 직접 입력 + height = next(o for o in pave["options"] if o["key"] == "height_m") + assert height["default"] == 0.2 From 6588db2cb213ee72a73f27736ab4fade7a37083f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:04:28 +0900 Subject: [PATCH 10/47] =?UTF-8?q?fix(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EC=A7=91=EA=B3=84=ED=91=9C=20=EA=B8=B8=EC=9D=B4=20=3D=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=ED=98=95=EC=9D=80=20=EC=8B=9C=EC=9E=91~?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=EB=A8=BC=EC=A0=80(87-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옛 length_m 이 앞서 범위를 고쳐도 집계 길이가 안 따라오던 흠 고침 · 점형은 입력 길이 그대로 - 시험: test_m02_table_fill 길이 우선순위 하나 더함 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- M02_MasterTemplete/M02_Table_Fill.py | 8 ++++---- resources/tester/test_m02_table_fill.py | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index 4d41ea46b..b0d13902e 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -194,11 +194,11 @@ def _label(key: str, value: Any) -> str: def _length(item: dict[str, Any]) -> float | None: - stated = _number(item["options"].get("length_m")) - if stated: - return stated + # 구간형은 시작~종료가 먼저(노면공 87-2) — 옛 length_m 은 범위를 고쳐도 안 따라옴 start, end = _number(item.get("start_m")), _number(item.get("end_m")) - return abs(end - start) if start is not None and end is not None else None + if start is not None and end is not None: + return abs(end - start) + return _number(item["options"].get("length_m")) or None def _source_value(source: dict[str, Any], item: dict[str, Any]) -> float | None: diff --git a/resources/tester/test_m02_table_fill.py b/resources/tester/test_m02_table_fill.py index df9aab5ae..e2ec9e675 100644 --- a/resources/tester/test_m02_table_fill.py +++ b/resources/tester/test_m02_table_fill.py @@ -216,3 +216,11 @@ def test_측점이_없어진_손_값은_맨_아래_비고_줄로(project: Path) assert last["값"] == {"sta": "NO.27+15", "h_intake": 1, "memo": "설계에서 측점 없어짐"} kept = fill.strip_design(doc) assert kept["줄"][-1]["값"]["h_intake"] == 1 # 다음 저장에도 남음 + + +def test_집계_길이는_구간형이면_시작_종료가_먼저(project: Path) -> None: + # 노면공 87-2 — 범위를 고쳐도 옛 length_m 이 안 앞섬 + moved = {"start_m": 200, "end_m": 245, "options": {"length_m": 30}} + assert fill._length(moved) == 45 + assert fill._length({"options": {"length_m": 12}}) == 12 # 점형 · 구간 없음 = 입력 길이 + assert fill._length({"options": {}}) is None From 33cda1200f175f4db2893a0fb20793d2e8e99142 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:04:30 +0900 Subject: [PATCH 11/47] =?UTF-8?q?chore(plan):=2091-1=20=EC=A0=9C=EC=95=BD?= =?UTF-8?q?=20=ED=8C=90=EC=A0=95=20=E2=80=94=20=EB=81=8A=EC=9D=8C=20?= =?UTF-8?q?=EB=84=B7=20=C2=B7=20=EB=82=A8=EA=B9=80=20=EB=91=98(=EB=B6=84?= =?UTF-8?q?=EC=84=9D=5F=EC=A0=95=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1008/분석_정함.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1008/분석_정함.md b/docs/raw/요구_1008/분석_정함.md index a8e8395cc..2fca9cb8a 100644 --- a/docs/raw/요구_1008/분석_정함.md +++ b/docs/raw/요구_1008/분석_정함.md @@ -56,6 +56,7 @@ - **유출 단 수(90-1)** — 관보호공 「추가 흙막이(단)」 스테퍼를 공용으로 세월교 · 박스 유출에 · 「+ 흙막이」 단추 없앰 · 단 수 → 상자 수(관보호공과 같은 방향) · 집계표도 단 수로 상자 자름 · 유출이 날개벽 · 없음인데 상자가 있던 자료 = 숨기고 집계에서 뺌(값은 파일에 남음). - **도수로 · 돌붙임(90-2)** — 두께 칸(cm · 기본 30 · 사용자 확정) · 유출 성토사면 선을 사면 안쪽으로 두께만큼 오프셋한 띠(윗면 = 사면선 · 근거 교본 「지반선보다 낮게 원지반에 설치」) · 유출 끝 ~ 성토사면이 원지반 만나는 곳(흙막이 단 사이 성토 조각 포함) · 안에 돌 무늬(흙막이 돌 그리기 재사용) · 도수로 · 돌붙임 형상 같음 · 배관 · 박스 · 세월교 · 물넘이포장(`ford_chute`) · 면적 · 터파기 영향 없음. - **제약 삭제(91)** — 형상 자체(벽 외곽)는 원지반 · 암 참조 없음(조사_G 1). 「해당」 = 세월교 바닥 · 흙막이 기본 높이(하상) · 다단 자동 자리(원지반에 앉는 x) · I형 집수정 하단. 더 = 설계선 끝 2 px 판정(줌마다 끝점 변동) · 전면 기울기 지형 자동판정. 91-1 에서 재현 목록 → 브레인 판정 → 91-3. 끝 연결선(성토부선 · 절토선이 원지반에서 끝남)은 형상 아님 → 남김. +- **91-1 판정(브레인 · 10-8)** — ① 세월교 바닥 = 끊음(91-2 손 칸) ② 다단 자동 자리 = 끊음(지형 무관 등간격 · 앞 단 바닥 + 1:1.2 사면 일정 길이) ③ I형 집수정 근입 = 남김(집수정 형상 규칙 · 흙막이 아님) ④ 설계선 끝 2 px 판정 = 끊음(수치 교점 0.01 m) ⑤ 전면 기울기 지형 자동판정 = 끊음(관보호공 · 세월교 · 박스 흙막이 = 성토부 표값 고정 · 사면안정 흙막이 · 옹벽 = 설치 측 칸 값 · B08 도 같은 함수) ⑥ 끝 연결선 = 남김(잔재 문구만 지움). - **세월교 바닥(91-2)** — 처음 놓을 때 하상값 · 손 조정 칸(박스 `riseM` 꼴) · 원지반이 바뀌어도 안 따라감 · 흙막이 기본 높이도 이 값 기준. - **복합경사(91-4)** — 공용 도우미 하나(`common_util_cross_berm.ts cutProfilePoints` · 경계 = 원지반 + `rock_boundary_offset_m` · 켬 = 암 프리셋 + `two_stage_slope` · 소단 = `design.berm`) · 쓰는 곳 = 흙막이 수평공간 뒤 · 박스 끝 · 세월교 측벽 끝 · ㄴ·ㄷ 집수정 끝 · 절토 쪽 배관 유출 벽 뒤(지금 절토사면이 사라짐) · 소단 셈은 구조물 끝에서 새로 · `cutLine` 두 점 → 점열(횡단 셋 · 3D). From f54def5b2b72e1949d54796e331c94bc26d97d33 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:05:09 +0900 Subject: [PATCH 12/47] =?UTF-8?q?fix(B06):=20=EC=84=B8=EC=9B=94=EA=B5=90?= =?UTF-8?q?=20=C2=B7=20=EB=B0=95=EC=8A=A4=20=EC=9C=A0=EC=9E=85=20=EC=88=98?= =?UTF-8?q?=ED=8F=89=EA=B3=B5=EA=B0=84=20=EC=8B=9C=EC=9E=91=20=3D=20?= =?UTF-8?q?=EB=B3=B8=EC=B2=B4=20=EB=B0=94=EB=8B=A5=20=EB=86=92=EC=9D=B4=20?= =?UTF-8?q?=ED=9D=99=EB=A7=89=EC=9D=B4=20=EC=A0=84=EB=A9=B4(89-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- B06_Section/B06_Section_UI_Cross_Body_Guard.ts | 17 +++++++++++++++++ .../B06_Section_UI_Cross_Culvert_Geom.ts | 3 ++- resources/tester/helper_79_guard_shape.cjs | 13 +++++++++++++ resources/tester/test_79_guard_shape.py | 6 ++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts index 640af0277..b3fdefb2b 100644 --- a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts +++ b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts @@ -271,3 +271,20 @@ export function withoutGuardedFill( label: { ...ford.label, text: `${ford.label.text} L${Math.ceil(lengthM - 1e-6)}m` }, } as T; } + +/** 세월교 · 박스 유입 흙막이 수평공간 시작 = 본체 바닥(세월교 바닥판 · 박스 저판 상면) 높이의 + * 흙막이 전면 자리(89-3). 본체가 이 측점에 없으면 null(배관 규칙). */ +export function guardFloorStart( + section: CrossSection, + base: BodyGuardBase | null, + wall: WallLayout, +): OffsetPoint | null { + const end = base?.ends.inlet; + if (!end) return null; + const slab = section.culvert?.body === "box" ? (section.box?.slab_thickness_m ?? 0) : 0; + const floor = end.invert + slab; + return { + offset: wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - floor), + elevation: floor, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 7c3b5bbd9..14c2cecde 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -25,6 +25,7 @@ import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall"; import { bodyGuardAxes, bodyGuardBase, + guardFloorStart, bodyGuardHeight, fordPipeFit, mergeGuardTrim, @@ -596,7 +597,7 @@ export function computeCulvertLayout( outletBottom: outletWall?.bottomBack.elevation ?? 0, basinStart: culvert.hidden_pipe ? inletWall - ? pipeCorners.inlet.bottom + ? (guardFloorStart(section, body, inletWall) ?? pipeCorners.inlet.bottom) : null : basinFillStart, basinBottom: culvert.hidden_pipe ? (inletWall?.bottomBack.elevation ?? 0) : basinFillBottom, diff --git a/resources/tester/helper_79_guard_shape.cjs b/resources/tester/helper_79_guard_shape.cjs index 69a30ad20..aacab3ac3 100644 --- a/resources/tester/helper_79_guard_shape.cjs +++ b/resources/tester/helper_79_guard_shape.cjs @@ -154,6 +154,19 @@ out.cutBerm2 = cutTails( sections.map((s) => (s === at(352.14) ? berm2 : s)), ); +// ⑤b 유입 수평공간 시작 높이 = 본체 바닥(세월교 바닥판 상면 · 박스 저판 상면 · 89-3) +out.inletFloor = [352.14, 173.087].map((chainage) => { + const section = at(chainage); + const layouts = computeStoredLayouts(section, sections); + const start = layouts.culvert.basinFill.segments[0]?.points[0]; + const inletLeft = (section.uphill_side ?? "left") === "left"; + const floor = layouts.ford + ? layouts.ford.sides.find((s) => s.role === "inlet").slabTopElevation + : layouts.box.sides.find((s) => (s.role === "left") === inletLeft).bottomElevation + + layouts.box.box.slab_thickness_m; + return { start: start ? r3(start.elevation) : null, floor: r3(floor) }; +}); + // ⑥ 관 보호공 기본 자리 = 제일 위(성토선 0 지점 · d 0) · 관 길이 m 단위 const pipe = clone(at(180)); pipe.culvert = { diff --git a/resources/tester/test_79_guard_shape.py b/resources/tester/test_79_guard_shape.py index f33cbcafe..f12a573dc 100644 --- a/resources/tester/test_79_guard_shape.py +++ b/resources/tester/test_79_guard_shape.py @@ -87,6 +87,12 @@ def test_흙막이_끝_절토는_수평공간_뒤(shape: dict) -> None: assert tail["berm"] == pytest.approx(2.0, abs=1e-3) +def test_유입_수평공간_시작은_본체_바닥(shape: dict) -> None: + """89-3 — 박스 저판 상면 · 세월교 바닥판 상면 높이에서 수평 → 절토.""" + for case in shape["inletFloor"]: + assert case["start"] == pytest.approx(case["floor"], abs=1e-3) + + def test_관_보호공_기본_자리는_제일_위(shape: dict) -> None: pipe = shape["pipe"] assert pipe is not None From 875ceec7f7fc84f38c6564d23212e5912ec57258 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:05:25 +0900 Subject: [PATCH 13/47] =?UTF-8?q?feat(M02):=20=EC=BC=9C=EC=8C=93=EA=B8=B0?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=EC=9C=A8=20=E2=80=94=20=EA=B3=A8=EC=8C=93?= =?UTF-8?q?=EA=B8=B0=20=EA=B8=B0=EC=A4=80=20=EB=A1=9C=EC=A7=81=20=EC=84=B8?= =?UTF-8?q?=EB=B6=80=ED=95=AD=EB=AA=A9=20=EC=88=98=EB=9F=89=20=C3=97=20?= =?UTF-8?q?=EC=9C=A8=20=C2=B7=20=EB=AC=B8=EC=84=9C=20=C2=B7=20=EC=82=AC?= =?UTF-8?q?=EB=B3=B8=20=C2=B7=20=EC=84=9C=EA=B3=A0=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=C2=B7=20=EC=98=B5=EC=85=98=20=EC=B9=B8=20?= =?UTF-8?q?=C2=B7=20=EC=A4=84=20=EC=95=84=EB=9E=98=20=EA=B8=80(85-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- M02_MasterTemplete/M02_Item_Prices.py | 9 +- M02_MasterTemplete/M02_Labor_Doc.py | 51 ++++++- M02_MasterTemplete/M02_Labor_Inputs.py | 15 +- M02_MasterTemplete/M02_Labor_Ref.py | 8 +- .../M02_MasterTemplete_Api_Fetch.ts | 2 + .../M02_MasterTemplete_Labor_Api.ts | 3 + .../M02_MasterTemplete_Labor_Detail.ts | 9 +- .../M02_MasterTemplete_Labor_Input.ts | 50 ++++++- .../M02_MasterTemplete_Labor_RefEdit.ts | 3 + .../M02_MasterTemplete_Labor_Rows.ts | 4 +- .../M02_MasterTemplete_Labor_Text.ts | 6 + resources/tester/test_m02_labor_layer.py | 132 ++++++++++++++++++ 12 files changed, 277 insertions(+), 15 deletions(-) create mode 100644 resources/tester/test_m02_labor_layer.py diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py index 56ad2f407..6e4c49b2f 100644 --- a/M02_MasterTemplete/M02_Item_Prices.py +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -280,8 +280,15 @@ def calc( snapshot = load_prices(prices) gaps = missing_inputs(snapshot, labor, values) blocked = {gap["로직"] for gap in gaps} + from M02_MasterTemplete import M02_Labor_Doc as labor_doc + sums = dict.fromkeys(mf.COST_ITEMS, Decimal(0)) lines, stuck = [], [] + rates = { # 켜쌓기율(85-2) — 문서 계산과 같은 `layered` + one.get("로직"): one.get("켜쌓기율") + for one in (labor.get("줄") or {}).get("담은로직") or [] + if isinstance(one, dict) + } for at, key in enumerate(_logic_keys(labor), start=1): head: dict[str, Any] = {"차례": at, "로직": key} if key in blocked: @@ -292,7 +299,7 @@ def calc( given = {name: one.get("값") for name, one in (values.get(key) or {}).items()} try: with mf.mp.choosing((picks or {}).get(key)): - got = mf.run(snapshot, key, given) + got = labor_doc.layered(mf.run(snapshot, key, given), rates.get(key)) except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as error: lines.append({**head, "ok": False, "까닭": str(error) or type(error).__name__}) stuck.append(key) diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index d89f26d66..9640e799d 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -1,6 +1,6 @@ """M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장). -문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량} +문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 별칭, 메모, 입력값, 수량, 켜쌓기율?} | {참조: {층, 주인, 키}, 별칭, 메모, 수량}], 자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠). 수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}` @@ -8,6 +8,8 @@ 금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은 반영 없음 · 자재 기준 단가 = `master_material.element` 기본값 · 자재 줄에 값 열 다섯(`가격`) · 기준단가가 된 칸 이름(`기준칸`)도 실음(43-3). +켜쌓기율(85-2) = 표 주문 「골쌓기 기준」 로직만 · % · 없음 = 100 · 그 로직 세부항목 줄 전부 수량 × 율 +(`layered` · `run_line` 한 곳 → 문서 · 프로젝트 사본 · 서고 같은 함수). 참조 줄(단가승격 · 사전조합 · 50-3) = 기준이 가리킨 문서 1 단위 값(펼침 `M02_Labor_Ref.Expand`) · 분모 = 그 문서 단위 · 배 1 · 알약 키 = `ref_id` · 같은 참조 두 번 금지 · 위 층만. 짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` — @@ -22,6 +24,7 @@ from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -105,6 +108,7 @@ def clean(doc: Any) -> dict[str, Any]: if not isinstance(given, dict): raise _bad(f"{where} · 입력값이 묶음 아님") memo, alias = one.get("메모"), _alias(one.get("별칭"), where) + rate = _rate(one.get("켜쌓기율"), code, where) lines.append( { "로직": code, @@ -112,6 +116,7 @@ def clean(doc: Any) -> dict[str, Any]: "메모": str(memo) if memo else None, "입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")}, "수량": _qty(one.get("수량"), where), + **({"켜쌓기율": rate} if rate is not None else {}), } ) mats, codes = [], set() @@ -157,6 +162,42 @@ def _alias(alias: Any, where: str) -> str | None: return alias or None +def _rate(value: Any, code: str, where: str) -> int | float | None: + """켜쌓기율(85-2) — 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖 로직은 None(안 둠).""" + if value is None or value == "": + return None + rate = _number(value) + if rate is None or not Decimal(0) < rate <= Decimal(200): + raise _bad(f"{where} · 켜쌓기율은 0 초과 200 이하 %") + if rate != rate.quantize(Decimal("0.1")): + raise _bad(f"{where} · 켜쌓기율은 소수 한 자리까지") + if rate == 100 or code not in labor_inputs.layered(): + return None + return int(rate) if rate == rate.to_integral_value() else float(rate) + + +def layered(got: dict[str, Any], rate: Any) -> dict[str, Any]: + """엔진 답 × 켜쌓기율(85-2) — 세부항목 줄 전부 수량 · 금액 · 비목 · 비목 합 · 계 · 단가는 그대로.""" + if rate in (None, 100) or "결과" in got: + return got + times = Decimal(str(rate)) / 100 + + def mul(value: Any) -> Any: + return value * times if isinstance(value, Decimal) else value + + lines = [ + { + **x, + "수량": mul(x.get("수량")), + "금액": mul(x.get("금액")), + "비목": {k: mul(v) for k, v in (x.get("비목") or {}).items()}, + } + for x in got.get("줄") or [] + ] + sums = {k: mul(got[k]) for k in COSTS} + return {**got, "줄": lines, **sums, "계": sum(sums.values())} + + def from_combo(key: str) -> dict[str, Any]: """옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 — 담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키). @@ -331,7 +372,7 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: "분모": pill.get("단위"), "배": pill.get("배") or 1, } - lines.append({**line, **run_line(whole, code, one.get("입력값"))}) + lines.append({**line, **run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"))}) mats = [ { "코드": one["코드"], @@ -344,13 +385,13 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: return settle(_json({"줄": lines, "자재": mats}), doc, have) -def run_line(whole: Any, code: str, given: Any) -> dict[str, Any]: - """로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값) — `{기준, 까닭}` · +def run_line(whole: Any, code: str, given: Any, rate: Any = None) -> dict[str, Any]: + """로직 한 줄 기준(서버 단독 · M01 엔진 · 빈 입력 = 자동값 · × 켜쌓기율) — `{기준, 까닭}` · 돈 아닌 로직은 `{결과, 기준: None}`(반영 없음).""" try: logic = whole.logic(code) auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None} - got = mf.run(whole, code, {**auto, **m01._dec(given or {})}) + got = layered(mf.run(whole, code, {**auto, **m01._dec(given or {})}), rate) except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: return {"기준": None, "까닭": str(e) or type(e).__name__} if "결과" in got: diff --git a/M02_MasterTemplete/M02_Labor_Inputs.py b/M02_MasterTemplete/M02_Labor_Inputs.py index fd18f0489..c782955ac 100644 --- a/M02_MasterTemplete/M02_Labor_Inputs.py +++ b/M02_MasterTemplete/M02_Labor_Inputs.py @@ -15,6 +15,7 @@ from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store _SPEC = ("이름", "설명", "단위", "고르기", "범위") +_LAYERED = "골쌓기 기준" # 85-2 — 표 주문에 이 글이 있으면 켜쌓기율 대상 로직 def _num(value: Any) -> Any: @@ -40,6 +41,16 @@ def _logics(codes: list[str]) -> tuple[list[dict], list[str]]: return found, missing +def layered() -> set[str]: + """켜쌓기율(85-2) 대상 로직 키 — 표 `주` 에 「골쌓기 기준」 · 그 표 `용도.로직키`(자동 판정).""" + out: set[str] = set() + for data in m01.loaded()[0].values(): + for row in data.get("표") or []: + if any(_LAYERED in str(note) for note in row.get("주") or []): + out.update((row.get("용도") or {}).get("로직키") or []) + return out + + def _labels(whole: Any, spec: dict) -> dict[str, str]: """고르기 원문번호 → 보이는 글(85-1) — 기계 「이름 규격」 · 직종(입력 이름에 「직종」) 「이름」 · 모르는 번호는 뺌.""" table = "LB" if "직종" in str(spec.get("이름") or "") else "EQ" @@ -53,8 +64,9 @@ def _labels(whole: Any, spec: dict) -> dict[str, str]: def _listed(logics: list[dict]) -> list[dict[str, Any]]: - """로직 줄마다 `{로직, 이름, 결과단위, 출처, 입력: [{이름, 설명, 단위, 고르기?, 고르기이름?, 범위?, 자동값}]}`.""" + """로직 줄마다 `{로직, 이름, 결과단위, 출처, 켜쌓기, 입력: [{이름, 설명, 단위, 고르기?, 고르기이름?, 범위?, 자동값}]}`.""" whole = m01.loaded()[1] + layers = layered() out = [] for logic in logics: auto = m01.mau.values(whole, logic) @@ -73,6 +85,7 @@ def _listed(logics: list[dict]) -> list[dict[str, Any]]: "이름": logic.get("이름"), "결과단위": logic.get("결과단위"), "출처": logic.get("출처"), + "켜쌓기": logic.get("키") in layers, "입력": specs, } ) diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index a1c43145e..7ccecac25 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -152,7 +152,7 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]: 이름=name or pill.get("이름") or line["로직"], 단위=unit or pill.get("단위") or None, 배=pill.get("배") or 1, # ponytail: 저장 때 적음 · 마스터 결과단위가 바뀌면 다시 저장 - 담은로직=[{"로직": line["로직"], "입력값": line["입력값"]}], + 담은로직=[{k: line[k] for k in ("로직", "입력값", "켜쌓기율") if k in line}], **origin(line["로직"]), ) else: @@ -200,6 +200,7 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: head = {"참조": one["참조"]} if "참조" in one else {"로직": one["로직"]} if "로직" in one: head["입력값"] = one["입력값"] + head.update({k: one[k] for k in ("켜쌓기율",) if k in one}) lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]}) at_ = here() me = {"층": at_[0], "주인": at_[1], "키": key} @@ -368,7 +369,8 @@ class Expand: from M02_MasterTemplete import M02_Labor_Doc as labor line = (doc.get("담은로직") or [{}])[0] - got = labor.run_line(self.whole, str(line.get("로직") or ""), line.get("입력값")) + code = str(line.get("로직") or "") + got = labor.run_line(self.whole, code, line.get("입력값"), line.get("켜쌓기율")) times = labor._number(doc.get("배")) or Decimal(1) base = got.get("기준") why = got.get("까닭") or (None if base else "돈 아닌 로직") @@ -395,7 +397,7 @@ class Expand: from M02_MasterTemplete import M02_Labor_Doc as labor pill = pill or {} - got = labor.run_line(self.whole, one["로직"], one.get("입력값")) + got = labor.run_line(self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율")) times = labor._number(pill.get("배")) or Decimal(1) base = got.get("기준") return { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index c6e60c267..3016f98cc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -331,6 +331,8 @@ export interface ItemLogic { 결과단위?: string; /** 품셈 출처 글(47-4 · 원문 패널) */ 출처?: string | null; + /** 켜쌓기율 대상(85-2 · 표 주문 「골쌓기 기준」) */ + 켜쌓기?: boolean; 입력: ItemInput[]; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index c896cb70a..67b1d3931 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -129,6 +129,8 @@ export interface LaborRow { 메모: string | null; 입력값: Record; 수량: LaborQty; + /** 켜쌓기 적용율 %(85-2 · 대상 로직만 · 없음 = 100) */ + 켜쌓기율?: number | null; } export interface LaborMat { 코드: string; @@ -164,6 +166,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc { 입력값: r.입력값 ?? {}, 수량: r.수량 ?? null, ...(r.참조 ? { 참조: r.참조 } : {}), + ...(r.켜쌓기율 != null ? { 켜쌓기율: r.켜쌓기율 } : {}), })), 자재: (d.자재 ?? []).map((m) => ({ 코드: m.코드, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 236f211c6..1b4bb5333 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -347,13 +347,18 @@ export function mountLaborDetail( logic, values: row.입력값, memo: row.메모, + rate: row.켜쌓기율 ?? null, readOnly: !edit, done: (next) => { const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; - const same = JSON.stringify(values) === JSON.stringify(row.입력값); + const same = + JSON.stringify(values) === JSON.stringify(row.입력값) && + next.켜쌓기율 === (row.켜쌓기율 ?? null); row.입력값 = values; row.메모 = next.메모; - changed(same); // 입력값이 그대로면(메모만) 서버 안 부름 + if (next.켜쌓기율 === null) delete row.켜쌓기율; + else row.켜쌓기율 = next.켜쌓기율; + changed(same); // 입력값 · 켜쌓기율이 그대로면(메모만) 서버 안 부름 }, }); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts index f60fe5c0d..bdeabdc1a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts @@ -39,6 +39,17 @@ function footer( export interface LaborInputResult { 입력값: LaborValues; 메모: string | null; + /** 켜쌓기 적용율 %(85-2 · 대상 로직만 · 빈 칸 · 100 = null) */ + 켜쌓기율: number | null; +} + +/** 켜쌓기율 칸 글 → 값 — 빈 글 · 100 = null · 0 초과 200 이하 · 소수 한 자리 밖 = NaN */ +export function layerRate(raw: string): number | null { + if (raw.trim() === "") return null; + const v = Number(raw); + const ok = + Number.isFinite(v) && v > 0 && v <= 200 && Math.abs(v * 10 - Math.round(v * 10)) < 1e-9; + return !ok ? NaN : v === 100 ? null : v; } export interface LaborInputOptions { @@ -48,6 +59,8 @@ export interface LaborInputOptions { logic: ItemLogic | null; values: LaborValues; memo: string | null; + /** 켜쌓기율(85-2) — 로직이 대상일 때만 칸 */ + rate?: number | null; readOnly: boolean; done: (next: LaborInputResult) => void; /** 머리 한 줄 알림(51-14 · 서고 원본 고침) */ @@ -100,9 +113,27 @@ export function openLaborInput(opt: LaborInputOptions): void { }) as HTMLInputElement; memo.value = opt.memo ?? ""; memo.readOnly = opt.readOnly; + const rate = opt.logic?.켜쌓기 + ? (el("input", { + className: "ui-input m02-inputval__control", + attrs: { + type: "number", + min: "0.1", + max: "200", + step: "0.1", + placeholder: "100", + "data-labor-layer": "", + }, + }) as HTMLInputElement) + : null; + if (rate) { + rate.value = opt.rate == null ? "" : String(opt.rate); + rate.readOnly = opt.readOnly; + } const ok = (): void => { - if (bad.size) return showToast(tc("In_Bad"), "error"); - opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null }); + const layer = rate ? layerRate(rate.value) : null; + if (bad.size || Number.isNaN(layer)) return showToast(tc("In_Bad"), "error"); + opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null, 켜쌓기율: layer }); close(); }; body.append( @@ -129,6 +160,21 @@ export function openLaborInput(opt: LaborInputOptions): void { ...(rows.length ? rows : [el("p", { className: "m02-labin__muted", text: tc("In_None") })]), + ...(rate + ? [ + el("div", { + className: "m02-inputval__row", + children: [ + el("span", { + className: "m02-inputval__name", + text: tc("In_Layer"), + attrs: { title: tc("In_LayerHint") }, + }), + rate, + ], + }), + ] + : []), el("div", { className: "m02-inputval__row", children: [ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts index 4ecb9e78f..f94d73223 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts @@ -56,12 +56,15 @@ export async function openRefEdit(opt: RefEditOptions): Promise { logic, values: row.입력값, memo: row.메모, + rate: row.켜쌓기율 ?? null, readOnly: opt.readOnly, notice, okLabel: tc("Save"), done: (next) => { row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; row.메모 = next.메모; + if (next.켜쌓기율 === null) delete row.켜쌓기율; + else row.켜쌓기율 = next.켜쌓기율; void save(doc) .catch((error) => fail(error, "Lib_SaveFailed")) .finally(opt.closed); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index bcdd9122b..4a9d61940 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -307,7 +307,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement // 참조 줄 — 키 · 이름 · 단위 = 가리킨 문서 것(calc 답) · 넣을 값 없음(승격 때 고정) const unit = row.참조 ? (line?.분모 ?? "") : (logic?.결과단위 ?? ""); const values = valueLine(logic?.입력 ?? [], row.입력값); - const note = values.text; + const layer = + row.켜쌓기율 != null ? tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)) : ""; + const note = [values.text, layer].filter(Boolean).join(" · "); const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null); const small = ( label: string, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 64eebefb1..28d5b4286 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -112,6 +112,12 @@ const TEXT = { In_Title: ["넣을 값 — {value}", "Inputs — {value}"], In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"], In_Memo: ["메모", "Memo"], + In_Layer: ["켜쌓기 적용율 (%)", "Layered-course rate (%)"], + In_LayerHint: [ + "0 초과 200 이하 · 소수 한 자리 · 빈 칸 = 100", + "0 < rate ≤ 200 · 1 decimal · empty = 100", + ], + Labor_Layer: ["켜쌓기 {n}%", "Layered {n}%"], In_QtyCell: ["면적산출 칸", "Area sheet cell"], In_QtyEmpty: ["빈 칸", "empty cell"], In_QtyHand: ["손 입력", "Manual"], diff --git a/resources/tester/test_m02_labor_layer.py b/resources/tester/test_m02_labor_layer.py new file mode 100644 index 000000000..dbe9a852e --- /dev/null +++ b/resources/tester/test_m02_labor_layer.py @@ -0,0 +1,132 @@ +"""켜쌓기율(85-2) — 표 주문 「골쌓기 기준」 로직만 · 세부항목 줄 전부 수량 × 율 · 문서 · 사본 같은 함수. + +못박는 것 + - 대상 = 표 주문 자동 판정(지금 GC000260 · GC000261) · 입력 목록에 「켜쌓기」 표시. + - GC000260 90% = 100% × 0.9(비목 · 계 · 줄 수량 · 금액) · 단가는 그대로. + - clean = 0 초과 200 이하 · 소수 한 자리 · 100 · 대상 밖은 안 둠. + - 프로젝트 사본 계산(`M02_Item_Prices.calc`)도 같은 율 · 같은 값. + - 서고(단가승격)도 율을 담고 같은 값. +""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Item_Prices as prices +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +CODE = "GC000260" + + +def _d(value) -> Decimal: + return Decimal(str(value)) + + +def test_대상은_골쌓기_기준_주문_로직() -> None: + assert labor_inputs.layered() == {"GC000260", "GC000261"} + got = labor_inputs.logic_inputs([CODE, "GF000219"])["로직"] + assert [x["켜쌓기"] for x in got] == [True, False] + + +def test_90퍼센트는_100퍼센트_곱하기_0점9() -> None: + whole = m01.loaded()[1] + full = labor.run_line(whole, CODE, {}) + part = labor.run_line(whole, CODE, {}, 90) + for k in (*labor.COSTS, "계"): + assert part["기준"][k] == pytest.approx(full["기준"][k] * Decimal("0.9")) + for a, b in zip(full["줄"], part["줄"]): + assert b["단가"] == a["단가"] + assert b["수량"] == pytest.approx(a["수량"] * Decimal("0.9")) + assert b["금액"] == pytest.approx(a["금액"] * Decimal("0.9")) + for k, v in a["비목"].items(): + assert b["비목"][k] == pytest.approx(v * Decimal("0.9")) + assert labor.run_line(whole, CODE, {}, 100) == full + + +@pytest.mark.parametrize("bad", [0, -5, 200.1, 90.55, "abc"]) +def test_율_범위_밖은_400(bad) -> None: + with pytest.raises(store.StoreError): + labor.clean({"담은로직": [{"로직": CODE, "켜쌓기율": bad}]}) + + +def test_율_정리() -> None: + def rate(code: str, value): + return labor.clean({"담은로직": [{"로직": code, "켜쌓기율": value}]})["담은로직"][0].get( + "켜쌓기율" + ) + + assert rate(CODE, "90") == 90 + assert rate(CODE, 87.5) == 87.5 + assert rate(CODE, 200) == 200 + assert rate(CODE, 100) is None # 100 = 없음 + assert rate(CODE, None) is None + assert rate("GF000219", 90) is None # 대상 밖은 안 둠 + + +def test_문서_계산과_프로젝트_사본_같음() -> None: + doc = labor.clean({"단위": "㎡", "담은로직": [{"로직": CODE, "켜쌓기율": 90}]}) + whole = m01.loaded()[1] + auto = m01.mau.values(whole, whole.logic(CODE)) + values = {CODE: {k: {"값": labor._json(v), "확인": True} for k, v in auto.items()}} + copy = {"줄": doc, "알약": labor.pills(doc)} + got = prices.calc(copy, prices.to_json(prices.collect([CODE])), values, {}) + origin = labor.calc(doc, {}) + (line,) = got["줄"] + assert line["계"] == pytest.approx(origin["줄"][0]["기준"]["계"]) + full = labor.calc({**doc, "담은로직": [{**doc["담은로직"][0], "켜쌓기율": None}]}, {}) + assert _d(line["계"]) == pytest.approx(_d(full["줄"][0]["기준"]["계"]) * Decimal("0.9")) + + +def test_서고_단가승격도_율() -> None: + doc = refs.clean_doc( + "promote", {"키": "SP-900001", "담은로직": [{"로직": CODE, "켜쌓기율": 90}]} + ) + assert doc["담은로직"][0]["켜쌓기율"] == 90 + whole = m01.loaded()[1] + expand = refs.Expand(whole, lambda ref: doc) + got = expand({"층": "system", "주인": None, "키": "SP-900001"}) + full = labor.run_line(whole, CODE, {}) + times = _d(doc["배"]) + assert got["기준"]["계"] == pytest.approx(full["기준"]["계"] * Decimal("0.9") / times) + + +RATE_SCRIPT = r""" +const fs = require("fs"); +const path = require("path"); +const root = process.argv[1]; +const ts = require(path.join(root, "config", "node_modules", "typescript")); +const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : () => undefined) }); +const f = path.join(root, "M02_MasterTemplete", "M02_MasterTemplete_Labor_Input.ts"); +const js = ts.transpileModule(fs.readFileSync(f, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => stub); +const m = box.exports; +console.log(JSON.stringify(["", "90", "87.5", "100", "200", "0", "200.1", "90.55", "x"].map( + (v) => { const r = m.layerRate(v); return Number.isNaN(r) ? "bad" : r; }))); +""" + + +def test_화면_율_칸_검사() -> None: + """옵션 모달 켜쌓기율 칸 — 서버 `_rate` 와 같은 범위(빈 칸 · 100 = 없음).""" + import json + import shutil + import subprocess + from pathlib import Path + + node = shutil.which("node") + if not node: + pytest.skip("node 없음") + root = Path(__file__).resolve().parents[2] + out = subprocess.run( + [node, "-e", RATE_SCRIPT, str(root)], capture_output=True, text=True, encoding="utf-8" + ) + assert out.returncode == 0, out.stderr + assert json.loads(out.stdout) == [None, 90, 87.5, None, 200, "bad", "bad", "bad", "bad"] From cd8a2263234e1762e7572c5c70337007cc724052 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:06:22 +0900 Subject: [PATCH 14/47] =?UTF-8?q?feat(M02):=20=EC=98=AE=EA=B8=B0=EA=B8=B0?= =?UTF-8?q?=20=EA=B0=99=EC=9D=80=20=EC=9D=B4=EB=A6=84=20=3D=20=E3=80=8C?= =?UTF-8?q?=EC=9D=B4=EB=A6=84(1)=E3=80=8D=20=E2=80=94=20=ED=95=AD=EB=AA=A9?= =?UTF-8?q?=20=C2=B7=20=ED=8F=B4=EB=8D=94=20=C2=B7=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EA=B0=9C=20=C2=B7=20=EB=B6=99=EC=97=AC=EB=84=A3=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=B3=B5=EC=82=AC=20=EC=83=9D=EC=84=B1=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=8C(86-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- M02_MasterTemplete/M02_Item_Paste.py | 25 ++++++------- M02_MasterTemplete/M02_Item_Personal.py | 7 +--- .../M02_MasterTemplete_Api_Fetch.ts | 2 + .../M02_MasterTemplete_Router_Items.py | 5 ++- M02_MasterTemplete/M02_MasterTemplete_Tree.py | 26 ++++++++++--- .../M02_MasterTemplete_TreePanel.ts | 8 +++- resources/tester/test_m02_tree.py | 37 ++++++++++++++++++- resources/tester/test_m02_tree_paste.py | 21 +++++++++-- ui_template/ui_template_locale_m2.ts | 4 ++ 9 files changed, 103 insertions(+), 32 deletions(-) diff --git a/M02_MasterTemplete/M02_Item_Paste.py b/M02_MasterTemplete/M02_Item_Paste.py index 453dce6fb..e906dffb6 100644 --- a/M02_MasterTemplete/M02_Item_Paste.py +++ b/M02_MasterTemplete/M02_Item_Paste.py @@ -1,7 +1,7 @@ """여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 구조물 라이브러리 나무의 [복사] → [붙여넣기]. 붙일 폴더(`dest`) 아래 새 항목 — 대상 층 새 키(SC · SP) · 열 `본뜬` = 원본(층 · 주인 · 키) · -같은 이름은 「이름 (2)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더 (2)」). +같은 이름은 「이름(1)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더(1)」). 파일(도면 · 면적 · 입력 연결)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫. """ @@ -40,13 +40,6 @@ def _items(doc: dict[str, Any]) -> list[dict[str, Any]]: return [c for c in doc.get("열") or [] if tree.is_item(c)] -def _unique(path: list[str], taken: list[list[str]]) -> list[str]: - n, out = 2, path - while out in taken: - out, n = [*path[:-1], f"{path[-1]} ({n})"], n + 1 - return out - - def paste( source: Path, target: Path, @@ -67,6 +60,7 @@ def paste( taken_folders = tree.folders_of(dst) plan: list[tuple[str, list[str]]] = [] # (원본 키, 새 경로) new_folders: list[list[str]] = [] + renamed: list[str] = [] # 겹쳐서 번호 붙은 새 이름 def busy(path: list[str]) -> bool: paths = [*taken, *(p for _, p in plan)] @@ -78,16 +72,18 @@ def paste( found = next((p for p, k in src_items if k == key), None) if found is None: raise store.StoreError(404, f"없는 항목 「{key}」") - plan.append((key, _unique([*dest, found[-1]], [*taken, *(p for _, p in plan)]))) + used = [*taken, *(p for _, p in plan)] + path = tree.unique_path([*dest, found[-1]], lambda p: p in used) + renamed += [path[-1]] if path[-1] != found[-1] else [] + plan.append((key, path)) continue folder = list(one.get("경로") or []) if not folder or ( folder not in src_folders and not any(_under(p, folder) for p, _ in src_items) ): raise store.StoreError(404, f"없는 폴더 「{' '.join(folder)}」") - top, n = [*dest, folder[-1]], 2 - while busy(top): - top, n = [*dest, f"{folder[-1]} ({n})"], n + 1 + top = tree.unique_path([*dest, folder[-1]], busy) + renamed += [top[-1]] if top[-1] != folder[-1] else [] new_folders.append(top) new_folders += [[*top, *f[len(folder) :]] for f in src_folders if _under(f, folder)] plan += [(k, [*top, *p[len(folder) :]]) for p, k in src_items if _under(p, folder) and k] @@ -102,4 +98,7 @@ def paste( doc = got["문서"] doc["폴더"] = [*(doc.get("폴더") or []), *new_folders] store.write("table", store.STRUCTURE_TABLE, got["판"], doc) - return {"op": "paste", "경로": dest, "열": made} + note: dict[str, Any] = {"op": "paste", "경로": dest, "열": made} + if renamed: + note["바뀐이름"] = renamed + return note diff --git a/M02_MasterTemplete/M02_Item_Personal.py b/M02_MasterTemplete/M02_Item_Personal.py index 2ffd79765..b98cc2f5c 100644 --- a/M02_MasterTemplete/M02_Item_Personal.py +++ b/M02_MasterTemplete/M02_Item_Personal.py @@ -34,13 +34,10 @@ def heads_of(path: list[str], name: str | None) -> list[str]: def _free_path(doc: dict[str, Any], full: list[str]) -> list[str]: - """같은 경로 항목이 이미 있으면 마지막 이름을 「이름 (2)」 로 + """같은 경로 항목이 이미 있으면 마지막 이름을 「이름(1)」 로 (나무는 같은 경로 두 항목을 안 둠).""" taken = [tree.path_of(c) for c in doc.get("열", []) if tree.is_item(c)] - out, n = list(full), 2 - while out in taken: - out, n = [*full[:-1], f"{full[-1]} ({n})"], n + 1 - return out + return tree.unique_path(list(full), lambda p: p in taken) def create_item( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index a76619d33..30ec77179 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -354,6 +354,8 @@ export interface TreeNote { /** 새 · 고친 항목 열 — 붙여넣기는 새 열 목록(PLAN 30-2) */ 열?: string | string[]; 지운항목?: string[]; + /** 같은 이름이 있어 「이름(1)」 꼴로 바뀐 새 이름 — 옮기기 · 붙여넣기(PLAN 86-2) */ + 바뀐이름?: string[]; } export interface TreeView { 판: string; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index d92c53a72..da280d8c1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -235,11 +235,12 @@ def _edit_tree(folder: Path, body: dict[str, Any]) -> dict[str, Any]: else: # 여러 개 — 차례로 고쳐 저장은 한 번(하나라도 막히면 아무것도 안 씀) if body.get("op") not in ("move", "delete") or not targets: raise tree.TreeError(422, "여러 개는 옮기기 · 지우기만 — 「대상」 이 필요함") - doc, note = got["문서"], {"op": body["op"], "지운항목": []} + doc, note = got["문서"], {"op": body["op"], "지운항목": [], "바뀐이름": []} for one in paste_store.outer(targets): step = {**body, "경로": one.get("경로"), "키": one.get("키")} doc, done = tree.apply(doc, step, ids) note["지운항목"] += done.get("지운항목", []) + note["바뀐이름"] += done.get("바뀐이름", []) except tree.TreeError as e: raise store.StoreError(e.status, e.detail) from e store.write("table", store.STRUCTURE_TABLE, got["판"], doc) @@ -267,7 +268,7 @@ def post_paste( session: Session = Depends(verify_session), ): """여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 복사한 층은 읽기 권한 · 이 층은 쓰기 권한 · - 새 키(SC · SP) · 본뜬 = 원본 · 같은 이름 「이름 (2)」 · 폴더는 안의 것까지 → 새 나무 + 알림.""" + 새 키(SC · SP) · 본뜬 = 원본 · 같은 이름 「이름(1)」 · 폴더는 안의 것까지 → 새 나무 + 알림.""" target = _root(session, layer, owner, write=True) source = _root(session, body.from_layer, body.from_owner) who = access.grant(session, body.from_layer, body.from_owner).owner diff --git a/M02_MasterTemplete/M02_MasterTemplete_Tree.py b/M02_MasterTemplete/M02_MasterTemplete_Tree.py index 7e3d0369e..22d6c85dd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Tree.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Tree.py @@ -14,6 +14,7 @@ from __future__ import annotations import copy import re +from collections.abc import Callable from typing import Any MIN_LEVELS = 4 # 옛 약속 — 종류 · 분류1 · 분류2 · 분류3 @@ -173,6 +174,14 @@ def _taken_item(doc: dict[str, Any], path: list[str], skip=None) -> None: raise TreeError(409, f"이미 있는 항목 「{name_of(path)}」") +def unique_path(path: list[str], busy: Callable[[list[str]], bool]) -> list[str]: + """겹치는 경로면 마지막 이름을 「이름(1)」 「이름(2)」… 로 — 옮기기 · 붙여넣기 · 복사 생성이 한 벌을 씀.""" + out, n = path, 1 + while busy(out): + out, n = [*path[:-1], f"{path[-1]}({n})"], n + 1 + return out + + def _set_path(col: dict[str, Any], path: list[str]) -> None: col["경로"] = path col["머리"] = list(path) # 머리 줄 수는 저장 맞춤(`normalize_table`)이 채움 @@ -272,21 +281,28 @@ def apply( to = _path(body.get("새경로"), "새경로") if key: col = _find(out, key, ids) - moved = [*to, path_of(col)[-1]] - _taken_item(out, moved, [col]) + want = [*to, path_of(col)[-1]] + taken = _item_paths(out, [col]) + moved = unique_path(want, lambda p: p in taken) # 같은 폴더 같은 이름 = 「이름(1)」 out["열"] = [c for c in out["열"] if c is not col] _set_path(col, moved) _place(out, col) note["경로"] = moved + if moved != want: + note["바뀐이름"] = [moved[-1]] else: _folder(out, where, "옮길") if _under(to, where): raise TreeError(422, "폴더를 제 안으로 옮길 수 없음") - moved = [*to, where[-1]] - if moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out)): - raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」") + folders_now, paths_now = folders_of(out), _item_paths(out) + moved = unique_path( + [*to, where[-1]], + lambda p: p != where and (p in folders_now or any(_under(x, p) for x in paths_now)), + ) _move_folder(out, where, moved, replace=True) note["경로"] = moved + if moved[-1] != where[-1]: + note["바뀐이름"] = [moved[-1]] else: # delete if key: gone = [_find(out, key, ids)] diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index f8c2ed41f..942642abd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -425,7 +425,13 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { } picked = new Set(); paint(); - showToast(L("M02_TreeDone"), "success"); + const renamed = note?.바뀐이름 ?? []; + showToast( + renamed.length + ? L("M02_TreeRenamed").replace("{value}", renamed.join("」 · 「")) + : L("M02_TreeDone"), + "success", + ); await opt.onChanged(col); } catch (error) { // 409 = 판 다름(서버 detail 이 객체 → 「HTTP 409」) 또는 같은 이름(글 까닭 — 그대로 보임) diff --git a/resources/tester/test_m02_tree.py b/resources/tester/test_m02_tree.py index af43e062d..306d82db7 100644 --- a/resources/tester/test_m02_tree.py +++ b/resources/tester/test_m02_tree.py @@ -198,6 +198,39 @@ def test_빈_층에서_폴더_항목_깊이_1_2_5_이름_옮기기_지우기(wor assert json.loads((root / "table/구조물집계표.json").read_bytes())["열"] == [] +def test_옮기기_같은_이름은_이름_번호로_저장(world): + """옮기기(항목 · 폴더) 같은 폴더 같은 이름 = 말없이 「이름(1)」 「이름(2)」 · 키 · 번호 그대로. + 사용자가 직접 친 이름(더하기 · 이름 바꾸기) 겹침은 그대로 409.""" + root = world["storage"] / "7" / "42" / "templates" + t = Tree(_as(world), "personal") + t.do(op="add-folder", 경로=[], 이름="A") + t.do(op="add-folder", 경로=[], 이름="B") + for f in ("A", "B"): + t.do(op="add-item", 경로=[f], 이름="같음") + t.do(op="add-item", 경로=["B"], 이름="같음(1)") + t.do(op="add-folder", 경로=["A"], 이름="하위") + t.do(op="add-folder", 경로=["B"], 이름="하위") + t.do(op="add-item", 경로=["B", "하위"], 이름="안쪽") + mine = t.item(["A", "같음"]) + note = t.do(op="move", 키=mine["key"], 새경로=["B"])["알림"] + assert note["경로"] == ["B", "같음(2)"] and note["바뀐이름"] == ["같음(2)"] # (1) 도 있어서 + moved = t.item(["B", "같음(2)"]) + assert moved["key"] == mine["key"] and _numbers(root, moved["열"]) == _numbers(root, mine["열"]) + # 겹침 없으면 이름 그대로 · 바뀐이름 없음 · 제자리 옮기기도 그대로 + free = t.do(op="move", 키=moved["key"], 새경로=["A"])["알림"] + assert free["경로"] == ["A", "같음(2)"] and "바뀐이름" not in free + stay = t.do(op="move", 키=t.item(["B", "같음"])["key"], 새경로=["B"])["알림"] + assert stay["경로"] == ["B", "같음"] and "바뀐이름" not in stay + # 폴더 — 같은 이름 폴더가 이미 있으면 「하위(1)」 · 안의 항목 따라감 · 같은 부모로는 그대로 + folder = t.do(op="move", 경로=["A", "하위"], 새경로=["B"])["알림"] + assert folder["경로"] == ["B", "하위(1)"] and folder["바뀐이름"] == ["하위(1)"] + t.do(op="move", 경로=["B", "하위"], 새경로=["B"]) # 제자리 — 409 아님 + assert ["B", "하위", "안쪽"] in t.paths() + # 직접 친 이름은 지금처럼 막음 + t.do(409, op="add-item", 경로=["B"], 이름="같음") + t.do(409, op="rename", 키=t.item(["B", "같음(1)"])["key"], 이름="같음") + + def test_판_409_권한_403(world): admin = Tree(_as(world, "ADMIN"), "company") stale = admin.now["판"] @@ -254,7 +287,7 @@ def test_표_저장과_나무가_한_줄기(world): def test_프로젝트에서_새_항목도_경로_그대로(world): """개인에 저장 · 프로젝트에서 새 항목(`M02_Item_Personal`)도 경로를 넷에 접지 않고 그대로 · - 같은 경로면 「이름 (2)」.""" + 같은 경로면 「이름(1)」.""" from M02_MasterTemplete import M02_Item_Personal as personal folder = layers.personal_dir(7, 42) @@ -262,5 +295,5 @@ def test_프로젝트에서_새_항목도_경로_그대로(world): again = personal.create_item(folder, ["가", "나", "다", "라"], "마", "m") t = Tree(_as(world), "personal") assert t.item(["가", "나", "다", "라", "마"])["key"] == made["key"] - assert t.item(["가", "나", "다", "라", "마 (2)"])["key"] == again["key"] + assert t.item(["가", "나", "다", "라", "마(1)"])["key"] == again["key"] assert made["이름"] == "가 나 다 라 마" diff --git a/resources/tester/test_m02_tree_paste.py b/resources/tester/test_m02_tree_paste.py index 9b40d26dd..7cecd6ca3 100644 --- a/resources/tester/test_m02_tree_paste.py +++ b/resources/tester/test_m02_tree_paste.py @@ -1,7 +1,7 @@ """M02 구조물 라이브러리 여러 개 · 층 사이 붙여넣기(PLAN 30-2) — 임시 storage. ① 회사 항목 · 폴더를 일반 사용자 개인 층에 붙임 — 새 SP 키 · 본뜬 = 원본 · 세 파일 복사 · - 폴더는 안의 것 · 빈 폴더까지 · 다시 붙이면 「이름 (2)」 · 고른 폴더 안의 것은 한 번만. + 폴더는 안의 것 · 빈 폴더까지 · 다시 붙이면 「이름(1)」 · 고른 폴더 안의 것은 한 번만. ② 권한 — 쓰기 층만(일반 사용자 회사 층 403 · 남의 층 403) · 복사한 층은 읽기 권한(남의 회사 403). ③ 여러 개 옮기기 · 지우기 한 번에(하나라도 막히면 아무것도 안 바뀜). """ @@ -52,10 +52,10 @@ def test_회사_항목_폴더를_개인에_붙임_새_키_파일_이름2(world): assert made["본뜬"] == {"layer": "company", "owner": "7", "key": wall} for kind in ("structure", "area"): assert (root / kind / f"{made['id']}.json").is_file() - # 다시 붙이면 같은 이름 「(2)」 · 붙일 폴더 안으로 + # 다시 붙이면 같은 이름 「(1)」 · 붙일 폴더 안으로 again = client.post(PASTE.format("personal"), json={**body, "경로": []}).json() - assert ["배수 (2)", "측구"] in [r["경로"] for r in again["items"]] - assert ["옹벽 (2)"] in [r["경로"] for r in again["items"]] + assert ["배수(1)", "측구"] in [r["경로"] for r in again["items"]] + assert ["옹벽(1)"] in [r["경로"] for r in again["items"]] inner = client.post(PASTE.format("personal"), json={**body, "대상": [body["대상"][2]], "경로": ["배수"]}).json() # fmt: skip assert ["배수", "옹벽"] in [r["경로"] for r in inner["items"]] @@ -109,6 +109,19 @@ def test_여러_개_옮기기_지우기_한_번에(world): assert t.paths() == [["나", "둘"]] +def test_여러_개_옮기기_같은_이름은_이름_번호로(world): + t = Tree(_as(world), "personal") + t.do(op="add-folder", 경로=[], 이름="나") + t.do(op="add-item", 경로=["나"], 이름="같음") + t.do(op="add-item", 경로=[], 이름="같음") + t.do(op="add-folder", 경로=[], 이름="묶음") + t.do(op="add-item", 경로=["묶음"], 이름="같음") + moves = [{"경로": [], "키": t.item(["같음"])["key"]}, {"경로": ["묶음"]}] + note = t.do(op="move", 대상=moves, 새경로=["나"])["알림"] + assert sorted(t.paths()) == [["나", "같음"], ["나", "같음(1)"], ["나", "묶음", "같음"]] + assert note["바뀐이름"] == ["같음(1)"] + + def test_항목_추가_일위대가_단위_없이(world): """PLAN 31-2 · 39-1d — 항목 추가 = 종류(폴더) + 이름 · 단위 안 받아도 됨 · 일위대가 키는 안 받음 — 열 칸에 안 적고 늘 빈 labor 문서 diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 39a8a1f48..6a13b6696 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -157,6 +157,10 @@ export const ui_locales_m2 = { M02_TreeSaveGo: ["저장 후 넘어감", "Save and continue"], M02_TreeDiscardGo: ["버리고 넘어감", "Discard and continue"], M02_TreeDone: ["나무를 고침", "Tree updated"], + M02_TreeRenamed: [ + "나무를 고침 — 같은 이름이 있어 「{value}」 로 저장", + "Tree updated — name already taken, saved as “{value}”", + ], M02_PracticeTitle: ["실무문서", "Practice documents"], M02_PracticeRow: ["실무문서 목록", "Practice document list"], M02_PracticeCount: [ From 084b52001e6c1dd7b60655c90d376c0b3c55a7f1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:07:39 +0900 Subject: [PATCH 15/47] =?UTF-8?q?feat(M02):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=A1=9C=EC=A7=81=20=EC=A4=84=20=EC=88=9C=EC=84=9C?= =?UTF-8?q?=20=E2=80=94=20=E2=96=B2=E2=96=BC=20=EC=9E=91=EC=9D=80=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20+=20=EC=86=90=EC=9E=A1=EC=9D=B4=20?= =?UTF-8?q?=EB=81=8C=EA=B8=B0=20=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=EC=B9=B4=EB=93=9C=EB=8F=84(85-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- .../M02_MasterTemplete_Labor.css | 28 ++++++++- .../M02_MasterTemplete_Labor_Detail.ts | 10 ++-- .../M02_MasterTemplete_Labor_Rows.ts | 59 +++++++++++++++++-- .../M02_MasterTemplete_Labor_Text.ts | 1 + resources/tester/test_m02_card_shell.py | 7 ++- 5 files changed, 92 insertions(+), 13 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index bf59b0c4f..e40258c2b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -90,7 +90,33 @@ white-space: nowrap; } -/* 순서 칸 2행(51-14) — 위 [위로] [아래로] / 아래 [옵션]·[수정] [삭제] · 줄 사이도 단추 묶음 간격 */ +/* 순서(85-3) — 손잡이 + ▲▼ 작은 단추 · 끌어 놓을 줄 위 선 */ +.m02-labrow__order { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.m02-labrow__arrow.ui-btn { + min-width: 0; + height: 20px; + padding: 0 5px; + font-size: 10px; + line-height: 1; +} + +.m02-labrow__grip { + padding: 0 4px; + color: var(--color-text-secondary); + cursor: grab; + user-select: none; +} + +.m02-labrow__row.is-dragover td { + box-shadow: inset 0 2px 0 var(--color-primary); +} + +/* 순서 칸 2행(51-14) — 위 손잡이 ▲▼ / 아래 [옵션]·[수정] [삭제] · 줄 사이도 단추 묶음 간격 */ .m02-labrow__tools { display: inline-flex; flex-direction: column; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 1b4bb5333..5f9415e7b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -186,7 +186,7 @@ export function mountLaborDetail( option: openRow, ref: openRef, qty: openQty, - ...(opt.card ? {} : { move }), + move: reorder, remove: removeRow, alias: setAlias, } @@ -310,10 +310,10 @@ export function mountLaborDetail( } }; - function move(i: number, d: number): void { - const j = i + d; - if (j < 0 || j >= cur.담은로직.length) return; - [cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]]; + /** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 끌기 = 놓은 줄 자리) */ + function reorder(from: number, to: number): void { + if (from === to || to < 0 || to >= cur.담은로직.length) return; + cur.담은로직.splice(to, 0, ...cur.담은로직.splice(from, 1)); changed(); } /** 줄 고름(47-5) — 하이라이트 + 하위 행(51-2 · 표만 다시 그림 · 다이어그램 안 다시 그림) + 원문 패널(47-4) · diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 4a9d61940..262c577cd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -148,6 +148,9 @@ function moneyCell( } /** 이름 칸 글마다 title = 전체 글(한 줄 말줄임 · 45-7) */ +/** 끌고 있는 줄 차례(85-3) — 손잡이 dragstart ~ drop */ +let dragFrom: number | null = null; + const titled = (td: HTMLElement): HTMLElement => { for (const c of td.children) c.setAttribute("title", c.textContent ?? ""); return td; @@ -179,7 +182,8 @@ export interface RowHandlers { ref?: (i: number) => void; /** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */ qty?: (i: number) => void; - move?: (i: number, d: number) => void; + /** 줄 순서(85-3) — from 줄을 to 자리로(▲▼ = 옆 자리 · 손잡이 끌기 = 놓은 줄 자리) */ + move?: (from: number, to: number) => void; remove?: (i: number) => void; /** 별칭 칸 고침(44-6 · 글자마다 · 빈 글 = null) — 없으면 글만 보임 */ alias?: (i: number, value: string | null) => void; @@ -324,6 +328,32 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement run(); }, }); + // 순서(85-3) — ▲▼ 작은 단추(글은 title · aria) + 손잡이 끌기(줄 전체는 안 끎 · 별칭 칸 글 고르기) + const arrow = (mark: string, label: string, to: number): HTMLElement => { + const b = small(mark, () => move?.(i, to)); + b.classList.add("m02-labrow__arrow"); + b.title = label; + b.setAttribute("aria-label", label); + if (to < 0 || to >= data.rows.length) (b as HTMLButtonElement).disabled = true; + return b; + }; + const grip = (): HTMLElement => { + const g = el("span", { + className: "m02-labrow__grip", + text: "⠿", + attrs: { draggable: "true", title: tc("Labor_Drag"), "data-labor-grip": String(i) }, + }); + g.addEventListener("click", (e) => e.stopPropagation()); + g.addEventListener("dragstart", (ev) => { + dragFrom = i; + ev.dataTransfer?.setData("text/plain", String(i)); + if (ev.dataTransfer) ev.dataTransfer.effectAllowed = "move"; + }); + g.addEventListener("dragend", () => { + dragFrom = null; + }); + return g; + }; const sel = data.selected === id ? " is-selected" : ""; const tr = el("tr", { className: (on ? "m02-labrow__row is-open" : "m02-labrow__row") + sel, @@ -373,10 +403,14 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement children: [ ...(move ? [ - group([ - small(tc("Up"), () => move(i, -1)), - small(tc("Down"), () => move(i, 1)), - ]), + el("div", { + className: "m02-labrow__order", + children: [ + grip(), + arrow("▲", tc("Up"), i - 1), + arrow("▼", tc("Down"), i + 1), + ], + }), ] : []), group([ @@ -403,6 +437,21 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement ], }); if (on) tr.addEventListener("click", () => on.open(i)); + if (move) { + tr.addEventListener("dragover", (ev) => { + if (dragFrom === null || dragFrom === i) return; + ev.preventDefault(); + tr.classList.add("is-dragover"); + }); + tr.addEventListener("dragleave", () => tr.classList.remove("is-dragover")); + tr.addEventListener("drop", (ev) => { + ev.preventDefault(); + tr.classList.remove("is-dragover"); + const from = dragFrom; + dragFrom = null; + if (from !== null && from !== i) move(from, i); + }); + } const subs = sel ? subRows(line, wait, tools) : []; if (!note) return [tr, ...subs]; // 옵션 값 줄(44-6) — 이름 ~ 합계 칸에 걸친 한 줄(말줄임 · 전체 글은 title) · 행 높이 안 커짐 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 28d5b4286..abdc2f6bc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -33,6 +33,7 @@ const TEXT = { Col_Move: ["순서", "Move"], Up: ["위로", "Up"], Down: ["아래로", "Down"], + Labor_Drag: ["끌어 순서 바꿈", "Drag to reorder"], Remove: ["삭제", "Delete"], Labor_Option: ["옵션", "Options"], Labor_Edit: ["수정", "Edit"], diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index 6b03b170e..bed5215ae 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -78,7 +78,7 @@ def test_일위대가_카드는_상세_카드_갈래(): assert "onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable())," in cards assert "Labor_View" not in cards # 한 줄 표(40-2) 안 씀 detail = _text("M02_MasterTemplete_Labor_Detail.ts") - assert "...(opt.card ? {} : { move })" in detail # 순서 단추 없음 · 빼기는 그대로 + assert "move: reorder," in detail # 85-3 — 카드도 순서(▲▼ · 끌기) · 빼기는 그대로 # 서고 모드(50-3 · lib)도 더하기 · 다이어그램 없음 # 51-11 사전조합 = 일위대가 상세와 같은 [로직 추가] · 같은 목록(걸러냄 없음) assert "opt.card || promote ? null : addLogic" in detail @@ -134,7 +134,10 @@ def test_일위대가_줄_고름과_옵션_단추(): ) # 읽기만도 고름 · [옵션] = 보기 모달 assert "selected: picked" in detail rows = _text("M02_MasterTemplete_Labor_Rows.ts") - up, down = rows.index('small(tc("Up")'), rows.index('small(tc("Down")') + # 85-3 — ▲▼ 작은 단추(글은 title · aria) + 손잡이 끌기 + up, down = rows.index('arrow("▲", tc("Up")'), rows.index('arrow("▼", tc("Down")') + assert rows.index("grip(),") < up and 'b.setAttribute("aria-label", label)' in rows + assert "move: reorder," in detail and "opt.card ? {} : { move }" not in detail # 카드도 순서 opt, rm = ( rows.index('small(tc("Labor_Option")'), rows.index('small(tc("Remove"), () => remove(i), "danger")'), From d9bd08a236545ac94f0a5034ba4d034442c1f0f8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:07:48 +0900 Subject: [PATCH 16/47] =?UTF-8?q?fix(B06):=20=EC=84=B8=EC=9B=94=EA=B5=90?= =?UTF-8?q?=20=C2=B7=20=EB=B0=95=EC=8A=A4=20=EC=9C=A0=EC=B6=9C=20=EC=88=98?= =?UTF-8?q?=ED=8F=89=EA=B3=B5=EA=B0=84=20=EC=97=86=EC=95=B0=20=E2=80=94=20?= =?UTF-8?q?=EC=B9=B8=20=C2=B7=20=EA=B8=B0=ED=95=98=20=C2=B7=20=EC=84=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=A7=9D=20=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20=EC=9D=B4=EB=A6=84=20=EC=A4=84(89-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- A00_Common/design/design_structure_guard.ts | 3 ++- .../B05_Profile_UI_Drainage_Facility_Merge.ts | 4 ++-- B06_Section/B06_Section_Engine_Culvert.py | 4 ++-- B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts | 13 +++++-------- common_util/common_util_culvert_sets.ts | 5 +++-- resources/master_template/구조물정의.json | 2 -- resources/tester/helper_79_guard_shape.cjs | 9 +++++++-- resources/tester/test_65_ford_defaults.py | 5 +++-- resources/tester/test_79_guard_shape.py | 6 ++++-- 9 files changed, 28 insertions(+), 23 deletions(-) diff --git a/A00_Common/design/design_structure_guard.ts b/A00_Common/design/design_structure_guard.ts index e1b4fbdb5..3d4ad1c45 100644 --- a/A00_Common/design/design_structure_guard.ts +++ b/A00_Common/design/design_structure_guard.ts @@ -83,11 +83,12 @@ export const revetKeys = (side: "inlet" | "outlet"): Record => ( }); /** 세월교 · 박스 유입 · 유출 흙막이 칸(79-7) — 높이 빈 칸 = 자동(`heightHint` · 횡단도가 셈) · - * 절토부 앞 수평공간 칸(`{side}_revet_berm_m`)을 더함. */ + * 유입만 절토부 앞 수평공간 칸(`inlet_revet_berm_m` · 유출은 없음 · 89-4)을 더함. */ export function bodyGuardItems(side: "inlet" | "outlet", heightHint: string): SpecItem[] { const items = guardItems({ 흙막이: revetKeys(side) }, undefined, true).map((item) => item.id === "height" ? { ...item, default: undefined, hint: heightHint } : item, ); + if (side === "outlet") return items; const berm: SpecItem = { id: "berm", label: "수평공간 (m)", diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index 0bab3c485..49dc35c2e 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -43,7 +43,7 @@ export const FORM_OPTION_KEYS: Readonly> = { ...revetKeys("inlet"), ...revetKeys("outlet"), "inlet_revet_berm_m", // 흙막이 절토부 앞 수평공간(79-7) - "outlet_revet_berm_m", + "outlet_revet_berm_m", // 칸 없앰(89-4) — 목록에 남겨 다음 [저장] 때 옛 값을 지움 ], // 물넘이 포장 = 표 03 명세 키(PLAN 52-3 · `design_structure_ford_pavement`) + 월류 폭 · 높이(65-5) — 옛 경사 · // 두께 · 길이는 폼에서 빠져 이어 붙임 · 도수로/돌붙임 키는 `FORM_OPTION_PREFIXES` @@ -61,7 +61,7 @@ export const FORM_OPTION_KEYS: Readonly> = { ...revetKeys("inlet"), ...revetKeys("outlet"), "inlet_revet_berm_m", - "outlet_revet_berm_m", + "outlet_revet_berm_m", // 칸 없앰(89-4) — 목록에 남겨 다음 [저장] 때 옛 값을 지움 ], revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")], }; diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 9f0631eec..ab28ec192 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -329,8 +329,8 @@ def _guard_set(values: dict[str, Any], body: str, diameter_m: float) -> dict[str for role, key in zip(("inlet", "outlet"), _GUARD_KEYS[body]): guarded = str(values.get(key) or "").strip() == "흙막이" sides[role] = _revet_side(values, role) if guarded else {"role": role, "structure": ""} - if guarded: - # 절토부 앞 수평공간(79-7) — 흙막이 컨테이너 칸 · 빈 값 = 화면 기본값 + if guarded and role == "inlet": + # 절토부 앞 수평공간(79-7) — 유입만(유출은 없음 · 89-4) · 빈 값 = 화면 기본값 sides[role]["revet_berm_m"] = _number(values.get(f"{role}_revet_berm_m"), None) if not any(side["structure"] for side in sides.values()): return None diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts index 6396cc06f..4978284d2 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts @@ -5,7 +5,7 @@ * 집수정 계류측 두 채널의 입력을 짜고 면적용(미확정 단 제외) 결과를 함께 돌려준다. * ========================================================================== */ -import type { CrossSection, CulvertSideSpec } from "./B06_Section_Api_Fetch"; +import type { CrossSection } from "./B06_Section_Api_Fetch"; import { GUARD_CUT_BERM_M, REVET_EMBED_DEPTH_M, @@ -85,17 +85,14 @@ export function buildCulvertTiers(input: { side: culvert?.hidden_pipe ? culvert.inlet.face_side : null, }); // 세월교 · 박스 흙막이 끝 절토 = 컨테이너 수평공간 뒤 절토 경사(79-7) · 배관은 종전(3도 선). - const cutBerm = (spec: CulvertSideSpec | undefined) => + const cutBerm = (lengthM: number) => culvert?.body - ? { - lengthM: Math.max(spec?.revet_berm_m ?? GUARD_CUT_BERM_M, 0), - slopeRatio: section.design?.cut_slope_ratio ?? 1.0, - } + ? { lengthM: Math.max(lengthM, 0), slopeRatio: section.design?.cut_slope_ratio ?? 1.0 } : undefined; // 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로). // 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다. const outletInput = { - cutBerm: cutBerm(culvert?.outlet), + cutBerm: cutBerm(0), // 유출 수평공간 없음 — 끝이 원지반에 묻히면 바로 절토(89-4) startBottomElevation: input.outletBottom, outward: input.outletInfo.outward, groundAt, @@ -109,7 +106,7 @@ export function buildCulvertTiers(input: { // 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 — // 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자). const basinInput = { - cutBerm: cutBerm(culvert?.inlet), + cutBerm: cutBerm(culvert?.inlet.revet_berm_m ?? GUARD_CUT_BERM_M), startBottomElevation: input.basinBottom, outward: input.inletInfo.outward, groundAt, diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 235142da7..490e7d4c6 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -213,12 +213,13 @@ export function guardSet( diameterM: number, ): CulvertSetSpec | null { const [inletKey, outletKey] = GUARD_KEYS[body]; - // 절토부 앞 수평공간(79-7) — 흙막이 컨테이너 칸 · 빈 값 = 화면 기본값 const side = (role: string, key: string): CulvertSetSpec => String(values[key] ?? "").trim() === "흙막이" - ? { ...revetSide(values, role), revet_berm_m: num(values[`${role}_revet_berm_m`], null) } + ? revetSide(values, role) : { role, structure: "" }; const inlet = side("inlet", inletKey); + // 절토부 앞 수평공간(79-7) — 유입만(유출은 없음 · 89-4) · 빈 값 = 화면 기본값 + if (inlet.structure) inlet.revet_berm_m = num(values.inlet_revet_berm_m, null); const outlet = side("outlet", outletKey); if (!inlet.structure && !outlet.structure) return null; return { diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 32a69a793..308e34b20 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -3418,7 +3418,6 @@ "outlet.height": "높이 (m)", "outlet.before": "기준측점 전 (m)", "outlet.after": "기준측점 후 (m)", - "outlet.berm": "수평공간 (m)", "soil_add": "흙막이", "soil_add.form": "형태", "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", @@ -3489,7 +3488,6 @@ "outlet.height": "높이 (m)", "outlet.before": "기준측점 전 (m)", "outlet.after": "기준측점 후 (m)", - "outlet.berm": "수평공간 (m)", "soil_add": "흙막이", "soil_add.form": "형태", "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", diff --git a/resources/tester/helper_79_guard_shape.cjs b/resources/tester/helper_79_guard_shape.cjs index aacab3ac3..4fa0852ee 100644 --- a/resources/tester/helper_79_guard_shape.cjs +++ b/resources/tester/helper_79_guard_shape.cjs @@ -137,9 +137,14 @@ out.boxWing = wingLayout.sides.map((s) => ({ // ⑤ 흙막이 끝 절토 = 수평공간 뒤 절토 경사 — 수평공간 칸 값을 따름 function cutTails(section, list) { const culvert = computeStoredLayouts(section, list).culvert; - return [...culvert.outletFill.segments, ...culvert.basinFill.segments] + const tagged = (segments, role) => segments.map((g) => ({ ...g, role })); + return [ + ...tagged(culvert.outletFill.segments, "outlet"), + ...tagged(culvert.basinFill.segments, "inlet"), + ] .filter((g) => g.kind === "cut") .map((g) => ({ + role: g.role, points: g.points.length, berm: r3(Math.abs(g.points[1].offset - g.points[0].offset)), flat: r3(g.points[1].elevation - g.points[0].elevation), @@ -148,7 +153,7 @@ function cutTails(section, list) { out.cutDefault = [cutTails(at(352.14), sections), cutTails(at(173.087), sections)]; const berm2 = clone(at(352.14)); berm2.culvert.inlet.revet_berm_m = 2; -berm2.culvert.outlet.revet_berm_m = 2; +berm2.culvert.outlet.revet_berm_m = 2; // 유출은 칸이 없어 무시(89-4) out.cutBerm2 = cutTails( berm2, sections.map((s) => (s === at(352.14) ? berm2 : s)), diff --git a/resources/tester/test_65_ford_defaults.py b/resources/tester/test_65_ford_defaults.py index 052826723..863d2d2a4 100644 --- a/resources/tester/test_65_ford_defaults.py +++ b/resources/tester/test_65_ford_defaults.py @@ -35,7 +35,7 @@ GUARD = [ ["height", ["흙막이"], None], # 빈 칸 = 본체 끝 높이(79-7 · 횡단도가 셈) ["before", ["흙막이"], "5"], ["after", ["흙막이"], "5"], - ["berm", ["흙막이"], "1"], # 절토부 앞 수평공간(79-7) + ["berm", ["흙막이"], "1"], # 절토부 앞 수평공간(79-7) — 유입만(89-4) ] @@ -55,7 +55,8 @@ def out() -> dict: def test_유입_유출은_구조와_흙막이_칸만(out): for spec in ("ford", "box"): for side in ("inlet", "outlet"): - assert out[spec][side] == GUARD, (spec, side) + want = GUARD if side == "inlet" else GUARD[:-1] + assert out[spec][side] == want, (spec, side) assert [i[0] for i in out[spec]["soil_add"]] == [ "form", "length", diff --git a/resources/tester/test_79_guard_shape.py b/resources/tester/test_79_guard_shape.py index f12a573dc..9c8f0291d 100644 --- a/resources/tester/test_79_guard_shape.py +++ b/resources/tester/test_79_guard_shape.py @@ -79,12 +79,14 @@ def test_박스_날개벽_끝_절성토선(shape: dict) -> None: def test_흙막이_끝_절토는_수평공간_뒤(shape: dict) -> None: tails = [tail for group in shape["cutDefault"] for tail in group] assert tails, "절토 끝선이 없음" + assert any(tail["role"] == "inlet" for tail in tails) for tail in tails: assert tail["points"] == 3 assert tail["flat"] == pytest.approx(0, abs=1e-6) - assert tail["berm"] == pytest.approx(1.0, abs=1e-3) + # 유출은 수평공간 없음 — 묻히면 바로 절토(89-4) + assert tail["berm"] == pytest.approx(1.0 if tail["role"] == "inlet" else 0, abs=1e-3) for tail in shape["cutBerm2"]: - assert tail["berm"] == pytest.approx(2.0, abs=1e-3) + assert tail["berm"] == pytest.approx(2.0 if tail["role"] == "inlet" else 0, abs=1e-3) def test_유입_수평공간_시작은_본체_바닥(shape: dict) -> None: From 04dd8cd0125dc4da4319750bf1bfdd163a74679d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:07:53 +0900 Subject: [PATCH 17/47] =?UTF-8?q?fix(B07):=20=EB=8F=84=EB=A9=B4=20?= =?UTF-8?q?=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=E2=80=94=20bulge=20?= =?UTF-8?q?=ED=98=B8=20=C2=B7=20OCS=20=EA=B1=B0=EC=9A=B8=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20TEXT=20=EC=A0=95=EB=A0=AC=EC=A0=90=20?= =?UTF-8?q?=C2=B7=20=EA=B2=B9=20=EB=B8=94=EB=A1=9D=20=ED=92=82(84-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LWPOLYLINE/POLYLINE 은 ezdxf path.make_path().flattening(0.2) 로 — bulge 호 마디 펴짐 · 압출 −Z 는 WCS 로 - TEXT 는 get_placement 정렬점 · OCS → WCS · 가로 정렬(왼 · 가운데 · 오른) · MTEXT 붙임점 가로 정렬 - INSERT 속 INSERT 까지 풂(16 겹 한도) - 견본 DXF 시험(호 · bulge · 거울 블록 · 겹 블록 · TEXT 정렬) — 옛 코드에선 넷 실패 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- .../B07_DesignDetail_Engine_Frame_Import.py | 86 +++++++++-- .../tester/test_b07_frame_import_shapes.py | 135 ++++++++++++++++++ 2 files changed, 206 insertions(+), 15 deletions(-) create mode 100644 resources/tester/test_b07_frame_import_shapes.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py index 8472d92fd..673df9a21 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Import.py @@ -29,6 +29,8 @@ from uuid import NAMESPACE_URL, uuid5 import ezdxf from ezdxf import colors as ezdxf_colors +from ezdxf import path as ezdxf_path +from ezdxf.enums import TextEntityAlignment from ezdxf.lldxf.encoding import decode_dxf_unicode, has_dxf_unicode logger = logging.getLogger(__name__) @@ -38,6 +40,8 @@ _DEFAULT_COLOR = "#f5f7fa" # 곡선을 선분으로 풀 때 허용 오차(mm) — 도각 크기(840x594mm)에서 눈에 띄지 않는다. _FLATTEN_MM = 0.2 _MAX_ENTITIES = 20000 +# 블록 속 블록을 풀 깊이 — 자기를 품는 깨진 블록에서 끝없이 돌지 않게. +_MAX_BLOCK_DEPTH = 16 # 프로그램에 함께 담은 LibreDWG 자리 — `B07_DesignDetail/openwebcad/tools/libredwg/`. @@ -117,16 +121,26 @@ def _plain(label: str) -> str: return decode_dxf_unicode(label) if has_dxf_unicode(label) else label -def _text(index: int, entity: Any, label: str, insert: Any, height: float) -> dict[str, Any]: - rotation = float(getattr(entity.dxf, "rotation", 0.0) or 0.0) +def _text( + index: int, + entity: Any, + label: str, + insert: Any, + height: float, + direction: Any = None, + align: str = "left", +) -> dict[str, Any]: + if direction is None: + rotation = float(getattr(entity.dxf, "rotation", 0.0) or 0.0) + direction = (cos(radians(rotation)), sin(radians(rotation))) return { **_base(index, entity, "Text"), "shapeData": { "label": _plain(label), "basePoint": _point(insert[0], insert[1]), "options": { - "textDirection": _point(cos(radians(rotation)), sin(radians(rotation))), - "textAlign": "left", + "textDirection": _point(direction[0], direction[1]), + "textAlign": align, "textColor": _color(entity), "fontSize": float(height) or 3.0, "fontFamily": "sans-serif", @@ -143,16 +157,50 @@ def _flatten(entity: Any) -> list[Any] | None: return None +def _polyline_points(entity: Any) -> tuple[list[Any], bool]: + """폴리선 → WCS 점열. bulge(호 마디)는 펴고 OCS(압출 −Z 거울 블록)는 WCS 로. + + `make_path` 가 닫힌 폴리선의 닫는 마디까지 넣으므로 닫힘 표시는 False. + 면 · 메시 POLYLINE 처럼 경로로 못 바꾸면 꼭짓점을 그대로 잇는다. + """ + try: + return list(ezdxf_path.make_path(entity).flattening(_FLATTEN_MM)), False + except Exception: + if entity.dxftype() == "LWPOLYLINE": + return list(entity.get_points("xy")), bool(entity.closed) + return [vertex.dxf.location for vertex in entity.vertices], bool(entity.is_closed) + + +# DXF 가로 정렬 → CAD 글 정렬. 맞춤(ALIGNED · FIT)은 첫 점에서 왼쪽. +_TEXT_ALIGN = { + TextEntityAlignment.CENTER: "center", + TextEntityAlignment.MIDDLE: "center", + TextEntityAlignment.TOP_CENTER: "center", + TextEntityAlignment.MIDDLE_CENTER: "center", + TextEntityAlignment.BOTTOM_CENTER: "center", + TextEntityAlignment.RIGHT: "right", + TextEntityAlignment.TOP_RIGHT: "right", + TextEntityAlignment.MIDDLE_RIGHT: "right", + TextEntityAlignment.BOTTOM_RIGHT: "right", +} + + +def _text_placement(entity: Any) -> tuple[Any, Any, str]: + """TEXT 의 (WCS 기준점, WCS 방향, 가로 정렬). 정렬점 · OCS(압출 −Z)를 따른다.""" + align, first, _second = entity.get_placement() + ocs = entity.ocs() + rotation = radians(float(entity.dxf.rotation or 0.0)) + direction = ocs.to_wcs((cos(rotation), sin(rotation), 0.0)) + return ocs.to_wcs(first), direction, _TEXT_ALIGN.get(align, "left") + + def _convert_entity(index: int, entity: Any) -> list[dict[str, Any]]: kind = entity.dxftype() if kind == "LINE": return [_line(index, entity, entity.dxf.start, entity.dxf.end)] - if kind == "LWPOLYLINE": - shape = _polyline(index, entity, list(entity.get_points("xy")), bool(entity.closed)) - return [shape] if shape else [] - if kind == "POLYLINE": - points = [vertex.dxf.location for vertex in entity.vertices] - shape = _polyline(index, entity, points, bool(entity.is_closed)) + if kind in ("LWPOLYLINE", "POLYLINE"): + points, closed = _polyline_points(entity) + shape = _polyline(index, entity, points, closed) return [shape] if shape else [] if kind in ("CIRCLE", "ARC", "ELLIPSE", "SPLINE"): points = _flatten(entity) @@ -172,24 +220,32 @@ def _convert_entity(index: int, entity: Any) -> list[dict[str, Any]]: label = str(entity.dxf.text or "").strip() if not label: return [] - return [_text(index, entity, label, entity.dxf.insert, float(entity.dxf.height or 3.0))] + insert, direction, align = _text_placement(entity) + height = float(entity.dxf.height or 3.0) + return [_text(index, entity, label, insert, height, direction, align)] if kind == "MTEXT": label = str(entity.plain_text() or "").strip() if not label: return [] height = float(entity.dxf.char_height or 3.0) - return [_text(index, entity, label, entity.dxf.insert, height)] + # 붙임점 1~9 = 위 · 가운데 · 아래 줄의 왼 · 가운데 · 오른. + align = ("left", "center", "right")[(int(entity.dxf.attachment_point or 1) - 1) % 3] + return [_text(index, entity, label, entity.dxf.insert, height, align=align)] return [] -def _expand(entity: Any) -> list[Any]: - """블록·치수처럼 속에 도형을 품은 것은 풀어서 낱개로 만든다. 못 풀면 버린다.""" +def _expand(entity: Any, depth: int = 0) -> list[Any]: + """블록·치수처럼 속에 도형을 품은 것은 풀어서 낱개로 — 블록 속 블록까지. 못 풀면 버린다.""" if entity.dxftype() in ("INSERT", "DIMENSION", "LEADER", "MULTILEADER"): + if depth >= _MAX_BLOCK_DEPTH: + logger.info("B07 도각 불러오기 — 블록이 %d 겹 넘게 겹쳐 건너뜀", _MAX_BLOCK_DEPTH) + return [] try: - return list(entity.virtual_entities()) + inner = list(entity.virtual_entities()) except Exception: logger.info("B07 도각 불러오기 — %s 는 풀지 못해 건너뜀", entity.dxftype()) return [] + return [item for child in inner for item in _expand(child, depth + 1)] return [entity] diff --git a/resources/tester/test_b07_frame_import_shapes.py b/resources/tester/test_b07_frame_import_shapes.py new file mode 100644 index 000000000..a93942d27 --- /dev/null +++ b/resources/tester/test_b07_frame_import_shapes.py @@ -0,0 +1,135 @@ +"""B07 도면 불러오기 — 호 · bulge 폴리선 · 거울 블록 · 겹 블록 견본 DXF(84-2) · 내보내기 시계방향 호(84-3). + +견본은 ezdxf 로 그 자리에서 만들어 끌어다 놓기 길(`drawing_file_to_entities`)로 읽는다. +""" + +from __future__ import annotations + +import io +import math + +import ezdxf +from ezdxf.enums import TextEntityAlignment + +from B07_DesignDetail import B07_DesignDetail_Engine_Frame_Export as export +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Import import drawing_file_to_entities + + +def _read(doc: ezdxf.document.Drawing) -> list[dict]: + stream = io.StringIO() + doc.write(stream) + entities, _layers = drawing_file_to_entities("견본.dxf", stream.getvalue().encode("utf-8")) + return entities + + +def _points(polyline: dict) -> list[tuple[float, float]]: + children = polyline["children"] + points = [ + (c["shapeData"]["startPoint"]["x"], c["shapeData"]["startPoint"]["y"]) for c in children + ] + last = children[-1]["shapeData"]["endPoint"] + return [*points, (last["x"], last["y"])] + + +def _on_circle(points, cx: float, cy: float, r: float) -> bool: + return all(abs(math.hypot(x - cx, y - cy) - r) < 0.21 for x, y in points) + + +def test_호는_1사분면_작은_호(): + doc = ezdxf.new() + doc.modelspace().add_arc((0, 0), 10, 0, 90) + (arc,) = _read(doc) + points = _points(arc) + assert _on_circle(points, 0, 0, 10) + assert points[0] == (10.0, 0.0) and math.isclose(points[-1][0], 0, abs_tol=1e-9) + assert all(x >= -1e-9 and y >= -1e-9 for x, y in points) # 큰 호(270°)로 뒤집히지 않음 + + +def test_bulge_폴리선은_호로_펴짐(): + doc = ezdxf.new() + space = doc.modelspace() + # 반원(bulge 1 · 반시계 → 아래로) · 작은 호(bulge −0.2 · 시계 → 위로 1) + space.add_lwpolyline([(0, 0, 0, 0, 1), (10, 0)], format="xyseb") + space.add_lwpolyline([(0, 20, 0, 0, -0.2), (10, 20)], format="xyseb") + half, small = _read(doc) + half_points = _points(half) + assert len(half_points) > 3 and _on_circle(half_points, 5, 0, 5) + assert min(y for _x, y in half_points) < -4.9 + small_points = _points(small) + assert len(small_points) > 3 + assert math.isclose(max(y for _x, y in small_points), 21.0, abs_tol=0.01) # 처짐 = 0.2 × 10 / 2 + assert min(y for _x, y in small_points) >= 20 - 1e-9 + + +def test_거울_블록은_WCS_자리(): + doc = ezdxf.new() + block = doc.blocks.new("거울") + block.add_lwpolyline([(0, 0, 0, 0, -0.2), (10, 0)], format="xyseb") + block.add_arc((20, 0), 2, 0, 90) + block.add_text("가", dxfattribs={"height": 2.5}).set_placement((2, 3)) + doc.modelspace().add_blockref("거울", (100, 0), dxfattribs={"xscale": -1}) + by_type: dict[str, list[dict]] = {} + for entity in _read(doc): + by_type.setdefault(entity["type"], []).append(entity) + bulge, arc = by_type["PolyLine"] + bulge_points = _points(bulge) + assert ( + min(x for x, _y in bulge_points) >= 90 - 1e-9 + and max(x for x, _y in bulge_points) <= 100 + 1e-9 + ) + assert math.isclose(max(y for _x, y in bulge_points), 1.0, abs_tol=0.01) # 위로 그대로 + arc_points = _points(arc) + assert _on_circle(arc_points, 80, 0, 2) + assert all(x <= 80 + 1e-9 and y >= -1e-9 for x, y in arc_points) # 2사분면으로 대칭 + (text,) = by_type["Text"] + assert text["shapeData"]["basePoint"] == {"x": 98.0, "y": 3.0} + + +def test_겹_블록도_풀림(): + doc = ezdxf.new() + doc.blocks.new("속").add_line((0, 0), (1, 0)) + doc.blocks.new("겉").add_blockref("속", (5, 0)) + doc.modelspace().add_blockref("겉", (10, 0)) + (line,) = _read(doc) + assert line["type"] == "Line" + assert line["shapeData"] == { + "startPoint": {"x": 15.0, "y": 0.0}, + "endPoint": {"x": 16.0, "y": 0.0}, + } + + +def test_TEXT_정렬점_자리(): + doc = ezdxf.new() + space = doc.modelspace() + space.add_text("가운데", dxfattribs={"height": 2}).set_placement( + (50, 5), align=TextEntityAlignment.MIDDLE_CENTER + ) + space.add_text("오른", dxfattribs={"height": 2}).set_placement( + (80, 5), align=TextEntityAlignment.BOTTOM_RIGHT + ) + center, right = _read(doc) + assert center["shapeData"]["basePoint"] == {"x": 50.0, "y": 5.0} + assert center["shapeData"]["options"]["textAlign"] == "center" + assert right["shapeData"]["basePoint"] == {"x": 80.0, "y": 5.0} + assert right["shapeData"]["options"]["textAlign"] == "right" + + +def test_내보내기_시계방향_작은_호(tmp_path): + arc = { + "type": "Arc", + "layerId": "기본", + "shapeData": { + "center": {"x": 0, "y": 0}, + "radius": 1, + "startAngle": math.pi / 2, + "endAngle": 0, + "counterClockwise": False, + }, + } + raw, skipped = export.drawing_to_dxf({"entities": [arc], "layers": []}) + assert skipped == {} + path = tmp_path / "out.dxf" + path.write_bytes(raw) + (out,) = ezdxf.readfile(path).modelspace().query("ARC") + span = (out.dxf.end_angle - out.dxf.start_angle) % 360 + assert math.isclose(span, 90) and math.isclose(out.dxf.start_angle % 360, 0, abs_tol=1e-9) From edd51b66e9a3057b742dbaf71b278967d10bf133 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:08:03 +0900 Subject: [PATCH 18/47] =?UTF-8?q?fix(B07):=20=EC=9D=B4=EB=8F=99=20=C2=B7?= =?UTF-8?q?=20=ED=81=AC=EA=B8=B0=20=C2=B7=20=ED=9A=8C=EC=A0=84=20=C2=B7=20?= =?UTF-8?q?=EB=B3=B5=EC=82=AC=20=C2=B7=20=EB=B0=B0=EC=97=B4=20=EB=95=8C=20?= =?UTF-8?q?=EC=83=89=20=C2=B7=20=EC=B8=B5=20=EC=9C=A0=EC=A7=80=20=C2=B7=20?= =?UTF-8?q?=ED=98=B8=20=EB=8C=80=EC=B9=AD=20=EC=B6=95=20=C2=B7=20=EC=8B=9C?= =?UTF-8?q?=EA=B3=84=EB=B0=A9=ED=96=A5=20=ED=98=B8=20=EB=82=B4=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EA=B8=B0(84-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 복제 함수 cloneKeepingLook 한 곳(move-tool.helpers) — 옮기기 · 크기 · 회전은 id · 묶음까지 그대로 · 복사 · 배열은 새 id - LineEntity · ArcEntity clone 이 색 · 층 · 선 꼴 유지(폴리선 자식 포함) - 복사가 누를 때마다 원본을 한 벌씩 더 쌓던 흠 고침 - ArcEntity mirror 가 축 각도 따름(α → 2θ − α) · 그리기 끝각 = 시작각 ± 쓸어간 각(끝각 0 → 2π 온 원 흠) - DXF 내보내기 시계방향 호는 시작 · 끝 바꿔 같은 작은 호 - 시험: ArcEntity.test.ts 10 · resources/tester/cad/clone-keeps-look.test.ts 7 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- .../B07_DesignDetail_Engine_Frame_Export.py | 3 + .../openwebcad/src/entities/ArcEntity.test.ts | 62 +++++++++++++++++++ .../openwebcad/src/entities/ArcEntity.ts | 27 ++++---- .../openwebcad/src/entities/LineEntity.ts | 8 ++- .../openwebcad/src/tools/array-tool.ts | 5 +- .../openwebcad/src/tools/copy-tool.ts | 12 ++-- .../openwebcad/src/tools/move-tool.helpers.ts | 15 +++++ .../openwebcad/src/tools/move-tool.ts | 6 +- .../openwebcad/src/tools/rotate-tool.ts | 7 ++- .../openwebcad/src/tools/scale-tool.ts | 7 ++- resources/tester/cad/clone-keeps-look.test.ts | 61 ++++++++++++++++++ 11 files changed, 182 insertions(+), 31 deletions(-) create mode 100644 resources/tester/cad/clone-keeps-look.test.ts diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py index e95c3b958..da64d98ee 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py @@ -195,6 +195,9 @@ def _add_entity( start_angle = shape.get("startAngle") end_angle = shape.get("endAngle") if isinstance(start_angle, (int, float)) and isinstance(end_angle, (int, float)): + # DXF 호는 늘 반시계 — 시계방향 호는 시작 · 끝을 바꿔야 같은 작은 호. + if shape.get("counterClockwise") is False: + start_angle, end_angle = end_angle, start_angle space.add_arc( center, radius, diff --git a/B07_DesignDetail/openwebcad/src/entities/ArcEntity.test.ts b/B07_DesignDetail/openwebcad/src/entities/ArcEntity.test.ts index c094d88b7..75114f281 100644 --- a/B07_DesignDetail/openwebcad/src/entities/ArcEntity.test.ts +++ b/B07_DesignDetail/openwebcad/src/entities/ArcEntity.test.ts @@ -1,7 +1,10 @@ import { type Arc, Point } from '@flatten-js/core'; import { describe, expect, it } from 'vitest'; import { EPSILON } from '../App.consts.ts'; +import type { DrawController } from '../drawControllers/DrawController.ts'; +import { mirrorPointOverAxis } from '../helpers/mirror-point-over-axis.ts'; import { ArcEntity } from './ArcEntity.ts'; +import { LineEntity } from './LineEntity.ts'; describe('ArcEntity.distanceTo', () => { /** @@ -57,3 +60,62 @@ describe('ArcEntity.distanceTo', () => { expect(distanceInfo[0]).toBeLessThan(1); }); }); + +describe('ArcEntity 대칭 · 그리기 · 복제(84-3)', () => { + const middle = (entity: ArcEntity) => { + const arc = entity.getShape() as Arc; + return arc.pointAtLength(arc.length / 2) as Point; + }; + const mirrorOver = (axisEnd: Point) => { + const arc = new ArcEntity('layer1', new Point(2, 1), 1, 0, Math.PI / 2, true); + const before = arc.getShape() as Arc; + const axis = new LineEntity('layer1', new Point(0, 0), axisEnd); + const expected = [before.start, middle(arc), before.end].map((point) => + mirrorPointOverAxis(point, axis) + ); + arc.mirror(axis); + const after = arc.getShape() as Arc; + return { expected, actual: [after.start, middle(arc), after.end], length: after.length }; + }; + + for (const [name, axisEnd] of [ + ['세로축', new Point(0, 1)], + ['가로축', new Point(1, 0)], + ['45° 축', new Point(1, 1)], + ['30° 축', new Point(Math.cos(Math.PI / 6), Math.sin(Math.PI / 6))], + ] as const) { + it(`${name} 대칭 — 끝점 · 가운데 점이 대칭 자리 · 작은 호 그대로`, () => { + const { expected, actual, length } = mirrorOver(axisEnd); + for (let index = 0; index < 3; index++) { + expect(actual[index].x).toBeCloseTo(expected[index].x, 9); + expect(actual[index].y).toBeCloseTo(expected[index].y, 9); + } + expect(length).toBeCloseTo(Math.PI / 2, 9); + }); + } + + it('시작각이 음수 · 끝각 0 인 호 — 끝각을 2π 로 바꾸지 않음(온 원으로 안 그림)', () => { + const arc = new ArcEntity('layer1', new Point(0, 0), 1, -Math.PI / 2, 0, true); + const calls: number[][] = []; + const controller = { + setLineStyles: () => {}, + drawArc: (_c: Point, _r: number, start: number, end: number) => calls.push([start, end]), + } as unknown as DrawController; + arc.draw(controller); + expect(calls[0][1] - calls[0][0]).toBeCloseTo(Math.PI / 2, 9); + }); + + it('복제 — 색 · 층 · 선 꼴 그대로', () => { + const arc = new ArcEntity('구조물', new Point(0, 0), 1, 0, 1, false); + arc.lineColor = '#ff0000'; + arc.lineWidth = 3; + arc.lineDash = [2, 1]; + const copy = arc.clone(); + expect([copy.layerId, copy.lineColor, copy.lineWidth, copy.lineDash]).toEqual([ + '구조물', + '#ff0000', + 3, + [2, 1], + ]); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/entities/ArcEntity.ts b/B07_DesignDetail/openwebcad/src/entities/ArcEntity.ts index f5e9e49c8..43b522328 100644 --- a/B07_DesignDetail/openwebcad/src/entities/ArcEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/ArcEntity.ts @@ -60,8 +60,9 @@ export class ArcEntity implements Entity, StartAndEndpointEntity { drawController.drawArc( this.arc.center, this.arc.r.valueOf(), - this.arc?.startAngle || 0, - this.arc?.endAngle || 2 * Math.PI, + this.arc.startAngle, + // 끝각 = 시작각 ± 쓸어간 각 — 끝각 0 을 2π 로 바꾸면 시작각이 음수인 호(대칭 뒤)가 온 원으로 그려짐. + this.arc.startAngle + (this.arc.counterClockwise ? this.arc.sweep : -this.arc.sweep), this.arc.counterClockwise ); } @@ -87,12 +88,13 @@ export class ArcEntity implements Entity, StartAndEndpointEntity { public mirror(mirrorAxis: LineEntity) { const mirroredCenter = mirrorPointOverAxis(this.arc.center, mirrorAxis); - mirrorAxis.getAngle(); + // 축 각 θ 에 대한 대칭 — 각 α → 2θ − α · 도는 방향 반대. + const doubleAxisAngle = 2 * mirrorAxis.getAngle(); this.arc = new Arc( mirroredCenter, this.arc.r.valueOf(), - -this.arc.startAngle, - -this.arc.endAngle, + doubleAxisAngle - this.arc.startAngle, + doubleAxisAngle - this.arc.endAngle, !this.arc.counterClockwise ); } @@ -100,14 +102,13 @@ export class ArcEntity implements Entity, StartAndEndpointEntity { public clone(): Entity { if (this.arc) { const { center, r, startAngle, endAngle, counterClockwise } = this.arc; - return new ArcEntity( - getActiveLayerId(), - center, - r.valueOf(), - startAngle, - endAngle, - counterClockwise - ); + // 색 · 층 · 선 꼴은 그대로 — 폴리선 자식도 이 길로 복제됨. + const copy = new ArcEntity(this.layerId, center, r.valueOf(), startAngle, endAngle, counterClockwise); + copy.lineColor = this.lineColor; + copy.lineWidth = this.lineWidth; + copy.lineDash = this.lineDash; + copy.opacity = this.opacity; + return copy; } return this; } diff --git a/B07_DesignDetail/openwebcad/src/entities/LineEntity.ts b/B07_DesignDetail/openwebcad/src/entities/LineEntity.ts index 590a5d1bf..bed94b431 100644 --- a/B07_DesignDetail/openwebcad/src/entities/LineEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/LineEntity.ts @@ -76,7 +76,13 @@ export class LineEntity implements Entity, StartAndEndpointEntity { } public clone(): LineEntity { - return new LineEntity(getActiveLayerId(), this.segment.clone()); + // 색 · 층 · 선 꼴은 그대로 — 폴리선 자식도 이 길로 복제됨. + const copy = new LineEntity(this.layerId, this.segment.clone()); + copy.lineColor = this.lineColor; + copy.lineWidth = this.lineWidth; + copy.lineDash = this.lineDash; + copy.opacity = this.opacity; + return copy; } public intersectsWithBox(box: Box): boolean { diff --git a/B07_DesignDetail/openwebcad/src/tools/array-tool.ts b/B07_DesignDetail/openwebcad/src/tools/array-tool.ts index 829a3454a..2fd83edff 100644 --- a/B07_DesignDetail/openwebcad/src/tools/array-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/array-tool.ts @@ -16,6 +16,7 @@ import { } from '../state'; import { Tool } from '../tools'; import { CopyAction } from './copy-tool.ts'; +import { cloneKeepingLook } from './move-tool.helpers.ts'; import { selectToolStateMachine } from './select-tool.ts'; import type { AbsolutePointInputEvent, @@ -337,7 +338,7 @@ export const arrayToolStateMachine = createMachine( const y = distanceVector.y * i; resultEntities.push( ...getSelectedEntities().map((entity) => { - const clone = entity.clone(); + const clone = cloneKeepingLook(entity); clone.move(x, y); return clone; }) @@ -346,7 +347,7 @@ export const arrayToolStateMachine = createMachine( const angleRad = context.angleStep * i * TO_RADIANS; resultEntities.push( ...getSelectedEntities().map((entity) => { - const clone = entity.clone(); + const clone = cloneKeepingLook(entity); clone.rotate(context.pivotPoint as Point, angleRad); return clone; }) diff --git a/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts b/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts index 7ef246f2f..73b6811fb 100644 --- a/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts @@ -17,7 +17,7 @@ import type { Entity } from '../entities/Entity'; import { compact } from 'es-toolkit'; import { LineEntity } from '../entities/LineEntity'; import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts'; -import { moveEntities } from './move-tool.helpers'; +import { cloneKeepingLook, moveEntities } from './move-tool.helpers'; export interface CopyContext extends ToolContext { startPoint: Point | null; @@ -208,7 +208,7 @@ export const copyToolStateMachine = createMachine( return { startPoint: context.startPoint, // Make a copy of the selected entities before copying them, so we can restore them when the user cancels the copy action - originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())), + originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity))), copiedEntities: selectedEntities, }; }), @@ -221,7 +221,7 @@ export const copyToolStateMachine = createMachine( // Copy the entities to the new location // Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be copied; - const movedEntities = context.originalSelectedEntities.map((entity) => entity.clone()); + const movedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity)); moveEntities( movedEntities, endPointTemp.x - context.startPoint.x, @@ -246,15 +246,15 @@ export const copyToolStateMachine = createMachine( // Copy the entities one final time const currentEndPoint = (event as MouseClickEvent).worldMouseLocation; - const copiedEntities = context.originalSelectedEntities.map((entity) => entity.clone()); + const copiedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity)); moveEntities( copiedEntities, currentEndPoint.x - context.startPoint.x, currentEndPoint.y - context.startPoint.y ); - // Switch the copied entities back from the ghost helper entities to the real entities - addEntities([...context.originalSelectedEntities, ...copiedEntities], true); + // 원본은 목록에 그대로 있음 — 복사본만 더함(원본까지 더하면 누를 때마다 원본이 겹겹이 쌓임). + addEntities(copiedEntities, true); }, [CopyAction.DESELECT_ENTITIES]: assign(() => { setGhostHelperEntities([]); diff --git a/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts index 97c15c503..c05b9451c 100644 --- a/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/move-tool.helpers.ts @@ -1,4 +1,5 @@ import type { Entity } from '../entities/Entity'; +import { copyStyle } from './modify/modify.helpers'; /** * Move entities by the difference between the start and end points @@ -11,3 +12,17 @@ export function moveEntities(entities: Entity[], deltaX: number, deltaY: number) entity.move(deltaX, deltaY); } } + +/** + * 복제해도 색 · 층 · 선 꼴 · 투명도를 그대로 둔다(`clone()` 은 활성 층 · 흰색으로 새로 만듦). + * `inPlace` = 옮기기 · 크기 · 회전처럼 같은 도형을 갈아 끼울 때 — id · 묶음까지 그대로. + */ +export function cloneKeepingLook(entity: Entity, inPlace = false): Entity { + const copy = copyStyle(entity, entity.clone()); + copy.opacity = entity.opacity; + if (inPlace) { + copy.id = entity.id; + copy.groupId = entity.groupId; + } + return copy; +} diff --git a/B07_DesignDetail/openwebcad/src/tools/move-tool.ts b/B07_DesignDetail/openwebcad/src/tools/move-tool.ts index 346be9091..ba19c7073 100644 --- a/B07_DesignDetail/openwebcad/src/tools/move-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/move-tool.ts @@ -16,7 +16,7 @@ import { assign, createMachine, sendTo } from 'xstate'; import { selectToolStateMachine } from './select-tool'; import type { Entity } from '../entities/Entity'; import { compact } from 'es-toolkit'; -import { moveEntities } from './move-tool.helpers'; +import { cloneKeepingLook, moveEntities } from './move-tool.helpers'; import { LineEntity } from '../entities/LineEntity'; import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts'; @@ -211,7 +211,7 @@ export const moveToolStateMachine = createMachine( return { startPoint: context.startPoint, // Make a copy of the selected entities before moving them, so we can restore them when the user cancels the move action - originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())), + originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))), movedEntities: selectedEntities, }; }), @@ -224,7 +224,7 @@ export const moveToolStateMachine = createMachine( // Move the entities to the new location // Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be moved; - const movedEntities = context.originalSelectedEntities.map((entity) => entity.clone()); + const movedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true)); moveEntities( movedEntities, endPointTemp.x - context.startPoint.x, diff --git a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts index 0d792a54e..46888d7a5 100644 --- a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts @@ -16,6 +16,7 @@ import { selectToolStateMachine } from './select-tool'; import type { Entity } from '../entities/Entity'; import { compact } from 'es-toolkit'; import { rotateEntities } from './rotate-tool.helpers'; +import { cloneKeepingLook } from './move-tool.helpers'; export interface RotateContext extends ToolContext { rotationOrigin: Point | null; @@ -231,7 +232,7 @@ export const rotateToolStateMachine = createMachine( return { ...context, // Make a copy of the selected entities before rotating them, so we can restore them when the user cancels the rotate action - originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())), + originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))), }; }), [RotateAction.DRAW_TEMP_ROTATE_ENTITIES]: ({ context, event }) => { @@ -245,7 +246,7 @@ export const rotateToolStateMachine = createMachine( // Draw all selected entities according to rotate vector, so the user gets visual feedback of where the entities will be end up after rotating const rotatedEntities = compact( - context.originalSelectedEntities.map((entity) => entity.clone()) + context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true)) ); rotateEntities( rotatedEntities, @@ -264,7 +265,7 @@ export const rotateToolStateMachine = createMachine( // Rotate the entities one final time const rotatedEntities = compact( - context.originalSelectedEntities.map((entity) => entity.clone()) + context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true)) ); rotateEntities( rotatedEntities, diff --git a/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts b/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts index 99b5ed11c..660a885b2 100644 --- a/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts @@ -16,6 +16,7 @@ import { selectToolStateMachine } from './select-tool'; import type { Entity } from '../entities/Entity'; import { compact } from 'es-toolkit'; import { scaleEntities } from './scale-tool.helpers'; +import { cloneKeepingLook } from './move-tool.helpers'; export interface ScaleContext extends ToolContext { baseVectorStartPoint: Point | null; @@ -234,7 +235,7 @@ export const scaleToolStateMachine = createMachine( return { ...context, // Make a copy of the selected entities before scaling them, so we can restore them when the user cancels the scale action - originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())), + originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))), }; }), [ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => { @@ -248,7 +249,7 @@ export const scaleToolStateMachine = createMachine( // Draw all selected entities according to scale vector, so the user gets visual feedback of where the entities will be end up after scaling const scaledEntities = compact( - context.originalSelectedEntities.map((entity) => entity.clone()) + context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true)) ); scaleEntities( scaledEntities, @@ -267,7 +268,7 @@ export const scaleToolStateMachine = createMachine( // Scale the entities one final time const scaledEntities = compact( - context.originalSelectedEntities.map((entity) => entity.clone()) + context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true)) ); scaleEntities( scaledEntities, diff --git a/resources/tester/cad/clone-keeps-look.test.ts b/resources/tester/cad/clone-keeps-look.test.ts new file mode 100644 index 000000000..bb39eeb68 --- /dev/null +++ b/resources/tester/cad/clone-keeps-look.test.ts @@ -0,0 +1,61 @@ +/** + * 옮기기 · 크기 · 회전 · 복사 · 배열 복제가 색 · 층 · 선 꼴을 지키는지(84-3). + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --config + */ +import { Point } from "@flatten-js/core"; +import { describe, expect, it } from "vitest"; +import { LineEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/LineEntity"; +import { PointEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/PointEntity"; +import { PolyLineEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/PolyLineEntity"; +import { TextEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/TextEntity"; +import { cloneKeepingLook } from "../../../B07_DesignDetail/openwebcad/src/tools/move-tool.helpers"; +import type { Entity } from "../../../B07_DesignDetail/openwebcad/src/entities/Entity"; + +const styled = (entity: T): T => { + entity.lineColor = "#ff0000"; + entity.lineWidth = 2; + entity.lineDash = [4, 2]; + entity.opacity = 0.5; + entity.groupId = "g1"; + return entity; +}; +const look = (entity: Entity) => [ + entity.layerId, + entity.lineColor, + entity.lineWidth, + entity.lineDash, + entity.opacity, +]; +const LOOK = ["구조물", "#ff0000", 2, [4, 2], 0.5]; + +describe("cloneKeepingLook", () => { + const samples: [string, () => Entity][] = [ + ["선", () => styled(new LineEntity("구조물", new Point(0, 0), new Point(1, 0)))], + ["점", () => styled(new PointEntity("구조물", new Point(1, 1)))], + ["글", () => styled(new TextEntity("구조물", "배수관", new Point(0, 0)))], + ]; + for (const [name, make] of samples) { + it(`${name} — 옮기기(inPlace) 는 id · 묶음 · 색 · 층 그대로`, () => { + const source = make(); + const copy = cloneKeepingLook(source, true); + copy.move(5, 5); + expect(look(copy)).toEqual(LOOK); + expect([copy.id, copy.groupId]).toEqual([source.id, "g1"]); + }); + it(`${name} — 복사는 새 id · 색 · 층 그대로`, () => { + const source = make(); + const copy = cloneKeepingLook(source); + expect(look(copy)).toEqual(LOOK); + expect(copy.id).not.toBe(source.id); + }); + } + + it("폴리선 — 자식 선까지 색 · 층 그대로(크기 바꿈 뒤)", () => { + const child = styled(new LineEntity("구조물", new Point(0, 0), new Point(1, 0))); + const polyline = styled(new PolyLineEntity("구조물", [child])); + const copy = cloneKeepingLook(polyline, true) as PolyLineEntity; + copy.scale(new Point(0, 0), 2); + expect(look(copy)).toEqual(LOOK); + expect(look(copy.getEntities()[0])).toEqual(LOOK); + }); +}); From 33119bcc67fa36b7cc5240224bab5b739e4f9454 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:08:15 +0900 Subject: [PATCH 19/47] =?UTF-8?q?feat(B05):=20=EB=85=B8=EB=A9=B4=EA=B3=B5?= =?UTF-8?q?=20=ED=99=95=ED=8F=AD=20=EA=B5=AC=EA=B0=84=20=EC=83=81=EC=9E=90?= =?UTF-8?q?=20=C2=B7=20=EB=AC=BC=EB=84=98=EC=9D=B4=20=EA=B2=B9=EC=B9=A8=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EB=AC=B8=EA=B5=AC=20=C2=B7=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=8B=9C=ED=97=98=20=EB=A7=9E=EC=B6=A4(87-3=20?= =?UTF-8?q?=C2=B7=2087-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상세 내용 아래 「확폭 구간」 상자: 범위 안 확폭 > 0 측점을 이어진 구간으로 묶어 구간마다 켬/끔 단추(기본 켬) · 끈 구간 = options.widening_off "시작-끝,…"(측점 m) · 안 맞는 값 무시 · 횡단 자료 없으면 안내 - 횡단 측점 읽기 손잡이 pipeSections(design_structure_pipe_calc) · 구간 규칙 design_structure_widening(순수) - 물넘이 겹침 안내 = 시작 · 종료 측점을 고치라는 문구 - 시험: test_87_widening_runs(새) · ui_checks 52-10 · 52-15 시작 · 종료 측점 꼴로 맞춤 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../design/design_structure_pipe_calc.ts | 3 + .../design/design_structure_widening.ts | 114 ++++++++++++++++++ B05_Profile/B05_Profile_UI_Structures_Form.ts | 27 +++-- .../B05_Profile_UI_Structures_Pavement.ts | 2 +- B05_Profile/B05_Profile_UI_Structures_Spec.ts | 30 ++++- .../B05_Profile_UI_Structures_Widening.ts | 102 ++++++++++++++++ .../B05_Profile_UI_Style_Structures.css | 22 ++++ resources/tester/helper_87_widening.cjs | 54 +++++++++ resources/tester/test_87_widening_runs.py | 60 +++++++++ .../tester/ui_checks/52-10_노면공_기타.js | 44 +++++-- .../tester/ui_checks/52-15_같은측점_고르기.js | 15 ++- 11 files changed, 444 insertions(+), 29 deletions(-) create mode 100644 A00_Common/design/design_structure_widening.ts create mode 100644 B05_Profile/B05_Profile_UI_Structures_Widening.ts create mode 100644 resources/tester/helper_87_widening.cjs create mode 100644 resources/tester/test_87_widening_runs.py diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts index 91e21143e..54b6bb940 100644 --- a/A00_Common/design/design_structure_pipe_calc.ts +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -20,6 +20,9 @@ export function setPipeSectionsSource(source: () => readonly CrossSection[] | nu sectionsSource = source; } +/** 횡단 측점 목록 읽기 — 노면공 확폭 구간 상자(87-3)가 확폭을 읽음 · 자료 없으면 null. */ +export const pipeSections = (): readonly CrossSection[] | null => sectionsSource?.() ?? null; + /** 유출 단 수를 대는 곳 — 횡단 보기의 세션 값 우선(`countFor`) · 없으면 정본 patch. */ let extraCountSource: ((section: CrossSection) => number) | null = null; export function setPipeExtraCountSource(source: (section: CrossSection) => number): void { diff --git a/A00_Common/design/design_structure_widening.ts b/A00_Common/design/design_structure_widening.ts new file mode 100644 index 000000000..6b841ea38 --- /dev/null +++ b/A00_Common/design/design_structure_widening.ts @@ -0,0 +1,114 @@ +/* ============================================================================= + * design_structure_widening.ts + * 노면공 › 확폭 구간(87-3) — 범위 안에서 확폭(좌 + 우) > 0 인 횡단 측점을 이어진 구간으로 묶고, + * 끈 구간을 `options.widening_off = "시작-끝,시작-끝"`(측점 m · 빈 글 = 모두 켬)으로 읽고 씀. + * 켬 = 확폭 포함 · 끔 = 규격 폭만. 노선이 바뀌어 구간 끝이 안 맞는 끈 값은 무시(= 켬). + * 횡단 형상 쪽(88장)도 이 키를 같은 글로 읽음 — 측점이 범위 ±0.02 m 안. + * import 없음 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +/** 구조물 범위 · 구간 끝 맞춤 허용치(m). */ +export const WIDENING_TOL_M = 0.02; + +/** 횡단 측점에서 읽는 몫만 — 횡단 설계 확폭(좌 · 우) · 설계 없으면 저장된 곡선 확폭(바깥쪽 한 방향). */ +export interface WideningStation { + chainage_m: number; + curve_widening_m?: number | null; + curve_outer_side?: "left" | "right" | null; + design?: { widening_left_m?: number; widening_right_m?: number } | null; +} + +export interface WideningRun { + start_m: number; + end_m: number; + /** 구간 안 확폭(좌 + 우) 최댓값(m). */ + widening_m: number; +} + +/** 한 측점의 확폭(좌 + 우, m) — 없으면 0. */ +export function wideningOf(station: WideningStation): number { + const sum = station.design + ? (station.design.widening_left_m ?? 0) + (station.design.widening_right_m ?? 0) + : station.curve_outer_side + ? (station.curve_widening_m ?? 0) + : 0; + return Number.isFinite(sum) && sum > 0 ? sum : 0; +} + +/** 범위 `[start, end]` 안 확폭 > 0 측점을 이어진 구간(범위 안 측점 목록에서 이웃)으로 묶음. */ +export function wideningRuns( + stations: readonly WideningStation[], + start: number, + end: number, +): WideningRun[] { + const inside = stations + .filter((s) => s.chainage_m >= start - WIDENING_TOL_M && s.chainage_m <= end + WIDENING_TOL_M) + .sort((a, b) => a.chainage_m - b.chainage_m); + const runs: WideningRun[] = []; + let open: WideningRun | null = null; + for (const station of inside) { + const width = wideningOf(station); + if (width <= 0) { + open = null; + continue; + } + if (open) { + open.end_m = station.chainage_m; + open.widening_m = Math.max(open.widening_m, width); + } else { + open = { start_m: station.chainage_m, end_m: station.chainage_m, widening_m: width }; + runs.push(open); + } + } + return runs; +} + +const round = (value: number): number => Number(value.toFixed(2)); + +/** 저장 글 → 끈 구간 쌍. 틀린 조각은 버림. */ +export function parseOff(text: unknown): Array<[number, number]> { + if (typeof text !== "string") return []; + const pairs: Array<[number, number]> = []; + for (const part of text.split(",")) { + const [a, b] = part.split("-").map((token) => Number(token.trim())); + if (part.includes("-") && Number.isFinite(a) && Number.isFinite(b) && b >= a) + pairs.push([a, b]); + } + return pairs; +} + +export const formatOff = (pairs: ReadonlyArray): string => + pairs.map(([a, b]) => `${round(a)}-${round(b)}`).join(","); + +/** 끈 쌍이 이 구간과 맞는가(시작 · 끝 각각 허용치 안). */ +export const pairMatches = (pair: readonly [number, number], run: WideningRun): boolean => + Math.abs(pair[0] - run.start_m) <= WIDENING_TOL_M && + Math.abs(pair[1] - run.end_m) <= WIDENING_TOL_M; + +/** 지금 구간들 중 끈 것만 남긴 글(안 맞는 옛 값은 빠짐). */ +export function offForRuns(runs: readonly WideningRun[], text: unknown): string { + const pairs = parseOff(text); + return formatOff( + runs + .filter((run) => pairs.some((p) => pairMatches(p, run))) + .map((run) => [run.start_m, run.end_m] as const), + ); +} + +/** 구간 하나의 켬/끔을 뒤집은 새 글(맞는 구간 쌍만 남김). */ +export function toggleRun( + runs: readonly WideningRun[], + text: unknown, + target: WideningRun, +): string { + const pairs = parseOff(text); + const isOff = (run: WideningRun): boolean => pairs.some((p) => pairMatches(p, run)); + return formatOff( + runs + .filter((run) => (run === target ? !isOff(run) : isOff(run))) + .map((run) => [run.start_m, run.end_m] as const), + ); +} + +export const isRunOff = (run: WideningRun, text: unknown): boolean => + parseOff(text).some((p) => pairMatches(p, run)); diff --git a/B05_Profile/B05_Profile_UI_Structures_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Form.ts index 7a7c33571..1cbb4d8cc 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Form.ts @@ -29,6 +29,7 @@ import { type FacilityDefinition, } from "./B05_Profile_UI_Structures_Define_Form"; import { createStructureSpecForm } from "./B05_Profile_UI_Structures_Spec"; +import { createWideningBox } from "./B05_Profile_UI_Structures_Widening"; export interface StructuresFormElements { root: HTMLElement; @@ -156,6 +157,16 @@ export function buildStructuresForm(options: { set: groupBoxes.setOne, }); groupBoxes.watch(facilityOptions.paintLinks); + // 노면공 시작~종료 읽기(87-1 길이 출력 · 87-3 확폭 구간 상자가 같이 씀) — 못 읽으면 null + const rangeNow = (): { start: number | null; end: number | null } => { + const step = options.getInterval(); + return { start: startFields.read(step, false), end: endFields.read(step, false) }; + }; + const widening = createWideningBox({ + range: rangeNow, + interval: options.getInterval, + onChange: options.onFacilityChange, + }); // 일반 구조물 명세 컨테이너(PLAN 52-10 · 노면공 등) — 잇기 · 반영 길은 배관 명세와 같음 const structureSpec = createStructureSpecForm( optionRow, @@ -163,19 +174,20 @@ export function buildStructuresForm(options: { options.onFacilityChange, // 노면공 길이 칸(87-1) = 시작~종료 출력 — 못 셈이면 빈 글(「—」) (_c, item) => { - if (item.id !== "length") return ""; - const step = options.getInterval(); - const start = startFields.read(step, false); - const end = endFields.read(step, false); - return start !== null && end !== null && end > start + const { start, end } = rangeNow(); + return item.id === "length" && start !== null && end !== null && end > start ? String(Number((end - start).toFixed(2))) : ""; }, + widening, ); - // 시작 · 종료를 고치면 길이 출력을 다시 그림 + // 시작 · 종료를 고치면 길이 출력 · 확폭 구간을 다시 그림 [startFields, endFields].forEach((fields) => [fields.station, fields.remainder].forEach((input) => - input.addEventListener("change", () => structureSpec.refresh()), + input.addEventListener("change", () => { + structureSpec.refresh(); + widening.refresh(); + }), ), ); groupBoxes.watch(structureSpec.paintLinks); @@ -190,6 +202,7 @@ export function buildStructuresForm(options: { positionDivider, optionRow, structureSpec.root, + widening.root, // 노면공 「확폭 구간」 — 상세 내용 아래(87-3) facilityOptions.root, staleBox, groupBoxes.root, diff --git a/B05_Profile/B05_Profile_UI_Structures_Pavement.ts b/B05_Profile/B05_Profile_UI_Structures_Pavement.ts index a2c435550..ed3a7805d 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Pavement.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Pavement.ts @@ -54,7 +54,7 @@ export async function splitPavementRange( await showConfirmDialog( `지정한 포장 구간(${format(start)}~${format(end)})이 물넘이포장` + `(${format(partial.start)}~${format(partial.end)})의 끝에 걸칩니다.\n` + - "물넘이는 그 자체가 포장이라 겹칠 수 없습니다 — 기준 측점·길이·전후 값을 고쳐 주세요.", + "물넘이는 그 자체가 포장이라 겹칠 수 없습니다 — 시작·종료 측점을 고쳐 주세요.", "확인", ); return null; diff --git a/B05_Profile/B05_Profile_UI_Structures_Spec.ts b/B05_Profile/B05_Profile_UI_Structures_Spec.ts index e5cb9320d..ff2ad8b7e 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Spec.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Spec.ts @@ -18,8 +18,11 @@ import { import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec"; import { optionDefaults } from "./B05_Profile_UI_Structures_Fields"; +import type { WideningBox } from "./B05_Profile_UI_Structures_Widening"; const forms = new WeakMap(); +/** 범위형 명세(노면공 · 87-3)의 「확폭 구간」 상자 — 옵션 칸 짝. */ +const widenings = new WeakMap(); /** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌. */ export const structureSpecOf = (type: StructureType | null): StructureSpec | null => @@ -31,14 +34,19 @@ export function createStructureSpecForm( links: SpecLinks, onChange: () => void, auto?: SpecFormHooks["auto"], + widening?: WideningBox, ): SpecForm { const form = createSpecForm(links, { onChange, auto }); form.root.hidden = true; forms.set(optionRow, form); + if (widening) widenings.set(optionRow, widening); const lock = (): void => { const locked = optionRow.classList.contains("is-locked"); - form.root.inert = locked; - form.root.classList.toggle("is-locked", locked); + for (const root of [form.root, widening?.root]) { + if (!root) continue; + root.inert = locked; + root.classList.toggle("is-locked", locked); + } }; if (typeof MutationObserver !== "undefined") new MutationObserver(lock).observe(optionRow, { @@ -51,7 +59,16 @@ export function createStructureSpecForm( /** 정의 옵션 칸으로 안 그리는 키 — 명세 키 · 명세가 뺀 옛 키. */ export function specOwnedKeys(type: StructureType | null): Set { const spec = structureSpecOf(type); - return new Set(spec ? [...specKeys(spec).keys, ...spec.retired, ...(spec.dropped ?? [])] : []); + return new Set( + spec + ? [ + ...specKeys(spec).keys, + ...spec.retired, + ...(spec.dropped ?? []), + ...(spec.byRange ? ["widening_off"] : []), + ] + : [], + ); } /** 종류 · 값으로 명세 폼을 다시 그림(명세 없는 종류면 가림). */ @@ -62,6 +79,7 @@ export function paintStructureSpec( ): void { const form = forms.get(optionRow); const spec = structureSpecOf(type); + widenings.get(optionRow)?.paint(!!spec?.byRange, values.widening_off); if (!form) return; form.root.hidden = !spec; if (!spec) return; @@ -75,7 +93,9 @@ export function readStructureSpec( type: StructureType | null, ): Record { const form = forms.get(optionRow); - return form && structureSpecOf(type) ? form.read() : {}; + const spec = structureSpecOf(type); + if (!form || !spec) return {}; + return { ...form.read(), ...(spec.byRange ? widenings.get(optionRow)?.read() : {}) }; } /** [수정] 병합에서 폼이 다시 쓰는 키 — 명세 키 + 옛 값 중 선택 컨테이너 머리 키(빼면 지워져야 함). */ @@ -87,5 +107,5 @@ export function specRenderedKeys( if (!spec) return []; const { keys, prefixes } = specKeys(spec); const own = Object.keys(previous ?? {}).filter((key) => prefixes.some((p) => key.startsWith(p))); - return [...keys, ...own, ...(spec.dropped ?? [])]; + return [...keys, ...own, ...(spec.dropped ?? []), ...(spec.byRange ? ["widening_off"] : [])]; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Widening.ts b/B05_Profile/B05_Profile_UI_Structures_Widening.ts new file mode 100644 index 000000000..252ce54f9 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Widening.ts @@ -0,0 +1,102 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Widening.ts + * 노면공 「확폭 구간」 상자(PLAN 87-3) — 상세 내용 아래. 범위 안 확폭 > 0 측점을 이어진 구간으로 묶어 + * 구간마다 단추(켬 = 확폭 포함 · 끔 = 규격 폭만 · 기본 켬). 끈 구간 = `options.widening_off`. + * 규칙(구간 묶기 · 글 읽고 쓰기)은 `design_structure_widening` · 횡단 측점 읽기는 `pipeSections`. + * ========================================================================== */ + +import { pipeSections } from "../A00_Common/design/design_structure_pipe_calc"; +import { + isRunOff, + offForRuns, + toggleRun, + wideningRuns, + type WideningRun, +} from "../A00_Common/design/design_structure_widening"; +import { stationLabel } from "../common_util/common_util_station"; +import { group } from "./B05_Profile_UI_Drainage_Facility_Fields"; + +export interface WideningBox { + root: HTMLElement; + /** 종류 · 저장 값으로 다시 그림(범위형이 아니면 가림). */ + paint: (visible: boolean, stored: string | number | undefined) => void; + /** 저장할 값 — 끈 구간이 없으면 빈 묶음. 횡단 자료가 없으면 저장 값을 그대로 지킴. */ + read: () => Record; + /** 범위 · 횡단 자료가 바뀌었을 때 다시 그림. */ + refresh: () => void; +} + +export function createWideningBox(hooks: { + range: () => { start: number | null; end: number | null }; + interval: () => number; + onChange: () => void; +}): WideningBox { + const box = group("확폭 구간"); + box.root.classList.add("b05-structure__widening"); + box.root.hidden = true; + let off = ""; + + /** 지금 범위의 구간들 — 횡단 자료 · 범위가 없으면 null(+ 안내 글). */ + const runs = (): { list: WideningRun[] | null; note: string } => { + const sections = pipeSections(); + if (!sections?.length) return { list: null, note: "횡단 자료 없음" }; + const { start, end } = hooks.range(); + if (start === null || end === null || end <= start) + return { list: null, note: "시작 · 종료 측점을 넣으면 보임" }; + const list = wideningRuns(sections, start, end); + return { list, note: list.length ? "" : "범위 안 확폭 없음" }; + }; + + const paintBody = (): void => { + const { list, note } = runs(); + if (!list?.length) { + const text = document.createElement("span"); + text.className = "b05-structure__widening-note"; + text.textContent = note; + box.body.replaceChildren(text); + return; + } + const step = hooks.interval(); + box.body.replaceChildren( + ...list.map((run) => { + const on = !isRunOff(run, off); + const button = document.createElement("button"); + button.type = "button"; + button.className = `b05-route__irregular-btn${on ? " is-primary" : ""}`; + button.setAttribute("aria-pressed", String(on)); + button.dataset.wideningRun = `${run.start_m}-${run.end_m}`; + button.textContent = `${stationLabel(run.start_m, step)} ~ ${stationLabel(run.end_m, step)} · 확폭 ${Number(run.widening_m.toFixed(2))} m`; + button.title = on ? "확폭 포함 — 누르면 규격 폭만" : "규격 폭만 — 누르면 확폭 포함"; + button.addEventListener("click", () => { + off = toggleRun(list, off, run); + paintBody(); + hooks.onChange(); + }); + return button; + }), + ); + }; + + // 횡단 자료가 늦게 오면 상자 위에 올 때 다시 그림 + box.root.addEventListener("pointerenter", () => { + if (!box.root.hidden) paintBody(); + }); + + return { + root: box.root, + paint(visible, stored) { + box.root.hidden = !visible; + off = typeof stored === "string" ? stored : ""; + if (visible) paintBody(); + }, + read() { + const { list } = runs(); + const text = list ? offForRuns(list, off) : off; + const out: Record = text ? { widening_off: text } : {}; + return out; + }, + refresh() { + if (!box.root.hidden) paintBody(); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index e0cadecda..9a1311990 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -570,3 +570,25 @@ font-size: var(--text-caption); line-height: 1; } + +/* 노면공 「확폭 구간」 상자(PLAN 87-3) — 구간마다 단추 한 줄 */ +.b05-structure__widening[hidden] { + display: none; +} + +.b05-structure__widening .b05-structure__group-body { + display: flex; + flex-direction: column; + gap: 4px; +} + +.b05-structure__widening .b05-structure__group-body button { + align-self: stretch; + text-align: left; + white-space: normal; +} + +.b05-structure__widening-note { + color: var(--text-muted, inherit); + font-size: var(--text-caption); +} diff --git a/resources/tester/helper_87_widening.cjs b/resources/tester/helper_87_widening.cjs new file mode 100644 index 000000000..4aea3aeda --- /dev/null +++ b/resources/tester/helper_87_widening.cjs @@ -0,0 +1,54 @@ +/* 노면공 확폭 구간 규칙(PLAN 87-3) — `design_structure_widening.ts` 를 그 자리에서 트랜스파일해 Node 로 돌림. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join( + __dirname, + "..", + "..", + "A00_Common", + "design", + "design_structure_widening.ts", +); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", js)(box.exports, box); +const w = box.exports; + +// 곡선 둘(앞 No.1+0~No.3+0 · 뒤 No.5+0~No.6+0) 사이에 직선 측점 하나 — 확폭 좌우 합 +const at = (chainage_m, left = 0, right = 0) => ({ + chainage_m, + design: { widening_left_m: left, widening_right_m: right }, +}); +const stations = [ + at(0), + at(20, 0.3, 0), + at(40, 0.75, 0), + at(60, 0.4, 0), + at(80), + at(100, 0, 0.5), + at(120, 0, 0.6), + at(140), +]; +const runs = w.wideningRuns(stations, 0, 140); +const inner = w.wideningRuns(stations, 30, 110); // 범위가 곡선을 가로지름 +const off = w.formatOff([[20, 60]]); +const out = { + runs, + inner, + none: w.wideningRuns(stations, 0, 10), + fallback: w.wideningOf({ chainage_m: 1, curve_widening_m: 0.9, curve_outer_side: "left" }), + fallbackStraight: w.wideningOf({ chainage_m: 1, curve_widening_m: 0.9, curve_outer_side: null }), + off, + parsed: w.parseOff("20-60, 100-120,bad,5"), + isOff: runs.map((r) => w.isRunOff(r, off)), + toggledOff: w.toggleRun(runs, "", runs[1]), + toggledBoth: w.toggleRun(runs, off, runs[1]), + toggledOn: w.toggleRun(runs, off, runs[0]), + stale: w.offForRuns(runs, "20-60,0-5,100-130"), + empty: w.offForRuns([], "20-60"), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_87_widening_runs.py b/resources/tester/test_87_widening_runs.py new file mode 100644 index 000000000..05a91b85d --- /dev/null +++ b/resources/tester/test_87_widening_runs.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +"""노면공 확폭 구간(PLAN 87-3) — 구간 묶기 · `widening_off` 글 읽고 쓰기 · 안 맞는 값 무시. + +규칙은 Node 헬퍼(`helper_87_widening.cjs`)가 `design_structure_widening.ts` 를 그대로 돌림. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_87_widening.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_곡선_둘은_구간_둘_확폭은_구간_최대(out): + assert out["runs"] == [ + {"start_m": 20, "end_m": 60, "widening_m": 0.75}, + {"start_m": 100, "end_m": 120, "widening_m": 0.6}, + ] + + +def test_범위_안_측점만_묶음(out): + assert out["inner"] == [ + {"start_m": 40, "end_m": 60, "widening_m": 0.75}, + {"start_m": 100, "end_m": 100, "widening_m": 0.5}, + ] + assert out["none"] == [] + + +def test_설계_없으면_곡선_확폭_바깥쪽만(out): + assert out["fallback"] == 0.9 + assert out["fallbackStraight"] == 0 + + +def test_끈_구간_글_왕복(out): + assert out["off"] == "20-60" + assert out["parsed"] == [[20, 60], [100, 120]] # 틀린 조각은 버림 + assert out["isOff"] == [True, False] + assert out["toggledOff"] == "100-120" + assert out["toggledBoth"] == "20-60,100-120" + assert out["toggledOn"] == "" + + +def test_안_맞는_끈_값은_무시_저장_때_빠짐(out): + assert out["stale"] == "20-60" + assert out["empty"] == "" diff --git a/resources/tester/ui_checks/52-10_노면공_기타.js b/resources/tester/ui_checks/52-10_노면공_기타.js index 10ca498ab..efce4d6ff 100644 --- a/resources/tester/ui_checks/52-10_노면공_기타.js +++ b/resources/tester/ui_checks/52-10_노면공_기타.js @@ -8,8 +8,9 @@ * orca eval --expression "window.__m5210='restore'; $(cat 이 파일)" → 추가한 둘 삭제 · [저장] * 쓰는 것: 검증 프로젝트 structures.json(되돌림 단계가 지움) * 기대 수치: - * 포장 — containers ["detail"] · 상세 내용 · 포장사양 선택지 ["콘크리트"] · 높이 0.35 · 잇기 지정 · - * 종류 단위 잇기 줄 가림 · 옛 상세 칸(두께 · 줄눈 …) 안 보임 · 길이 · 전 · 후 칸 보임 + * 포장 — containers ["detail"] · 상세 내용 · 포장사양 선택지 [콘크리트 · 아스콘 · 쇄석] · 칸 차례 포장사양 → 길이(출력 20) → + * 높이 0.35 · 잇기 지정 · 종류 단위 잇기 줄 가림 · 옛 상세 칸(두께 · 줄눈 …) 안 보임 · + * 기준 측점 칸 없음 · 시작 · 종료 칸 보임 · 길이 · 전 · 후 입력 칸 없음 * 기타 — 「상세 종류 이름」 글 칸 = 「52-10 시험 기타」 · 목록 이름도 그 글 · * 선택 컨테이너 「상세 내용」(52-14) 하나만(52-17) — 추가 → [추가] 없음 · 빼기 → 다시 보임 · * 다시 추가 · 비고 「52-14 비고」(길이 상한 100) · 잇기 · 하나 남음 · 종류 단위 잇기 줄 가림 @@ -37,7 +38,7 @@ }; const listItems = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; const ITEMS = [ - { typeId: "pavement_concrete", group: "G", at: [4, 6], list: /콘크리트 포장/ }, + { typeId: "pavement_concrete", group: "G", at: [4, 6], to: [5, 6], list: /콘크리트 포장/ }, { typeId: "etc", group: "호환", at: [5, 2], list: new RegExp(NAME) }, ]; const typeSummary = () => @@ -76,6 +77,16 @@ : null, paveSpec: specCtl("spec")?.value ?? null, height: specCtl("height")?.value ?? null, + itemOrder: boxes[0] + ? [...boxes[0].querySelectorAll("[data-spec-item]")].map((n) => n.dataset.specItem) + : [], + lengthOut: + spec()?.querySelector('[data-spec-item="detail.length"] output')?.textContent ?? null, + anchorHidden: !!sec().querySelectorAll(".b05-structure__position-row > label")[2]?.hidden, + startEndShown: [1, 3].every( + (n) => !sec().querySelectorAll(".b05-structure__position-row > label")[n]?.hidden, + ), + wideningShown: vis(sec().querySelector(".b05-structure__widening")), containerLink: !!boxes[0]?.querySelector(".b05-structure__summary-name.is-assigned"), containerLinks: boxes.map( (b) => !!b.querySelector(".b05-structure__summary-name.is-assigned"), @@ -139,11 +150,20 @@ for (const item of ITEMS) { await pickType(item.group, item.typeId); const row = { lockedBeforePosition: shape(item.typeId).locked }; - const [no, dist] = sec() - .querySelectorAll(".b05-structure__position-row > label")[2] - .querySelectorAll("input"); - fire(no, String(item.at[0])); - fire(dist, String(item.at[1])); + const labels = sec().querySelectorAll(".b05-structure__position-row > label"); + if (item.to) { + // 노면공 = 시작 · 종료 측점만(87-1) — 기준 측점 칸 없음 + const [s0, s1] = labels[1].querySelectorAll("input"); + const [e0, e1] = labels[3].querySelectorAll("input"); + fire(s0, String(item.at[0])); + fire(s1, String(item.at[1])); + fire(e0, String(item.to[0])); + fire(e1, String(item.to[1])); + } else { + const [no, dist] = labels[2].querySelectorAll("input"); + fire(no, String(item.at[0])); + fire(dist, String(item.at[1])); + } await w(300); if (item.typeId === "pavement_concrete") { fire(specCtl("spec"), "콘크리트"); @@ -226,13 +246,17 @@ !pave.missing && JSON.stringify(pave.containers) === '["detail"]' && pave.legend[0] === "상세 내용" && - JSON.stringify(pave.paveChoices) === '["콘크리트"]' && + JSON.stringify(pave.paveChoices) === '["콘크리트","아스콘","쇄석"]' && + JSON.stringify(pave.itemOrder) === '["detail.spec","detail.length","detail.height"]' && + Number(pave.lengthOut) === 20 && + pave.anchorHidden && + pave.startEndShown && pave.paveSpec === "콘크리트" && Number(pave.height) === 0.35 && pave.containerLink && !pave.typeSummaryVisible && !pave.options.some((l) => /두께|줄눈|철망|비닐|포장 폭|확폭/.test(l)) && - ["길이", "기준측점 전", "기준측점 후"].every((l) => pave.options.some((o) => o.startsWith(l))); + !pave.options.some((l) => /^(길이|기준측점 전|기준측점 후)/.test(l)); // 87-1 입력 칸 없음 out.etcOk = !!etc && !etc.missing && diff --git a/resources/tester/ui_checks/52-15_같은측점_고르기.js b/resources/tester/ui_checks/52-15_같은측점_고르기.js index 605392815..4f41b9f7c 100644 --- a/resources/tester/ui_checks/52-15_같은측점_고르기.js +++ b/resources/tester/ui_checks/52-15_같은측점_고르기.js @@ -13,7 +13,7 @@ (async () => { const MODE = window.__m5215 || "check"; const PIPE = /^22\+0 배관/; - const PAVE = /^21\+15~22\+5 콘크리트 포장/; // 기준 22+0 · 길이 10 m + const PAVE = /^21\+15~22\+5 콘크리트 포장/; // 시작 21+15 · 종료 22+5 (10 m) const OTHER = /^4\+5 배관/; const w = (ms) => new Promise((r) => setTimeout(r, ms)); const until = async (fn, ms = 15000) => { @@ -85,13 +85,16 @@ await w(300); fire(t, "pavement_concrete"); await w(1500); - const anchor = () => - sec().querySelectorAll(".b05-structure__position-row > label")[2].querySelectorAll("input"); - fire(anchor()[0], "22"); + // 노면공 = 시작 · 종료 측점만(87-1) — 시작 21+15 · 종료 22+5(10 m) + const pos = (n) => + sec().querySelectorAll(".b05-structure__position-row > label")[n].querySelectorAll("input"); + fire(pos(1)[0], "21"); + fire(pos(1)[1], "15"); + fire(pos(3)[0], "22"); await w(300); - fire(anchor()[1], "0"); + fire(pos(3)[1], "5"); await w(500); - out.anchor = [...anchor()].map((i) => i.value); + out.anchor = [...pos(1), ...pos(3)].map((i) => i.value); fire(specCtl("spec"), "콘크리트"); await w(200); fire(specCtl("height"), "0.35"); From d8c75722f47e4daf4dea6c68da2d25114e902a31 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:08:54 +0900 Subject: [PATCH 20/47] =?UTF-8?q?feat(B07):=20=EA=B3=A0=EB=A5=B8=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=E2=86=92=20=ED=91=9C=20=EC=9E=90=EB=A3=8C?= =?UTF-8?q?=20=EC=9D=B8=EC=8B=9D=20=ED=95=A8=EC=88=98=20=C2=B7=20=ED=91=9C?= =?UTF-8?q?=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=C2=B7=20=EC=84=A0=20=EA=B2=A9?= =?UTF-8?q?=EC=9E=90=20=C2=B7=20=EA=B8=80=20=EC=9E=90=EB=A6=AC=20=ED=8F=B4?= =?UTF-8?q?=EB=B0=B1(83-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- .../src/helpers/table-from-drawing.ts | 381 ++++++++++++++++++ .../tester/cad/table-from-drawing.test.ts | 192 +++++++++ 2 files changed, 573 insertions(+) create mode 100644 B07_DesignDetail/openwebcad/src/helpers/table-from-drawing.ts create mode 100644 resources/tester/cad/table-from-drawing.test.ts diff --git a/B07_DesignDetail/openwebcad/src/helpers/table-from-drawing.ts b/B07_DesignDetail/openwebcad/src/helpers/table-from-drawing.ts new file mode 100644 index 000000000..0f88531ff --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/table-from-drawing.ts @@ -0,0 +1,381 @@ +/** + * 고른 범위 → 표 자료(M02 83장 · 도면 → 면적산출 한 번 옮기기). 상태 없는 순수 함수 — 엔티티 JSON(`toJson` 꼴)만 읽는다. + * ① 표 엔티티(Table) 칸 그대로(병합 = colSpan · rowSpan · 폭 · 높이) + * ② 선 + 글 격자 인식 — 가로 · 세로 선 경계를 허용오차로 묶고(PolyLine 자식 · 사각형 변 풀어 씀 · + * 끊긴 · 겹친 선 합침) 안쪽 선 빠진 곳 = 병합 · 글 자리가 든 칸에 글 + * ③ 둘 다 아님 글 자리로 줄(y 묶음) · 칸(x 차례) 폴백 + * 「1,500」 같은 글은 숫자로. 선이 축에서 기운 것(회전된 표)은 ③ 으로 떨어진다. + * 칸 기하는 `table-geometry.ts`(cellOwners) 와 같은 규칙 — 서버 짝 판정은 `Engine_Frame_Export.table_parts`. + * 시험: `resources/tester/cad/table-from-drawing.test.ts`(node --experimental-strip-types). + */ +import { cellOwners, type TableCells } from './table-geometry.ts'; + +export type DrawingCellValue = string | number; + +export interface DrawingTableMerge { + row: number; + col: number; + rowSpan: number; + colSpan: number; +} + +export interface DrawingTable { + /** table = 표 엔티티 · grid = 선 격자 · text = 글 자리 폴백 */ + kind: 'table' | 'grid' | 'text'; + rows: DrawingCellValue[][]; + merges: DrawingTableMerge[]; + /** 도면 단위(mm) — 폴백(text)은 빈 배열(기본 폭) */ + colWidths: number[]; + rowHeights: number[]; +} + +/** 엔티티 JSON 중 읽는 칸만 */ +export interface DrawingEntityJson { + type: string; + // biome-ignore lint/suspicious/noExplicitAny: 엔티티 종류마다 꼴이 달라 읽는 자리에서 가림 + shapeData?: any; + children?: DrawingEntityJson[] | null; +} + +interface Seg { + x1: number; + y1: number; + x2: number; + y2: number; +} +interface Label { + text: string; + x: number; + y: number; + size: number; +} +interface Interval { + at: number; + from: number; + to: number; +} + +const NUMBER = /^[+\-−]?(\d{1,3}(,\d{3})+|\d+)(\.\d+)?$|^[+\-−]?\.\d+$/; + +/** 「1,500」 · 「-2.5」 → 숫자 · 그 밖은 글 그대로(앞뒤 공백만 뗌) */ +export function cellValue(text: string): DrawingCellValue { + const trimmed = text.trim(); + if (!NUMBER.test(trimmed)) return trimmed; + const value = Number(trimmed.replace(/,/g, '').replace('−', '-')); + return Number.isFinite(value) ? value : trimmed; +} + +const span = (value: number | undefined): number => Math.max(1, Math.round(value ?? 1)); + +/** ① 표 엔티티 → 표 자료 */ +function fromTableEntity(data: { + columnWidths: number[]; + rowHeights: number[]; + cells: TableCells; +}): DrawingTable { + const rowCount = data.rowHeights.length; + const colCount = data.columnWidths.length; + const rows: DrawingCellValue[][] = []; + const merges: DrawingTableMerge[] = []; + for (let row = 0; row < rowCount; row += 1) { + const line: DrawingCellValue[] = []; + for (let col = 0; col < colCount; col += 1) { + const cell = data.cells[row]?.[col]; + line.push(cell ? cellValue(cell.text ?? '') : ''); + if (!cell) continue; + const rowSpan = Math.min(span(cell.rowSpan), rowCount - row); + const colSpan = Math.min(span(cell.colSpan), colCount - col); + if (rowSpan > 1 || colSpan > 1) merges.push({ row, col, rowSpan, colSpan }); + } + rows.push(line); + } + return { + kind: 'table', + rows, + merges, + colWidths: [...data.columnWidths], + rowHeights: [...data.rowHeights], + }; +} + +/** 선 · 글 모으기 — PolyLine 자식 · 사각형 변을 풀어 씀 */ +function collect(entities: DrawingEntityJson[], segs: Seg[], labels: Label[]): void { + for (const entity of entities) { + const data = entity.shapeData; + if (entity.type === 'Line' && data?.startPoint && data?.endPoint) { + segs.push({ + x1: data.startPoint.x, + y1: data.startPoint.y, + x2: data.endPoint.x, + y2: data.endPoint.y, + }); + } else if (entity.type === 'Rectangle' && Array.isArray(data?.points)) { + const points = data.points as { x: number; y: number }[]; + for (let index = 0; index < points.length; index += 1) { + const a = points[index]; + const b = points[(index + 1) % points.length]; + segs.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y }); + } + } else if (entity.type === 'PolyLine' && entity.children) { + collect(entity.children, segs, labels); + } else if (entity.type === 'Text' && data?.basePoint && String(data.label ?? '').trim()) { + labels.push(labelOf(data)); + } + } +} + +/** 글이 놓인 자리 — 자리표 칸이면 basePoint 가 가운데 · 아니면 기준선에서 글 높이 반쯤 위 */ +function labelOf(data: { + label: string; + basePoint: { x: number; y: number }; + options?: { fontSize?: number; boxWidth?: number; boxHeight?: number }; +}): Label { + const size = data.options?.fontSize ?? 2.2; + const boxed = Boolean(data.options?.boxWidth && data.options?.boxHeight); + return { + text: data.label, + x: data.basePoint.x, + y: boxed ? data.basePoint.y : data.basePoint.y + size * 0.35, + size, + }; +} + +/** 값들을 허용오차 안에서 묶어 대표값(평균) 목록 — 오름차순 */ +function cluster(values: number[], tol: number): number[] { + const sorted = [...values].sort((a, b) => a - b); + const groups: number[][] = []; + for (const value of sorted) { + const last = groups[groups.length - 1]; + if (last && value - last[last.length - 1] <= tol) last.push(value); + else groups.push([value]); + } + return groups.map((group) => group.reduce((sum, value) => sum + value, 0) / group.length); +} + +const nearest = (edges: number[], value: number, tol: number): number => { + let best = -1; + let bestGap = tol; + for (let index = 0; index < edges.length; index += 1) { + const gap = Math.abs(edges[index] - value); + if (gap <= bestGap) { + best = index; + bestGap = gap; + } + } + return best; +}; + +/** 한 경계 위 선 구간들이 [from, to] 를 거의(90%) 덮는가 — 끊긴 · 겹친 선은 합쳐 셈 */ +function covers(intervals: Interval[], from: number, to: number, tol: number): boolean { + const parts = intervals + .map((part) => [Math.max(part.from, from), Math.min(part.to, to)] as const) + .filter(([a, b]) => b > a) + .sort((p, q) => p[0] - q[0]); + let covered = 0; + let reach = from; + for (const [a, b] of parts) { + const start = Math.max(a, reach); + if (b > start) covered += b - start; + reach = Math.max(reach, b); + } + return covered >= (to - from) * 0.9 - tol; +} + +/** ② 선 + 글 격자 — 경계가 2×2 안 되거나 글이 반 넘게 격자 밖이면 null */ +function fromGrid(segs: Seg[], labels: Label[]): DrawingTable | null { + const box = { x0: Infinity, x1: -Infinity, y0: Infinity, y1: -Infinity }; + for (const s of segs) { + box.x0 = Math.min(box.x0, s.x1, s.x2); + box.x1 = Math.max(box.x1, s.x1, s.x2); + box.y0 = Math.min(box.y0, s.y1, s.y2); + box.y1 = Math.max(box.y1, s.y1, s.y2); + } + const extent = Math.max(box.x1 - box.x0, box.y1 - box.y0, 1e-9); + const tol = extent * 0.003; + const horizontal: Seg[] = []; + const vertical: Seg[] = []; + for (const s of segs) { + const dx = Math.abs(s.x2 - s.x1); + const dy = Math.abs(s.y2 - s.y1); + if (Math.max(dx, dy) <= tol) continue; // 점 같은 선 + if (dy <= dx * 0.01) horizontal.push(s); + else if (dx <= dy * 0.01) vertical.push(s); + } + let colEdges = cluster( + vertical.map((s) => (s.x1 + s.x2) / 2), + tol + ); + let rowEdges = cluster( + horizontal.map((s) => (s.y1 + s.y2) / 2), + tol + ).reverse(); // 위 → 아래 + const lineOf = (list: Seg[], edges: number[], vertical: boolean): Interval[][] => { + const byEdge: Interval[][] = edges.map(() => []); + for (const s of list) { + const at = vertical ? (s.x1 + s.x2) / 2 : (s.y1 + s.y2) / 2; + const index = nearest(edges, at, tol); + if (index < 0) continue; + const [a, b] = vertical ? [s.y1, s.y2] : [s.x1, s.x2]; + byEdge[index].push({ at, from: Math.min(a, b), to: Math.max(a, b) }); + } + return byEdge; + }; + // 칸 경계를 하나도 덮지 못하는 짧은 선(눈금 · 지시선)의 경계는 뺀다 — 두 번이면 자리 잡음 + for (let pass = 0; pass < 2; pass += 1) { + if (colEdges.length < 2 || rowEdges.length < 2) return null; + const vLines = lineOf(vertical, colEdges, true); + const hLines = lineOf(horizontal, rowEdges, false); + const keepCol = colEdges.map((_, i) => + rowEdges.slice(1).some((y, j) => covers(vLines[i], y + tol, rowEdges[j] - tol, tol)) + ); + const keepRow = rowEdges.map((_, j) => + colEdges.slice(1).some((x, i) => covers(hLines[j], colEdges[i] + tol, x - tol, tol)) + ); + colEdges = colEdges.filter((_, i) => keepCol[i]); + rowEdges = rowEdges.filter((_, j) => keepRow[j]); + } + if (colEdges.length < 2 || rowEdges.length < 2) return null; + const colCount = colEdges.length - 1; + const rowCount = rowEdges.length - 1; + const vLines = lineOf(vertical, colEdges, true); + const hLines = lineOf(horizontal, rowEdges, false); + + // 안쪽 선 빠진 곳 = 같은 묶음(union-find) + const parent = Array.from({ length: rowCount * colCount }, (_, i) => i); + const find = (i: number): number => { + while (parent[i] !== i) { + parent[i] = parent[parent[i]]; + i = parent[i]; + } + return i; + }; + const join = (a: number, b: number): void => { + parent[find(a)] = find(b); + }; + for (let r = 0; r < rowCount; r += 1) { + for (let c = 1; c < colCount; c += 1) { + if (!covers(vLines[c], rowEdges[r + 1] + tol, rowEdges[r] - tol, tol)) + join(r * colCount + c - 1, r * colCount + c); + } + } + for (let r = 1; r < rowCount; r += 1) { + for (let c = 0; c < colCount; c += 1) { + if (!covers(hLines[r], colEdges[c] + tol, colEdges[c + 1] - tol, tol)) + join((r - 1) * colCount + c, r * colCount + c); + } + } + // 묶음이 꽉 찬 사각형이면 병합 — 아니면(ㄱ 자 등) 칸 따로 + const groups = new Map(); + for (let i = 0; i < rowCount * colCount; i += 1) { + const root = find(i); + const members = groups.get(root); + if (members) members.push(i); + else groups.set(root, [i]); + } + const cells: TableCells = Array.from({ length: rowCount }, () => + Array.from({ length: colCount }, () => ({ text: '' })) + ); + for (const members of groups.values()) { + if (members.length < 2) continue; + const rs = members.map((i) => Math.floor(i / colCount)); + const cs = members.map((i) => i % colCount); + const [r0, r1, c0, c1] = [Math.min(...rs), Math.max(...rs), Math.min(...cs), Math.max(...cs)]; + if ((r1 - r0 + 1) * (c1 - c0 + 1) !== members.length) continue; + cells[r0][c0] = { text: '', rowSpan: r1 - r0 + 1, colSpan: c1 - c0 + 1 }; + for (let r = r0; r <= r1; r += 1) + for (let c = c0; c <= c1; c += 1) if (r !== r0 || c !== c0) cells[r][c] = null; + } + + // 글 → 칸(병합이면 앵커) · 한 칸 여러 글은 위 → 아래 · 왼 → 오른 차례로 띄어 이음 + const owners = cellOwners(cells, rowCount, colCount); + const texts = new Map(); + let outside = 0; + for (const label of labels) { + const c = colEdges.findIndex( + (x, i) => i < colCount && label.x >= x && label.x <= colEdges[i + 1] + ); + const r = rowEdges.findIndex( + (y, j) => j < rowCount && label.y <= y && label.y >= rowEdges[j + 1] + ); + if (c < 0 || r < 0) { + outside += 1; + continue; + } + const key = owners[r][c] ?? `${r}:${c}`; + const inCell = texts.get(key); + if (inCell) inCell.push(label); + else texts.set(key, [label]); + } + if (!labels.length || outside * 2 > labels.length) return null; + const rows: DrawingCellValue[][] = cells.map((line) => line.map(() => '')); + const merges: DrawingTableMerge[] = []; + cells.forEach((line, r) => + line.forEach((cell, c) => { + if (!cell) return; + if ((cell.rowSpan ?? 1) > 1 || (cell.colSpan ?? 1) > 1) + merges.push({ row: r, col: c, rowSpan: cell.rowSpan ?? 1, colSpan: cell.colSpan ?? 1 }); + const inCell = texts.get(`${r}:${c}`) ?? []; + const sorted = sortReading(inCell); + if (sorted.length) rows[r][c] = cellValue(sorted.map((label) => label.text.trim()).join(' ')); + }) + ); + return { + kind: 'grid', + rows, + merges, + colWidths: colEdges.slice(1).map((x, i) => x - colEdges[i]), + rowHeights: rowEdges.slice(1).map((y, j) => rowEdges[j] - y), + }; +} + +/** 읽는 차례 — 줄(y, 글 높이 반 안이면 같은 줄) 위 → 아래 · 줄 안 왼 → 오른 */ +function textLines(labels: Label[]): Label[][] { + const sorted = [...labels].sort((a, b) => b.y - a.y); + const lines: { y: number; size: number; items: Label[] }[] = []; + for (const label of sorted) { + const last = lines[lines.length - 1]; + if (last && last.y - label.y <= Math.max(last.size, label.size) * 0.5) last.items.push(label); + else lines.push({ y: label.y, size: label.size, items: [label] }); + } + return lines.map((line) => line.items.sort((a, b) => a.x - b.x)); +} + +const sortReading = (labels: Label[]): Label[] => textLines(labels).flat(); + +/** ③ 글 자리 폴백 — 줄마다 x 차례로 칸 */ +function fromText(labels: Label[]): DrawingTable { + const lines = textLines(labels); + const width = Math.max(...lines.map((line) => line.length)); + return { + kind: 'text', + rows: lines.map((line) => { + const values: DrawingCellValue[] = line.map((label) => cellValue(label.text)); + while (values.length < width) values.push(''); + return values; + }), + merges: [], + colWidths: [], + rowHeights: [], + }; +} + +/** + * 고른 엔티티 JSON → 표 자료. 표 엔티티가 있으면 칸이 가장 많은 표 하나 · 없으면 선 격자 · 그래도 아니면 글 자리. + * 표로 옮길 글이 하나도 없으면(빈 범위 · 글 없는 선) null. + */ +export function tableFromDrawing(entities: DrawingEntityJson[]): DrawingTable | null { + const tables = entities.filter( + (entity) => entity.type === 'Table' && entity.shapeData?.columnWidths?.length + ); + if (tables.length) { + const size = (entity: DrawingEntityJson) => + entity.shapeData.columnWidths.length * entity.shapeData.rowHeights.length; + const biggest = tables.reduce((best, entity) => (size(entity) > size(best) ? entity : best)); + return fromTableEntity(biggest.shapeData); + } + const segs: Seg[] = []; + const labels: Label[] = []; + collect(entities, segs, labels); + if (!labels.length) return null; + return (segs.length ? fromGrid(segs, labels) : null) ?? fromText(labels); +} diff --git a/resources/tester/cad/table-from-drawing.test.ts b/resources/tester/cad/table-from-drawing.test.ts new file mode 100644 index 000000000..0a49213f3 --- /dev/null +++ b/resources/tester/cad/table-from-drawing.test.ts @@ -0,0 +1,192 @@ +/** + * 고른 범위 → 표 자료(`helpers/table-from-drawing.ts` · M02 83-1) 검증. + * 실행: node --experimental-strip-types resources/tester/cad/table-from-drawing.test.ts + * 견본 셋 = 표 엔티티 · 선 표(병합 · PolyLine · 끊긴 선) · 글만 + 빈 범위 · 글 없는 선 · 회전된 표. + */ +import assert from "node:assert/strict"; +import { + cellValue, + tableFromDrawing, + type DrawingEntityJson, +} from "../../../B07_DesignDetail/openwebcad/src/helpers/table-from-drawing.ts"; + +const line = (x1: number, y1: number, x2: number, y2: number): DrawingEntityJson => ({ + type: "Line", + shapeData: { startPoint: { x: x1, y: y1 }, endPoint: { x: x2, y: y2 } }, +}); +const text = (label: string, x: number, y: number, fontSize = 2.5): DrawingEntityJson => ({ + type: "Text", + shapeData: { label, basePoint: { x, y }, options: { fontSize, textAlign: "left" } }, +}); + +// 숫자 글 +assert.equal(cellValue("1,500"), 1500, "천 단위 쉼표"); +assert.equal(cellValue(" -2.75 "), -2.75, "음수 소수"); +assert.equal(cellValue("12,34"), "12,34", "쉼표 자리 틀리면 글"); +assert.equal(cellValue("D600"), "D600", "글 그대로"); +assert.equal(cellValue("1,234,567.5"), 1234567.5); + +// ① 표 엔티티 — 칸 그대로 · 병합 · 폭 · 높이 +const tableEntity: DrawingEntityJson = { + type: "Table", + shapeData: { + origin: { x: 0, y: 100 }, + columnWidths: [30, 20, 25], + rowHeights: [8, 6], + cells: [ + [{ text: "구분", rowSpan: 2 }, { text: "수량", colSpan: 2 }, null], + [null, { text: "1,500" }, { text: "m2" }], + ], + }, +}; +const t1 = tableFromDrawing([tableEntity, text("밖 글", 500, 500)]); +assert.ok(t1); +assert.equal(t1.kind, "table"); +assert.deepEqual(t1.rows, [ + ["구분", "수량", ""], + ["", 1500, "m2"], +]); +assert.deepEqual(t1.merges, [ + { row: 0, col: 0, rowSpan: 2, colSpan: 1 }, + { row: 0, col: 1, rowSpan: 1, colSpan: 2 }, +]); +assert.deepEqual(t1.colWidths, [30, 20, 25]); +assert.deepEqual(t1.rowHeights, [8, 6]); + +// ② 선 표 — 3열 × 3행 · 맨 위 줄 가운데 세로선 빠짐(병합) · 바깥 테두리는 PolyLine · 가로선 한 줄은 끊겨 둘 +// x 경계 0 · 40 · 70 · 100 / y 경계 60 · 50 · 40 · 30 +const outline: DrawingEntityJson = { + type: "PolyLine", + shapeData: null, + children: [ + line(0, 60, 100, 60), + line(100, 60, 100, 30), + line(100, 30, 0, 30), + line(0, 30, 0, 60), + ], +}; +const gridEntities: DrawingEntityJson[] = [ + outline, + line(0, 50, 55, 50), // 끊긴 가로선 둘(겹침 5) + line(50, 50.05, 100, 50.05), // 살짝 어긋남(허용오차 안) + line(0, 40, 100, 40), + line(40, 60, 40, 30), // 세로선 — 맨 위 줄도 지남 + line(70, 50, 70, 30), // 맨 위 줄엔 없음 → (0,1)~(0,2) 병합 + text("구분", 2, 53), + text("수량", 45, 53), + text("흙깎기", 2, 43), + text("1,500", 42, 43), + text("m3", 72, 43), + text("돌쌓기", 2, 33), + text("2.5", 42, 33), + text("m2", 72, 33), + text("(전면)", 2, 31), // 같은 칸 두 줄 → 「돌쌓기 (전면)」 +]; +const t2 = tableFromDrawing(gridEntities); +assert.ok(t2); +assert.equal(t2.kind, "grid"); +assert.deepEqual(t2.rows, [ + ["구분", "수량", ""], + ["흙깎기", 1500, "m3"], + ["돌쌓기 (전면)", 2.5, "m2"], +]); +assert.deepEqual(t2.merges, [{ row: 0, col: 1, rowSpan: 1, colSpan: 2 }]); +assert.deepEqual( + t2.colWidths.map((w) => Math.round(w)), + [40, 30, 30], +); +assert.deepEqual( + t2.rowHeights.map((h) => Math.round(h)), + [10, 10, 10], +); + +// 사각형 엔티티 칸 · 짧은 눈금 선은 경계로 안 침 +const rectGrid = tableFromDrawing([ + { + type: "Rectangle", + shapeData: { + points: [ + { x: 0, y: 0 }, + { x: 20, y: 0 }, + { x: 20, y: 10 }, + { x: 0, y: 10 }, + ], + }, + }, + { + type: "Rectangle", + shapeData: { + points: [ + { x: 20, y: 0 }, + { x: 50, y: 0 }, + { x: 50, y: 10 }, + { x: 20, y: 10 }, + ], + }, + }, + line(35, 0, 35, 1), // 눈금 + text("가", 2, 4), + text("나", 22, 4), +]); +assert.ok(rectGrid); +assert.equal(rectGrid.kind, "grid"); +assert.deepEqual(rectGrid.rows, [["가", "나"]]); +assert.deepEqual(rectGrid.colWidths, [20, 30]); + +// ③ 글만 — 줄(y 묶음) · 칸(x 차례) · 모자란 칸은 빈 칸 +const t3 = tableFromDrawing([ + text("합계", 0, 10), + text("3,000", 40, 10.3), + text("비고", 80, 9.8), + text("소계", 0, 0), + text("1,000", 40, 0), +]); +assert.ok(t3); +assert.equal(t3.kind, "text"); +assert.deepEqual(t3.rows, [ + ["합계", 3000, "비고"], + ["소계", 1000, ""], +]); +assert.deepEqual(t3.merges, []); + +// 빈 범위 · 글 없는 선 = null +assert.equal(tableFromDrawing([]), null, "빈 범위"); +assert.equal( + tableFromDrawing([line(0, 0, 10, 0), line(0, 5, 10, 5), line(0, 0, 0, 5), line(10, 0, 10, 5)]), + null, + "글 없는 선", +); + +// 회전된 표(30°) — 축 선이 없어 글 자리 폴백 +const rot = (x: number, y: number): [number, number] => { + const a = Math.PI / 6; + return [x * Math.cos(a) - y * Math.sin(a), x * Math.sin(a) + y * Math.cos(a)]; +}; +const rline = (x1: number, y1: number, x2: number, y2: number) => + line(...rot(x1, y1), ...rot(x2, y2)); +const t4 = tableFromDrawing([ + rline(0, 0, 40, 0), + rline(0, 10, 40, 10), + rline(0, 0, 0, 10), + rline(40, 0, 40, 10), + rline(20, 0, 20, 10), + text("A", ...rot(2, 4)), + text("B", ...rot(22, 4)), +]); +assert.ok(t4); +assert.equal(t4.kind, "text"); +assert.equal(t4.rows.flat().filter(Boolean).length, 2, "글 둘 다 살아 있음"); + +// 격자 밖 글이 반 넘으면 폴백(표가 아닌 범위) +const t5 = tableFromDrawing([ + line(0, 0, 10, 0), + line(0, 10, 10, 10), + line(0, 0, 0, 10), + line(10, 0, 10, 10), + text("안", 2, 4), + text("밖1", 50, 50), + text("밖2", 80, 50), +]); +assert.equal(t5?.kind, "text"); + +console.log("table-from-drawing: 통과"); From 4e9df0e4c947126b927557cef2b0d1b57552fce9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:09:29 +0900 Subject: [PATCH 21/47] =?UTF-8?q?feat(B07):=20CAD=20=EB=AA=85=EB=A0=B9=20T?= =?UTF-8?q?OAREA=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B4=EB=82=B4=EA=B8=B0=20=C2=B7=20=EB=B6=80=EB=AA=A8=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20export-table=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?(83-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- .../openwebcad/src/commands/commands.file.ts | 9 ++ .../openwebcad/src/commands/registry.ts | 1 + .../src/integration/aislo-table-export.ts | 83 +++++++++++ B07_DesignDetail/openwebcad/src/main.tsx | 2 + resources/tester/cad/table-export.test.ts | 141 ++++++++++++++++++ 5 files changed, 236 insertions(+) create mode 100644 B07_DesignDetail/openwebcad/src/integration/aislo-table-export.ts create mode 100644 resources/tester/cad/table-export.test.ts diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.file.ts b/B07_DesignDetail/openwebcad/src/commands/commands.file.ts index bfc01319b..f5978a9bd 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.file.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.file.ts @@ -4,6 +4,7 @@ import { clearRecovery, restoreRecovery } from '../helpers/autosave'; import { exportEntitiesToJsonFile } from '../helpers/import-export-handlers/export-entities-to-json'; import { exportEntitiesToLocalStorage } from '../helpers/import-export-handlers/export-entities-to-local-storage'; import { requestDrawingExport, requestHostSave } from '../integration/aislo-drawing-bridge'; +import { runSendSelectionTable } from '../integration/aislo-table-export'; import { exportEntitiesToPngFile } from '../helpers/import-export-handlers/export-entities-to-png'; import { exportEntitiesToSvgFile } from '../helpers/import-export-handlers/export-entities-to-svg'; import { isHostSave, redo, undo } from '../state'; @@ -87,6 +88,14 @@ export const FILE_COMMANDS: CadCommand[] = [ return 'PNG 내보내기'; }, }, + { + id: 'TOAREA', + label: '면적산출로 보내기', + glyph: '▦', + hint: '고른 범위(표 · 선 표 · 글)를 면적산출 새 시트로 한 번 옮긴다', + needsSelection: true, + run: runSendSelectionTable, + }, { id: 'UNDO', label: '실행 취소', diff --git a/B07_DesignDetail/openwebcad/src/commands/registry.ts b/B07_DesignDetail/openwebcad/src/commands/registry.ts index 1409ee81d..f6974dcb2 100644 --- a/B07_DesignDetail/openwebcad/src/commands/registry.ts +++ b/B07_DesignDetail/openwebcad/src/commands/registry.ts @@ -89,6 +89,7 @@ const VIEW_ONLY_COMMAND_IDS: ReadonlySet = new Set([ 'EXPORTPNG', 'COPYCLIP', 'COPYBASE', + 'TOAREA', // 고른 범위 → 면적산출 표(M02 83장) ]); /** 확정한 도면에서 이 명령을 실행해도 되는가. */ diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-table-export.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-table-export.ts new file mode 100644 index 000000000..20f8da719 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-table-export.ts @@ -0,0 +1,83 @@ +/** + * CAD → 부모 화면 표 보내기(M02 83장 · 도면 → 면적산출 한 번 옮기기 · 연동 아님). 짝 = `M02_MasterTemplete_AreaDrawing.ts`. + * export-table-request 부모 [면적산출로 보내기] 단추 → 고른 범위를 표 자료로 + * export-table{table} 표 자료(`helpers/table-from-drawing.ts` 꼴) → 부모가 새 시트로 + * CAD 명령 TOAREA(「면적산출로 보내기」)도 같은 길. 보기 전용 도면에서도 됨(도면을 안 바꿈). + * 고른 것이 없거나 옮길 글이 없으면 부모에 안 보내고 안내만. + */ +import { toast } from 'react-toastify'; +import type { DrawingEntityJson } from '../helpers/table-from-drawing.ts'; +import { type DrawingTable, tableFromDrawing } from '../helpers/table-from-drawing.ts'; +import { getSelectedEntities } from '../state.ts'; + +export const AISLO_EXPORT_TABLE_REQUEST_MESSAGE = 'aislo:b08:export-table-request'; +export const AISLO_EXPORT_TABLE_MESSAGE = 'aislo:b08:export-table'; + +const TEXT = { + empty: '면적산출로 보낼 범위를 먼저 고르세요(누름 · 끌기).', + noText: '고른 범위에 글이 없어 표로 옮길 것이 없습니다.', + standalone: '면적산출 화면에서 연 도면에서만 보낼 수 있습니다.', +}; + +type Notify = (type: string, payload: Record) => void; + +function notifyParent(type: string, payload: Record): void { + if (window.parent === window) return; + window.parent.postMessage({ type, ...payload }, window.location.origin); +} + +/** 고른 범위 → 표 자료 · 고른 것이 없으면 'empty' · 글이 없으면 'no-text' */ +export async function selectionTable(): Promise { + const selected = getSelectedEntities(); + if (!selected.length) return 'empty'; + const json = (await Promise.all(selected.map((entity) => entity.toJson()))).filter( + (entity): entity is NonNullable => Boolean(entity) + ); + return tableFromDrawing(json as DrawingEntityJson[]) ?? 'no-text'; +} + +/** 고른 범위를 표로 만들어 부모에 보냄 — 보냈으면 true */ +export async function sendSelectionTable(notify: Notify = notifyParent): Promise { + const result = await selectionTable(); + if (result === 'empty') toast.info(TEXT.empty); + else if (result === 'no-text') toast.info(TEXT.noText); + else { + notify(AISLO_EXPORT_TABLE_MESSAGE, { table: result }); + return true; + } + return false; +} + +/** CAD 명령 TOAREA — 부모 없이 띄운 CAD(단독)면 안내만 */ +export function runSendSelectionTable(): string { + if (window.parent === window) { + toast.info(TEXT.standalone); + return '부모 화면 없음'; + } + void sendSelectionTable(); + return '면적산출로 보내기'; +} + +/** 부모 요청 메시지면 처리하고 true */ +export async function handleTableExportMessage( + data: unknown, + notify: Notify = notifyParent +): Promise { + const message = data as { type?: string } | null; + if ( + !message || + typeof message !== 'object' || + message.type !== AISLO_EXPORT_TABLE_REQUEST_MESSAGE + ) + return false; + await sendSelectionTable(notify); + return true; +} + +/** 부모 [면적산출로 보내기] 요청을 받음 — 같은 출처 · 부모 창만 */ +export function registerAisloTableExport(): void { + window.addEventListener('message', (event: MessageEvent) => { + if (event.origin !== window.location.origin || event.source !== window.parent) return; + void handleTableExportMessage(event.data); + }); +} diff --git a/B07_DesignDetail/openwebcad/src/main.tsx b/B07_DesignDetail/openwebcad/src/main.tsx index 9a42948d5..043965ab2 100644 --- a/B07_DesignDetail/openwebcad/src/main.tsx +++ b/B07_DesignDetail/openwebcad/src/main.tsx @@ -18,6 +18,7 @@ import { queryEntitiesNearPoint } from './helpers/spatial-index'; import { trackHoveredSnapPoint } from './helpers/track-hovered-snap-points'; import { InputController } from './inputController/input-controller.ts'; import { registerAisloDrawingBridge } from './integration/aislo-drawing-bridge.ts'; +import { registerAisloTableExport } from './integration/aislo-table-export.ts'; import { registerDxfClipPaste } from './integration/dxf-clip-paste.ts'; import { getActiveToolActor, @@ -155,6 +156,7 @@ function initApplication() { setLayers(layers); setActiveLayerId(layers[0].id); registerAisloDrawingBridge(); + registerAisloTableExport(); // 고른 범위 → 면적산출 표(M02 83장) registerDxfClipPaste(); registerCadDebugHook(); registerAutoSave(); diff --git a/resources/tester/cad/table-export.test.ts b/resources/tester/cad/table-export.test.ts new file mode 100644 index 000000000..318a99d04 --- /dev/null +++ b/resources/tester/cad/table-export.test.ts @@ -0,0 +1,141 @@ +/** + * CAD → 부모 표 보내기(`integration/aislo-table-export.ts` · M02 83-2) 검증. + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad table-export + * (시험 파일은 바깥 라이브러리를 직접 부르지 않음 — 엔티티는 fromJson 으로 만듦) + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + getCommandById, + isViewOnlyCommand, +} from "../../../B07_DesignDetail/openwebcad/src/commands/registry.ts"; +import type { JsonEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/Entity.ts"; +import { LineEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/LineEntity.ts"; +import { TextEntity } from "../../../B07_DesignDetail/openwebcad/src/entities/TextEntity.ts"; +import { + AISLO_EXPORT_TABLE_MESSAGE, + AISLO_EXPORT_TABLE_REQUEST_MESSAGE, + handleTableExportMessage, + selectionTable, +} from "../../../B07_DesignDetail/openwebcad/src/integration/aislo-table-export.ts"; +import { + getEntities, + setEntities, + setLayers, + setSelectedEntityIds, +} from "../../../B07_DesignDetail/openwebcad/src/state.ts"; + +const layer = { id: "layer-1", isLocked: false, isVisible: true, name: "작업" }; + +const line = (x1: number, y1: number, x2: number, y2: number) => + LineEntity.fromJson({ + id: crypto.randomUUID(), + type: "Line", + lineColor: "#fff", + lineWidth: 1, + layerId: layer.id, + shapeData: { startPoint: { x: x1, y: y1 }, endPoint: { x: x2, y: y2 } }, + } as unknown as JsonEntity); +const text = (label: string, x: number, y: number) => + TextEntity.fromJson({ + id: crypto.randomUUID(), + type: "Text", + lineColor: "#fff", + lineWidth: 1, + layerId: layer.id, + shapeData: { + label, + basePoint: { x, y }, + options: { + textDirection: { x: 1, y: 0 }, + textAlign: "left", + textColor: "#fff", + fontSize: 2.5, + fontFamily: "Noto Sans KR", + }, + }, + } as unknown as JsonEntity); + +/** 2×2 선 표 + 글 넷 + 표 밖 선 하나(고르지 않음) */ +async function drawing() { + const parts = await Promise.all([ + line(0, 20, 40, 20), + line(0, 10, 40, 10), + line(0, 0, 40, 0), + line(0, 0, 0, 20), + line(20, 0, 20, 20), + line(40, 0, 40, 20), + text("항목", 2, 14), + text("수량", 22, 14), + text("흙깎기", 2, 4), + text("1,500", 22, 4), + ]); + const outside = await line(100, 100, 200, 100); + setEntities([...parts, outside]); + return { parts, outside }; +} + +beforeEach(() => { + setLayers([layer]); + setEntities([]); + setSelectedEntityIds([]); +}); + +describe("고른 범위 → 표 자료", () => { + it("고른 선 + 글만 표로 · 숫자 글은 숫자", async () => { + const { parts } = await drawing(); + setSelectedEntityIds(parts.map((entity) => entity.id)); + const table = await selectionTable(); + expect(table).toMatchObject({ + kind: "grid", + rows: [ + ["항목", "수량"], + ["흙깎기", 1500], + ], + colWidths: [20, 20], + }); + }); + + it("고른 것 없음 = empty · 글 없는 선 = no-text", async () => { + const { outside } = await drawing(); + expect(await selectionTable()).toBe("empty"); + setSelectedEntityIds([outside.id]); + expect(await selectionTable()).toBe("no-text"); + }); +}); + +describe("부모 요청 메시지", () => { + it("export-table-request = 고른 범위를 export-table{table} 로 회신 · 도면은 그대로", async () => { + const { parts } = await drawing(); + setSelectedEntityIds(parts.map((entity) => entity.id)); + const before = getEntities(); + const notify = vi.fn(); + expect( + await handleTableExportMessage({ type: AISLO_EXPORT_TABLE_REQUEST_MESSAGE }, notify), + ).toBe(true); + expect(notify).toHaveBeenCalledTimes(1); + const [type, payload] = notify.mock.calls[0]; + expect(type).toBe(AISLO_EXPORT_TABLE_MESSAGE); + expect(payload.table.rows[1]).toEqual(["흙깎기", 1500]); + expect(getEntities()).toBe(before); + }); + + it("고른 것이 없으면 회신 안 함 · 다른 메시지는 false", async () => { + await drawing(); + const notify = vi.fn(); + expect( + await handleTableExportMessage({ type: AISLO_EXPORT_TABLE_REQUEST_MESSAGE }, notify), + ).toBe(true); + expect(notify).not.toHaveBeenCalled(); + expect(await handleTableExportMessage({ type: "aislo:b08:save-request" }, notify)).toBe(false); + expect(await handleTableExportMessage(null, notify)).toBe(false); + }); +}); + +describe("CAD 명령 TOAREA", () => { + it("고른 것이 있어야 하고 · 확정(보기 전용) 도면에서도 됨", () => { + const command = getCommandById("TOAREA"); + expect(command?.label).toBe("면적산출로 보내기"); + expect(command?.needsSelection).toBe(true); + expect(command && isViewOnlyCommand(command)).toBe(true); + }); +}); From eaeff398dc942ac3c5d060e5f90bc5aefdb9210e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:09:49 +0900 Subject: [PATCH 22/47] =?UTF-8?q?feat(M02):=20=EC=9B=90=EB=AC=B8=20?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=E2=80=94=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=ED=96=89=20=EB=8B=A8=EC=B6=94=20=C2=B7=20pdf=20?= =?UTF-8?q?=ED=86=B5=EC=A7=B8=20=EA=B8=B8(=EC=B1=85=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EB=A7=8C)=20=C2=B7=20=EC=9B=90=EB=AC=B8=20=EC=9D=BD=EA=B8=B0?= =?UTF-8?q?=20M02=20=EB=8C=80=EB=A6=AC=20=EA=B8=B8(85-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- .../M01_MasterData_UI_LogicLab_Source.ts | 43 +++++++++++++--- .../M02_MasterTemplete_Labor_Detail.ts | 6 ++- .../M02_MasterTemplete_Router_Combos.py | 29 +++++++++++ resources/tester/test_m01_logic_source.py | 50 +++++++++++++++++++ 4 files changed, 121 insertions(+), 7 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts index 2de0b8895..05eff5feb 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_Source.ts * 원문 보기(품셈 본문 절) — 출처 글로 서버가 찾음(`GET /m01/logic/source`) · 늦게 온 답은 버림 - * M01 로직 상세 「원문」 패널 · M02 일위대가 상세 「원문」 패널(47-4)이 같이 씀 + * M01 로직 상세 「원문」 패널 · M02 일위대가 상세 「원문」 패널(47-4)이 같이 씀 · + * 길은 부르는 쪽이 줌(85-4 · M02 = `/m02/logic-lib/source` + 제목 행 [원문 다운로드] pdf 통째) * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -16,6 +17,7 @@ const TEXT = { From: ["출처", "Source"], NoSource: ["출처 없음", "No source"], Near: ["{v} 절이 없어 위 절", "No {v} section — showing the parent"], + Download: ["원문 다운로드", "Download source"], } as const; const ts = (key: keyof typeof TEXT, v = ""): string => @@ -70,15 +72,38 @@ export interface SourceView { show(src: string): void; } -export function createSourceView(): SourceView { +/** 출처 글 → 원문 pdf 책(85-4) — 모르면 null */ +const bookOf = (src: string): string | null => + src.startsWith("건설품셈") ? "건설" : src.startsWith("산림품셈") ? "산림" : null; + +export interface SourceViewOptions { + /** 원문 절 길(기본 M01 `/m01/logic/source` — 시스템 관리자) */ + path?: string; + /** 원문 pdf 길(`?book=`) — 주면 제목 행 오른쪽 [원문 다운로드] */ + pdf?: string; +} + +export function createSourceView(opts: SourceViewOptions = {}): SourceView { const body = el("div", { className: "m01lab-src__body" }); const title = el("strong", { className: "m02-dfp__title" }); + const download = opts.pdf + ? (el("a", { + className: "ui-btn ui-btn--ghost ui-btn--sm", + text: ts("Download"), + attrs: { download: "", "data-source-pdf": "" }, + }) as HTMLAnchorElement) + : null; let shown: string | null = null; const show = (src: string): void => { if (shown === src) return; shown = src; title.textContent = src || ts("NoSource"); + if (download) { + const book = bookOf(src); + download.hidden = !book; + download.href = book ? `${API_BASE_URL}${opts.pdf}?${new URLSearchParams({ book })}` : ""; + } body.replaceChildren(el("p", { className: "m01-logic__muted", text: ts("Loading") })); const none = (): void => body.replaceChildren( @@ -86,9 +111,12 @@ export function createSourceView(): SourceView { el("p", { className: "m01-logic__muted", text: `${ts("From")} · ${src || "—"}` }), ); if (!src.trim()) return none(); - void fetch(`${API_BASE_URL}/m01/logic/source?${new URLSearchParams({ src })}`, { - credentials: "include", - }) + void fetch( + `${API_BASE_URL}${opts.path ?? "/m01/logic/source"}?${new URLSearchParams({ src })}`, + { + credentials: "include", + }, + ) .then((r) => (r.ok ? (r.json() as Promise<{ found: Found[] }>) : { found: [] })) .catch(() => ({ found: [] as Found[] })) .then(({ found }) => { @@ -115,5 +143,8 @@ export function createSourceView(): SourceView { }); }; - return { root: el("div", { className: "m01lab-src", children: [title, body] }), show }; + const head = download + ? el("div", { className: "m02-dfp__head", children: [title, download] }) + : title; + return { root: el("div", { className: "m01lab-src", children: [head, body] }), show }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 5f9415e7b..5f8b7821c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -131,7 +131,11 @@ export function mountLaborDetail( }); /** 원문 패널(47-4 · 카드 갈래엔 없음) — 열려 있을 때만 찾음 · 고른 줄 없으면 안내 한 줄 */ - const source = createSourceView(); + // 85-4 — M02 길(로그인 사용자 모두) · 제목 행 [원문 다운로드] + const source = createSourceView({ + path: "/m02/logic-lib/source", + pdf: "/m02/logic-lib/source-pdf", + }); const sourceHost = el("div", { attrs: { "data-labor-source": "" } }); const side = opt.card ? null diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 4dfbcc95f..d31f40c65 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -7,14 +7,19 @@ 담을 로직 고르기 · 일위대가 입력 모달이 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은 `/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음. 39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌). +85-4 — 원문 패널 읽기(`/logic-lib/source` · M01 길과 같은 몸) · 원문 pdf 통째 +(`/logic-lib/source-pdf` · 책 이름만 받음 · 경로 안 받음)도 로그인만. """ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import Response from common_util.common_util_auth import verify_session +from common_util.common_util_http_cache import cached_file_response from M01_MasterData import M01_MasterData_Store as m01 +from M01_MasterData import M01_MasterData_Store_Make as make from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos @@ -100,3 +105,27 @@ def get_logic_auto(key: str) -> dict: def get_logic_inputs(keys: str = "") -> dict: """일위대가 입력 모달 — 로직 키들(쉼표)의 입력 목록 + 자동값(`M02_Labor_Inputs`).""" return _read(labor_inputs.logic_inputs, keys.split(",")) + + +#: 원문 pdf(85-4) — 책 이름 → 파일(쪽 자르기 없음) +SOURCE_PDF = { + "건설": make.BODY / "건설공사_표준품셈" / "2026년_건설공사_표준품셈.pdf", + "산림": make.BODY / "산림_표준품셈" / "첨부" / "(산림청고시 제2025-82호) 산림사업 표준품셈.pdf", +} + + +@router.get("/logic-lib/source", dependencies=[Depends(verify_session)]) +def get_logic_source(src: str) -> dict: + """원문 패널 — 출처 글로 찾은 품셈 본문 절(M01 `/logic/source` 와 같은 몸 · 로그인만).""" + return _read(make.source, src) + + +@router.get("/logic-lib/source-pdf", dependencies=[Depends(verify_session)]) +def get_source_pdf(request: Request, book: str) -> Response: + """원문 pdf 통째 내려받기 — `book` = 건설 | 산림(그 밖 400).""" + path = SOURCE_PDF.get(book) + if path is None: + raise HTTPException(status_code=400, detail="책은 건설 · 산림 중 하나") + if not path.is_file(): + raise HTTPException(status_code=404, detail="원문 pdf 없음") + return cached_file_response(request, path, "application/pdf", filename=path.name) diff --git a/resources/tester/test_m01_logic_source.py b/resources/tester/test_m01_logic_source.py index 5c595c771..6d23437af 100644 --- a/resources/tester/test_m01_logic_source.py +++ b/resources/tester/test_m01_logic_source.py @@ -104,3 +104,53 @@ def test_item_all_marked() -> None: total += 1 missing += not any(f.get("item") for f in make.source(src)["found"]) assert total >= 30 and missing == 0, (total, missing) + + +def _m02_client(role: str = "USER") -> TestClient: + from common_util.common_util_auth import verify_session + from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos + + app = FastAPI() + app.include_router(combos.router) + app.dependency_overrides[verify_session] = lambda: {"user_id": 42, "role": role} + return TestClient(app) + + +def test_m02_원문_대리_길과_pdf() -> None: + """85-4 — 일반 사용자도 원문(M01 길과 같은 몸) · pdf 통째(책 이름만 · 그 밖 400 · 한글 파일명).""" + from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos + + client = _m02_client() + got = client.get("/api/m02/logic-lib/source", params={"src": "산림품셈 3-1"}) + assert got.status_code == 200 and got.json() == make.source("산림품셈 3-1") + for book in ("건설", "산림"): + res = client.get("/api/m02/logic-lib/source-pdf", params={"book": book}) + assert res.status_code == 200 and res.headers["content-type"] == "application/pdf" + assert ( + len(res.content) == combos.SOURCE_PDF[book].stat().st_size + and res.content[:4] == b"%PDF" + ) + assert "filename*=utf-8''" in res.headers["content-disposition"] + again = client.get( + "/api/m02/logic-lib/source-pdf", + params={"book": book}, + headers={"if-none-match": res.headers["etag"]}, + ) + assert again.status_code == 304 + for bad in ("../../main.py", "건설품셈", ""): + assert client.get("/api/m02/logic-lib/source-pdf", params={"book": bad}).status_code == 400 + + +def test_원문_패널_길_주입() -> None: + """85-4 — M01 은 옛 길 그대로(기본값) · M02 일위대가 상세는 M02 길 + pdf.""" + from pathlib import Path + + root = Path(__file__).resolve().parents[2] + src = (root / "M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts").read_text(encoding="utf-8") + assert 'opts.path ?? "/m01/logic/source"' in src + side = (root / "M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts").read_text(encoding="utf-8") + assert "createSourceView()" in side + labor = (root / "M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts").read_text( + encoding="utf-8" + ) + assert 'path: "/m02/logic-lib/source"' in labor and 'pdf: "/m02/logic-lib/source-pdf"' in labor From caf38ccb3e137f6c8130e538d81a109713d37832 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:10:49 +0900 Subject: [PATCH 23/47] =?UTF-8?q?feat(M02):=20CAD=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=ED=83=AD=20=EC=A4=84=20=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EB=8F=84=EB=A9=B4=20=EC=83=81=EC=84=B8=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EC=9E=A5=20=ED=83=AD=20=C2=B7=20=EC=B9=B4=EB=93=9C=20=E3=80=8C?= =?UTF-8?q?=EC=9E=A5=20n=E3=80=8D(82-1=20=C2=B7=2082-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui_template/cad_host/cad_host_tabs — 고르기 · [+] · 이름 · 지우기 · 끌어 순서 · 닫기(켬/끔) · 바꿀 때 지금 장 받아 두고 싣기 - mountStructureSheets — 1장 고정 + 더한장 · 백업 칸 장마다 · 장 도번 도각 · 안 실린 장 표 갈기 - 상세 열기 · 저장 = 도면 · 더한장 칸 · 카드 1장 + 장 수 · 도각 도번 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../M02_MasterTemplete_Drawing.css | 24 ++ .../M02_MasterTemplete_Drawing.ts | 214 +++++++++++- .../M02_MasterTemplete_DrawingCard.ts | 10 + .../M02_MasterTemplete_UI_Main.ts | 11 +- resources/tester/test_cad_host_tabs.py | 221 ++++++++++++ resources/tester/test_m02_structure_panel.py | 7 +- ui_template/cad_host/cad_host_tabs.css | 95 +++++ ui_template/cad_host/cad_host_tabs.ts | 330 ++++++++++++++++++ 8 files changed, 885 insertions(+), 27 deletions(-) create mode 100644 resources/tester/test_cad_host_tabs.py create mode 100644 ui_template/cad_host/cad_host_tabs.css create mode 100644 ui_template/cad_host/cad_host_tabs.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.css b/M02_MasterTemplete/M02_MasterTemplete_Drawing.css index 4a9153a91..488d56cee 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.css @@ -86,3 +86,27 @@ max-width: calc(100vw - 16px); box-shadow: 0 4px 16px rgb(0 0 0 / 25%); } + +/* 카드 미리보기 「장 n」(82-2) — 1장만 보이는 여러 장 도면 */ +.m02-drawing:has(> .m02-drawing__sheets) { + position: relative; +} + +.m02-drawing__sheets { + position: absolute; + z-index: 3; + top: 6px; + right: 8px; + padding: 1px 8px; + border: 1px solid var(--color-border); + border-radius: 10px; + background: var(--color-surface-raised); + color: var(--color-text-secondary); + font-size: 11px; + pointer-events: none; +} + +/* 탭 줄(82-1) — 그림 칸에 붙임 */ +.m02-drawing > .cad-tabs { + margin-bottom: calc(-1 * var(--spacing-8)); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts index fe5fabb1a..26d2af925 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts @@ -13,6 +13,7 @@ import "./M02_MasterTemplete_Drawing.css"; import { API_BASE_URL } from "@config/config_frontend"; import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host"; +import { createCadTabs, type CadTab, type CadTabs } from "@ui/cad_host/cad_host_tabs"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; @@ -51,13 +52,19 @@ export interface DrawingTemplateOptions { frame?: boolean; /** 파일 불러오기(📂) 단추를 안 둠 — 집계표 카드 미리보기(불러오기는 도면 상세에서만). */ noImport?: boolean; + /** 장id(82-1) — 자동백업 칸을 장마다 나눔(1장 = 빈 값 · 옛 칸 그대로) */ + sheet?: string; + /** 장 도번(82-2) — 얹은 도각 `{{도면번호}}` 자리 */ + frameNumber?: string; + /** 탭 줄(82-1) — 그림 칸 바로 위에 놓음 */ + tabs?: HTMLElement; } const RECOVERY_PREFIX = "OPEN_WEB_CAD__RECOVERY__"; /** 자동백업 칸 이름 — 층(과 프로젝트)을 붙여 같은 이름의 양식끼리 안 겹치게 한다. */ -function recoveryScopeOf(options: DrawingTemplateOptions): string { - const name = options.name ?? "drawing"; +function recoveryScopeOf(options: DrawingTemplateOptions, sheet = ""): string { + const name = `${options.name ?? "drawing"}${sheet ? `#${sheet}` : ""}`; if (!options.layer) return `m02:${name}`; const project = options.layer === "project" ? `:${options.projectId ?? ""}` : ""; return `m02:${options.layer}${project}:${name}`; @@ -84,6 +91,8 @@ export interface DrawingTemplateHandle { /** 표 놓기 · 칸 갈기(41장) — `cad_host` 그대로 */ placeTable: (entity: Record, link: string) => void; updateTable: (link: string, cells: unknown[][]) => Promise; + /** 다른 장을 같은 CAD 에 실음(82-1) — 백업 칸 · 도각 도번도 그 장 것으로 */ + loadSheet: (drawing: DrawingTemplateDoc, sheet: string, frameNumber?: string) => void; destroy: () => void; } @@ -98,18 +107,22 @@ interface StructureFrame { layer: Record | null; } const BLANK_LAYER = { id: "기본", name: "기본", isVisible: true, isLocked: false }; -let framePromise: Promise | null = null; +const framePromises = new Map>(); -/** 구조물 도각 — 한 번만 받아 돌려씀 · 못 받으면 도각 없이 */ -function fetchFrame(): Promise { - framePromise ??= fetch(`${API_BASE_URL}/m02/structure-frame`, { credentials: "include" }) - .then((response) => (response.ok ? (response.json() as Promise) : null)) - .catch(() => null) - .then((frame) => { - if (!frame?.layer) framePromise = null; // 다음에 다시 받음 - return frame?.layer ? frame : null; - }); - return framePromise; +/** 구조물 도각 — 장 도번마다 한 번만 받아 돌려씀(`{{도면번호}}` · 82-2) · 못 받으면 도각 없이 */ +function fetchFrame(number = ""): Promise { + const query = number ? `?number=${encodeURIComponent(number)}` : ""; + const got = + framePromises.get(number) ?? + fetch(`${API_BASE_URL}/m02/structure-frame${query}`, { credentials: "include" }) + .then((response) => (response.ok ? (response.json() as Promise) : null)) + .catch(() => null) + .then((frame) => { + if (!frame?.layer) framePromises.delete(number); // 다음에 다시 받음 + return frame?.layer ? frame : null; + }); + framePromises.set(number, got); + return got; } /** 도각 층을 뺀 도면 — 구조물 엔티티 · 층만 남음 */ @@ -147,8 +160,9 @@ export function mountDrawingTemplate( ): DrawingTemplateHandle { const readOnly = options.readOnly === true; const noArea = options.noArea === true; - const recoveryScope = recoveryScopeOf(options); - migrateOldRecovery(options, recoveryScope); + let recoveryScope = recoveryScopeOf(options, options.sheet); + let frameNumber = options.frameNumber ?? ""; + migrateOldRecovery(options, recoveryScopeOf(options)); let base: DrawingTemplateDoc = doc; const root = document.createElement("div"); @@ -238,7 +252,7 @@ export function mountDrawingTemplate( const load = async (drawing: DrawingTemplateDoc): Promise => { const mine = ++loadSeq; cad.beginLoading(); - const frame = options.frame ? await fetchFrame() : null; + const frame = options.frame ? await fetchFrame(frameNumber) : null; if (mine !== loadSeq) return; // 그새 다른 도면을 실음 const own = withoutFrame(drawing); const shown = frame?.layer @@ -319,11 +333,12 @@ export function mountDrawingTemplate( // 한 줄 — 페이지가 머리 칸(headerSlot)을 주면 거기, 아니면 그림 칸 위에 작게. toolbar.append(...(noArea ? [] : [areaButton]), actions); + const tabs = options.tabs ? [options.tabs] : []; if (options.headerSlot) { toolbar.classList.add("m02-drawing__toolbar--slot"); options.headerSlot.append(toolbar); - root.append(cad.element); - } else root.append(...(noArea && !canImport ? [] : [toolbar]), cad.element); + root.append(...tabs, cad.element); + } else root.append(...(noArea && !canImport ? [] : [toolbar]), ...tabs, cad.element); container.append(root); void load(doc); @@ -331,6 +346,12 @@ export function mountDrawingTemplate( getDoc, placeTable: cad.placeTable, updateTable: cad.updateTable, + loadSheet: (drawing, sheet, number) => { + recoveryScope = recoveryScopeOf(options, sheet); + frameNumber = number ?? ""; + base = drawing; + void load(drawing); + }, destroy: () => { areaModal?.close(); cad.destroy(); @@ -339,3 +360,160 @@ export function mountDrawingTemplate( }, }; } + +/* --- 구조물 도면 여러 장(82-1 · 82-2) — 한 파일 `도면`(1장) + `더한장[]` · 탭 줄 = 공용 `cad_host_tabs` --- */ + +/** 구조물 도면 더한 장 — `M02_MasterTemplete_Api_Fetch` `StructureSheet` 와 같은 꼴 */ +interface SheetDoc { + 장id: string; + 이름: string; + 도면: DrawingTemplateDoc; +} +/** 구조물 도면 문서에서 쓰는 칸 */ +interface SheetsDoc { + 도번?: string; + 도면: DrawingTemplateDoc; + 더한장?: SheetDoc[]; +} + +const FIRST_TAB = "#1"; // 1장 탭 id — 장id 는 「#」 를 안 씀(저장은 빈 값) +const FIRST_NAME = "1장"; + +/** 장 도번 — 1장 = 도번 · n 째 장 = 도번-n(SD-000010-2) */ +export const sheetNumber = (number: string | undefined, index: number): string => + !number ? "" : index === 0 ? number : `${number}-${index + 1}`; + +/** 표 칸 갈기 — 안 실린 장(받아 둔 편집본)에서 · 크기는 칸 수가 바뀔 때만(서버 `refresh_drawing` 과 같음) */ +function updateStashedTables( + drawing: DrawingTemplateDoc, + link: string, + cells: unknown[][], +): number { + let count = 0; + for (const entity of drawing.entities) { + const shape = (entity as { type?: string; shapeData?: Record }).shapeData; + const style = shape?.style as Record | undefined; + if (entity.type !== "Table" || !shape || style?.연결 !== link) continue; + shape.cells = cells; + count += 1; + } + return count; +} + +export interface StructureSheetsHandle extends Omit { + /** `{도면, 더한장?}` — 부르는 쪽이 읽은 문서에 얹음 */ + getDoc: () => Promise<{ 도면: DrawingTemplateDoc; 더한장?: SheetDoc[] }>; + /** 지금 탭 장id(1장 = 빈 값) */ + activeSheet: () => string; +} + +/** 구조물 도면 상세 — 탭 줄 + 한 CAD · 탭 바꾸면 지금 장을 받아 두고 다음 장을 실음 */ +export function mountStructureSheets( + container: HTMLElement, + doc: SheetsDoc, + options: DrawingTemplateOptions = {}, +): StructureSheetsHandle { + const readOnly = options.readOnly === true; + const more = doc.더한장 ?? []; + let cadDirty = false; + let listDirty = false; + const report = (): void => options.onChanged?.(cadDirty || listDirty || tabs.anyDirty()); + const numberOf = (id: string): string => + sheetNumber( + doc.도번, + tabs.tabs().findIndex((tab) => tab.id === id), + ); + + const tabs: CadTabs = createCadTabs({ + features: readOnly ? {} : { add: true, rename: true, remove: true, reorder: true }, + capture: async () => ({ drawing: await drawing.getDoc(), dirty: cadDirty }), + show: (tab) => { + cadDirty = false; + drawing.loadSheet( + tab.drawing ?? { entities: [], layers: [BLANK_LAYER] }, + tab.id === FIRST_TAB ? "" : tab.id, + numberOf(tab.id), + ); + report(); + }, + create: (): CadTab => { + const names = new Set(tabs.tabs().map((tab) => tab.name)); + let n = tabs.tabs().length + 1; + while (names.has(`${n}장`)) n += 1; + const id = `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return { + id, + name: `${n}장`, + drawing: { format: 6, entities: [], layers: [{ ...BLANK_LAYER }] }, + }; + }, + onChange: () => { + listDirty = true; + retitle(); + report(); + }, + }); + const retitle = (): void => { + for (const tab of tabs.tabs()) tab.title = numberOf(tab.id); + }; + tabs.set( + [ + { id: FIRST_TAB, name: FIRST_NAME, drawing: doc.도면, fixed: true }, + ...more.map((sheet) => ({ id: sheet.장id, name: sheet.이름, drawing: sheet.도면 })), + ], + FIRST_TAB, + ); + retitle(); + tabs.set(tabs.tabs(), FIRST_TAB); // 풍선 글을 그림 + if (readOnly && !more.length) tabs.element.hidden = true; // 볼 장이 하나 — 줄 없음 + + const drawing = mountDrawingTemplate(container, doc.도면, { + ...options, + sheet: "", + frameNumber: doc.도번, + tabs: tabs.element, + onChanged: (dirty) => { + cadDirty = dirty; + report(); + }, + }); + + return { + getDoc: async () => { + const all = await tabs.collect(); + const first = all.find((tab) => tab.id === FIRST_TAB); + const rest = all + .filter((tab) => tab.id !== FIRST_TAB) + .map((tab) => ({ + 장id: tab.id, + 이름: tab.name, + 도면: tab.drawing ?? { format: 6, entities: [], layers: [{ ...BLANK_LAYER }] }, + })); + listDirty = false; + tabs.markClean(true); + return { + 도면: first?.drawing ?? doc.도면, + ...(rest.length || doc.더한장 ? { 더한장: rest } : {}), + }; + }, + activeSheet: () => { + const id = tabs.active()?.id ?? FIRST_TAB; + return id === FIRST_TAB ? "" : id; + }, + placeTable: drawing.placeTable, + // 실린 장은 CAD · 안 실린 장은 받아 둔 편집본에서(82-3) + updateTable: async (link, cells) => { + const now = tabs.active(); + let count = 0; + for (const tab of tabs.tabs()) { + if (tab === now || !tab.drawing) continue; + const changed = updateStashedTables(tab.drawing, link, cells); + if (changed) tab.dirty = true; + count += changed; + } + if (count) report(); + return count + (await drawing.updateTable(link, cells).catch(() => 0)); + }, + destroy: drawing.destroy, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts b/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts index 43d010d1e..cf2d79135 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts @@ -82,11 +82,21 @@ function mountStructure( originMarker: true, // 좌표 0,0 기준점 십자 noArea: true, // 작도 영역 칸 없음 frame: true, // 도각 = 보여주기만 · 외곽 좌하단 (0,0)(56장) + frameNumber: doc.도번, // 도각 `{{도면번호}}` = 1장 도번(82-2) noImport: true, // 파일 불러오기는 도면 상세에서만(47-2) ...hooks, }); const frame = host.querySelector("iframe"); if (frame) hideChrome(frame, edit ? HIDE_EDIT : HIDE_VIEW); + // 여러 장(82-2) — 미리보기는 1장 · 장 수만 알림(다른 장은 도면 상세 탭에서) + const sheets = 1 + (doc.더한장?.length ?? 0); + if (sheets > 1) { + const badge = document.createElement("span"); + badge.className = "m02-drawing__sheets"; + badge.textContent = `장 ${sheets}`; + badge.title = `1장을 보는 중 · 모두 ${sheets}장 — 다른 장은 도면 상세에서`; + host.querySelector(".m02-drawing")?.append(badge); + } return handle; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 8bdf22c42..d5ce4ae27 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -416,14 +416,13 @@ export function createMain( // 열 때만 `import()` 로 받음(계약 `spreadsheet_types.ts` 머리) return mountSheet(doc, at.kind, at, readOnly); } + // 구조물 도면 = 여러 장 탭(82-1 · `도면` 1장 + `더한장`) — 도번은 안 건드림 const mountDrawing = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>( DRAWING_MODS, - "mountDrawingTemplate", + at.kind === "structure" ? "mountStructureSheets" : "mountDrawingTemplate", ); if (mountDrawing) { - // 구조물 도면은 CAD 문서가 `도면` 칸에 들어 있음 — 도번은 안 건드림 - const cad = at.kind === "structure" ? (doc as StructureDoc).도면 : doc; - editor = mountDrawing(host, cad, { + editor = mountDrawing(host, doc, { onSave: () => void doSave(), readOnly, name: at.name, @@ -504,13 +503,13 @@ export function createMain( const at = sel; try { const edited = editor ? await editor.getDoc() : loaded; - // 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번은 읽은 그대로. + // 구조물 도면 저장은 `도면` · `더한장` 칸만 갈아 끼움 — 도번은 읽은 그대로. // 면적산출은 스프레드시트 문서로 통째 갈아 끼우되 번호는 계약 밖(엔진이 모름)이라 옮겨 붙임. const doc = at.kind === "structure" && loaded ? toDrawing.withLinks({ ...(loaded as StructureDoc), - 도면: edited as StructureDoc["도면"], + ...(edited as Pick), }) : at.kind === "area" && loaded ? { ...(loaded as Record), ...(edited as Record) } diff --git a/resources/tester/test_cad_host_tabs.py b/resources/tester/test_cad_host_tabs.py new file mode 100644 index 000000000..96b6c78bc --- /dev/null +++ b/resources/tester/test_cad_host_tabs.py @@ -0,0 +1,221 @@ +"""CAD 공용 탭 줄(PLAN 82-1 `cad_host_tabs.ts`) · M02 구조물 도면 여러 장(82-2 `mountStructureSheets`) +— Node 로 TS 를 돌려 판정(가짜 DOM · 가짜 CAD). + +못박는 것 + - 탭 바꿀 때 지금 장을 받아 두고(capture) 다음 장을 실음 · 탭 셋 왕복 뒤 장마다 엔티티 수 그대로. + - 고정 탭(1장) 앞으로는 못 끎 · 지우기 · 같은 이름 막음. + - 여러 장 문서 getDoc = `도면`(1장) + `더한장[{장id, 이름, 도면}]` · 새 장 · 장 도번 SD-1-2 · 백업 칸 장마다. + - 안 실린 장 표 갈기(updateTable) = 받아 둔 장에서. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = r""" +const fs = require("fs"); +const root = process.argv[1]; +const ts = require(root + "/config/node_modules/typescript"); +const load = (file, stubs) => { + const src = fs.readFileSync(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) => + n.endsWith(".css") ? {} : (stubs[n] ?? require(n)), + ); + return box.exports; +}; +// 가짜 DOM — 쓰는 것만 +const node = (tag) => ({ + tag, kids: [], dataset: {}, on: {}, attrs: {}, hidden: false, textContent: "", title: "", className: "", + classList: { add() {}, toggle() {} }, style: {}, + append(...x) { this.kids.push(...x); }, prepend(...x) { this.kids.unshift(...x); }, + replaceChildren(...x) { this.kids = x; }, replaceWith() {}, remove() {}, select() {}, focus() {}, + setAttribute(k, v) { this.attrs[k] = v; }, addEventListener(k, f) { this.on[k] = f; }, + getBoundingClientRect: () => ({ left: 0, width: 100 }), querySelector: () => null, +}); +globalThis.document = { createElement: node }; +const toasts = []; +const confirms = []; +const elements = { + showToast: (m, k) => toasts.push([k, m]), + showConfirmDialog: async (m) => (confirms.push(m), true), + createButton: () => node("button"), + createInputField: (o) => ({ root: node("div"), input: { value: o.value, disabled: false, addEventListener() {} }, setError() {} }), +}; +const tabsMod = load("ui_template/cad_host/cad_host_tabs.ts", { "@ui/ui_template_elements": elements }); + +(async () => { + const out = {}; + // ① 탭 셋 왕복 — CAD 하나에 장을 갈아 실음 + let shown = { id: "a", n: 3 }; + const log = []; + const tabs = tabsMod.createCadTabs({ + features: { add: true, rename: true, remove: true, reorder: true }, + capture: async () => ({ drawing: { ...shown, n: shown.n + (shown.id === "b" ? 1 : 0) }, dirty: shown.id === "b" }), + show: (tab) => (log.push(tab.id), (shown = tab.drawing)), + onChange: () => log.push("change"), + }); + tabs.set([ + { id: "a", name: "1장", drawing: { id: "a", n: 3 }, fixed: true }, + { id: "b", name: "2장", drawing: { id: "b", n: 5 } }, + { id: "c", name: "3장", drawing: { id: "c", n: 0 } }, + ], "a"); + for (const id of ["b", "c", "a", "b", "a"]) await tabs.select(id); + const all = await tabs.collect(); + out.roundtrip = all.map((t) => [t.id, t.drawing.n, !!t.dirty]); + out.shownOrder = log.filter((x) => x !== "change"); + // ② 끌기 — c 를 a(고정) 앞에 놓아도 a 뒤 + const tabNode = (id) => tabs.element.kids.find((k) => k.dataset.tabId === id); + tabNode("c").on.dragstart({ dataTransfer: { setData() {} } }); + tabNode("a").on.dragover({ preventDefault() {}, clientX: 10 }); + tabNode("a").on.drop({ preventDefault() {} }); + out.order = tabs.tabs().map((t) => t.id); + out.fixedDraggable = !!tabNode("a").draggable; + out.fixedX = tabNode("a").kids.length; // 고정 탭 = 글만(지우기 없음) + // ③ 지우기 — 물음 뒤 · 지금 탭이면 다음 탭을 실음 + await tabs.select("c"); + const xc = tabNode("c").kids.find((k) => k.className === "cad-tabs__x"); + await xc.on.click({ stopPropagation() {} }); + out.afterRemove = { ids: tabs.tabs().map((t) => t.id), active: tabs.active().id, confirms: confirms.length }; + // ④ [+] — create 없으면 아무것도 안 함 · 닫기 기능(B07) = 미저장 탭만 물음 + const b07 = []; + const closer = tabsMod.createCadTabs({ + features: { close: true }, + capture: async () => ({ drawing: "편집본", dirty: true }), + show: (tab) => b07.push(["show", tab.id, tab.drawing ?? null, !!tab.dirty]), + onClosed: (tab) => b07.push(["closed", tab.id]), + }); + await closer.open({ id: "d1", name: "종단" }); + await closer.open({ id: "d2", name: "횡단" }); + await closer.open({ id: "d1", name: "종단" }); + const before = confirms.length; + const d2 = closer.element.kids.find((k) => k.dataset.tabId === "d2"); + await d2.kids.find((k) => k.className === "cad-tabs__x").on.click({ stopPropagation() {} }); + out.b07 = { log: b07, asked: confirms.length - before, ids: closer.tabs().map((t) => t.id) }; + + // ⑤ M02 여러 장 — 가짜 CAD(requestSave = 지금 실린 도면 + 편집) + const cadLoads = []; + let inCad = null; + const fakeCad = { + element: node("div"), + beginLoading() {}, + load: (drawing, meta, frameEdit, fields, extra) => (cadLoads.push(extra.recoveryScope), (inCad = drawing)), + requestSave: async () => ({ drawing: inCad, quantityTable: null }), + placeTable() {}, + updateTable: async () => 1, + destroy() {}, + }; + const urls = []; + globalThis.fetch = async (url) => (urls.push(url), { ok: true, json: async () => ({ entities: [{ type: "Text", url, layerId: "m02-structure-frame" }], layer: { id: "m02-structure-frame" } }) }); + const drawingMod = load("M02_MasterTemplete/M02_MasterTemplete_Drawing.ts", { + "@config/config_frontend": { API_BASE_URL: "/api" }, + "@ui/cad_host/cad_host": { createCadHost: () => fakeCad }, + "@ui/cad_host/cad_host_tabs": tabsMod, + "@ui/ui_template_elements": elements, + "@ui/ui_template_modal": { openModal: () => ({ close() {} }) }, + }); + const table = (link) => ({ type: "Table", shapeData: { cells: [[{ text: "옛" }]], style: { 연결: link } } }); + const doc = { + 도번: "SD-1", + 도면: { format: 6, entities: [{ type: "Line" }, { type: "Line" }], layers: [] }, + 더한장: [{ 장id: "s2", 이름: "단면", 도면: { format: 6, entities: [table("T2")], layers: [] } }], + }; + const dirt = []; + const host = node("div"); + const sheets = drawingMod.mountStructureSheets(host, doc, { + name: "rv", layer: "system", noArea: true, frame: true, onChanged: (d) => dirt.push(d), + }); + await new Promise((r) => setTimeout(r, 5)); + const bar = host.kids[0].kids.find((k) => k.className === "cad-tabs"); + const plus = bar.kids.find((k) => k.className === "cad-tabs__add"); + await plus.on.click(); + await new Promise((r) => setTimeout(r, 5)); + inCad = { ...inCad, entities: [...inCad.entities, { type: "Circle" }] }; // 새 장에 하나 그림 + const third = bar.kids.find((k) => k.dataset.tabId && k.dataset.active === "true").dataset.tabId; + await bar.kids.find((k) => k.dataset.tabId === "#1").on.click(); + await new Promise((r) => setTimeout(r, 5)); + out.updated = await sheets.updateTable("T2", [[{ text: "새" }]]); + const got = await sheets.getDoc(); + out.m02 = { + first: got.도면.entities.length, + more: got.더한장.map((s) => [s.이름, s.도면.entities.length]), + t2: got.더한장[0].도면.entities[0].shapeData.cells[0][0].text, + newId: got.더한장[1].장id === third, + scopes: cadLoads, + active: sheets.activeSheet(), + dirty: dirt.at(-1), + number: drawingMod.sheetNumber("SD-1", 2), + frames: urls, + }; + console.log(JSON.stringify(out)); +})(); +""" + + +@pytest.fixture(scope="module") +def got(): + if shutil.which("node") is None: + pytest.skip("node 가 없음") + proc = subprocess.run( + ["node", "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8" + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +def test_탭_셋_왕복_엔티티_수_그대로_고친_장만_표시(got): + # b 는 들를 때마다 하나씩 더 그림(+1 두 번) · a · c 는 그대로 + assert got["roundtrip"] == [["a", 3, False], ["b", 7, True], ["c", 0, False]] + assert got["shownOrder"] == ["b", "c", "a", "b", "a"] + + +def test_고정_탭_앞으로는_못_끎(got): + assert got["order"] == ["a", "c", "b"] + assert got["fixedDraggable"] is False and got["fixedX"] == 1 + + +def test_지우기는_물음_뒤_다음_탭을_실음(got): + assert got["afterRemove"] == {"ids": ["a", "b"], "active": "b", "confirms": 1} + + +def test_닫기_기능은_미저장_탭만_물음(got): + b07 = got["b07"] + assert b07["log"][:3] == [ + ["show", "d1", None, False], + ["show", "d2", None, False], + ["show", "d1", "편집본", True], # 받아 둔 편집본으로 돌아옴 + ] + assert b07["asked"] == 1 and ["closed", "d2"] in b07["log"] and b07["ids"] == ["d1"] + + +def test_여러_장_문서_장_도번_백업_칸(got): + m02 = got["m02"] + assert m02["first"] == 2 + assert m02["more"] == [["단면", 1], ["3장", 1]] + assert m02["newId"] is True and m02["active"] == "" + assert m02["scopes"][0] == "m02:system:rv" and m02["scopes"][1].startswith("m02:system:rv#s") + assert m02["number"] == "SD-1-3" + q = "/api/m02/structure-frame?number=" + assert m02["frames"] == [q + "SD-1", q + "SD-1-3"] # 장 도번마다 한 번(1장은 다시 안 받음) + assert m02["dirty"] is True # 장 더함 = 저장 안 한 고침 + + +def test_안_실린_장_표도_갈음(got): + assert got["updated"] == 2 # 받아 둔 둘째 장 1 + 실린 장(가짜 CAD) 1 + assert got["m02"]["t2"] == "새" + + +def test_카드는_1장_그리고_장_수(): + card = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts").read_text("utf-8") + assert "const sheets = 1 + (doc.더한장?.length ?? 0);" in card + assert "badge.textContent = `장 ${sheets}`;" in card + main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8") + assert '"mountStructureSheets"' in main + assert '...(edited as Pick),' in main diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index f2771090c..05880fc67 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -90,10 +90,11 @@ def test_집계표만_컨테이너를_붙임(): r'at\.layer !== "project" && at\.name === STRUCTURE_TABLE\s*\? buildPanel\(at\.layer\)\s*: null', body, ) - # 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로 - # 도면 칸만 갈아 끼움 · 맨 위 연결표는 도면 표에 맞춤(41-3 `toDrawing.withLinks`) + # 구조물 도면 저장은 `도면` · `더한장`(82-2) 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로 + # 맨 위 연결표는 도면 표에 맞춤(41-3 `toDrawing.withLinks`) assert re.search( - r'toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*도면: edited as StructureDoc\["도면"\],\s*\}\)', + r'toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*' + r'\.\.\.\(edited as Pick\),\s*\}\)', body, ) diff --git a/ui_template/cad_host/cad_host_tabs.css b/ui_template/cad_host/cad_host_tabs.css new file mode 100644 index 000000000..abc753155 --- /dev/null +++ b/ui_template/cad_host/cad_host_tabs.css @@ -0,0 +1,95 @@ +/* ui_template/cad_host/cad_host_tabs — CAD 를 감싸는 공용 탭 줄(82-1 · M02 구조물 도면 · B07). */ + +.cad-tabs { + display: flex; + flex: 0 0 auto; + align-items: flex-end; + gap: 2px; + min-width: 0; + padding: 4px 6px 0; + overflow-x: auto; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); + scrollbar-width: thin; +} + +.cad-tabs__tab { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 4px; + max-width: 220px; + padding: 4px 8px; + border: 1px solid var(--color-border); + border-bottom: none; + border-radius: 6px 6px 0 0; + background: var(--color-canvas); + color: var(--color-text-secondary); + font-size: 12px; + cursor: pointer; + user-select: none; +} + +.cad-tabs__tab[data-active="true"] { + background: var(--color-surface-raised); + color: var(--color-text); + font-weight: 600; + box-shadow: inset 0 2px 0 var(--color-primary); +} + +.cad-tabs__tab[data-dirty="true"] .cad-tabs__label::after { + content: " ●"; + color: var(--color-primary); +} + +.cad-tabs__tab[data-drop="before"] { + box-shadow: inset 2px 0 0 var(--color-accent); +} + +.cad-tabs__tab[data-drop="after"] { + box-shadow: inset -2px 0 0 var(--color-accent); +} + +.cad-tabs__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cad-tabs__input { + width: 110px; + padding: 0 4px; + border: 1px solid var(--color-accent); + border-radius: 4px; + background: var(--color-canvas); + color: var(--color-text); + font: inherit; +} + +.cad-tabs__x, +.cad-tabs__add { + padding: 0 4px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--color-text-secondary); + font-size: 14px; + line-height: 1; + cursor: pointer; +} + +.cad-tabs__x:hover, +.cad-tabs__add:hover { + background: var(--color-surface-raised); + color: var(--color-text); +} + +.cad-tabs__add { + align-self: center; + margin-left: 2px; + font-size: 16px; +} + +.cad-tabs[data-busy="true"] .cad-tabs__tab { + cursor: progress; +} diff --git a/ui_template/cad_host/cad_host_tabs.ts b/ui_template/cad_host/cad_host_tabs.ts new file mode 100644 index 000000000..960b2f6f5 --- /dev/null +++ b/ui_template/cad_host/cad_host_tabs.ts @@ -0,0 +1,330 @@ +/* ============================================================================= + * ui_template/cad_host/cad_host_tabs.ts + * CAD 를 감싸는 공용 탭 줄(PLAN 82-1) — 한 iframe 에 여러 장 · CAD 안 상태는 안 건드림. + * + * 탭을 바꿀 때 지금 장을 `capture`(= `cad_host.requestSave`)로 받아 그 탭에 두고 다음 장을 + * `show` 로 싣는다(받아 둔 편집본이 있으면 그것). 저장 길 · 장 내용 꼴은 쓰는 페이지 몫. + * 기능(더하기 · 이름 · 지우기 · 끌어 순서 · 닫기)은 쓰는 페이지가 고른다 — M02 구조물 도면 · + * B07 상세 설계(목록에서 연 도면 = 탭 · 닫기만). + * ========================================================================== */ + +import "./cad_host_tabs.css"; +import { showConfirmDialog, showToast } from "@ui/ui_template_elements"; + +export interface CadTab { + id: string; + name: string; + /** 받아 둔 편집본 — 없으면 `show` 가 페이지 자료에서 받아 옴 */ + drawing?: D; + /** 저장 안 한 고침 — 받아 둘 때 페이지 미저장 표시를 옮김 */ + dirty?: boolean; + /** 고정 — 끌기 · 지우기 · 닫기 · 이름 바꾸기 없음(M02 1장) */ + fixed?: boolean; + /** 풍선 글(장 도번 등) */ + title?: string; +} + +export interface CadTabsFeatures { + add?: boolean; + rename?: boolean; + remove?: boolean; + reorder?: boolean; + close?: boolean; +} + +export interface CadTabsOptions { + features?: CadTabsFeatures; + /** 지금 장 편집본 받기(requestSave) · dirty = 페이지 미저장 표시 */ + capture: () => Promise<{ drawing: D; dirty: boolean }>; + /** 장 싣기 — `tab.drawing` 이 있으면 받아 둔 편집본 */ + show: (tab: CadTab) => void | Promise; + /** 떠나도 되나(도각 편집 중 등) — false 면 그대로 */ + canLeave?: () => boolean; + /** [+] — 새 장 · null = 그만 */ + create?: () => CadTab | null | Promise | null>; + /** 장 목록이 바뀜(더하기 · 이름 · 지우기 · 순서) — 페이지 미저장 표시 */ + onChange?: () => void; + /** 탭을 닫음(close) — 남은 탭이 없으면 페이지가 정함 */ + onClosed?: (tab: CadTab) => void; + /** 물음 글 */ + text?: Partial; +} + +export interface CadTabs { + element: HTMLElement; + tabs: () => CadTab[]; + active: () => CadTab | undefined; + /** 탭 목록을 갈아 끼움(싣지 않음) */ + set: (tabs: CadTab[], activeId: string) => void; + /** 그 탭으로(없으면 끝에 더함) — 지금 장은 받아 둠 · 못 떠나면 false */ + open: (tab: CadTab) => Promise; + select: (id: string) => Promise; + /** 지금 장을 받아 두고 모든 탭 */ + collect: () => Promise[]>; + /** 저장 뒤 — 받아 둔 편집본 · 미저장 표시를 지움(`keepDrawings` = 편집본은 둠) */ + markClean: (keepDrawings?: boolean) => void; + /** 미저장 탭이 있나(지금 탭은 페이지 표시로) */ + anyDirty: () => boolean; +} + +const TEXT = { + add: "새 장", + rename: "두 번 눌러 이름 바꾸기", + remove: "장 지우기", + close: "닫기", + removeAsk: "「{name}」 장을 지울까요? 저장하면 되돌릴 수 없음", + removeOk: "지우기", + closeAsk: "「{name}」 에 저장 안 한 고침이 있음 — 버리고 닫을까요?", + closeOk: "버리고 닫기", + emptyName: "장 이름을 넣을 것", + sameName: "같은 이름의 장이 있음", +}; + +export function createCadTabs(options: CadTabsOptions): CadTabs { + const features = options.features ?? {}; + const text = { ...TEXT, ...options.text }; + const element = document.createElement("div"); + element.className = "cad-tabs"; + element.setAttribute("role", "tablist"); + let list: CadTab[] = []; + let activeId = ""; + let busy = false; + let dragId: string | null = null; + + const active = (): CadTab | undefined => list.find((tab) => tab.id === activeId); + const changed = (): void => options.onChange?.(); + + /** 지금 장을 받아 그 탭에 둠 */ + const stash = async (): Promise => { + const now = active(); + if (!now) return; + const got = await options.capture(); + now.drawing = got.drawing; + now.dirty = !!now.dirty || got.dirty; + }; + + const go = async (id: string): Promise => { + const next = list.find((tab) => tab.id === id); + if (!next || busy) return false; + if (id === activeId) return true; + if (options.canLeave && !options.canLeave()) return false; + busy = true; + try { + await stash(); + activeId = id; + render(); + await options.show(next); + return true; + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + return false; + } finally { + busy = false; + render(); + } + }; + + const rename = (tab: CadTab, label: HTMLElement): void => { + const input = document.createElement("input"); + input.className = "cad-tabs__input"; + input.value = tab.name; + input.maxLength = 40; + let done = false; + const finish = (save: boolean): void => { + if (done) return; + const name = input.value.trim(); + if (save && name !== tab.name) { + if (!name) return void showToast(text.emptyName, "error"); + if (list.some((other) => other !== tab && other.name === name)) + return void showToast(text.sameName, "error"); + tab.name = name; + changed(); + } + done = true; + render(); + }; + input.addEventListener("keydown", (event) => { + event.stopPropagation(); + if (event.isComposing) return; + if (event.key === "Enter") finish(true); + else if (event.key === "Escape") finish(false); + }); + input.addEventListener("blur", () => { + finish(true); + if (!done) finish(false); // 틀린 이름으로 초점을 잃으면 옛 이름 그대로 + }); + label.replaceWith(input); + input.select(); + }; + + const remove = async (tab: CadTab): Promise => { + if (busy) return; + const closing = !features.remove; + if (closing && tab.id === activeId) { + // 닫기 — 지금 탭의 미저장은 페이지 표시로 · 받아 둔 장은 탭에 + await stash().catch(() => undefined); + } + const ask = closing + ? tab.dirty && text.closeAsk.replace("{name}", tab.name) + : text.removeAsk.replace("{name}", tab.name); + if (ask && !(await showConfirmDialog(ask, closing ? text.closeOk : text.removeOk))) return; + const at = list.indexOf(tab); + list = list.filter((one) => one !== tab); + if (closing) options.onClosed?.(tab); + else changed(); + if (tab.id === activeId) { + activeId = ""; + const next = list[Math.min(at, list.length - 1)]; + if (next) { + activeId = next.id; + render(); + busy = true; + try { + await options.show(next); + } finally { + busy = false; + } + } + } + render(); + }; + + const drop = (target: CadTab, after: boolean): void => { + const moving = list.find((tab) => tab.id === dragId); + dragId = null; + if (!moving || moving === target) return; + const rest = list.filter((tab) => tab !== moving); + const at = rest.indexOf(target) + (after ? 1 : 0); + rest.splice(Math.max(at, rest.filter((tab) => tab.fixed).length), 0, moving); // 고정 탭 앞으로는 못 감 + if (rest.every((tab, index) => tab === list[index])) return render(); + list = rest; + changed(); + render(); + }; + + function render(): void { + element.dataset.busy = String(busy); + element.replaceChildren( + ...list.map((tab) => { + const node = document.createElement("div"); + node.className = "cad-tabs__tab"; + node.setAttribute("role", "tab"); + node.dataset.tabId = tab.id; + node.dataset.active = String(tab.id === activeId); + node.dataset.dirty = String(!!tab.dirty); + node.setAttribute("aria-selected", String(tab.id === activeId)); + node.title = [tab.title, features.rename && !tab.fixed ? text.rename : ""] + .filter(Boolean) + .join(" · "); + const label = document.createElement("span"); + label.className = "cad-tabs__label"; + label.textContent = tab.name; + node.append(label); + node.addEventListener("click", () => void go(tab.id)); + if (features.rename && !tab.fixed) { + node.addEventListener("dblclick", (event) => { + event.preventDefault(); + rename(tab, label); + }); + } + if ((features.remove || features.close) && !tab.fixed) { + const x = document.createElement("button"); + x.type = "button"; + x.className = "cad-tabs__x"; + x.textContent = "×"; + x.title = x.ariaLabel = features.remove ? text.remove : text.close; + x.addEventListener("click", (event) => { + event.stopPropagation(); + void remove(tab); + }); + node.append(x); + } + if (features.reorder && !tab.fixed) { + node.draggable = true; + node.addEventListener("dragstart", (event) => { + dragId = tab.id; + event.dataTransfer?.setData("text/plain", tab.id); + if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"; + }); + node.addEventListener("dragend", () => (dragId = null)); + } + if (features.reorder) { + node.addEventListener("dragover", (event) => { + if (!dragId) return; + event.preventDefault(); + const box = node.getBoundingClientRect(); + node.dataset.drop = event.clientX > box.left + box.width / 2 ? "after" : "before"; + }); + node.addEventListener("dragleave", () => delete node.dataset.drop); + node.addEventListener("drop", (event) => { + event.preventDefault(); + const after = node.dataset.drop === "after"; + delete node.dataset.drop; + drop(tab, after); + }); + } + return node; + }), + ); + if (features.add) { + const plus = document.createElement("button"); + plus.type = "button"; + plus.className = "cad-tabs__add"; + plus.textContent = "+"; + plus.title = plus.ariaLabel = text.add; + plus.disabled = busy; + plus.addEventListener("click", async () => { + if (busy) return; + const made = await options.create?.(); + if (!made) return; + list = [...list, made]; + changed(); + await go(made.id); + }); + element.append(plus); + } + } + + return { + element, + tabs: () => list, + active, + set: (tabs, id) => { + list = [...tabs]; + activeId = id; + render(); + }, + open: async (tab) => { + if (!list.some((one) => one.id === tab.id)) { + if (!activeId) { + // 첫 탭 — 받아 둘 지금 장이 없음 + list = [...list, tab]; + activeId = tab.id; + render(); + await options.show(tab); + return true; + } + if (options.canLeave && !options.canLeave()) return false; + list = [...list, tab]; + if (await go(tab.id)) return true; + list = list.filter((one) => one !== tab); // 못 옮겨 감 — 더한 탭도 뺌 + render(); + return false; + } + return go(tab.id); + }, + select: go, + collect: async () => { + await stash(); + return list; + }, + markClean: (keepDrawings = false) => { + for (const tab of list) { + tab.dirty = false; + if (!keepDrawings) delete tab.drawing; + } + render(); + }, + anyDirty: () => list.some((tab) => tab.dirty), + }; +} From b15a945bf836fa6a5f011d22ee8396567262a5de Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:10:50 +0900 Subject: [PATCH 24/47] =?UTF-8?q?feat(M02):=20=EB=A9=B4=EC=A0=81=20?= =?UTF-8?q?=E2=86=92=20=EB=8F=84=EB=A9=B4=20=ED=91=9C=20=EC=97=AC=EB=9F=AC?= =?UTF-8?q?=20=EC=9E=A5=20=E2=80=94=20=EC=83=88=20T=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=EC=9E=A5=20=C2=B7=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=ED=91=9C=20=EC=9E=A5=20=C2=B7=20=EC=A0=80=EC=9E=A5=20=EB=95=8C?= =?UTF-8?q?=20=EB=AA=A8=EB=93=A0=20=EC=9E=A5=20=ED=91=9C(82-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../M02_MasterTemplete_AreaToDrawing.ts | 25 +++++++++++++------ resources/tester/test_m02_area_to_drawing.py | 21 +++++++++++++--- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts index da16b9918..e3221c45c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts @@ -8,6 +8,8 @@ * 표 style.연결 = 연결 · 도면에서 지운 표는 기록에서 뺌(면적 [저장] 때 서버가 칸 글 · 값을 다시 적음 · 41-1) * ③ 도면을 열 때 — 연결표마다 지금 면적 값과 마지막 값이 다르면 알림 한 줄 + [업데이트](`updateTable`) * ④ 표 여러 개 — id 마다 한 줄 + * ⑤ 여러 장(82-3) — 놓을 장 = 지금 탭 · 새 T번호 = 모든 장에서 안 쓴 것 · 연결표 줄 `장` = 표가 놓인 장id + * (1장 = 빈 값) · 안 실린 장 표 갱신 = 도면 손잡이(받아 둔 장) · 면적 [저장] 때는 서버가 모든 장 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -108,6 +110,12 @@ async function fetchTable(layer: Layer, column: string, spec: LinkSpec): Promise return data as AreaTable; } +/** 장마다 `[장id, entities]` — 1장 = 빈 값(서버 `drawing_sheets` 와 같음) */ +const sheetsOf = (doc: StructureDoc): [string, unknown[]][] => [ + ["", doc.도면?.entities ?? []], + ...(doc.더한장 ?? []).map((s): [string, unknown[]] => [s.장id, s.도면?.entities ?? []]), +]; + const isTable = (e: unknown): e is TableEntityJson => !!e && typeof e === "object" && (e as { type?: unknown }).type === "Table"; const linkOf = (e: TableEntityJson): string | null => { @@ -241,7 +249,8 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { /** 다음 연결 id — 기록 · 도면 표지에 없는 가장 작은 T번호 */ const nextId = (doc: StructureDoc, taken: Set): string => { for (const row of doc.연결표 ?? []) taken.add(row.연결); - for (const e of doc.도면?.entities ?? []) if (isTable(e)) taken.add(linkOf(e) ?? ""); + for (const [, entities] of sheetsOf(doc)) + for (const e of entities) if (isTable(e)) taken.add(linkOf(e) ?? ""); let n = 1; while (taken.has(`T${n}`)) n += 1; return `T${n}`; @@ -323,12 +332,14 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { waiting = false; const old = new Map((doc.연결표 ?? []).map((row) => [row.연결, row])); const rows: TableLink[] = []; - for (const e of doc.도면?.entities ?? []) { - if (!isTable(e)) continue; - const id = linkOf(e); - const row = id ? (old.get(id) ?? fresh.get(id)) : undefined; - if (!id || !row || rows.some((r) => r.연결 === id)) continue; - rows.push({ ...row, 값: latest.get(id) ?? row.값 }); + for (const [sheet, entities] of sheetsOf(doc)) { + for (const e of entities) { + if (!isTable(e)) continue; + const id = linkOf(e); + const row = id ? (old.get(id) ?? fresh.get(id)) : undefined; + if (!id || !row || rows.some((r) => r.연결 === id)) continue; + rows.push({ ...row, 장: sheet, 값: latest.get(id) ?? row.값 }); + } } // 기록이 없던 도면은 칸을 안 만듦(옛 문서 그대로) return rows.length || doc.연결표 ? { ...doc, 연결표: rows } : doc; diff --git a/resources/tester/test_m02_area_to_drawing.py b/resources/tester/test_m02_area_to_drawing.py index b0e1a1923..ff11fa2bd 100644 --- a/resources/tester/test_m02_area_to_drawing.py +++ b/resources/tester/test_m02_area_to_drawing.py @@ -112,6 +112,16 @@ globalThis.fetch = async (url, init) => { await slot.kids[0].on.click(); await new Promise((r) => setTimeout(r, 20)); ad.onDrawing(handle, { ...area, kind: "structure", name: "item2" }, drawing([])); + const other = placed.length - before; + // 여러 장(82-3) — 둘째 장 T2 가 있으니 새 표는 T3 · 저장 연결표 줄 장 = 표가 놓인 장id + ad.select({ 시트: "산출", 칸: "A1", 범위: "A1" }); + await slot.kids[0].on.click(); + await new Promise((r) => setTimeout(r, 20)); + const multi = drawing([table([["a"]], { 연결: "T1" })], [old]); + multi.더한장 = [{ 장id: "s2", 이름: "2장", 도면: { entities: [table([["b"]], { 연결: "T2" })] } }]; + ad.onDrawing(handle, { ...area, kind: "structure" }, multi); + const multiId = placed.at(-1)[1]; + const multiSaved = ad.withLinks({ ...multi, 연결표: [old, { ...old, 연결: "T2" }] }).연결표.map((r) => [r.연결, r.장]); // 빈 칸만 고른 범위 — 크기를 묻기 전에 막고 알림 · 도면 안 엶 now = [["", " "]]; const openedBefore = opened.length; @@ -119,7 +129,7 @@ globalThis.fetch = async (url, init) => { await slot.kids[0].on.click(); await new Promise((r) => setTimeout(r, 20)); const empty = { opened: opened.length - openedBefore, toast: toasts.at(-1) }; - console.log(JSON.stringify({ ro, buttons: slot.kids.length, sent, placed, notice: notice.map((x) => (typeof x === "string" ? x : x.label)), saved: saved.연결표, plain: "연결표" in plain, other: placed.length - before, waitPlaced, waitDropped, waitSaved: ad.waiting(), empty })); + console.log(JSON.stringify({ ro, buttons: slot.kids.length, sent, placed, notice: notice.map((x) => (typeof x === "string" ? x : x.label)), saved: saved.연결표, multiId, multiSaved, plain: "연결표" in plain, other, waitPlaced, waitDropped, waitSaved: ad.waiting(), empty })); })(); """ @@ -161,12 +171,17 @@ def test_다시_열면_다름_알림과_업데이트(got): def test_저장_연결표는_남은_표만(got): now = {"s1!B2": "1.5", "s1!C2": "2"} assert got["saved"] == [ - {"연결": "T1", "시트": "s1", "범위": "A1:A1", "글자크기": 3, "값": now}, # [업데이트] 값 - {"연결": "T2", "시트": "s1", "범위": "B2:C2", "글자크기": 2.5, "값": now}, + {"연결": "T1", "장": "", "시트": "s1", "범위": "A1:A1", "글자크기": 3, "값": now}, + {"연결": "T2", "장": "", "시트": "s1", "범위": "B2:C2", "글자크기": 2.5, "값": now}, ] assert got["plain"] is False +def test_여러_장_새_번호는_모든_장에서_안_쓴_것_연결표_장(got): + assert got["multiId"] == "T3" + assert got["multiSaved"] == [["T1", ""], ["T2", "s2"]] + + def test_다른_열_도면에는_안_놓음(got): assert got["other"] == 0 From 142194a1e5278c98798156a5f1d59ea35b61dc4c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:11:47 +0900 Subject: [PATCH 25/47] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EC=A1=B0=ED=95=A9=20=EC=83=81=EB=8B=A8=20=ED=91=9C=20?= =?UTF-8?q?=E3=80=8C=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=ED=95=A9?= =?UTF-8?q?=EA=B3=84=E3=80=8D=20=EC=9D=BD=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=A4=84=20=E2=80=94=20=EB=B0=98=EC=98=81=ED=95=A9=20=C2=B7=20?= =?UTF-8?q?=EC=97=86=EC=9C=BC=EB=A9=B4=20=EA=B8=B0=EC=A4=80=ED=95=A9(?= =?UTF-8?q?=EA=B8=B0=EC=A4=80)=20=C2=B7=20=EC=B9=B4=EB=93=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=92=A4=20=EB=8B=A4=EC=8B=9C=20=EC=9D=BD=EA=B8=B0?= =?UTF-8?q?(86-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Api_Fetch.ts | 3 ++ .../M02_MasterTemplete_ItemCards.ts | 14 ++++-- .../M02_MasterTemplete_Store.py | 32 +++++++++++-- .../M02_MasterTemplete_Structure.ts | 19 ++++++++ .../M02_MasterTemplete_UI_Main.ts | 3 ++ resources/tester/test_m02_combo_table.py | 46 +++++++++++++++++++ resources/tester/test_m02_structure_panel.py | 2 +- resources/tester/test_sheet_drawing_select.py | 2 +- ui_template/sheet/ui_template_sheet.ts | 3 ++ ui_template/sheet/ui_template_sheet_ops.ts | 4 +- ui_template/sheet/ui_template_sheet_render.ts | 23 +++++++--- ui_template/sheet/ui_template_sheet_text.ts | 1 + 12 files changed, 136 insertions(+), 16 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 30ec77179..6fc25f8ee 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -175,6 +175,9 @@ export interface StructureNumbers { 면적번: string; /** 일위대가 `SU-######`(39장) */ 일위대가번: string; + /** 일위대가 합계(86-3) — 반영합 · 없으면 기준합(`합계기준` 참) · 계산값 없으면 칸 없음 */ + 일위대가합계?: number; + 합계기준?: boolean; } /** `{열 id: {도번, 면적번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index ea605c524..0c35d5b1d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -24,7 +24,6 @@ import { readTemplate, saveTemplate, type StructureDoc, - type StructureNumbers, } from "./M02_MasterTemplete_Api_Fetch"; import { cardShell, @@ -48,7 +47,7 @@ export type EditKind = ItemPart; /** 카드 종류 — 열 문서 셋 + 일위대가(39장 · 보기 갈래 카드 · 고침은 일위대가 상세) */ export type CardKind = EditKind | "labor"; export type SheetKind = Exclude; -export const PART_FIELD: Record = { +export const PART_FIELD: Record = { structure: "도번", labor: "일위대가번", area: "면적번", @@ -100,6 +99,8 @@ export interface ItemCardsOptions { extraCards?: HTMLElement[]; /** 카드 가운데 하나라도 저장 안 한 고침이 생김 · 없어짐(PLAN 31-4 · 31-7) */ onDirty?: (dirty: boolean) => void; + /** 카드 [저장] 이 끝남 — 번호 · 일위대가 합계를 다시 읽는 자리(86-3) */ + onSaved?: () => void; /** 면적산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */ sheetEdit?: SheetEditOptions; /** 「가져오기」(39-5 · 43-9b) — 다른 항목의 이 칸을 골라 덮어씀 · 빈 카드도 같음 · 없으면 단추 없음 */ @@ -195,7 +196,14 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { const no = el("span", { className: "m02-struct__no" }); const name = el("span", { className: "m02-struct__title" }); const shell = cardShell({ title, body, sub: [no, name] }); - const saver = saveControl(shell, save, () => opts.onDirty?.(anyDirty())); + const saver = saveControl( + shell, + async () => { + await save(); + opts.onSaved?.(); + }, + () => opts.onDirty?.(anyDirty()), + ); const help = helpButton(shell, title, CARD_HELP[kind]); // 제목 바로 옆(47-1) const edit = createButton({ label: TEXT.edit, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 214f2fe6b..1c4f38552 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -226,13 +226,39 @@ def _codes() -> dict[str, str]: return codes -def structure_numbers() -> dict[str, dict[str, str]]: - """`{열 id: {도번, 면적번}}` — 집계표 줄 · 좌측 줄이 한 번에 받음(없는 쪽은 빈 글).""" - out: dict[str, dict[str, str]] = {} +def _total(sums: Any) -> float | None: + """`계산값` 합 칸의 `계` — 수 아닌 값 · 없음 = None.""" + total = sums.get("계") if isinstance(sums, dict) else None + if total is None or isinstance(total, bool): + return None + try: + return float(total) + except (TypeError, ValueError): + return None + + +def _labor_sum(doc: dict[str, Any]) -> dict[str, Any]: + """일위대가 합계 칸(86-3) — 반영합 `계`(0 아님) · 없으면 기준합 `계` + `합계기준` 참 · + 담은 로직 0 · 계산값 없음 = 빈 칸 (목록 `labor` 합계와 같은 규칙).""" + if not doc.get("담은로직"): + return {} + calc = doc.get("계산값") or {} + if total := _total(calc.get("반영합")): + return {"일위대가합계": total, "합계기준": False} + base = _total(calc.get("기준합")) + return {} if base is None else {"일위대가합계": base, "합계기준": True} + + +def structure_numbers() -> dict[str, dict[str, Any]]: + """`{열 id: {도번, 일위대가번, 면적번, (일위대가합계, 합계기준)}}` — 집계표 줄 · 좌측 줄이 한 번에 + 받음(번호 없는 쪽은 빈 글 · 합계는 일위대가 계산값이 있을 때만).""" + out: dict[str, dict[str, Any]] = {} for kind, key, _, _ in PARTS: for name, doc in _docs(kind): row = out.setdefault(name, {k: "" for _, k, _, _ in PARTS}) row[key] = str(doc.get(key) or "") + if kind == "labor": + row.update(_labor_sum(doc)) return out diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index d11baa92a..892af0823 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -58,6 +58,7 @@ const TEXT = { noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음", pillsFailed: "일위대가 알약을 못 받음 — {value}", discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?", + baseNote: "(기준)", }; /** 집계표 줄 열쇠 → 문서 */ @@ -73,6 +74,13 @@ const fill = (text: string, value: string): string => text.replace("{value}", va /** `{열 id: {도번, 면적번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */ export type ColumnNumbers = Record; +/** 집계표 「일위대가 합계」 줄 글(86-3) — 반영합 · 수량이 없으면 기준합 + 「(기준)」 · 계산값 없음 = 빈 글(「—」) */ +export function laborSumText(numbers: StructureNumbers | undefined): string { + if (typeof numbers?.일위대가합계 !== "number") return ""; + const text = numbers.일위대가합계.toLocaleString("ko-KR", { maximumFractionDigits: 0 }); + return numbers.합계기준 ? `${text} ${TEXT.baseNote}` : text; +} + /** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2 · 분류3) · 꼴만 씀 */ export interface StructureColumn { id: string; @@ -183,6 +191,8 @@ export interface StructurePanelOptions { /** [도면 수정] · [면적산출 수정] — 그 열을 CAD · 스프레드시트 전체 편집으로 엶 * (페이지의 `open`) · label 은 화면 제목에 씀 */ onEdit: (column: string, label: string, kind: CardKind) => void; + /** 번호 · 일위대가 합계를 다시 읽음(카드 [저장] 뒤 · 86-3) — 집계표 줄 글을 새로 그림 */ + onNumbers?: () => void; /** 집계표에 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */ confirmLeave: () => Promise; /** 카드 고침을 버리지 않고 머묾 — 표의 고른 열을 되돌림(31-3) */ @@ -221,6 +231,15 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane editable: () => opts.isAdmin, onEdit: (kind) => void openEdit(kind), numberOf: (kind, id) => numbers[id]?.[PART_FIELD[kind]], + // 카드 [저장] 뒤 — 서버가 일위대가 계산값을 다시 적었을 수 있어 번호 · 합계를 다시 읽음(86-3) + onSaved: () => + void fetchStructureNumbers().then( + (got) => { + numbers = got; + opts.onNumbers?.(); + }, + () => undefined, + ), // 도번 목록에 있는 열인데 못 읽으면 알림 — 없는 열은 그냥 「도면 없음」 warnOnFail: (id) => !!numbers[id], // 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 집계표 줄 「조회 후 선택」 과 같은 길 diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index a319ab1d8..9931bb0ce 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -22,6 +22,7 @@ import { comboMerge, comboView, createStructurePanel, + laborSumText, pillsFor, STRUCTURE_TABLE, type StructureColumn, @@ -262,6 +263,7 @@ export function createMain( isAdmin, confirmLeave, keepColumn: (colId) => sheet?.selectColumn(colId), // 카드 고침을 안 버림 — 고른 열 되돌림 + onNumbers: () => sheet?.recalc(), // 카드 [저장] 뒤 번호 · 일위대가 합계 줄을 새로(86-3) // 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶 onEdit: (column, label, kind) => { void open({ layer, projectId: null, kind, name: column, label }); @@ -357,6 +359,7 @@ export function createMain( laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? "", drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "", areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "", + laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId]), // 86-3 combo: { ...(readOnly || !extras.onHeadRename ? {} diff --git a/resources/tester/test_m02_combo_table.py b/resources/tester/test_m02_combo_table.py index f6d74b82b..193f23b41 100644 --- a/resources/tester/test_m02_combo_table.py +++ b/resources/tester/test_m02_combo_table.py @@ -90,3 +90,49 @@ def test_머리_고침은_쓸_수_있는_층만_나무로(): assert page.index("...(canEdit(layer, isAdmin)\n ? {\n onHeadRename") > 0 tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert "if (opt.canEdit && (await ready())) await edit(body, colId);" in tree + + +def test_일위대가_합계_줄_번호_칸(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """86-3 — `structure_numbers` 의 일위대가 합계: 반영합 `계` · 없으면 기준합 `계` + 합계기준 · + 담은 로직 0 · 계산값 없음 = 칸 없음(목록 `labor` 합계와 같은 규칙).""" + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + monkeypatch.setattr(store, "FOLDER", tmp_path) + (tmp_path / "labor").mkdir() + docs = { + "a": {"일위대가번": "SU-000001", "담은로직": ["x"], "계산값": {"반영합": {"계": 1500.4}}}, + "b": { + "일위대가번": "SU-000002", + "담은로직": ["x"], + "계산값": {"반영합": {"계": 0}, "기준합": {"계": 900}}, + }, + "c": {"일위대가번": "SU-000003", "담은로직": [], "계산값": {"반영합": {"계": 5}}}, + "d": {"일위대가번": "SU-000004", "담은로직": ["x"], "계산값": {}}, + } + for name, doc in docs.items(): + (tmp_path / "labor" / f"{name}.json").write_text(json.dumps(doc), encoding="utf-8") + got = store.structure_numbers() + assert (got["a"]["일위대가합계"], got["a"]["합계기준"]) == (1500.4, False) + assert (got["b"]["일위대가합계"], got["b"]["합계기준"]) == (900.0, True) + assert "일위대가합계" not in got["c"] and "일위대가합계" not in got["d"] + assert got["d"]["일위대가번"] == "SU-000004" and got["d"]["도번"] == "" + + +def test_일위대가_합계_줄_표_부품과_연결(): + sheet = _text(SHEET / "ui_template_sheet.ts") + assert "laborSumLabel: opts.laborSumLabel," in sheet + ops = _text(SHEET / "ui_template_sheet_ops.ts") + assert 'export const KEY_SUM = "s:laborsum";' in ops + assert "[KEY_PRICE, KEY_DRAWING, KEY_AREA, KEY_SUM]" in ops # 읽기만 — 고치기 · 쓰기 막힘 + assert "KEY_SUM" not in re.search(r"MASTER_ROWS = \[[^\]]*\]", ops).group( + 0 + ) # 방향키 · 고르기 밖 + render = _text(SHEET / "ui_template_sheet_render.ts") + assert "state.combo && state.laborSumLabel" in render and "masterRow(KEY_SUM)" in render + main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") + assert "laborSumLabel: (colId: string) => laborSumText(struct.numbers()[colId])" in main + assert "onNumbers: () => sheet?.recalc()" in main # 카드 [저장] 뒤 다시 읽기 + struct = _text(M02 / "M02_MasterTemplete_Structure.ts") + assert "fetchStructureNumbers().then(" in struct and "opts.onNumbers?.()" in struct + cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts") + assert "opts.onSaved?.();" in cards diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index f2771090c..ff9e24e19 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -74,7 +74,7 @@ def test_sub2_가_부르는_이름과_모양(): ), name assert "fetchStructureNumbers = (): Promise>" in body assert re.search( - r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;\s*\}", + r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;(\s*/\*\*[^*]*\*/)?\s*일위대가합계\?: number;\s*합계기준\?: boolean;\s*\}", body, ) assert re.search(r'export type Kind = [^;]*"area" \| "labor"', body) # 재료산출 없앰 diff --git a/resources/tester/test_sheet_drawing_select.py b/resources/tester/test_sheet_drawing_select.py index 133f770b1..41b1e95c1 100644 --- a/resources/tester/test_sheet_drawing_select.py +++ b/resources/tester/test_sheet_drawing_select.py @@ -76,7 +76,7 @@ def test_text_column_pick_rows_blank(): def test_labor_row_shows_su_number(): """마스터 일위대가 줄 = 그 열 일위대가번(SU) — 열의 옛 `일위대가` 칸(UA 키)은 안 읽음.""" ops = OPS.read_text(encoding="utf-8") - assert "REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA]" in ops + assert "REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA, KEY_SUM]" in ops render = (OPS.parent / "ui_template_sheet_render.ts").read_text(encoding="utf-8") assert re.search(r"key === KEY_PRICE\s*\?\s*state\.laborLabel", render) assert "col.일위대가" not in render diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index 418dcb355..3d952c266 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -59,6 +59,8 @@ export interface SheetOptions { drawingLabel?: (colId: string) => string; /** 면적산출 줄 칸 글(번호) — `drawingLabel` 과 같음. */ areaLabel?: (colId: string) => string; + /** 구조물 조합 표(`combo`) 「일위대가 합계」 줄 칸 글 — 주면 읽기 전용 줄이 생김(86-3). */ + laborSumLabel?: (colId: string) => string; /** 칸을 고름 — 누르기 · 방향키 · Tab. */ onSelect?: (sel: SheetSelection) => void; /** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 칸을 두 번 누르기 · Enter · F2 로 부름. @@ -90,6 +92,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio laborLabel: opts.laborLabel, drawingLabel: opts.drawingLabel, areaLabel: opts.areaLabel, + laborSumLabel: opts.laborSumLabel, pick: !!opts.onPick, headLocked: !!opts.lockHead, combo: !!opts.combo, diff --git a/ui_template/sheet/ui_template_sheet_ops.ts b/ui_template/sheet/ui_template_sheet_ops.ts index 0f0a51ea2..1df960141 100644 --- a/ui_template/sheet/ui_template_sheet_ops.ts +++ b/ui_template/sheet/ui_template_sheet_ops.ts @@ -22,11 +22,13 @@ export const KEY_UNIT = "s:unit"; export const KEY_PRICE = "s:price"; export const KEY_DRAWING = "s:drawing"; export const KEY_AREA = "s:area"; +/** 구조물 조합 표 읽기 전용 줄 「일위대가 합계」(86-3) — 마스터 줄 차례 · 조회 후 선택에는 안 들어감. */ +export const KEY_SUM = "s:laborsum"; /** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 일위대가 · 도면 · 면적산출 줄은 손으로 못 침(번호 글). */ export const MASTER_ROWS = [KEY_PRICE, KEY_DRAWING, KEY_AREA] as const; export type MasterRowKey = (typeof MASTER_ROWS)[number]; /** 번호 글 줄 — 부른 쪽이 준 글(`refLabel`)만 보임(일위대가 = SU · 열의 옛 `일위대가` 칸은 안 읽음). */ -export const REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA]; +export const REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA, KEY_SUM]; /** 조회 후 선택 줄 — `onPick` 을 주면 이 줄 칸은 치지 않고 고름. */ export const PICK_ROWS: readonly string[] = MASTER_ROWS; diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index 8ef33269c..36114d47e 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -17,6 +17,7 @@ import { isBlankPick, isCalcColumn, KEY_AREA, + KEY_SUM, KEY_PRICE, KEY_UNIT, MASTER_ROWS, @@ -43,6 +44,8 @@ export interface RenderState { laborLabel?: (colId: string) => string; drawingLabel?: (colId: string) => string; areaLabel?: (colId: string) => string; + /** 구조물 조합 표 「일위대가 합계」 줄 칸 글 — 주면 combo 표에 읽기 전용 줄이 생김(86-3). */ + laborSumLabel?: (colId: string) => string; /** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */ pick?: boolean; /** 머리 · 층 이름을 못 고침(`lockHead`) — 칸 겉모양만. */ @@ -55,11 +58,13 @@ export interface RenderState { /** 일위대가 · 도면 · 면적산출 줄 칸 글(번호). */ export const refText = (state: RenderState, key: string, colId: string): string => - (key === KEY_AREA - ? state.areaLabel - : key === KEY_PRICE - ? state.laborLabel - : state.drawingLabel)?.(colId)?.trim() || st("Drawing_None"); + key === KEY_SUM + ? state.laborSumLabel?.(colId)?.trim() || "—" + : (key === KEY_AREA + ? state.areaLabel + : key === KEY_PRICE + ? state.laborLabel + : state.drawingLabel)?.(colId)?.trim() || st("Drawing_None"); /** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */ const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map): number => @@ -173,17 +178,18 @@ export function renderSheet(state: RenderState): HTMLElement { return tr; }; - const masterRow = (key: MasterRowKey): HTMLElement => { + const masterRow = (key: MasterRowKey | typeof KEY_SUM): HTMLElement => { const label = ( { "s:price": "Row_Unit_Price", "s:drawing": "Row_Drawing", "s:area": "Row_Area", + "s:laborsum": "Row_LaborSum", } as const )[key]; const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] }); cols.forEach((col, j) => { - const blank = isBlankPick(key, col); + const blank = key === KEY_SUM ? col.꼴 === "글" : isBlankPick(key, col); const text = blank ? "" : refText(state, key, col.id); // 마스터 줄 = 모두 번호 글 const td = bodyCell(key, col, j, text); td.classList.remove("is-num"); @@ -264,6 +270,9 @@ ${formulaLabel(formula, names)}` children: [ // 구조물 조합 = 일위대가 · 도면 · 면적산출 키 줄 안 보임(보기만 · 번호는 장부 · 카드에) ...(mode === "master" && p === 0 && !state.combo ? MASTER_ROWS.map(masterRow) : []), + ...(mode === "master" && p === 0 && state.combo && state.laborSumLabel + ? [masterRow(KEY_SUM)] + : []), // 구조물 조합 = 읽기 전용 합계 줄 하나(86-3) ...page.rows.map((row, i) => dataRow(row, page.first + i)), ...(page.totals ? totalRows() : []), ], diff --git a/ui_template/sheet/ui_template_sheet_text.ts b/ui_template/sheet/ui_template_sheet_text.ts index 3ed27f142..b53aba30f 100644 --- a/ui_template/sheet/ui_template_sheet_text.ts +++ b/ui_template/sheet/ui_template_sheet_text.ts @@ -11,6 +11,7 @@ const TEXT = { Col_New: ["새 열", "New column"], Row_Unit: ["단위", "Unit"], Row_Unit_Price: ["일위대가", "Unit price"], + Row_LaborSum: ["일위대가 합계", "Unit price total"], Row_Drawing: ["도면", "Drawing"], Row_Area: ["면적산출", "Area"], Add_Suffix: [" 추가", " +"], From de93c7b2489a45dbe53ffefddae51923f67a03e7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:12:16 +0900 Subject: [PATCH 26/47] =?UTF-8?q?fix(B06):=20=EC=9B=90=EC=A7=80=EB=B0=98?= =?UTF-8?q?=20=EC=A0=9C=EC=95=BD=20=EB=81=8A=EC=9D=8C=20=E2=80=94=20?= =?UTF-8?q?=EB=8B=A4=EB=8B=A8=20=EC=9E=90=EB=8F=99=20=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EC=82=AC=EB=A9=B4=201.0=20m=20=C2=B7=20=EC=84=A4=EA=B3=84?= =?UTF-8?q?=EC=84=A0=20=EB=81=9D=20=EC=88=98=EC=B9=98=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=20=C2=B7=20=EC=A0=84=EB=A9=B4=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=B9=B8=20=EC=95=84=EB=8B=88=EB=A9=B4=20=EC=84=B1=ED=86=A0=20?= =?UTF-8?q?=C2=B7=20=E3=80=8C=EC=A7=80=ED=98=95=EC=97=90=20=EB=A7=89?= =?UTF-8?q?=ED=9E=98=E3=80=8D=20=EC=9E=94=EC=9E=AC=20=EC=A7=80=EC=9B=80(91?= =?UTF-8?q?-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- B06_Section/B06_Section_UI_Cross_Culvert.ts | 3 - .../B06_Section_UI_Cross_Culvert_Const.ts | 2 + .../B06_Section_UI_Cross_Culvert_Extra.ts | 54 +--- .../B06_Section_UI_Cross_Culvert_Solve.ts | 54 ---- .../B06_Section_UI_Cross_Culvert_Types.ts | 7 +- .../B06_Section_UI_Cross_Culvert_Wire.ts | 39 --- B06_Section/B06_Section_UI_Cross_Design.ts | 260 +---------------- .../B06_Section_UI_Cross_Design_Line.ts | 263 ++++++++++++++++++ B06_Section/B06_Section_UI_Cross_Revetment.ts | 32 +-- common_util/common_util_masonry_slope.ts | 35 +-- .../common_util_structure_face_role.py | 9 +- resources/tester/helper_91_ground_free.cjs | 30 ++ resources/tester/test_91_ground_free.py | 39 +++ ui_template/ui_template_locale_b2.ts | 4 - 14 files changed, 360 insertions(+), 471 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Design_Line.ts diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index e8e02a3c4..245e18dd4 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -191,9 +191,6 @@ export function appendCulvertOverlay( ? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 구조물 별도(추가 예정)` : ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m(실무 기초콘크리트 H=0.5 — 법정 규정 없음)`) + (wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : "") + - (wall.shiftBlockedM - ? ` · ⚠ 좌우 이동 ${wall.shiftBlockedM.toFixed(2)}m 는 지형에 막힘(더 나가면 벽이 묻힘)` - : "") + (wall.shiftFloorM ? ` · ⚠ 좌우 이동 하한 ${wall.shiftFloorM.toFixed(2)}m — 성토선 각도(1:${FILL_SLOPE_RATIO_MIN})를` + ` 지키려면 그만큼은 나가 있어야 함. 그 아래 값을 넣으면 벽이 안 움직임` diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index e4bad48ea..05082dfcf 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -169,6 +169,8 @@ export function pipeWallMinPureHeight(diameterM: number): number { export const WALL_MIN_PURE_HEIGHT_M = 0.1; /** 일반(추가) 기슭막이 기본 높이(m). */ export const EXTRA_WALL_DEFAULT_HEIGHT_M = 1.5; +/** 다단 자동 자리 — 앞 단 시작점 → 벽 상단 1:1.2 사면 길이(m) · 원지반 무관(91-3 ② 브레인). */ +export const EXTRA_TIER_SLOPE_M = 1.0; /** * 성토 비탈 기울기(1:n) 허용 범위 — 지식DB `성토_비탈면.md` §1: diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index f516c140e..720b9452e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -4,14 +4,14 @@ * * 다단 기슭막이는 **1:1.2 고정 물매에서 안정적으로 성토고를 올리는 수단**이다 * (2026-08-22 사용자 확정 규칙): - * · 기슭막이 사이 성토사면은 **정확히 1:1.2** — 벽 높이를 자리에 맞춰 역산한다. + * · 기슭막이 사이 성토사면은 **정확히 1:1.2** · 자동 자리 = 사면 1.0 m 뒤 벽 상단(원지반 무관 · 91-3). * · 아랫단 벽 상단은 윗단 벽 **하단(수평 기초선)을 뚫고 올라갈 수 없다**. * · 다음 단 성토선 시작점 = 윗단 벽 **하단 수평선 +0.5m와 전면 경사선(1:n · 표준경사)의 * 교차점**(배관 기슭막이의 관 하단 꼭짓점과 같은 자리 — 배관만 없다). - * · 그 시작점이 원지반 아래면(벽이 0.5m 이상 묻힘) **성토 불필요** — 다단 종료. * ========================================================================== */ import { + EXTRA_TIER_SLOPE_M, EXTRA_WALL_DEFAULT_HEIGHT_M, PIPE_CONNECT_GRADE, FILL_SLOPE_MAX_LENGTH_M, @@ -263,18 +263,15 @@ function trailingFill( * 유출측 성토부선·다단 기슭막이 일괄 계산 (2026-08-22 4축 조작 체계). * * 단별 벽 높이 h = 사용자 설정 ?? 기본 1.5m (하한 0.5 · 형태별 상한 없음). 벽 상단은 - * 항상 src에서 내려오는 **1:1.2 성토선 위** — 자동 자리는 높이 h 벽이 지반에 - * 앉으며 상단이 선에 닿는 지점(heightAt(x)=h 교차점, 닫힌식): - * heightAt(x) = (src.elev − 지반(x) − (D − 0.25t)/1.2) / (1 − 0.15/1.2) + * 항상 src에서 내려오는 **1:1.2 성토선 위** — 자동 자리는 그 선을 사면 길이 + * `EXTRA_TIER_SLOPE_M` 만큼 내려간 곳에 벽 상단(원지반 무관 · 91-3). * 조작: 좌우 x = src 높이의 **수평 선반**을 끼워 평행이동(물매 불변, 상단 표고 * 불변 — 윗단 기준 0.5m 수평 구현), 상하 d = 성토선을 타는 대각(수평 성분). * 위(−d) 한계 = 윗단 하단 관통 금지(상단 ≤ 윗단 하단). 바닥은 상단−h−0.5로 - * 지반과 무관 — 못 닿으면 뜨고(floatGap 경고), 지나면 묻힌다. - * 요청 단 수보다 지형이 허락하는 단이 적으면 되는 만큼만 세운다. + * 지반과 무관 — 못 닿으면 뜨고(floatGap 경고), 지나면 묻힌다. 요청 단 수 그대로 세운다. */ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult { const { outward, groundAt, limitOffset } = input; - const thickness = REVET_THICKNESS_M; /** 다단 1회 전개 — plan: "user"(조작값) / "greedy"(등간격 1차 근사) / d 명시 배열. */ const cascadeOnce = (plan: "user" | "greedy" | number[]): OutletExtrasResult => { const walls: WallLayout[] = []; @@ -299,43 +296,10 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult const exposed = height - REVET_EMBED_DEPTH_M; // 전면 기울기 — 형태·요청 높이로 표준경사(2026-09-14 B5). 자리 닫힌식의 분모도 따라감. const lean = input.leanFor?.(form, height) ?? REVET_LEAN_RATIO; - const heightDenominator = 1 - lean / 2 / FILL_SLOPE_RATIO_MIN; - - /** 자리 x(벽 하단 중점)에 지반 안착 + 상단이 성토선에 닿는 데 필요한 높이. */ - const heightAt = (x: number): number => { - const run = (x - src.offset) * outward; - return ( - (src.elevation - groundAt(x) - (run - 0.25 * thickness) / FILL_SLOPE_RATIO_MIN) / - heightDenominator - ); - }; - // 자동 자리 = heightAt이 사용자 높이 h에 처음 닿는 지점(0.05m 스캔 후 선형 보간). - // 지형이 완만해 h까지 못 크면 **가장 깊이 앉는 지점**(heightAt 최대)으로 폴백 — - // 바닥은 상단−h−0.5라 지반과 어긋나도 된다(뜨면 경고, 묻히면 그대로). - const step = 0.05; - const span = (limitOffset - src.offset) * outward; - let autoOffset: number | null = null; - let bestOffset: number | null = null; - let bestHeight = 0.05; // 이 이하로만 앉는 지형이면 단을 세우지 않는다 - let previousShort = 0; // 직전 스캔점의 h 부족량(heightAt < h) — 보간용 - for (let t = step; t <= Math.max(span, 0) + 1e-9; t += step) { - const x = src.offset + outward * t; - const gap = heightAt(x) - exposed; - if (gap >= 0) { - // 교차점 선형 보간 — 격자 대신 정확 자리(사면선·지반 동시 접점). - const back = previousShort + gap > 1e-9 ? (gap / (previousShort + gap)) * step : 0; - autoOffset = x - outward * back; - break; - } - previousShort = -gap; - if (heightAt(x) > bestHeight) { - bestHeight = heightAt(x); - bestOffset = x; - } - } - // 앉을 지형이 없으면(묻힘) 이음선이 시작점 바로 밖 — 원지반과 무관하게 선다. - autoOffset ??= bestOffset ?? src.offset + outward * jointRunOf(exposed, lean); - const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(exposed, lean); + // 자동 자리 = 앞 단 시작점에서 1:1.2 사면 EXTRA_TIER_SLOPE_M 뒤 벽 상단 — 원지반 무관(91-3 ②). + const autoJointRun = + (EXTRA_TIER_SLOPE_M * FILL_SLOPE_RATIO_MIN) / Math.hypot(1, FILL_SLOPE_RATIO_MIN); + const autoOffset = src.offset + outward * (autoJointRun + jointRunOf(exposed, lean)); /** 자동 자리(선반 0·수직 0)의 벽 상단 표고 — 조작은 여기서부터 잰다. */ const autoTop = src.elevation - autoJointRun / FILL_SLOPE_RATIO_MIN; // 관통 금지: 상단 ≤ 윗단 하단 → 그만큼은 반드시 내려간다. diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts index d45f2a726..9888d2c48 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts @@ -9,7 +9,6 @@ import { FILL_MIN_RISE_M, FILL_SLOPE_MAX_LENGTH_M, FILL_SLOPE_RATIO_MIN, - REVET_HEIGHT_STEP_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, REVET_TRAP_TOP_M, @@ -217,21 +216,6 @@ export function outletSlopeFactory( }; } -/** - * 손으로 민 벽 자리 한계 — 안쪽 당김은 **1:1.2가 성립하는 가장 안쪽 자리**(innerLimit - * = 자동 자리)에서 멈춘다(2026-08-22 사용자 확정 — 우선순위: 배관 연장 > 높이 증가 > - * 바닥 띄움. 당겨서 물매가 깨질 바에는 관을 길게 유지한다). 1:1.2 자리가 아예 없는 - * 지형 폴백에서는 innerLimit이 노견까지 내려오고, 그때만 높이 증가·바닥 띄움 - * (`solveWallVertical`)이 발동한다. 바깥 밀기는 막지 않는다(노폭 연장으로 흡수). - */ -export function clampWallOffset( - wantedOffset: number, - innerLimitOffset: number, - outward: number, -): number { - return (wantedOffset - innerLimitOffset) * outward < 0 ? innerLimitOffset : wantedOffset; -} - /** * 기슭막이 **자동 자리 = 노견(노폭 연장)이 최소가 되는 지점**(2026-08-22 사용자 확정). * 노견 끝에서 바깥으로 훑어, 표준 높이 벽으로 ① 성토고 하한(FILL_MIN_RISE_M)과 @@ -279,44 +263,6 @@ export interface WallVertical { floatGapM: number; } -/** - * 벽 자리에 맞는 **수직 제원**을 푼다(2026-08-22 사용자 확정 — 최소 노견 유지 규칙). - * 벽 상단(이음선 상단점)은 기본 노견에서 내려오는 **1:1.2 사면선 위**에 놓여야 한다. - * ① 자리가 안쪽이라 표준 높이(관경+여유고)로 사면선에 못 닿으면 → 높이를 키운다. - * ② 형태별 높이 한계(찰 3.0/메 2.0)에 걸리면 → 바닥을 원지반에서 띄운다 - * (뜬 공간은 별도 지지 구조물 — 추가 예정). - * ③ 자리가 바깥이라 사면선이 표준 높이 아래로 지나면 → 표준 높이 유지, 모자란 - * 물매는 `fillSlopeOf`가 노폭 연장으로 맞춘다(기존 규칙). - * 높이는 0.1m 눈금 올림(2026-08-21 확정)이라 상단이 사면선보다 반 눈금쯤 높을 수 있다. - */ -export function solveWallVertical( - anchorOffset: number, - edge: { offset_m: number; elevation_m: number }, - outward: number, - groundBase: number, - minHeight: number, - limitHeight: number, - lean: number = REVET_LEAN_RATIO, -): WallVertical { - const gridUp = (value: number): number => - Math.ceil(value / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M; - let height = minHeight; - let desiredTop = groundBase + minHeight; - for (let i = 0; i < 4; i += 1) { - const joint = anchorOffset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_TRAP_TOP_M); - const run = Math.max(0, (joint - edge.offset_m) * outward); - desiredTop = edge.elevation_m - run / FILL_SLOPE_RATIO_MIN; - const next = Math.min(Math.max(gridUp(desiredTop - groundBase), minHeight), limitHeight); - if (Math.abs(next - height) < 1e-9) { - height = next; - break; - } - height = next; - } - const floatGap = Math.max(0, desiredTop - limitHeight - groundBase); - return { height, baseElevation: groundBase + floatGap, floatGapM: floatGap }; -} - /** 교점을 마감면 **선분 안**으로 자른다 — 선분 밖(벽 하단 아래 등)으로 새지 않게. */ export function clampToFace( point: OffsetPoint, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 1c8af8d03..322748453 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -71,12 +71,9 @@ export interface WallLayout { * 별도 지지 구조물로 메운다(추가 예정). 0이면 지반 위(근입 0.5m). */ floatGapM: number; - /** 사용자가 요청한 좌우 이동을 **지형이 막아 못 나간** 거리(m, 2026-09-06). - * 0보다 크면 그 자리에서 더 나가면 벽이 원지반에 묻힌다는 뜻이라 툴팁으로 알린다. */ - shiftBlockedM?: number; /** 좌우 이동의 **하한**(m, 2026-09-07). 선반 길이가 음수가 되지 않게 x 는 최소 - * `1.2 × 상하 내림`까지 따라 나간다. 요청이 이 값보다 작으면 눌러도 벽이 안 움직이고, - * 지형에 막힌 것이 아니라 `shiftBlockedM` 은 0으로 나온다 — 그 까닭을 툴팁으로 알린다. */ + * `1.2 × 상하 내림`까지 따라 나간다. 요청이 이 값보다 작으면 눌러도 벽이 안 움직임 — + * 그 까닭을 툴팁으로 알린다. */ shiftFloorM?: number; /** 재질(메/찰/콘크리트) — 높이 한계를 정한다(2026-08-22 사용자). */ material: RevetMaterial; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 2c39d8d32..f22cb5c36 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -151,31 +151,9 @@ export interface CulvertLink { * 옆 측점에 서야 한다). 고정 기본 10m(5/5)는 소유 벽 제원조차 없을 때의 마지막 값이다. * 손으로 한 번 잡은 단은 그 값으로 고정된다 — 계곡부·능선부에서 단마다 연장이 다르다. */ -/** 「단이 못 섰다」 안내를 이미 낸 자리 — 다시 그릴 때마다 뜨지 않게 한 번만 낸다. - * 키에 세워진 단 수를 넣어, 지형·조작이 바뀌어 결과가 달라지면 다시 알린다. */ -const extraLimitNotified = new Set(); - /** 벽마다 직전에 적용된 자리 — 자리 조작이 한계에 막혀 벽이 그대로일 때만 알리려고 둠. */ const lastApplied = new Map(); -/** 요청 단 수보다 적게 섰음을 알린다 — 조정창을 열지 않은 경로(좌측 폼 등)에서도 뜬다. - * 종전에는 `activeRevet` 이 그 벽일 때만 떠서, 다른 길로 단 수를 바꾸면 조용히 잘렸다 - * (2026-09-07). 문구는 종전 그대로 쓴다. */ -function noticeExtraLimit(chainageM: number, side: "outlet" | "basin", built: number): void { - const key = `${chainageM.toFixed(2)}|${side}|${built}`; - if (extraLimitNotified.has(key)) return; - extraLimitNotified.add(key); - showToast(L("B06_Cross_Extra_Limit").replace("{n}", String(built)), "info"); -} - -/** 요청대로 다 선 자리는 안내 기록을 지운다 — 다음에 또 모자라면 다시 알린다. */ -function clearExtraLimitNotice(chainageM: number, side: "outlet" | "basin"): void { - const head = `${chainageM.toFixed(2)}|${side}|`; - for (const key of [...extraLimitNotified]) { - if (key.startsWith(head)) extraLimitNotified.delete(key); - } -} - export function tierSpanOf(section: CrossSection, key: string): StructureSpan { const stored = section.design?.extra_spans?.[key]; if (stored) return { beforeM: Math.max(stored.before_m, 0), afterM: Math.max(stored.after_m, 0) }; @@ -512,23 +490,6 @@ export function computeCardCulvert( inletStructure.updateAdjust(section.chainage_m, applied); } } - if (extraWalls && extraWalls.countFor(section, "basin") > layout.basinExtras.length) { - // 유입측(집수정 계류측·독립 기슭막이)도 유출측과 같은 규칙으로 알린다 — 종전에는 - // 조용히 잘려서 +를 눌러도 아무 일이 없는 것처럼 보였다(2026-08-29 사용자 지적: - // 우측 독립 기슭막이에서 추가2가 안 서는데 토스트가 없다). - noticeExtraLimit(section.chainage_m, "basin", layout.basinExtras.length); - extraWalls.syncCount(section.chainage_m, layout.basinExtras.length, "basin"); - } else if (extraWalls) { - clearExtraLimitNotice(section.chainage_m, "basin"); - } - if (extraWalls && extraWalls.countFor(section, "outlet") > layout.extraWalls.length) { - // 요청 단 수보다 지형이 허락하는 단이 적다(벽이 원지반에 0.5m 이상 묻히면 성토 - // 불필요 — 그 아래 단은 못 세운다). 조작 중일 때만 가능한 단 수를 토스트로 알린다. - noticeExtraLimit(section.chainage_m, "outlet", layout.extraWalls.length); - extraWalls.syncCount(section.chainage_m, layout.extraWalls.length, "outlet"); - } else if (extraWalls) { - clearExtraLimitNotice(section.chainage_m, "outlet"); - } if (revetOffset) { const roles: Array<[RevetKey, WallAdjust]> = [ ["inlet", layout.revetShift.inlet], diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 47e973173..19e1e8dbc 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -16,20 +16,17 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { - CrossDesign, CrossSection, DitchSide, DitchType, GroundType, SectionMode, - SectionSample, } from "./B06_Section_Api_Fetch"; export { appendPavementOverlay, appendRockBoundaryOverlay, } from "./B06_Section_UI_Cross_Design_Surface"; - -const SVG_NS = "http://www.w3.org/2000/svg"; +export { appendCrossDesignOverlay } from "./B06_Section_UI_Cross_Design_Line"; /** 넘침 정리 한 장 몫 — 쓰기·읽기·쓰기 세 토막으로 갈라 두어 프레임 단위로 묶는다. */ interface ReflowCard { @@ -524,261 +521,6 @@ export function buildDesignControls( return { bar, groundSegment }; } -/** - * 횡단 SVG에 표준단면 설계선을 겹쳐 그린다. 지면선과 겹치는 구간(사면이 지반을 추종하는 - * 부분)만 점선으로 그려 뒤에 깔린 지표선이 비쳐 보이게 하고, 나머지는 실선으로 둔다. - * - * `trim`이 오면 그 offset 범위 밖 구간은 그리지 않는다 — 배수관 측점에서 기슭막이가 - * 성토를 받치므로 벽과 교차된 이후의 성토 경사는 끊는다(2026-08-20 사용자 확정). - * 경계에 걸린 구간은 경계 지점까지 잘라 그린다. - */ -export function appendCrossDesignOverlay( - svg: SVGElement, - design: CrossDesign, - x: (offset: number) => number, - toDisplayY: (elevation: number) => number, - groundSamples: SectionSample[], - trim?: { - minOffset: number; - maxOffset: number; - minElevation?: number; - maxElevation?: number; - minSlope?: { points: Array<{ offset: number; elevation: number }> }; - maxSlope?: { points: Array<{ offset: number; elevation: number }> }; - }, - // 노폭 라벨 고정 자리(B06 카드 — X 축 라벨 바로 위 가운데). 없으면 노면 위. - labelAt?: { layer: SVGElement; x: number; y: number }, -): void { - const line = design.design_line; - if (!line || line.length < 2) return; - - // 지면선 표고 보간기(정렬된 유효 샘플, 범위 밖 끝값 클램프). - const ground = groundSamples - .filter((s) => s.valid !== false && s.elevation_m !== null && Number.isFinite(s.elevation_m)) - .map((s) => ({ offset: s.offset_m ?? 0, elevation: s.elevation_m as number })) - .sort((a, b) => a.offset - b.offset); - const groundAt = (offset: number): number | null => { - if (!ground.length) return null; - if (offset <= ground[0].offset) return ground[0].elevation; - const last = ground[ground.length - 1]; - if (offset >= last.offset) return last.elevation; - for (let i = 1; i < ground.length; i += 1) { - if (offset > ground[i].offset) continue; - const a = ground[i - 1]; - const b = ground[i]; - const span = b.offset - a.offset; - if (span <= 0) return b.elevation; - return a.elevation + (b.elevation - a.elevation) * ((offset - a.offset) / span); - } - return last.elevation; - }; - - // 각 설계선 점이 지면선과 픽셀 단위로 겹치는지 판정한다. - const OVERLAP_PX = 2; - const overlap = line.map((point) => { - const g = groundAt(point.offset_m); - if (g === null) return false; - return Math.abs(toDisplayY(point.elevation_m) - toDisplayY(g)) <= OVERLAP_PX; - }); - - // 사면 끝(계획선이 원지반과 처음 만나는 지점) **바깥**은 계획선을 그리지 않는다 — - // 그 너머는 손대지 않는 원지반이라 지반선이 이미 표현한다(2026-08-20 사용자 지적: - // 12+0.0 좌측 성토부에서 접점 이후 계획선이 계속 그려짐). - // 탐색은 **노견 바깥**에서 시작한다 — 노면·측구·노견을 지나는 계획선은 지반과 - // 우연히 겹쳐도 지워선 안 되고, 노견 이후 사면이 지반과 만나는 2차 접점만 종료점이다 - // (2026-08-20 사용자 ①). - const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m); - const edgesForTrim = design.road_edges; - const leftEdge = edgesForTrim - ? Math.max(edgesForTrim.left.offset_m, edgesForTrim.right.offset_m) - : 0; - const rightEdge = edgesForTrim - ? Math.min(edgesForTrim.left.offset_m, edgesForTrim.right.offset_m) - : 0; - // 측구는 노견 **바깥**에 붙는다(road_edges 밖). 측구가 있는 쪽은 측구 바깥 끝까지 - // 보호하고 그 다음부터 접점을 찾는다 — 노견에서 바로 찾으면 측구가 통째로 잘린다 - // (2026-08-20 사용자: "측구 이후 성토부가 존재하는 경사로의 1차 교차점까지 보호"). - const ditchSpec = design.ditch; - const ditchWidth = - design.ditch_enabled === false || !ditchSpec || ditchSpec.type === "none" - ? 0 - : ditchSpec.type === "standard" - ? ditchSpec.top_width_m - : ditchSpec.width_m; - // 좌표 규약: +offset = 좌측. ditch_side "left" = 큰 offset 쪽. - const protectMax = leftEdge + (design.ditch_side === "left" ? ditchWidth : 0); - const protectMin = rightEdge - (design.ditch_side === "right" ? ditchWidth : 0); - const meetOffset = (startOffset: number, step: number): number | null => { - const indices = - step > 0 ? sorted.map((_, i) => i) : sorted.map((_, i) => sorted.length - 1 - i); - for (const i of indices) { - const point = sorted[i]; - // 노견 안쪽(도로 구간)은 건너뛴다. - if (step > 0 ? point.offset_m <= startOffset : point.offset_m >= startOffset) continue; - const g = groundAt(point.offset_m); - if (g === null) continue; - if (Math.abs(toDisplayY(point.elevation_m) - toDisplayY(g)) <= OVERLAP_PX) - return point.offset_m; - } - return null; - }; - const meetLeft = meetOffset(protectMin, -1); // 작은 offset 쪽(측구까지 지난 뒤) - const meetRight = meetOffset(protectMax, 1); // 큰 offset 쪽(측구까지 지난 뒤) - - // 트림 범위로 구간 하나를 자른다. 완전히 밖이면 null, 걸치면 경계에서 보간해 끊는다. - const clipSegment = ( - a: { offset_m: number; elevation_m: number }, - b: { offset_m: number; elevation_m: number }, - ): - | [{ offset_m: number; elevation_m: number }, { offset_m: number; elevation_m: number }] - | null => { - // 배수관 세트 트림 + 사면 끝 접점 트림을 합친 유효 범위. - // 배수관 레이아웃이 사면 폴리라인(min/maxSlope)을 준 쪽은 **그 시작점(노견)에서** - // 설계선을 끊는다 — 그 바깥은 폴리라인이 그리므로 이중선이 되고, 트림 경계에서 - // 표고를 벽 상단으로 스냅하면서 수직으로 튀는 선이 남는다(2026-08-22 사용자 지적: - // 집수정을 좌우로 옮기면 벽 상단이 노견 표고 그대로라 스냅 폭이 그만큼 커진다). - const minSlopeStart = trim?.minSlope?.points[0]?.offset; - const maxSlopeStart = trim?.maxSlope?.points[0]?.offset; - const minOffset = Math.max( - trim?.minOffset ?? -Infinity, - meetLeft ?? -Infinity, - minSlopeStart ?? -Infinity, - ); - const maxOffset = Math.min( - trim?.maxOffset ?? Infinity, - meetRight ?? Infinity, - maxSlopeStart ?? Infinity, - ); - if (!Number.isFinite(minOffset) && !Number.isFinite(maxOffset)) return [a, b]; - const effective = { minOffset, maxOffset }; - const lo = Math.min(a.offset_m, b.offset_m); - const hi = Math.max(a.offset_m, b.offset_m); - if (hi <= effective.minOffset || lo >= effective.maxOffset) return null; - const at = (offset: number) => { - const span = b.offset_m - a.offset_m; - const t = Math.abs(span) > 1e-9 ? (offset - a.offset_m) / span : 0; - return { offset_m: offset, elevation_m: a.elevation_m + (b.elevation_m - a.elevation_m) * t }; - }; - // 배수관 트림 경계에서는 끝단 표고를 **벽 이음선 상단점**에 맞춰 내린다 — 벽 높이가 - // 관경+여유고로 고정된 뒤로는 설계 사면선 표고와 어긋나 사면선 끝이 벽에서 떨어진다 - // (2026-08-21 사용자 ②). 사면 끝 접점 트림이 이긴 경계에는 적용하지 않는다. - const snap = ( - p: { offset_m: number; elevation_m: number }, - elevation: number | undefined, - wins: boolean, - ) => (elevation != null && wins ? { offset_m: p.offset_m, elevation_m: elevation } : p); - const minIsCulvert = trim != null && Math.abs(effective.minOffset - trim.minOffset) < 1e-9; - const maxIsCulvert = trim != null && Math.abs(effective.maxOffset - trim.maxOffset) < 1e-9; - const clamp = (p: { offset_m: number; elevation_m: number }) => - p.offset_m < effective.minOffset - ? snap(at(effective.minOffset), trim?.minElevation, minIsCulvert) - : p.offset_m > effective.maxOffset - ? snap(at(effective.maxOffset), trim?.maxElevation, maxIsCulvert) - : p; - return [clamp(a), clamp(b)]; - }; - - // 세그먼트별로 그린다. 지면선과 겹치는 구간(= 계획선이 원지반과 차이가 없는 구간)은 - // **그리지 않는다** — 지반선이 이미 같은 자리를 표현하므로 정보가 없다 - // (2026-08-20 사용자 지시: 보라색 점선 삭제). - // 단 **노면·노견·측구부터 1차 접점까지는 언제나 그린다** — 계획고가 지반과 우연히 - // 몇 ㎝ 차이면 노면 계획선이 통째로 끊겨 보이고(8·10·10+0.9), 노견 끝 성토고가 - // 4㎝ 정도면 접점까지의 짧은 사면 조각도 사라진다(9+0.0 — 2026-08-20 사용자 지적). - // 접점을 못 찾은 쪽은 보호 구간을 노견·측구까지로 둔다. - const keepMin = meetLeft ?? protectMin; - const keepMax = meetRight ?? protectMax; - const isProtected = (a: { offset_m: number }, b: { offset_m: number }): boolean => - Math.max(a.offset_m, b.offset_m) > keepMin && Math.min(a.offset_m, b.offset_m) < keepMax; - for (let i = 1; i < line.length; i += 1) { - if (overlap[i - 1] && overlap[i] && !isProtected(line[i - 1], line[i])) continue; - const clipped = clipSegment(line[i - 1], line[i]); - if (!clipped) continue; - const seg = document.createElementNS(SVG_NS, "line"); - seg.setAttribute("x1", String(x(clipped[0].offset_m))); - seg.setAttribute("y1", String(toDisplayY(clipped[0].elevation_m))); - seg.setAttribute("x2", String(x(clipped[1].offset_m))); - seg.setAttribute("y2", String(toDisplayY(clipped[1].elevation_m))); - seg.setAttribute("class", "b06-chart__design-cross"); - svg.append(seg); - } - - // 배수관 측점 성토 사면 — 백엔드 설계선(1:1.2 고정) 대신 **배수관 레이아웃이 준 직선** - // 을 그린다. 벽 자리가 관 길이 맞춤으로 옮겨져도 노견 → 벽 이음선 상단점을 잇는 단일 - // 각도가 유지된다(2026-08-21 사용자 ②, 성토 사면은 단일 각도). - for (const segment of [trim?.minSlope, trim?.maxSlope]) { - if (!segment || segment.points.length < 2) continue; - // 폴리라인 — [원 노견, (연장된 노견), 벽 이음선 상단점]. 물매가 1:1.2 고정이라 - // 벽이 밖으로 나간 만큼 노면이 연장된다(2026-08-21 사용자 확정). - for (let i = 1; i < segment.points.length; i += 1) { - const a = segment.points[i - 1]; - const b = segment.points[i]; - const seg = document.createElementNS(SVG_NS, "line"); - seg.setAttribute("x1", String(x(a.offset))); - seg.setAttribute("y1", String(toDisplayY(a.elevation))); - seg.setAttribute("x2", String(x(b.offset))); - seg.setAttribute("y2", String(toDisplayY(b.elevation))); - seg.setAttribute("class", "b06-chart__design-cross"); - svg.append(seg); - } - } - - // 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안 - // 구분이 안 되는 경계를 표시한다. - const edges = design.carriageway_edges; - if (edges) { - const tickAt = (offsetM: number, elevationM: number, className: string, half: number) => { - const cx = x(offsetM); - const cy = toDisplayY(elevationM); - const tick = document.createElementNS(SVG_NS, "line"); - tick.setAttribute("x1", String(cx)); - tick.setAttribute("y1", String(cy - half)); - tick.setAttribute("x2", String(cx)); - tick.setAttribute("y2", String(cy + half)); - tick.setAttribute("class", className); - svg.append(tick); - }; - for (const edge of [edges.left, edges.right]) { - tickAt(edge.offset_m, edge.elevation_m, "b06-chart__carriageway-tick", 3.6); - } - // 노견 바깥 끝에도 틱을 세운다(2026-09-12 사용자) — 종전에는 차도에만 표기가 있어 - // **노견이 늘어난 건지 차도가 늘어난 건지 화면에서 못 가렸다**. 노견은 좌·우 0.5m 로 - // 고정이고 확폭은 차도에만 붙으므로, 두 틱 사이 간격이 그 사실을 그대로 보여 준다. - const roadEdges = design.road_edges; - if (roadEdges) { - for (const edge of [roadEdges.left, roadEdges.right]) { - tickAt(edge.offset_m, edge.elevation_m, "b06-chart__shoulder-tick", 2.4); - } - } - // 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다. - // 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다. - const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0); - const standardWidth = design.carriageway_standard_width_m; - const shoulderLeft = roadEdges ? roadEdges.left.offset_m - edges.left.offset_m : null; - const shoulderRight = roadEdges ? edges.right.offset_m - roadEdges.right.offset_m : null; - const label = document.createElementNS(SVG_NS, "text"); - label.setAttribute( - "x", - String(labelAt?.x ?? (x(edges.left.offset_m) + x(edges.right.offset_m)) / 2), - ); - label.setAttribute("y", String(labelAt?.y ?? toDisplayY(edges.left.elevation_m) - 6)); - label.setAttribute("class", "b06-chart__carriageway-label"); - const widthText = - widened > 0.001 && typeof standardWidth === "number" - ? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)} + 확폭 ${widened.toFixed(2)})` - : `노폭 ${design.carriageway_width_m.toFixed(2)}m`; - // 노견은 좌우가 같으면 한 번만 적는다 — 라벨이 길어지면 옆 측점 라벨과 겹친다. - label.textContent = - shoulderLeft != null && shoulderRight != null - ? `${widthText} · 노견 ${ - Math.abs(shoulderLeft - shoulderRight) < 0.005 - ? `${shoulderLeft.toFixed(2)}m` - : `좌 ${shoulderLeft.toFixed(2)} / 우 ${shoulderRight.toFixed(2)}m` - }` - : widthText; - (labelAt?.layer ?? svg).append(label); - } -} - /** * 암 경계선(지면선 복사 + 상하 오프셋, 점선)을 겹쳐 그린다. * 계획선(설계선)이 아니라 **지반선(지면선)**을 복사해 이동하는 것이 규칙이다. diff --git a/B06_Section/B06_Section_UI_Cross_Design_Line.ts b/B06_Section/B06_Section_UI_Cross_Design_Line.ts new file mode 100644 index 000000000..cc9dbfae3 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Design_Line.ts @@ -0,0 +1,263 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Design_Line.ts + * 횡단 설계선 오버레이 — `_Cross_Design.ts` 에서 분리(700줄 제한 · 91-3). + * 사면 끝 = 설계선이 원지반과 처음 만나는 자리 · 수치(0.01 m)로 판정 — 화면 축척과 무관. + * ========================================================================== */ + +import type { CrossDesign, SectionSample } from "./B06_Section_Api_Fetch"; + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** + * 횡단 SVG에 표준단면 설계선을 겹쳐 그린다. 지면선과 겹치는 구간(사면이 지반을 추종하는 + * 부분)만 점선으로 그려 뒤에 깔린 지표선이 비쳐 보이게 하고, 나머지는 실선으로 둔다. + * + * `trim`이 오면 그 offset 범위 밖 구간은 그리지 않는다 — 배수관 측점에서 기슭막이가 + * 성토를 받치므로 벽과 교차된 이후의 성토 경사는 끊는다(2026-08-20 사용자 확정). + * 경계에 걸린 구간은 경계 지점까지 잘라 그린다. + */ +export function appendCrossDesignOverlay( + svg: SVGElement, + design: CrossDesign, + x: (offset: number) => number, + toDisplayY: (elevation: number) => number, + groundSamples: SectionSample[], + trim?: { + minOffset: number; + maxOffset: number; + minElevation?: number; + maxElevation?: number; + minSlope?: { points: Array<{ offset: number; elevation: number }> }; + maxSlope?: { points: Array<{ offset: number; elevation: number }> }; + }, + // 노폭 라벨 고정 자리(B06 카드 — X 축 라벨 바로 위 가운데). 없으면 노면 위. + labelAt?: { layer: SVGElement; x: number; y: number }, +): void { + const line = design.design_line; + if (!line || line.length < 2) return; + + // 지면선 표고 보간기(정렬된 유효 샘플, 범위 밖 끝값 클램프). + const ground = groundSamples + .filter((s) => s.valid !== false && s.elevation_m !== null && Number.isFinite(s.elevation_m)) + .map((s) => ({ offset: s.offset_m ?? 0, elevation: s.elevation_m as number })) + .sort((a, b) => a.offset - b.offset); + const groundAt = (offset: number): number | null => { + if (!ground.length) return null; + if (offset <= ground[0].offset) return ground[0].elevation; + const last = ground[ground.length - 1]; + if (offset >= last.offset) return last.elevation; + for (let i = 1; i < ground.length; i += 1) { + if (offset > ground[i].offset) continue; + const a = ground[i - 1]; + const b = ground[i]; + const span = b.offset - a.offset; + if (span <= 0) return b.elevation; + return a.elevation + (b.elevation - a.elevation) * ((offset - a.offset) / span); + } + return last.elevation; + }; + + // 각 설계선 점이 지면선과 겹치는지 — 수치(0.01 m)로 판정 · 화면 축척(줌)과 무관(91-3). + const OVERLAP_M = 0.01; + const overlap = line.map((point) => { + const g = groundAt(point.offset_m); + if (g === null) return false; + return Math.abs(point.elevation_m - g) <= OVERLAP_M; + }); + + // 사면 끝(계획선이 원지반과 처음 만나는 지점) **바깥**은 계획선을 그리지 않는다 — + // 그 너머는 손대지 않는 원지반이라 지반선이 이미 표현한다(2026-08-20 사용자 지적: + // 12+0.0 좌측 성토부에서 접점 이후 계획선이 계속 그려짐). + // 탐색은 **노견 바깥**에서 시작한다 — 노면·측구·노견을 지나는 계획선은 지반과 + // 우연히 겹쳐도 지워선 안 되고, 노견 이후 사면이 지반과 만나는 2차 접점만 종료점이다 + // (2026-08-20 사용자 ①). + const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m); + const edgesForTrim = design.road_edges; + const leftEdge = edgesForTrim + ? Math.max(edgesForTrim.left.offset_m, edgesForTrim.right.offset_m) + : 0; + const rightEdge = edgesForTrim + ? Math.min(edgesForTrim.left.offset_m, edgesForTrim.right.offset_m) + : 0; + // 측구는 노견 **바깥**에 붙는다(road_edges 밖). 측구가 있는 쪽은 측구 바깥 끝까지 + // 보호하고 그 다음부터 접점을 찾는다 — 노견에서 바로 찾으면 측구가 통째로 잘린다 + // (2026-08-20 사용자: "측구 이후 성토부가 존재하는 경사로의 1차 교차점까지 보호"). + const ditchSpec = design.ditch; + const ditchWidth = + design.ditch_enabled === false || !ditchSpec || ditchSpec.type === "none" + ? 0 + : ditchSpec.type === "standard" + ? ditchSpec.top_width_m + : ditchSpec.width_m; + // 좌표 규약: +offset = 좌측. ditch_side "left" = 큰 offset 쪽. + const protectMax = leftEdge + (design.ditch_side === "left" ? ditchWidth : 0); + const protectMin = rightEdge - (design.ditch_side === "right" ? ditchWidth : 0); + const meetOffset = (startOffset: number, step: number): number | null => { + const indices = + step > 0 ? sorted.map((_, i) => i) : sorted.map((_, i) => sorted.length - 1 - i); + for (const i of indices) { + const point = sorted[i]; + // 노견 안쪽(도로 구간)은 건너뛴다. + if (step > 0 ? point.offset_m <= startOffset : point.offset_m >= startOffset) continue; + const g = groundAt(point.offset_m); + if (g === null) continue; + if (Math.abs(point.elevation_m - g) <= OVERLAP_M) return point.offset_m; + } + return null; + }; + const meetLeft = meetOffset(protectMin, -1); // 작은 offset 쪽(측구까지 지난 뒤) + const meetRight = meetOffset(protectMax, 1); // 큰 offset 쪽(측구까지 지난 뒤) + + // 트림 범위로 구간 하나를 자른다. 완전히 밖이면 null, 걸치면 경계에서 보간해 끊는다. + const clipSegment = ( + a: { offset_m: number; elevation_m: number }, + b: { offset_m: number; elevation_m: number }, + ): + | [{ offset_m: number; elevation_m: number }, { offset_m: number; elevation_m: number }] + | null => { + // 배수관 세트 트림 + 사면 끝 접점 트림을 합친 유효 범위. + // 배수관 레이아웃이 사면 폴리라인(min/maxSlope)을 준 쪽은 **그 시작점(노견)에서** + // 설계선을 끊는다 — 그 바깥은 폴리라인이 그리므로 이중선이 되고, 트림 경계에서 + // 표고를 벽 상단으로 스냅하면서 수직으로 튀는 선이 남는다(2026-08-22 사용자 지적: + // 집수정을 좌우로 옮기면 벽 상단이 노견 표고 그대로라 스냅 폭이 그만큼 커진다). + const minSlopeStart = trim?.minSlope?.points[0]?.offset; + const maxSlopeStart = trim?.maxSlope?.points[0]?.offset; + const minOffset = Math.max( + trim?.minOffset ?? -Infinity, + meetLeft ?? -Infinity, + minSlopeStart ?? -Infinity, + ); + const maxOffset = Math.min( + trim?.maxOffset ?? Infinity, + meetRight ?? Infinity, + maxSlopeStart ?? Infinity, + ); + if (!Number.isFinite(minOffset) && !Number.isFinite(maxOffset)) return [a, b]; + const effective = { minOffset, maxOffset }; + const lo = Math.min(a.offset_m, b.offset_m); + const hi = Math.max(a.offset_m, b.offset_m); + if (hi <= effective.minOffset || lo >= effective.maxOffset) return null; + const at = (offset: number) => { + const span = b.offset_m - a.offset_m; + const t = Math.abs(span) > 1e-9 ? (offset - a.offset_m) / span : 0; + return { offset_m: offset, elevation_m: a.elevation_m + (b.elevation_m - a.elevation_m) * t }; + }; + // 배수관 트림 경계에서는 끝단 표고를 **벽 이음선 상단점**에 맞춰 내린다 — 벽 높이가 + // 관경+여유고로 고정된 뒤로는 설계 사면선 표고와 어긋나 사면선 끝이 벽에서 떨어진다 + // (2026-08-21 사용자 ②). 사면 끝 접점 트림이 이긴 경계에는 적용하지 않는다. + const snap = ( + p: { offset_m: number; elevation_m: number }, + elevation: number | undefined, + wins: boolean, + ) => (elevation != null && wins ? { offset_m: p.offset_m, elevation_m: elevation } : p); + const minIsCulvert = trim != null && Math.abs(effective.minOffset - trim.minOffset) < 1e-9; + const maxIsCulvert = trim != null && Math.abs(effective.maxOffset - trim.maxOffset) < 1e-9; + const clamp = (p: { offset_m: number; elevation_m: number }) => + p.offset_m < effective.minOffset + ? snap(at(effective.minOffset), trim?.minElevation, minIsCulvert) + : p.offset_m > effective.maxOffset + ? snap(at(effective.maxOffset), trim?.maxElevation, maxIsCulvert) + : p; + return [clamp(a), clamp(b)]; + }; + + // 세그먼트별로 그린다. 지면선과 겹치는 구간(= 계획선이 원지반과 차이가 없는 구간)은 + // **그리지 않는다** — 지반선이 이미 같은 자리를 표현하므로 정보가 없다 + // (2026-08-20 사용자 지시: 보라색 점선 삭제). + // 단 **노면·노견·측구부터 1차 접점까지는 언제나 그린다** — 계획고가 지반과 우연히 + // 몇 ㎝ 차이면 노면 계획선이 통째로 끊겨 보이고(8·10·10+0.9), 노견 끝 성토고가 + // 4㎝ 정도면 접점까지의 짧은 사면 조각도 사라진다(9+0.0 — 2026-08-20 사용자 지적). + // 접점을 못 찾은 쪽은 보호 구간을 노견·측구까지로 둔다. + const keepMin = meetLeft ?? protectMin; + const keepMax = meetRight ?? protectMax; + const isProtected = (a: { offset_m: number }, b: { offset_m: number }): boolean => + Math.max(a.offset_m, b.offset_m) > keepMin && Math.min(a.offset_m, b.offset_m) < keepMax; + for (let i = 1; i < line.length; i += 1) { + if (overlap[i - 1] && overlap[i] && !isProtected(line[i - 1], line[i])) continue; + const clipped = clipSegment(line[i - 1], line[i]); + if (!clipped) continue; + const seg = document.createElementNS(SVG_NS, "line"); + seg.setAttribute("x1", String(x(clipped[0].offset_m))); + seg.setAttribute("y1", String(toDisplayY(clipped[0].elevation_m))); + seg.setAttribute("x2", String(x(clipped[1].offset_m))); + seg.setAttribute("y2", String(toDisplayY(clipped[1].elevation_m))); + seg.setAttribute("class", "b06-chart__design-cross"); + svg.append(seg); + } + + // 배수관 측점 성토 사면 — 백엔드 설계선(1:1.2 고정) 대신 **배수관 레이아웃이 준 직선** + // 을 그린다. 벽 자리가 관 길이 맞춤으로 옮겨져도 노견 → 벽 이음선 상단점을 잇는 단일 + // 각도가 유지된다(2026-08-21 사용자 ②, 성토 사면은 단일 각도). + for (const segment of [trim?.minSlope, trim?.maxSlope]) { + if (!segment || segment.points.length < 2) continue; + // 폴리라인 — [원 노견, (연장된 노견), 벽 이음선 상단점]. 물매가 1:1.2 고정이라 + // 벽이 밖으로 나간 만큼 노면이 연장된다(2026-08-21 사용자 확정). + for (let i = 1; i < segment.points.length; i += 1) { + const a = segment.points[i - 1]; + const b = segment.points[i]; + const seg = document.createElementNS(SVG_NS, "line"); + seg.setAttribute("x1", String(x(a.offset))); + seg.setAttribute("y1", String(toDisplayY(a.elevation))); + seg.setAttribute("x2", String(x(b.offset))); + seg.setAttribute("y2", String(toDisplayY(b.elevation))); + seg.setAttribute("class", "b06-chart__design-cross"); + svg.append(seg); + } + } + + // 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안 + // 구분이 안 되는 경계를 표시한다. + const edges = design.carriageway_edges; + if (edges) { + const tickAt = (offsetM: number, elevationM: number, className: string, half: number) => { + const cx = x(offsetM); + const cy = toDisplayY(elevationM); + const tick = document.createElementNS(SVG_NS, "line"); + tick.setAttribute("x1", String(cx)); + tick.setAttribute("y1", String(cy - half)); + tick.setAttribute("x2", String(cx)); + tick.setAttribute("y2", String(cy + half)); + tick.setAttribute("class", className); + svg.append(tick); + }; + for (const edge of [edges.left, edges.right]) { + tickAt(edge.offset_m, edge.elevation_m, "b06-chart__carriageway-tick", 3.6); + } + // 노견 바깥 끝에도 틱을 세운다(2026-09-12 사용자) — 종전에는 차도에만 표기가 있어 + // **노견이 늘어난 건지 차도가 늘어난 건지 화면에서 못 가렸다**. 노견은 좌·우 0.5m 로 + // 고정이고 확폭은 차도에만 붙으므로, 두 틱 사이 간격이 그 사실을 그대로 보여 준다. + const roadEdges = design.road_edges; + if (roadEdges) { + for (const edge of [roadEdges.left, roadEdges.right]) { + tickAt(edge.offset_m, edge.elevation_m, "b06-chart__shoulder-tick", 2.4); + } + } + // 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다. + // 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다. + const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0); + const standardWidth = design.carriageway_standard_width_m; + const shoulderLeft = roadEdges ? roadEdges.left.offset_m - edges.left.offset_m : null; + const shoulderRight = roadEdges ? edges.right.offset_m - roadEdges.right.offset_m : null; + const label = document.createElementNS(SVG_NS, "text"); + label.setAttribute( + "x", + String(labelAt?.x ?? (x(edges.left.offset_m) + x(edges.right.offset_m)) / 2), + ); + label.setAttribute("y", String(labelAt?.y ?? toDisplayY(edges.left.elevation_m) - 6)); + label.setAttribute("class", "b06-chart__carriageway-label"); + const widthText = + widened > 0.001 && typeof standardWidth === "number" + ? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)} + 확폭 ${widened.toFixed(2)})` + : `노폭 ${design.carriageway_width_m.toFixed(2)}m`; + // 노견은 좌우가 같으면 한 번만 적는다 — 라벨이 길어지면 옆 측점 라벨과 겹친다. + label.textContent = + shoulderLeft != null && shoulderRight != null + ? `${widthText} · 노견 ${ + Math.abs(shoulderLeft - shoulderRight) < 0.005 + ? `${shoulderLeft.toFixed(2)}m` + : `좌 ${shoulderLeft.toFixed(2)} / 우 ${shoulderRight.toFixed(2)}m` + }` + : widthText; + (labelAt?.layer ?? svg).append(label); + } +} diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index f73a30df1..0c802f065 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -16,7 +16,6 @@ * 3D 코리도는 `tiers[].polygon`·`span`을 그대로 스윕한다. * ========================================================================== */ -import { showToast } from "@ui/ui_template_elements"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import { FILL_SLOPE_RATIO_MIN, @@ -241,8 +240,7 @@ export function computeRevetmentLayout( roadElevation: edge.elevation_m, }); // 넣은 좌우 이동이 **각도 하한**에 눌려 통째로 무시됐으면 그 하한을 남긴다(2026-09-07). - // 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났고, - // 지형에 막힌 것이 아니라 `shiftBlockedM` 은 0 이어서 까닭을 알 길이 없었다. + // 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났음. if (requestedX > 0.005 && floor > requestedX + 0.005) { tier1.shiftFloorM = Math.round(floor * 100) / 100; } @@ -324,27 +322,6 @@ export function computeRevetmentLayout( }; } -/** 자리가 없어 못 세운 단이 있으면 **한 번만** 알린다 — 카드마다 띄우면 화면이 덮인다. */ -const shortfallStations = new Set(); -let shortfallTimer = 0; -let shortfallLast = ""; - -function noticeShortfall(layout: RevetmentLayout): void { - if (layout.walls.length >= layout.requestedTiers) return; - shortfallStations.add(Math.round(layout.chainageM * 100)); - window.clearTimeout(shortfallTimer); - shortfallTimer = window.setTimeout(() => { - const message = - `기슭막이 ${layout.requestedTiers}단 중 ${layout.walls.length}단만 세울 수 있습니다` + - `(측점 ${shortfallStations.size}곳) — 아래 자리가 원지반에 묻힙니다. ` + - "기준 벽을 사면 위로 올린 뒤(▲) 단을 늘리세요."; - shortfallStations.clear(); - if (message === shortfallLast) return; - shortfallLast = message; - showToast(message, "warning"); - }, 400); -} - /** 횡단 카드에 독립 기슭막이(1단+다단)를 그린다 — 배관 벽과 같은 도형. 그렸으면 setter. */ export function appendRevetmentOverlay( svg: SVGElement, @@ -354,7 +331,6 @@ export function appendRevetmentOverlay( onSelect?: () => void, ): ((active: boolean) => void) | null { if (!layout || !layout.walls.length) return null; - noticeShortfall(layout); // 성토부선(벽 사이·벽 아래) 먼저 — 벽 밑에 깔린다. for (const segment of layout.fillSegments) { if (segment.kind === "cut") continue; @@ -383,11 +359,7 @@ export function appendRevetmentOverlay( ? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 별도` : ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m`) + (wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : "") + - // 「좌우로 옮겨도 안 움직인다」의 까닭 두 가지 — 배관 벽 툴팁과 같은 문구다 - // (2026-09-07). 독립 벽 툴팁은 따로 만들어져 있어 여태 어느 쪽도 안 떴다. - (wall.shiftBlockedM - ? ` · ⚠ 좌우 이동 ${wall.shiftBlockedM.toFixed(2)}m 는 지형에 막힘(더 나가면 벽이 묻힘)` - : "") + + // 「좌우로 옮겨도 안 움직인다」의 까닭 — 배관 벽 툴팁과 같은 문구(2026-09-07). (wall.shiftFloorM ? ` · ⚠ 좌우 이동 하한 ${wall.shiftFloorM.toFixed(2)}m — 성토선 각도(1:${FILL_SLOPE_RATIO_MIN})를` + ` 지키려면 그만큼은 나가 있어야 함. 그 아래 값을 넣으면 벽이 안 움직임` diff --git a/common_util/common_util_masonry_slope.ts b/common_util/common_util_masonry_slope.ts index 7b9f1a98e..f9c500a2c 100644 --- a/common_util/common_util_masonry_slope.ts +++ b/common_util/common_util_masonry_slope.ts @@ -21,9 +21,9 @@ export interface WallLeanInput { form?: string | null; /** 직고(m) — 순수 높이. */ height_m: number; - /** 측점 단면유형 `design.section_mode`. */ + /** 측점 단면유형 `design.section_mode` — 기울기 판정에 안 씀(91-3 · 칸 · 성토 기본). */ section_mode?: string | null; - /** 설치 측 — 「자동(성토 쪽)」·「좌」·「우」(없으면 자동). */ + /** 설치 측 — 기울기 판정에 안 씀(91-3). */ side?: string | null; /** 성토/절토 칸 — 고르면 이김. */ face_role?: string | null; @@ -38,32 +38,10 @@ const FILL = "성토"; const CUT = "절토"; /** 형태 → 찰(true)/메(false) — 파이썬 `REVETMENT_STONE_FORMS`. */ const STONE_FORMS: Record = { "돌쌓기(찰)": true, "돌쌓기(메)": false }; -/** 단면유형 → (좌측 역할, 우측 역할). 좌 = +offset. */ -const MODE_ROLES: Record = { - left_cut: [CUT, FILL], - right_cut: [FILL, CUT], - both_cut: [CUT, CUT], - both_fill: [FILL, FILL], -}; -const LEFT = new Set(["좌", "left", "L"]); -const RIGHT = new Set(["우", "right", "R"]); -const AUTO = new Set(["자동(성토 쪽)", "자동", "auto", ""]); - -/** 성토/절토 — 칸이 이기고, 비면 단면유형 + 설치 측. 못 가르면 `null`(성토로 눅이지 않음). */ -export function faceRoleOf( - sectionMode: string | null | undefined, - side: string | null | undefined, - faceRole?: string | null, -): string | null { +/** 성토/절토 — 칸이 이기고, 비면 성토(흙막이 · 사면안정 흙막이 · 옹벽 모두 · 원지반 자동판정 안 씀 · 91-3). */ +export function faceRoleOf(faceRole?: string | null): string { const picked = String(faceRole ?? "").trim(); - if (picked === FILL || picked === CUT) return picked; - const roles = MODE_ROLES[String(sectionMode ?? "").trim()]; - if (!roles) return null; - const where = String(side ?? "").trim(); - if (LEFT.has(where)) return roles[0]; - if (RIGHT.has(where)) return roles[1]; - if (AUTO.has(where)) return roles[0] === CUT && roles[1] === CUT ? null : FILL; - return null; + return picked === CUT ? CUT : FILL; } /** 벽 전면 기울기 n — 표를 못 고르면 종전 0.3. */ @@ -72,8 +50,7 @@ export function wallLeanRatio(table: MasonrySlopeTable, input: WallLeanInput): n if (wet === undefined) return LEGACY_LEAN_RATIO; const given = input.face_slope_ratio; if (typeof given === "number" && Number.isFinite(given) && given > 0) return given; - const face = faceRoleOf(input.section_mode, input.side, input.face_role); - if (!face) return LEGACY_LEAN_RATIO; + const face = faceRoleOf(input.face_role); const row = table.table?.[wet ? "찰쌓기" : "메쌓기"]?.[face]; if (!row?.length) return LEGACY_LEAN_RATIO; const steps = table.steps_m ?? []; diff --git a/common_util/common_util_structure_face_role.py b/common_util/common_util_structure_face_role.py index af614785d..4dfe00c84 100644 --- a/common_util/common_util_structure_face_role.py +++ b/common_util/common_util_structure_face_role.py @@ -61,16 +61,19 @@ def structure_face_role(section_mode: str | None, side: str | None) -> tuple[str return None, f"설치 측을 알 수 없음({side!r}) — 성절토를 가를 근거 없음" -#: 성토/절토를 **사용자가 고르는 칸** — 비우면 위 판정(계산이지 기본값이 아님 · 2026-09-14 브레인 판정 ㉲). +#: 성토/절토를 **사용자가 고르는 칸** — 비우면 성토(91-3). FACE_ROLE_KEY = "face_role" def structure_face_role_of( section_mode: str | None, options: dict | None ) -> tuple[str | None, str]: - """칸 `face_role`(성토·절토)을 골랐으면 그 값, 비었으면 단면유형 + 설치 측 판정.""" + """벽 기울기용 — 칸 `face_role`(성토·절토)을 골랐으면 그 값, 비었으면 성토. + + 단면유형(원지반 자동판정)은 안 봄(91-3 · TS 짝 `common_util_masonry_slope.faceRoleOf`). + """ values = options or {} picked = str(values.get(FACE_ROLE_KEY) or "").strip() if picked in (FILL, CUT): return picked, f"사용자 지정 {picked}면(성토/절토 칸)" - return structure_face_role(section_mode, values.get("side")) + return FILL, "성토/절토 칸 비움 → 성토면(기본)" diff --git a/resources/tester/helper_91_ground_free.cjs b/resources/tester/helper_91_ground_free.cjs index fe598aba9..5aed020f1 100644 --- a/resources/tester/helper_91_ground_free.cjs +++ b/resources/tester/helper_91_ground_free.cjs @@ -97,4 +97,34 @@ out.fordHandBedDown = fordFloor((s) => { hand(s); shiftGround(s, -2, -2, 2); }); +// ② 91-3 다단 자동 자리 — 관 180 유출 흙막이 + 2단 · 원지반을 옮겨도 단 자리 같음 · 사면 1.0 m +const { computeCulvertLayout } = loadTs(B06("B06_Section_UI_Cross_Culvert_Geom.ts")); +function tiers(dz) { + const s = clone(at(180)); + shiftGround(s, dz); + s.culvert = { + type: "pipe", + chainage_m: 180, + pipe_kind: "흄관", + diameter_m: 1.0, + min_cover_m: 0.5, + inlet: { role: "inlet", structure: "기슭막이", revet_form: "돌쌓기(찰)" }, + outlet: { role: "outlet", structure: "기슭막이", revet_form: "돌쌓기(찰)" }, + }; + s.design.inlet_structure = "revet"; + const tier = { x: null, d: null, h: 2, m: null }; + const layout = computeCulvertLayout(s, s.samples, { extras: [tier, tier] }, "revet"); + return { + walls: layout.extraWalls.map((w) => [r3(w.bottomBack.offset), r3(w.bottomBack.elevation)]), + slopes: layout.outletFill.segments.slice(0, 2).map((g) => r3(g.lengthM)), + }; +} +out.tiers = [tiers(0), tiers(0.5), tiers(-0.5)]; + +// ⑤ 91-3 전면 기울기 — 단면유형(원지반 자동판정)과 무관 · 성토/절토 칸만 +const { leanOf } = loadTs(B06("B06_Section_UI_Cross_Lean.ts")); +out.lean = ["left_cut", "right_cut", "both_fill", "both_cut"].map((mode) => [ + leanOf({ form: "돌쌓기(찰)", height_m: 1.5, section_mode: mode, side: "좌" }), + leanOf({ form: "돌쌓기(찰)", height_m: 1.5, section_mode: mode, side: "좌", face_role: "절토" }), +]); process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_91_ground_free.py b/resources/tester/test_91_ground_free.py index c33e96f8e..7fc4cdf36 100644 --- a/resources/tester/test_91_ground_free.py +++ b/resources/tester/test_91_ground_free.py @@ -44,3 +44,42 @@ def test_세월교_바닥_손_값은_원지반을_바꿔도_그대로(fit: dict) def test_세월교_박스_칸은_측벽_높이_줄을_받음() -> None: dock = (ROOT / "B06_Section/B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8") assert "sideSlots?.wingOutlet) return WALL_HEIGHT_ROW" in dock + + +def test_다단_자동_자리는_원지반과_무관_사면_1m(fit: dict) -> None: + base, up, down = fit["tiers"] + assert base["walls"] == up["walls"] == down["walls"] + assert base["slopes"] == [1.0, 1.0] + + +def test_전면_기울기는_단면유형과_무관_칸만(fit: dict) -> None: + # 단면유형 넷 모두 같은 값 · 비면 성토 표값(1.5 m 찰 = 0.25) · 절토 칸 = 0.2 + assert all(pair == [0.25, 0.2] for pair in fit["lean"]) + + +def test_B08_성토절토_판정도_칸_아니면_성토() -> None: + from common_util.common_util_structure_face_role import structure_face_role_of + + for mode in ("left_cut", "right_cut", "both_cut", "both_fill"): + for side in ("좌", "우", "자동(성토 쪽)"): + assert structure_face_role_of(mode, {"side": side})[0] == "성토" + assert structure_face_role_of("both_fill", {"face_role": "절토"})[0] == "절토" + + +def test_설계선_끝은_화면_픽셀이_아니라_수치로() -> None: + line = (ROOT / "B06_Section/B06_Section_UI_Cross_Design_Line.ts").read_text(encoding="utf-8") + assert "OVERLAP_PX" not in line + assert line.count("Math.abs(point.elevation_m - g) <= OVERLAP_M") == 2 + + +def test_지형에_막힘_잔재_없음() -> None: + for name in ( + "B06_Section_UI_Cross_Culvert.ts", + "B06_Section_UI_Cross_Revetment.ts", + "B06_Section_UI_Cross_Culvert_Wire.ts", + "B06_Section_UI_Cross_Culvert_Types.ts", + "B06_Section_UI_Cross_Culvert_Extra.ts", + ): + source = (ROOT / "B06_Section" / name).read_text(encoding="utf-8") + assert "shiftBlockedM" not in source and "지형에 막힘" not in source, name + assert "noticeExtraLimit" not in source and "noticeShortfall" not in source, name diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index ee0678e37..1431b0b66 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -290,10 +290,6 @@ export const ui_locales_b2 = { "Distribute tiers evenly along the slope", ], B06_Cross_Extra_Remove: ["한 단 삭제", "Remove one tier"], - B06_Cross_Extra_Limit: [ - "지형상 추가 기슭막이는 {n}단까지만 가능합니다 — 벽이 원지반에 0.5m 이상 묻히면 성토가 필요 없습니다", - "Terrain allows only {n} extra revetment tier(s) — no fill needed once a wall is buried 0.5m+", - ], B06_Cross_Struct_Auto: ["자동(규칙)", "Auto (by rule)"], B06_Cross_Struct_Revet: ["기슭막이+배관", "Revetment + pipe"], B06_Cross_Struct_I: ["집수정 I형", "Catch basin — I"], From b08796ab4e44ae91cf9a0a503ac6d682d66ab9fa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:12:45 +0900 Subject: [PATCH 27/47] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EB=94=B0=EB=A1=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=E2=80=94=20follow:false=20=C2=B7=20=EA=B3=A0=EC=A0=95=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=C2=B7=20=EC=9B=90=EB=AC=B8=20=EA=B0=95?= =?UTF-8?q?=EC=A1=B0=EB=8A=94=20=ED=8C=A8=EB=84=90=20=EB=AA=B8=EB=A7=8C(85?= =?UTF-8?q?-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- .../M01_MasterData_UI_LogicLab_Source.ts | 11 ++++++-- .../M02_MasterTemplete_AreaPanel.ts | 1 + .../M02_MasterTemplete_Labor_Detail.ts | 1 + .../M02_MasterTemplete_UI_Style.css | 24 ++++++++++++++++ resources/tester/test_ui_side_panels.py | 21 ++++++++++++++ .../tester/ui_checks/49_오른쪽패널_양식.js | 12 +++++++- .../tester/ui_checks/51-1_오른쪽패널_높이.js | 28 +++++++++++++++---- ui_template/ui_template_side_panels.css | 16 +++++++++++ ui_template/ui_template_side_panels.ts | 6 +++- 9 files changed, 110 insertions(+), 10 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts index 05eff5feb..b7ceac66e 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts @@ -137,9 +137,14 @@ export function createSourceView(opts: SourceViewOptions = {}): SourceView { ...f.blocks.map(blockNode), ]), ); - requestAnimationFrame(() => - body.querySelector(".m01lab-src__hit")?.scrollIntoView({ block: "center" }), - ); + // 강조 항을 패널 몸 가운데로 — 몸만 굴림(85-5 · scrollIntoView 는 메인 창까지 굴림) + requestAnimationFrame(() => { + const hit = body.querySelector(".m01lab-src__hit"); + const box = hit?.closest(".ui-side-panels__body"); + if (!hit || !box) return hit?.scrollIntoView({ block: "nearest" }); + const off = hit.getBoundingClientRect().top - box.getBoundingClientRect().top; + box.scrollTop += off - (box.clientHeight - hit.offsetHeight) / 2; + }); }); }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts index 9d806e4e9..aab337c36 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts @@ -99,6 +99,7 @@ export function buildAreaPanel(opts: AreaPanelOptions): AreaPanel { const panels = createSidePanels({ prefKey: "m02-area-panels", defaultWidth: DEFAULT_WIDTH, + follow: false, // 85-5 — 면적 상세 고정 높이 칸을 채움 · 메인 스크롤과 따로 panels: [ { id: "labor", title: TEXT.labor, body: view.root }, { id: "drawing", title: TEXT.drawing, body: drawing.root }, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 5f8b7821c..cf2d81160 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -141,6 +141,7 @@ export function mountLaborDetail( ? null : createSidePanels({ prefKey: "m02-labor-side", + follow: false, // 85-5 — 상세 고정 높이 칸을 채움 · 메인 스크롤과 따로 panels: [{ id: "source", title: tc("Labor_Source"), body: sourceHost }], onChange: () => showSource(), }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 5fe85ec07..d002e926d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -152,6 +152,30 @@ min-width: 0; } +/* 일위대가 상세(85-5) — 면적 상세와 같은 고정 높이 · 본문 칸만 굴림 · 오른쪽 패널은 칸 높이(따로 스크롤) */ +.m02-main:has(> .m02-main__host > .ui-side-panels-row) { + box-sizing: border-box; + height: calc(100vh - var(--spacing-64)); + min-height: 0; +} + +.m02-main__host:has(> .ui-side-panels-row) { + display: flex; + flex: 1 1 0; + min-height: 0; +} + +.m02-main__host > .ui-side-panels-row { + flex: 1 1 0; + min-width: 0; + min-height: 0; +} + +.m02-main__host > .ui-side-panels-row > :first-child { + min-height: 0; + overflow: auto; +} + .m02-main__empty { margin: 0; padding: var(--spacing-24); diff --git a/resources/tester/test_ui_side_panels.py b/resources/tester/test_ui_side_panels.py index bc508ddb6..353093a1c 100644 --- a/resources/tester/test_ui_side_panels.py +++ b/resources/tester/test_ui_side_panels.py @@ -42,3 +42,24 @@ def test_폭_자르기(): ) # fmt: skip assert result.returncode == 0, result.stderr assert json.loads(result.stdout) == [280, 500, 1360, 280] + + +def test_따라붙기_끔_옵션(): + """85-5 — `follow: false` = followView 안 부름 · 칸 높이 채움(is-fill) · M02 상세 둘만 씀 · M01 그대로.""" + src = PART.read_text(encoding="utf-8") + assert ( + 'if (opts.follow === false) root.classList.add("is-fill");\n else followView(root);' in src + ) + css = PART.with_suffix(".css").read_text(encoding="utf-8") + assert ".ui-side-panels.is-fill {" in css and "overscroll-behavior: contain" in css + m02 = ROOT / "M02_MasterTemplete" + for name in ("M02_MasterTemplete_AreaPanel.ts", "M02_MasterTemplete_Labor_Detail.ts"): + assert "follow: false," in (m02 / name).read_text(encoding="utf-8"), name + m01 = (ROOT / "M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts").read_text(encoding="utf-8") + assert "follow" not in m01 + style = (m02 / "M02_MasterTemplete_UI_Style.css").read_text(encoding="utf-8") + assert ".m02-main:has(> .m02-main__host > .ui-side-panels-row)" in style + source = (ROOT / "M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts").read_text( + encoding="utf-8" + ) + assert 'scrollIntoView({ block: "center" })' not in source and "box.scrollTop +=" in source diff --git a/resources/tester/ui_checks/49_오른쪽패널_양식.js b/resources/tester/ui_checks/49_오른쪽패널_양식.js index 0b38e149f..e61229f2c 100644 --- a/resources/tester/ui_checks/49_오른쪽패널_양식.js +++ b/resources/tester/ui_checks/49_오른쪽패널_양식.js @@ -7,6 +7,7 @@ * - handles = 패널 수(M01 2 · 일위대가 1 · 면적 2) · 손잡이 x < 패널 x(왼쪽 가장자리 밖) * - steps[i].open = 누른 패널 하나만 · width 모두 같음(폭 공유) · none.open = [] · none.rootW = 0(손잡이만 남음) * - sticky.tops = [0 · 250 · 400 굴렸을 때 패널 top] — 머리띠(head) 아래 8 에 닿으면 그 자리에 멈춤(250 = 400) · 본문이 짧으면 덜 굴러감(scrolled) · inView = 패널 위아래 끝이 창 안 + * · M02 일위대가 · 면적산출(85-5 · is-fill) = 본문 스크롤 칸이 패널 형제 본문 · tops 셋 같음(이동 0) * - ownScroll: 패널 몸을 굴리면 몸 scrollTop 만 바뀌고 본문 스크롤 그대로(mainMoved 0) * - m01Cells = m02Cells(같은 로직 GC000198 원문 표 칸 수) · 표 둘(7칸 · 2칸) * - m01Look = m02Look(sameLook true) — 같은 클래스 · 글자 크기(16px) · 표 칸 꼴 · tableFit 모두 true(표 높이 = 내용 · 자체 세로 스크롤 없음) @@ -60,7 +61,16 @@ const panel = box(scrollId); const body = $(".ui-side-panels__body", panel); // 본문 스크롤 칸 — 위 칸 중 굴러가는 것 · 없으면 창 · 숨은 탭은 scroll 알림이 안 와 직접 보냄 - let sc = null; + let sc = sp.classList.contains("is-fill") + ? ([...sp.parentElement.querySelectorAll("*")].find((n) => { + const oy = getComputedStyle(n).overflowY; + return ( + !sp.contains(n) && + (oy === "auto" || oy === "scroll") && + n.scrollHeight > n.clientHeight + 1 + ); + }) ?? null) + : null; for (let n = sp.parentElement; n && !sc; n = n.parentElement) { const oy = getComputedStyle(n).overflowY; if ((oy === "auto" || oy === "scroll") && n.scrollHeight > n.clientHeight + 1) sc = n; diff --git a/resources/tester/ui_checks/51-1_오른쪽패널_높이.js b/resources/tester/ui_checks/51-1_오른쪽패널_높이.js index 1a447c13b..bd2ac48ae 100644 --- a/resources/tester/ui_checks/51-1_오른쪽패널_높이.js +++ b/resources/tester/ui_checks/51-1_오른쪽패널_높이.js @@ -2,6 +2,7 @@ * 시작 화면: 패널이 이미 열린 상세 화면 하나(M02 일위대가 상세 · M02 면적산출 · M01 로직 상세 중 하나) · 화면마다 한 번씩 부름 * 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌) * 기대: ok = true · h = want(innerHeight − max(머리띠, 칸 위)) ±2 · 칸이 올라가 붙은 뒤는 innerHeight − 머리띠 · inView = 그대로·처음·중간·끝 모두 true + * · M02 일위대가 · 면적산출 상세(85-5 · follow:false = is-fill) — want = 칸 높이 · 본문 스크롤 칸 = 패널 형제 본문 · moved(처음~끝 top 차) = 0 */ (async () => { const w = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -10,7 +11,17 @@ ); if (!sp) return JSON.stringify({ error: "펴진 패널 없음" }); const head = document.querySelector(".app-header")?.getBoundingClientRect().bottom ?? 0; + const fill = sp.classList.contains("is-fill"); + const scrolls = (n) => + ["auto", "scroll"].includes(getComputedStyle(n).overflowY) && + n.scrollHeight > n.clientHeight + 1; const scroller = (() => { + // is-fill(85-5) — 메인 = 같은 칸의 본문(패널 밖) + if (fill) + return ( + [...sp.parentElement.querySelectorAll("*")].find((n) => !sp.contains(n) && scrolls(n)) ?? + document.scrollingElement + ); for (let n = sp.parentElement; n; n = n.parentElement) { const y = getComputedStyle(n).overflowY; if ((y === "auto" || y === "scroll") && n.scrollHeight > n.clientHeight + 1) return n; @@ -23,9 +34,9 @@ window.dispatchEvent(new Event("scroll")); // 뒤에 가린 탭은 스크롤 알림이 안 와서 직접 await w(150); const r = sp.getBoundingClientRect(); - const want = Math.round( - innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top), - ); + const want = fill + ? Math.round(sp.parentElement.getBoundingClientRect().height) + : Math.round(innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top)); const body = sp.querySelector(".ui-side-panels__panel:not([hidden]) .ui-side-panels__body"); return { name, @@ -43,7 +54,9 @@ const still = { name: "그대로", h: Math.round(r0.height), - want: Math.round(innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top)), + want: fill + ? Math.round(sp.parentElement.getBoundingClientRect().height) + : Math.round(innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top)), inView: r0.top >= head - 2 && r0.bottom <= innerHeight + 2, }; const rows = [ @@ -58,6 +71,11 @@ head: Math.round(head), scrollMax: Math.round(max), rows, - ok: rows.every((r) => Math.abs(r.h - r.want) <= 2 && r.inView), + fill, + moved: + Math.max(...rows.slice(1).map((r) => r.top)) - Math.min(...rows.slice(1).map((r) => r.top)), + ok: + rows.every((r) => Math.abs(r.h - r.want) <= 2 && r.inView) && + (!fill || rows.slice(1).every((r) => r.top === rows[1].top)), }); })(); diff --git a/ui_template/ui_template_side_panels.css b/ui_template/ui_template_side_panels.css index 77f3f306f..ca85e4cac 100644 --- a/ui_template/ui_template_side_panels.css +++ b/ui_template/ui_template_side_panels.css @@ -28,6 +28,21 @@ margin-left: calc(var(--spacing-24) + var(--spacing-8)); } +/* follow:false(85-5) — 부르는 칸(고정 높이) 높이를 채움 · 패널 내용이 칸 높이를 늘리지 않음 */ +.ui-side-panels.is-fill { + align-self: stretch; + width: var(--ui-side-panels-width, 420px); + height: auto; + min-height: 240px; +} + +.ui-side-panels.is-fill > .ui-side-panels__panel { + position: absolute; + inset: 0; + width: auto; + height: auto; +} + /* 모두 접힘 — 폭 0 · 손잡이만 왼쪽 밖에 남음 */ .ui-side-panels.is-collapsed { width: 0; @@ -113,4 +128,5 @@ min-height: 0; padding: var(--spacing-12); overflow: auto; + overscroll-behavior: contain; /* 몸 끝에서 메인으로 이어 굴리지 않음(85-5) */ } diff --git a/ui_template/ui_template_side_panels.ts b/ui_template/ui_template_side_panels.ts index 93aa4c17c..e60217c44 100644 --- a/ui_template/ui_template_side_panels.ts +++ b/ui_template/ui_template_side_panels.ts @@ -4,6 +4,7 @@ * 좌측 패널과 같은 접기 양식 — 패널 왼쪽 가장자리 손잡이(공용 createWorkflowPanelHandle · 오른쪽으로 접힘) * 패널마다 손잡이 하나(이름표 세로) · 누르면 폄/접음 · 모두 접히면 손잡이만 남음 * 편 패널 = 머리(제목) + 몸 · 왼쪽 경계 끌기(화살표 키) · 보이는 칸 안에 붙어 몸만 따로 스크롤(followView) + * `follow: false`(85-5) = followView 안 씀 · 부르는 칸(고정 높이) 높이를 채움 · 메인 스크롤과 따로(M02 상세) * 열린 패널 · 폭 = 부르는 쪽 취향 키(`aislo:pref:{prefKey}`) * 부르기: const sp = createSidePanels({ prefKey, panels: [{ id, title, body }] }) → * 본문과 함께 `.ui-side-panels-row` 칸에 [본문, sp.root] 로 넣음 @@ -42,6 +43,8 @@ export interface SidePanelsOpts { onChange?(open: string | null): void; /** 폭이 바뀜(끌기 · 화살표) */ onResize?(px: number): void; + /** 보이는 칸 따라 붙음(followView) · 기본 참 · 거짓 = 부르는 칸 높이를 채움(칸이 고정 높이 · 85-5) */ + follow?: boolean; } export interface SidePanels { @@ -166,7 +169,8 @@ export function createSidePanels(opts: SidePanelsOpts): SidePanels { } draw(); - followView(root); + if (opts.follow === false) root.classList.add("is-fill"); + else followView(root); return { root, open, current: () => now, width: () => width }; } From 4e18bca4e4daf926b1856bb7a67ef2971aa8e80e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:12:58 +0900 Subject: [PATCH 28/47] =?UTF-8?q?feat(B07):=20=EB=AA=A9=EB=A1=9D=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=97=B0=20=EB=8F=84=EB=A9=B4=20=3D=20=ED=83=AD=20?= =?UTF-8?q?=E2=80=94=20=EC=97=B4=EA=B8=B0=20=C2=B7=20=EB=8B=AB=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=A0=84=ED=99=98=20=C2=B7=20=EC=9D=B4=EC=A0=84/?= =?UTF-8?q?=EB=8B=A4=EC=9D=8C=20=C2=B7=20=ED=83=AD=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EB=95=8C=20=EB=AF=B8=EC=A0=80=EC=9E=A5=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=EC=A7=80=ED=82=B4(82-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 탭 줄: 마지막 한 탭 닫기 없음 · 지금 탭 미저장 점 · 탭 하나만 저장 표시 지움 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 51 +++++++++++++++---- .../B07_DesignDetail_UI_Style.css | 13 +++++ .../M02_MasterTemplete_Drawing.ts | 3 +- resources/tester/test_cad_host_tabs.py | 14 ++++- ui_template/cad_host/cad_host_tabs.ts | 19 +++++-- 5 files changed, 82 insertions(+), 18 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index 3be48a4e3..f53756663 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -52,6 +52,7 @@ import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures"; // CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3). import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host"; import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit"; +import { createCadTabs, type CadTab } from "@ui/cad_host/cad_host_tabs"; /** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */ interface DesignMeta { @@ -109,7 +110,9 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // (2026-09-01 사용자 확정) — 예전에는 이 통지가 확정을 풀어, 되돌리기나 색 // 고르기 같은 곁가지 동작에도 확정이 조용히 날아갔다. onChanged: (dirty) => { - if (!frameEditor.isEditing()) cadDirty = dirty; + if (frameEditor.isEditing()) return; + cadDirty = dirty; + if (dirty) tabs.markDirty(); }, onNavigate: (direction) => navigateDrawing(direction), onExport: (fileFormat) => void exportDrawingFile(fileFormat), @@ -248,7 +251,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { /** 저장하지 않은 편집이 있으면 묻는다. 버리기로 해야 이동한다. */ const mayDiscardEdits = (): boolean => { - if (!cadDirty) return true; + if (!cadDirty && !tabs.anyDirty()) return true; return window.confirm( "저장하지 않은 편집이 있습니다.\n" + "지금 도면을 바꾸면 편집이 사라집니다. 버리고 이동할까요?\n\n" + @@ -259,7 +262,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { /** 도면 전환 중인가 — 겹쳐 누르면 늦게 온 응답이 화면을 덮는다. */ let loadInFlight = false; - const loadDrawing = async (drawing: DesignDrawingItem, index: number) => { + /** `stash` = 탭에 받아 둔 저장 안 한 편집본(82-4) — 있으면 그것을 싣고 미저장 그대로 */ + const loadDrawing = async (drawing: DesignDrawingItem, index: number, stash?: CadDrawing) => { if (!projectId || loadInFlight) return; loadInFlight = true; highlightActive(drawing.id); @@ -271,10 +275,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { currentDrawing = drawing; currentIndex = index; currentConfirmed = response.confirmed; - cadDirty = false; // 새 도면을 실었다 — 미저장 편집은 이 도면 것이 아니다 + cadDirty = !!stash; // 새 도면을 실었다 — 미저장은 받아 둔 편집본일 때만 applyConfirmButtonState(); updateInfoPanel(drawing, response); - cad.load(response.drawing, buildMeta(drawing, response, index)); + cad.load(stash ?? response.drawing, buildMeta(drawing, response, index)); if (response.drawing.stale_layout) { showToast( "옛 배치로 확정된 장입니다. [수정]을 누르면 박스를 그림에 1 mm로 붙인 새 배치로 다시 만듭니다.", @@ -290,17 +294,37 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { } }; + /** + * 목록에서 연 도면 = 탭(82-4) — 탭을 바꿀 때 지금 도면을 받아 두고(저장 안 한 편집 그대로) 다음 장을 싣는다. + * 새 장 · 지우기는 없음(서버 길 없음) · 닫기는 미저장 탭만 물음 · 마지막 한 탭은 못 닫음. + */ + const tabs = createCadTabs({ + features: { close: true }, + capture: async () => ({ drawing: (await requestCadDrawing()).drawing, dirty: cadDirty }), + canLeave: () => { + if (!frameEditor.isEditing()) return !loadInFlight; + showToast("도각 편집을 마친 뒤 다른 도면으로 옮기세요.", "warning"); + return false; + }, + show: async (tab: CadTab) => { + const index = drawings.findIndex((item) => item.id === tab.id); + if (index >= 0) + await loadDrawing(drawings[index], index, tab.dirty ? tab.drawing : undefined); + }, + }); + const openTab = (drawing: DesignDrawingItem): Promise => + tabs.open({ id: drawing.id, name: drawing.label, title: drawing.label }); + const selectDrawing = (drawing: DesignDrawingItem) => { - if (drawing === currentDrawing || !mayDiscardEdits()) return; - void loadDrawing(drawing, drawings.indexOf(drawing)); + if (drawing === currentDrawing) return; + void openTab(drawing); }; const navigateDrawing = (direction: "prev" | "next") => { if (currentIndex < 0) return; const target = direction === "prev" ? currentIndex - 1 : currentIndex + 1; if (target < 0 || target >= drawings.length) return; - if (!mayDiscardEdits()) return; - void loadDrawing(drawings[target], target); + void openTab(drawings[target]); }; const requestCadDrawing = (): Promise => cad.requestSave(); @@ -319,6 +343,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { currentConfirmed = true; currentDrawing.confirmed = true; cadDirty = false; // 저장했다 + const savedTab = tabs.active(); + if (savedTab) tabs.markClean(savedTab); // 받아 둔 편집본은 저장됨 applyConfirmButtonState(); drawingCache.delete(currentDrawing.id); // 확정본은 서버 저장분이 정본이다. @@ -462,12 +488,15 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { drawingPanel.append(infoPanelHost, confirmActions); + const cadStack = document.createElement("div"); + cadStack.className = "b07-cad-stack"; + cadStack.append(tabs.element, cad.element); const layout = createWorkflowLayout({ title: L("B07_Design_Title"), steps: workflowSteps(), activeStep: 4, leftPanel: drawingPanel, - mainContent: cad.element, + mainContent: cadStack, stages: workflowState?.stages, currentStage: workflowState?.current_stage, routes: WORKFLOW_STEP_ROUTES, @@ -492,7 +521,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { const reopen = drawings.findIndex((item) => item.id === reopenDrawingId); reopenDrawingId = undefined; const first = Math.max(reopen, 0); - void loadDrawing(drawings[first], first).then(() => { + void openTab(drawings[first]).then(() => { // 첫 장을 띄운 뒤에 나머지를 받는다 — 진입 속도를 뺏지 않는다. void prefetchAllDrawings(); }); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index d098787a4..20dd06b03 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -328,3 +328,16 @@ .b07-plan-tools__remove { padding: 0 var(--spacing-8); } + +/* 목록에서 연 도면 탭 줄 + CAD(82-4) */ +.b07-cad-stack { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.b07-cad-stack > .cad-host { + flex: 1 1 0; + height: auto; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts index 26d2af925..c245f122c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts @@ -474,6 +474,7 @@ export function mountStructureSheets( tabs: tabs.element, onChanged: (dirty) => { cadDirty = dirty; + if (dirty && !readOnly) tabs.markDirty(); report(); }, }); @@ -490,7 +491,7 @@ export function mountStructureSheets( 도면: tab.drawing ?? { format: 6, entities: [], layers: [{ ...BLANK_LAYER }] }, })); listDirty = false; - tabs.markClean(true); + tabs.markClean(undefined, true); return { 도면: first?.drawing ?? doc.도면, ...(rest.length || doc.더한장 ? { 더한장: rest } : {}), diff --git a/resources/tester/test_cad_host_tabs.py b/resources/tester/test_cad_host_tabs.py index 96b6c78bc..e9ee764b8 100644 --- a/resources/tester/test_cad_host_tabs.py +++ b/resources/tester/test_cad_host_tabs.py @@ -98,7 +98,8 @@ const tabsMod = load("ui_template/cad_host/cad_host_tabs.ts", { "@ui/ui_template const before = confirms.length; const d2 = closer.element.kids.find((k) => k.dataset.tabId === "d2"); await d2.kids.find((k) => k.className === "cad-tabs__x").on.click({ stopPropagation() {} }); - out.b07 = { log: b07, asked: confirms.length - before, ids: closer.tabs().map((t) => t.id) }; + const last = closer.element.kids.find((k) => k.dataset.tabId === "d1").kids.length; // 마지막 탭 = 닫기 없음 + out.b07 = { log: b07, asked: confirms.length - before, ids: closer.tabs().map((t) => t.id), last }; // ⑤ M02 여러 장 — 가짜 CAD(requestSave = 지금 실린 도면 + 편집) const cadLoads = []; @@ -193,6 +194,17 @@ def test_닫기_기능은_미저장_탭만_물음(got): ["show", "d1", "편집본", True], # 받아 둔 편집본으로 돌아옴 ] assert b07["asked"] == 1 and ["closed", "d2"] in b07["log"] and b07["ids"] == ["d1"] + assert b07["last"] == 1 + + +def test_B07_목록에서_연_도면이_탭_미저장_편집_지킴(): + page = (ROOT / "B07_DesignDetail/B07_DesignDetail_UI_Page.ts").read_text("utf-8") + assert "features: { close: true }," in page # [+] · 지우기 없음(서버 길 없음) + assert "dirty: cadDirty }" in page # 떠날 때 받아 둠 + assert "tab.dirty ? tab.drawing : undefined" in page # 돌아오면 받아 둔 편집본 + assert "cadDirty = !!stash;" in page and "cad.load(stash ?? response.drawing" in page + assert "if (!cadDirty && !tabs.anyDirty()) return true;" in page + assert "void openTab(drawings[target]);" in page # 이전 · 다음 = 그 도면 탭 def test_여러_장_문서_장_도번_백업_칸(got): diff --git a/ui_template/cad_host/cad_host_tabs.ts b/ui_template/cad_host/cad_host_tabs.ts index 960b2f6f5..5e9ef481f 100644 --- a/ui_template/cad_host/cad_host_tabs.ts +++ b/ui_template/cad_host/cad_host_tabs.ts @@ -61,8 +61,10 @@ export interface CadTabs { select: (id: string) => Promise; /** 지금 장을 받아 두고 모든 탭 */ collect: () => Promise[]>; - /** 저장 뒤 — 받아 둔 편집본 · 미저장 표시를 지움(`keepDrawings` = 편집본은 둠) */ - markClean: (keepDrawings?: boolean) => void; + /** 저장 뒤 — 받아 둔 편집본 · 미저장 표시를 지움(`tab` 없으면 모두 · `keepDrawings` = 편집본은 둠) */ + markClean: (tab?: CadTab, keepDrawings?: boolean) => void; + /** 지금 탭에 미저장 점(●) — 페이지 편집 통지(참만 · 거짓은 저장 뒤 `markClean`) */ + markDirty: () => void; /** 미저장 탭이 있나(지금 탭은 페이지 표시로) */ anyDirty: () => boolean; } @@ -227,7 +229,8 @@ export function createCadTabs(options: CadTabsOptions): CadTabs { rename(tab, label); }); } - if ((features.remove || features.close) && !tab.fixed) { + // 닫기는 마지막 한 탭이면 없음(볼 장이 하나는 남음) + if ((features.remove || (features.close && list.length > 1)) && !tab.fixed) { const x = document.createElement("button"); x.type = "button"; x.className = "cad-tabs__x"; @@ -318,13 +321,19 @@ export function createCadTabs(options: CadTabsOptions): CadTabs { await stash(); return list; }, - markClean: (keepDrawings = false) => { - for (const tab of list) { + markClean: (only, keepDrawings = false) => { + for (const tab of only ? [only] : list) { tab.dirty = false; if (!keepDrawings) delete tab.drawing; } render(); }, + markDirty: () => { + const now = active(); + if (!now || now.dirty) return; + now.dirty = true; + render(); + }, anyDirty: () => list.some((tab) => tab.dirty), }; } From 434a745ff68024e4f4151524e8edfd2c614621e4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:13:43 +0900 Subject: [PATCH 29/47] =?UTF-8?q?feat(B05):=20=EC=84=B8=EC=9B=94=EA=B5=90?= =?UTF-8?q?=20=C2=B7=20=EB=B0=95=EC=8A=A4=20=EC=9C=A0=EC=B6=9C=20=3D=20?= =?UTF-8?q?=E3=80=8C=EC=B6=94=EA=B0=80=20=ED=9D=99=EB=A7=89=EC=9D=B4(?= =?UTF-8?q?=EB=8B=A8)=E3=80=8D=20=EA=B3=B5=EC=9A=A9=20=EC=8A=A4=ED=85=8C?= =?UTF-8?q?=ED=8D=BC=20=C2=B7=20=E3=80=8C+=20=ED=9D=99=EB=A7=89=EC=9D=B4?= =?UTF-8?q?=E3=80=8D=20=EC=97=86=EC=95=B0=20=C2=B7=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=EB=8B=A8=20=EC=88=98=EB=A1=9C=20=EC=83=81=EC=9E=90(90-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../B05_Profile_UI_Drainage_Facility.ts | 47 ++++++++-------- .../B05_Profile_UI_Drainage_Revet_Choice.ts | 56 ++++++++++++++++++- B06_Section/B06_Section_UI_Adjust_Dock.ts | 9 +-- .../B06_Section_UI_Page_Pipe_Options.ts | 15 ----- M02_MasterTemplete/M02_Table_Fill_Spec.py | 11 +++- .../tester/helper_54_27_soil_tier_field.cjs | 50 ++++++++++++++--- resources/tester/test_54_10_soil_tiers.py | 2 + .../tester/test_54_27_soil_tier_field.py | 14 ++++- resources/tester/test_65_ford_defaults.py | 34 +++++++---- 9 files changed, 170 insertions(+), 68 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 54ee46eb3..b7592b7ef 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -40,13 +40,16 @@ import { INLET_REVET_KEYS, type InletStructureKind, labeled, - numberInput, OUTLET_REVET_KEYS, - stepper, WING_IN_KEYS, WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; -import { guardChoiceFields, revetChoiceRow } from "./B05_Profile_UI_Drainage_Revet_Choice"; +import { + guardChoiceFields, + guardSoilCount, + outletTiersRow, + revetChoiceRow, +} from "./B05_Profile_UI_Drainage_Revet_Choice"; import { optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import { createSpecForm, @@ -67,7 +70,6 @@ import { pipeDrawn, pipeExtraCount, pipeLengthM, - setPipeExtraCount, } from "../A00_Common/design/design_structure_pipe_calc"; import { hasLinks, @@ -296,25 +298,11 @@ export function createFacilityOptionsForm( return drawnMemo; }; // 유출 「추가 흙막이(단)」(54-27) — 값 = 횡단 조정 단 수(같은 저장 자리) · 흙막이 상자 수가 따라감 - const tiersInput = numberInput("1", "0"); - tiersInput.max = "9"; - const tiersRow = labeled("추가 흙막이(단)", stepper(tiersInput, 1, 0)); - tiersRow.dataset.specItem = "outlet.tiers"; - tiersRow - .querySelectorAll("button") - .forEach((b, i) => (b.title = `1단 ${i ? "늘리기" : "줄이기"}`)); - const paintTiers = (values: Record): HTMLElement => { - const count = pipeExtraCount(chainage); - tiersInput.value = count === null ? "" : String(count); - tiersInput.disabled = count === null; - tiersRow.hidden = !!values.structure && values.structure !== "기슭막이"; - return tiersRow; - }; - tiersInput.addEventListener("change", () => { - const count = Math.max(0, Math.min(9, Math.round(Number(tiersInput.value) || 0))); - // 지형이 못 세운 단은 횡단 기하가 자름 — 끝난 뒤 세운 만큼으로 다시 그림 - void setPipeExtraCount(chainage, count).then(() => pipeForm.recount()); - }); + const tiersRow = outletTiersRow( + () => chainage, + () => pipeForm.recount(), + (structure) => !structure || structure === "기슭막이", + ); const inletChoice = revetChoiceRow( () => chainage, () => pipeForm.refresh(), @@ -327,7 +315,7 @@ export function createFacilityOptionsForm( // 관 보호공 [연동] · [경사](54-30) — 유입 · 유출 상자 각각 한 줄(같은 값) · 길이 있는 흙막이만(54-40 · 집수정 · 안 고름은 숨김) fields: (c, values) => c.id === "outlet" - ? [paintTiers(values), outletChoice.paint(values.structure !== "기슭막이")] + ? [tiersRow.paint(values), outletChoice.paint(values.structure !== "기슭막이")] : c.id === "inlet" ? [inletChoice.paint(values.structure !== "기슭막이")] : c.id === "soil_add" // 추가 흙막이 상자도 같은 줄(54-36 · 상자마다 한 줄 · 같은 값) @@ -407,11 +395,17 @@ export function createFacilityOptionsForm( }; }; const fordFlowBox = overflow(() => fordForm, "ford"); + // 유출 흙막이 단 수 = 흙막이 상자 수(관보호공과 같은 방향 · 「+ 흙막이」 없음 · 90-1) const fordForm: SpecForm = createSpecForm(specLinks, { onChange: fordFlowBox.onChange, fields: guardChoiceFields( () => chainage, () => fordForm.refresh(), + () => fordForm.recount(), + ), + count: guardSoilCount( + () => chainage, + () => fordForm.state().outlet?.[0]?.structure, ), }); @@ -473,6 +467,11 @@ export function createFacilityOptionsForm( fields: guardChoiceFields( () => chainage, () => boxForm.refresh(), + () => boxForm.recount(), + ), + count: guardSoilCount( + () => chainage, + () => boxForm.state().outlet?.[0]?.structure, ), }); // 날개벽 칸 — 세월교 · BOX 가 같은 조각을 쓰던 자리 · 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음 diff --git a/B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts b/B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts index f6a147ffc..93a98bb67 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts @@ -6,8 +6,11 @@ * ========================================================================== */ import { toggleButton } from "./B05_Profile_UI_Panel_Fields"; +import { labeled, numberInput, stepper } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { + pipeExtraCount, pipeRevetChoice, + setPipeExtraCount, setPipeRevetChoice, } from "../A00_Common/design/design_structure_pipe_calc"; @@ -54,20 +57,67 @@ export function revetChoiceRow( return { paint }; } -/** 세월교 · 박스 흙막이 상자 줄(65-2 · 배관과 같은 줄) — 흙막이를 고른 유입 · 유출 · 추가 흙막이 상자마다 한 줄. - * 명세 폼 `fields` 자리에 그대로 씀. */ +/** 유출 「추가 흙막이(단)」(54-27 · 90-1 배관 · 세월교 · 박스 공용) — 값 = 횡단 조정 단 수(같은 저장 자리) · + * 흙막이 상자 수가 따라감(폼 `count` 훅 = `pipeExtraCount`). `shown` = 유출 구조 값이 벽인지. */ +export function outletTiersRow( + chainageOf: () => number | null, + /** 쓴 뒤 — 지형이 못 세운 단은 횡단 기하가 자름 · 세운 만큼으로 상자를 다시 셈. */ + recount: () => void, + shown: (structure: string | undefined) => boolean, +): { paint: (values: Record) => HTMLElement } { + const input = numberInput("1", "0"); + input.max = "9"; + const row = labeled("추가 흙막이(단)", stepper(input, 1, 0)); + row.dataset.specItem = "outlet.tiers"; + row.querySelectorAll("button").forEach((b, i) => (b.title = `1단 ${i ? "늘리기" : "줄이기"}`)); + input.addEventListener("change", () => { + const count = Math.max(0, Math.min(9, Math.round(Number(input.value) || 0))); + void setPipeExtraCount(chainageOf(), count).then(recount); + }); + return { + paint: (values) => { + const count = pipeExtraCount(chainageOf()); + input.value = count === null ? "" : String(count); + input.disabled = count === null; + row.hidden = !shown(values.structure); + return row; + }, + }; +} + +/** 세월교 · 박스 흙막이 상자 줄(65-2 · 배관과 같은 줄) — 흙막이를 고른 유입 · 유출 · 추가 흙막이 상자마다 한 줄 · + * 유출 상자엔 「추가 흙막이(단)」(90-1). 명세 폼 `fields` 자리에 그대로 씀. */ export function guardChoiceFields( chainageOf: () => number | null, written: () => void, + recount: () => void, ): (c: { id: string }, values: Record) => HTMLElement[] { const sides = { inlet: revetChoiceRow(chainageOf, written), outlet: revetChoiceRow(chainageOf, written), }; + const tiers = outletTiersRow(chainageOf, recount, (structure) => structure === "흙막이"); return (c, values) => c.id === "soil_add" ? [revetChoiceRow(chainageOf, written).paint(false)] : c.id === "inlet" || c.id === "outlet" - ? [sides[c.id].paint(values.structure !== "흙막이")] + ? [ + ...(c.id === "outlet" ? [tiers.paint(values)] : []), + sides[c.id].paint(values.structure !== "흙막이"), + ] : []; } + +/** 세월교 · 박스 흙막이 상자 수(90-1) — 유출이 흙막이면 지형이 세운 유출 단 수 · 아니면 0(옛 상자는 + * 숨기고 값은 파일에 남김). 명세 폼 `count` 훅 자리. */ +export function guardSoilCount( + chainageOf: () => number | null, + outletStructure: () => string | undefined, +): (c: { id: string }) => number | null | undefined { + return (c) => + c.id !== "soil_add" + ? undefined + : outletStructure() === "흙막이" + ? pipeExtraCount(chainageOf()) + : 0; +} diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index d1979183c..6c6b3aef8 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -175,10 +175,11 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void { const group = target.closest("fieldset"); if (!group) return; const ownerSlot = slotOf(panelRoot.dataset.extraSide === "inlet" ? "inlet" : "outlet"); - // 배관 유출 다단 = 그 단의 「흙막이」 컨테이너(54-10) — 옛 추가 기슭막이 칸 대신 그 상자를 켬 · - // 단 수 · 옵션은 부모(유출) 칸에 그대로 + // 배관 · 세월교 · 박스 유출 다단 = 그 단의 「흙막이」 컨테이너(54-10 · 90-1) — 옛 추가 기슭막이 칸 + // 대신 그 상자를 켬 · 단 수 · 옵션은 부모(유출) 칸에 그대로 const soil = - ownerSlot === sideSlots.outlet && slotUsable(ownerSlot) + (ownerSlot === sideSlots.outlet || ownerSlot === sideSlots.wingOutlet) && + slotUsable(ownerSlot) ? ownerSlot .closest(".b05-structure__spec") ?.querySelectorAll('fieldset[data-spec-container="soil_add"]')[ @@ -186,7 +187,7 @@ function relocateSideRows(panelRoot: HTMLElement, scroll = true): void { ] : undefined; if (soil) { - moveRows(panelRoot, PIPE_OUTLET_ROWS, ownerSlot); + moveRows(panelRoot, rowsFor(ownerSlot), ownerSlot); setGroupLabel(soil, ""); soil.classList.add("is-active"); if (scroll) soil.scrollIntoView({ block: "nearest" }); diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index 96bba9554..90a9243c5 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -142,13 +142,6 @@ export function applyPipeOptionsToCache( }); } } - // 세월교 · 박스 추가 흙막이 = 폼 상자 수가 유출 다단 수(배관은 횡단이 정함 · 73-4) - const soil = body ? soilCount(patch) : null; - if (soil !== null && soil !== (owner.design?.extra_wall_counts?.outlet ?? 0)) - // 늦게 불러옴 — 흙막이 세트 때만 쓰는 횡단 기하 묶음(배관 폼 길은 안 끌어옴) - void import("../A00_Common/design/design_structure_pipe_calc").then((calc) => - calc.setPipeExtraCount(owner.chainage_m, soil), - ); if (!culvert.hidden_pipe || body) applySoilTiers(ctx, owner, patch, whole); own(culvert.inlet, "basin_length_m", num("inlet_basin_length_m")); own(culvert.inlet, "basin_before_m", num("inlet_basin_before_m")); @@ -190,14 +183,6 @@ function syncBodyGuard( return !!guard; } -/** 추가 흙막이 상자 수 — 가장 큰 `soil_add_{n}_*` 의 n. */ -function soilCount(patch: Record): number { - return Math.max( - 0, - ...Object.keys(patch).map((key) => Number(/^soil_add_(\d+)_/.exec(key)?.[1] ?? 0)), - ); -} - /** 흙막이 컨테이너가 마지막으로 보낸 높이 · 형태(`측점:extra{n}`) — 기하가 다시 그릴 때마다 키를 지형에 * 맞춰 고쳐 되받으므로(syncApplied) 되받은 값과 견주면 안 바꾼 단도 다시 보냄(54-10). */ const soilSent = new Map(); diff --git a/M02_MasterTemplete/M02_Table_Fill_Spec.py b/M02_MasterTemplete/M02_Table_Fill_Spec.py index 57a9df415..6e077e1d5 100644 --- a/M02_MasterTemplete/M02_Table_Fill_Spec.py +++ b/M02_MasterTemplete/M02_Table_Fill_Spec.py @@ -7,7 +7,8 @@ 짝: `A00_Common/design/design_structure_guard.ts` · `_road.ts` 선택 컨테이너(머리 · 잇기 · 제목) — 시험이 대조. 기타(52-14) = 사용자가 적은 상세 종류 이름이 집계 건 이름(`그룹`) · 비면 「기타」. -배관 흙막이 = 지형이 세운 유출 다단 수(`흙막이단`)까지만 — 넘친 컨테이너 값은 파일에만(54-11). +배관 · 세월교 · 박스 흙막이 = 지형이 세운 유출 다단 수(`흙막이단`)까지만 — 넘친 컨테이너 값은 +파일에만(54-11 · 90-1) · 세월교 · 박스 유출이 흙막이가 아니면 0. """ from __future__ import annotations @@ -30,6 +31,9 @@ SPEC_CONTAINERS: dict[str, tuple[tuple[str, str, str], ...]] = { "etc": (("etc_detail", "getcdetail", "상세 내용"),), } _TEXT = ("structure", "form", "note") +#: 흙막이 상자 수 = 지형이 세운 유출 다단 수인 종류(54-11 · 90-1) · 세월교 · 박스 유출 구조 칸 +_TIERED = ("pipe", "box_culvert", "ford_bridge") +_BODY_OUTLET = {"box_culvert": "wing_out", "ford_bridge": "outlet_type"} def etc_label(type_id: str, options: dict[str, Any] | None) -> dict[str, str]: @@ -70,6 +74,9 @@ def container_rows(item: dict[str, Any], links: dict[str, Any]) -> list[dict[str """한 구조물의 선택 컨테이너 인스턴스마다 한 건(n 차례) — 컨테이너 없는 종류는 빈 목록.""" rows: list[dict[str, Any]] = [] tiers = item.get("흙막이단") # None = 모름(저장된 것 그대로) + guard_key = _BODY_OUTLET.get(item["type_id"]) + if guard_key and str(item["options"].get(guard_key) or "").strip() != "흙막이": + tiers = 0 # 유출이 날개벽 · 없음 — 옛 상자는 숨김 · 값은 파일에 남김(90-1) for prefix, link, title in SPEC_CONTAINERS.get(item["type_id"], ()): head = re.compile(rf"^{prefix}_(\d+)_(.+)$") found: dict[int, dict[str, Any]] = {} @@ -80,7 +87,7 @@ def container_rows(item: dict[str, Any], links: dict[str, Any]) -> list[dict[str for n in sorted(found): if ( prefix == "soil_add" - and item["type_id"] == "pipe" + and item["type_id"] in _TIERED and tiers is not None and n > tiers ): diff --git a/resources/tester/helper_54_27_soil_tier_field.cjs b/resources/tester/helper_54_27_soil_tier_field.cjs index 975e4948a..abee5d6a7 100644 --- a/resources/tester/helper_54_27_soil_tier_field.cjs +++ b/resources/tester/helper_54_27_soil_tier_field.cjs @@ -118,6 +118,8 @@ const PIPE_CALC = { pipeDrawn: () => null, pipeLengthM: () => null, pipeExtraCount: () => Math.min(stored, LIMIT), + pipeRevetChoice: () => null, + setPipeRevetChoice: () => Promise.resolve(), setPipeExtraCount: (chainage, count) => { writes.push([chainage, count]); stored = count; @@ -127,11 +129,8 @@ const PIPE_CALC = { const STUBS = { "@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 }, "../A00_Common/design/design_structure_pipe_calc": PIPE_CALC, - // 관 보호공 [연동] · [경사] 줄(54-30) — 여기서는 자리만 - "./B05_Profile_UI_Drainage_Revet_Choice": { - revetChoiceRow: () => ({ paint: () => new FakeNode("div") }), - guardChoiceFields: () => () => [], - }, + // 관 보호공 [연동] · [경사] 단추(54-30) — 여기서는 자리만 · 단 칸(90-1 공용)은 실제 파일 + "./B05_Profile_UI_Panel_Fields": { toggleButton: () => new FakeNode("button") }, "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": { buildSummaryRow: () => { const root = new FakeNode("div"); @@ -169,7 +168,13 @@ const form = createFacilityOptionsForm(); form.setChainage(100); form.setFacility("pipe", { pipe_diameter_mm: 1000 }); -const spec = form.root.querySelector(".b05-structure__spec"); +// 시설마다 명세 폼이 따로 — 지금 시설의 폼(본체 상자 id 로 찾음) +let body = null; +const specOf = () => + form.root + .querySelectorAll(".b05-structure__spec") + .find((s) => !body || s.children.some((k) => k.dataset?.specContainer === body)); +let spec = specOf(); const boxesOf = (id) => spec.children.filter((k) => k.dataset?.specContainer === id); const tierRow = () => boxesOf("outlet")[0] @@ -199,10 +204,41 @@ async function main() { stored = 2; form.recount(); out.fromCross = snap(); - out.writes = writes; // 다른 구조(옛 값)면 칸을 가림 form.setFacility("pipe", { pipe_diameter_mm: 1000, outlet_type: "옛값" }); out.otherHidden = tierRow()?.hidden ?? null; + // 세월교 · 박스 유출 흙막이(90-1) — 같은 단 칸 · 단 수 = 흙막이 상자 수 · 「+ 흙막이」 없음 · + // 유출이 흙막이가 아니면 단 칸 · 옛 상자 숨김(값은 남김) + const addButton = () => + spec.children.find((k) => k.dataset?.specAdd === "soil_add") ? true : false; + const old = { soil_add_1_form: "바자", soil_add_2_form: "돌망태", soil_add_3_form: "바자" }; + for (const [facility, key, container] of [ + ["ford_bridge", "outlet_type", "ford"], + ["box_culvert", "wing_out", "body"], + ]) { + stored = 0; + body = container; + form.setFacility(facility, { [key]: "흙막이", ...old }); + spec = specOf(); + const steps = {}; + for (const n of ["2", "0"]) { + const input = tierInput(); + input.value = n; + input.dispatchEvent(new Event("change")); + await Promise.resolve(); + await Promise.resolve(); + steps[`set${n}`] = { ...snap(), hidden: tierRow()?.hidden ?? null, add: addButton() }; + } + form.setFacility(facility, { [key]: "날개벽", ...old }); + steps.wing = { + boxes: boxesOf("soil_add").length, + hidden: tierRow()?.hidden ?? null, + add: addButton(), + kept: Object.keys(form.readOptions()).filter((k) => k.startsWith("soil_add_")).length, + }; + out[facility] = steps; + } + out.writes = writes; process.stdout.write(JSON.stringify(out)); } main().catch((error) => { diff --git a/resources/tester/test_54_10_soil_tiers.py b/resources/tester/test_54_10_soil_tiers.py index bd116dcda..ec4310e11 100644 --- a/resources/tester/test_54_10_soil_tiers.py +++ b/resources/tester/test_54_10_soil_tiers.py @@ -89,6 +89,8 @@ def test_다단_벽_고르기는_흙막이_컨테이너로() -> None: dock = (ROOT / "B06_Section" / "B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8") assert 'fieldset[data-spec-container="soil_add"]' in dock assert "panelRoot.dataset.extraIndex" in dock + # 세월교 · 박스 유출 다단도 그 상자로(90-1 · 유출 자리 = 날개벽 칸) + assert "ownerSlot === sideSlots.outlet || ownerSlot === sideSlots.wingOutlet" in dock panel = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Structure_Panel.ts").read_text( encoding="utf-8" ) diff --git a/resources/tester/test_54_27_soil_tier_field.py b/resources/tester/test_54_27_soil_tier_field.py index 4c55e96a6..5a2ed80d3 100644 --- a/resources/tester/test_54_27_soil_tier_field.py +++ b/resources/tester/test_54_27_soil_tier_field.py @@ -45,7 +45,19 @@ def test_지형_한도를_넘으면_세운_만큼(out) -> None: def test_좌측_칸은_횡단_단_수_자리에_씀(out) -> None: - assert out["writes"] == [[100, 2], [100, 1], [100, 5], [100, 0]] + assert out["writes"][:4] == [[100, 2], [100, 1], [100, 5], [100, 0]] + + +@pytest.mark.parametrize("facility", ["ford_bridge", "box_culvert"]) +def test_세월교_박스_유출도_같은_단_칸(out, facility) -> None: + """90-1 — 단 수 = 흙막이 상자 수 · 「+ 흙막이」 없음 · 유출이 흙막이 아니면 칸 · 옛 상자 숨김(값 남김).""" + steps = out[facility] + assert (steps["set2"]["value"], steps["set2"]["boxes"]) == ("2", 2) + assert (steps["set0"]["value"], steps["set0"]["boxes"]) == ("0", 0) + assert steps["set2"]["structures"] == ["형태", "형태"] + assert not steps["set2"]["hidden"] and not steps["set2"]["add"] + assert steps["wing"] == {"boxes": 0, "hidden": True, "add": False, "kept": 3} + assert out["writes"][4:] == [[100, 2], [100, 0]] * 2 def test_횡단에서_바꾸면_좌측_칸이_따라감(out) -> None: diff --git a/resources/tester/test_65_ford_defaults.py b/resources/tester/test_65_ford_defaults.py index 863d2d2a4..95d751985 100644 --- a/resources/tester/test_65_ford_defaults.py +++ b/resources/tester/test_65_ford_defaults.py @@ -134,18 +134,28 @@ def test_자동_배치_새_세월교는_기본값과_월류_높이(): assert "ford_height_m" not in blind.options # 유량 없으면 빈칸 -def test_집계표_세월교_흙막이_상자_건(): - item = { - "type_id": "ford_bridge", - "options": {"soil_add_1_form": "바자", "soil_add_2_form": "돌망태"}, - "복사본": "pipe@50.0", - "흙막이단": 0, # 배관 단 수는 세월교에 안 걸림 - } - rows = container_rows(item, {"gsoiladd2": "SS000001"}) - assert [(r["그룹"], r["summary_item"]) for r in rows] == [ - ("흙막이 1", None), - ("흙막이 2", "SS000001"), - ] +def test_집계표_세월교_박스_흙막이_상자는_유출_단_수만큼(): + """90-1 — 관보호공과 같은 방향(단 수 → 상자) · 유출이 흙막이가 아니면 옛 상자는 집계에서 뺌.""" + soil = {"soil_add_1_form": "바자", "soil_add_2_form": "돌망태"} + + def groups(type_id: str, outlet: str, tiers: int | None) -> list[tuple[str, object]]: + key = {"ford_bridge": "outlet_type", "box_culvert": "wing_out"}[type_id] + item = { + "type_id": type_id, + "options": {**soil, key: outlet}, + "복사본": "pipe@50.0", + "흙막이단": tiers, + } + rows = container_rows(item, {"gsoiladd2": "SS000001"}) + return [(r["그룹"], r["summary_item"]) for r in rows] + + for type_id in ("ford_bridge", "box_culvert"): + both = [("흙막이 1", None), ("흙막이 2", "SS000001")] + assert groups(type_id, "흙막이", 2) == both + assert groups(type_id, "흙막이", 1) == both[:1] + assert groups(type_id, "흙막이", None) == both # 모름 = 저장된 것 그대로 + assert groups(type_id, "날개벽", 2) == [] + assert groups(type_id, "", None) == [] def test_B06_측벽_읽기_줄은_세월교_박스_칸에_안_옮김(): From ac051ed9afa7bf82855c4f8ea6bd84ffd6716b2b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:14:50 +0900 Subject: [PATCH 30/47] =?UTF-8?q?fix(B06):=20=EB=85=B8=EB=A9=B4=EA=B3=B5?= =?UTF-8?q?=20=EB=AC=B4=EB=8A=AC=EB=A5=BC=20=ED=8F=AC=EC=9E=A5=20=EB=8B=A4?= =?UTF-8?q?=EA=B0=81=ED=98=95=20=ED=95=9C=20=EC=9E=A5=20=EC=B1=84=EC=9B=80?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=E2=80=94=20B07=20=EB=8F=84=EB=A9=B4=20?= =?UTF-8?q?=EA=B2=B9=EC=84=A0=20=EC=97=86=EC=9D=8C(88-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../B06_Section_UI_Cross_Design_Surface.ts | 21 +++++++++---------- .../B06_Section_UI_Style_Cross_Areas.css | 6 +++--- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Design_Surface.ts b/B06_Section/B06_Section_UI_Cross_Design_Surface.ts index dca4ed947..4355fab5c 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Surface.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Surface.ts @@ -92,18 +92,17 @@ export function appendPavementOverlay( const polygon = document.createElementNS(SVG_NS, "polygon"); polygon.setAttribute("points", points); polygon.setAttribute("class", "b06-chart__pavement"); - svg.append(polygon); const spec = entry?.spec; const mark = spec ? PAVE_MARKS[spec] : undefined; - if (!spec || !mark) continue; - const id = `b06-pave-hatch-${(paveSeq += 1)}`; - const defs = document.createElementNS(SVG_NS, "defs"); - defs.innerHTML = `${mark.marks}`; - const hatch = document.createElementNS(SVG_NS, "polygon"); - hatch.setAttribute("points", points); - hatch.setAttribute("class", "b06-chart__pavement-hatch"); - hatch.setAttribute("fill", `url(#${id})`); - hatch.dataset.paveSpec = spec; - svg.append(defs, hatch); + if (spec && mark) { + // 무늬는 바탕 채움째 한 패턴 — 다각형 한 장(B07 도면 수확이 겹선을 안 담음) + const id = `b06-pave-hatch-${(paveSeq += 1)}`; + const defs = document.createElementNS(SVG_NS, "defs"); + defs.innerHTML = `${mark.marks}`; + svg.append(defs); + polygon.style.fill = `url(#${id})`; + polygon.dataset.paveSpec = spec; + } + svg.append(polygon); } } diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 390b8c475..ff1b95e00 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -185,9 +185,9 @@ stroke-width: 1; } -/* 노면공 포장사양 무늬(88): 채움 박스 위에 무늬만 한 겹 — 콘크리트 빗금 · 아스콘 점 · 쇄석 돌 */ -.b06-chart__pavement-hatch { - stroke: none; +/* 노면공 포장사양 무늬(88): 포장 박스 채움 = 바탕 + 무늬 — 콘크리트 빗금 · 아스콘 점 · 쇄석 돌 */ +.b06-chart__pave-bg { + fill: color-mix(in srgb, var(--color-text-secondary) 30%, transparent); } .b06-chart__pave-mark { From 54e55576f16a87987fb469cd241f5ecff2f204f4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:17:23 +0900 Subject: [PATCH 31/47] =?UTF-8?q?chore(plan):=20=EB=B6=84=EC=84=9D=5F?= =?UTF-8?q?=EC=A0=95=ED=95=A8=20=EA=B3=B5=EC=9C=A0=EB=B3=B8=20=EB=94=B0?= =?UTF-8?q?=EB=9D=BC=EA=B0=90(91-1=20=EC=A4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- docs/raw/요구_1008/분석_정함.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1008/분석_정함.md b/docs/raw/요구_1008/분석_정함.md index a8e8395cc..2fca9cb8a 100644 --- a/docs/raw/요구_1008/분석_정함.md +++ b/docs/raw/요구_1008/분석_정함.md @@ -56,6 +56,7 @@ - **유출 단 수(90-1)** — 관보호공 「추가 흙막이(단)」 스테퍼를 공용으로 세월교 · 박스 유출에 · 「+ 흙막이」 단추 없앰 · 단 수 → 상자 수(관보호공과 같은 방향) · 집계표도 단 수로 상자 자름 · 유출이 날개벽 · 없음인데 상자가 있던 자료 = 숨기고 집계에서 뺌(값은 파일에 남음). - **도수로 · 돌붙임(90-2)** — 두께 칸(cm · 기본 30 · 사용자 확정) · 유출 성토사면 선을 사면 안쪽으로 두께만큼 오프셋한 띠(윗면 = 사면선 · 근거 교본 「지반선보다 낮게 원지반에 설치」) · 유출 끝 ~ 성토사면이 원지반 만나는 곳(흙막이 단 사이 성토 조각 포함) · 안에 돌 무늬(흙막이 돌 그리기 재사용) · 도수로 · 돌붙임 형상 같음 · 배관 · 박스 · 세월교 · 물넘이포장(`ford_chute`) · 면적 · 터파기 영향 없음. - **제약 삭제(91)** — 형상 자체(벽 외곽)는 원지반 · 암 참조 없음(조사_G 1). 「해당」 = 세월교 바닥 · 흙막이 기본 높이(하상) · 다단 자동 자리(원지반에 앉는 x) · I형 집수정 하단. 더 = 설계선 끝 2 px 판정(줌마다 끝점 변동) · 전면 기울기 지형 자동판정. 91-1 에서 재현 목록 → 브레인 판정 → 91-3. 끝 연결선(성토부선 · 절토선이 원지반에서 끝남)은 형상 아님 → 남김. +- **91-1 판정(브레인 · 10-8)** — ① 세월교 바닥 = 끊음(91-2 손 칸) ② 다단 자동 자리 = 끊음(지형 무관 등간격 · 앞 단 바닥 + 1:1.2 사면 일정 길이) ③ I형 집수정 근입 = 남김(집수정 형상 규칙 · 흙막이 아님) ④ 설계선 끝 2 px 판정 = 끊음(수치 교점 0.01 m) ⑤ 전면 기울기 지형 자동판정 = 끊음(관보호공 · 세월교 · 박스 흙막이 = 성토부 표값 고정 · 사면안정 흙막이 · 옹벽 = 설치 측 칸 값 · B08 도 같은 함수) ⑥ 끝 연결선 = 남김(잔재 문구만 지움). - **세월교 바닥(91-2)** — 처음 놓을 때 하상값 · 손 조정 칸(박스 `riseM` 꼴) · 원지반이 바뀌어도 안 따라감 · 흙막이 기본 높이도 이 값 기준. - **복합경사(91-4)** — 공용 도우미 하나(`common_util_cross_berm.ts cutProfilePoints` · 경계 = 원지반 + `rock_boundary_offset_m` · 켬 = 암 프리셋 + `two_stage_slope` · 소단 = `design.berm`) · 쓰는 곳 = 흙막이 수평공간 뒤 · 박스 끝 · 세월교 측벽 끝 · ㄴ·ㄷ 집수정 끝 · 절토 쪽 배관 유출 벽 뒤(지금 절토사면이 사라짐) · 소단 셈은 구조물 끝에서 새로 · `cutLine` 두 점 → 점열(횡단 셋 · 3D). From c7341ef484913eb5d58ce192e4f8ae4e45121f56 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:19:56 +0900 Subject: [PATCH 32/47] =?UTF-8?q?feat(M02):=20=EB=A9=B4=EC=A0=81=20?= =?UTF-8?q?=EB=8F=84=EB=A9=B4=20=ED=8C=A8=EB=84=90=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20[=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=B4=EB=82=B4=EA=B8=B0]=20=C2=B7=20=ED=91=9C?= =?UTF-8?q?=20=EC=9E=90=EB=A3=8C=20=E2=86=92=20=EC=83=88=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=90=EB=A3=8C=20=EB=B3=80=ED=99=98=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=202=EC=9E=A5=20=EC=9D=B4=EC=83=81=20?= =?UTF-8?q?=ED=83=AD=20=EC=A4=84(83-3=20=EC=95=9E=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=C2=B7=20addSheet=20=EC=86=90=EC=9E=A1=EC=9D=B4=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- .../M02_MasterTemplete_AreaDrawing.ts | 86 ++++++- .../M02_MasterTemplete_AreaFromDrawing.ts | 111 ++++++++ .../M02_MasterTemplete_AreaPanel.css | 24 ++ .../M02_MasterTemplete_AreaPanel.ts | 8 +- .../M02_MasterTemplete_UI_Main.ts | 3 +- .../tester/test_m02_area_from_drawing.py | 134 ++++++++++ resources/tester/ui_checks/83-3_도면_면적.js | 238 ++++++++++++++++++ 7 files changed, 593 insertions(+), 11 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_AreaFromDrawing.ts create mode 100644 resources/tester/test_m02_area_from_drawing.py create mode 100644 resources/tester/ui_checks/83-3_도면_면적.js diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts index bcd413cca..491ddcbf6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts @@ -1,19 +1,51 @@ /* ============================================================================= * M02_MasterTemplete_AreaDrawing.ts - * 면적산출 상세 오른쪽 「도면」 패널 몸(PLAN 47-6) — 그 열 구조물 도면을 보기만(확대 · 이동). - * 그리기는 구조물 조합 도면 카드 미리보기와 같은 길(`DrawingCard.mountDrawingView`). + * 면적산출 상세 오른쪽 「도면」 패널 몸(PLAN 47-6) — 그 열 구조물 도면을 보기 · 고르기(고치기 없음). * 처음 펼 때 읽고 그림 — 접힌 채 iframe 을 띄우지 않음. + * 83-3 — 머리 [면적산출로 보내기] = CAD 에 고른 범위 요청(`aislo:b08:export-table-request`) → 받은 표 자료 + * (`aislo:b08:export-table` · CAD `integration/aislo-table-export.ts`)를 `onTable` 로(새 시트 만들기는 부른 쪽). + * CAD 명령 TOAREA 로 보낸 것도 같은 길. 고칠 수 없는 면적(읽기 층)이면 단추 없음. * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; +import { createButton, el } from "@ui/ui_template_elements"; import { readTemplate, type Layer, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch"; -import { mountDrawingView } from "./M02_MasterTemplete_DrawingCard"; +import { mountDrawingTemplate, type DrawingTemplateDoc } from "./M02_MasterTemplete_Drawing"; +import type { DrawingTable } from "./M02_MasterTemplete_AreaFromDrawing"; const TEXT = { loading: "도면 불러오는 중…", none: "이 열은 구조물 도면이 없음", + send: "면적산출로 보내기", + sendTip: + "도면에서 고른 범위(표 · 선으로 그린 표 · 글)를 면적산출 새 시트로 한 번 옮김 — 연동 아님 · 누름 · 끌기로 고름", }; +export const EXPORT_TABLE_REQUEST = "aislo:b08:export-table-request"; +export const EXPORT_TABLE = "aislo:b08:export-table"; + +/** 고르기만 — 제목줄 · 리본 · 패널 · 상태줄 숨김(카드 고치기 갈래와 같은 숨김 · 명령 안내 줄은 남김) */ +const HIDE = + ".cad-titlebar, .cad-ribbon, .cad-inspector, .cad-statusbar, .cad-quick-access" + + " { display: none !important; }"; + +function hideChrome(frame: HTMLIFrameElement): void { + const apply = (): void => { + const inner = frame.contentDocument; + if (!inner?.head || inner.getElementById("m02-area-hide")) return; + const style = inner.createElement("style"); + style.id = "m02-area-hide"; + style.textContent = HIDE; + inner.head.append(style); + }; + frame.addEventListener("load", apply); + apply(); +} + +export interface AreaDrawingOptions { + /** 받은 표 자료 — 없으면(읽기 층) 보내기 단추 없음 */ + onTable?: (table: DrawingTable) => void; +} + export interface AreaDrawing { root: HTMLElement; /** 패널이 펼쳐짐 — 처음 한 번만 읽고 그림 */ @@ -21,13 +53,37 @@ export interface AreaDrawing { destroy: () => void; } -export function createAreaDrawing(layer: Layer, column: string): AreaDrawing { +export function createAreaDrawing( + layer: Layer, + column: string, + opts: AreaDrawingOptions = {}, +): AreaDrawing { const root = el("div", { className: "m02-apanel__drawing" }); + const stage = el("div", { className: "m02-apanel__drawstage" }); let view: { destroy: () => void } | null = null; + let frame: HTMLIFrameElement | null = null; let started = false; let dead = false; const note = (text: string): void => - root.replaceChildren(el("p", { className: "m02-apanel__note", text })); + stage.replaceChildren(el("p", { className: "m02-apanel__note", text })); + + const send = createButton({ label: TEXT.send, size: "sm", variant: "ghost" }); + send.title = TEXT.sendTip; + send.dataset.areaFromDrawing = ""; + send.disabled = true; // 도면이 서면 켬 + send.addEventListener("click", () => + frame?.contentWindow?.postMessage({ type: EXPORT_TABLE_REQUEST }, window.location.origin), + ); + if (opts.onTable) root.append(el("div", { className: "m02-apanel__drawbar", children: [send] })); + root.append(stage); + + const onMessage = (event: MessageEvent): void => { + if (!frame || event.source !== frame.contentWindow) return; + if (event.origin !== window.location.origin) return; + const data = event.data as { type?: string; table?: DrawingTable } | null; + if (data?.type === EXPORT_TABLE && data.table && opts.onTable) opts.onTable(data.table); + }; + window.addEventListener("message", onMessage); const show = (): void => { if (started) return; @@ -38,8 +94,21 @@ export function createAreaDrawing(layer: Layer, column: string): AreaDrawing { if (dead) return; const doc = got.문서 as StructureDoc | null; if (!doc?.도면) return note(TEXT.none); - root.replaceChildren(); - view = mountDrawingView(root, doc, { layer, projectId: null }, column); + stage.replaceChildren(); + // 보기 전용 + 고르기(누름 · 끌기) — 미리보기(끌기 = 화면 옮기기)는 고르기가 꺼져 있어 안 씀 + view = mountDrawingTemplate(stage, doc.도면 as unknown as DrawingTemplateDoc, { + readOnly: true, + name: `preview:${column}`, + layer, + panelsCollapsed: true, + originMarker: true, + noArea: true, + frame: true, + noImport: true, + }); + frame = stage.querySelector("iframe"); + if (frame) hideChrome(frame); + send.disabled = !frame; }) .catch(() => !dead && note(TEXT.none)); }; @@ -49,6 +118,7 @@ export function createAreaDrawing(layer: Layer, column: string): AreaDrawing { show, destroy: () => { dead = true; + window.removeEventListener("message", onMessage); view?.destroy(); root.remove(); }, diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaFromDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaFromDrawing.ts new file mode 100644 index 000000000..60786eb97 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaFromDrawing.ts @@ -0,0 +1,111 @@ +/* ============================================================================= + * M02_MasterTemplete_AreaFromDrawing.ts + * 도면 → 면적산출 한 번 옮기기(PLAN 83-3 · 연동 아님) — 오른쪽 도면 패널(`AreaDrawing`)이 받은 표 자료를 + * 면적 통합문서의 새 시트(「도면에서 n」)로 만듦. 기존 시트는 안 덮음 · 병합 · 열 폭 · 행 높이 반영. + * 표 자료 꼴 = CAD `helpers/table-from-drawing.ts` 의 `DrawingTable`(rows · merges · colWidths · rowHeights · 도면 mm). + * 스프레드시트 부품은 안 고침 — 시트 자료만 만들고 싣기는 공개 손잡이 `addSheet`(되돌리기 한 번 · onChange → 화면 고침 표시). + * ========================================================================== */ + +import { showToast } from "@ui/ui_template_elements"; +import type { Cell, Sheet, Workbook } from "../A00_Common/spreadsheet/spreadsheet_types"; + +const TEXT = { + done: (name: string) => + `도면 표를 「${name}」 시트로 옮김 — [저장]을 눌러야 저장 · 되돌리기 Ctrl+Z`, + loose: "표 선을 못 찾아 글 자리로 줄 · 칸을 나눔 — 칸이 맞는지 확인", + noHandle: "스프레드시트가 시트 더하기를 아직 못 함 — 옮기지 않음", + noSheet: "면적산출 시트가 열려 있지 않음", +}; + +/** CAD 가 보낸 표 자료(`DrawingTable`) */ +export interface DrawingTable { + kind: "table" | "grid" | "text"; + rows: (string | number)[][]; + merges: { row: number; col: number; rowSpan: number; colSpan: number }[]; + colWidths: number[]; + rowHeights: number[]; +} + +export const SHEET_PREFIX = "도면에서"; + +/** 도면 mm → 엑셀 열 폭(글자 단위 · 1글자 ≈ 7 px ≈ 1.85 mm) · 행 높이(pt = mm × 72 / 25.4) */ +const CHAR_PER_MM = 0.54; +const PT_PER_MM = 72 / 25.4; +const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v)); +const round1 = (v: number): number => Math.round(v * 10) / 10; + +/** 0 → `A` · 27 → `AB`(`spreadsheet_address.colName` 과 같은 규칙) */ +function colName(c: number): string { + let s = ""; + for (let n = c + 1; n > 0; n = Math.floor((n - 1) / 26)) + s = String.fromCharCode(65 + ((n - 1) % 26)) + s; + return s; +} +const a1 = (r: number, c: number): string => `${colName(c)}${r + 1}`; + +/** 「도면에서 n」 — 안 쓴 가장 작은 n */ +export function nextSheetName(book: Pick): string { + const used = new Set(book.시트.map((s) => s.이름)); + let n = 1; + while (used.has(`${SHEET_PREFIX} ${n}`)) n += 1; + return `${SHEET_PREFIX} ${n}`; +} + +/** 안 쓴 시트 id(`s1` …) */ +function nextSheetId(book: Pick): string { + const used = new Set(book.시트.map((s) => s.id)); + let n = book.시트.length + 1; + while (used.has(`s${n}`)) n += 1; + return `s${n}`; +} + +/** 표 자료 → 새 시트(이름 · id 는 문서에서 안 쓴 것) */ +export function tableToSheet(table: DrawingTable, book: Pick): Sheet { + const 칸: Record = {}; + table.rows.forEach((line, r) => + line.forEach((value, c) => { + if (value === "" || value === null || value === undefined) return; + 칸[a1(r, c)] = { 값: value }; + }), + ); + const 병합 = table.merges + .filter((m) => m.rowSpan > 1 || m.colSpan > 1) + .map((m) => `${a1(m.row, m.col)}:${a1(m.row + m.rowSpan - 1, m.col + m.colSpan - 1)}`); + const sheet: Sheet = { id: nextSheetId(book), 이름: nextSheetName(book), 칸 }; + if (병합.length) sheet.병합 = 병합; + if (table.colWidths.length) { + sheet.열 = {}; + table.colWidths.forEach((w, c) => { + if (w > 0) sheet.열![colName(c)] = { 폭: round1(clamp(w * CHAR_PER_MM, 2, 60)) }; + }); + } + if (table.rowHeights.length) { + sheet.행 = {}; + table.rowHeights.forEach((h, r) => { + if (h > 0) sheet.행![String(r + 1)] = { 높이: round1(clamp(h * PT_PER_MM, 12, 100)) }; + }); + } + return sheet; +} + +/** 스프레드시트 공개 손잡이 중 쓰는 것 — 시트 더하기(되돌리기 한 번 · onChange · 탭 줄은 부품 몫) */ +export interface AddSheetHandle { + getDoc(): Workbook; + addSheet?: (sheet: Sheet, opts?: { activate?: boolean }) => unknown; +} + +/** 받은 표 → 면적 새 시트(활성) · 알림 — 옮긴 시트 이름 · 못 하면 null */ +export function importDrawingTable( + handle: AddSheetHandle | null, + table: DrawingTable, +): string | null { + if (!handle?.addSheet) { + showToast(handle ? TEXT.noHandle : TEXT.noSheet, "error"); + return null; + } + const sheet = tableToSheet(table, handle.getDoc()); + handle.addSheet(sheet, { activate: true }); + showToast(TEXT.done(sheet.이름), "success"); + if (table.kind === "text") showToast(TEXT.loose, "info"); + return sheet.이름; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css index c1a2de4c4..169333d9a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css @@ -62,3 +62,27 @@ height: min(60vh, 520px); min-height: 240px; } + +/* 도면 패널 머리 [면적산출로 보내기](83-3) — 아래 도면 칸이 남은 높이를 다 씀 */ +.m02-apanel__drawing:has(> .m02-apanel__drawstage) { + flex-direction: column; +} + +.m02-apanel__drawbar { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border-bottom: 1px solid var(--color-border); +} + +.m02-apanel__drawstage { + display: flex; + flex: 1 1 0; + min-width: 0; + min-height: 0; +} + +.m02-apanel__drawstage .cad-host { + min-height: 0; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts index 9d806e4e9..684d8e6b6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts @@ -2,7 +2,7 @@ * M02_MasterTemplete_AreaPanel.ts * 면적산출 상세 오른쪽 패널(PLAN 39-4 D2 · D6 · 47-6) — 틀 = 공용 오른쪽 접는 패널(하나만 열림 · 폭 공유) * · 「일위대가」 = 그 열 일위대가의 담은 로직 + 자재목록(기준 · 반영) · 몸 = 일위대가 보기 갈래(`Labor_View`) - * · 「도면」 = 그 열 구조물 도면 보기만(`AreaDrawing` · 처음 펼 때 그림) + * · 「도면」 = 그 열 구조물 도면 보기 · 고르기(`AreaDrawing` · 처음 펼 때 그림) · [면적산출로 보내기] = 새 시트(83-3) * · 줄 누름 = 칸 지정 모달(`AreaLink` · 그 줄 알약 키를 면적 칸에 잇기 · 풀기) * · 시트 칸 값 · 연결이 바뀌면 `refresh` — 받은 기준에 바로 곱함 · 칸 주소도 넘김(서버 안 부름 · `Labor_View.setQty`) * ========================================================================== */ @@ -12,6 +12,7 @@ import { createSidePanels } from "@ui/ui_template_side_panels"; import type { LinkItem, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; import { createAreaDrawing } from "./M02_MasterTemplete_AreaDrawing"; +import { importDrawingTable } from "./M02_MasterTemplete_AreaFromDrawing"; import { openAreaLink } from "./M02_MasterTemplete_AreaLink"; import { createLaborView, type LaborViewRow } from "./M02_MasterTemplete_Labor_View"; import "./M02_MasterTemplete_AreaPanel.css"; @@ -95,7 +96,10 @@ export function buildAreaPanel(opts: AreaPanelOptions): AreaPanel { }); }; const view = createLaborView(openLink); - const drawing = createAreaDrawing(opts.layer, opts.column); + // 도면 → 면적 새 시트(83-3 · 한 번 · 연동 아님) — 고칠 수 없는 면적이면 보내기 단추 없음 + const drawing = createAreaDrawing(opts.layer, opts.column, { + onTable: opts.readOnly ? undefined : (table) => void importDrawingTable(opts.sheet(), table), + }); const panels = createSidePanels({ prefKey: "m02-area-panels", defaultWidth: DEFAULT_WIDTH, diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 8bdf22c42..4c4b9a079 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -287,7 +287,8 @@ export function createMain( doc as Parameters[1], { readOnly, - tabs: false, // 시트 하나 · 탭 줄 없음 + // 시트 하나 = 탭 줄 없음 · 도면에서 옮긴 시트가 있으면(2장 이상 · 83-3) 탭 줄 + tabs: ((doc as { 시트?: unknown[] }).시트?.length ?? 0) > 1, onChange: () => { dirty = !readOnly; apanel?.refresh(); // 칸 값 · 연결이 바뀜 — 반영 금액을 바로 다시 곱함 diff --git a/resources/tester/test_m02_area_from_drawing.py b/resources/tester/test_m02_area_from_drawing.py new file mode 100644 index 000000000..226c1edeb --- /dev/null +++ b/resources/tester/test_m02_area_from_drawing.py @@ -0,0 +1,134 @@ +"""도면 → 면적산출 한 번 옮기기(PLAN 83-3 · `M02_MasterTemplete_AreaFromDrawing.ts`) — Node 로 TS 를 돌려 판정. + +못박는 것 + - 표 자료(CAD `table-from-drawing.ts` 꼴) → 새 시트 「도면에서 n」(안 쓴 가장 작은 n · 안 쓴 id) · 기존 시트 안 덮음. + - 숫자 칸 = 수 · 빈 칸 안 적음 · 병합 = A1 범위 · 열 폭(글자) · 행 높이(pt) = 도면 mm 환산 · 폴백(text) = 폭 · 높이 없음. + - 싣기 = 공개 손잡이 addSheet(시트, {activate}) 한 번 · 손잡이 없으면 안 옮기고 알림. + - 패널 메시지 이름 = CAD `aislo-table-export.ts` 와 같음 · 읽기 면적은 보내기 단추 없음 · 시트 2장 이상이면 탭 줄. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +SCRIPT = r""" +const fs = require("fs"); +const ts = require(process.argv[1] + "/config/node_modules/typescript"); +const src = fs.readFileSync(process.argv[1] + "/M02_MasterTemplete/M02_MasterTemplete_AreaFromDrawing.ts", "utf8"); +const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const toasts = []; +const stubs = { "@ui/ui_template_elements": { showToast: (m, k) => toasts.push([k, m]) } }; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n)); +const { tableToSheet, nextSheetName, importDrawingTable } = box.exports; + +const book = { 시트: [{ id: "s1", 이름: "면적" }, { id: "s2", 이름: "도면에서 1" }] }; +const grid = { + kind: "grid", + rows: [["구분", "수량", ""], ["흙깎기", 1500, "m3"], ["", 2.5, ""]], + merges: [{ row: 0, col: 1, rowSpan: 1, colSpan: 2 }, { row: 1, col: 0, rowSpan: 2, colSpan: 1 }], + colWidths: [40, 30, 300], + rowHeights: [10, 7, 1], +}; +const text = { kind: "text", rows: [["합계", 3000], ["소계", ""]], merges: [], colWidths: [], rowHeights: [] }; + +const out = {}; +out.gridSheet = tableToSheet(grid, book); +out.textSheet = tableToSheet(text, { 시트: [] }); +out.names = [nextSheetName({ 시트: [] }), nextSheetName(book), nextSheetName({ 시트: [{ 이름: "도면에서 2" }] })]; + +const added = []; +const handle = { getDoc: () => book, addSheet: (s, o) => added.push([s, o]) }; +out.imported = importDrawingTable(handle, text); +out.added = added; +out.noHandle = importDrawingTable({ getDoc: () => book }, grid); +out.noSheet = importDrawingTable(null, grid); +out.toasts = toasts; +console.log(JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def run(): + node = shutil.which("node") + if not node: + pytest.skip("node 없음") + done = subprocess.run( + [node, "-e", SCRIPT, str(ROOT)], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout) + + +def test_grid_table_becomes_new_sheet(run): + sheet = run["gridSheet"] + assert sheet["이름"] == "도면에서 2" # 「도면에서 1」 이 이미 있음 + assert sheet["id"] == "s3" + assert sheet["칸"] == { + "A1": {"값": "구분"}, + "B1": {"값": "수량"}, + "A2": {"값": "흙깎기"}, + "B2": {"값": 1500}, + "C2": {"값": "m3"}, + "B3": {"값": 2.5}, + } + assert sheet["병합"] == ["B1:C1", "A2:A3"] + # 40 mm → 21.6 글자 · 300 mm → 60(위 끝) · 10 mm → 28.3 pt · 1 mm → 12(아래 끝) + assert sheet["열"] == {"A": {"폭": 21.6}, "B": {"폭": 16.2}, "C": {"폭": 60}} + assert sheet["행"] == {"1": {"높이": 28.3}, "2": {"높이": 19.8}, "3": {"높이": 12}} + + +def test_text_fallback_has_no_sizes(run): + sheet = run["textSheet"] + assert sheet["이름"] == "도면에서 1" and sheet["id"] == "s1" + assert sheet["칸"] == {"A1": {"값": "합계"}, "B1": {"값": 3000}, "A2": {"값": "소계"}} + assert "병합" not in sheet and "열" not in sheet and "행" not in sheet + + +def test_sheet_names(run): + assert run["names"] == ["도면에서 1", "도면에서 2", "도면에서 1"] + + +def test_import_uses_add_sheet_once(run): + assert run["imported"] == "도면에서 2" + assert len(run["added"]) == 1 + sheet, opts = run["added"][0] + assert sheet["이름"] == "도면에서 2" and opts == {"activate": True} + assert run["noHandle"] is None and run["noSheet"] is None + kinds = [kind for kind, _ in run["toasts"]] + assert kinds == [ + "success", + "info", + "error", + "error", + ] # 옮김 · 글 폴백 확인 · 손잡이 없음 · 시트 없음 + assert "「도면에서 2」" in run["toasts"][0][1] + + +def test_panel_wiring_matches_cad(): + cad = (ROOT / "B07_DesignDetail/openwebcad/src/integration/aislo-table-export.ts").read_text( + encoding="utf-8" + ) + panel = (M02 / "M02_MasterTemplete_AreaDrawing.ts").read_text(encoding="utf-8") + + def const(text, name): + return re.search(rf"{name} = ['\"]([^'\"]+)['\"]", text).group(1) + + assert const(cad, "AISLO_EXPORT_TABLE_REQUEST_MESSAGE") == const(panel, "EXPORT_TABLE_REQUEST") + assert const(cad, "AISLO_EXPORT_TABLE_MESSAGE") == const(panel, "EXPORT_TABLE") + assert "readOnly: true" in panel and "preview: true" not in panel # 보기 전용 + 고르기 + apanel = (M02 / "M02_MasterTemplete_AreaPanel.ts").read_text(encoding="utf-8") + assert "onTable: opts.readOnly ? undefined" in apanel # 읽기 면적 = 단추 없음 + main = (M02 / "M02_MasterTemplete_UI_Main.ts").read_text(encoding="utf-8") + assert "tabs: ((doc as { 시트?: unknown[] }).시트?.length ?? 0) > 1" in main diff --git a/resources/tester/ui_checks/83-3_도면_면적.js b/resources/tester/ui_checks/83-3_도면_면적.js new file mode 100644 index 000000000..d8f9990ba --- /dev/null +++ b/resources/tester/ui_checks/83-3_도면_면적.js @@ -0,0 +1,238 @@ +/* 83-3 — 면적산출 상세 오른쪽 「도면」 패널 [면적산출로 보내기] · 고른 범위 → 표 자료 → 새 시트 + * 시작 화면: M02 마스터 템플릿 · 면적산출 상세(47-6 스크립트로 연 뒤) · 「도면」 패널 펼침 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 하는 일: 패널 CAD 에 견본 도면(표 · 선 표 · 글만 · 글 없는 선 · 회전된 표)을 보기 전용으로 싣고(저장 안 됨) + * 범위마다 끌어 고른 뒤 단추를 눌러 부모가 받은 표 자료(export-table)와 알림을 모음 · 빈 범위도. + * 기대 수치: + * - table = kind table · rows [["구분","수량",""],["",1500,"m2"]] · merges 2 + * - grid = kind grid · rows [["구분","수량",""],["흙깎기",1500,"m3"],["돌쌓기",2.5,"m2"]] · merges 1 + * - text = kind text · rows [["합계",3000,"비고"],["소계",1000,""]] + * - empty · lines = 받은 것 없음(알림만) · rotated = kind text(글 둘) + * - 끝에 원래 도면을 다시 실음(restored) + */ +(async () => { + const W = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s, root = document) => root.querySelector(s); + const until = async (fn, ms = 8000) => { + for (let t = 0; t < ms; t += 100) { + const v = fn(); + if (v) return v; + await W(100); + } + return null; + }; + const panel = $('.ui-side-panels__panel[data-panel="drawing"]'); + const frame = panel?.querySelector("iframe"); + const btn = $("[data-area-from-drawing]"); + if (!frame || !btn) return JSON.stringify({ error: "도면 패널 · 단추 없음" }); + const win = frame.contentWindow; + const cad = win.__aisloCad; + const L = "L83"; + let n = 0; + const id = () => `t83-${(n += 1)}`; + const base = { lineColor: "#fff", lineWidth: 1, layerId: L }; + const line = (x1, y1, x2, y2) => ({ + id: id(), + type: "Line", + ...base, + shapeData: { startPoint: { x: x1, y: y1 }, endPoint: { x: x2, y: y2 } }, + }); + const text = (label, x, y) => ({ + id: id(), + type: "Text", + ...base, + shapeData: { + label, + basePoint: { x, y }, + options: { + textDirection: { x: 1, y: 0 }, + textAlign: "left", + textColor: "#fff", + fontSize: 2.5, + fontFamily: "Noto Sans KR", + }, + }, + }); + const rot = (x, y, ox, oy) => { + const a = Math.PI / 6; + return [ox + x * Math.cos(a) - y * Math.sin(a), oy + x * Math.sin(a) + y * Math.cos(a)]; + }; + const groups = { + table: [ + { + id: id(), + type: "Table", + ...base, + shapeData: { + origin: { x: 0, y: 100 }, + columnWidths: [30, 20, 25], + rowHeights: [8, 6], + cells: [ + [{ text: "구분", rowSpan: 2 }, { text: "수량", colSpan: 2 }, null], + [null, { text: "1,500" }, { text: "m2" }], + ], + style: { fontSize: 2.5 }, + }, + }, + ], + grid: [ + line(100, 60, 200, 60), + line(100, 30, 200, 30), + line(100, 60, 100, 30), + line(200, 60, 200, 30), + line(100, 50, 155, 50), + line(150, 50, 200, 50), + line(100, 40, 200, 40), + line(140, 60, 140, 30), + line(170, 50, 170, 30), + text("구분", 102, 53), + text("수량", 145, 53), + text("흙깎기", 102, 43), + text("1,500", 142, 43), + text("m3", 172, 43), + text("돌쌓기", 102, 33), + text("2.5", 142, 33), + text("m2", 172, 33), + ], + text: [ + text("합계", 250, 60), + text("3,000", 290, 60.3), + text("비고", 330, 59.8), + text("소계", 250, 50), + text("1,000", 290, 50), + ], + lines: [ + line(0, 0, 40, 0), + line(0, 20, 40, 20), + line(0, 0, 0, 20), + line(40, 0, 40, 20), + line(20, 0, 20, 20), + ], + rotated: [ + line(...rot(0, 0, 250, 0), ...rot(40, 0, 250, 0)), + line(...rot(0, 10, 250, 0), ...rot(40, 10, 250, 0)), + line(...rot(0, 0, 250, 0), ...rot(0, 10, 250, 0)), + line(...rot(40, 0, 250, 0), ...rot(40, 10, 250, 0)), + line(...rot(20, 0, 250, 0), ...rot(20, 10, 250, 0)), + text("A", ...rot(2, 4, 250, 0)), + text("B", ...rot(22, 4, 250, 0)), + ], + }; + const all = Object.values(groups).flat(); + // 원래 도면 — 끝에 다시 실음 + const original = await new Promise((resolve) => { + const on = (e) => { + if (e.source !== win || e.data?.type !== "aislo:b08:save-response") return; + window.removeEventListener("message", on); + resolve(e.data.drawing); + }; + window.addEventListener("message", on); + win.postMessage({ type: "aislo:b08:save-request" }, location.origin); + }); + const load = async (drawing) => { + const done = new Promise((resolve) => { + const on = (e) => { + if (e.source !== win || e.data?.type !== "aislo:b08:drawing-loaded") return; + window.removeEventListener("message", on); + resolve(true); + }; + window.addEventListener("message", on); + }); + win.postMessage( + { + type: "aislo:b08:load-drawing", + drawing, + readOnly: true, + panelsCollapsed: true, + recoveryScope: "verify83", + }, + location.origin, + ); + return Promise.race([done, W(5000)]); + }; + await load({ + format: 6, + entities: all, + layers: [{ id: L, name: "검증83", isVisible: true, isLocked: false }], + }); + await W(400); + + // 받은 표 자료 · 알림 모음 + const got = []; + const onMsg = (e) => + e.source === win && e.data?.type === "aislo:b08:export-table" && got.push(e.data.table); + window.addEventListener("message", onMsg); + const toasts = () => + [...document.querySelectorAll(".ui-toast, .ui-toast__message, [class*=toast]")] + .map((t) => t.innerText.trim()) + .filter(Boolean); + const canvas = frame.contentDocument.querySelector("canvas"); + const box = (list) => { + const xs = [], + ys = []; + for (const e of list) { + const s = e.shapeData; + if (e.type === "Line") + (xs.push(s.startPoint.x, s.endPoint.x), ys.push(s.startPoint.y, s.endPoint.y)); + if (e.type === "Text") + (xs.push(s.basePoint.x, s.basePoint.x + 6), ys.push(s.basePoint.y, s.basePoint.y + 3)); + if (e.type === "Table") { + xs.push(s.origin.x, s.origin.x + s.columnWidths.reduce((a, b) => a + b, 0)); + ys.push(s.origin.y, s.origin.y - s.rowHeights.reduce((a, b) => a + b, 0)); + } + } + return [Math.min(...xs) - 5, Math.min(...ys) - 5, Math.max(...xs) + 5, Math.max(...ys) + 5]; + }; + const fire = (type, p) => { + const r = canvas.getBoundingClientRect(); + const ev = new win.MouseEvent(type, { + bubbles: true, + clientX: r.left + p.x, + clientY: r.bottom - p.y, + button: 0, + buttons: type === "mouseup" ? 0 : 1, + }); + canvas.dispatchEvent(ev); + }; + const drag = async ([x0, y0, x1, y1]) => { + frame.contentDocument.dispatchEvent( + new win.KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + await W(150); + const a = cad.screen(x0, y0), + b = cad.screen(x1, y1); + fire("mousemove", a); + await W(60); + fire("mousedown", a); + await W(60); + fire("mousemove", { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }); + await W(60); + fire("mousemove", b); + await W(60); + fire("mouseup", b); + await W(250); + }; + const out = {}; + for (const [name, list] of Object.entries(groups)) { + got.length = 0; + await drag(box(list)); + out[name] = { selected: cad.selection().length }; + btn.click(); + await W(500); + out[name].table = got[0] ?? null; + out[name].toasts = toasts().slice(-2); + } + // 빈 범위 — 아무것도 없는 자리를 끌어 고름 + got.length = 0; + await drag([210, 70, 240, 90]); + btn.click(); + await W(500); + out.empty = { + selected: cad.selection().length, + table: got[0] ?? null, + toasts: toasts().slice(-1), + }; + window.removeEventListener("message", onMsg); + out.restored = await load(original); + return JSON.stringify(out); +})(); From 158c5ff37fb7b1be1d7a326baa8f6ab3eb2fc949 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:20:08 +0900 Subject: [PATCH 33/47] =?UTF-8?q?feat(B06):=20=EC=9C=A0=EC=B6=9C=EC=B8=A1?= =?UTF-8?q?=20=EC=A0=88=ED=86=A0=20=EB=B3=B5=ED=95=A9=EA=B2=BD=EC=82=AC=20?= =?UTF-8?q?=EA=B3=B5=EC=9A=A9=20=EB=8F=84=EC=9A=B0=EB=AF=B8=20=E2=80=94=20?= =?UTF-8?q?=ED=9D=99=EB=A7=89=EC=9D=B4=20=EC=88=98=ED=8F=89=EA=B3=B5?= =?UTF-8?q?=EA=B0=84=20=EB=92=A4=20=C2=B7=20=EB=B0=95=EC=8A=A4=20=EB=81=9D?= =?UTF-8?q?=20=C2=B7=20=EC=84=B8=EC=9B=94=EA=B5=90=20=EC=B8=A1=EB=B2=BD=20?= =?UTF-8?q?=EB=81=9D=20=C2=B7=20=E3=84=B4=C2=B7=E3=84=B7=20=EC=A7=91?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=81=9D=20=C2=B7=20=EC=A0=88=ED=86=A0=20?= =?UTF-8?q?=EC=AA=BD=20=EB=B0=B0=EA=B4=80=20=EC=9C=A0=EC=B6=9C=20=EB=B2=BD?= =?UTF-8?q?=20=EB=92=A4=20=C2=B7=20=EC=A0=88=ED=86=A0=EC=84=A0=20=EC=A0=90?= =?UTF-8?q?=EC=97=B4=20=C2=B7=203D(91-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- ...5_Profile_UI_Corridor_Station_Structure.ts | 6 +- B06_Section/B06_Section_UI_Cross_Box.ts | 2 +- B06_Section/B06_Section_UI_Cross_Box_Geom.ts | 12 +- B06_Section/B06_Section_UI_Cross_Culvert.ts | 11 +- .../B06_Section_UI_Cross_Culvert_Basin.ts | 30 ++--- .../B06_Section_UI_Cross_Culvert_Extra.ts | 39 +++---- .../B06_Section_UI_Cross_Culvert_Geom.ts | 5 +- .../B06_Section_UI_Cross_Culvert_Tiers.ts | 3 +- .../B06_Section_UI_Cross_Culvert_Types.ts | 4 +- B06_Section/B06_Section_UI_Cross_Ford.ts | 2 +- B06_Section/B06_Section_UI_Cross_Ford_Geom.ts | 4 +- .../B06_Section_UI_Cross_Structure_Cut.ts | 107 ++++++++++++++++++ resources/tester/helper_91_ground_free.cjs | 61 ++++++++++ resources/tester/test_79_guard_shape.py | 3 +- resources/tester/test_91_ground_free.py | 31 +++++ 15 files changed, 251 insertions(+), 69 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Structure_Cut.ts diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts index e0c6913f4..13b7a6c7e 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Station_Structure.ts @@ -211,7 +211,7 @@ export function fordSilhouettes(section: CrossSection): StructureSilhouette[] { points.push(floorOuter); patchPoints.push(floorOuter); const run: OffsetPoint[] = [floorOuter]; - for (const cutPoint of [side.cutLine.from, side.cutLine.to]) { + for (const cutPoint of side.cutLine) { const at = { offset_m: cutPoint.offset, elevation_m: cutPoint.elevation }; points.push(at); patchPoints.push(at); @@ -347,8 +347,8 @@ export function culvertSilhouettes(section: CrossSection): StructureSilhouette[] } // 집수정이 원지반에 박히면 절토선까지 — 그만큼 원지반을 걷어낸다. if (layout.basin.cutLine) { - push(inletSide, [layout.basin.cutLine.from, layout.basin.cutLine.to]); - addRun(inletSide, [layout.basin.cutLine.from, layout.basin.cutLine.to]); + push(inletSide, layout.basin.cutLine); + addRun(inletSide, layout.basin.cutLine); basinCut = true; } spans[inletSide] = basinSpanOf(section); diff --git a/B06_Section/B06_Section_UI_Cross_Box.ts b/B06_Section/B06_Section_UI_Cross_Box.ts index c83a1a607..ab2422824 100644 --- a/B06_Section/B06_Section_UI_Cross_Box.ts +++ b/B06_Section/B06_Section_UI_Cross_Box.ts @@ -136,7 +136,7 @@ export function appendBoxOverlay( })); if (side.cutLine) lines.push({ - points: [side.cutLine.from, side.cutLine.to], + points: side.cutLine, text: `${mark}측 끝 절토선(계획선)`, }); for (const line of lines) { diff --git a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts index a9ae4f1b3..247e0dae7 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts @@ -26,6 +26,7 @@ import type { import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra"; import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const"; import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve"; +import { structureCutPoints, structureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut"; /** 구체 한쪽 끝의 조작값 — 좌·우를 따로 잡는다(2026-08-25 사용자). */ export interface BoxSideAdjust { @@ -135,7 +136,8 @@ export function computeBoxLayout( // 본체는 늘 수평(79-7) — 좌 · 우 표고 조정이 달라도(옛 저장) 평균 하나로 섬. const riseM = (adjust.left.riseM + adjust.right.riseM) / 2; const sampleOffsets = groundSamples.map((sample) => sample.offset_m ?? 0); - const cutSlopeRatio = section.design?.cut_slope_ratio ?? 1.0; + // 끝 절토 = 본선 절토 사면과 같은 복합경사(91-4) + const cutSpec = structureCutSpec(section.design); const sideOf = (role: BoxSideRole): BoxSideLayout => { const outward = role === "left" ? 1 : -1; @@ -172,13 +174,7 @@ export function computeBoxLayout( let cutLine: BasinLayout["cutLine"] = null; let fillSegments: OutletFillSegment[] = []; if (groundAt(end.offset) > end.elevation + 0.05) { - for (let h = 0.05; h <= 20; h += 0.05) { - const probe = end.offset + outward * cutSlopeRatio * h; - if (groundAt(probe) <= end.elevation + h) { - cutLine = { from: end, to: { offset: probe, elevation: end.elevation + h } }; - break; - } - } + cutLine = structureCutPoints(end, outward, cutSpec, groundAt); } else if (groundAt(end.offset) < end.elevation - 0.05) { fillSegments = buildExtrasAt(end, { startBottomElevation: bottomElevation, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 245e18dd4..c05df849a 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -225,11 +225,12 @@ export function appendCulvertOverlay( layer.append(shape); } if (basin.cutLine) { - const cut = document.createElementNS(SVG_NS, "line"); - cut.setAttribute("x1", String(x(basin.cutLine.from.offset))); - cut.setAttribute("y1", String(toDisplayY(basin.cutLine.from.elevation))); - cut.setAttribute("x2", String(x(basin.cutLine.to.offset))); - cut.setAttribute("y2", String(toDisplayY(basin.cutLine.to.elevation))); + // 점열(암 무릎 · 소단 · 91-4) + const cut = document.createElementNS(SVG_NS, "polyline"); + cut.setAttribute( + "points", + basin.cutLine.map((point) => `${x(point.offset)},${toDisplayY(point.elevation)}`).join(" "), + ); // 절토선은 **공사 계획선**이다 — 설계선과 같은 보라 실선으로 그린다 // (2026-08-20 사용자 ②). cut.setAttribute("class", "b06-chart__design-cross"); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts index 3ef18d3fc..3d362750a 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts @@ -20,7 +20,7 @@ import { REVET_FREEBOARD_M, REVET_LEAN_RATIO, } from "./B06_Section_UI_Cross_Culvert_Const"; -import type { CulvertSideSpec } from "./B06_Section_Api_Fetch"; +import type { CrossDesign, CulvertSideSpec } from "./B06_Section_Api_Fetch"; import { minShoulderWallOffset, slopeLengthAlong, @@ -34,6 +34,7 @@ import type { OffsetPoint, InletStructureChoice, } from "./B06_Section_UI_Cross_Culvert_Types"; +import { structureCutPoints, structureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut"; /** 자동(기본) 집수정 모양(54-36) — 좌측 폼 「(기본)」 표시도 이 값. */ export const DEFAULT_BASIN_SHAPE: BasinShape = "L"; @@ -140,8 +141,10 @@ export interface BasinBuildInput { /** 이 측의 노견 끝 점 — 벽 상단 접점·I형 높이 성장 기준. */ edge: { offset_m: number; elevation_m: number }; groundAt: (offset: number) => number; - /** 표준단면 절토 경사(1:n) — 절토 계획선용. */ - cutSlopeRatio: number; + /** 표준단면 절토 경사(1:n) — 절토 계획선용(`cutDesign` 이 없을 때 경사 하나). */ + cutSlopeRatio?: number; + /** 측점 설계값 — 있으면 절토선이 본선과 같은 복합경사(암 무릎 · 소단 · 91-4). */ + cutDesign?: Partial | null; adjust: BasinAdjust; /** 벽 두께(m). 기본 = 집수정 부재 두께. 세월교 측벽이 다른 두께를 쓸 때 넘긴다. */ memberThicknessM?: number; @@ -183,17 +186,8 @@ export const BASIN_INNER_WIDTH_M = 1.0; /** 집수정 단면을 만든다. 형태별 규칙은 파일 머리말 참조. */ export function buildBasin(input: BasinBuildInput): BasinBuildResult { - const { - anchor: baseAnchor, - outward, - shape, - reason, - diameterM, - edge, - groundAt, - cutSlopeRatio, - adjust, - } = input; + const { anchor: baseAnchor, outward, shape, reason, diameterM, edge, groundAt, adjust } = input; + const cutSpec = structureCutSpec(input.cutDesign ?? { cut_slope_ratio: input.cutSlopeRatio }); /** 이 부재의 기움 — 집수정 0.3, 세월교 0(수직). */ const lean = input.leanRatio ?? REVET_LEAN_RATIO; const movedAnchor = { @@ -339,13 +333,7 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult { : { offset: floorOuter, elevation: anchor.elevation }; const groundHere = groundAt(refTop.offset); if (groundHere > refTop.elevation + 0.05) { - for (let h = 0.05; h <= 20; h += 0.05) { - const probe = refTop.offset + outward * cutSlopeRatio * h; - if (groundAt(probe) <= refTop.elevation + h) { - basinCut = { from: refTop, to: { offset: probe, elevation: refTop.elevation + h } }; - break; - } - } + basinCut = structureCutPoints(refTop, outward, cutSpec, groundAt); } else if (groundHere < refTop.elevation - 0.05) { // 원지반 밖으로 나왔다 — 성토부선은 유출측과 같은 체계(1:1.2 + 5m 다단)로 // 본체가 그린다(2026-08-22 사용자 확정). 여기서는 시작점만 넘긴다. diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index 720b9452e..fb01894dd 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -29,6 +29,8 @@ import type { WallAdjust, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Types"; +import { structureCutPoints } from "./B06_Section_UI_Cross_Structure_Cut"; +import type { StructureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut"; import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall"; export interface OutletExtrasInput { @@ -48,20 +50,19 @@ export interface OutletExtrasInput { equalize?: boolean; /** 단 벽의 전면 기울기 — 형태·순수 높이로 표준경사를 고름(없으면 종전 1:0.3). */ leanFor?: (form: string, pureHeightM: number) => number; - /** 끝이 원지반에 묻힐 때 수평공간 뒤 절토 경사(1:n)로 지반까지(세월교 · 박스 흙막이 · 79-7). - * 없으면 종전 3도 선. */ - cutBerm?: { lengthM: number; slopeRatio: number }; + /** 끝이 원지반에 묻힐 때 수평공간 뒤 절토(본선과 같은 복합경사 · 91-4)로 지반까지(세월교 · 박스 + * 흙막이 · 79-7). 없으면 종전 3도 선. */ + cutBerm?: { lengthM: number; cut: StructureCutSpec }; } -/** 수평공간(berm) 뒤 절토 경사로 원지반까지 — 공간 안에서 지반을 만나면 거기서 끝(79-7). */ +/** 수평공간(berm) 뒤 절토(복합경사 · 91-4)로 원지반까지 — 공간 안에서 지반을 만나면 거기서 끝(79-7). */ function bermCut( src: OffsetPoint, outward: number, - berm: { lengthM: number; slopeRatio: number }, + berm: { lengthM: number; cut: StructureCutSpec }, groundAt: (offset: number) => number, limitOffset: number, ): OutletFillSegment { - const ratio = Math.max(berm.slopeRatio, 0.1); const flat = slopedCrossing(src, outward, 0, groundAt, limitOffset); const segment = (points: OffsetPoint[]): OutletFillSegment => ({ points, @@ -78,22 +79,16 @@ function bermCut( if (flat !== null && (flat - src.offset) * outward <= berm.lengthM + 1e-9) return segment([src, { offset: flat, elevation: src.elevation }]); const bend = { offset: src.offset + outward * berm.lengthM, elevation: src.elevation }; - const span = (limitOffset - bend.offset) * outward; - let previousGap = groundAt(bend.offset) - bend.elevation; - for (let t = 0.25; t <= span + 1e-9; t += 0.25) { - const gap = groundAt(bend.offset + outward * t) - (bend.elevation + t / ratio); - if (gap <= 0) { - const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1; - const cross = t - 0.25 * (1 - fraction); - return segment([ - src, - bend, - { offset: bend.offset + outward * cross, elevation: bend.elevation + cross / ratio }, - ]); - } - previousGap = gap; - } - return segment([src, bend]); + // 소단 셈은 수평공간 끝에서 새로(91-4) + const cut = structureCutPoints( + bend, + outward, + berm.cut, + groundAt, + (limitOffset - bend.offset) * outward, + ); + const head = berm.lengthM > 1e-9 ? [src, bend] : [src]; + return segment(cut ? [...head, ...cut.slice(1)] : head.length > 1 ? head : [src, bend]); } export interface OutletExtrasResult { diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 7c3b5bbd9..ebb01bf9c 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -56,6 +56,7 @@ import { inletChoiceAvailability, } from "./B06_Section_UI_Cross_Culvert_Basin"; import { inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra"; +import { withBackCut } from "./B06_Section_UI_Cross_Structure_Cut"; import { buildCulvertTiers, drawnFillPoints, @@ -335,7 +336,7 @@ export function computeCulvertLayout( diameterM: diameter, edge: spec.role === "inlet" ? inletInfo.edge : outletInfo.edge, groundAt, - cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0, + cutDesign: section.design, adjust: basinAdjust, heightM: basinHeightM, }); @@ -615,7 +616,7 @@ export function computeCulvertLayout( const slopeOf = (wall: WallLayout): FillSlopeSegment => fillSlopeOf(wall, wall.role === "inlet" ? inletInfo.edge : outletInfo.edge); for (const wall of walls) { - const slope = slopeOf(wall); + const slope = withBackCut(slopeOf(wall), wall, section.design, groundAt, !culvert.hidden_pipe); // 사면선은 노견부터 우리가 그린다 — 트림 경계를 노견까지 당겨 백엔드 설계선의 // 성토 구간을 통째로 덮는다(설계선은 1:1.2 고정이라 그대로 두면 다시 어긋난다). if (wall.outward > 0) { diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts index 6396cc06f..37815717a 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts @@ -16,6 +16,7 @@ import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra"; import { fillWallBaseWidth } from "./B06_Section_UI_Cross_Culvert_Solve"; import type { OffsetPoint, WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types"; import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean"; +import { structureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut"; // d의 0점 = **성토선 길이 0 지점**(벽 상단 = 노견 — 2026-08-23 사용자 확정). // 그 자리(하단선 중점)는 이음선 상단점이 노견 끝에 오는 offset이고, 기본 자리는 @@ -89,7 +90,7 @@ export function buildCulvertTiers(input: { culvert?.body ? { lengthM: Math.max(spec?.revet_berm_m ?? GUARD_CUT_BERM_M, 0), - slopeRatio: section.design?.cut_slope_ratio ?? 1.0, + cut: structureCutSpec(section.design), } : undefined; // 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로). diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 322748453..98bd07199 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -112,8 +112,8 @@ export interface BasinLayout { * "cut" = 절토측 유입(성토사면 없음), "short" = 성토사면 3m 이하, * "manual" = 규칙상 기슭막이 자리지만 사용자가 집수정을 선택. */ reason: "cut" | "short" | "manual"; - /** 집수정이 원지반 안으로 들어갈 때의 절토선(구조물 바깥 끝 → 지반 교차점). */ - cutLine: { from: OffsetPoint; to: OffsetPoint } | null; + /** 집수정이 원지반 안으로 들어갈 때의 절토선 점열(구조물 바깥 끝 → 복합경사 → 지반 교차점 · 91-4). */ + cutLine: OffsetPoint[] | null; /** 구조물이 원지반 밖으로 나올 때의 성토(되메움)선 — 경사 5도(임시), 지반 교차점까지. */ fillLine: { from: OffsetPoint; to: OffsetPoint } | null; } diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index f21cc38ca..6ea9ed38d 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -138,7 +138,7 @@ export function appendFordOverlay( ); } if (side.cutLine) { - planLine([side.cutLine.from, side.cutLine.to], `${role} 측벽 절토선(계획선)`); + planLine(side.cutLine, `${role} 측벽 절토선(계획선)`); } } diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts index d40326c05..c33ac5554 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts @@ -148,7 +148,6 @@ export function computeFordLayout( const uphill = section.uphill_side ?? "left"; const inletOnLeft = uphill === "left"; const roadTopAt = roadTopFactory(section, edges.left, edges.right); - const cutSlopeRatio = section.design?.cut_slope_ratio ?? 1.0; // 바닥판이 놓일 계류 하상 — 두 노견 사이 원지반 최저점. const bedElevation = (() => { @@ -182,7 +181,8 @@ export function computeFordLayout( diameterM: ford.diameter_m, edge: { offset_m: edge.offset_m, elevation_m: roadTopAt(edge.offset_m) }, groundAt, - cutSlopeRatio, + // 측벽 끝 절토 = 본선 절토 사면과 같은 복합경사(91-4) + cutDesign: section.design, // 내공 폭 자리에 날개벽 투영 연장을 넣는다 — 바닥판이 그만큼 계류측으로 뻗는다. adjust: { innerWidthM: Math.max(wing.slab_extend_m, 0), diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Cut.ts b/B06_Section/B06_Section_UI_Cross_Structure_Cut.ts new file mode 100644 index 000000000..d24ec6819 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Structure_Cut.ts @@ -0,0 +1,107 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Structure_Cut.ts + * 구조물 끝 → 원지반 **절토선 점열**(91-4) — 본선 절토 사면과 같은 복합경사. + * + * 꼭짓점은 `common_util_cross_berm.cutProfilePoints` 그대로(암 → 토사 무릎 · 소단) · 경계 = 원지반 + + * `rock_boundary_offset_m` · 켬 = 암 프리셋 + `two_stage_slope` · 소단 = `design.berm` · 소단 셈은 + * 구조물 끝에서 새로 · 원지반 교점에서 자름. + * 쓰는 곳 = 흙막이 수평공간 뒤 · 박스 끝 · 세월교 측벽 끝 · ㄴ·ㄷ 집수정 끝 · 절토 쪽 배관 유출 벽 뒤. + * ========================================================================== */ + +import { bermSpec, cutProfilePoints } from "../common_util/common_util_cross_berm"; +import type { BermSpec } from "../common_util/common_util_cross_berm"; +import type { CrossDesign } from "./B06_Section_Api_Fetch"; +import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; + +/** 절토 제원 — 암(또는 단일) 경사 · 토사 경사 · 암 경계 오프셋(없으면 경사 하나) · 소단. */ +export interface StructureCutSpec { + cutRatio: number; + soilRatio: number; + rockOffsetM: number | null; + berm: BermSpec | null; +} + +/** 측점 설계값에서 절토 제원 — 본선 `computeCrossDesign` 과 같은 켬 조건. */ +export function structureCutSpec(design?: Partial | null): StructureCutSpec { + const cutRatio = Math.max(design?.cut_slope_ratio ?? 1.0, 0.1); + const offset = design?.rock_boundary_offset_m; + const twoStage = + design?.geometry_preset === "rock" && design.two_stage_slope === true && offset != null; + const berm = design?.berm; + return { + cutRatio, + soilRatio: Math.max(design?.soil_cut_slope_ratio ?? cutRatio, 0.1), + rockOffsetM: twoStage ? offset : null, + berm: berm ? bermSpec(berm.width_m, berm.interval_m, berm.slope_deg) : null, + }; +} + +/** 원지반 교점 찾는 걸음(m). */ +const STEP_M = 0.05; + +/** + * `from`(원지반 아래) → 바깥(`outward`)으로 절토 꼭짓점 · 마지막 점 = 원지반 교점. + * `reachM` 안에서 못 만나면 null. + */ +export function structureCutPoints( + from: OffsetPoint, + outward: number, + spec: StructureCutSpec, + groundAt: (offset: number) => number, + reachM = 50, +): OffsetPoint[] | null { + const at = (dist: number): number => from.offset + outward * dist; + const rockOffsetM = spec.rockOffsetM; + const boundary = rockOffsetM === null ? null : (dist: number) => groundAt(at(dist)) + rockOffsetM; + const profile = cutProfilePoints( + 0, + from.elevation, + spec.cutRatio, + spec.soilRatio, + boundary, + spec.berm, + Math.max(reachM, STEP_M), + // 소단이 있으면 경계를 오갈 때마다 꺾음 — 본선 `cutPoints` 와 같음. + spec.berm !== null, + ); + const points: OffsetPoint[] = [from]; + for (let i = 1; i < profile.length; i += 1) { + const [d0, z0] = profile[i - 1]; + const [d1, z1] = profile[i]; + const steps = Math.max(1, Math.ceil((d1 - d0) / STEP_M)); + let previousGap = groundAt(at(d0)) - z0; + for (let k = 1; k <= steps; k += 1) { + const t = k / steps; + const gap = groundAt(at(d0 + (d1 - d0) * t)) - (z0 + (z1 - z0) * t); + if (gap <= 0) { + // 걸음 사이 교점 보간 — 선이 지반을 넘지 않게 + const share = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1; + const tc = (k - 1 + share) / steps; + points.push({ offset: at(d0 + (d1 - d0) * tc), elevation: z0 + (z1 - z0) * tc }); + return points; + } + previousGap = gap; + } + points.push({ offset: at(d1), elevation: z1 }); + } + return null; +} + +/** + * 배관 유출 벽이 절토 쪽(벽 바깥 윗모서리가 원지반 아래)이면 그 모서리부터 절토선을 사면 점열 뒤에 + * 이음 — 트림이 노견부터 설계선을 지워 절토사면이 사라지던 자리(91-4). 관 숨김(세월교 · 박스 흙막이 · + * 독립 기슭막이)은 `enabled` 거짓. + */ +export function withBackCut( + slope: T, + wall: { role: string; outward: number; points: OffsetPoint[] }, + design: Partial | null | undefined, + groundAt: (offset: number) => number, + enabled: boolean, +): T { + const corner = wall.points[2]; + if (!enabled || wall.role !== "outlet" || !corner) return slope; + if (groundAt(corner.offset) <= corner.elevation + 0.05) return slope; + const cut = structureCutPoints(corner, wall.outward, structureCutSpec(design), groundAt); + return cut ? { ...slope, points: [...slope.points, ...cut] } : slope; +} diff --git a/resources/tester/helper_91_ground_free.cjs b/resources/tester/helper_91_ground_free.cjs index 5aed020f1..47eb605a6 100644 --- a/resources/tester/helper_91_ground_free.cjs +++ b/resources/tester/helper_91_ground_free.cjs @@ -127,4 +127,65 @@ out.lean = ["left_cut", "right_cut", "both_fill", "both_cut"].map((mode) => [ leanOf({ form: "돌쌓기(찰)", height_m: 1.5, section_mode: mode, side: "좌" }), leanOf({ form: "돌쌓기(찰)", height_m: 1.5, section_mode: mode, side: "좌", face_role: "절토" }), ]); +// ③ 91-4 유출측 절토 복합경사 — 꼭짓점 사이 경사(수평/수직) · 사면 길이 · 끝 = 원지반 교점 +const { groundInterpolator } = loadTs(B06("B06_Section_UI_Cross_Culvert_Solve.ts")); +function profile(points, samples) { + const groundAt = groundInterpolator(samples); + const legs = points.slice(1).map((p, i) => { + const dx = Math.abs(p.offset - points[i].offset); + const dz = p.elevation - points[i].elevation; + return { ratio: dz > 1e-9 ? r3(dx / dz) : null, slant: r3(Math.hypot(dx, dz)), run: r3(dx) }; + }); + const end = points[points.length - 1]; + return { legs, endGap: r3(Math.abs(groundAt(end.offset) - end.elevation)) }; +} +const withBerm = (s) => { + s.design.berm = { width_m: 0.5, interval_m: 3, slope_deg: 0 }; +}; +// 흙막이 수평공간 뒤(박스 352.14 · 세월교 173.09) +function guardTails(base, edit = () => undefined) { + const s = clone(base); + edit(s); + const { culvert } = layoutsOf(s, base); + return [...culvert.outletFill.segments, ...culvert.basinFill.segments] + .filter((g) => g.kind === "cut") + .map((g) => profile(g.points, s.samples)); +} +out.guardTails = [...guardTails(at(352.14)), ...guardTails(FORD)]; +// 박스 끝 · 세월교 측벽 끝(흙막이 없음) +const { computeBoxLayout } = loadTs(B06("B06_Section_UI_Cross_Box_Geom.ts")); +const { computeFordLayout } = loadTs(B06("B06_Section_UI_Cross_Ford_Geom.ts")); +function endCuts(base, compute) { + const s = clone(base); + delete s.culvert; + s.uphill_side = "left"; // 절토 쪽(우측)을 유출로 + return compute(s, s.samples) + .sides.filter((side) => side.cutLine) + .map((side) => profile(side.cutLine, s.samples)); +} +out.boxEnd = endCuts(at(352.14), computeBoxLayout); +out.fordEnd = endCuts(FORD, computeFordLayout); +// 절토 쪽 배관 유출 벽 뒤(관 340 · 유출 = 우측 절토) · 소단 있음/없음 +function cutOutlet(edit = () => undefined) { + const s = clone(at(340)); + s.uphill_side = "left"; + s.culvert = { + type: "pipe", + chainage_m: 340, + pipe_kind: "흄관", + diameter_m: 1.0, + min_cover_m: 0.5, + inlet: { role: "inlet", structure: "기슭막이", revet_form: "돌쌓기(찰)" }, + outlet: { role: "outlet", structure: "기슭막이", revet_form: "돌쌓기(찰)" }, + }; + s.design.inlet_structure = "revet"; + edit(s); + const layout = computeCulvertLayout(s, s.samples, undefined, "revet"); + const wall = layout.walls.find((w) => w.role === "outlet"); + const pts = layout.designTrim.minSlope.points; + const from = pts.findIndex((p) => Math.abs(p.offset - wall.points[2].offset) < 1e-9); + return profile(pts.slice(from), s.samples); +} +out.cutOutlet = cutOutlet(); +out.cutOutletBerm = cutOutlet(withBerm); process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_79_guard_shape.py b/resources/tester/test_79_guard_shape.py index f33cbcafe..22607986f 100644 --- a/resources/tester/test_79_guard_shape.py +++ b/resources/tester/test_79_guard_shape.py @@ -80,7 +80,8 @@ def test_흙막이_끝_절토는_수평공간_뒤(shape: dict) -> None: tails = [tail for group in shape["cutDefault"] for tail in group] assert tails, "절토 끝선이 없음" for tail in tails: - assert tail["points"] == 3 + # 시작 · 수평공간 끝 · (암 무릎 · 소단) · 원지반 교점 — 본선 절토와 같은 복합경사(91-4) + assert tail["points"] >= 3 assert tail["flat"] == pytest.approx(0, abs=1e-6) assert tail["berm"] == pytest.approx(1.0, abs=1e-3) for tail in shape["cutBerm2"]: diff --git a/resources/tester/test_91_ground_free.py b/resources/tester/test_91_ground_free.py index 7fc4cdf36..09e1c5de4 100644 --- a/resources/tester/test_91_ground_free.py +++ b/resources/tester/test_91_ground_free.py @@ -83,3 +83,34 @@ def test_지형에_막힘_잔재_없음() -> None: source = (ROOT / "B06_Section" / name).read_text(encoding="utf-8") assert "shiftBlockedM" not in source and "지형에 막힘" not in source, name assert "noticeExtraLimit" not in source and "noticeShortfall" not in source, name + + +def _knee(cut: dict) -> bool: + """암(1:0.4) 뒤 토사(1:1.0) 무릎이 있음 — 픽스처 = 리핑암 · 2단계 · 경계 −0.5 m.""" + ratios = [leg["ratio"] for leg in cut["legs"] if leg["ratio"] is not None] + return any(a == 0.4 and b == 1.0 for a, b in zip(ratios, ratios[1:])) + + +def test_흙막이_수평공간_뒤_절토_복합경사(fit: dict) -> None: + tails = fit["guardTails"] + assert tails and all(cut["endGap"] < 0.01 for cut in tails) + assert all(cut["legs"][0]["ratio"] is None for cut in tails) # 수평공간 먼저 + assert sum(_knee(cut) for cut in tails) >= 2 + + +def test_박스_끝_세월교_측벽_끝_절토_복합경사(fit: dict) -> None: + for cuts in (fit["boxEnd"], fit["fordEnd"]): + assert cuts and all(_knee(cut) and cut["endGap"] < 0.01 for cut in cuts) + + +def test_절토_쪽_배관_유출_벽_뒤_절토사면_살아남(fit: dict) -> None: + cut = fit["cutOutlet"] + assert _knee(cut) and cut["endGap"] < 0.01 + + +def test_소단은_구조물_끝에서_새로_셈(fit: dict) -> None: + legs = fit["cutOutletBerm"]["legs"] + flat = next(i for i, leg in enumerate(legs) if leg["ratio"] is None) + assert sum(leg["slant"] for leg in legs[:flat]) == pytest.approx(3.0, abs=1e-3) + assert legs[flat]["run"] == pytest.approx(0.5, abs=1e-3) + assert fit["cutOutletBerm"]["endGap"] < 0.01 From de2c03cfc7af1da694b1cd1eeca21fa7b3e2534b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:21:32 +0900 Subject: [PATCH 34/47] =?UTF-8?q?feat(B07):=20CAD=20=EC=88=98=ED=99=95=20?= =?UTF-8?q?=E2=80=94=20pattern=20=EC=B1=84=EC=9B=80=20=EB=AC=B4=EB=8A=AC?= =?UTF-8?q?=EB=A5=BC=20=EB=8B=A4=EA=B0=81=ED=98=95=20=EC=95=88=20=EC=84=A0?= =?UTF-8?q?=20=C2=B7=20=EC=9B=90=EC=9C=BC=EB=A1=9C=20=ED=8E=B4=EC=84=9C=20?= =?UTF-8?q?=EB=8F=84=EB=A9=B4=EC=97=90(88-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../B07_DesignDetail_Cross_Overlay.ts | 117 ++++++++++++++++++ resources/tester/test_cad_pattern_harvest.mjs | 110 ++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 resources/tester/test_cad_pattern_harvest.mjs diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts index ff43a5cff..10fba7455 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts @@ -317,6 +317,108 @@ function rectPoints(element: SVGElement): XY[] { }); } +/** pattern 한 칸의 도형(SVG 좌표) — 선(path M/L) · 원/타원. 바탕 rect 는 그림이 아니라 뺌. */ +export interface PatternTile { + width: number; + height: number; + paths: XY[][]; + rings: Array<{ cx: number; cy: number; rx: number; ry: number }>; +} + +/** 무늬 칸 수 상한 — 넘으면 무늬를 버리고 외곽선만(ponytail: 큰 다각형이면 칸 크기를 키울 것). */ +const PATTERN_TILE_LIMIT = 20000; + +/** + * pattern 채움을 다각형(도면 좌표) 안으로 펴서 자른다(88-4) — CAD 에는 무늬 채움이 없다. + * 칸은 SVG 사용자 좌표 원점 기준(`patternUnits="userSpaceOnUse"`). 선은 이어 붙여 한 줄로, + * 다각형 안에 온전히 든 원은 원 그대로 · 걸친 원/타원은 잘린 폴리선으로. + */ +export function patternMarks( + polygon: XY[], + tile: PatternTile, +): { polylines: XY[][]; circles: Array<{ center: XY; radius: number }> } { + const polylines: XY[][] = []; + const circles: Array<{ center: XY; radius: number }> = []; + const { width, height } = tile; + if (polygon.length < 3 || !(width > 0) || !(height > 0)) return { polylines, circles }; + const xs = polygon.map((point) => point[0]); + const ys = polygon.map((point) => -point[1]); + const [i0, i1] = [Math.floor(Math.min(...xs) / width), Math.floor(Math.max(...xs) / width)]; + const [j0, j1] = [Math.floor(Math.min(...ys) / height), Math.floor(Math.max(...ys) / height)]; + if ((i1 - i0 + 1) * (j1 - j0 + 1) > PATTERN_TILE_LIMIT) return { polylines, circles }; + const lines: XY[][] = []; + const arcs: XY[][] = []; + for (let i = i0; i <= i1; i += 1) { + for (let j = j0; j <= j1; j += 1) { + const [dx, dy] = [i * width, j * height]; + for (const path of tile.paths) { + lines.push( + ...clipPointsToPolygon( + path.map(([x, y]) => flip(x + dx, y + dy)), + polygon, + ), + ); + } + for (const { cx, cy, rx, ry } of tile.rings) { + const center = flip(cx + dx, cy + dy); + const ring: XY[] = circlePoints([0, 0], 1).map(([x, y]) => [ + center[0] + x * rx, + center[1] + y * ry, + ]); + if (rx === ry && ring.every((point) => pointInPolygon(point, polygon))) { + circles.push({ center, radius: rx }); + } else { + arcs.push(...clipPointsToPolygon(ring, polygon)); + } + } + } + } + polylines.push(...chain(lines).map((points) => simplify(points)), ...chain(arcs)); + return { polylines, circles }; +} + +/** 다각형 채움이 가리키는 pattern 을 칸 도형으로 읽는다(채움 = fill 속성 또는 style). 없으면 null. */ +function patternTileOf(element: SVGElement, root: SVGElement): PatternTile | null { + const fill = element.getAttribute("fill") ?? String(element.style?.fill ?? ""); + const id = /url\(["']?#([^"')]+)/.exec(fill)?.[1]; + if (!id) return null; + const pattern = Array.from(root.querySelectorAll("pattern")).find( + (node) => node.getAttribute("id") === id, + ); + if (!pattern) return null; + const tile: PatternTile = { + width: attr(pattern, "width"), + height: attr(pattern, "height"), + paths: [], + rings: [], + }; + for (const child of Array.from(pattern.children) as SVGElement[]) { + const tag = child.tagName.toLowerCase(); + if (tag === "path") { + // M/L 절대 좌표만(무늬 빗금 꼴) — M 마다 새 줄 + for (const run of (child.getAttribute("d") ?? "").split(/(?=M)/i)) { + const numbers = run.match(/-?[\d.]+/g)?.map(Number) ?? []; + const points: XY[] = []; + for (let index = 0; index + 1 < numbers.length; index += 2) { + points.push([numbers[index], numbers[index + 1]]); + } + if (points.length > 1) tile.paths.push(points); + } + } else if (tag === "circle") { + const r = attr(child, "r"); + tile.rings.push({ cx: attr(child, "cx"), cy: attr(child, "cy"), rx: r, ry: r }); + } else if (tag === "ellipse") { + tile.rings.push({ + cx: attr(child, "cx"), + cy: attr(child, "cy"), + rx: attr(child, "rx"), + ry: attr(child, "ry"), + }); + } + } + return tile; +} + /** 오프스크린 SVG에 그려진 도형을 CAD 엔티티로 옮긴다 (블록 테두리 안으로 자른다). */ function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] { const [fx0, , fx1] = frame; @@ -337,6 +439,21 @@ function harvest(root: SVGElement, style: Style, frame: number[]): Entity[] { const poly = polyEntity(style, simplify(clipX(run, fx0, fx1))); if (poly) entities.push(poly); } + // 무늬 채움(노면공 · 물넘이포장) — 무늬 선 · 원을 다각형 안으로 펴서 같은 층 · 색으로(88-4) + const tile = tag === "polygon" && !clip ? patternTileOf(element, root) : null; + if (tile) { + const marks = patternMarks(points, tile); + for (const run of marks.polylines) { + const poly = polyEntity(style, clipX(run, fx0, fx1)); + if (poly) entities.push(poly); + } + for (const { center, radius } of marks.circles) { + if (!inside(center[0])) continue; + entities.push( + baseEntity(style, "Circle", { center: { x: center[0], y: center[1] }, radius }), + ); + } + } } else if (tag === "line") { const start = flip(attr(element, "x1"), attr(element, "y1")); const end = flip(attr(element, "x2"), attr(element, "y2")); diff --git a/resources/tester/test_cad_pattern_harvest.mjs b/resources/tester/test_cad_pattern_harvest.mjs new file mode 100644 index 000000000..3e3506ea2 --- /dev/null +++ b/resources/tester/test_cad_pattern_harvest.mjs @@ -0,0 +1,110 @@ +// B07 CAD 수확 — pattern 채움(노면공 무늬 셋 · 물넘이 빗금)을 다각형 안 선 · 원으로 펴기(88-4). +// 실제 코드(`B07_DesignDetail_Cross_Overlay.patternMarks`)를 vite ssr 로 묶어 돌린다. +// 실행: node resources/tester/test_cad_pattern_harvest.mjs +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const ROOT = resolve(import.meta.dirname, "../.."); +const out = mkdtempSync(join(tmpdir(), "cad-pattern-")); +execFileSync( + "node", + [ + "./config/node_modules/vite/bin/vite.js", + "build", + "--configLoader", + "runner", + "--ssr", + "../B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts", + "--outDir", + out, + "--logLevel", + "error", + ], + { cwd: ROOT, stdio: "pipe" }, +); +const { patternMarks } = await import( + pathToFileURL(join(out, "B07_DesignDetail_Cross_Overlay.js")).href +); + +// 도면 좌표(y 위로) 다각형 — SVG y 100~103(두께 3) · x 10~70. 닫힌 점열. +const polygon = [ + [10, -100], + [70, -100], + [70, -103], + [10, -103], + [10, -100], +]; +const inPoly = ([x, y]) => x >= 10 - 1e-6 && x <= 70 + 1e-6 && y <= -100 + 1e-6 && y >= -103 - 1e-6; +const allPoints = (marks) => [ + ...marks.polylines.flat(), + ...marks.circles.flatMap(({ center: [cx, cy], radius }) => [ + [cx - radius, cy], + [cx + radius, cy], + [cx, cy - radius], + [cx, cy + radius], + ]), +]; + +// ① 콘크리트 · 물넘이 빗금(M0,6 L6,0 · 칸 6) — 대각선이 칸을 넘어 이어져 한 줄씩 · 모두 다각형 안 +{ + const marks = patternMarks(polygon, { + width: 6, + height: 6, + paths: [ + [ + [0, 6], + [6, 0], + ], + ], + rings: [], + }); + assert.equal(marks.circles.length, 0); + // x+y = 6k 인 대각선 중 띠(y 100~103)를 지나는 것: x+y ∈ (110, 173) → k = 19..28 = 10 줄 + assert.equal(marks.polylines.length, 10, `빗금 줄 수 ${marks.polylines.length}`); + for (const run of marks.polylines) assert.equal(run.length, 2, "한 줄 = 두 점(이어 붙임)"); + assert.ok(allPoints(marks).every(inPoly), "빗금이 다각형 밖으로 나감"); +} + +// ② 아스콘 점(원 r 0.7 · 칸 5) — 온전히 든 원은 원 그대로 +{ + const marks = patternMarks(polygon, { + width: 5, + height: 5, + paths: [], + rings: [{ cx: 1.5, cy: 1.5, rx: 0.7, ry: 0.7 }], + }); + // 중심 y = 101.5 (칸 j=20) · x = 11.5, 16.5 … 66.5 = 12 개 — 모두 띠 안 + assert.equal(marks.circles.length, 12, `점 수 ${marks.circles.length}`); + assert.equal(marks.polylines.length, 0); + assert.ok(allPoints(marks).every(inPoly), "점이 다각형 밖으로 나감"); +} + +// ③ 쇄석 돌(원 r 1.6 + 타원 · 칸 9) — 걸친 것은 잘린 폴리선 · 밖으로 안 나감 +{ + const marks = patternMarks(polygon, { + width: 9, + height: 9, + paths: [], + rings: [ + { cx: 2.5, cy: 2.5, rx: 1.6, ry: 1.6 }, + { cx: 6.8, cy: 6.5, rx: 1.8, ry: 1.2 }, + ], + }); + assert.ok(marks.polylines.length + marks.circles.length > 0, "돌 무늬 없음"); + assert.ok(allPoints(marks).every(inPoly), "돌이 다각형 밖으로 나감"); +} + +// ④ 빈 칸 · 다각형 없음 = 무늬 없음 +{ + assert.deepEqual(patternMarks(polygon, { width: 0, height: 6, paths: [], rings: [] }), { + polylines: [], + circles: [], + }); + assert.equal(patternMarks([], { width: 6, height: 6, paths: [], rings: [] }).polylines.length, 0); +} + +console.log("OK test_cad_pattern_harvest.mjs — 4건"); From 43f0f6276f829822563659c97b57337de6cfa21b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:22:53 +0900 Subject: [PATCH 35/47] =?UTF-8?q?feat(B06):=20=EB=8F=84=EC=88=98=EB=A1=9C?= =?UTF-8?q?=20=C2=B7=20=EB=8F=8C=EB=B6=99=EC=9E=84=20=EB=91=90=EA=BB=98=20?= =?UTF-8?q?=EC=B9=B8(cm=20=C2=B7=20=EA=B8=B0=EB=B3=B8=2030)=20=C2=B7=20?= =?UTF-8?q?=EC=9C=A0=EC=B6=9C=20=EC=84=B1=ED=86=A0=EC=82=AC=EB=A9=B4=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=20=EB=9D=A0=20+=20=EB=8F=8C=20=C2=B7=20?= =?UTF-8?q?=EC=84=B8=ED=8A=B8=20=EC=A7=9D=20=C2=B7=20=EC=98=9B=20apron=20?= =?UTF-8?q?=EC=9E=94=EC=9E=AC=20=EC=A7=80=EC=9B=80(90-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../design/design_structure_ford_pavement.ts | 2 + A00_Common/design/design_structure_guard.ts | 26 +++-- B06_Section/B06_Section_Api_Types.ts | 13 ++- B06_Section/B06_Section_Engine_Culvert.py | 53 +++++---- B06_Section/B06_Section_UI_Cross_Box.ts | 12 +++ B06_Section/B06_Section_UI_Cross_Culvert.ts | 12 ++- .../B06_Section_UI_Cross_Culvert_Const.ts | 3 - B06_Section/B06_Section_UI_Cross_Ford.ts | 12 +++ .../B06_Section_UI_Cross_Wall_Hatch.ts | 102 +++++++++++++++++- .../B06_Section_UI_Style_Cross_Areas.css | 2 +- common_util/common_util_culvert_sets.ts | 100 ++++++++++------- resources/master_template/구조물정의.json | 9 +- resources/tester/helper_90_chute_band.cjs | 84 +++++++++++++++ .../test_52_3_structure_spec_ford_pavement.py | 3 +- resources/tester/test_65_ford_defaults.py | 3 +- resources/tester/test_90_chute_band.py | 59 ++++++++++ resources/tester/test_b06_culvert_set.py | 26 +++-- .../tester/test_b06_culvert_sets_mirror.py | 11 ++ 18 files changed, 434 insertions(+), 98 deletions(-) create mode 100644 resources/tester/helper_90_chute_band.cjs create mode 100644 resources/tester/test_90_chute_band.py diff --git a/A00_Common/design/design_structure_ford_pavement.ts b/A00_Common/design/design_structure_ford_pavement.ts index 582b1bd33..d9ccc19d2 100644 --- a/A00_Common/design/design_structure_ford_pavement.ts +++ b/A00_Common/design/design_structure_ford_pavement.ts @@ -32,6 +32,7 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = { prefix: "ford_chute", link: "gfordpavechute", max: 1, + // 구조 · 두께(cm · 새 상자 30) — 배관 · 박스 · 세월교 `CHUTE_ITEMS` 와 같은 칸(90-2) items: [ { id: "structure", @@ -42,6 +43,7 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = { { value: "돌붙임", label: "돌붙임" }, ], }, + { id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "30" }, ], }, ], diff --git a/A00_Common/design/design_structure_guard.ts b/A00_Common/design/design_structure_guard.ts index 3d4ad1c45..721bbce37 100644 --- a/A00_Common/design/design_structure_guard.ts +++ b/A00_Common/design/design_structure_guard.ts @@ -110,6 +110,20 @@ export const SOIL_ADD: SpecContainer = { items: guardItems(), }; +/** 도수로/돌붙임 칸 — 구조 · 두께(cm · 새 상자 30 · 90-2 사용자 확정) · 물넘이 포장(`ford_chute`)도 같은 칸. */ +export const CHUTE_ITEMS: SpecItem[] = [ + { + id: "structure", + label: "구조", + kind: "select", + choices: [ + { value: "도수로", label: "도수로" }, + { value: "돌붙임", label: "돌붙임" }, + ], + }, + { id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "30" }, +]; + /** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */ export const CHUTE_ADD: SpecContainer = { id: "chute_add", @@ -118,15 +132,5 @@ export const CHUTE_ADD: SpecContainer = { prefix: "chute_add", link: "gchuteadd", max: 1, - items: [ - { - id: "structure", - label: "구조", - kind: "select", - choices: [ - { value: "도수로", label: "도수로" }, - { value: "돌붙임", label: "돌붙임" }, - ], - }, - ], + items: CHUTE_ITEMS, }; diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index ae320539e..7ec2811bf 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -219,10 +219,12 @@ export interface CulvertSideSpec { face_side?: string | null; face_role?: string | null; face_slope_ratio?: number | string | null; - /** 보호공(물받이) 길이 = 낙차고 × 2 (사방교본 교차 참조, 2026-08-19 사용자 확정). */ - apron_length_m?: number; - /** 보호공 두께 1.0m 내외 (사방교본 교차 참조). */ - apron_thickness_m?: number; +} + +/** 도수로 · 돌붙임(90-2) — 유출 성토사면을 따라 사면 안쪽으로 두께만큼 띠 · 안에 돌(그림만 · 면적 무관). */ +export interface ChuteSpec { + structure: string; + thickness_m: number; } /** 배수관 측점의 세트(배관·기슭막이·보호공) 제원 — 횡단 카드 오버레이 입력. */ @@ -248,6 +250,7 @@ export interface CulvertSet { foundation?: string | null; /** 세월교 · 박스 흙막이(73-4) — 자리 기준 본체. 있으면 벽 자동 자리 = 관(세월교) · 본체(박스) 끝. */ body?: "box" | "ford"; + chute?: ChuteSpec; } /** 세월교 날개벽 한쪽 — 횡단면엔 안 보이고 바닥판 연장량만 넘긴다. */ @@ -279,6 +282,7 @@ export interface FordSet { wing_out: FordWingSpec; /** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */ defaulted?: string[]; + chute?: ChuteSpec; } /** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */ @@ -299,6 +303,7 @@ export interface BoxSet { wing_out: FordWingSpec; /** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */ defaulted?: string[]; + chute?: ChuteSpec; } export interface CrossSection extends SectionStation { diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index ab28ec192..788e9b5e8 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -24,6 +24,7 @@ from __future__ import annotations import json import logging import math +import re from functools import lru_cache from pathlib import Path from typing import Any @@ -63,12 +64,8 @@ _SPAN_LINKED_TYPES: frozenset[str] = frozenset({"ford_pavement"}) # ⚠ 교차 참조 ① 관 위 최소 토피(m). 임도 배수관 토피 규정이 지식DB에 없어 별표2 # 교량·암거 "복토 시 흙 두께 50㎝ 이상"을 끌어왔다. B05 계획고 하향 차단 기준이다. MIN_PIPE_COVER_M = 0.5 -# ⚠ 교차 참조 ② 보호공(물받이) 길이 = 낙차고(기슭막이 높이) × 배수. -# 사방기술교본 바닥막이 물받이 "길이 = 낙차고의 2~3배" 중 2배 채택. -APRON_LENGTH_FACTOR = 2.0 -# 보호공 두께(m) — 사용자 확정 0.45 (2026-08-20, 실무 돌붙임 L3=45와 정합). -# 사방교본 물받이 "두께 1m 내외"는 바닥막이용 교차 참조라 채택하지 않았다. -APRON_THICKNESS_M = 0.45 +# 도수로 · 돌붙임 두께 빈 값(cm) — 사용자 확정 30 cm(10-8 · 90-2). +CHUTE_THICKNESS_CM = 30.0 # 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3) = 사방교본 계획비탈 # 1:0.3~0.5의 급한 쪽. 지식DB 근거가 있는 값이라 교차 참조 표시가 붙지 않는다. @@ -226,15 +223,36 @@ def _side_spec( "face_slope": REVET_FACE_SLOPE, } ) - # 보호공은 기슭막이 바닥의 세굴 방지 구조 — 낙차고(기슭막이 높이)에 종속한다. - if height is not None and height > 0: - spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3) - spec["apron_thickness_m"] = APRON_THICKNESS_M + return spec + + +def _chute(values: dict[str, Any]) -> dict[str, Any] | None: + """도수로 · 돌붙임(90-2) — 첫 상자(`chute_add_{n}_*`) 구조 · 두께(cm → m · 빈 값 30 cm) · + 구조를 안 고르면 None. 횡단이 유출 성토사면을 따라 띠로 그림. TS 짝 `chute`.""" + head = re.compile(r"^chute_add_(\d+)_structure$") + picked = sorted( + (int(hit.group(1)), str(value).strip()) + for key, value in values.items() + if (hit := head.match(key)) and str(value or "").strip() + ) + if not picked: + return None + n, structure = picked[0] + cm = _number(values.get(f"chute_add_{n}_thickness"), None) + cm = cm if cm and cm > 0 else CHUTE_THICKNESS_CM + return {"structure": structure, "thickness_m": round(cm / 100.0, 3)} + + +def _with_chute(spec: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]: + """도수로 · 돌붙임이 있으면 세트에 `chute` 로 실음(90-2).""" + chute = _chute(values) + if chute: + spec["chute"] = chute return spec def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]: - """관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 보호공).""" + """관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 도수로 · 돌붙임).""" defaults = _registry_pipe_defaults() values = dict(options or {}) diameter_mm = _number( @@ -242,7 +260,7 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]: ) kind = values.get("pipe_kind") diameter_m = round((diameter_mm or 1000.0) / 1000.0, 3) - return { + spec = { "type": "pipe", "pipe_kind": str(kind) if kind else None, "diameter_m": diameter_m, @@ -253,6 +271,7 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]: "inlet": _side_spec(values, defaults, "inlet", diameter_m), "outlet": _side_spec(values, defaults, "outlet", diameter_m), } + return _with_chute(spec, values) def _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]: @@ -286,9 +305,6 @@ def _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]: "face_role": values.get("face_role"), "face_slope_ratio": values.get("face_slope_ratio"), } - if height is not None and height > 0: - spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3) - spec["apron_thickness_m"] = APRON_THICKNESS_M return spec @@ -334,7 +350,7 @@ def _guard_set(values: dict[str, Any], body: str, diameter_m: float) -> dict[str sides[role]["revet_berm_m"] = _number(values.get(f"{role}_revet_berm_m"), None) if not any(side["structure"] for side in sides.values()): return None - return { + guard = { "type": "pipe", "hidden_pipe": True, "body": body, @@ -346,6 +362,7 @@ def _guard_set(values: dict[str, Any], body: str, diameter_m: float) -> dict[str "min_cover_m": 0.0, **sides, } + return _with_chute(guard, values) # 흙막이 쪽 유출 띠는 흙막이 성토선을 따름(90-2) def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]: @@ -459,7 +476,7 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]: guard = _guard_set(values, "ford", spec["diameter_m"]) if guard: spec["guard"] = guard - return spec + return _with_chute(spec, values) def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]: @@ -536,7 +553,7 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]: guard = _guard_set(values, "box", box_body_height(spec)) if guard: spec["guard"] = guard - return spec + return _with_chute(spec, values) def box_body_height(box: dict[str, Any]) -> float: diff --git a/B06_Section/B06_Section_UI_Cross_Box.ts b/B06_Section/B06_Section_UI_Cross_Box.ts index c83a1a607..0eed5732a 100644 --- a/B06_Section/B06_Section_UI_Cross_Box.ts +++ b/B06_Section/B06_Section_UI_Cross_Box.ts @@ -11,6 +11,7 @@ import type { BoxLayout, BoxSideRole } from "./B06_Section_UI_Cross_Box_Geom"; import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; +import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch"; export { computeBoxLayout, @@ -127,6 +128,17 @@ export function appendBoxOverlay( polyline.append(title); layer.append(polyline); } + // 도수로 · 돌붙임 띠(90-2) — 유출 끝 ~ 원지반 성토부선 따라(흙막이 쪽은 배관 오버레이가 그림) + if (box.chute) + appendChuteBand( + layer, + layout.sides + .filter((side) => side.wingRole === "outlet") + .flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut").map((s) => s.points)), + box.chute, + x, + toDisplayY, + ); // ⑥ 끝 바깥 절 · 성토선 — 세월교 측벽과 같은 체계(79-7). 흙막이 쪽은 기하가 걷어 둠. for (const side of layout.sides) { const mark = side.role === "left" ? "좌" : "우"; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index e8e02a3c4..8e905c8c5 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -22,7 +22,7 @@ import type { WallLayout, } from "./B06_Section_UI_Cross_Culvert_Geom"; import { appendWallTrench, foundationChoice } from "./B06_Section_UI_Cross_Excavation"; -import { appendWallHatch } from "./B06_Section_UI_Cross_Wall_Hatch"; +import { appendChuteBand, appendWallHatch } from "./B06_Section_UI_Cross_Wall_Hatch"; // 기하 계산 진입점과 공개 상수·타입은 여기서 재수출한다 — B05(최소 토피)와 횡단 뷰가 // 이 모듈 경로로 이미 참조하고 있어 분리 후에도 import 경로를 바꾸지 않는다. @@ -101,6 +101,16 @@ export function appendCulvertOverlay( layer.append(line); }; const drawOutletFill = (): void => { + // 도수로 · 돌붙임 띠(90-2) — 유출 끝 ~ 원지반 성토 조각(단 사이 포함) · 연동 카드엔 안 그림 + // (B07 은 `linked` 없이 관 숨김으로만 알림 — 관 숨긴 배관 = 연동 카드) + if (culvert.chute && !linked && (!hidePipe || culvert.body)) + appendChuteBand( + layer, + layout.outletFill.segments.filter((s) => s.kind !== "cut").map((s) => s.points), + culvert.chute, + x, + toDisplayY, + ); for (const segment of layout.outletFill.segments) { // 유출부가 지면에 닿은 뒤의 절토선은 그리지 않는다(2026-08-24 사용자) — 벽이 // 이미 원지반에 묻힌 구간이라 도면에 더 보탤 정보가 없다. 면적·수량 계산은 diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index e4bad48ea..4baa279d9 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -20,9 +20,6 @@ export const REVET_THICKNESS_M = 0.45; * 못 가른 자리만 이 값. 돌쌓기 벽은 `B06_Section_UI_Cross_Lean.leanOf`(품셈 13-4-4 [주]⑪ 표준경사). */ export const REVET_LEAN_RATIO = 0.3; -/** 돌붙임 두께(m) — 실무 돌붙임 L3=45(울진 1공구 수량집계표). */ -export const PITCHING_THICKNESS_M = 0.45; - /** 사다리꼴(배면부) 상단 폭(m) — 띠 두께의 절반(2026-08-20 사용자 확정). */ export const REVET_TRAP_TOP_M = REVET_THICKNESS_M / 2; diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index c3c4217a3..e01993074 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -11,6 +11,7 @@ import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom"; import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; +import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch"; export { computeFordLayout, @@ -127,6 +128,17 @@ export function appendFordOverlay( layer.append(pipeOuter, pipeInner); const bodyShapes = [slab, pipeOuter, pipeInner]; + // 도수로 · 돌붙임 띠(90-2) — 유출 측벽 끝 ~ 원지반 성토부선 따라(흙막이 쪽은 배관 오버레이가 그림) + if (ford.chute) + appendChuteBand( + layer, + layout.sides + .filter((side) => side.role === "outlet") + .flatMap((side) => side.fillSegments.filter((s) => s.kind !== "cut").map((s) => s.points)), + ford.chute, + x, + toDisplayY, + ); // ④ 계류측 마감선 — 구조물이 원지반 위면 성토부선, 안이면 절토선(집수정과 같은 체계). for (const side of layout.sides) { const role = side.role === "inlet" ? "유입" : "유출"; diff --git a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts index 0449d5ff9..9f35159bf 100644 --- a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts +++ b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts @@ -44,7 +44,7 @@ export function hatchOfForm(form: string | null | undefined): WallHatch { } /** 띠 안의 국소 좌표 → 화면 px. u = 바닥(0)~상단(1), v = 배면(-0.5)~전면(+0.5). */ -interface BandFrame { +export interface BandFrame { point: (u: number, v: number) => [number, number]; /** 띠 축 기울기(도) — 사각형·원의 세로축을 벽 경사에 맞춘다. */ leanDegrees: number; @@ -64,7 +64,7 @@ function line(a: [number, number], b: [number, number]): SVGLineElement { } /** 띠 중심선 위 한 칸 — 회전 사각형(돌·격자 칸). */ -function cell( +export function cell( frame: BandFrame, u: number, v: number, @@ -87,14 +87,20 @@ function cell( } /** 켜 수 — 켜 높이가 목표치에 가장 가깝게, 최소 2켜. */ -function courses(spanM: number, targetM: number): number { +export function courses(spanM: number, targetM: number): number { return Math.max(2, Math.round(spanM / targetM)); } -function drawStones(group: SVGElement, frame: BandFrame, wet: boolean): void { +/** 돌 한 줄 — `thicknessM` = 띠 두께(벽 = 흙막이 띠 · 도수로/돌붙임 = 그 두께 · 90-2). */ +export function drawStones( + group: SVGElement, + frame: BandFrame, + wet: boolean, + thicknessM = REVET_THICKNESS_M, +): void { const count = courses(frame.spanM, 0.45); const courseM = frame.spanM / count; - const widthM = REVET_THICKNESS_M * (wet ? 0.76 : 0.9); + const widthM = thicknessM * (wet ? 0.76 : 0.9); const heightM = courseM * (wet ? 0.7 : 0.86); for (let i = 0; i < count; i += 1) { group.append(cell(frame, (i + 0.5) / count, 0, widthM, heightM, 0.3)); @@ -308,3 +314,89 @@ export function appendWallHatch( // 옹벽만 — 흙막이(찰쌓기 · 콘크리트 등)에는 없음(73-2). if (wall.retaining) drawDrains(group, wall, x, toDisplayY); } + +/** + * 도수로 · 돌붙임 띠(90-2) — 유출 성토사면 선(윗면 = 사면선)을 사면 안쪽(아래)으로 두께만큼 오프셋한 + * 띠 · 안에 돌(흙막이 돌과 같은 그리기 · 두께 인자). 선마다 한 띠 · 그림만(면적 · 터파기 무관). + * 도수로 · 돌붙임 형상 같음 · 배관 · 박스 · 세월교 오버레이가 부름(B07 은 오버레이를 따라옴). + */ +export function appendChuteBand( + layer: SVGElement, + lines: OffsetPoint[][], + chute: { structure: string; thickness_m: number }, + x: (offset: number) => number, + toDisplayY: (elevation: number) => number, +): void { + const thickness = chute.thickness_m; + if (!(thickness > 0)) return; + const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1; + const px = (p: OffsetPoint): [number, number] => [x(p.offset), toDisplayY(p.elevation)]; + for (const raw of lines) { + const top = raw.filter( + (p, i) => + i === 0 || + Math.hypot(p.offset - raw[i - 1].offset, p.elevation - raw[i - 1].elevation) > 1e-6, + ); + if (top.length < 2) continue; + // 변마다 아래쪽(사면 안쪽) 단위 법선 + const normals = top.slice(1).map((p, i) => { + const dx = p.offset - top[i].offset; + const dz = p.elevation - top[i].elevation; + const length = Math.hypot(dx, dz); + const sign = dx / length > 0 ? -1 : 1; + return { offset: (sign * -dz) / length, elevation: (sign * dx) / length }; + }); + // 꺾임은 두 오프셋 선의 교점(마이터 · 너무 뾰족하면 두 배까지) + const bottom = top.map((p, i) => { + const a = normals[Math.max(i - 1, 0)]; + const b = normals[Math.min(i, normals.length - 1)]; + const sum = { offset: a.offset + b.offset, elevation: a.elevation + b.elevation }; + const k = thickness / Math.max(sum.offset * a.offset + sum.elevation * a.elevation, 0.5); + return { offset: p.offset + sum.offset * k, elevation: p.elevation + sum.elevation * k }; + }); + const outline = [...top, ...bottom.reverse()]; + const band = document.createElementNS(SVG_NS, "polygon"); + band.setAttribute("points", outline.map((p) => px(p).join(",")).join(" ")); + band.setAttribute("class", "b06-chart__culvert-pitching"); + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = `유출부 ${chute.structure} — 두께 ${thickness.toFixed(2)}m · 성토사면 따라 원지반까지`; + band.append(title); + clipSeq += 1; + const clipId = `b06-chute-clip-${clipSeq}`; + const clip = document.createElementNS(SVG_NS, "clipPath"); + clip.setAttribute("id", clipId); + const clipShape = document.createElementNS(SVG_NS, "polygon"); + clipShape.setAttribute("points", band.getAttribute("points") ?? ""); + clip.append(clipShape); + const group = document.createElementNS(SVG_NS, "g"); + group.setAttribute("clip-path", `url(#${clipId})`); + top.slice(1).forEach((end, i) => { + const start = top[i]; + const normal = normals[i]; + const [ax, ay] = px(start); + const [bx, by] = px(end); + drawStones( + group, + { + spanM: Math.hypot(end.offset - start.offset, end.elevation - start.elevation), + pixelsPerMeter, + point: (u, v) => + px({ + offset: + start.offset + + (end.offset - start.offset) * u + + normal.offset * thickness * (v + 0.5), + elevation: + start.elevation + + (end.elevation - start.elevation) * u + + normal.elevation * thickness * (v + 0.5), + }), + leanDegrees: (Math.atan2(bx - ax, -(by - ay)) * 180) / Math.PI, + }, + false, + thickness, + ); + }); + layer.append(band, clip, group); + } +} diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index d240917ba..bd2757250 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -344,7 +344,7 @@ stroke-width: 2.2; } -/* 유출부 돌붙임 — 성토사면을 따라 원지반까지 덮는 돌붙임 띠(실무 도면의 돌 연쇄) */ +/* 유출부 도수로 · 돌붙임 띠(90-2) — 성토사면 안쪽으로 두께만큼 · 안에 돌(`appendChuteBand`) */ .b06-chart__culvert-pitching { fill: color-mix(in srgb, #3fa34d 18%, transparent); stroke: #3fa34d; diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 490e7d4c6..d65b778de 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -37,8 +37,7 @@ const SECTION_KEYS: Record = { const SPAN_LINKED_TYPES = new Set(["ford_pavement"]); export const MIN_PIPE_COVER_M = 0.5; -export const APRON_LENGTH_FACTOR = 2.0; -export const APRON_THICKNESS_M = 0.45; +export const CHUTE_THICKNESS_CM = 30.0; export const REVET_FACE_SLOPE = 0.3; export const INLET_STRUCTURE_BASIN = "집수정"; export const FORD_SLAB_THICKNESS_M = 0.3; @@ -124,15 +123,35 @@ function sideSpec( spec.revet_before_m = before; spec.revet_after_m = after; spec.face_slope = REVET_FACE_SLOPE; - // 보호공은 기슭막이 바닥의 세굴 방지 구조 — 낙차고(기슭막이 높이)에 종속한다. - if (height !== null && height > 0) { - spec.apron_length_m = round(height * APRON_LENGTH_FACTOR, 3); - spec.apron_thickness_m = APRON_THICKNESS_M; - } return spec; } -/** 관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 보호공). */ +/** 도수로 · 돌붙임(90-2) — 첫 상자(`chute_add_{n}_*`) 구조 · 두께(cm → m · 빈 값 30 cm) · 구조를 안 + * 고르면 null. 횡단이 유출 성토사면을 따라 띠로 그림. 파이썬 짝 `_chute`. */ +export function chute( + values: Record, +): { structure: string; thickness_m: number } | null { + const picked = Object.entries(values) + .flatMap(([key, value]) => { + const hit = /^chute_add_(\d+)_structure$/.exec(key); + const structure = String(value ?? "").trim(); + return hit && structure ? [[Number(hit[1]), structure] as const] : []; + }) + .sort((a, b) => a[0] - b[0]); + if (!picked.length) return null; + const [n, structure] = picked[0]; + const cm = num(values[`chute_add_${n}_thickness`], null); + return { structure, thickness_m: round((cm && cm > 0 ? cm : CHUTE_THICKNESS_CM) / 100, 3) }; +} + +/** 도수로 · 돌붙임이 있으면 세트에 `chute` 로 실음(90-2). */ +function withChute(spec: CulvertSetSpec, values: Record): CulvertSetSpec { + const found = chute(values); + if (found) spec.chute = found; + return spec; +} + +/** 관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 도수로 · 돌붙임). */ function culvertSet( options: Record, registry: CulvertRegistryDefaults, @@ -140,18 +159,21 @@ function culvertSet( const defaults = registry.pipe ?? {}; const diameterMm = num(options.pipe_diameter_mm, num(defaults.pipe_diameter_mm, 1000.0)); const diameterM = pipeDiameterM(diameterMm); - return { - type: "pipe", - pipe_kind: text(options.pipe_kind), - diameter_m: diameterM, - min_cover_m: MIN_PIPE_COVER_M, - // 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다. 한 공사에서 한쪽만 기초유로 - // 하는 일은 드물어 폼을 둘로 늘리지 않았다(2026-09-09). 실무가 갈라 쓰는 것이 - // 확인되면 그때 나눌 것. - foundation: text(options.revet_foundation), - inlet: sideSpec(options, defaults, "inlet", diameterM), - outlet: sideSpec(options, defaults, "outlet", diameterM), - }; + return withChute( + { + type: "pipe", + pipe_kind: text(options.pipe_kind), + diameter_m: diameterM, + min_cover_m: MIN_PIPE_COVER_M, + // 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다. 한 공사에서 한쪽만 기초유로 + // 하는 일은 드물어 폼을 둘로 늘리지 않았다(2026-09-09). 실무가 갈라 쓰는 것이 + // 확인되면 그때 나눌 것. + foundation: text(options.revet_foundation), + inlet: sideSpec(options, defaults, "inlet", diameterM), + outlet: sideSpec(options, defaults, "outlet", diameterM), + }, + options, + ); } /** @@ -175,10 +197,6 @@ function revetSide(values: Record, role: string): CulvertSetSpe face_role: values.face_role ?? null, face_slope_ratio: values.face_slope_ratio ?? null, }; - if (height !== null && height > 0) { - spec.apron_length_m = round(height * APRON_LENGTH_FACTOR, 3); - spec.apron_thickness_m = APRON_THICKNESS_M; - } return spec; } @@ -222,19 +240,23 @@ export function guardSet( if (inlet.structure) inlet.revet_berm_m = num(values.inlet_revet_berm_m, null); const outlet = side("outlet", outletKey); if (!inlet.structure && !outlet.structure) return null; - return { - type: "pipe", - hidden_pipe: true, - body, - side: "양쪽", - tiers: 1, - foundation: null, - pipe_kind: null, - diameter_m: diameterM, - min_cover_m: 0.0, - inlet, - outlet, - }; + // 흙막이 쪽 유출 띠는 흙막이 성토선을 따름(90-2) + return withChute( + { + type: "pipe", + hidden_pipe: true, + body, + side: "양쪽", + tiers: 1, + foundation: null, + pipe_kind: null, + diameter_m: diameterM, + min_cover_m: 0.0, + inlet, + outlet, + }, + values, + ); } /** 박스 본체 전체 높이(상판 + 내공 + 저판) — 흙막이 세트의 관경 자리(파이썬 `wall_body_height`). */ @@ -373,7 +395,7 @@ function fordSet( ...wingDefaulted(values, wingOut, "out"), ], }; - return withGuard(spec, guardSet(values, "ford", Number(spec.diameter_m))); + return withChute(withGuard(spec, guardSet(values, "ford", Number(spec.diameter_m))), values); } /** 물넘이포장 1개소 — 구조물이 아니라 **파인 노면**이라 형상이 다르다. */ @@ -441,7 +463,7 @@ function boxSet( ...wingDefaulted(values, wingOut, "out"), ], }; - return withGuard(spec, guardSet(values, "box", boxBodyHeight(spec))); + return withChute(withGuard(spec, guardSet(values, "box", boxBodyHeight(spec))), values); } /** 관 지점 목록 → 누가거리(소수 2자리)별 세트 제원. */ diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 308e34b20..66f923635 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -3371,7 +3371,8 @@ "chute_add": "도수로/돌붙임", "chute_add.structure": "구조", "chute_add.structure=도수로": "도수로", - "chute_add.structure=돌붙임": "돌붙임" + "chute_add.structure=돌붙임": "돌붙임", + "chute_add.thickness": "두께 (cm)" } }, "box_culvert": { @@ -3433,7 +3434,8 @@ "chute_add": "도수로/돌붙임", "chute_add.structure": "구조", "chute_add.structure=도수로": "도수로", - "chute_add.structure=돌붙임": "돌붙임" + "chute_add.structure=돌붙임": "돌붙임", + "chute_add.thickness": "두께 (cm)" } }, "ford_pavement": { @@ -3503,7 +3505,8 @@ "chute_add": "도수로/돌붙임", "chute_add.structure": "구조", "chute_add.structure=도수로": "도수로", - "chute_add.structure=돌붙임": "돌붙임" + "chute_add.structure=돌붙임": "돌붙임", + "chute_add.thickness": "두께 (cm)" } }, "revetment": { diff --git a/resources/tester/helper_90_chute_band.cjs b/resources/tester/helper_90_chute_band.cjs new file mode 100644 index 000000000..13708c8f2 --- /dev/null +++ b/resources/tester/helper_90_chute_band.cjs @@ -0,0 +1,84 @@ +/* 90-2 도수로 · 돌붙임 띠 헬퍼 — `appendChuteBand`(B06_Section_UI_Cross_Wall_Hatch.ts)를 그 자리에서 + * 트랜스파일해 흉내 DOM 에 그림 · 결과 = 띠 꼭짓점(m) · 돌 수(표준출력 JSON). 짝 = `test_90_chute_band.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) => loadTs(path.join(path.dirname(full), `${request}.ts`)); + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +class Node { + constructor(tag) { + this.tag = tag; + this.attrs = {}; + this.children = []; + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + append(...kids) { + this.children.push(...kids); + } + set textContent(v) { + this.text = v; + } +} +globalThis.document = { createElementNS: (_ns, tag) => new Node(tag) }; + +const { appendChuteBand } = loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Wall_Hatch.ts"), +); +const SCALE = 100; +const run = (line) => { + const layer = new Node("g"); + appendChuteBand( + layer, + [line], + { structure: "돌붙임", thickness_m: 0.3 }, + (o) => o * SCALE, + (e) => -e * SCALE, + ); + const band = layer.children.find((k) => k.tag === "polygon"); + const group = layer.children.find((k) => k.tag === "g"); + const points = band.attrs.points.split(" ").map((p) => { + const [px, py] = p.split(",").map(Number); + return { offset: px / SCALE, elevation: -py / SCALE }; + }); + return { + points, + stones: group.children.filter((k) => k.tag === "rect").length, + clipped: group.attrs["clip-path"]?.startsWith("url(#") ?? false, + title: band.children[0]?.text ?? "", + }; +}; +const out = { + // 좌측 유출(+ 바깥) · 성토 1:1.2 → 단 사이 수평 조각 → 다시 성토(흙막이 단 사이 포함) + left: run([ + { offset: 5, elevation: 10 }, + { offset: 7.4, elevation: 8 }, + { offset: 8.4, elevation: 8 }, + { offset: 10.8, elevation: 6 }, + ]), + // 우측 유출(− 바깥) · 곧은 성토 한 줄 + right: run([ + { offset: -5, elevation: 10 }, + { offset: -8.6, elevation: 7 }, + ]), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_52_3_structure_spec_ford_pavement.py b/resources/tester/test_52_3_structure_spec_ford_pavement.py index 04e92025a..e183dd48c 100644 --- a/resources/tester/test_52_3_structure_spec_ford_pavement.py +++ b/resources/tester/test_52_3_structure_spec_ford_pavement.py @@ -75,7 +75,8 @@ def test_저장_값은_도_와_인스턴스_키(out): "width": "6", "height": "0.3", } # 월류 폭 · 높이 = 옛 키 그대로 읽음(65-5) - assert out["state"] == {"slope": [slope], "chute": [{"structure": "돌붙임"}]} + # 두께(cm · 90-2) — 빈 칸은 빈 값(새 상자만 기본 30) + assert out["state"] == {"slope": [slope], "chute": [{"structure": "돌붙임", "thickness": ""}]} assert out["written"] == { "ford_slope_deg": 15, "ford_width_m": 6, diff --git a/resources/tester/test_65_ford_defaults.py b/resources/tester/test_65_ford_defaults.py index 95d751985..09323289b 100644 --- a/resources/tester/test_65_ford_defaults.py +++ b/resources/tester/test_65_ford_defaults.py @@ -64,7 +64,8 @@ def test_유입_유출은_구조와_흙막이_칸만(out): "before", "after", ] - assert out[spec]["chute_add"] == [["structure", None, None]] + # 도수로/돌붙임 = 구조 · 두께(cm · 새 상자 30 · 90-2) + assert out[spec]["chute_add"] == [["structure", None, None], ["thickness", None, "30"]] keys = out[f"{spec}Keys"] assert keys["prefixes"] == ["soil_add_", "chute_add_"] # 측벽 높이 · 좌우 · 상하 · 바닥판 · 날개벽 치수 칸 없음 diff --git a/resources/tester/test_90_chute_band.py b/resources/tester/test_90_chute_band.py new file mode 100644 index 000000000..21e4c98f3 --- /dev/null +++ b/resources/tester/test_90_chute_band.py @@ -0,0 +1,59 @@ +"""90-2 도수로 · 돌붙임 띠 — 윗면 = 유출 성토사면 선 · 사면 안쪽(아래)으로 두께만큼 · 안에 돌. + +화면 TS(`appendChuteBand`)를 그 자리에서 돌린 값(`helper_90_chute_band.cjs`). +""" + +import json +import math +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parents[1] +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +@pytest.fixture(scope="module") +def out() -> dict: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_90_chute_band.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def _distance(p: dict, a: dict, b: dict) -> float: + dx, dz = b["offset"] - a["offset"], b["elevation"] - a["elevation"] + cross = (p["offset"] - a["offset"]) * dz - (p["elevation"] - a["elevation"]) * dx + return abs(cross) / math.hypot(dx, dz) + + +@pytest.mark.parametrize("case", ["left", "right"]) +def test_띠_두께는_사면에서_직각으로_0_30m_아래(out: dict, case: str) -> None: + points = out[case]["points"] + n = len(points) // 2 + top, bottom = points[:n], points[n:][::-1] + for i, low in enumerate(bottom): + # 꼭짓점마다 붙은 변(앞 · 뒤)까지 직각 거리 = 두께 + for j in {max(i - 1, 0), min(i, n - 2)}: + assert _distance(low, top[j], top[j + 1]) == pytest.approx(0.3, abs=1e-6) + assert low["elevation"] < top[i]["elevation"] # 사면 안쪽 = 아래 + + +def test_안에_돌_윗면은_사면선(out: dict) -> None: + for case in out.values(): + assert case["stones"] >= 2 and case["clipped"] + assert "돌붙임" in case["title"] and "0.30m" in case["title"] + assert out["left"]["points"][:4] == [ + {"offset": 5, "elevation": 10}, + {"offset": 7.4, "elevation": 8}, + {"offset": 8.4, "elevation": 8}, + {"offset": 10.8, "elevation": 6}, + ] diff --git a/resources/tester/test_b06_culvert_set.py b/resources/tester/test_b06_culvert_set.py index 1970ecbbe..22a6bd6f2 100644 --- a/resources/tester/test_b06_culvert_set.py +++ b/resources/tester/test_b06_culvert_set.py @@ -13,8 +13,7 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402 - APRON_LENGTH_FACTOR, - APRON_THICKNESS_M, + CHUTE_THICKNESS_CM, MIN_PIPE_COVER_M, REVET_FACE_SLOPE, _culvert_set, @@ -24,10 +23,9 @@ from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402 def test_cross_reference_constants_match_plan(): - """계획서 3-0 표 값 — 토피 0.5m·보호공 길이 2배·두께 0.45m(사용자 확정)·전면 1:0.3.""" + """계획서 3-0 표 값 — 토피 0.5m·전면 1:0.3 · 도수로/돌붙임 빈 두께 30 cm(90-2 사용자 확정).""" assert MIN_PIPE_COVER_M == 0.5 - assert APRON_LENGTH_FACTOR == 2.0 - assert APRON_THICKNESS_M == 0.45 + assert CHUTE_THICKNESS_CM == 30.0 assert REVET_FACE_SLOPE == 0.3 # 배관은 수평도 가능 — 세트에 경사 강제값을 싣지 않는다(2026-08-20 사용자 확정). assert "min_slope" not in _culvert_set(None) @@ -54,9 +52,8 @@ def test_saved_values_drive_members(): assert part["revet_form"] is None assert part["revet_height_m"] == 2.0 assert part["face_slope"] == REVET_FACE_SLOPE - # 보호공 = 낙차고(2.0) × 2 = 4.0m, 두께 0.45m(사용자 확정). - assert part["apron_length_m"] == 4.0 - assert part["apron_thickness_m"] == 0.45 + # 옛 보호공(apron) 칸은 걷음 — 유출 보호는 도수로/돌붙임 띠(90-2) + assert "apron_length_m" not in part def test_basin_inlet_skips_revet_and_apron(): @@ -64,10 +61,9 @@ def test_basin_inlet_skips_revet_and_apron(): spec = _culvert_set({"inlet_type": "집수정", "outlet_type": "기슭막이"}) assert spec["inlet"]["structure"] == "집수정" assert "revet_height_m" not in spec["inlet"] - assert "apron_length_m" not in spec["inlet"] # 유출측은 그대로 세트를 갖춘다. assert spec["outlet"]["structure"] == "기슭막이" - assert spec["outlet"]["apron_length_m"] == 4.0 # 빈 높이 = 관경 기준 최소 2.0 × 2 + assert spec["outlet"]["revet_height_m"] == 2.0 # 빈 높이 = 관경 기준 최소 def test_string_diameter_and_custom_height(): @@ -77,7 +73,15 @@ def test_string_diameter_and_custom_height(): ) assert spec["diameter_m"] == 0.8 assert spec["outlet"]["revet_height_m"] == 1.5 - assert spec["outlet"]["apron_length_m"] == 3.0 # 1.5 × 2 + + +def test_chute_thickness_cm_to_m(): + """도수로/돌붙임(90-2) — 구조를 고르면 `chute` · 두께 cm → m · 빈 두께 30 cm · 안 고르면 없음.""" + assert "chute" not in _culvert_set({"chute_add_1_thickness": 40}) + spec = _culvert_set({"chute_add_1_structure": "돌붙임", "chute_add_1_thickness": "45"}) + assert spec["chute"] == {"structure": "돌붙임", "thickness_m": 0.45} + blank = _culvert_set({"chute_add_1_structure": "도수로", "chute_add_1_thickness": ""}) + assert blank["chute"] == {"structure": "도수로", "thickness_m": 0.3} def test_attach_only_pipe_facilities(tmp_path): diff --git a/resources/tester/test_b06_culvert_sets_mirror.py b/resources/tester/test_b06_culvert_sets_mirror.py index 8ddc842e5..22e87c786 100644 --- a/resources/tester/test_b06_culvert_sets_mirror.py +++ b/resources/tester/test_b06_culvert_sets_mirror.py @@ -57,6 +57,10 @@ POINTS = [ "inlet_type": "흙막이", "inlet_revet_form": "콘크리트", "inlet_revet_height_m": 1.5, + "inlet_revet_berm_m": 2, + "outlet_revet_berm_m": 3, # 유출 수평공간 칸 없음(89-4) — 세트에 안 실림 + # 도수로/돌붙임(90-2) — 빈 두께 = 30 cm + "chute_add_1_structure": "도수로", }, }, { @@ -72,6 +76,8 @@ POINTS = [ "outlet_revet_form": "돌쌓기(찰)", "outlet_revet_height_m": 3.5, "outlet_revet_length_m": 10, + "chute_add_2_structure": "돌붙임", + "chute_add_2_thickness": 45, }, }, { @@ -199,3 +205,8 @@ def test_ford_box_guard_attached_as_culvert() -> None: assert box["diameter_m"] == round(b["top_thickness_m"] + 2.5 + b["slab_thickness_m"], 3) # 흙막이 없는 시설엔 안 얹음 assert "culvert" not in sections[100.0] + # 유입만 수평공간(89-4) · 도수로/돌붙임 두께 cm → m(90-2) + assert ford["inlet"]["revet_berm_m"] == 2 and "revet_berm_m" not in ford["outlet"] + assert sections[60.0]["ford"]["chute"] == {"structure": "도수로", "thickness_m": 0.3} + assert box["chute"] == {"structure": "돌붙임", "thickness_m": 0.45} + assert "chute" not in sections[40.0]["culvert"] From 2ebc888f03022a7f26a87972d0111ade4d5fa94d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:31:40 +0900 Subject: [PATCH 36/47] =?UTF-8?q?feat(spreadsheet):=20=EC=9A=B0=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EB=A9=94=EB=89=B4=20=ED=95=AD=EB=AA=A9=20=EB=AA=A8?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EA=B3=B5=EC=9A=A9=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=EC=84=A0=20=C2=B7=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B0=BD=20=EC=86=90=EC=9E=A1=EC=9D=B4(81-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spreadsheet_menu_items.ts 새 파일 — 붙여넣기(보안 문맥만) · 선택하여 붙여넣기 ▸ · 행 · 열 · 셀 삽입/삭제 ▸ · 크기 · 데이터에 맞추기 · 시트 정렬 · 필터 · 고정 ▸ · 그룹 ▸ · 셀 작업 더보기 ▸ · 읽기 전용 복사 - ui_template_context_menu 항목에 구분선 "-" 덧붙임(맨 앞 · 맨 뒤 · 겹침 안 그림) - ctx.dataMenu — [데이터 ▾] 와 같은 패널 창 · 필터를 우클릭에서 부름 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- .../spreadsheet/spreadsheet_data_menu.ts | 10 + .../spreadsheet/spreadsheet_menu_items.ts | 441 ++++++++++++++++++ .../spreadsheet/spreadsheet_view_types.ts | 2 + ui_template/ui_template_context_menu.css | 7 + ui_template/ui_template_context_menu.ts | 16 +- 5 files changed, 474 insertions(+), 2 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_menu_items.ts diff --git a/A00_Common/spreadsheet/spreadsheet_data_menu.ts b/A00_Common/spreadsheet/spreadsheet_data_menu.ts index 63390c33b..e27d0402a 100644 --- a/A00_Common/spreadsheet/spreadsheet_data_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_data_menu.ts @@ -97,6 +97,16 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe back(); }; const act = mountActions(ctx, back); + ctx.dataMenu = (kind) => { + if (kind === "filter") return (toggleFilter(), back()); + if (kind === "condfmt") return void openPanel(TEXT.condfmt, async () => createCondFmtPanel); + if (kind === "validation") + return void openPanel(TEXT.validation, async () => createValidationPanel); + void openPanel( + TEXT.names, + async () => (await import("./spreadsheet_names")).createNameManagerPanel, + ); + }; const menu = createMapContextMenu("ss"); ctx.root.append(menu.element); let closeSort: (() => void) | null = null; diff --git a/A00_Common/spreadsheet/spreadsheet_menu_items.ts b/A00_Common/spreadsheet/spreadsheet_menu_items.ts new file mode 100644 index 000000000..649f8ed01 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_menu_items.ts @@ -0,0 +1,441 @@ +/* ============================================================================= + * spreadsheet_menu_items.ts (81-3) + * 우클릭 메뉴에 덧붙이는 항목 — 구글 시트 우클릭 가운데 기능이 이미 있는 것만(조사_A 3 표 (a)). + * 붙여넣기 · 선택하여 붙여넣기 ▸ · 행 · 열 · 셀 삽입/삭제 ▸ · 행 · 열 크기 · 데이터에 맞추기 · + * 시트 정렬 · 필터 · 고정 ▸ · 그룹 ▸ · 셀 작업 더보기 ▸(드롭다운 · 조건부 서식 · 범위 이름). + * 안 만듦 = 하이퍼링크 · 댓글 · 범위/시트 보호 · 다른 파일로 복사. + * `spreadsheet_menu.ts` 가 이 목록을 펼쳐 붙임. 몸통 창 · 명령 모듈은 누를 때 `import()`(크기 예산). + * 붙여넣기는 보안 문맥(localhost · https)에서만 클립보드를 읽어 실제로 붙임 — http 는 Ctrl+V 안내. + * ========================================================================== */ + +import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; +import { dialogKeys, type Act } from "./spreadsheet_actions"; +import { parseA1 } from "./spreadsheet_address"; +import { parseClipboard } from "./spreadsheet_clipboard"; +import type { Editor } from "./spreadsheet_editor"; +import { + buildColGeometry, + buildRowGeometry, + MAX_COL_CHARS, + pxToColChars, + pxToPt, +} from "./spreadsheet_headers"; +import type { PasteSpecialMode } from "./spreadsheet_paste_special"; +import { st } from "./spreadsheet_text"; +import type { CellRange, Command, Sheet } from "./spreadsheet_types"; +import type { SpreadsheetContext } from "./spreadsheet_view_types"; + +const TEXT = { + paste: "붙여넣기", + pasteSpecial: "선택하여 붙여넣기", + pasteValues: "값만 붙여넣기", + pasteFormat: "서식만 붙여넣기", + pasteSpecialHint: "선택하여 붙여넣기는 Ctrl+Shift+V 를 쓸 것", + pasteFailed: "붙여넣을 내용이 없음", + insertLines: "행 · 열 삽입", + rowsAbove: (n: number) => `위에 ${n}행 삽입`, + rowsBelow: (n: number) => `아래에 ${n}행 삽입`, + colsLeft: (n: number) => `왼쪽에 ${n}열 삽입`, + colsRight: (n: number) => `오른쪽에 ${n}열 삽입`, + insertCells: "셀 삽입", + shiftRight: "셀 삽입 후 오른쪽으로 밀기", + shiftDown: "셀 삽입 후 아래로 밀기", + deleteLines: "행 · 열 삭제", + deleteRows: (n: number) => `${n}행 삭제`, + deleteCols: (n: number) => `${n}열 삭제`, + deleteCells: "셀 삭제", + shiftLeft: "셀 삭제 후 왼쪽으로 당기기", + shiftUp: "셀 삭제 후 위로 당기기", + rowSize: "행 크기 조절…", + colSize: "열 크기 조절…", + rowSizeLabel: "행 높이(px)", + colSizeLabel: "열 너비(px)", + autofit: "데이터에 맞추기", + sortAsc: "시트 정렬 A → Z", + sortDesc: "시트 정렬 Z → A", + filterOn: "필터 만들기", + filterOff: "필터 삭제", + freeze: "고정", + freezeRow: "첫 행 고정", + freezeCol: "첫 열 고정", + freezeAt: "현재 칸까지 고정", + unfreeze: "고정 풀기", + group: "그룹", + groupOn: "그룹으로 묶기", + groupOff: "그룹 풀기", + more: "셀 작업 더보기", + dropdown: "드롭다운…", + condfmt: "조건부 서식…", + nameRange: "범위 이름 지정…", + nameLabel: "이름", + nameEmpty: "이름을 넣을 것", + ok: "확인", + cancel: "취소", + badNumber: "1 이상 숫자를 넣을 것", +}; + +type Item = [string, () => void]; +const SEP = "-" as const; + +/** 메뉴를 닫으면 누른 단추가 지워져 초점이 문서 밖으로 감 — 격자 입력칸으로 되돌림 */ +function back(ctx: SpreadsheetContext): void { + (ctx.editor as Editor).focus?.(); +} + +/** 잘라내기 · 복사 — 초점을 격자로 되돌린 뒤 불러야 부품의 copy · cut 듣기가 받음(빈 복사 막음) */ +export function copyCut(ctx: SpreadsheetContext, kind: "cut" | "copy"): void { + back(ctx); + document.execCommand(kind); +} + +/** 읽기 전용 · 미리보기 시트 — 「복사」 한 줄 */ +export function copyOnlyItems(ctx: SpreadsheetContext): MapContextMenuItem[] { + return [[st("MenuCopy"), () => copyCut(ctx, "copy")]]; +} + +const id = (ctx: SpreadsheetContext): string => ctx.selection.시트; + +/** 메뉴로 클립보드를 읽을 수 있는가 — 보안 문맥(localhost · https)만 */ +export function canMenuPaste(): boolean { + return globalThis.isSecureContext === true && typeof navigator?.clipboard?.read === "function"; +} + +async function readClipboard(): Promise<{ html: string; text: string } | null> { + if (!canMenuPaste()) return null; + try { + let html = ""; + let text = ""; + for (const item of await navigator.clipboard.read()) { + if (!html && item.types.includes("text/html")) + html = await (await item.getType("text/html")).text(); + if (!text && item.types.includes("text/plain")) + text = await (await item.getType("text/plain")).text(); + } + return { html, text }; + } catch { + return null; // 권한 거절 — 안내로 + } +} + +/** 메뉴 붙여넣기 — 보통은 부품의 paste 듣기로(잘라내기 옮김 · 엑셀 경고 그대로) · 골라 붙이기는 바로 */ +export async function menuPaste(ctx: SpreadsheetContext, mode?: PasteSpecialMode): Promise { + const clip = await readClipboard(); + if (!clip) return showToast(mode ? TEXT.pasteSpecialHint : st("MenuPasteHint"), "info"); + back(ctx); + if (!mode) { + const data = new DataTransfer(); + data.setData("text/html", clip.html); + data.setData("text/plain", clip.text); + ctx.root.dispatchEvent( + new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true }), + ); + return; + } + const at = { ...ctx.selection.활성 }; + const block = parseClipboard(clip.html, clip.text, at); + if (!block) return showToast(TEXT.pasteFailed, "error"); + const source = + block.출처 === "aislo" && block.원점 ? { 시트: id(ctx), ...block.원점 } : undefined; + (await import("./spreadsheet_paste_special")).pasteSpecial(ctx, block, at, mode, source); +} + +export function pasteItems(ctx: SpreadsheetContext): MapContextMenuItem[] { + return [ + [TEXT.paste, () => void menuPaste(ctx)], + { + label: TEXT.pasteSpecial, + children: [ + [TEXT.pasteValues, () => void menuPaste(ctx, "값")], + [TEXT.pasteFormat, () => void menuPaste(ctx, "서식")], + ], + }, + ]; +} + +/** 행 · 열 넣기 — 위 · 왼쪽은 `at` · 아래 · 오른쪽은 `at + 수`(고른 줄 수만큼) */ +export function insertLines( + ctx: SpreadsheetContext, + rows: boolean, + at: number, + 수: number, + after: boolean, +): void { + ctx.dispatch({ 종류: rows ? "행넣기" : "열넣기", 시트: id(ctx), at: after ? at + 수 : at, 수 }); +} + +function deleteLines(ctx: SpreadsheetContext, rows: boolean, at: number, 수: number): void { + ctx.dispatch({ 종류: rows ? "행지우기" : "열지우기", 시트: id(ctx), at, 수 }); +} + +export function rowInsertItems(ctx: SpreadsheetContext, at: number, 수: number): Item[] { + return [ + [TEXT.rowsAbove(수), () => insertLines(ctx, true, at, 수, false)], + [TEXT.rowsBelow(수), () => insertLines(ctx, true, at, 수, true)], + ]; +} + +export function colInsertItems(ctx: SpreadsheetContext, at: number, 수: number): Item[] { + return [ + [TEXT.colsLeft(수), () => insertLines(ctx, false, at, 수, false)], + [TEXT.colsRight(수), () => insertLines(ctx, false, at, 수, true)], + ]; +} + +const shifts = () => import("./spreadsheet_insert_dialog"); + +/** 칸 메뉴 — 행 · 열 · 셀 삽입/삭제 ▸ */ +export function cellInsertItems(ctx: SpreadsheetContext, g: CellRange): MapContextMenuItem[] { + const rows = g.r1 - g.r0 + 1; + const cols = g.c1 - g.c0 + 1; + return [ + { + label: TEXT.insertLines, + children: [...rowInsertItems(ctx, g.r0, rows), ...colInsertItems(ctx, g.c0, cols)], + }, + { + label: TEXT.insertCells, + children: [ + [TEXT.shiftRight, () => void shifts().then((m) => m.insertShiftRight(ctx, g))], + [TEXT.shiftDown, () => void shifts().then((m) => m.insertShiftDown(ctx, g))], + ], + }, + { + label: TEXT.deleteLines, + children: [ + [TEXT.deleteRows(rows), () => deleteLines(ctx, true, g.r0, rows)], + [TEXT.deleteCols(cols), () => deleteLines(ctx, false, g.c0, cols)], + ], + }, + { + label: TEXT.deleteCells, + children: [ + [TEXT.shiftLeft, () => void shifts().then((m) => m.deleteShiftLeft(ctx, g))], + [TEXT.shiftUp, () => void shifts().then((m) => m.deleteShiftUp(ctx, g))], + ], + }, + ]; +} + +/** 몸통은 [데이터 ▾] 와 같은 늦은 길(`spreadsheet_actions_run.ts`) */ +function run(ctx: SpreadsheetContext, act: Act): void { + void import("./spreadsheet_actions_run").then((m) => m.run(ctx, () => back(ctx), act)); +} + +function freezeGroupItems(ctx: SpreadsheetContext): MapContextMenuItem[] { + return [ + { + label: TEXT.freeze, + children: [ + [TEXT.freezeRow, () => run(ctx, "freezeRow")], + [TEXT.freezeCol, () => run(ctx, "freezeCol")], + [TEXT.freezeAt, () => run(ctx, "freezeAt")], + [TEXT.unfreeze, () => run(ctx, "unfreeze")], + ], + }, + { + label: TEXT.group, + children: [ + [TEXT.groupOn, () => run(ctx, "group")], + [TEXT.groupOff, () => run(ctx, "ungroup")], + ], + }, + ]; +} + +/** 쓴 칸을 모두 덮는 범위(없으면 null) */ +export function usedRange(sheet: Sheet): CellRange | null { + let g: CellRange | null = null; + for (const key of Object.keys(sheet.칸)) { + const p = parseA1(key); + if (!p) continue; + g = g + ? { + r0: Math.min(g.r0, p.r), + c0: Math.min(g.c0, p.c), + r1: Math.max(g.r1, p.r), + c1: Math.max(g.c1, p.c), + } + : { r0: p.r, c0: p.c, r1: p.r, c1: p.c }; + } + return g; +} + +/** 열 머리에서 — 쓴 범위를 잠깐 골라 `job`(정렬 · 필터) 뒤 고름을 되돌림. 빈 열이면 안 함 */ +async function onUsed( + ctx: SpreadsheetContext, + c: number, + job: () => void | Promise, +): Promise { + const used = usedRange(ctx.sheet()); + if (!used || c < used.c0 || c > used.c1) return; + const keep = ctx.selection; + const top = { r: used.r0, c }; + ctx.select({ 시트: id(ctx), 범위: [used], 활성: top, 기준: top }); + try { + await job(); + } finally { + ctx.select(keep); + } +} + +/** 시트 정렬 — 쓴 범위 전체를 이 열 기준으로(열 하나만 섞이지 않게) */ +export function sortByColumn(ctx: SpreadsheetContext, c: number, desc: boolean): Promise { + return onUsed(ctx, c, async () => + (await import("./spreadsheet_sort_dialog")).sortSelection(ctx, desc), + ); +} + +function filterItem(ctx: SpreadsheetContext, c?: number): Item { + const on = !!ctx.sheet().필터; + const go = (): void => ctx.dataMenu?.("filter"); + return [ + on ? TEXT.filterOff : TEXT.filterOn, + () => (on || c === undefined ? go() : void onUsed(ctx, c, go)), + ]; +} + +/** 숫자 · 글 하나 받는 작은 창 — 확인이 거르면(오류 글) 창이 남음 */ +function askValue( + ctx: SpreadsheetContext, + title: string, + label: string, + value: string, + submit: (text: string) => string | null | Promise, +): void { + const overlay = el("div", { className: "ui-confirm" }); + const close = (): void => { + overlay.remove(); + back(ctx); + }; + const field = createInputField({ label, value }); + const error = el("p", { text: "" }); + error.style.cssText = "color:var(--color-danger, red);font-size:0.8rem;min-height:1em;"; + const ok = async (): Promise => { + const fail = await submit(field.input.value.trim()); + if (fail) error.textContent = fail; + else close(); + }; + field.input.addEventListener("keydown", (e) => { + if (e.key === "Enter") void ok(); + }); + overlay.append( + el("div", { + className: "ui-confirm__panel", + children: [ + el("h3", { text: title }), + field.root, + error, + el("div", { + className: "ui-confirm__actions", + children: [ + createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }), + createButton({ label: TEXT.ok, variant: "filled", onClick: () => void ok() }), + ], + }), + ], + }), + ); + document.body.append(overlay); + dialogKeys(overlay, close); + field.input.focus(); + field.input.select(); +} + +/** 행 높이 · 열 너비 바꾸기(px) — 고른 줄 모두 한 명령(한 번에 되돌림) · 잘못된 값이면 오류 글 */ +export function resizeLines( + ctx: SpreadsheetContext, + rows: boolean, + lines: number[], + text: string, +): string | null { + const px = Number(text); + if (!Number.isFinite(px) || px < 1) return TEXT.badNumber; + ctx.dispatch( + rows + ? { 종류: "행높이", 시트: id(ctx), 행: lines, 높이: Math.min(pxToPt(px), 409) } + : { 종류: "열폭", 시트: id(ctx), 열: lines, 폭: Math.min(pxToColChars(px), MAX_COL_CHARS) }, + ); + return null; +} + +function sizeItem(ctx: SpreadsheetContext, rows: boolean, lines: number[]): Item { + return [ + rows ? TEXT.rowSize : TEXT.colSize, + () => { + const geo = (rows ? buildRowGeometry : buildColGeometry)(ctx.book, ctx.sheet()); + askValue( + ctx, + rows ? TEXT.rowSize : TEXT.colSize, + rows ? TEXT.rowSizeLabel : TEXT.colSizeLabel, + String(Math.round(geo.size(lines[0]))), + (text) => resizeLines(ctx, rows, lines, text), + ); + }, + ]; +} + +/** 열 너비를 데이터에 맞춤 — 고른 열 모두 한 명령 */ +export async function autofitColumns(ctx: SpreadsheetContext, cols: number[]): Promise { + const { estimateColumnWidth } = await import("./spreadsheet_autofit"); + const 명령: Command[] = cols.map((c) => ({ + 종류: "열폭", + 시트: id(ctx), + 열: [c], + 폭: estimateColumnWidth(ctx.book, id(ctx), c), + })); + ctx.dispatch({ 종류: "묶음", 명령 }); +} + +/** 행 머리 끝 — 크기 · 고정 ▸ · 그룹 ▸ */ +export function rowMoreItems(ctx: SpreadsheetContext, rows: number[]): MapContextMenuItem[] { + return [SEP, sizeItem(ctx, true, rows), SEP, ...freezeGroupItems(ctx)]; +} + +/** 열 머리 끝 — 크기 · 데이터에 맞추기 · 시트 정렬 · 필터 · 고정 ▸ · 그룹 ▸ */ +export function colMoreItems(ctx: SpreadsheetContext, cols: number[]): MapContextMenuItem[] { + const c = cols[0]; + return [ + SEP, + sizeItem(ctx, false, cols), + [TEXT.autofit, () => void autofitColumns(ctx, cols)], + SEP, + [TEXT.sortAsc, () => void sortByColumn(ctx, c, false)], + [TEXT.sortDesc, () => void sortByColumn(ctx, c, true)], + filterItem(ctx, c), + SEP, + ...freezeGroupItems(ctx), + ]; +} + +/** 칸 메뉴 끝 — 고정 ▸ · 그룹 ▸ · 셀 작업 더보기 ▸ */ +export function cellMoreItems(ctx: SpreadsheetContext, g: CellRange): MapContextMenuItem[] { + return [ + SEP, + ...freezeGroupItems(ctx), + { + label: TEXT.more, + children: [ + [TEXT.dropdown, () => ctx.dataMenu?.("validation")], + [TEXT.condfmt, () => ctx.dataMenu?.("condfmt")], + [ + TEXT.nameRange, + () => + askValue(ctx, TEXT.nameRange, TEXT.nameLabel, "", (name) => nameRange(ctx, name, g)), + ], + filterItem(ctx), + ], + }, + SEP, + ]; +} + +/** 범위 이름 — `spreadsheet_names.defineName`(오류 글 · 성공 null) */ +export async function nameRange( + ctx: SpreadsheetContext, + name: string, + g: CellRange, +): Promise { + if (!name) return TEXT.nameEmpty; + return (await import("./spreadsheet_names")).defineName(ctx, name, g); +} diff --git a/A00_Common/spreadsheet/spreadsheet_view_types.ts b/A00_Common/spreadsheet/spreadsheet_view_types.ts index 1f5ccb96a..d39a6ed0e 100644 --- a/A00_Common/spreadsheet/spreadsheet_view_types.ts +++ b/A00_Common/spreadsheet/spreadsheet_view_types.ts @@ -112,6 +112,8 @@ export interface SpreadsheetContext { /** 칸 메뉴 끝에 붙일 줄 — 칸 메뉴(`spreadsheet_menu.ts`)가 빈 것으로 세움(= 칸 메뉴 있음) · * 조건 만들기(`spreadsheet_condition.ts`)가 채움 · 없으면 칸 메뉴 없는 편집기 */ cellMenuExtra?: (r: number, c: number) => Array<[label: string, action: () => void]>; + /** [데이터 ▾] 와 같은 창 · 필터를 우클릭 메뉴에서 — 데이터 메뉴(`spreadsheet_data_menu.ts`)가 채움 */ + dataMenu?: (kind: "filter" | "condfmt" | "validation" | "names") => void; } /** E 부품 — D 가 `mountToolbar(ctx)` 등으로 붙이고 고름 · 문서가 바뀌면 `refresh()`. */ diff --git a/ui_template/ui_template_context_menu.css b/ui_template/ui_template_context_menu.css index 2c0bd6eef..7b11ab018 100644 --- a/ui_template/ui_template_context_menu.css +++ b/ui_template/ui_template_context_menu.css @@ -64,3 +64,10 @@ .ui-ctx-menu__item.is-sub { padding-left: var(--spacing-24); } + +/* 구분선(`"-"` 항목) */ +.ui-ctx-menu__sep { + height: 1px; + margin: var(--spacing-4) 0; + background: var(--color-border); +} diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts index 48874fbb6..3b787d4d3 100644 --- a/ui_template/ui_template_context_menu.ts +++ b/ui_template/ui_template_context_menu.ts @@ -14,10 +14,12 @@ import "./ui_template_context_menu.css"; /** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 누르면 바로 아래로 - * 펼쳐지는 아코디언(구조물군 → 종류, 2026-08-18 B05 우클릭 메뉴 일원화). */ + * 펼쳐지는 아코디언(구조물군 → 종류, 2026-08-18 B05 우클릭 메뉴 일원화). + * 구분선 = `"-"` — 맨 앞 · 맨 뒤 · 겹친 것은 안 그림. */ export type MapContextMenuItem = | [label: string, action: () => void] - | { label: string; children: Array<[label: string, action: () => void]> }; + | { label: string; children: Array<[label: string, action: () => void]> } + | "-"; export interface MapContextMenu { /** 뷰포트에 append할 요소. */ @@ -118,6 +120,14 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { enterMenu(registryEntry); element.replaceChildren(); items.forEach((item) => { + if (item === "-") { + const last = element.lastElementChild; + if (last && !last.classList.contains("ui-ctx-menu__sep")) + element.append( + Object.assign(document.createElement("div"), { className: "ui-ctx-menu__sep" }), + ); + return; + } if (Array.isArray(item)) { element.append(buildLeaf(item[0], item[1])); return; @@ -145,6 +155,8 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { }); element.append(head, body); }); + if (element.lastElementChild?.classList.contains("ui-ctx-menu__sep")) + element.lastElementChild.remove(); element.style.left = `${x}px`; element.style.top = `${y}px`; element.hidden = false; From e03600e4d90609dddb3ae57abb011bf1cfd0f726 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:32:48 +0900 Subject: [PATCH 37/47] =?UTF-8?q?fix(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EC=A1=B0=ED=95=A9=20=E3=80=8C=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=ED=95=A9=EA=B3=84=E3=80=8D=20=EC=A4=84=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=20=EC=B9=B8=20=ED=8F=AD=20=E2=80=94=20=EA=B8=80?= =?UTF-8?q?=EC=9D=B4=20=EC=95=88=20=EC=9E=98=EB=A6=AC=EA=B2=8C(86-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- ui_template/sheet/ui_template_sheet_render.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index 36114d47e..5c804be8b 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -118,6 +118,7 @@ export function renderSheet(state: RenderState): HTMLElement { st("Row_Unit_Price"), st("Row_Drawing"), st("Row_Area"), + ...(state.laborSumLabel ? [st("Row_LaborSum")] : []), ...(doc.합계줄 ?? []).map((t) => t.이름), ].map((label) => Math.ceil(textWidth(label, font)) + 16), ), From aa4d3f2439cc5810f6a516563fa9f18f2ca155ce Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:34:31 +0900 Subject: [PATCH 38/47] =?UTF-8?q?chore(plan):=20=EB=B6=84=EC=84=9D=5F?= =?UTF-8?q?=EC=A0=95=ED=95=A8=2091-1=20=ED=8C=90=EC=A0=95=20=EC=A4=84=20?= =?UTF-8?q?=EB=B0=9B=EC=9D=8C(dev=20=EC=99=80=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- docs/raw/요구_1008/분석_정함.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1008/분석_정함.md b/docs/raw/요구_1008/분석_정함.md index a8e8395cc..2fca9cb8a 100644 --- a/docs/raw/요구_1008/분석_정함.md +++ b/docs/raw/요구_1008/분석_정함.md @@ -56,6 +56,7 @@ - **유출 단 수(90-1)** — 관보호공 「추가 흙막이(단)」 스테퍼를 공용으로 세월교 · 박스 유출에 · 「+ 흙막이」 단추 없앰 · 단 수 → 상자 수(관보호공과 같은 방향) · 집계표도 단 수로 상자 자름 · 유출이 날개벽 · 없음인데 상자가 있던 자료 = 숨기고 집계에서 뺌(값은 파일에 남음). - **도수로 · 돌붙임(90-2)** — 두께 칸(cm · 기본 30 · 사용자 확정) · 유출 성토사면 선을 사면 안쪽으로 두께만큼 오프셋한 띠(윗면 = 사면선 · 근거 교본 「지반선보다 낮게 원지반에 설치」) · 유출 끝 ~ 성토사면이 원지반 만나는 곳(흙막이 단 사이 성토 조각 포함) · 안에 돌 무늬(흙막이 돌 그리기 재사용) · 도수로 · 돌붙임 형상 같음 · 배관 · 박스 · 세월교 · 물넘이포장(`ford_chute`) · 면적 · 터파기 영향 없음. - **제약 삭제(91)** — 형상 자체(벽 외곽)는 원지반 · 암 참조 없음(조사_G 1). 「해당」 = 세월교 바닥 · 흙막이 기본 높이(하상) · 다단 자동 자리(원지반에 앉는 x) · I형 집수정 하단. 더 = 설계선 끝 2 px 판정(줌마다 끝점 변동) · 전면 기울기 지형 자동판정. 91-1 에서 재현 목록 → 브레인 판정 → 91-3. 끝 연결선(성토부선 · 절토선이 원지반에서 끝남)은 형상 아님 → 남김. +- **91-1 판정(브레인 · 10-8)** — ① 세월교 바닥 = 끊음(91-2 손 칸) ② 다단 자동 자리 = 끊음(지형 무관 등간격 · 앞 단 바닥 + 1:1.2 사면 일정 길이) ③ I형 집수정 근입 = 남김(집수정 형상 규칙 · 흙막이 아님) ④ 설계선 끝 2 px 판정 = 끊음(수치 교점 0.01 m) ⑤ 전면 기울기 지형 자동판정 = 끊음(관보호공 · 세월교 · 박스 흙막이 = 성토부 표값 고정 · 사면안정 흙막이 · 옹벽 = 설치 측 칸 값 · B08 도 같은 함수) ⑥ 끝 연결선 = 남김(잔재 문구만 지움). - **세월교 바닥(91-2)** — 처음 놓을 때 하상값 · 손 조정 칸(박스 `riseM` 꼴) · 원지반이 바뀌어도 안 따라감 · 흙막이 기본 높이도 이 값 기준. - **복합경사(91-4)** — 공용 도우미 하나(`common_util_cross_berm.ts cutProfilePoints` · 경계 = 원지반 + `rock_boundary_offset_m` · 켬 = 암 프리셋 + `two_stage_slope` · 소단 = `design.berm`) · 쓰는 곳 = 흙막이 수평공간 뒤 · 박스 끝 · 세월교 측벽 끝 · ㄴ·ㄷ 집수정 끝 · 절토 쪽 배관 유출 벽 뒤(지금 절토사면이 사라짐) · 소단 셈은 구조물 끝에서 새로 · `cutLine` 두 점 → 점열(횡단 셋 · 3D). From ea8495792756df33e95669cfeb34a844a010ffc3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:34:54 +0900 Subject: [PATCH 39/47] =?UTF-8?q?feat(spreadsheet):=20=EC=9A=B0=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EB=A9=94=EB=89=B4=EC=97=90=20=EB=B6=99=EC=97=AC?= =?UTF-8?q?=EB=84=A3=EA=B8=B0=20=C2=B7=20=EC=82=BD=EC=9E=85/=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=E2=96=B8=20=C2=B7=20=ED=81=AC=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=C2=B7=20=ED=95=84=ED=84=B0=20=C2=B7=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=C2=B7=20=EA=B7=B8=EB=A3=B9=20=C2=B7=20?= =?UTF-8?q?=EB=8D=94=EB=B3=B4=EA=B8=B0=20=EB=B6=99=EC=9E=84=20=C2=B7=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20=EB=B3=B5=EC=82=AC(81?= =?UTF-8?q?-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spreadsheet_menu.ts — 칸 · 행 머리 · 열 머리 메뉴에 spreadsheet_menu_items 펼침 · 구분선 - 잘라내기 · 복사 — 초점을 격자로 되돌린 뒤 execCommand(메뉴가 닫히며 초점이 빠져 빈 복사되던 흠) - 읽기 전용 · 미리보기 시트 우클릭 = 「복사」 한 줄(spreadsheet.ts 메뉴 늘 붙임) - 시험 test_stage4_menu_items.ts 34 · 크기 화면 16.3 KB(예산 72) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- A00_Common/spreadsheet/spreadsheet.ts | 4 +- A00_Common/spreadsheet/spreadsheet_menu.ts | 54 +++- .../spreadsheet/test_stage4_menu_items.ts | 304 ++++++++++++++++++ 3 files changed, 343 insertions(+), 19 deletions(-) create mode 100644 resources/tester/spreadsheet/test_stage4_menu_items.ts diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 5596915d8..4c7e91766 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -110,7 +110,7 @@ export function createSpreadsheet( ): SpreadsheetHandle { const readOnly = !!opts.readOnly; const noTabs = opts.tabs === false; - const bare = readOnly || !!opts.preview; // 도구 모음 · 메뉴 없음 + const bare = readOnly || !!opts.preview; // 도구 모음 없음 · 우클릭은 「복사」 한 줄 const root = el("div", { className: `ss${readOnly ? " ss--readonly" : ""}${noTabs ? " ss--notabs" : ""}${opts.preview ? " ss--preview" : ""}`, }); @@ -142,7 +142,7 @@ export function createSpreadsheet( ctx.editor = ed; const toolbar: PartHandle | null = bare ? null : mountToolbar(ctx); const tabs: PartHandle | null = noTabs ? null : mountTabs(ctx); - const menu: PartHandle | null = bare ? null : attachMenu(ctx); + const menu: PartHandle = attachMenu(ctx); // 읽기 전용 · 미리보기는 「복사」 한 줄(81-3) const clip = attachClipboard(ctx); const status = createStatusBar(ctx); const others = [toolbar, tabs, menu, clip, status].filter((p): p is PartHandle => !!p); diff --git a/A00_Common/spreadsheet/spreadsheet_menu.ts b/A00_Common/spreadsheet/spreadsheet_menu.ts index 603fa99e7..a2af5138f 100644 --- a/A00_Common/spreadsheet/spreadsheet_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_menu.ts @@ -12,10 +12,21 @@ * ========================================================================== */ import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; -import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { createButton, el } from "@ui/ui_template_elements"; import { rangeToA1 } from "./spreadsheet_address"; import { confirmMerge, dialogKeys } from "./spreadsheet_actions"; import { deleteComment, getComment, setComment } from "./spreadsheet_comments"; +import { + cellInsertItems, + cellMoreItems, + colInsertItems, + colMoreItems, + copyCut, + copyOnlyItems, + pasteItems, + rowInsertItems, + rowMoreItems, +} from "./spreadsheet_menu_items"; import { st } from "./spreadsheet_text"; import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types"; import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; @@ -28,7 +39,8 @@ function within(range: CellRange, r: number, c: number): boolean { /** `ctx.root` 의 contextmenu 를 받음(root null) */ export function attachMenu(ctx: SpreadsheetContext): PartHandle { - if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} }; + // 읽기 전용 · 미리보기 = 「복사」 한 줄(81-3) + const bare = ctx.readOnly || ctx.root.classList.contains("ss--preview"); const menu = createMapContextMenu("ss"); ctx.root.append(menu.element); @@ -39,7 +51,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { } function cutCopy(kind: "cut" | "copy"): void { - document.execCommand(kind); + copyCut(ctx, kind); } /** 메모 글 입력 — 작은 오버레이(native prompt 안 씀, 자동화 창이 native 창을 자동 취소하는 @@ -79,7 +91,10 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { return [ [st("MenuCut"), () => cutCopy("cut")], [st("MenuCopy"), () => cutCopy("copy")], - [st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")], + ...pasteItems(ctx), + "-", + ...cellInsertItems(ctx, range), + "-", [ st("MenuClearContent"), () => ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [range] }), @@ -107,6 +122,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { ...(note ? ([[st("MenuCommentDelete"), () => deleteComment(ctx, r, c)]] as MapContextMenuItem[]) : []), + ...cellMoreItems(ctx, range), ...(ctx.cellMenuExtra?.(r, c) ?? []), ]; } @@ -117,11 +133,9 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { return [ [st("MenuCut"), () => cutCopy("cut")], [st("MenuCopy"), () => cutCopy("copy")], - [st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")], - [ - st("MenuInsertRow"), - () => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at, 수 }), - ], + ...pasteItems(ctx), + "-", + ...rowInsertItems(ctx, at, 수), [ st("MenuDeleteRow"), () => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at, 수 }), @@ -148,6 +162,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { 숨김: false, }), ], + ...rowMoreItems(ctx, rows), ]; } @@ -157,11 +172,9 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { return [ [st("MenuCut"), () => cutCopy("cut")], [st("MenuCopy"), () => cutCopy("copy")], - [st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")], - [ - st("MenuInsertCol"), - () => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at, 수 }), - ], + ...pasteItems(ctx), + "-", + ...colInsertItems(ctx, at, 수), [ st("MenuDeleteCol"), () => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at, 수 }), @@ -188,6 +201,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { 숨김: false, }), ], + ...colMoreItems(ctx, cols), ]; } @@ -217,7 +231,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { 활성: { r: hit.r, c: 0 }, 기준: { r: hit.r, c: 0 }, }); - menu.open(x, y, rowMenuItems(rows)); + menu.open(x, y, bare ? copyOnlyItems(ctx) : rowMenuItems(rows)); return; } @@ -235,7 +249,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { 활성: { r: 0, c: hit.c }, 기준: { r: 0, c: hit.c }, }); - menu.open(x, y, colMenuItems(cols)); + menu.open(x, y, bare ? copyOnlyItems(ctx) : colMenuItems(cols)); return; } @@ -250,7 +264,13 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { }); } const active = ctx.selection.범위[0] ?? range(hit.r, hit.c, hit.r, hit.c); - menu.open(x, y, cellMenuItems(active, hit.r, hit.c)); + menu.open( + x, + y, + bare + ? [...copyOnlyItems(ctx), ...(ctx.cellMenuExtra?.(hit.r, hit.c) ?? [])] + : cellMenuItems(active, hit.r, hit.c), + ); } } diff --git a/resources/tester/spreadsheet/test_stage4_menu_items.ts b/resources/tester/spreadsheet/test_stage4_menu_items.ts new file mode 100644 index 000000000..3c7fe1aec --- /dev/null +++ b/resources/tester/spreadsheet/test_stage4_menu_items.ts @@ -0,0 +1,304 @@ +/* ============================================================================= + * test_stage4_menu_items.ts — 우클릭 메뉴 덧붙인 항목(81-3) 시험. + * 행 위/아래 · 열 좌우 삽입 · 셀 삽입/삭제 · 행열 삭제 · 크기 · 시트 정렬 · 필터 · 고정 · 그룹 · + * 범위 이름 · 붙여넣기 보안 문맥 갈래 · 구분선 · 읽기 전용 복사. + * 돌리기: npx tsx resources/tester/spreadsheet/test_stage4_menu_items.ts + * ========================================================================== */ + +import { register } from "node:module"; +import type { MapContextMenuItem } from "../../../ui_template/ui_template_context_menu.ts"; +import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands.ts"; +import { createCalcEngine } from "../../../A00_Common/spreadsheet/spreadsheet_graph.ts"; +import { + canMenuPaste, + cellInsertItems, + cellMoreItems, + colInsertItems, + colMoreItems, + copyOnlyItems, + menuPaste, + nameRange, + pasteItems, + resizeLines, + rowInsertItems, + rowMoreItems, + sortByColumn, + usedRange, +} from "../../../A00_Common/spreadsheet/spreadsheet_menu_items.ts"; +import type { Cell, Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types.ts"; +import type { + Selection, + SpreadsheetContext, +} from "../../../A00_Common/spreadsheet/spreadsheet_view_types.ts"; +import { a1, assertEqual, makeBook, makeCtx, makeSheet, v } from "./test_stage2_helpers.ts"; + +// 늦게 부르는 창 모듈의 css 는 node 에서 빈 모듈로 +register( + "data:text/javascript,export async function load(u,c,n){return u.endsWith('.css')?{format:'module',source:'',shortCircuit:true}:n(u,c)}", +); +const tick = () => new Promise((r) => setTimeout(r, 30)); + +/** 진짜 명령 · 엔진 · 고름을 쓰는 문맥 */ +function realCtx(cells: Record): SpreadsheetContext & { sent: Command[] } { + const book: Workbook = makeBook([makeSheet("s1", cells)]); + const base = makeCtx(book, "s1"); + const sent: Command[] = []; + const ctx = base as unknown as SpreadsheetContext & { sent: Command[] }; + ctx.engine = createCalcEngine(book); + ctx.sent = sent; + ctx.dispatch = (cmd: Command) => { + sent.push(cmd); + applyCommand(book, cmd); + ctx.engine.rebuild(book); + }; + ctx.select = (sel: Selection) => void (ctx.selection = sel); + return ctx; +} +const col = (n: number) => + Object.fromEntries(Array.from({ length: n }, (_, i) => [a1(i, 0), v(i + 1)])); +const row = (n: number) => + Object.fromEntries(Array.from({ length: n }, (_, i) => [a1(0, i), v(i + 1)])); +const val = (ctx: SpreadsheetContext, r: number, c: number) => ctx.sheet().칸[a1(r, c)]?.값; +const leaf = (items: MapContextMenuItem[], label: string): (() => void) => { + for (const it of items) { + if (it === "-") continue; + if (Array.isArray(it)) { + if (it[0] === label) return it[1]; + } else { + const hit = it.children.find(([l]) => l === label); + if (hit) return hit[1]; + } + } + throw new Error(`항목 없음: ${label}`); +}; +const labels = (items: MapContextMenuItem[]) => + items.map((it) => (it === "-" ? "-" : Array.isArray(it) ? it[0] : `${it.label} ▸`)); + +// ── 행 위 · 아래 삽입(고른 2행 B2:B3 → at 1 · 수 2) ───────────────────────── +{ + const ctx = realCtx(col(5)); + leaf(rowInsertItems(ctx, 1, 2), "위에 2행 삽입")(); + assertEqual( + [val(ctx, 0, 0), val(ctx, 1, 0), val(ctx, 2, 0), val(ctx, 3, 0)], + [1, undefined, undefined, 2], + "위에 2행 — 2행부터 밀림", + ); +} +{ + const ctx = realCtx(col(5)); + leaf(rowInsertItems(ctx, 1, 2), "아래에 2행 삽입")(); + assertEqual( + [val(ctx, 2, 0), val(ctx, 3, 0), val(ctx, 4, 0), val(ctx, 5, 0)], + [3, undefined, undefined, 4], + "아래에 2행 — 고른 줄 뒤에 끼움", + ); +} +// ── 열 왼 · 오른쪽 삽입 ────────────────────────────────────────────────── +{ + const ctx = realCtx(row(4)); + leaf(colInsertItems(ctx, 1, 1), "왼쪽에 1열 삽입")(); + assertEqual([val(ctx, 0, 0), val(ctx, 0, 1), val(ctx, 0, 2)], [1, undefined, 2], "왼쪽에 1열"); +} +{ + const ctx = realCtx(row(4)); + leaf(colInsertItems(ctx, 1, 1), "오른쪽에 1열 삽입")(); + assertEqual([val(ctx, 0, 1), val(ctx, 0, 2), val(ctx, 0, 3)], [2, undefined, 3], "오른쪽에 1열"); +} +// ── 칸 메뉴 — 셀 삽입/삭제 ▸ · 행열 삭제 ▸ ────────────────────────────── +{ + const g = { r0: 0, c0: 1, r1: 0, c1: 1 }; + const ctx = realCtx(row(3)); + const items = cellInsertItems(ctx, g); + assertEqual( + labels(items), + ["행 · 열 삽입 ▸", "셀 삽입 ▸", "행 · 열 삭제 ▸", "셀 삭제 ▸"], + "칸 메뉴 하위 넷", + ); + leaf(items, "셀 삽입 후 오른쪽으로 밀기")(); + await tick(); + assertEqual( + [val(ctx, 0, 1), val(ctx, 0, 2), val(ctx, 0, 3)], + [undefined, 2, 3], + "셀 삽입 · 오른쪽으로", + ); + leaf(cellInsertItems(ctx, g), "셀 삭제 후 왼쪽으로 당기기")(); + await tick(); + assertEqual([val(ctx, 0, 1), val(ctx, 0, 2)], [2, 3], "셀 삭제 · 왼쪽으로"); +} +{ + const ctx = realCtx(col(3)); + leaf(cellInsertItems(ctx, { r0: 1, c0: 0, r1: 1, c1: 0 }), "셀 삽입 후 아래로 밀기")(); + await tick(); + assertEqual([val(ctx, 1, 0), val(ctx, 2, 0)], [undefined, 2], "셀 삽입 · 아래로"); + leaf(cellInsertItems(ctx, { r0: 1, c0: 0, r1: 1, c1: 0 }), "셀 삭제 후 위로 당기기")(); + await tick(); + assertEqual([val(ctx, 1, 0), val(ctx, 2, 0)], [2, 3], "셀 삭제 · 위로"); + leaf(cellInsertItems(ctx, { r0: 0, c0: 0, r1: 1, c1: 0 }), "2행 삭제")(); + assertEqual(val(ctx, 0, 0), 3, "칸 메뉴 행 삭제 · 고른 2행"); +} +// ── 크기 — px → 열폭(글자) · 행높이(pt) · 잘못된 값 ───────────────────────── +{ + const ctx = realCtx(row(2)); + assertEqual( + resizeLines(ctx, false, [0, 1], "abc"), + "1 이상 숫자를 넣을 것", + "크기 · 글이면 거름", + ); + assertEqual(resizeLines(ctx, false, [0, 1], "0"), "1 이상 숫자를 넣을 것", "크기 · 0 거름"); + assertEqual(resizeLines(ctx, false, [0, 1], "100"), null, "열 너비 100px"); + const w = ctx.sheet().열!; + assertEqual(w.A?.폭 === w.B?.폭 && (w.A?.폭 ?? 0) > 10, true, "열 둘 같은 폭(한 명령)"); + resizeLines(ctx, true, [2], "40"); + assertEqual( + Math.round((ctx.sheet().행?.["3"]?.높이 ?? 0) * 100) / 100, + 30, + "행 높이 40px = 30pt", + ); +} +// ── 시트 정렬(열 머리) — 쓴 범위 통째 · 고름 되돌림 · 빈 열은 안 함 ─────────────── +{ + const ctx = realCtx({ + A1: v("이름"), + B1: v("값"), + A2: v("다"), + B2: v(3), + A3: v("가"), + B3: v(1), + A4: v("나"), + B4: v(2), + }); + const keep = ctx.selection; + await sortByColumn(ctx, 1, false); + assertEqual( + [val(ctx, 1, 0), val(ctx, 2, 0), val(ctx, 3, 0)], + ["가", "나", "다"], + "B 열 기준 오름 — A 열도 같이", + ); + assertEqual(val(ctx, 0, 0), "이름", "머리 행 그대로"); + assertEqual(ctx.selection, keep, "정렬 뒤 고름 되돌림"); + await sortByColumn(ctx, 1, true); + assertEqual([val(ctx, 1, 1), val(ctx, 3, 1)], [3, 1], "내림"); + const before = ctx.sent.length; + await sortByColumn(ctx, 5, false); + assertEqual(ctx.sent.length, before, "빈 열 정렬은 명령 없음"); + assertEqual(usedRange(ctx.sheet()), { r0: 0, c0: 0, r1: 3, c1: 1 }, "쓴 범위"); +} +// ── 필터 · 고정 · 그룹 · 더보기 ───────────────────────────────────────────── +{ + const ctx = realCtx({ A1: v("머리"), A2: v(1), B2: v(2) }); + const calls: [string, unknown][] = []; + ctx.dataMenu = (kind) => calls.push([kind, structuredClone(ctx.selection.범위)]); + const more = colMoreItems(ctx, [0]); + assertEqual( + labels(more), + [ + "-", + "열 크기 조절…", + "데이터에 맞추기", + "-", + "시트 정렬 A → Z", + "시트 정렬 Z → A", + "필터 만들기", + "-", + "고정 ▸", + "그룹 ▸", + ], + "열 머리 덧붙임 순서", + ); + leaf(more, "필터 만들기")(); + await tick(); + assertEqual(calls[0], ["filter", [{ r0: 0, c0: 0, r1: 1, c1: 1 }]], "열 머리 필터 = 쓴 범위로"); + leaf(cellMoreItems(ctx, { r0: 0, c0: 0, r1: 0, c1: 0 }), "드롭다운…")(); + leaf(cellMoreItems(ctx, { r0: 0, c0: 0, r1: 0, c1: 0 }), "조건부 서식…")(); + assertEqual( + calls.slice(1).map((c) => c[0]), + ["validation", "condfmt"], + "더보기 → 데이터 창", + ); + leaf(rowMoreItems(ctx, [0]), "첫 행 고정")(); + await tick(); + assertEqual(ctx.sent.at(-1)?.종류, "틀고정", "고정 ▸ 첫 행"); + ctx.selection = { + 시트: "s1", + 범위: [{ r0: 1, c0: 0, r1: 1, c1: 16383 }], + 활성: { r: 1, c: 0 }, + 기준: { r: 1, c: 0 }, + }; + leaf(rowMoreItems(ctx, [1]), "그룹으로 묶기")(); + await tick(); + assertEqual(ctx.sheet().행?.["2"]?.묶음, { 층: 1 }, "그룹 ▸ 묶기"); + assertEqual( + await nameRange(ctx, "", { r0: 0, c0: 0, r1: 0, c1: 0 }), + "이름을 넣을 것", + "범위 이름 · 빈 값", + ); + assertEqual( + await nameRange(ctx, "합계칸", { r0: 1, c0: 0, r1: 1, c1: 1 }), + null, + "범위 이름 지정", + ); + assertEqual( + typeof (await nameRange(ctx, "합계칸", { r0: 0, c0: 0, r1: 0, c1: 0 })), + "string", + "같은 이름은 거름", + ); +} +// ── 붙여넣기 — 보안 문맥 갈래 ─────────────────────────────────────────────── +{ + const g = globalThis as Record; + g.isSecureContext = false; + assertEqual(canMenuPaste(), false, "http(보안 문맥 아님) — 메뉴로 못 읽음 → Ctrl+V 안내"); + g.isSecureContext = true; + assertEqual(canMenuPaste(), false, "클립보드 API 없음 — 안내"); + const clip = { html: "", text: "7\t8" }; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + read: async () => [ + { + types: ["text/plain"], + getType: async () => new Blob([clip.text]), + }, + ], + }, + }); + assertEqual(canMenuPaste(), true, "localhost · https — 메뉴로 읽음"); + class FakeData { + data: Record = {}; + setData(k: string, s: string) { + this.data[k] = s; + } + } + class FakePaste { + constructor( + public type: string, + public init: { clipboardData: FakeData }, + ) {} + } + g.DataTransfer = FakeData; + g.ClipboardEvent = FakePaste; + const ctx = realCtx({}); + const events: FakePaste[] = []; + ctx.root = { dispatchEvent: (e: FakePaste) => events.push(e) } as unknown as HTMLElement; + await menuPaste(ctx); + assertEqual( + [events[0]?.type, events[0]?.init.clipboardData.data["text/plain"]], + ["paste", "7\t8"], + "보통 붙여넣기 = 부품 paste 길로", + ); + ctx.selection = { + 시트: "s1", + 범위: [{ r0: 2, c0: 0, r1: 2, c1: 0 }], + 활성: { r: 2, c: 0 }, + 기준: { r: 2, c: 0 }, + }; + await menuPaste(ctx, "값"); + assertEqual([val(ctx, 2, 0), val(ctx, 2, 1)], [7, 8], "값만 붙여넣기 — 활성 칸부터"); + assertEqual(labels(pasteItems(ctx)), ["붙여넣기", "선택하여 붙여넣기 ▸"], "붙여넣기 항목"); +} +// ── 읽기 전용 — 복사 한 줄 ───────────────────────────────────────────────── +{ + const ctx = realCtx({}); + assertEqual(labels(copyOnlyItems(ctx)), ["복사"], "읽기 전용 · 미리보기 = 복사 한 줄"); +} +console.log("spreadsheet_menu_items 시험 끝"); From bdfadc057d74776eb595636838292e445830cbff Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:36:06 +0900 Subject: [PATCH 40/47] =?UTF-8?q?feat(spreadsheet):=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=CE=A6=20=E2=80=94=20=EA=B8=B8=EC=9D=B4=20=EB=AC=B6=EC=9D=8C?= =?UTF-8?q?=20=EB=81=9D=20=C2=B7=20=EC=B9=B8=20=EA=B8=80=20=C2=B7=20?= =?UTF-8?q?=ED=98=95=EC=8B=9D=20=EC=BD=94=EB=93=9C=20=EC=95=9E=EC=97=90=20?= =?UTF-8?q?=EB=B6=99=EC=9E=84(81-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- A00_Common/spreadsheet/spreadsheet_toolbar.ts | 30 +++++--- .../tester/test_spreadsheet_unit_prefix.py | 70 +++++++++++++++++++ 2 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 resources/tester/test_spreadsheet_unit_prefix.py diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.ts b/A00_Common/spreadsheet/spreadsheet_toolbar.ts index 5ff071829..9a934bf69 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.ts +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.ts @@ -63,12 +63,14 @@ const BORDER_LINES: BorderLine[] = [ type TextKey = Parameters[0]; const UNIT_GROUPS: [label: TextKey, items: string[]][] = [ - ["UnitGroup_area", ["㎡", "㎥", "m", "㎝", "㎜", "㎞"]], + ["UnitGroup_area", ["㎡", "㎥", "m", "㎝", "㎜", "㎞", "Φ"]], ["UnitGroup_weight", ["kg", "t", "ℓ", "㎖"]], ["UnitGroup_count", ["개", "본", "식", "EA", "매"]], ["UnitGroup_rate", ["m/s", "°", "%", "‰"]], ["UnitGroup_op", ["×", "÷", "±", "≤", "≥", "Σ"]], ]; +/** 숫자 앞에 붙는 단위(관 지름 Φ300) — 나머지는 뒤에 붙음 */ +const UNIT_PREFIX = ["Φ"]; const UNIT_RECENT_MAX = 8; const UNIT_RECENT_KEY = "aislo-ss-recent-units"; @@ -91,10 +93,14 @@ function pushRecentUnit(unit: string): string[] { return list; } -/** 숫자 형식 코드 끝 따옴표 글자(전에 붙인 단위)를 떼고 새 단위를 붙임 — 없으면 "0" 에서 시작. */ +/** 숫자 형식 코드 끝 따옴표 글자(전에 붙인 단위)를 떼고 새 단위를 붙임 — 없으면 "0" 에서 시작. + * 앞 단위(Φ)는 코드 앞에 붙임(`"Φ"0`) — 앞에 붙은 앞 단위는 먼저 뗌. */ function attachUnitToFormat(code: string, unit: string): string { - const base = (code || "0").replace(/"[^"]*"\s*$/, ""); - return base + `"${unit}"`; + const base = code || "0"; + if (!UNIT_PREFIX.includes(unit)) return base.replace(/"[^"]*"\s*$/, "") + `"${unit}"`; + let plain = base; + for (const p of UNIT_PREFIX) if (plain.startsWith(`"${p}"`)) plain = plain.slice(p.length + 2); + return `"${unit}"` + plain; } /** 활성 칸에 지금 걸린 서식 — 칸 자기 것 없으면 열 · 행 · 시트 기본(`Sheet.기본.서식`) · 그다음 0. */ @@ -399,10 +405,13 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { if (ctx.editor.editing()) { const active = document.activeElement; if (active instanceof HTMLTextAreaElement && ctx.root.contains(active)) { - const s = active.selectionStart ?? active.value.length; - const e = active.selectionEnd ?? active.value.length; + const front = UNIT_PREFIX.includes(unit); + const s = front ? 0 : (active.selectionStart ?? active.value.length); + const e = front ? 0 : (active.selectionEnd ?? active.value.length); + const was = active.selectionStart ?? active.value.length; active.value = active.value.slice(0, s) + unit + active.value.slice(e); - active.setSelectionRange(s + unit.length, s + unit.length); + const at = front ? was + unit.length : s + unit.length; + active.setSelectionRange(at, at); active.dispatchEvent(new Event("input", { bubbles: true })); } } else if (unitAsFormat.checked) { @@ -412,7 +421,12 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { const { r, c } = ctx.selection.활성; const sheet = ctx.sheet(); const key = toA1(r, c); - const text = cellToText(sheet.칸[key]) + unit; + const now = cellToText(sheet.칸[key]); + const text = UNIT_PREFIX.includes(unit) + ? now.startsWith(unit) + ? now + : unit + now // 앞 단위 — 「300」 → 「Φ300」 + : now + unit; ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: { [key]: textToCell(text, sheet.칸[key]) } }); } renderRecent(pushRecentUnit(unit)); diff --git a/resources/tester/test_spreadsheet_unit_prefix.py b/resources/tester/test_spreadsheet_unit_prefix.py new file mode 100644 index 000000000..ff96c3f63 --- /dev/null +++ b/resources/tester/test_spreadsheet_unit_prefix.py @@ -0,0 +1,70 @@ +"""스프레드시트 단위 Φ(PLAN 81-5 · `spreadsheet_toolbar.ts`) — 길이 묶음 끝에 Φ · 숫자 앞에 붙임. + +형식 코드 갈래(`attachUnitToFormat`)는 Node 로 함수를 떼어 돌리고, 칸 글 · 편집기 갈래는 글로 확인 +(화면 조작은 ORCA). +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TOOLBAR = ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_toolbar.ts" + + +def _text() -> str: + return TOOLBAR.read_text(encoding="utf-8") + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_형식_코드_앞_단위_뒤_단위(): + src = _text() + prefix = re.search(r"^const UNIT_PREFIX = .*;$", src, re.M) + func = re.search(r"^function attachUnitToFormat[\s\S]*?^}", src, re.M) + assert prefix and func + script = ( + prefix.group(0) + + "\n" + + func.group(0).replace(": string", "") # 형 이름 뗌 — Node 가 그대로 돌게 + + """ +const f = attachUnitToFormat; +console.log(JSON.stringify([ + f("", "Φ"), // 처음 = 「Φ」 + 0 + f("0.00", "Φ"), // 코드 앞 + f('"Φ"0', "Φ"), // 두 번 눌러도 한 번 + f('0"m"', "Φ"), // 뒤 단위는 그대로 + f('"Φ"0', "m"), // 뒤 단위를 붙여도 앞 단위 살아 있음 + f('0"m"', "㎜"), // 옛 동작 — 뒤 단위 바꿈 + f("", "m"), +])); +""" + ) + out = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, encoding="utf-8", check=True + ) + assert json.loads(out.stdout) == [ + '"Φ"0', + '"Φ"0.00', + '"Φ"0', + '"Φ"0"m"', + '"Φ"0"m"', + '0"㎜"', + '0"m"', + ] + + +def test_Φ_길이_묶음_끝_칸_글_편집기(): + src = _text() + assert '["UnitGroup_area", ["㎡", "㎥", "m", "㎝", "㎜", "㎞", "Φ"]]' in src + body = src.split("function insertUnit")[1].split("function renderRecent")[0] + assert "UNIT_PREFIX.includes(unit)" in body + assert ( + "now.startsWith(unit)" in body and "unit + now" in body + ) # 「300」 → 「Φ300」 · 두 번 안 붙음 + assert "const s = front ? 0" in body # 편집 중에도 글 맨 앞 From 3db09b790ce959d6c855d1979a04b934409853e5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:37:15 +0900 Subject: [PATCH 41/47] =?UTF-8?q?feat(spreadsheet):=20=EC=95=8C=EC=95=BD?= =?UTF-8?q?=20=EC=A4=84=20=EB=81=9D=20[=EC=97=B0=EA=B2=B0=20=EB=AA=A8?= =?UTF-8?q?=EB=91=90=20=ED=95=B4=EC=A0=9C]=20=E2=80=94=20=EB=AC=BC?= =?UTF-8?q?=EC=9D=8C=20=ED=95=9C=20=EB=B2=88=20=C2=B7=20=EB=AC=B6=EC=9D=8C?= =?UTF-8?q?=20=EB=AA=85=EB=A0=B9=20=ED=95=9C=20=EB=B2=88(81-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../spreadsheet/spreadsheet_link_set.ts | 14 ++++++++++ A00_Common/spreadsheet/spreadsheet_links.css | 7 +++++ A00_Common/spreadsheet/spreadsheet_links.ts | 26 +++++++++++++++++-- .../spreadsheet/spreadsheet_link_set_entry.ts | 22 ++++++++++++++++ resources/tester/test_spreadsheet_set_link.py | 12 ++++++++- 5 files changed, 78 insertions(+), 3 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet_link_set.ts b/A00_Common/spreadsheet/spreadsheet_link_set.ts index 0a2db64b1..faea14751 100644 --- a/A00_Common/spreadsheet/spreadsheet_link_set.ts +++ b/A00_Common/spreadsheet/spreadsheet_link_set.ts @@ -70,6 +70,20 @@ export function linkCommand( return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }; } +/** 알약 연결을 전부 풀 명령(81-4) — 연결 있는 시트마다 연결을 뺀 `시트통째` · 시트가 둘 이상이면 `묶음` + * (되돌리기 한 번) · 풀 연결이 없으면 null */ +export function clearLinksCommand(book: Workbook): Command | null { + const commands: Command[] = book.시트 + .filter((s) => s.연결?.length) + .map((s) => { + const copy = structuredClone(s); + delete copy.연결; + return { 종류: "시트통째", 시트: copy }; + }); + if (!commands.length) return null; + return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }; +} + /** 면적 문서에 알약 키를 잇기 · 풀기(`칸` null) — 활성 시트에 · 새 문서를 돌려줌(받은 문서는 그대로 · * 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null · 같은 칸 다른 알약은 함께 둠(`replace` 면 뺌). 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */ export function linkAreaCell( diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css index 30694cd8e..6c3c131a7 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.css +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -89,6 +89,13 @@ color: var(--color-text-secondary); } +/* 알약 줄 끝 [연결 모두 해제] — 점선 알약과 같은 결 · 오른쪽 끝으로 밀어 알약과 떨어뜨림 */ +.ss-link--clear { + margin-left: auto; + border-style: dashed; + color: var(--color-text-secondary); +} + /* 연결 칸 왼아래 세모(초록 오류 세모는 왼위 · 빨강 메모는 오른위) */ .ss-link-dot { position: absolute; diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index e2d31e23a..8643d1b3f 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -14,12 +14,12 @@ import "./spreadsheet_links.css"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; -import { el, showToast } from "@ui/ui_template_elements"; +import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { inRange, parseA1, parseRange, quoteSheet, toA1 } from "./spreadsheet_address"; import { applyCommand } from "./spreadsheet_commands"; import { askShare } from "./spreadsheet_link_ask"; import { linkHues } from "./spreadsheet_link_color"; -import { linkCommand, linksAt } from "./spreadsheet_link_set"; +import { clearLinksCommand, linkCommand, linksAt } from "./spreadsheet_link_set"; import type { slotOf } from "./spreadsheet_editor"; import { shiftSpan } from "./spreadsheet_refshift"; import { selectCell } from "./spreadsheet_selection"; @@ -73,6 +73,9 @@ const TEXT = { remove: "목록에서 빼기", removed: "목록에서 뺌 — 연결도 풀림", add: "+ 자재 추가", + clearAll: "연결 모두 해제", + clearTip: "이은 알약 연결을 한꺼번에 풂 — 알약 목록은 그대로 · 되돌리기 한 번", + clearAsk: "알약 연결 {n}개를 모두 풀까?", search: "자재 이름 · 규격 · 코드", searching: "찾는 중…", none: "맞는 자재 없음", @@ -190,6 +193,16 @@ export function mountLinks( back(); } + /** 알약 줄 끝 [연결 모두 해제](81-4) — 물음 한 번 · 연결만 빠짐(목록 그대로) · 시트 여럿이어도 되돌리기 한 번 */ + async function clearAll(): Promise { + const n = ctx.book.시트.reduce((sum, s) => sum + (s.연결?.length ?? 0), 0); + const command = clearLinksCommand(ctx.book); + if (ctx.readOnly || !command) return; + if (await showConfirmDialog(TEXT.clearAsk.replace("{n}", String(n)), TEXT.clearAll)) + ctx.dispatch(command); + back(); + } + /** 그 칸으로 가서 눈에 띄게 깜빡임(굵은 테두리 + 채움 · 테마 색 · 끝나면 스스로 사라짐) */ function goTo(sheet: Sheet, a1: string): void { const p = parseA1(a1); @@ -353,6 +366,15 @@ export function mountLinks( more.addEventListener("click", () => openPick(more)); pills.push(more); } + if (!ctx.readOnly && map.size) { + const clear = el("button", { + className: "ss-link ss-link--clear", + text: TEXT.clearAll, + attrs: { type: "button", title: TEXT.clearTip }, + }); + clear.addEventListener("click", () => void clearAll()); + pills.push(clear); + } bar.replaceChildren(...pills); if (fresh) bar.querySelector(".is-here")?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); diff --git a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts index 45acbf3be..8a74f2d55 100644 --- a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts @@ -3,6 +3,7 @@ import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands"; import { linkHues } from "../../../A00_Common/spreadsheet/spreadsheet_link_color"; import { + clearLinksCommand, linkAreaCell, linkCommand, linksAt, @@ -33,6 +34,11 @@ const book = (): Workbook => ], 활성: "s1", }) as unknown as Workbook; +const b0 = (): Workbook => { + const x = book(); + x.시트.forEach((t) => delete t.연결); + return x; +}; const links = (b: Workbook) => b.시트.map((s) => s.연결 ?? null); const run = (b: Workbook, c: Command | null | false) => { if (!c) return null; @@ -129,6 +135,22 @@ eq("틀린 주소", linkAreaCell(b, "철근|ton", "9C"), null); eq("풀기 · 시트 넘어", links(linkAreaCell(b, "치기|㎥", null)!), [links(book())[0], null]); eq("바뀔 것 없음 = 사본", JSON.stringify(linkAreaCell(b, "깔기|㎡", "B6")), before); +// 연결 모두 해제(81-4) — 연결 있는 시트마다 시트통째 · 둘 넘으면 묶음 · 되돌리기 한 번 · 없으면 null +b = book(); +const wholeBefore = JSON.stringify(b); +const clearAll = clearLinksCommand(b); +eq("모두 해제 = 묶음", clearAll && clearAll.종류, "묶음"); +undo = run(b, clearAll); +eq("모두 해제 뒤 연결 0", links(b), [null, null]); +eq("칸 값은 그대로", b.시트[0].칸, { B1: { 값: 12.5 } }); +if (undo) applyCommand(b, undo); +eq("되돌리기 한 번에 전부 돌아옴", JSON.stringify(b), wholeBefore); +eq("연결 없으면 null", clearLinksCommand(b0()), null); +b = book(); +b.시트[1].연결 = undefined; +const one = clearLinksCommand(b); +eq("시트 하나만 있으면 시트통째 하나", one && one.종류, "시트통째"); + // linkHues — 키로 정해짐 · 차례 무관 · 24 개까지 서로 다름 · 12 개까지 30° 바퀴 · 15° 눈금 const keys = Array.from({ length: 24 }, (_, i) => `알약${i}|㎡`); const h1 = linkHues(keys); diff --git a/resources/tester/test_spreadsheet_set_link.py b/resources/tester/test_spreadsheet_set_link.py index fbb65186c..45766837a 100644 --- a/resources/tester/test_spreadsheet_set_link.py +++ b/resources/tester/test_spreadsheet_set_link.py @@ -55,7 +55,7 @@ def test_setLink_순수_길(): assert run.stdout.strip(), run.stderr result = json.loads(run.stdout) assert result["fail"] == 0, result["failures"] - assert result["pass"] == 29 + assert result["pass"] == 35 def test_손잡이_setLink_몸(): @@ -90,3 +90,13 @@ def test_알약_끌어_놓기_색(): "--ss-link-l: 31%;" in css and css.count("--ss-link-l: 66%;") == 2 ) # 밝음 · 어두움(설정 · 시스템) assert "ss-link-flash" in css # 반짝임 그대로 + + +def test_연결_모두_해제_단추는_알약_줄_끝_편집_가능일_때만(): + src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_links.ts").read_text(encoding="utf-8") + assert "if (!ctx.readOnly && map.size) {" in src # 읽기 전용 · 연결 0 = 단추 없음 + assert 'className: "ss-link ss-link--clear"' in src + body = src.split("async function clearAll")[1].split("function goTo")[0] + assert "showConfirmDialog(TEXT.clearAsk.replace" in body # 물음 한 번 + assert "ctx.dispatch(command)" in body # 묶음 명령 한 번 = 되돌리기 한 번 + assert 'clearAsk: "알약 연결 {n}개를 모두 풀까?"' in src From 71d15bf10ba2582c3fe3145c6dcb13c65b282d78 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 12:37:46 +0900 Subject: [PATCH 42/47] =?UTF-8?q?chore(plan):=20=EB=B6=84=EC=84=9D=5F?= =?UTF-8?q?=EC=A0=95=ED=95=A8=2091-1=20=ED=8C=90=EC=A0=95=20=EC=A4=84=20?= =?UTF-8?q?=E2=80=94=20=EB=A9=94=EC=9D=B8=20=ED=8C=90=EA=B3=BC=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EB=82=B4=EC=9A=A9=20=EB=B0=9B=EC=95=84=20=EB=91=A0?= =?UTF-8?q?(91-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- docs/raw/요구_1008/분석_정함.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1008/분석_정함.md b/docs/raw/요구_1008/분석_정함.md index a8e8395cc..2fca9cb8a 100644 --- a/docs/raw/요구_1008/분석_정함.md +++ b/docs/raw/요구_1008/분석_정함.md @@ -56,6 +56,7 @@ - **유출 단 수(90-1)** — 관보호공 「추가 흙막이(단)」 스테퍼를 공용으로 세월교 · 박스 유출에 · 「+ 흙막이」 단추 없앰 · 단 수 → 상자 수(관보호공과 같은 방향) · 집계표도 단 수로 상자 자름 · 유출이 날개벽 · 없음인데 상자가 있던 자료 = 숨기고 집계에서 뺌(값은 파일에 남음). - **도수로 · 돌붙임(90-2)** — 두께 칸(cm · 기본 30 · 사용자 확정) · 유출 성토사면 선을 사면 안쪽으로 두께만큼 오프셋한 띠(윗면 = 사면선 · 근거 교본 「지반선보다 낮게 원지반에 설치」) · 유출 끝 ~ 성토사면이 원지반 만나는 곳(흙막이 단 사이 성토 조각 포함) · 안에 돌 무늬(흙막이 돌 그리기 재사용) · 도수로 · 돌붙임 형상 같음 · 배관 · 박스 · 세월교 · 물넘이포장(`ford_chute`) · 면적 · 터파기 영향 없음. - **제약 삭제(91)** — 형상 자체(벽 외곽)는 원지반 · 암 참조 없음(조사_G 1). 「해당」 = 세월교 바닥 · 흙막이 기본 높이(하상) · 다단 자동 자리(원지반에 앉는 x) · I형 집수정 하단. 더 = 설계선 끝 2 px 판정(줌마다 끝점 변동) · 전면 기울기 지형 자동판정. 91-1 에서 재현 목록 → 브레인 판정 → 91-3. 끝 연결선(성토부선 · 절토선이 원지반에서 끝남)은 형상 아님 → 남김. +- **91-1 판정(브레인 · 10-8)** — ① 세월교 바닥 = 끊음(91-2 손 칸) ② 다단 자동 자리 = 끊음(지형 무관 등간격 · 앞 단 바닥 + 1:1.2 사면 일정 길이) ③ I형 집수정 근입 = 남김(집수정 형상 규칙 · 흙막이 아님) ④ 설계선 끝 2 px 판정 = 끊음(수치 교점 0.01 m) ⑤ 전면 기울기 지형 자동판정 = 끊음(관보호공 · 세월교 · 박스 흙막이 = 성토부 표값 고정 · 사면안정 흙막이 · 옹벽 = 설치 측 칸 값 · B08 도 같은 함수) ⑥ 끝 연결선 = 남김(잔재 문구만 지움). - **세월교 바닥(91-2)** — 처음 놓을 때 하상값 · 손 조정 칸(박스 `riseM` 꼴) · 원지반이 바뀌어도 안 따라감 · 흙막이 기본 높이도 이 값 기준. - **복합경사(91-4)** — 공용 도우미 하나(`common_util_cross_berm.ts cutProfilePoints` · 경계 = 원지반 + `rock_boundary_offset_m` · 켬 = 암 프리셋 + `two_stage_slope` · 소단 = `design.berm`) · 쓰는 곳 = 흙막이 수평공간 뒤 · 박스 끝 · 세월교 측벽 끝 · ㄴ·ㄷ 집수정 끝 · 절토 쪽 배관 유출 벽 뒤(지금 절토사면이 사라짐) · 소단 셈은 구조물 끝에서 새로 · `cutLine` 두 점 → 점열(횡단 셋 · 3D). From 152d97af152f1fd8d906c389cd143a0bcbafddc3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:00:01 +0900 Subject: [PATCH 43/47] =?UTF-8?q?feat(spreadsheet):=20=EA=B3=B5=EA=B0=9C?= =?UTF-8?q?=20=EC=86=90=EC=9E=A1=EC=9D=B4=20addSheet=20=C2=B7=20=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A6=AC=EA=B8=B0=20=ED=95=9C=20=EB=B2=88=20=C2=B7=20?= =?UTF-8?q?=ED=83=AD=20=EC=97=86=EC=9D=B4=20=EB=9D=84=EC=9A=B4=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=ED=83=AD=20=EC=A4=84=20=EB=B6=99=EC=9E=84?= =?UTF-8?q?=20=C2=B7=20=EB=8F=84=EB=A9=B4=20=ED=91=9C=20=E2=86=92=20?= =?UTF-8?q?=EB=A9=B4=EC=A0=81=20=EC=83=88=20=EC=8B=9C=ED=8A=B8=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D(83-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- A00_Common/spreadsheet/spreadsheet.ts | 41 +++++ .../tester/ui_checks/83-3_새시트_되돌리기.js | 144 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 resources/tester/ui_checks/83-3_새시트_되돌리기.js diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 4c7e91766..403d9ac4a 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -36,6 +36,7 @@ import type { CalcValues, Command, CommandEffect, + Sheet, SheetCellAddress, Workbook, } from "./spreadsheet_types"; @@ -81,6 +82,10 @@ export interface SpreadsheetHandle { /** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 · * 그 칸 다른 알약은 함께 둠(`replace` 면 뺌) · 주소가 틀리거나 읽기만이면 false */ setLink(key: string, cell: string | null, info?: LinkInfo, replace?: boolean): boolean; + /** 시트 하나 덧붙임(끝 자리 · 83-3 도면 → 면적) — 되돌리기 한 번 · onChange · 이름 겹치면 「이름 (n)」 · + * `activate` 면 새 시트로 · 탭 줄 없이 띄운 시트면 탭 줄을 붙임(시트 1장으로 돌아가면 숨김) · + * 붙인 시트 이름 · 읽기만이면 null */ + addSheet(sheet: Sheet, opts?: { activate?: boolean }): string | null; destroy(): void; } @@ -154,6 +159,8 @@ export function createSpreadsheet( others.push(...extras.parts); const detachKeys = attachKeys(ctx, ed, extras.keys); const detachMouse = attachMouse(ctx, ed, extras.mouse); + /** `addSheet` 가 나중에 붙인 탭 줄(탭 없이 띄운 시트) */ + let lateTabs: PartHandle | null = null; const toast = el("div", { className: "ss-toast" }); let toastTimer = 0; /** 알약 막대 · 연결 따라가기 — 선택이 있거나 문서에 연결이 있을 때만 불러옴(크기 예산) */ @@ -303,6 +310,40 @@ export function createSpreadsheet( loadLinks(); // 처음 연결이면 알약 막대 · 칸 세모를 불러옴 return true; }, + addSheet(sheet, add) { + if (readOnly) return null; + const taken = (name: string) => + ctx.book.시트.some((x) => x.이름.toLowerCase() === name.toLowerCase()); + let 이름 = sheet.이름; + for (let n = 2; taken(이름); n += 1) 이름 = `${sheet.이름} (${n})`; + let id = sheet.id; + for (let n = ctx.book.시트.length + 1; ctx.book.시트.some((x) => x.id === id); n += 1) + id = `s${n}`; + const before = ctx.book.시트.length; + dispatch({ 종류: "시트더하기", 시트: { ...sheet, id, 이름 }, 자리: ctx.book.시트.length }); + if (ctx.book.시트.length === before) return null; // 명령이 막힘(알림은 냄) + if (noTabs && !lateTabs) { + // 탭 줄 없이 띄운 시트 — 지금 붙이고 시트가 1장이면(되돌리기) 숨김 + const part = mountTabs(ctx); + const bar = part.root; + const sync = (): void => { + const many = ctx.book.시트.length > 1; + if (bar) bar.hidden = !many; + root.classList.toggle("ss--notabs", !many); + }; + lateTabs = { + root: part.root, + refresh: () => (part.refresh(), sync()), + destroy: () => part.destroy(), + }; + if (bar) status.root ? status.root.before(bar) : root.append(bar); + others.push(lateTabs); + sync(); + } + if (add?.activate) showSheet(id); + else for (const p of others) p.refresh(); + return 이름; + }, destroy() { clearTimeout(toastTimer); detachKeys(); diff --git a/resources/tester/ui_checks/83-3_새시트_되돌리기.js b/resources/tester/ui_checks/83-3_새시트_되돌리기.js new file mode 100644 index 000000000..0022e9f56 --- /dev/null +++ b/resources/tester/ui_checks/83-3_새시트_되돌리기.js @@ -0,0 +1,144 @@ +/* 83-3 — 받은 표 → 면적 새 시트(addSheet) · 탭 줄 · 되돌리기 한 번 · 화면 고침 표시 + * 시작 화면: M02 · 시스템 층 임시 항목(「데스크탑보조임시」 · 시트 1장) 면적산출 상세 · 「도면」 패널 펼침 + * 뒤: [저장] → area/<열>.json 새 시트 병합 B1:C1 · 열 폭 21.6/16.2/16.2 · 행 높이 28.3 · 다시 열기 = 탭 2장 · 임시 항목 지움 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 하는 일: 패널 CAD 에 선 표 견본을 보기 전용으로 싣고(저장 안 됨) 끌어 고른 뒤 [면적산출로 보내기] + * → 탭 · 활성 시트 · 칸 글 · 병합 수를 읽음 → 되돌리기(↶) → 다시 읽음 → 다시 보냄(저장은 부른 쪽) + * 기대 수치: + * - before.tabs = 0(탭 줄 없음) · added.tabs = ["…", "도면에서 1"] · added.active = "도면에서 1" + * - added.cells = 구분 · 수량 · 흙깎기 · 1500 · m3 · 돌쌓기 · 2.5 · m2 · added.widths 비 = 40:30:30(156 · 118 · 118 px) + * - undone.tabsShown = false · undone.sheets = 1(탭 줄 숨음) · again.active = "도면에서 1" + * - 끝에 원래 도면을 다시 실음(restored) + */ +(async () => { + const W = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s, root = document) => root.querySelector(s); + const $$ = (s, root = document) => [...root.querySelectorAll(s)]; + const frame = $('.ui-side-panels__panel[data-panel="drawing"] iframe'); + const btn = $("[data-area-from-drawing]"); + if (!frame || !btn) return JSON.stringify({ error: "도면 패널 · 단추 없음" }); + const win = frame.contentWindow; + const cad = win.__aisloCad; + const L = "L83"; + let n = 0; + const base = { lineColor: "#fff", lineWidth: 1, layerId: L }; + const line = (x1, y1, x2, y2) => ({ + id: `t83-${(n += 1)}`, + type: "Line", + ...base, + shapeData: { startPoint: { x: x1, y: y1 }, endPoint: { x: x2, y: y2 } }, + }); + const text = (label, x, y) => ({ + id: `t83-${(n += 1)}`, + type: "Text", + ...base, + shapeData: { + label, + basePoint: { x, y }, + options: { + textDirection: { x: 1, y: 0 }, + textAlign: "left", + textColor: "#fff", + fontSize: 2.5, + fontFamily: "Noto Sans KR", + }, + }, + }); + const grid = [ + line(100, 60, 200, 60), + line(100, 30, 200, 30), + line(100, 60, 100, 30), + line(200, 60, 200, 30), + line(100, 50, 200, 50), + line(100, 40, 200, 40), + line(140, 60, 140, 30), + line(170, 50, 170, 30), + text("구분", 102, 53), + text("수량", 145, 53), + text("흙깎기", 102, 43), + text("1,500", 142, 43), + text("m3", 172, 43), + text("돌쌓기", 102, 33), + text("2.5", 142, 33), + text("m2", 172, 33), + ]; + const ask = (type, want, extra = {}) => + new Promise((resolve) => { + const on = (e) => { + if (e.source !== win || e.data?.type !== want) return; + window.removeEventListener("message", on); + resolve(e.data); + }; + window.addEventListener("message", on); + win.postMessage({ type, ...extra }, location.origin); + setTimeout(() => resolve(null), 5000); + }); + const original = (await ask("aislo:b08:save-request", "aislo:b08:save-response"))?.drawing; + const load = (drawing) => + ask("aislo:b08:load-drawing", "aislo:b08:drawing-loaded", { + drawing, + readOnly: true, + panelsCollapsed: true, + recoveryScope: "verify83", + }); + await load({ + format: 6, + entities: grid, + layers: [{ id: L, name: "검증83", isVisible: true, isLocked: false }], + }); + await W(400); + const canvas = frame.contentDocument.querySelector("canvas"); + const fire = (type, p) => { + const r = canvas.getBoundingClientRect(); + canvas.dispatchEvent( + new win.MouseEvent(type, { + bubbles: true, + clientX: r.left + p.x, + clientY: r.bottom - p.y, + button: 0, + buttons: type === "mouseup" ? 0 : 1, + }), + ); + }; + const a = cad.screen(95, 25), + b = cad.screen(205, 65); + fire("mousemove", a); + await W(60); + fire("mousedown", a); + await W(60); + fire("mousemove", b); + await W(60); + fire("mouseup", b); + await W(250); + const ss = $(".m02-main__host .ss"); + const state = () => { + const bar = $(".ss-tabs", ss); + return { + tabsShown: !!bar && !bar.hidden, + tabs: bar ? $$(".ss-tabs__tab", bar).map((t) => t.innerText.trim()) : [], + active: $(".ss-tabs__tab.is-active", ss)?.innerText.trim() ?? null, + sheets: bar ? $$(".ss-tabs__tab", bar).length : 1, + cells: $$(".aislo-cell__text", ss) + .map((c) => c.innerText.trim()) + .filter(Boolean) + .slice(0, 12), + widths: $$(".aislo-grid__colhead", ss) + .slice(0, 3) + .map((c) => Math.round(c.getBoundingClientRect().width)), + notabsClass: ss.classList.contains("ss--notabs"), + }; + }; + const out = { selected: cad.selection().length, before: state() }; + btn.click(); + await W(800); + out.added = state(); + const undo = $$("button", ss).find((x) => /되돌리기|실행 취소|↶/.test(x.title + x.innerText)); + undo?.click(); + await W(500); + out.undone = state(); + btn.click(); + await W(800); + out.again = state(); + out.restored = !!(await load(original)); + return JSON.stringify(out); +})(); From cfe4d8b26d28d3cc95a2efb757cc44b14c793563 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:04:31 +0900 Subject: [PATCH 44/47] =?UTF-8?q?fix(B05):=20=ED=99=95=ED=8F=AD=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20=EB=8B=A8=EC=B6=94=20=EC=BC=AC=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9D=20=C2=B7=20[=EC=B6=94=EA=B0=80]=20=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=EC=99=80=20=EA=B2=B9=EC=B9=98=EC=A7=80=20=EC=95=8A=EA=B2=8C=20?= =?UTF-8?q?=C2=B7=20=ED=99=94=EB=A9=B4=20=EC=8B=9C=ED=97=98=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4(87-3=20=C2=B7=2087-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 켬 단추 class is-primary 대신 is-on(시험 · [추가] 찾기와 겹침) · 켬 표식 ✓ · 단추 높이 자동 - ui_checks 52-10 · 53-1 노면공 = 시작 · 종료만 · 길이 출력 · 카드 23+0 기준으로 맞춤 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../B05_Profile_UI_Structures_Widening.ts | 6 ++++-- B05_Profile/B05_Profile_UI_Style_Structures.css | 16 +++++++++++++++- resources/tester/ui_checks/52-10_노면공_기타.js | 2 +- resources/tester/ui_checks/53-1_횡단_고르기.js | 10 ++++++---- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Structures_Widening.ts b/B05_Profile/B05_Profile_UI_Structures_Widening.ts index 252ce54f9..b649eb18f 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Widening.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Widening.ts @@ -62,10 +62,12 @@ export function createWideningBox(hooks: { const on = !isRunOff(run, off); const button = document.createElement("button"); button.type = "button"; - button.className = `b05-route__irregular-btn${on ? " is-primary" : ""}`; + button.className = `b05-route__irregular-btn${on ? " is-on" : ""}`; button.setAttribute("aria-pressed", String(on)); button.dataset.wideningRun = `${run.start_m}-${run.end_m}`; - button.textContent = `${stationLabel(run.start_m, step)} ~ ${stationLabel(run.end_m, step)} · 확폭 ${Number(run.widening_m.toFixed(2))} m`; + const from = stationLabel(run.start_m, step); + const to = stationLabel(run.end_m, step); + button.textContent = `${from === to ? from : `${from} ~ ${to}`} · 확폭 ${Number(run.widening_m.toFixed(2))} m`; button.title = on ? "확폭 포함 — 누르면 규격 폭만" : "규격 폭만 — 누르면 확폭 포함"; button.addEventListener("click", () => { off = toggleRun(list, off, run); diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 9a1311990..bb0919662 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -582,8 +582,12 @@ gap: 4px; } -.b05-structure__widening .b05-structure__group-body button { +.b05-structure__widening .b05-structure__group-body button:not(.b05-structure__step) { align-self: stretch; + height: auto; + min-height: var(--b05-control-h); + padding-block: 3px; + line-height: 1.3; text-align: left; white-space: normal; } @@ -592,3 +596,13 @@ color: var(--text-muted, inherit); font-size: var(--text-caption); } + +/* 켬 = 확폭 포함 — 테두리색 + 표식(`.is-primary` 는 [추가] 단추 몫이라 안 씀) */ +.b05-structure__widening .b05-route__irregular-btn.is-on { + border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent); + color: var(--color-text); +} + +.b05-structure__widening [data-widening-run][aria-pressed="true"]::before { + content: "✓ "; +} diff --git a/resources/tester/ui_checks/52-10_노면공_기타.js b/resources/tester/ui_checks/52-10_노면공_기타.js index efce4d6ff..7bc7c221f 100644 --- a/resources/tester/ui_checks/52-10_노면공_기타.js +++ b/resources/tester/ui_checks/52-10_노면공_기타.js @@ -262,7 +262,7 @@ !etc.missing && etc.name === NAME && JSON.stringify(etc.containers) === '["detail"]' && - JSON.stringify(etc.legend) === '["상세 내용 1"]' && + JSON.stringify(etc.legend) === '["상세 내용"]' && JSON.stringify(etc.notes) === '["52-14 비고"]' && JSON.stringify(etc.containerLinks) === "[true]" && (etc.phase !== "edit" || diff --git a/resources/tester/ui_checks/53-1_횡단_고르기.js b/resources/tester/ui_checks/53-1_횡단_고르기.js index c6025557e..1e56458ec 100644 --- a/resources/tester/ui_checks/53-1_횡단_고르기.js +++ b/resources/tester/ui_checks/53-1_횡단_고르기.js @@ -132,7 +132,7 @@ }; /** 세션에만 넣는 종류(표 01~08) — 이미 있으면 건너뜀. [군, 종류, 측점, +m, 길이(구간형)] */ const ADD = [ - ["G", "pavement_concrete", 22, 0, 10], + ["G", "pavement_concrete", 23, 0, 10], // 노면공 = 시작 · 종료만(87-1) — 23+0 ~ 23+10 · 카드 23+0 에 다른 구조물 없음 ["A", "cross_underdrain", 22, 0], ["E", "mirror", 22, 0], ["A", "ford_pavement", 3, 0], @@ -181,10 +181,12 @@ if (!labels[1].hidden) { const [s0, s1] = labels[1].querySelectorAll("input"); const [e0, e1] = labels[3].querySelectorAll("input"); - fire(s0, String(no - 1)); - fire(s1, "10"); + // 기준 칸 없는 종류(노면공 87-1)는 시작 = 누른 측점 · 종료 = +10 m(알약 = 시점) + const rangeOnly = labels[2].hidden; + fire(s0, String(rangeOnly ? no : no - 1)); + fire(s1, rangeOnly ? String(plus) : "10"); fire(e0, String(no)); - fire(e1, "10"); + fire(e1, rangeOnly ? String(plus + 10) : "10"); } await w(300); } else if (!labels[1].hidden) { From 52cd7e46cb08cd4559ffbe0e35944c2e1fe09568 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:05:17 +0900 Subject: [PATCH 45/47] =?UTF-8?q?fix(M02):=20=EB=B3=B4=EB=82=B8=20?= =?UTF-8?q?=ED=91=9C=20=EB=86=93=EA=B8=B0=20=EC=A0=84=20=ED=83=AD=EC=9D=84?= =?UTF-8?q?=20=EB=B0=94=EA=BE=B8=EB=A9=B4=20=EA=B7=B8=20=EC=9E=A5=EC=97=90?= =?UTF-8?q?=20=EB=8B=A4=EC=8B=9C=20=EB=86=93=EA=B8=B0=20=C2=B7=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=92=A4=20=EC=A7=80=EA=B8=88=20=EC=9E=A5=20?= =?UTF-8?q?=EB=AF=B8=EC=A0=80=EC=9E=A5=20=ED=91=9C=EC=8B=9C=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80(82-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../M02_MasterTemplete_Drawing.ts | 40 +++++++++++++++---- resources/tester/test_cad_host_tabs.py | 27 +++++++++++-- 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts index c245f122c..6334ac418 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts @@ -92,7 +92,12 @@ export interface DrawingTemplateHandle { placeTable: (entity: Record, link: string) => void; updateTable: (link: string, cells: unknown[][]) => Promise; /** 다른 장을 같은 CAD 에 실음(82-1) — 백업 칸 · 도각 도번도 그 장 것으로 */ - loadSheet: (drawing: DrawingTemplateDoc, sheet: string, frameNumber?: string) => void; + loadSheet: ( + drawing: DrawingTemplateDoc, + sheet: string, + frameNumber?: string, + after?: () => void, + ) => Promise; destroy: () => void; } @@ -249,7 +254,8 @@ export function mountDrawingTemplate( onHostSave: hostSave, }); let loadSeq = 0; - const load = async (drawing: DrawingTemplateDoc): Promise => { + /** `after` = 싣기를 보낸 바로 뒤(이 싣기가 마지막일 때만) — 표 놓기를 「열린 뒤」로 미룰 때 */ + const load = async (drawing: DrawingTemplateDoc, after?: () => void): Promise => { const mine = ++loadSeq; cad.beginLoading(); const frame = options.frame ? await fetchFrame(frameNumber) : null; @@ -279,6 +285,7 @@ export function mountDrawingTemplate( originMarker: options.originMarker === true, }, ); + after?.(); }; const actions = document.createElement("div"); @@ -346,11 +353,11 @@ export function mountDrawingTemplate( getDoc, placeTable: cad.placeTable, updateTable: cad.updateTable, - loadSheet: (drawing, sheet, number) => { + loadSheet: (drawing, sheet, number, after) => { recoveryScope = recoveryScopeOf(options, sheet); frameNumber = number ?? ""; base = drawing; - void load(drawing); + return load(drawing, after); }, destroy: () => { areaModal?.close(); @@ -417,6 +424,13 @@ export function mountStructureSheets( const more = doc.더한장 ?? []; let cadDirty = false; let listDirty = false; + /** 아직 안 놓은 보낸 표(82-3) — 놓기 전에 탭을 바꾸면 그 장에 다시 걺(놓을 장 = 지금 탭) */ + let placing: { entity: Record; link: string } | null = null; + const placed = (got: DrawingTemplateDoc, link: string): boolean => + got.entities.some((e) => { + const style = (e as { shapeData?: { style?: Record } }).shapeData?.style; + return e.type === "Table" && style?.연결 === link; + }); const report = (): void => options.onChanged?.(cadDirty || listDirty || tabs.anyDirty()); const numberOf = (id: string): string => sheetNumber( @@ -426,13 +440,19 @@ export function mountStructureSheets( const tabs: CadTabs = createCadTabs({ features: readOnly ? {} : { add: true, rename: true, remove: true, reorder: true }, - capture: async () => ({ drawing: await drawing.getDoc(), dirty: cadDirty }), - show: (tab) => { + capture: async () => { + const got = await drawing.getDoc(); + if (placing && placed(got, placing.link)) placing = null; + return { drawing: got, dirty: cadDirty }; + }, + show: async (tab) => { cadDirty = false; - drawing.loadSheet( + // 놓기는 싣기를 보낸 바로 뒤 — cad_host 가 「열린 뒤」로 미룸(먼저 보내면 싣기가 도구를 끔) + await drawing.loadSheet( tab.drawing ?? { entities: [], layers: [BLANK_LAYER] }, tab.id === FIRST_TAB ? "" : tab.id, numberOf(tab.id), + () => placing && drawing.placeTable(placing.entity, placing.link), ); report(); }, @@ -491,6 +511,7 @@ export function mountStructureSheets( 도면: tab.drawing ?? { format: 6, entities: [], layers: [{ ...BLANK_LAYER }] }, })); listDirty = false; + cadDirty = false; // 받기(requestSave)가 CAD 미저장도 내림 — 다음 고침이 다시 알림 tabs.markClean(undefined, true); return { 도면: first?.drawing ?? doc.도면, @@ -501,7 +522,10 @@ export function mountStructureSheets( const id = tabs.active()?.id ?? FIRST_TAB; return id === FIRST_TAB ? "" : id; }, - placeTable: drawing.placeTable, + placeTable: (entity, link) => { + placing = { entity, link }; + drawing.placeTable(entity, link); + }, // 실린 장은 CAD · 안 실린 장은 받아 둔 편집본에서(82-3) updateTable: async (link, cells) => { const now = tabs.active(); diff --git a/resources/tester/test_cad_host_tabs.py b/resources/tester/test_cad_host_tabs.py index e9ee764b8..975ab117f 100644 --- a/resources/tester/test_cad_host_tabs.py +++ b/resources/tester/test_cad_host_tabs.py @@ -103,13 +103,14 @@ const tabsMod = load("ui_template/cad_host/cad_host_tabs.ts", { "@ui/ui_template // ⑤ M02 여러 장 — 가짜 CAD(requestSave = 지금 실린 도면 + 편집) const cadLoads = []; + const ev = []; let inCad = null; const fakeCad = { element: node("div"), beginLoading() {}, - load: (drawing, meta, frameEdit, fields, extra) => (cadLoads.push(extra.recoveryScope), (inCad = drawing)), + load: (drawing, meta, frameEdit, fields, extra) => (ev.push("load"), cadLoads.push(extra.recoveryScope), (inCad = drawing)), requestSave: async () => ({ drawing: inCad, quantityTable: null }), - placeTable() {}, + placeTable: (entity, link) => ev.push("place " + link), updateTable: async () => 1, destroy() {}, }; @@ -142,6 +143,18 @@ const tabsMod = load("ui_template/cad_host/cad_host_tabs.ts", { "@ui/ui_template const third = bar.kids.find((k) => k.dataset.tabId && k.dataset.active === "true").dataset.tabId; await bar.kids.find((k) => k.dataset.tabId === "#1").on.click(); await new Promise((r) => setTimeout(r, 5)); + // 보낸 표를 놓기 전에 탭을 바꾸면 그 장에서 다시 놓기(싣기 뒤) · 놓은 뒤에는 안 걺 + const tabNodes = () => bar.kids.filter((k) => k.dataset.tabId); + ev.length = 0; + sheets.placeTable(table("T9"), "T9"); + await tabNodes()[1].on.click(); + await new Promise((r) => setTimeout(r, 5)); + out.replace = ev.slice(); + inCad = { ...inCad, entities: [...inCad.entities, table("T9")] }; // 놓음 + ev.length = 0; + await tabNodes()[0].on.click(); + await new Promise((r) => setTimeout(r, 5)); + out.placedOnce = ev.slice(); out.updated = await sheets.updateTable("T2", [[{ text: "새" }]]); const got = await sheets.getDoc(); out.m02 = { @@ -210,15 +223,21 @@ def test_B07_목록에서_연_도면이_탭_미저장_편집_지킴(): def test_여러_장_문서_장_도번_백업_칸(got): m02 = got["m02"] assert m02["first"] == 2 - assert m02["more"] == [["단면", 1], ["3장", 1]] + assert m02["more"] == [["단면", 2], ["3장", 1]] # 단면에 T9 표를 놓음 assert m02["newId"] is True and m02["active"] == "" assert m02["scopes"][0] == "m02:system:rv" and m02["scopes"][1].startswith("m02:system:rv#s") assert m02["number"] == "SD-1-3" q = "/api/m02/structure-frame?number=" - assert m02["frames"] == [q + "SD-1", q + "SD-1-3"] # 장 도번마다 한 번(1장은 다시 안 받음) + # 장 도번마다 한 번(같은 도번은 다시 안 받음) + assert m02["frames"] == [q + "SD-1", q + "SD-1-3", q + "SD-1-2"] assert m02["dirty"] is True # 장 더함 = 저장 안 한 고침 +def test_놓기_전_탭을_바꾸면_그_장에_다시_놓기(got): + assert got["replace"] == ["place T9", "load", "place T9"] # 싣기 뒤에 다시 + assert got["placedOnce"] == ["load"] + + def test_안_실린_장_표도_갈음(got): assert got["updated"] == 2 # 받아 둔 둘째 장 1 + 실린 장(가짜 CAD) 1 assert got["m02"]["t2"] == "새" From 2878b7c0de28f28af05d9f16df771d2f6ba58da1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:12:10 +0900 Subject: [PATCH 46/47] =?UTF-8?q?test(M02):=20=EC=A7=91=EA=B3=84=ED=91=9C?= =?UTF-8?q?=20=EC=8B=9C=ED=97=98=20=EB=84=B7=20=EB=82=A1=EC=9D=80=20?= =?UTF-8?q?=EA=B2=AC=EB=B3=B8=20=C2=B7=20=EA=B8=B0=EB=8C=80=EA=B0=92=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4(87-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자동 맞춤 견본 = 옵션 그룹 · 컨테이너 복사 건(복사본 …__그룹) 빼고 본체만 — 지정 항목 모드 몫이라 자동 맞춤에서 두 번 셈(46-3 · 54-6) - 관 유출 흙막이 열 id 「rv|돌쌓기(찰)|1.5」(기본 높이 54-36) · 세월교 열 「fb|1000|2」(련수 기본 2 · 65-1) - 템플릿 층 채운 표 알림 = 배관 한 개 컨테이너 셋 → 미지정 4건 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- resources/tester/test_m02_table_fill.py | 15 +++++++++------ resources/tester/test_m02_template_layers.py | 3 ++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/resources/tester/test_m02_table_fill.py b/resources/tester/test_m02_table_fill.py index e2ec9e675..1f69052ec 100644 --- a/resources/tester/test_m02_table_fill.py +++ b/resources/tester/test_m02_table_fill.py @@ -72,7 +72,9 @@ def project(tmp_path: Path) -> Path: def _auto(project: Path, document: dict[str, Any], lengths: dict[float, float]) -> dict[str, Any]: """바인딩 자동 맞춤(`fill_document` 기본) — 프로젝트 `fill_table` 은 지정 항목 모드(PLAN 21-3)라 펼침 · 묶음 · 손 값 규칙은 이 길로 봄.""" - return fill.fill_document(document, fill.collect_items(project, lengths)) + # 옵션 그룹 · 컨테이너마다 더 서는 건(복사본 `…__그룹`)은 지정 항목 모드 몫 — 자동 맞춤은 본체만 셈 + items = [i for i in fill.collect_items(project, lengths) if "__" not in i["복사본"]] + return fill.fill_document(document, items) def _filled(project: Path, document: dict[str, Any] | None = None) -> dict[str, Any]: @@ -94,10 +96,11 @@ def test_펼침_열은_설계에_쓰인_값만_차례대로(project: Path) -> No ] assert not any(i.startswith("rw|") for i in ids) # 옹벽 없음 → 열 없음 assert "rv" not in ids and "ps" in ids # 펼침 마스터 칸은 빠지고 고정 열은 남음 - # 관 유입·유출 기슭막이는 레지스트리 기본값(찰 · 메 · 높이 없음)으로 열이 섬 · id 는 설계 값 - assert "rv|돌쌓기(찰)|-" in ids and "rv|돌쌓기(메)|-" in ids - assert _col(doc, "rv|돌쌓기(찰)|-")["머리"] == ["돌기슭막이", "찰쌓기", "높이 미입력"] - assert _col(doc, "fb|1000|-")["머리"] == ["세월교", "Φ1000×련수 미입력", None] + # 관 유출 기슭막이는 레지스트리 기본값(찰 · 흙막이 기본 높이 1.5 — 54-36)으로 열이 섬 · id 는 설계 값 + assert [i for i in ids if i.startswith("rv|")] == ["rv|돌쌓기(찰)|1.5"] + assert _col(doc, "rv|돌쌓기(찰)|1.5")["머리"] == ["돌기슭막이", "찰쌓기", "H=1.5"] + # 세월교 기본값(65-1) — 련수 칸 기본 2 + assert _col(doc, "fb|1000|2")["머리"] == ["세월교", "Φ1000×2련", None] assert not any(i.startswith("pg_in|") for i in ids) # 날개벽·집수정 형식 없음 → 관보호공 없음 @@ -151,7 +154,7 @@ def test_측점_줄_같은_측점은_한_줄_관은_나눔(project: Path) -> Non assert first["sta"] == second["sta"] == "NO.4+5.05" assert first["pp_len|파형강관|1000"] == 14.0 and "pp_len|흄관|1000" not in first assert second["pp_len|흄관|1000"] == 14.0 # 한 측점 횡단 관 연장은 하나 — 두 관이 같이 씀 - assert first["rv|돌쌓기(찰)|-"] == second["rv|돌쌓기(찰)|-"] == 10 + assert first["rv|돌쌓기(찰)|1.5"] == second["rv|돌쌓기(찰)|1.5"] == 10 assert rows["s300.00"]["값"]["ps"] == 1 assert [row["값"].get("no") for row in doc["줄"][1:]] == [str(i) for i in range(1, 8)] assert "식" not in rows["s200.00~230.00"] # 줄눈 간격이 달라도 열 식이 없으면 줄 식 없음 diff --git a/resources/tester/test_m02_template_layers.py b/resources/tester/test_m02_template_layers.py index 4724c5ab2..2dbfe7a8f 100644 --- a/resources/tester/test_m02_template_layers.py +++ b/resources/tester/test_m02_template_layers.py @@ -242,8 +242,9 @@ def test_채운_표는_작업본을_채워_돌려주고_저장_안_함( before = layers.version_of(root / "templates/table/구조물집계표.json") got = world["client"].get(f"/api/m02/projects/{P1}/tables/구조물집계표/filled").json() # 프로젝트 채움 = 지정 항목 모드(PLAN 21-3) — 집계 항목 미지정 관은 표에 안 들어감 + # 배관 한 개 = 관 · 보호공(유입) · 보호공(유출) 컨테이너 셋(54-1) → 미지정 4건 assert not any(r["id"] == "s40.00" for r in got["문서"]["줄"]) - assert "집계 항목 미지정 1건 — 표에 안 들어감" in got["문서"]["알림"] + assert "집계 항목 미지정 4건 — 표에 안 들어감" in got["문서"]["알림"] assert got["결과"] == {"계산": {}} and got["판"] == before assert layers.version_of(root / "templates/table/구조물집계표.json") == before From fe8c177da70be969854c084ed2a1d16a7f4817c0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:12:50 +0900 Subject: [PATCH 47/47] =?UTF-8?q?fix(B06):=20=EC=84=B8=EC=9B=94=EA=B5=90?= =?UTF-8?q?=20=EC=B8=A1=EB=B2=BD=20=EB=86=92=EC=9D=B4=20=E3=80=8C=EC=9E=90?= =?UTF-8?q?=EB=8F=99=E3=80=8D=20=ED=91=9C=EC=8B=9C=EB=A5=BC=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=ED=91=9C=EB=A1=9C=20=E2=80=94=20=EC=A2=81=EC=9D=80=20?= =?UTF-8?q?=EA=B0=92=20=EC=B9=B8=20=EA=B2=B9=EC=B9=A8(91-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- B06_Section/B06_Section_UI_Cross_Ford_Panel.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts index 22842406e..3e0f4cab2 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts @@ -78,6 +78,7 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): // 측벽 높이 — 폼에 칸이 없던 값이라 폼 날개벽 칸으로 옮겨 붙는 줄(24-4a). const heightRow = makeRow("측벽 높이"); heightRow.row.classList.add("b06-structure-panel__struct--wall-height"); + const heightLabel = heightRow.row.querySelector(".b06-structure-panel__label")!; const heightValue = document.createElement("span"); heightValue.className = "b06-structure-panel__hval"; heightRow.controls.append( @@ -152,9 +153,9 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }): `높이 ${deps.heightFor(current).toFixed(2)}m` + (adjust.heightM === null ? " (자동)" : ""); moveLine.textContent = `좌우 ${adjust.lateralM.toFixed(1)}m · 상하 ${adjust.slopeM.toFixed(1)}m`; slabLine.textContent = `바닥판 ${deps.slabLengthM().toFixed(2)}m`; - // 자동 = 바닥판이 계류 하상을 따라감 · 손 값 = 원지반이 바뀌어도 그대로(91-2) - heightValue.textContent = - `${deps.heightFor(current).toFixed(1)}m` + (adjust.heightM === null ? " 자동" : ""); + heightValue.textContent = `${deps.heightFor(current).toFixed(1)}m`; + // 자동 = 바닥판이 계류 하상을 따라감 · 손 값 = 원지반이 바뀌어도 그대로(91-2) — 값 칸이 좁아 이름표에 + heightLabel.textContent = adjust.heightM === null ? "측벽 높이 (자동)" : "측벽 높이"; } return {