From ff19a0bc556656adf99035e73f0bfcbaa13e13ce Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:20:22 +0900 Subject: [PATCH 01/15] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=2085?= 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/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index cd0af97fd..89ead06c9 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -106,3 +106,4 @@ 82. 신규기능으로 B05 ~B09 페이지에 마우스 우측 클릭 오래누르기와 z키 누르기로 마우스 커서를 따라가는 돋보기 기능이 있으면 해. 범위와 배율은 넣거 일단 지정해줘. 83. 돋보기 구현 완료, 돋보기가 활성화된 상태에서는 스크롤로 배율을 조절하게 단 화면 스크롤은 안되게 막기, 돋보기의 크기는 지금크기의 50% 증가 84. 돋보기의 기능은 M01, M02, B01 ~ B09까지로 적용해줘. +85. 오토캐드 영역은 돋보기 필요없음 From 3af0c45306aefac866e3e0108b596c64e1216342 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:22:26 +0900 Subject: [PATCH 02/15] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=2086~95(=EB=84=B7=EC=A7=B8=20=EB=B6=99=EC=9E=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_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 89ead06c9..dfbca0626 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -107,3 +107,15 @@ 83. 돋보기 구현 완료, 돋보기가 활성화된 상태에서는 스크롤로 배율을 조절하게 단 화면 스크롤은 안되게 막기, 돋보기의 크기는 지금크기의 50% 증가 84. 돋보기의 기능은 M01, M02, B01 ~ B09까지로 적용해줘. 85. 오토캐드 영역은 돋보기 필요없음 + +## 넷째 붙임 (10-10 저녁) +86. M01 GF000156 의 모래단위중량, 자갈단위중량은 밀도값으로 보이는데 기본값이 있을 것으로 판단됨(or 현재 12-1-2의 원문을 조사하면 역산하여 밀도를 뽑을 수 있지 않음?). 원문 조사 +87. 엄상준의 SU-000007의 구조물 조합의 일위대가 중 기계비빔타설을 보면 시멘트, 모래, 자갈에 대한 원가들이 잡혀있으며 이때 단위가 잘못된걸로 판단됨. 확인, 추가로 하단의 자재 목록을 보면 별도계상이 나오게 되고 이때는 상단의 일위대가에 단가가 빠져야되는 상황으로 보임. 문제는 2가지임. 분리해서 검토. 일위대가의 단가산출 항목 중 상세내용의 내역을 자세히 검토하기 위해 자재목록으로 자동연결한 것 뿐이니 별도로 보면 안됨. +88. 기계비빔타설의 경우 원문참고가 12-1-2와 12-1-3 같이 되어야하는걸로 보이는데 하나만 보임. +89. (질문) 시멘트는 어떻게 연결되어 나온 정보임? +90. 구조물 조합의 자재에서 별도계상을 선택하면 선택된 자재품목을 사라져야함. 이건 나중에 본프로그램에서 별도로 계상할 생각임 +91. B05/B06 좌측 패널 — 구조물배치의 구조물 조합연결 모달창에서 M02의 구조물조합 페이지의 상단 테이블도 표현 +92. B06 횡단도 — 세월교와 배관 자리의 경우 배관정보를 텍스트로 표현해야되는데 이때 노폭과 노견의 길이정보를 보여주는 라벨 위로 이동해줘. +93. B06 — 관보호공의 위치제어의 경우 관 길이에 맞춰 이동하다보니 자유도가 떨어짐. 관 길이를 무시하고 0.1m 단위로 이동이 되게 반영, 단, 관길이 정보를 나타내는 라벨과 좌측 패널에서는 정수값으로 사사오입하여 작성하고 추가로 괄호를 추가하여 소수점 1째자리까지 표현, 이때 구조물 집계표로 정보를 전달할때는 사사오입한 정보를 전달 +94. 캐드 프로그램에서 스케일 기능은 잘되지만 추가로 오토캐드처럼 오리진을 지정하고 숫자로 스케일 값을 입력을 받게 추가 +95. 캐드 프로그램에서 줌아웃을 많이 하면 브라우져의 속도가 많이 떨어지던데 개선방향은? (질문) From 9085a5105697d906f5e313687703d58272056094 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:23:13 +0900 Subject: [PATCH 03/15] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=2096?= 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/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index dfbca0626..3b42827fc 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -119,3 +119,4 @@ 93. B06 — 관보호공의 위치제어의 경우 관 길이에 맞춰 이동하다보니 자유도가 떨어짐. 관 길이를 무시하고 0.1m 단위로 이동이 되게 반영, 단, 관길이 정보를 나타내는 라벨과 좌측 패널에서는 정수값으로 사사오입하여 작성하고 추가로 괄호를 추가하여 소수점 1째자리까지 표현, 이때 구조물 집계표로 정보를 전달할때는 사사오입한 정보를 전달 94. 캐드 프로그램에서 스케일 기능은 잘되지만 추가로 오토캐드처럼 오리진을 지정하고 숫자로 스케일 값을 입력을 받게 추가 95. 캐드 프로그램에서 줌아웃을 많이 하면 브라우져의 속도가 많이 떨어지던데 개선방향은? (질문) +96. 특히 M01 GF000156과 같은 단가산출이 있는지 조사하고 개선 From a5f5cac73933b36c2cc2d879551f86a5b14a691d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:26:31 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat(ui):=20=EB=8F=8B=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=20CAD=20=EC=98=81=EC=97=AD=20=EB=B9=BC=EA=B8=B0=20=E2=80=94=20?= =?UTF-8?q?CAD=20=EC=9C=84=20=EC=95=88=20=EC=BC=AC=20=C2=B7=20=EC=BC=9C?= =?UTF-8?q?=EC=A7=84=20=EC=B1=84=20=EB=93=A4=EC=96=B4=EA=B0=80=EB=A9=B4=20?= =?UTF-8?q?=EC=88=A8=EA=B9=80=20=C2=B7=20=EB=82=98=EC=98=A4=EB=A9=B4=20?= =?UTF-8?q?=EB=B0=B0=EC=9C=A8=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20CAD?= =?UTF-8?q?=20=EC=9C=84=20=ED=9C=A0=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7?= =?UTF-8?q?=20=EB=B3=B5=EC=A0=9C=EC=97=90=EC=84=9C=20CAD=20=EB=B9=88?= =?UTF-8?q?=EC=B9=B8=20(221-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_01QND7Co43B24zK2P937rdxt --- resources/tester/helper_218_1_loupe.cjs | 111 ++++++++++++++++++++++- resources/tester/test_218_1_loupe.py | 16 +++- resources/tester/test_221_1_loupe_cad.py | 69 ++++++++++++++ ui_template/ui_template_loupe.ts | 109 +++++++++++++--------- 4 files changed, 254 insertions(+), 51 deletions(-) create mode 100644 resources/tester/test_221_1_loupe_cad.py diff --git a/resources/tester/helper_218_1_loupe.cjs b/resources/tester/helper_218_1_loupe.cjs index cfa125bf1..52ac768cf 100644 --- a/resources/tester/helper_218_1_loupe.cjs +++ b/resources/tester/helper_218_1_loupe.cjs @@ -1,4 +1,4 @@ -/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율) 단위검증 헬퍼 — +/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율 · 221-1 CAD 빼기) 단위검증 헬퍼 — * 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 냄. 가짜 창 · 가짜 시계 · 가짜 요소로 판정. */ const fs = require("fs"); const path = require("path"); @@ -271,6 +271,98 @@ r.origin = { bottomRight: m.loupeOrigin(995, 795, 1000, 800), }; +{ + // 221-1 CAD 영역 — 위에선 안 켬 · 켜진 채 들어가면 숨김 · 나오면 보임 · 휠은 CAD 로 + const cad = { tagName: "IFRAME", closest: (sel) => (sel === m.LOUPE_SKIP ? {} : null) }; + const plain = { tagName: "DIV", closest: () => null }; + const kinds = (v) => v.log.map((l) => l[0]); + r.cad = { + isCad: [m.isCadTarget(cad), m.isCadTarget(plain), m.isCadTarget(null), m.isCadTarget({})], + skip: m.LOUPE_SKIP, + }; + { + // Z 를 CAD 위에서 누름 — 안 켬 · 나오면 보임 · 떼면 끔 + const { win, view } = setup(); + win.fire("mouseover", { target: cad }); + win.fire("keydown", { code: "KeyZ", target: cad }); + const onCad = view.log.length; + const wheel = win.fire("wheel", { deltaY: -100, target: cad }); + win.fire("mouseover", { target: plain, clientX: 50, clientY: 60 }); + const out = view.log[0]; + win.fire("keyup", { code: "KeyZ" }); + r.cad.keyOnCad = { onCad, wheelPrevented: wheel.prevented, out, off: last(view) }; + } + { + // 켜진 채 CAD 위로(mouseover · mousemove 둘 다) — 숨김 · 휠 안 막음 · 나오면 배율 그대로 보임 + const { win, view } = setup(); + win.fire("keydown", { code: "KeyZ" }); + win.fire("wheel", { deltaY: -100 }); // 2.25 + win.fire("mouseover", { target: cad }); + const hidden = last(view); + const n = view.log.length; + const wheel = win.fire("wheel", { deltaY: -100, target: cad }); + win.fire("mousemove", { target: cad, clientX: 5, clientY: 5 }); + const quiet = view.log.length === n; // 숨긴 동안 move · 배율 안 부름 + win.fire("mouseover", { target: plain, clientX: 70, clientY: 80 }); + const back = view.log.slice(n); + win.fire("mousemove", { target: cad, clientX: 6, clientY: 6 }); + const moveHide = last(view); + win.fire("mousemove", { target: plain, clientX: 7, clientY: 7 }); + r.cad.enter = { + hidden, + wheel: { prevented: wheel.prevented, stopped: wheel.stopped }, + quiet, + back, + moveHide, + moveBack: kinds(view).slice(-3), + }; + } + { + // 우클릭 길게 — CAD 위면 안 켜고 메뉴 그대로 + const { win, view } = setup(); + win.fire("mousedown", { button: 2, buttons: 2, target: cad }); + win.tick(1000); + win.fire("mouseup", { button: 2, target: cad }); + const menu = win.fire("contextmenu", { target: cad }); + r.cad.press = { log: view.log.length, menuPrevented: menu.prevented }; + } + { + // 초점이 CAD iframe 으로 넘어감(cad_host) — 끄지 않고 숨김 · Z 떼기는 그쪽 창에서 + const { win, view } = setup(); + const frameCalls = []; + const frame = { + ...cad, + contentWindow: { addEventListener: (type, fn, cap) => frameCalls.push({ type, fn, cap }) }, + }; + win.document = { activeElement: frame }; + win.fire("keydown", { code: "KeyZ" }); + win.fire("blur", { target: win }); + const blurHidden = kinds(view); + win.document = { activeElement: plain }; + const up = frameCalls.find((c) => c.type === "keyup"); + up.fn({ code: "KeyZ" }); + win.fire("mouseover", { target: plain }); + const afterRelease = kinds(view); + // 우클릭 누른 채 들어감 — 나오면 다시 보임 + const b = setup(); + b.win.fire("mousedown", { button: 2, buttons: 2 }); + b.win.tick(400); + b.win.document = { activeElement: frame }; + b.win.fire("blur", { target: b.win }); + b.win.document = { activeElement: plain }; + b.win.fire("mousemove", { buttons: 2, target: plain }); + const pressBack = kinds(b.view); + b.win.fire("mouseup", { button: 2 }); + r.cad.blur = { + blurHidden, + frameKeyup: up && up.cap, + afterRelease, + pressBack, + pressOff: last(b.view), + }; + } +} + // ---- 가짜 요소 ---- const SVG_NS = "http://www.w3.org/2000/svg"; class El { @@ -299,6 +391,9 @@ class El { getBoundingClientRect() { return this.rect; } + get classList() { + return { contains: (k) => this.className.split(" ").includes(k) }; + } setAttribute(k, v) { this.attrs[k] = v; } @@ -370,8 +465,13 @@ function makePage() { contentDocument: frameDoc, rect: { left: 0, top: 0, right: 320, bottom: 240, width: 320, height: 240 }, }); + const cadBox = new El("DIV", { + rect: { left: 0, top: 300, right: 400, bottom: 600, width: 400, height: 300 }, + }); + cadBox.className = "cad-host"; + cadBox.append(new El("IFRAME"), new El("A")); scroller.append(canvas, blank, input, check); - app.append(scroller, offscreen, table, svg, frame); + app.append(scroller, offscreen, table, svg, frame, cadBox); return app; } function fakeDocument(app) { @@ -406,7 +506,7 @@ function fakeDocument(app) { const stage = root.children[0]; const page = stage.children[0]; const copy = page.children[0]; - const [scroller, offscreen, table, svg, frame] = copy.children; + const [scroller, offscreen, table, svg, frame, cadBox] = copy.children; const [canvas, blank, input, check] = scroller.children; r.capture = { rootClass: root.className, @@ -429,7 +529,10 @@ function fakeDocument(app) { svgKept: svg.children.length, frameTag: frame.tagName, frameDrawn: frame.drawn, - frameSize: [frame.width, frame.height], + frameSize: [frame.style.width, frame.style.height], + frameEmpty: frame.children.length, + cadBoxEmpty: cadBox.children.length, + cadBoxSize: [cadBox.style.width, cadBox.style.height], watching: observers[0].watching === app, observeOpts: observers[0].opts, scrollListen: win.calls.some((c) => c.type === "scroll" && c.capture), diff --git a/resources/tester/test_218_1_loupe.py b/resources/tester/test_218_1_loupe.py index d8f67f5b8..9568efe79 100644 --- a/resources/tester/test_218_1_loupe.py +++ b/resources/tester/test_218_1_loupe.py @@ -49,7 +49,16 @@ def test_크기_배율_상수(r): def test_창_캡처_단계로_들음(r): assert r["listeners"] == [ - [t, True] for t in ("mousemove", "mousedown", "mouseup", "contextmenu", "keydown", "keyup") + [t, True] + for t in ( + "mousemove", + "mouseover", + "mousedown", + "mouseup", + "contextmenu", + "keydown", + "keyup", + ) ] + [["wheel", {"capture": True, "passive": False}], ["blur", True]] @@ -139,8 +148,9 @@ def test_화면_뜨기(r): assert c["offscreenChildren"] == 0 and c["offscreenSize"] == ["100px", "100px"] # 보이는 영역만 assert c["rowKept"] == 1 # 표 줄은 칸 너비 때문에 그대로 assert c["svgKept"] == 1 - assert c["frameTag"] == "CANVAS" and c["frameDrawn"] == [["cad", 5, 6, 300, 200]] - assert c["frameSize"] == [320, 240] + # iframe(CAD) 은 복제하지 않음 — 같은 크기 빈 상자(221-1) + assert c["frameTag"] == "DIV" and c["frameDrawn"] == [] and c["frameEmpty"] == 0 + assert c["frameSize"] == ["320px", "240px"] assert c["watching"] and c["observeOpts"]["subtree"] and c["scrollListen"] diff --git a/resources/tester/test_221_1_loupe_cad.py b/resources/tester/test_221_1_loupe_cad.py new file mode 100644 index 000000000..b74b31959 --- /dev/null +++ b/resources/tester/test_221_1_loupe_cad.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""돋보기 CAD 영역 빼기(계획서 221-1) 회귀검증 — CAD(cad_host 상자 · iframe) 위에선 안 켬 · +켜진 채 들어가면 숨김 · 나오면 다시 보임(배율 그대로) · CAD 위 휠은 안 막음 · 복제에서 CAD 는 빈칸. + +218-1 헬퍼(`helper_218_1_loupe.cjs`)의 CAD 장면을 판정함. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_218_1_loupe.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_CAD_판정(r): + c = r["cad"] + assert c["skip"] == ".cad-host, iframe" + assert c["isCad"] == [True, False, False, False] + + +def test_CAD_위_Z_안_켬_나오면_보임(r): + k = r["cad"]["keyOnCad"] + assert k["onCad"] == 0 and k["wheelPrevented"] is False + assert k["out"] == ["show", 50, 60] # 나온 자리에 뜸 + assert k["off"] == "hide" + + +def test_CAD_위_우클릭_길게_안_켬_메뉴_그대로(r): + assert r["cad"]["press"] == {"log": 0, "menuPrevented": False} + + +def test_켜진_채_CAD_위_숨김_휠_안_막음_나오면_배율_그대로(r): + e = r["cad"]["enter"] + assert e["hidden"] == "hide" + assert e["wheel"] == {"prevented": False, "stopped": False} # CAD 휠 줌 그대로 + assert e["quiet"] # 숨긴 동안 원을 안 움직임 + assert e["back"] == [["show", 70, 80], ["zoom", 2.25]] + assert e["moveHide"] == "hide" and e["moveBack"] == ["show", "zoom", "move"] + + +def test_초점이_CAD_로_넘어가도_끄지_않고_숨김(r): + b = r["cad"]["blur"] + assert b["blurHidden"] == ["show", "hide"] + assert b["frameKeyup"] is True # Z 떼기는 CAD 창에서 들음 + assert b["afterRelease"] == ["show", "hide"] # 뗀 뒤 나와도 안 뜸 + assert b["pressBack"] == ["show", "hide", "show", "move"] # 우클릭 누른 채 나오면 다시 + assert b["pressOff"] == "hide" + + +def test_복제에서_CAD_빈칸(r): + c = r["capture"] + assert c["frameTag"] == "DIV" and c["frameEmpty"] == 0 + assert c["frameSize"] == ["320px", "240px"] + assert c["cadBoxEmpty"] == 0 and c["cadBoxSize"] == ["400px", "300px"] diff --git a/ui_template/ui_template_loupe.ts b/ui_template/ui_template_loupe.ts index cdb4fe4c6..1703dc58a 100644 --- a/ui_template/ui_template_loupe.ts +++ b/ui_template/ui_template_loupe.ts @@ -7,7 +7,8 @@ * 길게 누른 우클릭은 뗀 뒤 메뉴(우리 메뉴 · 브라우저 메뉴)를 안 띄움 · 짧게 누르면 지금 우클릭 그대로. * 그리기 — 켤 때 `#app` 을 한 번 떠서(DOM 복제 + 2D canvas 그림 복사 · SVG 그대로) 원 안에 놓고, 움직일 때 transform 만 바꿈. * 화면 밖 상자는 속을 비우고 크기만 남김 · 켜 있는 동안 화면이 바뀌면 0.5 초 뒤 다시 뜸 · 끄면 복제를 지움. - * WebGL canvas 는 그림이 남아 있을 때만 옮겨짐 · 같은 출처 iframe(CAD)은 안의 canvas 만 옮김 — 못 옮기면 그 자리 빈칸. + * WebGL canvas 는 그림이 남아 있을 때만 옮겨짐. + * CAD 영역(cad_host 상자 · iframe · 221-1) — 커서가 그 위면 켜지 않고 · 켜진 채 들어가면 숨김(나오면 다시) · 휠도 CAD 로 · 복제는 빈칸. * ========================================================================== */ import "./ui_template_loupe.css"; @@ -33,6 +34,8 @@ export const LOUPE_EDGE = 4; export const LOUPE_ROUTE = /^(b0[1-9]|m0[12])-/; const SVG_NS = "http://www.w3.org/2000/svg"; +/** CAD 영역 — cad_host 가 끼운 상자와 iframe */ +export const LOUPE_SKIP = ".cad-host, iframe"; const NON_TEXT_INPUT = new Set([ "checkbox", "radio", @@ -77,6 +80,11 @@ export function isTypingTarget(target: unknown): boolean { return tag === "INPUT" && !NON_TEXT_INPUT.has((el.type || "text").toLowerCase()); } +/** CAD 영역 위인지 — 돋보기를 안 띄우는 자리(221-1) */ +export function isCadTarget(target: unknown): boolean { + return !!(target as Partial | null)?.closest?.(LOUPE_SKIP); +} + /** 휠 한 번 뒤 배율 — 위로 굴림(deltaY < 0) = 크게 · 한도 안 */ export function nextLoupeZoom(zoom: number, deltaY: number): number { const step = Math.sign(-deltaY) * LOUPE_ZOOM_STEP; @@ -115,18 +123,36 @@ export function initLoupe( let y = win.innerHeight / 2; let press = false; // 오른쪽 길게 누름 let key = false; // Z 누름 - let shown = false; + let shown = false; // 켜짐 + let seen = false; // 원이 보임 — 켜짐이어도 CAD 위면 숨김 + let overCad = false; let holdTimer = 0; let swallowMenu = false; // 길게 누른 뒤 따라오는 우클릭 메뉴 한 번을 먹음 let zoom = LOUPE_ZOOM; const update = (): void => { const want = (press || key) && isOn(); - if (want && !shown) { - zoom = LOUPE_ZOOM; // 켤 때마다 2배 + if (want && !shown) zoom = LOUPE_ZOOM; // 켤 때마다 2배 + const see = want && !overCad; + if (see && !seen) { view.show(x, y); - } else if (!want && shown) view.hide(); + if (zoom !== LOUPE_ZOOM) view.setZoom(zoom); // CAD 에서 나옴 — 배율 그대로 + } else if (!see && seen) view.hide(); shown = want; + seen = see; + }; + const track = (e: MouseEvent): void => { + x = e.clientX; + y = e.clientY; + const cad = isCadTarget(e.target); + if (cad === overCad) return; + overCad = cad; + update(); + }; + const releaseZ = (e: KeyboardEvent): void => { + if (keyLetter(e) !== "z" || !key) return; + key = false; + update(); }; const stopHold = (): void => { win.clearTimeout(holdTimer); @@ -136,20 +162,21 @@ export function initLoupe( win.addEventListener(type, fn, true); on("mousemove", (e) => { - x = e.clientX; - y = e.clientY; + track(e); if (!(e.buttons & 2) && (holdTimer || press)) { stopHold(); // 창 밖에서 뗌 — mouseup 이 안 옴 press = false; update(); } - if (shown) view.move(x, y); + if (seen) view.move(x, y); }); + on("mouseover", track); // iframe 위로 들어가면 mousemove 가 안 옴 — 들어갈 때 한 번 + on("mousedown", (e) => { if (e.button !== 2) return; swallowMenu = false; stopHold(); - if (!isOn()) return; + if (!isOn() || isCadTarget(e.target)) return; x = e.clientX; y = e.clientY; holdTimer = win.setTimeout(() => { @@ -177,16 +204,12 @@ export function initLoupe( key = true; update(); }); - on("keyup", (e) => { - if (keyLetter(e) !== "z" || !key) return; - key = false; - update(); - }); + on("keyup", releaseZ); // 켜 있는 동안만 휠을 잡음 — 잡기 단계 첫 자리 · passive:false 여야 스크롤을 막음 win.addEventListener( "wheel", (e) => { - if (!shown) return; + if (!seen) return; // CAD 위 — CAD 휠 줌 그대로 e.preventDefault(); e.stopImmediatePropagation(); // 표 · 그래프 휠 줌도 안 받음 zoom = nextLoupeZoom(zoom, e.deltaY); @@ -195,7 +218,19 @@ export function initLoupe( { capture: true, passive: false }, ); on("blur", (e) => { - if (e.target !== win) return; // 칸 초점 빠짐은 무시 — 창이 초점을 잃을 때만(CAD iframe 으로 넘어감 포함) + if (e.target !== win) return; // 칸 초점 빠짐은 무시 — 창이 초점을 잃을 때만 + const active = win.document?.activeElement as HTMLIFrameElement | null | undefined; + if (isCadTarget(active)) { + // 커서가 CAD 로 들어가 초점이 넘어감(cad_host) — 끄지 않고 숨김만 · Z 떼기는 그쪽 창에서 들음 + try { + active?.contentWindow?.addEventListener("keyup", releaseZ, true); + } catch { + // 다른 출처 — 나올 때 Z 를 다시 눌러야 꺼짐 + } + overCad = true; + update(); + return; + } stopHold(); press = key = false; update(); @@ -328,18 +363,15 @@ function walk( if (o.namespaceURI === SVG_NS) return; // SVG 는 복제 그대로 const tag = o.tagName.toUpperCase(); if (tag === "CANVAS") return drawCanvas(o as HTMLCanvasElement, c as HTMLCanvasElement); - if (tag === "IFRAME") return void c.replaceWith(frameShot(o as HTMLIFrameElement, doc)); if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return copyValue(o, c); const r = o.getBoundingClientRect(); - if (!KEEP_INSIDE.has(tag) && (r.bottom < 0 || r.right < 0 || r.top > vh || r.left > vw)) { - c.replaceChildren(); // 화면 밖 — 속을 비우고 크기만 - Object.assign((c as HTMLElement).style, { - boxSizing: "border-box", - width: `${r.width}px`, - height: `${r.height}px`, - }); - return; + if (tag === "IFRAME") { + const box = doc.createElement("div"); // 복제 iframe 은 CAD 를 또 띄움 — 빈 상자로 바꿈 + c.replaceWith(box); + return emptyBox(box, r); } + const off = r.bottom < 0 || r.right < 0 || r.top > vh || r.left > vw; + if (o.classList?.contains("cad-host") || (!KEEP_INSIDE.has(tag) && off)) return emptyBox(c, r); // CAD · 화면 밖 if (o.scrollTop || o.scrollLeft) scrolls.push([c, o.scrollTop, o.scrollLeft]); for (let i = 0; i < o.children.length; i++) walk(o.children[i], c.children[i], doc, vw, vh, scrolls); @@ -364,23 +396,12 @@ function copyValue(o: Element, c: Element): void { } } -/** iframe 자리 — 같은 출처면 안의 canvas 를 한 장에 모아 그림 · 아니면 빈칸 */ -function frameShot(frame: HTMLIFrameElement, doc: Document): HTMLCanvasElement { - const r = frame.getBoundingClientRect(); - const shot = doc.createElement("canvas"); - shot.className = frame.className; - shot.width = Math.max(1, Math.round(r.width)); - shot.height = Math.max(1, Math.round(r.height)); - Object.assign(shot.style, { display: "block", width: `${r.width}px`, height: `${r.height}px` }); - try { - const ctx = shot.getContext("2d"); - for (const canvas of Array.from(frame.contentDocument?.querySelectorAll("canvas") ?? [])) { - const cr = canvas.getBoundingClientRect(); - if (canvas.width && canvas.height) - ctx?.drawImage(canvas, cr.left, cr.top, cr.width, cr.height); - } - } catch { - // 다른 출처 · 그림 없음 — 빈칸 - } - return shot; +/** 속을 비우고 크기만 남김 — 화면 밖 · CAD 영역 */ +function emptyBox(c: Element, r: DOMRect): void { + c.replaceChildren(); + Object.assign((c as HTMLElement).style, { + boxSizing: "border-box", + width: `${r.width}px`, + height: `${r.height}px`, + }); } From 74c78154758c48d338e1b51eecb6d260ee39ff75 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:27:30 +0900 Subject: [PATCH 05/15] =?UTF-8?q?chore(plan):=2010-10=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=2097?= 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/요구_1010_2/사용자요구_1010_2.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 3b42827fc..2a930d328 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -120,3 +120,4 @@ 94. 캐드 프로그램에서 스케일 기능은 잘되지만 추가로 오토캐드처럼 오리진을 지정하고 숫자로 스케일 값을 입력을 받게 추가 95. 캐드 프로그램에서 줌아웃을 많이 하면 브라우져의 속도가 많이 떨어지던데 개선방향은? (질문) 96. 특히 M01 GF000156과 같은 단가산출이 있는지 조사하고 개선 +97. 돋보기 기능이 페이지의 스크롤에 상황에 따라 화면이 안바뀜 개선 From 83798367487a1ca3d53fd01ac3d47f467dae3a1d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:29:34 +0900 Subject: [PATCH 06/15] =?UTF-8?q?fix(ui):=20=EB=8F=8B=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EB=94=B0=EB=9D=BC=EA=B0=90=20?= =?UTF-8?q?=E2=80=94=20=EB=8B=A4=EC=8B=9C=20=EC=BC=A4=20=EB=95=8C=20?= =?UTF-8?q?=EC=9B=90=EC=9D=84=20=EB=A8=BC=EC=A0=80=20=EB=B3=B4=EC=97=AC=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=98=AE=EA=B9=80=EC=9D=B4=20?= =?UTF-8?q?=EB=A8=B9=EA=B2=8C=20=C2=B7=20=EC=BC=A0=20=EC=B1=84=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=EC=9D=80=20rAF=20=ED=95=9C=20=EB=B2=88?= =?UTF-8?q?=EC=97=90=20=EC=B9=B8=20=C2=B7=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=A7=9E=EC=B6=A4=20=C2=B7=200.5=20?= =?UTF-8?q?=EC=B4=88=20=EB=92=A4=20=EB=8B=A4=EC=8B=9C=20=EB=9C=B8=20(221-2?= =?UTF-8?q?)?= 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_01QND7Co43B24zK2P937rdxt --- resources/tester/helper_218_1_loupe.cjs | 58 ++++++++++++++++++++- resources/tester/test_221_2_loupe_scroll.py | 42 +++++++++++++++ ui_template/ui_template_loupe.ts | 52 +++++++++++++----- 3 files changed, 137 insertions(+), 15 deletions(-) create mode 100644 resources/tester/test_221_2_loupe_scroll.py diff --git a/resources/tester/helper_218_1_loupe.cjs b/resources/tester/helper_218_1_loupe.cjs index 52ac768cf..ac649dbf7 100644 --- a/resources/tester/helper_218_1_loupe.cjs +++ b/resources/tester/helper_218_1_loupe.cjs @@ -1,4 +1,4 @@ -/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율 · 221-1 CAD 빼기) 단위검증 헬퍼 — +/* 돋보기(`ui_template/ui_template_loupe.ts` · 계획서 218-1 · 220-1 휠 배율 · 221-1 CAD 빼기 · 221-2 스크롤) 단위검증 헬퍼 — * 그 자리에서 트랜스파일해 Node 로 돌리고 값을 JSON 으로 냄. 가짜 창 · 가짜 시계 · 가짜 요소로 판정. */ const fs = require("fs"); const path = require("path"); @@ -37,6 +37,8 @@ function fakeWindow(doc) { (win.calls = win.calls.filter((c) => c.type !== type || c.fn !== fn)); win.setTimeout = (fn, ms) => (win.timers.push({ id: ++win.seq, at: win.now + ms, fn }), win.seq); win.clearTimeout = (id) => (win.timers = win.timers.filter((t) => t.id !== id)); + win.requestAnimationFrame = (fn) => win.setTimeout(fn, 16); // 한 화면 틀 + win.cancelAnimationFrame = win.clearTimeout; win.tick = (ms) => { win.now += ms; for (const t of win.timers.filter((t) => t.at <= win.now)) { @@ -374,7 +376,7 @@ class El { this.style = {}; this.dataset = {}; this.attrs = {}; - this.scrollTop = opts.scrollTop || 0; + this.st = opts.scrollTop || 0; this.scrollLeft = 0; this.width = opts.width || 0; this.height = opts.height || 0; @@ -391,6 +393,14 @@ class El { getBoundingClientRect() { return this.rect; } + // 실제 DOM 처럼 숨긴(hidden) 조상 아래에선 스크롤을 적어도 안 먹음 + get scrollTop() { + return this.st; + } + set scrollTop(v) { + for (let e = this; e; e = e.parent) if (e.hidden) return; + this.st = v; + } get classList() { return { contains: (k) => this.className.split(" ").includes(k) }; } @@ -470,6 +480,8 @@ function makePage() { }); cadBox.className = "cad-host"; cadBox.append(new El("IFRAME"), new El("A")); + scroller.scrollHeight = 900; // 스크롤될 수 있는 칸 + scroller.clientHeight = 300; scroller.append(canvas, blank, input, check); app.append(scroller, offscreen, table, svg, frame, cadBox); return app; @@ -581,4 +593,46 @@ function fakeDocument(app) { r.hide.reopenLabel = [label.textContent, label.hidden, stage.style.transform]; r.hide.reuseRoot = doc.body.children.length; } +{ + // 221-2 스크롤 — 스크롤한 뒤 켬 · 켠 채 스크롤(칸 · 문서) + const app = makePage(); + const scroller = app.children[0]; + const doc = fakeDocument(app); + const win = fakeWindow(doc); + win.MutationObserver = class { + observe() {} + disconnect() {} + }; + const view = m.createLoupeView(win); + const cloneScroller = () => doc.body.children[0].children[0].children[0].children[0].children[0]; + const page = () => doc.body.children[0].children[0].children[0]; + view.show(500, 400); + view.hide(); + scroller.scrollTop = 0; // 0 에서 켬 → 스크롤 칸으로 잡힘 + view.show(500, 400); + const zero = cloneScroller().scrollTop; + view.hide(); + scroller.scrollTop = 260; // 꺼진 동안 스크롤해 둠 → 다시 켬(숨긴 원을 다시 씀) + view.show(500, 400); + const before = cloneScroller().scrollTop; + // 켠 채 스크롤 — 다음 화면 틀에 위치만 · 같은 복제 + const first = cloneScroller(); + scroller.scrollTop = 400; + app.rect = { ...app.rect, top: -50, bottom: 750 }; // 문서도 50 내려감 + const scroll = () => win.calls.filter((c) => c.type === "scroll").forEach((c) => c.fn({})); + scroll(); + scroll(); // 한 틀에 한 번으로 묶음 + const rafs = win.timers.filter((t) => t.at === win.now + 16).length; + win.tick(16); + const during = { + top: cloneScroller().scrollTop, + same: cloneScroller() === first, + pageTop: page().style.top, + }; + win.tick(484); // 0.5 초 뒤 다시 뜸 — 비워 둔 칸을 채움 · 스크롤은 그대로 + const recaptured = { same: cloneScroller() === first, top: cloneScroller().scrollTop }; + view.hide(); + const n = win.timers.length; + r.scroll = { zero, before, rafs, during, recaptured, timersAfterHide: n }; +} console.log(JSON.stringify(r)); diff --git a/resources/tester/test_221_2_loupe_scroll.py b/resources/tester/test_221_2_loupe_scroll.py new file mode 100644 index 000000000..feec5162c --- /dev/null +++ b/resources/tester/test_221_2_loupe_scroll.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +"""돋보기 스크롤 따라감(계획서 221-2) 회귀검증 — 스크롤해 둔 뒤 켜도 원 = 지금 화면 · +켠 채 스크롤하면 다음 화면 틀(rAF 한 번)에 위치만 맞춤 · 0.5 초 뒤 다시 뜸 · 끄면 예약 없음. + +218-1 헬퍼(`helper_218_1_loupe.cjs`)의 스크롤 장면을 판정함(숨긴 원 안에선 스크롤이 안 먹는 실제 동작 흉내). +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def s(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_218_1_loupe.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout)["scroll"] + + +def test_스크롤한_뒤_켬(s): + assert s["zero"] == 0 + assert s["before"] == 260 # 다시 켤 때도 지금 스크롤 그대로 + + +def test_켠_채_스크롤_위치만_맞춤(s): + assert s["rafs"] == 1 # 스크롤 두 번 → 한 틀에 한 번 + assert s["during"] == {"top": 400, "same": True, "pageTop": "-50px"} # 칸 · 문서 스크롤 + + +def test_켠_채_스크롤_0_5초_뒤_다시_뜸(s): + assert s["recaptured"] == {"same": False, "top": 400} + assert s["timersAfterHide"] == 0 diff --git a/ui_template/ui_template_loupe.ts b/ui_template/ui_template_loupe.ts index 1703dc58a..16479963a 100644 --- a/ui_template/ui_template_loupe.ts +++ b/ui_template/ui_template_loupe.ts @@ -7,6 +7,7 @@ * 길게 누른 우클릭은 뗀 뒤 메뉴(우리 메뉴 · 브라우저 메뉴)를 안 띄움 · 짧게 누르면 지금 우클릭 그대로. * 그리기 — 켤 때 `#app` 을 한 번 떠서(DOM 복제 + 2D canvas 그림 복사 · SVG 그대로) 원 안에 놓고, 움직일 때 transform 만 바꿈. * 화면 밖 상자는 속을 비우고 크기만 남김 · 켜 있는 동안 화면이 바뀌면 0.5 초 뒤 다시 뜸 · 끄면 복제를 지움. + * 스크롤(221-2) — 뜰 때 스크롤 칸마다 scrollTop · Left 를 복제에 옮김 · 켜진 동안 어느 칸이든 스크롤하면 그 화면 틀(rAF 한 번)에 위치를 맞추고 0.5 초 뒤 다시 뜸(비워 둔 화면 밖 칸을 채움). * WebGL canvas 는 그림이 남아 있을 때만 옮겨짐. * CAD 영역(cad_host 상자 · iframe · 221-1) — 커서가 그 위면 켜지 않고 · 켜진 채 들어가면 숨김(나오면 다시) · 휠도 CAD 로 · 복제는 빈칸. * ========================================================================== */ @@ -250,6 +251,8 @@ export function createLoupeView( let at: [number, number] = [0, 0]; // 마지막 커서 — 배율만 바뀔 때 다시 놓음 let observer: MutationObserver | null = null; let refreshTimer = 0; + let sync = (): void => {}; // 스크롤 위치 맞춤 — 뜰 때마다 새로 + let syncFrame = 0; const build = (): void => { root = doc.createElement("div"); @@ -271,7 +274,7 @@ export function createLoupeView( const source = sourceOf(); const t0 = win.performance?.now() ?? 0; stage!.replaceChildren(); - if (source) captureInto(source, stage!, win); + if (source) sync = captureInto(source, stage!, win); root!.dataset.captureMs = String(Math.round((win.performance?.now() ?? 0) - t0)); }; const refresh = (): void => { @@ -281,11 +284,19 @@ export function createLoupeView( if (observer) capture(); }, LOUPE_REFRESH_MS); }; + const onScroll = (): void => { + if (!syncFrame) + syncFrame = win.requestAnimationFrame(() => { + syncFrame = 0; + sync(); + }); + refresh(); + }; const view: LoupeView = { show(x, y) { if (!root) build(); + root!.hidden = label!.hidden = false; // 숨긴 채 뜨면 복제의 스크롤이 안 먹음 — 먼저 보임 capture(); - root!.hidden = label!.hidden = false; at = [x, y]; view.setZoom(LOUPE_ZOOM); // 켤 때마다 2배 · 자리도 놓음 const source = sourceOf(); @@ -299,7 +310,7 @@ export function createLoupeView( characterData: true, }); observer = watch; - win.addEventListener("scroll", refresh, true); + win.addEventListener("scroll", onScroll, true); }, move(x, y) { if (!root || !stage || !label) return; @@ -321,7 +332,9 @@ export function createLoupeView( observer = null; win.clearTimeout(refreshTimer); refreshTimer = 0; - win.removeEventListener("scroll", refresh, true); + win.cancelAnimationFrame(syncFrame); + syncFrame = 0; + win.removeEventListener("scroll", onScroll, true); stage?.replaceChildren(); // 복제를 지움 if (root) root.hidden = label!.hidden = true; }, @@ -329,8 +342,8 @@ export function createLoupeView( return view; } -/** `source` 를 떠서 `stage` 에 화면 자리 그대로 놓음 */ -export function captureInto(source: Element, stage: HTMLElement, win: Window): void { +/** `source` 를 떠서 `stage` 에 화면 자리 그대로 놓음 — 돌려주는 함수 = 지금 스크롤 · 자리로 다시 맞춤 */ +export function captureInto(source: Element, stage: HTMLElement, win: Window): () => void { const doc = win.document; const rect = source.getBoundingClientRect(); const page = doc.createElement("div"); @@ -342,14 +355,21 @@ export function captureInto(source: Element, stage: HTMLElement, win: Window): v height: `${rect.height}px`, }); const copy = source.cloneNode(true) as Element; - const scrolls: [Element, number, number][] = []; + const scrolls: [Element, Element][] = []; // [원본, 복제] 스크롤 칸 walk(source, copy, doc, win.innerWidth, win.innerHeight, scrolls); page.append(copy); stage.append(page); - for (const [el, top, left] of scrolls) { - el.scrollTop = top; // 복제는 스크롤이 0 으로 돌아감 — 붙인 뒤 되돌림 - el.scrollLeft = left; - } + const sync = (): void => { + const now = source.getBoundingClientRect(); // 문서 스크롤 — 페이지 자리 + page.style.left = `${now.left}px`; + page.style.top = `${now.top}px`; + for (const [o, c] of scrolls) { + c.scrollTop = o.scrollTop; // 복제는 스크롤이 0 으로 돌아감 — 붙인 뒤 옮김 + c.scrollLeft = o.scrollLeft; + } + }; + sync(); + return sync; } function walk( @@ -358,7 +378,7 @@ function walk( doc: Document, vw: number, vh: number, - scrolls: [Element, number, number][], + scrolls: [Element, Element][], ): void { if (o.namespaceURI === SVG_NS) return; // SVG 는 복제 그대로 const tag = o.tagName.toUpperCase(); @@ -372,7 +392,13 @@ function walk( } const off = r.bottom < 0 || r.right < 0 || r.top > vh || r.left > vw; if (o.classList?.contains("cad-host") || (!KEEP_INSIDE.has(tag) && off)) return emptyBox(c, r); // CAD · 화면 밖 - if (o.scrollTop || o.scrollLeft) scrolls.push([c, o.scrollTop, o.scrollLeft]); + if ( + o.scrollTop || + o.scrollLeft || + o.scrollHeight > o.clientHeight || + o.scrollWidth > o.clientWidth + ) + scrolls.push([o, c]); // 지금 스크롤됐거나 스크롤될 수 있는 칸 for (let i = 0; i < o.children.length; i++) walk(o.children[i], c.children[i], doc, vw, vh, scrolls); } From 02e0727170317ad901c863eb4c1b7862884646d5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:29:39 +0900 Subject: [PATCH 07/15] =?UTF-8?q?feat(cad):=20=EC=B6=95=EC=B2=99=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=A0=90=20=EC=A7=80=EC=A0=95=20=EB=92=A4=20?= =?UTF-8?q?=EC=88=AB=EC=9E=90=20=EB=B0=B0=EC=9C=A8=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=C2=B7=20Enter=20=EC=A0=81=EC=9A=A9=20=C2=B7=20Esc=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=C2=B7=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=ED=95=9C=20=EC=B9=B8=20=C2=B7=20=EB=AA=85=EB=A0=B9=20=EC=A4=84?= =?UTF-8?q?=20=EC=95=88=EB=82=B4=20=ED=95=9C=EA=B8=80=20(226-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_01TNMXVCZXMpHpVvqmFkeChL --- .../openwebcad/src/tools/scale-tool.test.ts | 89 +++++++++++++++++++ .../openwebcad/src/tools/scale-tool.ts | 49 ++++++++-- 2 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/tools/scale-tool.test.ts diff --git a/B07_DesignDetail/openwebcad/src/tools/scale-tool.test.ts b/B07_DesignDetail/openwebcad/src/tools/scale-tool.test.ts new file mode 100644 index 000000000..ee52ef7ae --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/scale-tool.test.ts @@ -0,0 +1,89 @@ +import { Point } from '@flatten-js/core'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { createActor } from 'xstate'; +import { LineEntity } from '../entities/LineEntity.ts'; +import { + getEntities, + resetUndoBaseline, + setEntities, + setSelectedEntityIds, + undo, +} from '../state.ts'; +import { ScaleState, scaleToolStateMachine } from './scale-tool.ts'; +import { ActorEvent } from './tool.types.ts'; + +const ends = (index: number) => { + const line = getEntities()[index] as LineEntity; + const a = line.getStartPoint(); + const b = line.getEndPoint(); + return [a.x, a.y, b.x, b.y]; +}; + +/** 기준점 (2,0) 클릭까지 — 선 A(0,0)-(10,0) 고름 · 선 B(0,5)-(4,5) 는 그대로 둘 것 */ +function startAtBasePoint() { + const a = new LineEntity('layer1', new Point(0, 0), new Point(10, 0)); + const b = new LineEntity('layer1', new Point(0, 5), new Point(4, 5)); + setEntities([a, b]); + resetUndoBaseline(); + setSelectedEntityIds([a.id]); + const actor = createActor(scaleToolStateMachine).start(); + expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT); + actor.send({ + type: ActorEvent.MOUSE_CLICK, + worldMouseLocation: new Point(2, 0), + screenMouseLocation: new Point(0, 0), + holdingCtrl: false, + holdingShift: false, + }); + expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT); + return { actor, a, b }; +} + +const typeNumber = (actor: ReturnType['actor'], value: number) => + actor.send({ type: ActorEvent.NUMBER_INPUT, value, worldMouseLocation: new Point(99, 99) }); + +describe('축척 — 기준점 + 숫자 배율 (226-1)', () => { + beforeEach(() => setSelectedEntityIds([])); + + it('배율 2 = 기준점 고정 두 배 · 다른 객체 · 순서 그대로', () => { + const { actor, a, b } = startAtBasePoint(); + typeNumber(actor, 2); + // (0,0)→(-2,0) · (10,0)→(18,0) — 기준점 (2,0) 은 그대로 + expect(ends(0)).toEqual([-2, 0, 18, 0]); + expect(getEntities()[0].id).toBe(a.id); + expect(getEntities()[1]).toBe(b); + expect(ends(1)).toEqual([0, 5, 4, 5]); + // 끝나면 선택 풀고 다음 고르기로 + expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_SELECTION); + }); + + it('배율 0.5 = 절반', () => { + const { actor } = startAtBasePoint(); + typeNumber(actor, 0.5); + expect(ends(0)).toEqual([1, 0, 6, 0]); + }); + + it('되돌리기 한 번에 원래 자리', () => { + const { actor } = startAtBasePoint(); + typeNumber(actor, 2); + undo(); + expect(ends(0)).toEqual([0, 0, 10, 0]); + expect(getEntities()).toHaveLength(2); + }); + + it('Esc = 취소 — 객체 그대로', () => { + const { actor, a, b } = startAtBasePoint(); + actor.send({ type: ActorEvent.ESC }); + expect(getEntities()).toEqual([a, b]); + expect(ends(0)).toEqual([0, 0, 10, 0]); + typeNumber(actor, 2); + expect(ends(0)).toEqual([0, 0, 10, 0]); + }); + + it('배율 0 은 받지 않음', () => { + const { actor } = startAtBasePoint(); + typeNumber(actor, 0); + expect(ends(0)).toEqual([0, 0, 10, 0]); + expect(actor.getSnapshot().value).toBe(ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts b/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts index 660a885b2..c73e27c6e 100644 --- a/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/scale-tool.ts @@ -2,15 +2,23 @@ import type { Point } from '@flatten-js/core'; import { addEntities, deleteEntities, + getEntities, getSelectedEntities, getSelectedEntityIds, setAngleGuideOriginPoint, + setEntities, setGhostHelperEntities, setSelectedEntityIds, setShouldDrawHelpers, } from '../state'; import { Tool } from '../tools'; -import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types'; +import type { + DrawEvent, + MouseClickEvent, + NumberInputEvent, + StateEvent, + ToolContext, +} from './tool.types'; import { assign, createMachine, sendTo } from 'xstate'; import { selectToolStateMachine } from './select-tool'; import type { Entity } from '../entities/Entity'; @@ -42,6 +50,7 @@ export enum ScaleAction { COPY_SELECTION_BEFORE_SCALE = 'COPY_SELECTION_BEFORE_SCALE', DRAW_TEMP_SCALE_ENTITIES = 'DRAW_TEMP_SCALE_ENTITIES', SCALE_SELECTION = 'SCALE_SELECTION', + SCALE_SELECTION_BY_FACTOR = 'SCALE_SELECTION_BY_FACTOR', DESELECT_ENTITIES = 'DESELECT_ENTITIES', RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', } @@ -98,7 +107,7 @@ export const scaleToolStateMachine = createMachine( [ScaleState.WAITING_FOR_SELECTION]: { description: 'Select what you want to scale', meta: { - instructions: 'Select what you want to scale, then ENTER', + instructions: '축척할 객체를 선택한 뒤 ENTER.', }, invoke: { id: 'selectToolInsideTheScaleTool', @@ -142,7 +151,7 @@ export const scaleToolStateMachine = createMachine( [ScaleState.WAITING_FOR_BASE_VECTOR_START_POINT]: { description: 'Select the origin of the scale operation', meta: { - instructions: 'Select the origin of the scale operation', + instructions: '기준점을 지정하십시오. (Esc 취소)', }, always: { actions: ScaleAction.ENABLE_HELPERS, @@ -161,9 +170,17 @@ export const scaleToolStateMachine = createMachine( [ScaleState.WAITING_FOR_BASE_VECTOR_END_POINT]: { description: 'Select the end of the base scale line', meta: { - instructions: 'Select the end of the base scale line', + instructions: + '배율을 입력하고 ENTER — 또는 끌어서 맞출 기준선 끝을 클릭하십시오. (Esc 취소)', }, on: { + // 오토캐드 SCALE — 기준점 뒤 숫자 배율(2 = 두 배 · 0.5 = 절반) + NUMBER_INPUT: { + guard: ({ event }) => + (event as NumberInputEvent).value > 0 && getSelectedEntityIds().length > 0, + actions: [ScaleAction.SCALE_SELECTION_BY_FACTOR, ScaleAction.DESELECT_ENTITIES], + target: ScaleState.INIT, + }, MOUSE_CLICK: { actions: [ ScaleAction.RECORD_BASE_VECTOR_END_POINT, @@ -180,7 +197,7 @@ export const scaleToolStateMachine = createMachine( [ScaleState.WAITING_FOR_SCALE_VECTOR_END_POINT]: { description: 'Select the end of the scale line', meta: { - instructions: 'Select the end of the scale line', + instructions: '새 길이의 끝을 클릭하십시오. (Esc 취소)', }, on: { DRAW: { @@ -235,7 +252,9 @@ 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) => cloneKeepingLook(entity, true))), + originalSelectedEntities: compact( + selectedEntities.map((entity) => cloneKeepingLook(entity, true)) + ), }; }), [ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => { @@ -282,6 +301,24 @@ export const scaleToolStateMachine = createMachine( setGhostHelperEntities([]); setSelectedEntityIds([]); }, + [ScaleAction.SCALE_SELECTION_BY_FACTOR]: ({ context, event }) => { + const origin = context.baseVectorStartPoint; + if (!origin) { + throw new Error('[SCALE] Calling scale by factor without a base point'); + } + const factor = (event as NumberInputEvent).value; + const selectedIds = new Set(getSelectedEntityIds()); + // 제자리 교체 — 복사본을 키워 같은 순서에 넣고 되돌리기 한 칸으로 남긴다 + setEntities( + getEntities().map((entity) => { + if (!selectedIds.has(entity.id)) return entity; + const scaled = cloneKeepingLook(entity, true); + scaled.scale(origin, factor); + return scaled; + }), + true + ); + }, [ScaleAction.DESELECT_ENTITIES]: assign(() => { setGhostHelperEntities([]); setSelectedEntityIds([]); From 91e85cfd95fb9a66a5aadcc477d8862bda50151e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:31:18 +0900 Subject: [PATCH 08/15] =?UTF-8?q?feat(ui):=20B05/B06=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=83=81=EB=8B=A8=EC=97=90=20M02=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=EC=A7=91=EA=B3=84=ED=91=9C=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20=ED=91=9C=20(224-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 고른 항목이 속한 층 · 주인의 구조물집계표를 M02 와 같은 표 부품 · 줄 글 도우미(numberLabels) · 숨김 열 규칙(comboView)으로 보임 - 같은 층 다른 조합 = 열만 옮김 · 다른 층 = 표 바뀜 · 편집 화면에서는 숨김 - 새 시험 test_224_1 · test_m02_combo_table 단정 고침 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3 --- .../M02_MasterTemplete_Api_Fetch.ts | 7 + .../M02_MasterTemplete_Structure.ts | 11 ++ .../M02_MasterTemplete_UI_Main.ts | 9 +- .../tester/helper_224_1_picker_top_table.cjs | 167 ++++++++++++++++++ .../tester/test_224_1_picker_top_table.py | 136 ++++++++++++++ resources/tester/test_m02_combo_table.py | 7 +- ui_template/ui_template_item_picker.css | 15 ++ ui_template/ui_template_item_picker.ts | 13 +- ui_template/ui_template_item_picker_data.ts | 3 + ui_template/ui_template_item_picker_table.ts | 94 ++++++++++ ui_template/ui_template_locale_m2.ts | 1 + 11 files changed, 453 insertions(+), 10 deletions(-) create mode 100644 resources/tester/helper_224_1_picker_top_table.cjs create mode 100644 resources/tester/test_224_1_picker_top_table.py create mode 100644 ui_template/ui_template_item_picker_table.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index e59d594fb..7c6f6e109 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -243,6 +243,13 @@ export interface StructureNumbers { export const fetchStructureNumbers = (): Promise> => scoped("/structures/numbers"); +/** 같은 번호 목록을 **고른 층**(+ 주인)에서 — 지금 층 길을 안 바꾸고 읽음(고르기 모달 상단 표 · 224-1) */ +export const fetchStructureNumbersAt = ( + layer: Layer, + owner: string | null, +): Promise> => + call(owned(layer, `/layers/${layer}/structures/numbers`, owner)); + /** 구조물집계표조합 한 줄 — 구조물 열 하나(키 `SS` + 6자리 · 한 번 준 키는 안 바뀜) */ export interface StructureCombo extends StructureNumbers { 키: string; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 881a4607b..f4b98ca00 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -86,6 +86,17 @@ export function laborSumState( return numbers?.합계상태 ? { state: numbers.합계상태, why: numbers.합계까닭 ?? "" } : undefined; } +/** 구조물 조합 표 번호 · 합계 줄 글(86-3 · 101-1) — 관리 화면과 고르기 모달 상단 표(224-1)가 같은 한 벌 */ +export function numberLabels(numbers: () => ColumnNumbers) { + return { + laborLabel: (colId: string) => numbers()[colId]?.일위대가번 ?? "", + drawingLabel: (colId: string) => numbers()[colId]?.도번 ?? "", + areaLabel: (colId: string) => numbers()[colId]?.면적번 ?? "", + laborSumLabel: (colId: string) => laborSumText(numbers()[colId]), + laborSumState: (colId: string) => laborSumState(numbers()[colId]), + }; +} + /** 집계표 열 — 머리 글(제목행 종류 · 분류1 · 분류2 · 분류3) · 꼴만 씀 */ export interface StructureColumn { id: string; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index c27d84018..b4c77c65f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -22,8 +22,7 @@ import { comboMerge, comboView, createStructurePanel, - laborSumState, - laborSumText, + numberLabels, pillsFor, STRUCTURE_TABLE, type StructureColumn, @@ -381,11 +380,7 @@ export function createMain( onChange: () => (dirty = !readOnly), ...(struct ? { - 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 - laborSumState: (colId: string) => laborSumState(struct.numbers()[colId]), // 101-1 + ...numberLabels(struct.numbers), // 번호 · 일위대가 합계 줄(86-3 · 101-1) — 고르기 모달 상단 표와 한 벌 combo: { ...(readOnly || !extras.onHeadRename ? {} diff --git a/resources/tester/helper_224_1_picker_top_table.cjs b/resources/tester/helper_224_1_picker_top_table.cjs new file mode 100644 index 000000000..a7b1e700a --- /dev/null +++ b/resources/tester/helper_224_1_picker_top_table.cjs @@ -0,0 +1,167 @@ +/* 고르기 모달 상단 표(224-1) 시험 헬퍼 — `ui_template_item_picker_table.ts` 를 트랜스파일해 + * 표 부품 · 집계표 모듈 · 서버 읽기를 대역으로 갈아 끼우고 돌려 부름 기록을 JSON 한 벌로 냄. + * 동적 `import()` 도 CommonJS 변환에서 같은 `require` 길을 타 대역이 먹음. + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_curve_radius_floor.cjs` 머리와 같다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const UI = path.join(ROOT, "ui_template"); +const log = []; +const state = { numbersFail: false, tableFail: null }; + +const node = (props = {}) => ({ + className: props.className ?? "", + hidden: "attrs" in props && props.attrs && "hidden" in props.attrs, + text: props.text ?? "", + children: props.children ?? [], + replaceChildren(...kids) { + this.children = kids; + }, +}); + +const STUBS = { + "./ui_template_elements": { el: (_tag, props) => node(props) }, + "./ui_template_locale": { t: (key) => (key === "M02_PickerTableFailed" ? "표 실패 {value}" : "읽는 중") }, + "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch": { + readTemplate: async (...args) => { + log.push(["readTemplate", ...args]); + if (state.tableFail) throw new Error(state.tableFail); + return { 문서: { 열: [], tag: `${args[0]}|${args[4]}` } }; + }, + fetchStructureNumbersAt: async (...args) => { + log.push(["numbers", ...args]); + if (state.numbersFail) throw new Error("numbers down"); + return { rv1: { 도번: "SD1" } }; + }, + }, + "../M02_MasterTemplete/M02_MasterTemplete_Structure": { + STRUCTURE_TABLE: "구조물집계표", + comboView: (doc) => ({ ...doc, comboView: true }), + numberLabels: (numbers) => ({ laborLabel: () => "", seenNumbers: numbers() }), + }, + "./sheet/ui_template_sheet": { + createSheet: (host, doc, opts) => { + const entry = { doc, opts, selected: [], destroyed: false, host }; + log.push(["createSheet", entry]); + host.replaceChildren({ sheet: true }); + return { + selectColumn: (id) => entry.selected.push(id), + destroy: () => (entry.destroyed = true), + }; + }, + }, +}; + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 20)); +const row = (key, 열, layer, owner) => ({ key, 열, layer, owner }); +const names = () => log.map((entry) => entry[0]); +const creates = () => log.filter((entry) => entry[0] === "createSheet").map((entry) => entry[1]); + +(async () => { + const { createComboTable } = loadTs(path.join(UI, "ui_template_item_picker_table.ts")); + const data = loadTs(path.join(UI, "ui_template_item_picker_data.ts")); + const out = {}; + const mine = row("SS1", "rv1", "personal", "1/3"); + const sameLayer = row("SS2", "rv2", "personal", "1/3"); + const master = row("SS9", "rv9", "system", null); + + const table = createComboTable(); + out.startHidden = table.root.hidden; + table.show(null); + out.nullHidden = table.root.hidden; + out.nullCalls = names(); + + table.show(mine); + out.shownHidden = table.root.hidden; + await flush(); + out.firstReads = log.filter((e) => e[0] !== "createSheet"); + const first = creates()[0]; + out.first = { + count: creates().length, + readOnly: first.opts.readOnly, + lockHead: first.opts.lockHead, + mode: first.opts.mode, + combo: !!first.opts.combo, + comboDoc: first.doc.comboView, + docTag: first.doc.tag, + numbersSeen: first.opts.seenNumbers, + selected: first.selected, + }; + + log.length = 0; + table.show(sameLayer); // 같은 층 다른 조합 — 표 그대로 · 열만 + await flush(); + out.sameLayer = { calls: names(), selected: first.selected, destroyed: first.destroyed }; + + log.length = 0; + table.show(master); // 다른 층 — 그 층 표로 바뀜 + await flush(); + out.otherLayer = { + reads: log.filter((e) => e[0] === "readTemplate"), + creates: creates().length, + oldDestroyed: first.destroyed, + selected: creates()[0].selected, + docTag: creates()[0].doc.tag, + }; + + log.length = 0; + table.show(null); + out.hideAgain = table.root.hidden; + + // 읽는 사이에 다른 조합을 고름 — 마지막 것만 그려짐 + log.length = 0; + table.show(mine); + table.show(master); + await flush(); + out.race = { creates: creates().length, docTag: creates()[0]?.doc.tag }; + + // 번호 읽기 실패 — 표는 그려짐 + log.length = 0; + state.numbersFail = true; + const lone = createComboTable(); + lone.show(mine); + await flush(); + out.numbersFail = { creates: creates().length, seen: creates()[0]?.opts.seenNumbers }; + state.numbersFail = false; + + // 표 읽기 실패 — 까닭 글 · 표 없음 + log.length = 0; + state.tableFail = "그 층은 아직 만든 것이 없음"; + const broken = createComboTable(); + broken.show(mine); + await flush(); + out.tableFail = { creates: creates().length, text: broken.root.children[0].text }; + + out.keys = { + mineSame: data.tableKey(mine) === data.tableKey(sameLayer), + layerDiffers: data.tableKey(mine) !== data.tableKey(master), + ownerDiffers: data.tableKey(mine) !== data.tableKey(row("SS1", "rv1", "personal", "1/4")), + mine: data.tableKey(mine), + }; + console.log(JSON.stringify(out)); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/resources/tester/test_224_1_picker_top_table.py b/resources/tester/test_224_1_picker_top_table.py new file mode 100644 index 000000000..e01ed490e --- /dev/null +++ b/resources/tester/test_224_1_picker_top_table.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +"""PLAN 224-1 — B05/B06 구조물 조합 고르기 모달 맨 위 = M02 구조물조합 상단 표(구조물집계표). + +① 행동(Node · 대역): 모달 상단 표 부품(`ui_template_item_picker_table.ts`)을 표 부품 · 집계표 모듈 · + 서버 읽기 대역 위에서 돌려 — 고른 항목의 층 표를 보기 전용으로 그리고, 같은 층 다른 조합 = 열만 옮김 · + 다른 층 = 표가 바뀜 · 읽는 사이 바뀌면 마지막 것만 · 번호 읽기 실패해도 표 · 표 읽기 실패 = 까닭 글. +② 같은 부품(소스): M02 관리 화면 · 모달이 같은 `createSheet` · 같은 줄 글 도우미(`numberLabels`) · 같은 + 숨김 열 규칙(`comboView`)을 씀 · 모달이 상단 표를 배선(고르면 보임 · 편집 화면에선 숨김). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +UI = ROOT / "ui_template" +M02 = ROOT / "M02_MasterTemplete" + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", str(HERE / "helper_224_1_picker_top_table.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["startHidden"] is True + assert out["nullHidden"] is True + assert out["nullCalls"] == [] # 고른 것 없으면 서버도 안 부름 + assert out["shownHidden"] is False + assert out["hideAgain"] is True + + +def test_고른_항목_층의_집계표를_보기_전용으로(out): + # 그 층 · 그 주인의 구조물집계표 + 번호 목록 + assert out["firstReads"] == [ + ["readTemplate", "personal", "table", "구조물집계표", None, "1/3"], + ["numbers", "personal", "1/3"], + ] + first = out["first"] + assert first["count"] == 1 + assert first["readOnly"] is True # 보기 전용 — 고침은 M02 에서 + assert first["lockHead"] is True + assert first["combo"] is True # M02 「구조물 조합」 상단 표 꼴 + assert first["comboDoc"] is True # 숨김 열 규칙 = M02 와 같은 `comboView` + assert first["docTag"] == "personal|1/3" + assert first["numbersSeen"] == {"rv1": {"도번": "SD1"}} # 줄 글 도우미가 같은 번호 목록을 받음 + + +def test_같은_층_다른_조합은_열만_옮김_다른_층은_표가_바뀜(out): + same = out["sameLayer"] + assert same["calls"] == [] # 서버 · 표 다시 안 그림 + assert same["selected"] == ["rv1", "rv2"] + assert same["destroyed"] is False + other = out["otherLayer"] + assert other["reads"] == [["readTemplate", "system", "table", "구조물집계표", None, None]] + assert other["creates"] == 1 + assert other["oldDestroyed"] is True # 옛 표를 거둠 + assert other["selected"] == ["rv9"] + assert other["docTag"] == "system|null" + + +def test_읽는_사이_바뀌면_마지막_조합만(out): + assert out["race"] == {"creates": 1, "docTag": "system|null"} + + +def test_번호_읽기_실패해도_표는_그려지고_표_읽기_실패는_까닭_글(out): + assert out["numbersFail"] == {"creates": 1, "seen": {}} + assert out["tableFail"] == {"creates": 0, "text": "표 실패 그 층은 아직 만든 것이 없음"} + + +def test_표_키_같은_층_주인이면_같고_층_주인이_다르면_다름(out): + assert out["keys"] == { + "mineSame": True, + "layerDiffers": True, + "ownerDiffers": True, + "mine": "personal|1/3", + } + + +def test_모달과_M02_가_같은_표_부품_줄_글_도우미_숨김_열_규칙(): + table = _text(UI / "ui_template_item_picker_table.ts") + main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") + struct = _text(M02 / "M02_MasterTemplete_Structure.ts") + # 같은 표 부품(관리 화면은 이 파일을 glob 으로 받음) + assert 'import("./sheet/ui_template_sheet")' in table + assert '"../ui_template/sheet/ui_template_sheet.ts"' in main + # 보기 전용 · 상단 표 꼴 + assert "readOnly: true," in table and "combo: {}," in table + # 줄 글 도우미 한 벌 — 둘 다 같은 함수 + assert "export function numberLabels(" in struct + assert "...struct.numberLabels(() => numbers)," in table + assert "...numberLabels(struct.numbers)" in main + # 숨김 열 규칙 한 벌 + assert "struct.comboView(got.문서)" in table + assert "comboView(doc)" in main + # 서버 읽기 = 고른 층 · 주인 · 같은 집계표 이름 + assert 'readTemplate(layer, "table", struct.STRUCTURE_TABLE, null, owner)' in table + assert 'STRUCTURE_TABLE = "구조물집계표"' in struct + + +def test_모달_배선_고르면_보이고_편집_화면에서는_숨김(): + ts = _text(UI / "ui_template_item_picker.ts") + assert "createComboTable()" in ts + assert "comboTable.show(chosen);" in ts # 고른 항목이 바뀔 때마다 + assert "body.append(tableWrap, cols, editHost," in ts # 맨 위 + assert ts.count("tableWrap.hidden = true;") == 1 # 편집 화면 열 때 + assert ts.count("tableWrap.hidden = false;") == 1 # 돌아올 때 + assert "comboTable.destroy();" in ts # 닫을 때 표 거둠 + + +def test_표_높이_제한과_숨김_css(): + css = _text(UI / "ui_template_item_picker.css") + assert ".ui-item-picker__table .ui-sheet__scroll {" in css + assert "max-height: 26vh;" in css + assert ".ui-item-picker__table-wrap[hidden]" in css + + +def test_번호_읽기_층_길_추가_지금_층을_안_바꿈(): + api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts") + assert "export const fetchStructureNumbersAt = (" in api + assert "call(owned(layer, `/layers/${layer}/structures/numbers`, owner));" in api diff --git a/resources/tester/test_m02_combo_table.py b/resources/tester/test_m02_combo_table.py index 0d7ba931c..11802c88e 100644 --- a/resources/tester/test_m02_combo_table.py +++ b/resources/tester/test_m02_combo_table.py @@ -163,8 +163,11 @@ def test_일위대가_합계_줄_표_부품과_연결(): 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 "laborSumState: (colId: string) => laborSumState(struct.numbers()[colId])" in main + # 줄 글은 도우미 한 벌(`numberLabels` · 고르기 모달 상단 표와 같음 · 224-1) + assert "...numberLabels(struct.numbers)" in main + helper = _text(M02 / "M02_MasterTemplete_Structure.ts") + assert "laborSumLabel: (colId: string) => laborSumText(numbers()[colId])" in helper + assert "laborSumState: (colId: string) => laborSumState(numbers()[colId])" in helper assert "(기준)" not in _text(M02 / "M02_MasterTemplete_Structure.ts") # 101-1 글 지움 assert "is-sum-${sum.state}" in render and "td.is-sum-error" in _text( SHEET / "ui_template_sheet.css" diff --git a/ui_template/ui_template_item_picker.css b/ui_template/ui_template_item_picker.css index b84f490ed..c91228696 100644 --- a/ui_template/ui_template_item_picker.css +++ b/ui_template/ui_template_item_picker.css @@ -52,6 +52,21 @@ overflow: auto; } +/* 상단 표(224-1) — M02 구조물집계표 같은 부품 · 높이만 줄임(표 안에서 굴림) */ +.ui-item-picker__table-wrap { + flex: none; + min-width: 0; +} + +.ui-item-picker__table .ui-sheet__scroll { + max-height: 26vh; +} + +.ui-item-picker__table-wrap[hidden], +.ui-item-picker__table[hidden] { + display: none; +} + .ui-item-picker__cols[hidden], .ui-item-picker__actions[hidden], .ui-item-picker .ui-modal__actions[hidden], diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts index fe68930e6..6eb58469e 100644 --- a/ui_template/ui_template_item_picker.ts +++ b/ui_template/ui_template_item_picker.ts @@ -8,6 +8,7 @@ * * 26-3b — 카드 · 편집 · [재적용] · [개인에 저장] 은 **고르는 구조물의 복사본**(`structureId`) 기준. * 나무 개인 그룹 · 폴더를 오른쪽 클릭하면 「새 항목」(프로젝트에서 새 항목 → 개인 층에도 만들고 폼에 지정). + * 224-1 — 맨 위 = M02 구조물조합 상단 표(구조물집계표 · 보기 전용 · 고른 항목의 열이 골라짐). * 173-2 — 나무 = M02 나무 꼴: 층 단추 셋(하나만 보임 · 뿌리 줄 없음) · 개인 맨 위 「새 항목」 = 빈 자리 오른쪽 클릭. * ========================================================================== */ @@ -43,6 +44,7 @@ import { askFields } from "./ui_template_item_picker_ask"; import { projectItems, type CreatedItem } from "./ui_template_item_picker_project"; import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail"; import { createEditView, type EditView } from "./ui_template_item_picker_edit"; +import { createComboTable } from "./ui_template_item_picker_table"; export type { SummaryItem } from "./ui_template_item_picker_data"; @@ -146,6 +148,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise void load(created.key), }); + const comboTable = createComboTable(); const cols = el("div", { className: "ui-item-picker__cols", children: [ @@ -156,6 +159,10 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { detailView.show(chosen); + comboTable.show(chosen); const layer = chosen ? layerOf(chosen) : null; applyButton.disabled = !chosen; clearButton.disabled = !opts.current; @@ -369,6 +377,7 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { if (editView && !(await editView.leave())) return false; editView?.destroy(); + comboTable.destroy(); resolve({ action: "cancel" }); return true; }, mount: (body) => { - body.append(cols, editHost, ...(opts.projectId ? [actions] : [])); + body.append(tableWrap, cols, editHost, ...(opts.projectId ? [actions] : [])); paintDetail(); void load(null); }, diff --git a/ui_template/ui_template_item_picker_data.ts b/ui_template/ui_template_item_picker_data.ts index 45dd71628..16aaf25e4 100644 --- a/ui_template/ui_template_item_picker_data.ts +++ b/ui_template/ui_template_item_picker_data.ts @@ -85,6 +85,9 @@ export const ownerOf = (row: PickerRow): string | null => /** 같은 항목의 자리표 — 층 · 주인 · 키 */ export const rowId = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}|${row.key}`; +/** 상단 표(224-1)가 보이는 집계표 = 고른 항목이 속한 층(+ 주인) 것 — 같은 키면 표는 그대로 두고 열만 옮김 */ +export const tableKey = (row: PickerRow): string => `${layerOf(row)}|${ownerOf(row) ?? ""}`; + export function toSummary(row: PickerRow): SummaryItem { return { layer: layerOf(row), owner: ownerOf(row), key: row.key, name: itemName(row) }; } diff --git a/ui_template/ui_template_item_picker_table.ts b/ui_template/ui_template_item_picker_table.ts new file mode 100644 index 000000000..5d742be8a --- /dev/null +++ b/ui_template/ui_template_item_picker_table.ts @@ -0,0 +1,94 @@ +/* ============================================================================= + * ui_template_item_picker_table.ts + * 항목 고르기 모달 상단 표(PLAN 224-1) — M02 「구조물 조합」 페이지 상단의 구조물집계표를 **같은 부품** + * (`ui_template/sheet` `createSheet` · 줄 글 = `numberLabels` · 숨김 열 = `comboView`)으로 보임. + * 보기 전용(`readOnly` · 고침은 M02 에서) · 고른 항목이 속한 층(+ 주인)의 표 · 고른 항목의 열이 밝게 골라짐. + * 같은 층의 다른 항목을 고르면 표는 그대로 두고 열만 옮김 · 다른 층 항목이면 그 층 표로 바뀜. + * 표 부품 · 집계표 모듈은 처음 보일 때만 `import()`(B05 · B06 번들에 안 섞임). + * ========================================================================== */ + +import { el } from "./ui_template_elements"; +import { + fetchStructureNumbersAt, + readTemplate, +} from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import { layerOf, ownerOf, tableKey, type PickerRow } from "./ui_template_item_picker_data"; +import { t } from "./ui_template_locale"; +import type { SheetDoc, SheetHandle } from "./sheet/ui_template_sheet"; + +export interface ComboTable { + root: HTMLElement; + /** 고른 항목 — `null` 이면 표를 숨김 */ + show: (row: PickerRow | null) => void; + destroy: () => void; +} + +const failed = (error: unknown): string => + t("M02_PickerTableFailed").replace( + "{value}", + error instanceof Error ? error.message : String(error), + ); + +export function createComboTable(): ComboTable { + const root = el("div", { className: "ui-item-picker__table", attrs: { hidden: "" } }); + let seq = 0; + let shownKey: string | null = null; + let sheet: SheetHandle | null = null; + /** 가장 나중에 고른 열 — 표를 읽는 사이에 바뀌면 읽은 뒤 이 열을 고름 */ + let column = ""; + + const drop = (): void => { + sheet?.destroy(); + sheet = null; + shownKey = null; + }; + + async function paint(row: PickerRow, key: string, mine: number): Promise { + drop(); + root.replaceChildren( + el("p", { className: "ui-item-picker__hint", text: t("M02_PickerLoading") }), + ); + try { + const layer = layerOf(row); + const owner = ownerOf(row); + const [{ createSheet }, struct] = await Promise.all([ + import("./sheet/ui_template_sheet"), + import("../M02_MasterTemplete/M02_MasterTemplete_Structure"), + ]); + const [got, numbers] = await Promise.all([ + readTemplate(layer, "table", struct.STRUCTURE_TABLE, null, owner), + fetchStructureNumbersAt(layer, owner).catch(() => ({})), + ]); + if (mine !== seq) return; + sheet = createSheet(root, struct.comboView(got.문서) as SheetDoc, { + mode: "master", + readOnly: true, + lockHead: true, + combo: {}, + ...struct.numberLabels(() => numbers), + }); + shownKey = key; + sheet.selectColumn(column); + } catch (error) { + if (mine === seq) + root.replaceChildren(el("p", { className: "ui-item-picker__hint", text: failed(error) })); + } + } + + return { + root, + show: (row) => { + const mine = ++seq; + root.hidden = !row; + if (!row) return; + column = row.열; + const key = tableKey(row); + if (sheet && key === shownKey) return sheet.selectColumn(column); + void paint(row, key, mine); + }, + destroy: () => { + seq += 1; + drop(); + }, + }; +} diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 6a13b6696..31fc267ea 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -80,6 +80,7 @@ export const ui_locales_m2 = { M02_PickerNoMatch: ["찾는 항목 없음", "No matching items"], M02_PickerLoading: ["읽는 중…", "Loading…"], M02_PickerLoadFailed: ["항목을 못 읽음 — {value}", "Failed to load items — {value}"], + M02_PickerTableFailed: ["상단 표를 못 읽음 — {value}", "Failed to load the top table — {value}"], M02_PickerDrawing: ["도면", "Drawing"], M02_PickerArea: ["면적산출", "Area takeoff"], M02_PickerUnitPrice: ["일위대가", "Unit price"], From 66e84b56bf6a63eceb3053685abab35f714dc848 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:33:00 +0900 Subject: [PATCH 09/15] =?UTF-8?q?=EF=BB=BFfix(cad):=20=EC=A4=8C=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EB=8A=90=EB=A0=A4=EC=A7=90=20=E2=80=94=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=84=20=EC=83=89=EC=9D=B8=20=EC=B9=B8=20=EB=B2=94=EC=9C=84?= =?UTF-8?q?=20=EC=9E=90=EB=A6=84=20=C2=B7=20=EB=AA=A8=EB=88=88=EC=9D=84=20?= =?UTF-8?q?=EC=9E=A5=EB=A9=B4=20=EB=B9=84=ED=8A=B8=EB=A7=B5=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=BA=8C(227-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 까닭: 화면 상자 조회가 엔티티 칸(64x64) 밖 빈 칸까지 돎 — 0.01배에서 한 화면 19.3 초 - 칸 범위를 0~64 로 잘라 0.01배 19,348 → 16 ms · 0.1배 175 → 15 ms - 모눈은 화면 clear() 만 그림 — 모눈 켜도 정지 화면에서 장면 다시 그리지 않음 - 측정 · 회귀 시험 zoom-out-perf.test.ts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- .../openwebcad/src/helpers/scene-cache.ts | 13 +- .../openwebcad/src/helpers/spatial-index.ts | 6 +- .../src/helpers/zoom-out-perf.test.ts | 191 ++++++++++++++++++ 3 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts diff --git a/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts b/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts index e985ea346..cb1b0aea8 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/scene-cache.ts @@ -1,5 +1,5 @@ import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; -import { getGridEnabled, getHighlightedEntityIds, setHighlightedEntityIds } from '../state'; +import { getHighlightedEntityIds, setHighlightedEntityIds } from '../state'; import { drawEntities } from './draw-functions'; import { getSceneVersion } from './scene-version'; import { queryEntitiesInBox } from './spatial-index'; @@ -78,7 +78,8 @@ function rebuildScene(drawController: ScreenCanvasDrawController): void { const savedHighlight = getHighlightedEntityIds(); if (savedHighlight.length) setHighlightedEntityIds([]); drawController.withContext(offscreenContext, () => { - drawController.clear(); + // 투명 바탕 — 배경 · 모눈은 화면 clear() 가 그림(모눈이 비트맵에 박히면 매 프레임 다시 그려야 함) + offscreenContext.clearRect(0, 0, size.x, size.y); // Style-run batching + sub-pixel decimation: one stroke per style run drawController.beginBatch(); drawEntities(drawController, sceneEntitiesForViewport(drawController)); @@ -124,13 +125,7 @@ export function drawScene(drawController: ScreenCanvasDrawController, now: numbe const offsetChanged = !!rendered && (rendered.offsetX !== offset.x || rendered.offsetY !== offset.y); - // Grid lines are screen-fixed (drawn in clear()), so blitting a shifted - // bitmap would drag the grid along — always re-render while grid is on. - if ( - paramsChanged || - getGridEnabled() || - (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS) - ) { + if (paramsChanged || (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)) { rebuildScene(drawController); } diff --git a/B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts b/B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts index 2c9c84532..970bf0660 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/spatial-index.ts @@ -26,8 +26,12 @@ let gridMinX = 0; let gridMinY = 0; let gridCols = 1; +/** 칸 번호 범위 — 엔티티가 놓인 칸(0~GRID_CELLS_PER_AXIS) 밖은 비었으니 잘라 냄(줌아웃 때 빈 칸 수억 개를 돌지 않게). */ function cellRange(min: number, max: number, gridMin: number): [number, number] { - return [Math.floor((min - gridMin) / cellSize), Math.floor((max - gridMin) / cellSize)]; + return [ + Math.max(0, Math.floor((min - gridMin) / cellSize)), + Math.min(GRID_CELLS_PER_AXIS, Math.floor((max - gridMin) / cellSize)), + ]; } function ensureIndex(): void { diff --git a/B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts b/B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts new file mode 100644 index 000000000..754eae362 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts @@ -0,0 +1,191 @@ +import { Point } from '@flatten-js/core'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +/** 줌아웃 느려짐(227-1) — 가짜 캔버스로 한 화면 그리기 비용을 잰다. */ +const counts = vi.hoisted(() => { + const tally = { segments: 0, arcs: 0, strokes: 0, fills: 0, texts: 0 }; + const g = globalThis as Record; + g.window = { dispatchEvent: () => true, matchMedia: () => ({ matches: true }) }; + g.Path2D = class { + moveTo() {} + lineTo() { + tally.segments++; + } + arc() { + tally.arcs++; + } + }; + const noop = () => {}; + g.__fakeContext = { + fillRect: noop, + clearRect: noop, + setLineDash: noop, + beginPath: noop, + moveTo: noop, + lineTo: () => tally.segments++, + arc: () => tally.arcs++, + closePath: noop, + stroke: () => tally.strokes++, + fill: () => tally.fills++, + save: noop, + restore: noop, + translate: noop, + rotate: noop, + fillText: () => tally.texts++, + measureText: (label: string) => ({ width: label.length * 7 }), + drawImage: noop, + }; + return tally; +}); + +import type { Layer } from '../App.types'; +import { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController'; +import type { Entity } from '../entities/Entity'; +import { HatchEntity } from '../entities/HatchEntity'; +import { LineEntity } from '../entities/LineEntity'; +import { MeasurementEntity } from '../entities/MeasurementEntity'; +import { PolyLineEntity } from '../entities/PolyLineEntity'; +import { TextEntity } from '../entities/TextEntity'; +import { setEntities, setGridEnabled, setLayers } from '../state'; +import { drawScene, invalidateSceneCache, scenePerf } from './scene-cache'; +import { queryEntitiesInBox } from './spatial-index'; + +const LAYER = 'perf'; + +function polyline(points: Point[]): PolyLineEntity { + const segments = points + .slice(1) + .map((point, index) => new LineEntity(LAYER, points[index], point)); + return new PolyLineEntity(LAYER, segments); +} + +/** B07 횡단 장 꼴 — 횡단 40 개(지반선 · 계획선 · 절토 · 성토 해치 · 글자 · 치수 · 모눈) + 등고선 */ +function crossSectionSheet(): Entity[] { + const entities: Entity[] = []; + for (let index = 0; index < 40; index++) { + const ox = (index % 4) * 80; + const oy = Math.floor(index / 4) * 30; + const ground = Array.from( + { length: 61 }, + (_, k) => new Point(ox + k, oy + 10 + 3 * Math.sin(k / 7 + index)) + ); + entities.push(polyline(ground)); + const design = [0, 15, 20, 25, 40, 45, 60].map( + (x, k) => new Point(ox + x, oy + 9 + (k % 2 ? 1 : 0)) + ); + entities.push(polyline(design)); + const hatchOptions = { style: 'pattern', spacing: 0.3 } as const; + entities.push( + new HatchEntity( + LAYER, + [new Point(ox + 15, oy + 9), new Point(ox + 25, oy + 9), new Point(ox + 20, oy + 13)], + hatchOptions + ), + new HatchEntity( + LAYER, + [new Point(ox + 40, oy + 9), new Point(ox + 60, oy + 9), new Point(ox + 50, oy + 6)], + hatchOptions + ) + ); + for (let k = 0; k < 25; k++) { + entities.push( + new TextEntity(LAYER, `${index}+${k}.00`, new Point(ox + 2 * k, oy + 2), { fontSize: 0.5 }) + ); + } + for (let k = 0; k < 6; k++) { + entities.push( + new MeasurementEntity( + LAYER, + new Point(ox + 10 * k, oy + 8), + new Point(ox + 10 * k + 8, oy + 8), + new Point(ox + 10 * k, oy + 7) + ) + ); + } + for (let k = 0; k <= 12; k++) { + entities.push( + new LineEntity(LAYER, new Point(ox + 5 * k, oy), new Point(ox + 5 * k, oy + 20)) + ); + } + } + for (let c = 0; c < 178; c++) { + const contour = Array.from( + { length: 85 }, + (_, k) => new Point(k * 3.7, c * 1.7 + 2 * Math.sin(k / 5 + c)) + ); + entities.push(polyline(contour)); + } + return entities; +} + +function viewAt(controller: ScreenCanvasDrawController, factor: number, fitScale: number) { + const size = controller.getCanvasSize(); + const center = controller.targetToWorld(new Point(size.x / 2, size.y / 2)); + const scale = fitScale * factor; + controller.setScreenScale(scale); + controller.setScreenOffset( + new Point(center.x - size.x / 2 / scale, center.y - size.y / 2 / scale) + ); +} + +describe('줌아웃 그리기 비용(227-1)', () => { + const controller = new ScreenCanvasDrawController( + (globalThis as unknown as { __fakeContext: CanvasRenderingContext2D }).__fakeContext + ); + let fitScale = 1; + + beforeAll(() => { + const g = globalThis as Record; + g.document = { + documentElement: { hasAttribute: () => false, getAttribute: () => null }, + createElement: () => ({ width: 0, height: 0, getContext: () => g.__fakeContext }), + }; + g.getComputedStyle = () => ({ getPropertyValue: () => '' }); + setLayers( + [{ id: LAYER, name: LAYER, isVisible: true, isLocked: false } as unknown as Layer], + false + ); + setEntities(crossSectionSheet(), false); + controller.setCanvasSize(new Point(1600, 900)); + controller.zoomToFitScreen(); + fitScale = controller.getScreenScale(); + }); + + it('배율별 한 화면 다시 그리기 — 시간 · 그린 수(보고용 표)', () => { + const rows = []; + for (const factor of [10, 1, 0.1, 0.01]) { + viewAt(controller, factor, fitScale); + for (const key of Object.keys(counts)) counts[key as keyof typeof counts] = 0; + invalidateSceneCache(); + const startedAt = performance.now(); + drawScene(controller, 0); + const ms = performance.now() - startedAt; + rows.push({ factor, ms: Number(ms.toFixed(1)), ...counts }); + } + console.table(rows); + expect(rows).toHaveLength(4); + }); + + it('화면보다 아주 큰 영역 조회도 빈 칸을 돌지 않음 — 0.001배 상자 50ms 안', () => { + viewAt(controller, 1, fitScale); + const all = queryEntitiesInBox(-1e9, -1e9, 1e9, 1e9).length; + const size = controller.getCanvasSize(); + const half = (Math.max(size.x, size.y) / (fitScale * 0.001)) * 1.5; + const startedAt = performance.now(); + const found = queryEntitiesInBox(-half, -half, half, half); + const ms = performance.now() - startedAt; + expect(found.length).toBe(all); + expect(ms).toBeLessThan(50); + }); + + it('모눈을 켜도 화면이 그대로면 장면을 다시 그리지 않음', () => { + viewAt(controller, 1, fitScale); + setGridEnabled(true); + drawScene(controller, 0); + const rebuilds = scenePerf.sceneRebuilds; + drawScene(controller, 1000); + drawScene(controller, 2000); + setGridEnabled(false); + expect(scenePerf.sceneRebuilds).toBe(rebuilds); + }); +}); From ab71a2e06a9bd12b8395f733601c817800910f37 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:34:52 +0900 Subject: [PATCH 10/15] =?UTF-8?q?fix(test):=20=ED=95=A9=EC=B9=9C=20?= =?UTF-8?q?=EB=92=A4=20=EB=B9=A8=EA=B0=95=20=EB=91=98=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=20=E2=80=94=20M02=20=EC=B9=B4=EB=93=9C=20=ED=8B=80=20Ctrl+S=20?= =?UTF-8?q?=EA=B8=B0=EB=8C=80=EA=B0=92=20=C2=B7=20B09=20=EC=95=88=EB=82=B4?= =?UTF-8?q?=20=EA=B8=80=20=E3=80=8C=EB=8D=94=ED=95=98=EA=B8=B0=E3=80=8D?= =?UTF-8?q?=E2=86=92=E3=80=8C=EC=B6=94=EA=B0=80=E3=80=8D=20(224-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_018jonfdxn1Xit9bdkNJpE4G --- B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts | 2 +- resources/tester/test_m02_card_shell.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts index 6d9cb5125..2d0b40f52 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -32,7 +32,7 @@ const GUIDE_MORE = "위로 · 아래로 = 같은 부모 안에서 아래 줄째 자리 바꿈 · 못 하는 동작 = 흐린 단추 · " + "끌기 = 줄을 잡아 위아래 = 자리 · 좌우 = 레벨(아래 줄째 · 빨간 선 = 못 놓음) · " + "키는 글 칸 편집 중이 아닐 때(Esc = 편집 끝 · 한 번 더 Esc = 고름 풂) · " + - "묶음 줄로 바꾸기 · 더하기 · 지우기 = 줄 우클릭"; + "묶음 줄로 바꾸기 · 추가 · 지우기 = 줄 우클릭"; let picked: string | null = null; /** 지금 그린 도구 줄 — 키가 부름 */ diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index 91d4a39a6..6626b25ac 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -26,7 +26,7 @@ def test_틀_제목_줄_부제_상태(): assert "loading: () => showLoading(host)," in shell assert "fail: (reason, retry) => host.replaceChildren(loadingError(reason, retry))," in shell assert "setDirty: (dirty) => (dot.hidden = !dirty)," in shell - assert 'event.key.toLowerCase() !== "s"' in shell # 카드 안 Ctrl+S + assert 'isCtrlKey(event, "s")' in shell # 카드 안 Ctrl+S def test_저장_단추_알림_한_벌(): From 9a850212d5bcdc8a189aa97a71ce48db2eef0418 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:39:00 +0900 Subject: [PATCH 11/15] =?UTF-8?q?fix(m02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=B3=84=EB=8F=84=EA=B3=84=EC=83=81=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=20=EC=85=88=EC=97=90=EC=84=9C=20=EB=BA=8C=20=C2=B7=20?= =?UTF-8?q?=EC=9B=90=EB=AC=B8=20=EA=B0=99=EC=9D=80=20=EC=B1=85=20=EC=A0=88?= =?UTF-8?q?=20=EB=AA=A8=EB=91=90=20=C2=B7=20=EB=8B=A8=EC=9C=84=20=EA=B9=8C?= =?UTF-8?q?=EB=8B=AD=20=EC=8B=9C=ED=97=98(222-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로직 자재 별도계상(빈 칸 = 기본) = 기준에서 빼고 일위대가 상세 줄에서 지움 · 자재목록엔 남김 - 사용자 자재 별도계상 = 합에서 뺌(파이썬 · TS settle 짝 · 거울 시험) - 사전조합 안 줄 · 프로젝트 사본 셈도 같은 길 - 별도계상 바꾸면 다시 셈(로직 자재 = 서버 · 사용자 자재 = 짝 셈) - 원문 출처 = 로직 출처 + 호표 · 입력 출처 같은 책 절(산림품셈 12-1-2 · 12-1-3) - 기존 시험 셋 = 직접계산 표시 · 대조용 apart 끔 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- M02_MasterTemplete/M02_Labor_Doc.py | 40 ++++- M02_MasterTemplete/M02_Labor_Inputs.py | 24 ++- M02_MasterTemplete/M02_Labor_Ref.py | 3 +- .../M02_MasterTemplete_Labor_Calc.ts | 11 +- .../M02_MasterTemplete_Labor_Detail.ts | 8 +- resources/tester/test_175_precombo_mats.py | 3 +- resources/tester/test_222_1_m02_apart.py | 148 ++++++++++++++++++ resources/tester/test_item_copy_complete.py | 5 +- resources/tester/test_m02_item_labor.py | 2 +- 9 files changed, 230 insertions(+), 14 deletions(-) create mode 100644 resources/tester/test_222_1_m02_apart.py diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 890c7155a..655ec4775 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -20,9 +20,11 @@ 고름 = `{호표 차례: MT키}`(엔진 `choosing` 그대로) · 금액은 로직 재료비 안이라 합 · 알약에 안 더함(보기만) · 사용자 자재와 같은 품목 두 번은 [저장] 때 400. 승격(134-1) = 로직 줄 「단가승격」 표시(`true` 만 · 없음 = 단가산출) · 금액 무관 · B09 호표 몫. -자재 세 칸(148-4) = `MARKS` 허용 값만(그 밖 · 빈 것 버림 · 금액 무관) — 사용자 자재 = 줄 덧칸 · +자재 세 칸(148-4) = `MARKS` 허용 값만(그 밖 · 빈 것 버림 · 별도계상만 금액에 닿음) — 사용자 자재 = 줄 덧칸 · 로직 자재 = 로직 줄 `자재표시 {호표 차례: {세 칸}}`(차례가 재료 줄인 것만 · 틀리면 버림) · 빈 칸 = 읽을 때 `MARK_DEFAULT`(`mark_of` · 저장은 비움 · 옛 「일반」 = 버림 → 사급 · 162-1). +별도계상(222-1) = 그 자재 금액은 기준 · 합에서 빠지고 로직 `줄` 에서 지움(`drop_apart` · `settle` · +짝 `Labor_Calc.ts`) · `재료` · `자재`(자재목록 · B08 수량)엔 남음. % 덧줄(149-4) = 식 「X * Y / 100」 덧줄에 덧칸 `율`(Y) · `규격`(「노무비의 %」 등 · `pct_extras`) — 표기만 · 단위 · 수량 · 금액 그대로(「식 · 1」) · 화면이 율 · % 로 그림 · M01 시험계산 표도 이 함수. """ @@ -224,6 +226,35 @@ def mark_of(one: Any, key: str) -> str: return marks(one).get(key) or MARK_DEFAULT[key] +def apart(one: Any) -> bool: + """별도계상(222-1) — 세 칸 「별도계상」(빈 칸 = 기본) 자재는 일위대가 셈에서 빠짐(본 프로그램에서 + 따로 계상 · 자재목록은 상세 보기라 따로 안 더함).""" + return mark_of(one, "별도계상") == "별도계상" + + +def drop_apart(got: dict[str, Any], shown: Any) -> dict[str, Any]: + """로직 줄 답(`run_line`)에서 별도계상 로직 자재 빼기(222-1) — 그 재료 줄 금액을 기준에서 빼고 + `줄`(일위대가 상세)에서 지움 · `재료`(자재목록 · 세 칸 고침)는 그대로. + ponytail: 「재료비의 %」 덧줄은 엔진 값 그대로(뺀 재료 몫 안 고침) — 그런 로직이 별도계상을 쓰면 엔진에 넘김.""" + shown = shown if isinstance(shown, dict) else {} + lines = got.get("줄") or [] + out = { + n + for m in got.get("재료") or [] + if (n := m["차례"]) < len(lines) and apart(shown.get(str(n))) + } + if not out or not got.get("기준"): + return got + base = {k: _number(v) or Decimal(0) for k, v in got["기준"].items()} + for n in out: + for k, v in (lines[n].get("비목") or {}).items(): + cut = _number(v) or Decimal(0) + base[k] = base.get(k, Decimal(0)) - cut + base["계"] -= cut + kept = [x for i, x in enumerate(lines) if i not in out] + return {**got, "기준": _json(base), "줄": kept} + + def _mat_marks(value: Any, logic: dict) -> dict[str, dict[str, str]] | None: """로직 자재 세 칸(148-4) `{호표 차례: {세 칸}}` — 맨 위 층 재료 줄 차례 · 허용 값만 · 빈 것 = None.""" @@ -386,6 +417,7 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None} hands = {line_id(one): one.get("수량") for one in doc.get("담은로직") or []} mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []} + aside = {one.get("코드") for one in doc.get("자재") or [] if apart(one)} # 222-1 합에서 뺌 zero = dict.fromkeys((*COSTS, "계"), Decimal(0)) lines, mats, bases, reflects = [], [], [], [] for line in result.get("줄") or []: @@ -399,9 +431,11 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: qty, source = _pick(mhands.get(one.get("코드")), one.get("알약"), have) price = _number(one.get("기준단가")) cost = price * qty if price is not None and qty is not None else None + mats.append({**one, "수량": qty, "수량출처": source, "반영": cost}) + if one.get("코드") in aside: + continue bases.append({**zero, "재료비": price, "계": price} if price is not None else None) reflects.append({**zero, "재료비": cost, "계": cost} if cost is not None else None) - mats.append({**one, "수량": qty, "수량출처": source, "반영": cost}) return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)}) @@ -468,7 +502,7 @@ def calc( "배": pill.get("배") or 1, } got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름")) - lines.append({**line, **got}) + lines.append({**line, **drop_apart(got, one.get("자재표시"))}) mats = [ { "코드": one["코드"], diff --git a/M02_MasterTemplete/M02_Labor_Inputs.py b/M02_MasterTemplete/M02_Labor_Inputs.py index c782955ac..204102bfe 100644 --- a/M02_MasterTemplete/M02_Labor_Inputs.py +++ b/M02_MasterTemplete/M02_Labor_Inputs.py @@ -8,6 +8,7 @@ from __future__ import annotations +import re from decimal import Decimal from typing import Any @@ -16,6 +17,7 @@ from M01_MasterData import M01_MasterData_Store_Combo as combo_store _SPEC = ("이름", "설명", "단위", "고르기", "범위") _LAYERED = "골쌓기 기준" # 85-2 — 표 주문에 이 글이 있으면 켜쌓기율 대상 로직 +_NUMBER = re.compile(r"(? Any: @@ -63,6 +65,26 @@ def _labels(whole: Any, spec: dict) -> dict[str, str]: return out +def sources(logic: dict) -> Any: + """원문 출처(222-1) — 로직 `출처` + 호표 · 입력 · 중간 · 덧줄 `출처` 가운데 같은 책(번호 앞 글이 + 같음)의 절 번호를 덧붙임(「산림품셈 12-1-2 · 12-1-3」 · 원문 패널이 절마다 찾음 · M01 + `make.source`) · 다른 책 절은 안 붙임 · 번호 없는 출처는 그대로.""" + main = str(logic.get("출처") or "") + head = _NUMBER.search(main) + if not head: + return logic.get("출처") + book, seen = main[: head.start()], set(_NUMBER.findall(main)) + extra = [] + for part in ("호표", "입력", "중간", "덧줄"): + for row in logic.get(part) or []: + src = str(row.get("출처") or "") if isinstance(row, dict) else "" + hit = _NUMBER.search(src) + if hit and src[: hit.start()] == book and hit.group() not in seen: + seen.add(hit.group()) + extra.append(hit.group()) + return " · ".join([main, *extra]) + + def _listed(logics: list[dict]) -> list[dict[str, Any]]: """로직 줄마다 `{로직, 이름, 결과단위, 출처, 켜쌓기, 입력: [{이름, 설명, 단위, 고르기?, 고르기이름?, 범위?, 자동값}]}`.""" whole = m01.loaded()[1] @@ -84,7 +106,7 @@ def _listed(logics: list[dict]) -> list[dict[str, Any]]: "로직": logic.get("키"), "이름": logic.get("이름"), "결과단위": logic.get("결과단위"), - "출처": logic.get("출처"), + "출처": sources(logic), "켜쌓기": logic.get("키") in layers, "입력": specs, } diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index bc06b9ac0..6e0a14bfb 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -423,6 +423,7 @@ class Expand: got = labor.run_line( self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름") ) + got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1) times = labor._number(pill.get("배")) or Decimal(1) base = got.get("기준") return { @@ -463,7 +464,7 @@ class Expand: rows.append({k: v * qty for k, v in sub["기준"].items()}) for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 손 · 빈칸 = 안 셈 qty = labor._number((mat.get("수량") or {}).get("손")) - if qty is None: + if qty is None or labor.apart(mat): # 별도계상 = 안 셈 · 안 줄에도 없음(222-1) continue got = labor.material_price(self.whole, mat["코드"], mat.get("단위")) price = labor._number(got["기준단가"]) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index 529f6bba4..cea35ce81 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -4,6 +4,7 @@ * 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. * 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에 * 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. + * 별도계상 자재(222-1 · 빈 칸 = 기본 별도계상) = 반영은 보이되 합에 안 더함(짝 `apart`). * 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`). */ import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; @@ -79,7 +80,7 @@ export interface LaborCalc { /** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */ export interface LaborQtyDoc { 담은로직?: { 로직?: string; 참조?: LaborRef; 수량?: Qty }[]; - 자재?: { 코드: string; 수량?: Qty }[]; + 자재?: { 코드: string; 수량?: Qty; 별도계상?: string }[]; } function num(raw: unknown): number | null { @@ -130,6 +131,10 @@ export function settle( } const hands = new Map((doc.담은로직 ?? []).map((one) => [lineId(one), one.수량 ?? null])); const mhands = new Map((doc.자재 ?? []).map((one) => [one.코드, one.수량 ?? null])); + // 별도계상(222-1) — 「직접계산」 아니면(빈 · 옛 값 = 기본 별도계상) 합에서 뺌(짝 `M02_Labor_Doc.apart`) + const aside = new Set( + (doc.자재 ?? []).filter((m) => m.별도계상 !== "직접계산").map((m) => m.코드), + ); const base = zero(); const done = zero(); const add = (sum: Money, m: Money | null): void => { @@ -146,8 +151,8 @@ export function settle( const [qty, source] = pick(mhands.get(one.코드), one.알약, have); const price = num(one.기준단가); const cost = price !== null && qty !== null ? price * qty : null; - if (price !== null) add(base, { ...zero(), 재료비: price, 계: price }); - if (cost !== null) add(done, { ...zero(), 재료비: cost, 계: cost }); + if (price !== null && !aside.has(one.코드)) add(base, { ...zero(), 재료비: price, 계: price }); + if (cost !== null && !aside.has(one.코드)) add(done, { ...zero(), 재료비: cost, 계: cost }); return { ...one, 수량: qty, 수량출처: source, 반영: cost }; }); return { 줄, 자재, 기준합: base, 반영합: done }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 71a5e79ce..f0b65b1a6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -383,14 +383,16 @@ export function mountLaborDetail( else delete cur.담은로직[i].승격; opt.onChanged(dirty()); } - // 자재 세 칸(148-4) — 화면 캐시만 · 금액 무관이라 다시 셈 없음 + // 자재 세 칸(148-4) — 화면 캐시만 · 별도계상만 금액에 닿음(222-1 · 사용자 자재 = 짝 셈 · 로직 자재 = 서버 기준) function markMat(i: number, key: MatMarkKey, v: string): void { setMark(cur.자재[i], key, v); - opt.onChanged(dirty()); + if (key === "별도계상") changed(true); + else opt.onChanged(dirty()); } function markLogicMat(i: number, at: number, key: MatMarkKey, v: string): void { setLogicMark(cur.담은로직[i], at, key, v); - opt.onChanged(dirty()); + if (key === "별도계상") changed(); + else opt.onChanged(dirty()); } function removeMat(i: number): void { cur.자재.splice(i, 1); diff --git a/resources/tester/test_175_precombo_mats.py b/resources/tester/test_175_precombo_mats.py index 9b98c4cb1..248695cfd 100644 --- a/resources/tester/test_175_precombo_mats.py +++ b/resources/tester/test_175_precombo_mats.py @@ -79,7 +79,8 @@ def _world_combo(world): # noqa: F811 {"로직": STONE, "수량": {"손": 1}}, {"로직": NET, "수량": {"손": 0}, "고름": {"4": NET_ITEM}}, ] - mats = [{"코드": MAT, "수량": {"손": 2}}, {"코드": WIRE, "수량": None}] + # MAT = 직접계산(222-1 · 기본 별도계상이면 셈에서 빠짐) + mats = [{"코드": MAT, "수량": {"손": 2}, "별도계상": "직접계산"}, {"코드": WIRE, "수량": None}] with store.at(world["system"]): key = refs.create("precombo", {"이름": "자재 조합", "담은로직": lines, "자재": mats})[ "이름" diff --git a/resources/tester/test_222_1_m02_apart.py b/resources/tester/test_222_1_m02_apart.py new file mode 100644 index 000000000..9d13b45f1 --- /dev/null +++ b/resources/tester/test_222_1_m02_apart.py @@ -0,0 +1,148 @@ +"""M02 구조물조합 일위대가 기계비빔타설(PLAN 222-1) — 단위 까닭 · 별도계상 빼기 · 원문 여럿. + +① 단위 — GF000156 모래 · 자갈 = 원문 kg(QF000353) ÷ 단위중량(㎏/㎥) · 칸에 t/㎥ 값(2 · 2.1)을 넣으면 + 838 ÷ 2 = 419 ㎥ 처럼 1000 배 · 0 = 표 위끝(1,700 · 1,800 ㎏/㎥) → 0.49 · 0.57 ㎥ · 칸을 비우면 + 자동값(master_auto 「나누는 자리 = 1」)이 1 이라 838 ㎥(자료 · 기본값 몫 — 223 보고). +② 별도계상 — 로직 자재(`자재표시` · 빈 칸 = 기본 별도계상) = 기준에서 빠지고 `줄` 에서 지움 · `재료` 는 + 남음 · 사용자 자재 = 합에서 뺌(짝 TS `settle`) · 사전조합 안 줄 · 사본 셈 같은 길. +③ 원문 — 로직 출처 + 호표 · 입력 출처 가운데 같은 책 절 번호(「산림품셈 12-1-2 · 12-1-3」). +""" + +import os +from decimal import Decimal + +import pytest + +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_Doc as labor +from M02_MasterTemplete import M02_Labor_Inputs as inputs +from M02_MasterTemplete import M02_Labor_Ref as refs +from resources.tester.test_m02_labor_calc_mirror import _same, _ts + +HERE = os.path.dirname(os.path.abspath(__file__)) +SRC = os.path.join(HERE, "..", "..", "M02_MasterTemplete") +MIX = "GF000156" # 기계비빔타설 — 호표 3 시멘트 · 4 모래 · 5 자갈 +GIVEN = {"재료배합": "(A)", "골재최대치수": 40} +MAT = "MT000008" +DIRECT = {"별도계상": "직접계산"} + + +def _whole(): + return m01.loaded()[1] + + +def _rows(got): + return {x["이름"]: {**x, "수량": float(x["수량"])} for x in got["줄"]} + + +# ── ① 단위 ────────────────────────────────────────────────────────── +def test_단위중량_칸에_t_값이면_1000배_0이면_표_위끝(): + wrong = labor.run_line(_whole(), MIX, {**GIVEN, "모래단위중량": 2, "자갈단위중량": 2.1}) + rows = _rows(wrong) + assert rows["시멘트"]["단위"] == "kg" and rows["시멘트"]["수량"] == pytest.approx(335) + assert rows["모래(건조상태)"]["수량"] == pytest.approx(838 / 2) # 419 ㎥ + assert rows["자갈 또는 부순돌"]["수량"] == pytest.approx(1032 / 2.1) # 491.4 ㎥ + zero = {**GIVEN, "모래단위중량": 0, "자갈단위중량": 0} + right = _rows(labor.run_line(_whole(), MIX, zero)) + assert right["모래(건조상태)"]["수량"] == pytest.approx(838 / 1700) + assert right["자갈 또는 부순돌"]["수량"] == pytest.approx(1032 / 1800) + + +# ── ② 별도계상 ─────────────────────────────────────────────────────── +def _calc(shown=None, mats=()): + line = {"로직": MIX, "입력값": GIVEN, "수량": {"손": 1}} + if shown: + line["자재표시"] = shown + doc = labor.clean({"열": "rv", "담은로직": [line], "자재": list(mats)}) + return labor.calc(doc, {}) + + +def test_로직_자재_기본_별도계상은_기준_줄에서_빠지고_재료엔_남음(): + raw = labor.run_line(_whole(), MIX, GIVEN) + cut = sum(Decimal(str(m["금액"])) for m in raw["재료"]) + one = _calc()["줄"][0] + names = {x["이름"] for x in one["줄"]} + assert not names & {"시멘트", "모래(건조상태)", "자갈 또는 부순돌"} + assert {"콘크리트공", "보통인부"} <= names + assert len(one["재료"]) == 3 # 자재목록(세 칸 고침)엔 남음 + want = Decimal(str(raw["기준"]["재료비"])) - cut + assert one["기준"]["재료비"] == pytest.approx(float(want)) + assert one["기준"]["계"] == pytest.approx(float(Decimal(str(raw["기준"]["계"])) - cut)) + assert one["기준"]["노무비"] == pytest.approx(float(raw["기준"]["노무비"])) + + +def test_직접계산은_그대로_섞이면_그_자재만_빠짐(): + raw = labor.run_line(_whole(), MIX, GIVEN) + full = _calc({str(n): DIRECT for n in (3, 4, 5)})["줄"][0] + assert full["기준"]["계"] == pytest.approx(float(raw["기준"]["계"])) + assert len(full["줄"]) == len(raw["줄"]) + part = _calc({"3": DIRECT})["줄"][0] # 시멘트만 셈 + names = {x["이름"] for x in part["줄"]} + assert "시멘트" in names and "모래(건조상태)" not in names + cement = next(m for m in raw["재료"] if m["이름"] == "시멘트") + others = sum(Decimal(str(m["금액"])) for m in raw["재료"]) - Decimal(str(cement["금액"])) + assert part["기준"]["계"] == pytest.approx(float(Decimal(str(raw["기준"]["계"])) - others)) + + +def test_사용자_자재_별도계상은_합에서_빠지고_반영은_보임(): + aside = _calc(None, [{"코드": MAT, "수량": {"손": 2}}]) + direct = _calc(None, [{"코드": MAT, "수량": {"손": 2}, **DIRECT}]) + price = direct["자재"][0]["기준단가"] + assert aside["자재"][0]["반영"] == pytest.approx(price * 2) # 목록엔 금액 보임 + assert direct["반영합"]["계"] - aside["반영합"]["계"] == pytest.approx(price * 2) + assert direct["기준합"]["재료비"] - aside["기준합"]["재료비"] == pytest.approx(price) + + +@pytest.mark.parametrize("mark", [None, "직접계산", "별도계상", "옛값"]) +def test_사용자_자재_별도계상_파이썬과_TS_같은_답(mark): + one = {"코드": MAT, "수량": {"손": 3}, **({"별도계상": mark} if mark else {})} + doc = labor.clean({"열": "rv", "담은로직": [{"로직": MIX, "입력값": GIVEN}], "자재": [one]}) + doc["자재"][0].update({"별도계상": mark} if mark else {}) # 옛 값도 그대로 넘겨 봄 + answer = labor.calc(doc, {}) + values = {"기계비빔타설|㎥": 2} + _same(labor.settle(answer, doc, values), _ts(answer, doc, values)) + + +def test_사전조합_안_줄도_별도계상_뺌(): + raw = labor.run_line(_whole(), MIX, GIVEN) + cut = sum(Decimal(str(m["금액"])) for m in raw["재료"]) + doc = { + "이름": "비빔 조합", + "단위": "㎥", + "담은로직": [{"로직": MIX, "입력값": GIVEN, "수량": {"손": 1}}], + "자재": [ + {"코드": MAT, "수량": {"손": 2}}, # 기본 별도계상 = 안 셈 · 안 줄에도 없음 + ], + } + ref = {"층": "system", "주인": None, "키": "SC999999"} + got = refs.Expand(_whole(), lambda r: doc)(ref) + assert [x.get("자재") for x in got["안"]] == [None] + pill = got["안"][0] + times = Decimal(1) # 기계비빔타설 알약 배 1 + assert pill["기준"]["계"] * times == pytest.approx(Decimal(str(raw["기준"]["계"])) - cut) + assert got["기준"]["계"] == pytest.approx(Decimal(str(raw["기준"]["계"])) - cut) + + +def test_화면_별도계상_바꾸면_다시_셈(): + detail = open(os.path.join(SRC, "M02_MasterTemplete_Labor_Detail.ts"), encoding="utf-8").read() + assert 'if (key === "별도계상") changed(true);' in detail # 사용자 자재 = 짝 셈 + assert 'if (key === "별도계상") changed();' in detail # 로직 자재 = 서버 기준 + + +# ── ③ 원문 여럿 ────────────────────────────────────────────────────── +def test_원문_출처는_같은_책_절_모두(): + src = inputs.sources(_whole().logic(MIX)) + assert src == "산림품셈 12-1-2 · 12-1-3" + assert inputs.logic_inputs([MIX])["로직"][0]["출처"] == src + assert [f["number"] for f in make.source(src)["found"]] == ["12-1-2", "12-1-3"] + + +def test_원문_출처_번호_없거나_더할_것_없으면_그대로(): + assert inputs.sources({"출처": "자체"}) == "자체" + assert inputs.sources({"출처": None}) is None + one = { + "출처": "산림품셈 3-1", + "호표": [{"출처": "건설품셈 공통 1-2-4 3"}, {"출처": "산림품셈 3-1"}], + } + assert inputs.sources(one) == "산림품셈 3-1" # 다른 책 · 같은 절은 안 붙임 diff --git a/resources/tester/test_item_copy_complete.py b/resources/tester/test_item_copy_complete.py index 693c02fe5..66f789fa8 100644 --- a/resources/tester/test_item_copy_complete.py +++ b/resources/tester/test_item_copy_complete.py @@ -184,7 +184,10 @@ def test_입력이_없으면_빠진_입력_목록_셈은_자동값(world): assert _stuck(got) == [] and got["기준합"]["계"] > 0 -def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바뀜(world): +def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바뀜(world, monkeypatch): + from M02_MasterTemplete import M02_Labor_Doc as labor_doc + + monkeypatch.setattr(labor_doc, "apart", lambda one: False) # M01 조합 셈과 대조(222-1 안 뺌) root, master_dir = world["root"], world["master"] _save(root, [_pipe()]) values = _fill_values(root) diff --git a/resources/tester/test_m02_item_labor.py b/resources/tester/test_m02_item_labor.py index eb480e157..76cf3c1ac 100644 --- a/resources/tester/test_m02_item_labor.py +++ b/resources/tester/test_m02_item_labor.py @@ -87,7 +87,7 @@ def world(tmp_path: Path, monkeypatch, master_copy: Path) -> dict[str, Any]: {"로직": LOGICS[1], "입력값": {name: value}, "수량": {"손": HAND}}, {"로직": LOGICS[2]}, ], - "자재": [{"코드": MATERIAL}], + "자재": [{"코드": MATERIAL, "별도계상": "직접계산"}], # 222-1 합에 셈 } ) _write(system / "labor" / f"{COLUMN}.json", doc) From c98706be06bb9ab5571454be449a9121fc517891 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:43:08 +0900 Subject: [PATCH 12/15] =?UTF-8?q?feat(b06):=20=EA=B4=80=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EA=B8=80=20=EB=85=B8=ED=8F=AD=20=EB=9D=BC=EB=B2=A8?= =?UTF-8?q?=20=EC=9C=84=20=C2=B7=20=EA=B4=80=EB=B3=B4=ED=98=B8=EA=B3=B5=20?= =?UTF-8?q?0.1=20m=20=EC=9D=B4=EB=8F=99=20=C2=B7=20=EA=B4=80=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=20=ED=91=9C=EC=8B=9C=20=E3=80=8C8=20m=20(7.6)?= =?UTF-8?q?=E3=80=8D=20=C2=B7=20B08=20=EA=B4=80=EC=97=B0=EC=9E=A5=20?= =?UTF-8?q?=EC=82=AC=EC=82=AC=EC=98=A4=EC=9E=85=20(225-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관 길이 = 유출 벽 자리에서 셈(0.1 m) · 정수 올림 연장 · 길이 맞춤 이동(_Culvert_Fit) 없앰 - 관보호공 ◀▶▲▼ · 끌기 0.1 m 걸음(세월교 흙막이만 1 m) · ◀▶ 때 d 빈 칸은 지금 자리로 묶음 - 관 · 세월교 정보 글 = 노폭 · 노견 라벨 바로 위 줄 · 카드 · 좌측 패널 「정수 m (소수 첫째)」 - 구조물 집계(M02_Table_Fill) 관연장 = 사사오입 정수 · TS pipeLengthWhole 짝 · 새 시험 test_225_1 - 정수 맞춤 없앰에 맞춰 139_6 · 186_1 · 108 · 79 기대값 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- .../design/design_structure_pipe_calc.ts | 1 + .../B05_Profile_UI_Drainage_Facility.ts | 3 +- .../B06_Section_UI_Cross_Body_Guard.ts | 2 +- B06_Section/B06_Section_UI_Cross_Culvert.ts | 8 +- .../B06_Section_UI_Cross_Culvert_Const.ts | 8 + .../B06_Section_UI_Cross_Culvert_Fit.ts | 90 ---------- .../B06_Section_UI_Cross_Culvert_Geom.ts | 103 ++---------- .../B06_Section_UI_Cross_Culvert_Types.ts | 25 +++ B06_Section/B06_Section_UI_Cross_Ford.ts | 16 +- .../B06_Section_UI_Cross_Structure_Panel.ts | 8 +- .../B06_Section_UI_Cross_View_Structure.ts | 11 +- B08_Quantity/B08_Quantity_Router_Summary.py | 2 +- M02_MasterTemplete/M02_Table_Fill.py | 6 +- resources/tester/helper_225_1_pipe_round.cjs | 154 ++++++++++++++++++ resources/tester/test_108_drag_box_hit.py | 4 +- resources/tester/test_139_6_pipe_fit.py | 12 +- resources/tester/test_186_1_outlet_fold.py | 3 +- resources/tester/test_225_1_pipe_round.py | 117 +++++++++++++ resources/tester/test_79_guard_shape.py | 4 +- 19 files changed, 364 insertions(+), 213 deletions(-) delete mode 100644 B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts create mode 100644 resources/tester/helper_225_1_pipe_round.cjs create mode 100644 resources/tester/test_225_1_pipe_round.py diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts index 54b6bb940..3f8223d5c 100644 --- a/A00_Common/design/design_structure_pipe_calc.ts +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -12,6 +12,7 @@ import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch"; import { computeStoredLayouts } from "../../B06_Section/B06_Section_Structure_Layouts"; export { DEFAULT_BASIN_SHAPE } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Basin"; +export { pipeLengthText } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; // 「8 m (7.6)」(225-1) import { PIPE_MATCH_M } from "./design_selection"; /** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */ diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index a58f42145..fb5437511 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -70,6 +70,7 @@ import { pipeDrawn, pipeExtraCount, pipeLengthM, + pipeLengthText, } from "../A00_Common/design/design_structure_pipe_calc"; import { hasLinks, @@ -339,7 +340,7 @@ export function createFacilityOptionsForm( auto: (_c, item) => { if (item.id !== "length") return ""; const length = pipeLengthM(chainage); - return length === null ? "횡단 설계 뒤 셈" : String(Math.round(length)); + return length === null ? "횡단 설계 뒤 셈" : pipeLengthText(length); }, // 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1) count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined), diff --git a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts index e0cb89151..d9e5c8461 100644 --- a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts +++ b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts @@ -147,7 +147,7 @@ export type BodyAxes = { x: number; d: number; through: number; outward: number * 맞춤 길이는 **기본 자리**(측벽 조작 0)로만 잼 — 한쪽 이동이 반대쪽 흙막이를 안 밈(107-1 · * 수량 L 은 그린 관 길이 올림 그대로). 노견을 넓힌 쪽(139-6)은 사면 우선 — 그 쪽 흙막이는 안 밀고 * 반대쪽(안 넓힌 쪽)을 밂 · 둘 다 넓혔으면 표기만 올림. - * ponytail: 흙막이 높이 역산 없음 — 윗면이 본체 끝 윗면에 묶임(79-7) · 필요하면 배관 `_Culvert_Fit` 꼴로. */ + * ponytail: 흙막이 높이 역산 없음 — 윗면이 본체 끝 윗면에 묶임(79-7) · 필요하면 높이 역산을 더함. */ export function fordPipeFit( section: CrossSection, samples: SectionSample[], diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 640890541..6413b563e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -21,7 +21,8 @@ import type { PipeEnd, WallLayout, } from "./B06_Section_UI_Cross_Culvert_Geom"; -import { outlineBox } from "./B06_Section_UI_Cross_Culvert_Types"; +import { appendPipeInfoText, outlineBox } from "./B06_Section_UI_Cross_Culvert_Types"; +import { pipeLengthText } from "./B06_Section_UI_Cross_Culvert_Const"; import { appendWallTrench, foundationChoice } from "./B06_Section_UI_Cross_Excavation"; import { appendChuteBand, @@ -305,8 +306,11 @@ export function appendCulvertOverlay( const kindLabel = culvert.pipe_kind ? `${culvert.pipe_kind} ` : ""; const wallThickness = pipeWallThicknessM(culvert.pipe_kind, diameter); + const pipeInfo = `관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipeLengthText(pipe.lengthM)}`; + // 관 정보 글 = 노폭 · 노견 라벨 위 줄(225-1 · B06 카드만 — 그 라벨 없는 덧그림엔 안 그림) + if (!hidePipe) appendPipeInfoText(layer, pipeInfo, null); const tip = - `관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipe.lengthM}.0m` + + pipeInfo + ` · 매설 경사 ${pipe.slopePct.toFixed(1)}%(수평 가능)` + ` · 관 두께 ${(wallThickness * 1000).toFixed(1)}㎜(외경선 기준)` + ` · 최소 토피 ${culvert.min_cover_m.toFixed(1)}m(별표2 복토 교차 참조)`; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index a6ac1bf2b..73a94ed11 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -13,6 +13,14 @@ import type { SpanRole, SpanValues } from "./B06_Section_UI_Cross_Culvert_Wire"; * 절토측 유입 벽에서 관 invert의 상한(노면 − 관경 − 토피)을 정한다. */ export const MIN_PIPE_COVER_M = 0.5; +/** 관 길이 정수(사사오입 · 0.5 올림 · 225-1) — 저장 값은 실제 길이(0.1 m) 그대로. + * ⚠ 파이썬 짝 `M02_Table_Fill.pipe_lengths_from_designs`(집계 값 · 거울 시험 test_225_1). */ +export const pipeLengthWhole = (lengthM: number): number => Math.floor(lengthM + 0.5); + +/** 관 길이 표시 「8 m (7.6)」(225-1 — 카드 글 · 좌측 패널). */ +export const pipeLengthText = (lengthM: number): string => + `${pipeLengthWhole(lengthM)} m (${lengthM.toFixed(1)})`; + /** 기슭막이 벽 두께(m) — 실무 견치돌 뒷길이 관측치 45㎝(울진 L3=45). 표시용 형상 값. */ export const REVET_THICKNESS_M = 0.45; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts deleted file mode 100644 index 022a7f12f..000000000 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* ============================================================================= - * B06_Section_UI_Cross_Culvert_Fit.ts - * 관 길이 **m 단위 맞춤** — 배수관 기하(`_Cross_Culvert_Geom.ts`)에서 700줄 제한으로 분리. - * - * · 보통(2026-08-21 사용자 ①) — 긴 변 기준 올림, 모자란 만큼 유출 벽 자리를 관 축 방향 바깥으로 - * 민다(벽 두께 0.45 · 높이 고정 · 벽 상단은 1:1.2 성토선을 탐). - * · 노견을 넓힌 쪽(139-6 설계선 쉬프트) — 사면 우선: 벽을 관 축을 따라 옮기되 벽 상단이 그 쪽 - * 성토선(넓힌 노견에서 같은 물매)에 닿도록 **벽 높이를 0.1 m 눈금으로 역산**. 눈금 탓에 남는 - * 차는 표기 올림. - * ========================================================================== */ - -import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; -import { - FILL_SLOPE_MAX_LENGTH_M, - FILL_SLOPE_RATIO_MAX, - FILL_SLOPE_RATIO_MIN, - REVET_EMBED_DEPTH_M, - REVET_HEIGHT_STEP_M, - WALL_MIN_PURE_HEIGHT_M, -} from "./B06_Section_UI_Cross_Culvert_Const"; - -export interface OutletFitInput { - /** 유출 쪽 노견을 넓혔나(139-6) — 높이 역산 갈래. */ - shifted: boolean; - edge: { offset_m: number; elevation_m: number }; - outward: number; - axis: () => OffsetPoint; - thickness: number; - anchor: OffsetPoint; - /** 계산용 높이(순수 − 근입). */ - height: number; - /** 보통 갈래의 invert 선(높이 고정 · 성토선 평행). */ - lineInvertAt: (offset: number) => number; - slopeAt: ( - offset: number, - thickness: number, - height: number, - ) => { ratio: number; lengthM: number }; - cutLength: () => number; - /** 벽을 새 자리 · 높이로 다시 세우고 관 축 길이 `target` 으로 겨눔 — 벽이 안 서면 false. */ - rebuild: (anchor: OffsetPoint, height: number, target: number) => boolean; -} - -/** 맞춤 뒤 벽 자리 · 높이. */ -export function fitOutletPipeLength(c: OutletFitInput): { anchor: OffsetPoint; height: number } { - const target = Math.ceil(c.cutLength() - 1e-6); - let anchor = c.anchor; - let height = c.height; - const ratio0 = c.slopeAt(anchor.offset, c.thickness, height).ratio; - for (let pass = 0; pass < 8; pass += 1) { - const gap = target - c.cutLength(); - if (Math.abs(gap) < 0.005) break; - const axis = c.axis(); - const moved = anchor.offset + axis.offset * gap; - // 노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 도면 성립이 먼저. - if ((moved - c.edge.offset_m) * c.outward < 0) break; - if (c.shifted) { - // 사면 우선 — invert 는 관 축을 따라 · 벽 상단 = 같은 물매 성토선 → 높이 역산(0.1 m 눈금) - if (!Number.isFinite(ratio0)) break; - const invert = anchor.elevation + axis.elevation * gap; - const run = Math.abs(moved + c.outward * (c.thickness / 2) - c.edge.offset_m); - const pure = c.edge.elevation_m - run / ratio0 - invert + REVET_EMBED_DEPTH_M; - const stepped = Math.round(pure / REVET_HEIGHT_STEP_M) * REVET_HEIGHT_STEP_M; - if (stepped < WALL_MIN_PURE_HEIGHT_M - 1e-9) break; - const next = stepped - REVET_EMBED_DEPTH_M; - if (Math.abs(next - height) < 1e-9 && Math.abs(moved - anchor.offset) < 0.01) break; - anchor = { offset: moved, elevation: invert }; - height = next; - if (!c.rebuild(anchor, height, target)) break; - continue; - } - const movedSlope = c.slopeAt(moved, c.thickness, height); - // 사면 역전(벽 상단이 노견 위)으로는 옮기지 않는다 · 성토고 0(성토선 0점)까지는 허용(2026-08-23). - if (c.edge.elevation_m - (c.lineInvertAt(moved) + height) < 0) break; - // 자동 자리만 옮긴다(수동 벽은 이 루프에 아예 안 들어온다). 폭은 0.45 고정 — - // 폭으로 흡수하면 미는 동안 벽 단면이 변한다(2026-08-21 사용자 지적). - if ( - movedSlope.ratio >= FILL_SLOPE_RATIO_MIN - 1e-6 && - movedSlope.ratio <= FILL_SLOPE_RATIO_MAX && - movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6 - ) { - anchor = { offset: moved, elevation: c.lineInvertAt(moved) }; - if (!c.rebuild(anchor, height, target)) break; - continue; - } - // 물매·사면 5m를 벗어나면 멈춘다. 폭으로 흡수하지 않는다(좌·우 단면이 달라진다). - break; - } - return { anchor, height }; -} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 70e2cd2de..3e46b469e 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -13,6 +13,7 @@ import { FILL_SLOPE_MAX_LENGTH_M, FILL_SLOPE_RATIO_MIN, MIN_PIPE_COVER_M, + pipeLengthWhole, REVET_EMBED_DEPTH_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, @@ -57,7 +58,6 @@ import { } 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 { fitOutletPipeLength } from "./B06_Section_UI_Cross_Culvert_Fit"; import { buildCulvertTiers, drawnFillPoints, @@ -67,7 +67,6 @@ import { import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve"; import { cutLength, - outletSlopeFactory, fillSlopeOf, groundInterpolator, designInterpolator, @@ -116,7 +115,7 @@ export function computeCulvertLayout( false, true, ); - return p ? { shape: p.basin?.shape ?? null, lengthM: p.pipe.lengthM } : null; + return p ? { shape: p.basin?.shape ?? null, lengthM: pipeLengthWhole(p.pipe.lengthM) } : null; }; const requestedBasin = basinAdjustment ?? DEFAULT_BASIN_ADJUST; const basinAdjust = skipBasinClamp ? requestedBasin : clampBasinMove(requestedBasin, probeBasin); @@ -284,13 +283,9 @@ export function computeCulvertLayout( const rise0 = outletAnchor.elevation - inlet.elevation; const length0 = Math.hypot(run0, rise0); if (!(length0 > 0.5)) return null; - // 관은 m 단위 설치 — 올림 연장분은 유출 쪽으로(2026-08-20 사용자 확정). - let lengthM = Math.ceil(length0 - 1e-6); - const scale = lengthM / length0; - const outlet: OffsetPoint = { - offset: inlet.offset + run0 * scale, - elevation: inlet.elevation + rise0 * scale, - }; + // 관 길이 = 그려진 실제 길이(0.1 m · 225-1 — 정수 올림 연장 없음 · 표시 · 집계만 반올림). + let lengthM = Math.round(length0 * 10) / 10; + const outlet: OffsetPoint = { ...outletAnchor }; const span = Math.abs(outletAnchor.offset - inlet.offset); let slopePct = span > 0 ? ((inlet.elevation - outletInvert) / span) * 100 : 0; @@ -418,8 +413,7 @@ export function computeCulvertLayout( // 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21). const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation); let outletWallAnchor = outletAnchor; - // 유출도 성토선 0점 기준(2026-08-23) — 기본 자리는 성토선 0 지점(79-5). 관 길이 - // 정수 맞춤 루프가 벽을 옮길 때 invert가 같은 성토선을 타도록 함수로 둔다. + // 유출도 성토선 0점 기준(2026-08-23) — 기본 자리는 성토선 0 지점(79-5). const outletZeroAnchor = slopeZeroAnchor( outletInfo.edge, outletInfo.outward, @@ -427,10 +421,6 @@ export function computeCulvertLayout( outletWallSpec.lean, ); const outletZeroInvert = outletInfo.edge.elevation_m - outletWallSpec.height; - const outletLineInvertAt = (offset: number): number => - designAt - ? outletZeroInvert - ((offset - outletZeroAnchor) * outletInfo.outward) / FILL_SLOPE_RATIO_MIN - : invertAt(offset); if (designAt) { // 4축 배치(유입과 동일 풀이). const placed = placePipeWall({ @@ -454,7 +444,7 @@ export function computeCulvertLayout( }; outletWallAnchor = { offset: placed.anchorOffset, elevation: placed.invert }; } - let outletWall = buildWall( + const outletWall = buildWall( culvert.outlet, outletWallAnchor, outletInfo.outward, @@ -467,91 +457,28 @@ export function computeCulvertLayout( ); // ── 관 축 확정 — 관 하단선은 시작점과 유출 벽 전면 기준선 교차점을 잇는다. const pipeStart = basinPipeEnd ?? inlet; - // 유출 벽이 사면 끝과 다른 자리면 관 끝도 그 자리 기준으로 맞춘다(올림 연장분은 - // 유출 쪽 — 2026-08-20 확정). 관 하단선은 전면 기준선 교차점을 지나 정수 길이로. + // 관 끝 = 유출 벽 전면 기준선 교차점 — 벽은 관 길이와 무관하게 0.1 m 로 움직이고 길이는 + // 벽 자리에서 셈(225-1 · 옛 정수 올림 연장 · 길이 맞춤 이동 없앰). if (outletWall) { const face = outletPipeEnd(outletWall); - const runW = face.offset - pipeStart.offset; - const riseW = face.elevation - pipeStart.elevation; - const lenW = Math.hypot(runW, riseW); - if (lenW > 0.5) { - const scaleW = Math.ceil(lenW - 1e-6) / lenW; - outlet.offset = pipeStart.offset + runW * scaleW; - outlet.elevation = pipeStart.elevation + riseW * scaleW; - lengthM = Math.ceil(lenW - 1e-6); + if (Math.hypot(face.offset - pipeStart.offset, face.elevation - pipeStart.elevation) > 0.5) { + outlet.offset = face.offset; + outlet.elevation = face.elevation; } } // ── 관 단면 꼭짓점 — 끝단면을 구조물 계류측 변으로 자른다. 축·법선은 벽이 옮겨질 // 때마다 다시 유도한다(2026-08-20 사용자 ③). const pipeAxis = pipeAxisSolver(pipeStart, outlet, diameter, endFaces); - const deriveAxis = pipeAxis.derive; const endCorners = pipeAxis.corners; - let pipeCorners = { + const pipeCorners = { inlet: endCorners("inlet", pipeStart), outlet: endCorners("outlet", outlet), }; - // ── 관 길이 m 단위 맞춤 — `_Culvert_Fit`(보통 = 유출 벽을 관 축 바깥으로 · 노견을 넓힌 - // 쪽 = 사면 우선 + 벽 높이 역산 · 139-6). 정수 맞춤 후보 자리도 같은 성토선을 탄다(2026-08-23). - const outletThickness = REVET_THICKNESS_M; // 늘 0.45 고정(2026-08-21) — 폭으로 길이를 맞추지 않음 - // 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22) — 표기만 올림한다. - // d = null(기본 자리)만 자동으로 본다(2026-08-23 — d는 0점 기준 절대값). - const outletPinned = !!outletBody || Math.abs(adjOutlet.x) > 1e-9 || adjOutlet.d != null; - if (outletWall && !outletPinned) { - const extend = - section.design?.[`shoulder_extend_${outletInfo.outward > 0 ? "left" : "right"}_m`]; - fitOutletPipeLength({ - shifted: (extend ?? 0) > 1e-9, - edge: outletInfo.edge, - outward: outletInfo.outward, - axis: () => pipeAxis.axis, - thickness: outletThickness, - anchor: outletWallAnchor, - height: outletWallSpec.height, - lineInvertAt: outletLineInvertAt, - slopeAt: (offset, thickness, height) => - outletSlopeFactory(outletInfo, outletLineInvertAt, height)(offset, thickness), - cutLength: () => cutLength(pipeCorners), - rebuild: (anchor, height, target) => { - outletWallAnchor = anchor; - walls.pop(); - outletWall = buildWall( - culvert.outlet, - anchor, - outletInfo.outward, - null, - outletThickness, - { - height, - baseElevation: anchor.elevation, - floatGapM: Math.max(0, anchor.elevation - invertAt(anchor.offset)), - }, - outletWallSpec.material, - outletWallSpec.form, - outletWallSpec.lean, - ); - if (!outletWall) return false; - // 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①). - const face = outletPipeEnd(outletWall); - const runW = face.offset - pipeStart.offset; - const riseW = face.elevation - pipeStart.elevation; - const lenW = Math.hypot(runW, riseW) || 1; - outlet.offset = pipeStart.offset + (runW / lenW) * target; - outlet.elevation = pipeStart.elevation + (riseW / lenW) * target; - deriveAxis(); - pipeCorners = { - inlet: endCorners("inlet", pipeStart), - outlet: endCorners("outlet", outlet), - }; - return true; - }, - }); - } if (outletWall) { - // 표기 길이 = **실제 그려진 관**의 올림값(목표를 적으면 도면보다 길게 적힌다). - // 수동 벽으로 실길이가 정수가 아니어도 표기만 올림한다(2026-08-22 사용자 확정). - lengthM = Math.max(1, Math.ceil(cutLength(pipeCorners) - 1e-6)); + // 관 길이 = **실제 그려진 관** 0.1 m(225-1 — 표시 · 집계 반올림은 `pipeLengthWhole`). + lengthM = Math.max(0.1, Math.round(cutLength(pipeCorners) * 10) / 10); slopePct = Math.abs(outlet.offset - pipeStart.offset) > 1e-9 ? ((pipeStart.elevation - outlet.elevation) / Math.abs(outlet.offset - pipeStart.offset)) * diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts index 94dc53ea0..8ffe3bed8 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Types.ts @@ -50,6 +50,31 @@ export function outlineBox(points: OffsetPoint[]): OffsetPoint[] { ]; } +/** + * 관 정보 글(세월교 · 관 자리) — 노폭 · 노견 라벨(`.b06-chart__carriageway-label`) 바로 위 줄(225-1). + * 같은 그림에 그 라벨이 없으면(B07 덧그림) `fallback` 자리 · fallback 도 없으면 안 그림. + */ +export function appendPipeInfoText( + layer: SVGElement, + text: string, + fallback: [number, number] | null, +): void { + const svg: Element = layer.ownerSVGElement ?? layer; // 맨 위 svg 는 ownerSVGElement = null + const road = svg.querySelector?.(".b06-chart__carriageway-label"); + if (!road && !fallback) return; + const label = document.createElementNS("http://www.w3.org/2000/svg", "text"); + const stacked = svg.querySelectorAll?.(".b06-chart__pipe-info").length ?? 0; + const [x, y] = road + ? [Number(road.getAttribute("x")), Number(road.getAttribute("y")) - 13 * (stacked + 1)] + : fallback!; + label.setAttribute("x", String(x)); + label.setAttribute("y", String(y)); + label.setAttribute("text-anchor", "middle"); + label.setAttribute("class", "b06-chart__culvert-label b06-chart__pipe-info"); + label.textContent = text; + (road?.parentNode ?? layer).appendChild(label); +} + /** 설계선(성토 경사) 트림 범위 — 이 밖은 벽·보호공이 대신하므로 그리지 않는다. */ export interface CulvertDesignTrim { minOffset: number; diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index 769e7ce9f..fde0f514b 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -10,7 +10,11 @@ * ========================================================================== */ import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom"; -import { outlineBox, type OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types"; +import { + appendPipeInfoText, + outlineBox, + type OffsetPoint, +} from "./B06_Section_UI_Cross_Culvert_Types"; import { appendChuteBand } from "./B06_Section_UI_Cross_Wall_Hatch"; export { @@ -196,14 +200,8 @@ export function appendFordOverlay( } } - const label = document.createElementNS(SVG_NS, "text"); - const [labelX, labelY] = toXY(layout.label.at); - label.setAttribute("x", String(labelX)); - label.setAttribute("y", String(labelY)); - label.setAttribute("text-anchor", "middle"); - label.setAttribute("class", "b06-chart__culvert-label"); - label.textContent = layout.label.text; - layer.append(label); + // 세월교 관 정보 글 = 노폭 · 노견 라벨 위 줄(225-1) · 그 라벨이 없으면 옛 자리. + appendPipeInfoText(layer, layout.label.text, toXY(layout.label.at)); return (key: FordPick | null): void => { for (const [role, shapes] of walls) { diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index 0dec26028..57a37320c 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -67,7 +67,7 @@ export interface StructurePanelDeps { bodyGuard?: () => boolean; /** 이동 조작 가능 여부 — 집수정(자리 고정)은 숨긴다. */ canNudge: (key: RevetKey) => boolean; - /** 좌우·상하 걸음이 0.1m인가(독립·추가 기슭막이). 배관 기준벽은 false = 1m. */ + /** 좌우·상하 걸음이 0.1m인가(관보호공 · 독립 · 추가 기슭막이). 세월교 흙막이는 false = 1m. */ fineMove: (key: RevetKey) => boolean; /** 그림 끌기 두 축(108-2) — ▲▼ 축 뜻 · 한계 · 한 번 반영. 없으면 끌 수 없다. */ dragAxes?: (key: RevetKey, basin: boolean) => Omit | null; @@ -102,13 +102,13 @@ export interface StructurePanelHandle { } /** - * 좌우·상하 한 걸음(m) — **관 길이 1m**에 맞춘다(2026-08-21 사용자 확정). + * 세월교 흙막이 좌우·상하 한 걸음(m) — 안이 관이라 **1m**(80). * 높이는 0.1m 눈금(2026-08-22 사용자 확정 2~3m·0.5~3m 구간 0.1 단위 제어). */ const STEP_M = 1.0; /** - * 독립·추가 기슭막이의 좌우·상하 한 걸음(m) — **0.1m**(2026-08-29 사용자). - * 배관 기준벽(유입·유출)만 관 길이에 맞춘 1m 걸음을 그대로 쓴다. + * 관보호공(배관 기준벽) · 독립 · 추가 기슭막이의 좌우·상하 한 걸음(m) — **0.1m** + * (관 길이와 무관 · 225-1). */ const OWN_STEP_M = 0.1; const HEIGHT_STEP_M = 0.1; diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index 00fa535c1..15cb2226a 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -92,9 +92,13 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe return { scrollKey: ctx.scrollKey, adjustFor: ctx.adjustOf, + // 배관 기준벽 d 빈 칸은 지금 자리로 묶음 — x 만 넣으면 옛 기본 d(경사길이 4m · 79b)로 튐(225-1). nudge: (key, screenDeltaM) => ctx.revetOffset?.update(ctx.adjustChainage(), key, { x: ctx.adjustOf(key).x + screenDeltaM * ctx.outwardOf(key), + ...(key === "inlet" || key === "outlet" + ? { d: ctx.adjustOf(key).d ?? ctx.appliedD().get(key) ?? null } + : {}), }), // d 미지정(자동)이면 실제 적용 자리에서부터 움직인다(2026-08-23 — d는 0점 // 기준 절대값이라 시작값이 필요하다). @@ -148,12 +152,11 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe resetBasin: () => ctx.inletStructure?.resetAdjust(ctx.adjustChainage()), // 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동). canNudge: (key) => key !== "inlet" || !ctx.inletIsBasin(), - // 걸음 분류(2026-08-29 사용자): **배관 기준벽만 1m**, 나머지(독립 기슭막이 기준벽· - // 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80). - // 연동을 푼 옆 측점 카드는 자기 자리를 잡는 벽이라 0.1m(139-2). + // 걸음 분류: 관보호공(배관 기준벽)도 관 길이와 무관하게 0.1m(225-1) · 세월교 흙막이만 + // 안이 관이라 1m(80) · 연동을 푼 옆 측점 카드는 자기 자리를 잡는 벽이라 0.1m(139-2). fineMove: (key) => (key !== "inlet" && key !== "outlet") || - (ctx.hiddenPipe() && !ctx.fordGuard?.()) || + !(ctx.hiddenPipe() && ctx.fordGuard?.()) || (ctx.isLinked && ctx.revetLink?.linkedFor(ctx.section) === false), dragAxes: (key, basin) => wallDragAxes(ctx, key, basin), extraState: () => ctx.extraState(), diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 104bcc4ac..20b13dcf9 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -7,7 +7,7 @@ 흙막이 › 형태 › H=높이 m 유입(흙막이일 때) · 유출 · 흙막이 n(실제 선 단까지) · 길이 = 전 + 후 · 빈 높이 = 횡단도가 그리는 관경 최소 높이 돌붙임 › 찰붙임 · 메붙임 ㎡ 도수로/돌붙임 붙임 형식 · 면적(빈칸이면 안 셈 · 98-3) - 관 공 › Φ{관경}m/m › 관매설 m B06 관 길이(1 m 올림 그대로) + 관 공 › Φ{관경}m/m › 관매설 m B06 관 길이 사사오입 정수(225-1) › 커플링 ea 올림(L ÷ 1본) − 1 · 1본 = 프로젝트 층 M02 구조물집계표 변수만 관경 · 련 빈칸 = 「미입력」 열(임의 값으로 안 채움 · 143-2) › 관보호공 모양 실제로 선 집수정 모양마다 1 개소(98-2 `inlet_basin`) diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index 1cb0db1ff..fb34caad1 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -50,7 +50,9 @@ _REF = re.compile(r"\[([^\]$@][^\]@]*)\]") def pipe_lengths_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, float]: - """B06 횡단 설계 `[{chainage_m, design}]` → `{측점: 관 연장}` · 없는 측점은 안 담음.""" + """B06 횡단 설계 `[{chainage_m, design}]` → `{측점: 관 연장}` · 없는 측점은 안 담음. + + 관 연장 = 사사오입 정수(0.5 올림 · 225-1 · ⚠ TS 짝 `pipeLengthWhole` · 거울 시험 test_225_1).""" found: dict[float, float] = {} for row in designs or []: design = row.get("design") if isinstance(row, dict) else None @@ -58,7 +60,7 @@ def pipe_lengths_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, continue length = _number(design.get("pipe_length_m")) if length and length > 0: - found[round(float(row.get("chainage_m") or 0.0), 3)] = length + found[round(float(row.get("chainage_m") or 0.0), 3)] = int(length + 0.5) return found diff --git a/resources/tester/helper_225_1_pipe_round.cjs b/resources/tester/helper_225_1_pipe_round.cjs new file mode 100644 index 000000000..a56c21a89 --- /dev/null +++ b/resources/tester/helper_225_1_pipe_round.cjs @@ -0,0 +1,154 @@ +/* 225-1 관 정보 글 자리 · 관보호공 0.1 m 이동 · 관 길이 표시 반올림 헬퍼 — 짝 = `test_225_1_pipe_round.py`. + * 입력 = 인자(반올림 거울 값 JSON 배열) · B07 픽스처 배관 측점(139-6 과 같은 자료). + */ +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 noop = new Proxy({}, { get: () => () => "" }); + +/** 아주 작은 SVG 가짜 — 글 자리 판정에 쓰는 것만(class 고르기 · 속성 · 부모). */ +class El { + constructor(tag) { + this.tag = tag; + this.attrs = {}; + this.children = []; + this.parentNode = null; + this.ownerSVGElement = null; + this.textContent = ""; + } + setAttribute(k, v) { + this.attrs[k] = String(v); + } + getAttribute(k) { + return this.attrs[k] ?? null; + } + appendChild(c) { + c.parentNode = this; + c.ownerSVGElement = this.tag === "svg" ? this : this.ownerSVGElement; + this.children.push(c); + return c; + } + append(...cs) { + cs.forEach((c) => this.appendChild(c)); + } + addEventListener() {} + all() { + return this.children.flatMap((c) => [c, ...c.all()]); + } + querySelectorAll(sel) { + return this.all().filter((e) => (e.attrs.class ?? "").split(" ").includes(sel.slice(1))); + } + querySelector(sel) { + return this.querySelectorAll(sel)[0] ?? null; + } +} +globalThis.document = { addEventListener: () => undefined, createElementNS: (_ns, t) => new El(t) }; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8")); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const resolve = (base) => + fs.existsSync(base) && fs.statSync(base).isFile() ? base : `${base}.ts`; + const req = (r) => { + if (r.startsWith("@ui/") || r.startsWith("@config/")) return noop; + if (r.startsWith("@util/")) return loadTs(resolve(path.join(ROOT, "common_util", r.slice(6)))); + return loadTs(resolve(path.join(path.dirname(full), r))); + }; + new Function("exports", "module", "require", js)(box.exports, box, req); + cache.set(full, box.exports); + return box.exports; +} + +const B06 = (name) => loadTs(path.join(ROOT, "B06_Section", `${name}.ts`)); +const { computeCulvertLayout } = B06("B06_Section_UI_Cross_Culvert_Geom"); +const { cutLength } = B06("B06_Section_UI_Cross_Culvert_Solve"); +const { pipeLengthText, pipeLengthWhole } = B06("B06_Section_UI_Cross_Culvert_Const"); +const { appendPipeInfoText } = B06("B06_Section_UI_Cross_Culvert_Types"); +const { structurePanelDeps } = B06("B06_Section_UI_Cross_View_Structure"); + +// ① 글 자리 — 노폭 · 노견 라벨(x 200 · y 180) 바로 위 줄 · 둘이면 한 줄 더 위 · 라벨 없으면 fallback +const svg = new El("svg"); +const road = new El("text"); +road.setAttribute("class", "b06-chart__carriageway-label"); +road.setAttribute("x", 200); +road.setAttribute("y", 180); +svg.appendChild(road); +const plot = new El("g"); +svg.appendChild(plot); +appendPipeInfoText(plot, "관매설 Φ600m/m, L=8 m (7.6)", null); +appendPipeInfoText(plot, "세월교 L8m", [10, 10]); +const infos = svg.querySelectorAll(".b06-chart__pipe-info").map((e) => ({ + x: Number(e.getAttribute("x")), + y: Number(e.getAttribute("y")), + text: e.textContent, + inSvg: e.parentNode === svg, +})); +const bare = new El("svg"); +appendPipeInfoText(bare, "관", null); +appendPipeInfoText(bare, "세월교", [10, 20]); +const bareInfos = bare.querySelectorAll(".b06-chart__pipe-info").map((e) => ({ + x: Number(e.getAttribute("x")), + y: Number(e.getAttribute("y")), + text: e.textContent, +})); + +// ② 0.1 m 이동 — 걸음 분류 · 유출 벽을 0.1 m 씩 밀면 관 길이가 벽 자리를 따라 0.1 m 로 바뀜 +const fine = (key, hiddenPipe, fordGuard) => + structurePanelDeps({ + hiddenPipe: () => hiddenPipe, + fordGuard: () => fordGuard, + isLinked: false, + }).fineMove(key); +const steps = { + pipeOutlet: fine("outlet", false, false), + pipeInlet: fine("inlet", false, false), + fordGuard: fine("outlet", true, true), + hiddenOwn: fine("outlet", true, false), +}; +const sections = JSON.parse( + fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"), +).cross_sections; +const section = sections.find((s) => s.culvert && !s.culvert.hidden_pipe); +const r3 = (v) => Math.round(v * 1000) / 1000; +// ◀/▶ 한 번 = x 0.1 + d 빈 칸은 지금 자리로 묶음(옛 기본 d 로 튀지 않게) +const patches = []; +const nudgeDeps = structurePanelDeps({ + adjustOf: () => ({ x: 0, d: null, h: null, m: null }), + appliedD: () => new Map([["outlet", 0.4]]), + outwardOf: () => 1, + adjustChainage: () => 0, + revetOffset: { update: (_c, key, patch) => patches.push({ key, ...patch }) }, +}); +nudgeDeps.nudge("outlet", 0.1); +nudgeDeps.nudge("extra0", 0.1); +const base = computeCulvertLayout(section, section.samples); +const d0 = base.revetShift.outlet.d; +const moved = [0, 0.1, 0.2, 0.3].map((x) => { + const layout = computeCulvertLayout(section, section.samples, { + outlet: { x, d: x ? d0 : null, h: null, m: null }, + }); + return { x, lengthM: layout.pipe.lengthM, cut: r3(cutLength(layout.pipeCorners)) }; +}); + +// ③ · ④ 표시 · 반올림 거울 +const values = JSON.parse(process.argv[2] ?? "[]"); +process.stdout.write( + JSON.stringify({ + infos, + bareInfos, + steps, + patches, + moved, + text: values.map((v) => pipeLengthText(v)), + whole: values.map((v) => pipeLengthWhole(v)), + }), +); diff --git a/resources/tester/test_108_drag_box_hit.py b/resources/tester/test_108_drag_box_hit.py index 78a1ba35d..a5033b204 100644 --- a/resources/tester/test_108_drag_box_hit.py +++ b/resources/tester/test_108_drag_box_hit.py @@ -31,9 +31,9 @@ def out() -> dict: @pytest.mark.parametrize("side", ["diag1", "diag-1"]) def test_대각_끌기는_두_축이_함께_걸음_배수로(out, side) -> None: - """배관 기준벽(1 m 걸음) — 사면 d 두 칸 + 수평 x 한 칸이 한 번에 · 값은 걸음 배수.""" + """배관 기준벽(0.1 m 걸음 · 225-1) — 사면 d · 수평 x 가 한 번에 · 값은 걸음 배수.""" case = out["wall"][side] - assert case["update"] == {"role": "outlet", "x": 1, "d": 4} + assert case["update"] == {"role": "outlet", "x": 1.3, "d": 4} assert "◀" in case["snap"]["text"] or "▶" in case["snap"]["text"] assert "▼ 2.0m" in case["snap"]["text"] diff --git a/resources/tester/test_139_6_pipe_fit.py b/resources/tester/test_139_6_pipe_fit.py index 34c642776..d2959f13a 100644 --- a/resources/tester/test_139_6_pipe_fit.py +++ b/resources/tester/test_139_6_pipe_fit.py @@ -1,7 +1,7 @@ """139-6 노견을 넓힌 유출 쪽 관 길이 자리 — 사면 우선(물매 1:1.2 그대로) + 유출 벽 높이 역산(0.1 m). 설계선 엔진으로 노견 확장 설계를 내고 배수관 기하를 돌린 값(`helper_139_6_pipe_fit.cjs` · B07 픽스처 -배관 85.052 · 유출 = 좌). 안 넓힌 측점은 종전 맞춤 그대로(나눔 전 코드와 같은 값 · 5.772 → 표기 6). +배관 85.052 · 유출 = 좌). 225-1 부터 정수 길이 맞춤 · 높이 역산 없음 — 관 길이 = 그려진 길이 0.1 m. """ import json @@ -40,8 +40,9 @@ def out(tmp_path_factory) -> dict: def test_안_넓힌_측점은_종전_그대로(out: dict) -> None: + """225-1 — 정수 맞춤 없앰: 벽은 기본 자리 · 관 길이 = 그려진 길이 0.1 m(5.772 → 5.8).""" base = out["base"] - assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 6) + assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 5.8) assert base["cut"] == pytest.approx(5.772) @@ -51,10 +52,9 @@ def test_넓힌_쪽은_물매_그대로_높이_역산(out: dict, name: str, exte assert case["edge"] == pytest.approx(2 + extend) and case["extend"] == extend assert case["slope"] == pytest.approx(1.2, abs=0.05) # 사면 우선 — 물매 그대로 assert round(case["pureHeight"] * 10) == pytest.approx(case["pureHeight"] * 10) # 0.1 m 눈금 - assert case["lengthM"] - case["cut"] < 0.1 # 눈금 탓 남는 차만 표기 올림 - assert float(case["lengthM"]).is_integer() + assert case["lengthM"] == round(case["cut"], 1) # 225-1 — 길이는 벽 자리에서 셈(올림 없음) def test_높이가_실제로_바뀜(out: dict) -> None: - """대조 — 1.3 m 넓히면 유출 벽 높이가 2.0 → 1.8 로 역산됨(시험이 높이 길을 실제로 잼).""" - assert out["shifted2"]["pureHeight"] == pytest.approx(1.8) + """225-1 — 길이 맞춤 높이 역산 없앰: 1.3 m 넓혀도 유출 벽 높이는 기본 2.0 그대로.""" + assert out["shifted2"]["pureHeight"] == pytest.approx(2.0) diff --git a/resources/tester/test_186_1_outlet_fold.py b/resources/tester/test_186_1_outlet_fold.py index c477551ca..4a173b744 100644 --- a/resources/tester/test_186_1_outlet_fold.py +++ b/resources/tester/test_186_1_outlet_fold.py @@ -63,7 +63,8 @@ def test_저장값_사본_면적(out: dict) -> None: """접힌 폐회로(절토 27.94 · 성토 0.34 ㎡) → 노견~벽~3도 선 절토만.""" assert out["stored"]["cut"] == pytest.approx(34.0391, abs=1e-3) assert out["stored"]["fill"] == 0 - assert out["auto"]["cut"] == pytest.approx(16.8211, abs=1e-3) + # 225-1 — 유출 벽이 관 길이 맞춤으로 밀리지 않고 기본 자리(16.8211 → 16.0929) + assert out["auto"]["cut"] == pytest.approx(16.0929, abs=1e-3) def test_절토_쪽_배관_유출도_안_접힘(out: dict) -> None: diff --git a/resources/tester/test_225_1_pipe_round.py b/resources/tester/test_225_1_pipe_round.py new file mode 100644 index 000000000..ac99b55a7 --- /dev/null +++ b/resources/tester/test_225_1_pipe_round.py @@ -0,0 +1,117 @@ +"""225-1 B06 관 정보 글 자리 · 관보호공 0.1 m 이동 · 관 길이 표시 반올림 · B08 집계 정수. + +화면 TS 를 그 자리에서 돌린 값(`helper_225_1_pipe_round.cjs`) + 파이썬 집계(`M02_Table_Fill`). +짝(반올림) = TS `pipeLengthWhole`(`B06_Section_UI_Cross_Culvert_Const.ts`) ↔ +파이썬 `pipe_lengths_from_designs` — 사사오입 · 0.5 올림. +""" + +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)) + +from M02_MasterTemplete.M02_Table_Fill import pipe_lengths_from_designs # noqa: E402 + +HELPER = Path(__file__).parent / "helper_225_1_pipe_round.cjs" +VALUES = [0.5, 1.4, 1.5, 2.5, 7.4, 7.5, 7.6, 10.0] + +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(HELPER), json.dumps(VALUES)], + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + ) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_관_정보_글은_노폭_라벨_바로_위(out: dict) -> None: + """노폭 · 노견 라벨(y 180) 위 줄 = 180 − 13 · 세월교 글이 또 있으면 한 줄 더 위 · 같은 x.""" + pipe, ford = out["infos"] + assert (pipe["x"], pipe["y"], pipe["inSvg"]) == (200, 167, True) + assert pipe["text"] == "관매설 Φ600m/m, L=8 m (7.6)" + assert (ford["x"], ford["y"]) == (200, 154) + + +def test_노폭_라벨_없으면_옛_자리_또는_안_그림(out: dict) -> None: + """B07 덧그림(라벨 없음) — 관 글은 안 그림 · 세월교 글은 옛 자리.""" + assert out["bareInfos"] == [{"x": 10, "y": 20, "text": "세월교"}] + + +def test_관보호공은_0_1m_걸음_세월교_흙막이만_1m(out: dict) -> None: + assert out["steps"] == { + "pipeOutlet": True, + "pipeInlet": True, + "fordGuard": False, + "hiddenOwn": True, + } + + +def test_좌우_한_번은_d_를_지금_자리로_묶음(out: dict) -> None: + """d 빈 칸 그대로 x 만 넣으면 옛 기본 d(경사길이 4 m)로 튐 — 배관 기준벽만 묶음.""" + assert out["patches"] == [ + {"key": "outlet", "x": 0.1, "d": 0.4}, + {"key": "extra0", "x": 0.1}, + ] + + +def test_관_길이는_벽_자리에서_0_1m_로(out: dict) -> None: + """유출 벽을 0.1 m 씩 밀면 관 길이도 0.1 m 씩 — 정수 올림 · 길이 맞춤 이동 없음.""" + lengths = [case["lengthM"] for case in out["moved"]] + assert lengths == [7.9, 8.0, 8.1, 8.2] + for case in out["moved"]: + assert case["lengthM"] == round(case["cut"], 1) + + +def test_표시는_정수_괄호_소수_첫째(out: dict) -> None: + assert out["text"][VALUES.index(7.6)] == "8 m (7.6)" + assert out["text"][VALUES.index(7.4)] == "7 m (7.4)" + assert out["text"][VALUES.index(7.5)] == "8 m (7.5)" + + +def test_B08_집계_값은_사사오입_정수() -> None: + designs = [ + {"chainage_m": 10, "design": {"pipe_length_m": 7.6}}, + {"chainage_m": 20, "design": {"pipe_length_m": 7.5}}, + {"chainage_m": 30, "design": {"pipe_length_m": 7.4}}, + ] + assert pipe_lengths_from_designs(designs) == {10.0: 8, 20.0: 8, 30.0: 7} + + +def test_반올림_짝_거울(out: dict) -> None: + """TS `pipeLengthWhole` ↔ 파이썬 집계 — 같은 값마다 같은 정수(0.5 올림).""" + designs = [{"chainage_m": i, "design": {"pipe_length_m": v}} for i, v in enumerate(VALUES)] + py = pipe_lengths_from_designs(designs) + assert [py[float(i)] for i in range(len(VALUES))] == out["whole"] + assert out["whole"] == [1, 1, 2, 3, 7, 8, 8, 10] + ts = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Const.ts").read_text( + encoding="utf-8" + ) + assert "Math.floor(lengthM + 0.5)" in ts and "M02_Table_Fill.pipe_lengths_from_designs" in ts + src = (ROOT / "M02_MasterTemplete" / "M02_Table_Fill.py").read_text(encoding="utf-8") + assert "int(length + 0.5)" in src and "pipeLengthWhole" in src + + +def test_카드_글과_좌측_패널이_같은_표시를_씀() -> None: + card = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert.ts").read_text(encoding="utf-8") + assert ( + "L=${pipeLengthText(pipe.lengthM)}" in card and "appendPipeInfoText(layer, pipeInfo" in card + ) + panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts").read_text( + encoding="utf-8" + ) + assert '"횡단 설계 뒤 셈" : pipeLengthText(length)' in panel diff --git a/resources/tester/test_79_guard_shape.py b/resources/tester/test_79_guard_shape.py index a89e33d92..8d40967f0 100644 --- a/resources/tester/test_79_guard_shape.py +++ b/resources/tester/test_79_guard_shape.py @@ -111,11 +111,11 @@ def test_관_보호공_기본_자리는_제일_위(shape: dict) -> None: assert pipe is not None # 벽 윗면 = 관 윗면(139-1) · 관 위 토피 0.5 → 유입 벽 윗면 = 노견 − 0.5 = 사면 d 0.6(1:1.2) assert pipe["d"]["inlet"] == pytest.approx(0.6, abs=1e-6) - # 유출은 관 m 단위 맞춤이 사면 아래로 민 만큼만 내려감(종전 기본 = 경사길이 4 m · d 3.07) + # 유출은 기본 자리(225-1 — 관 길이 맞춤 이동 없음 · 종전 기본 = 경사길이 4 m · d 3.07) assert pipe["d"]["outlet"] < 1.0 inlet = next(w for w in pipe["walls"] if w["role"] == "inlet") assert inlet["topBelowEdge"] == pytest.approx(0.5, abs=0.05) - assert float(pipe["lengthM"]).is_integer() + assert round(pipe["lengthM"], 1) == pipe["lengthM"] # 225-1 — 실제 길이 0.1 m def test_저장_x_있는_관_보호공은_종전_자리_그대로(shape: dict) -> None: From 265c23d5f241016ddb97efa03bbef64d7e29d35f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:47:04 +0900 Subject: [PATCH 13/15] =?UTF-8?q?fix(test):=20=EA=B4=80=20=EB=B3=B4?= =?UTF-8?q?=ED=98=B8=EA=B3=B5=20=EA=B8=B0=EB=B3=B8=20=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EC=9C=A0=EC=9E=85=20d=20=EA=B8=B0=EB=8C=80=EA=B0=92=200.6=20?= =?UTF-8?q?=E2=86=92=200.4=20=C2=B7=20=EC=9C=97=EB=A9=B4=20=EB=85=B8?= =?UTF-8?q?=EA=B2=AC=20=E2=88=92=200.3=20(211-1=20=EB=B2=BD=200.2=20?= =?UTF-8?q?=EB=86=92=EC=95=84=EC=A7=84=20=EB=8B=B9=EC=97=B0=ED=95=9C=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC)(225-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_018jonfdxn1Xit9bdkNJpE4G --- resources/tester/test_79_guard_shape.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/resources/tester/test_79_guard_shape.py b/resources/tester/test_79_guard_shape.py index 8d40967f0..c2a12a831 100644 --- a/resources/tester/test_79_guard_shape.py +++ b/resources/tester/test_79_guard_shape.py @@ -109,12 +109,13 @@ def test_유입_수평공간_시작은_본체_바닥(shape: dict) -> None: def test_관_보호공_기본_자리는_제일_위(shape: dict) -> None: pipe = shape["pipe"] assert pipe is not None - # 벽 윗면 = 관 윗면(139-1) · 관 위 토피 0.5 → 유입 벽 윗면 = 노견 − 0.5 = 사면 d 0.6(1:1.2) - assert pipe["d"]["inlet"] == pytest.approx(0.6, abs=1e-6) + # 유입 벽 높이 = 관경 + 0.7(211-1) · 바닥 = 관 invert 상한(노견 − 관경 − 토피 0.5) 고정이라 + # 벽이 0.2 높아진 만큼 윗면이 노견 − 0.3 으로 올라 사면 d 0.6 → 0.4(1:1.2 · 0.1 올림) + assert pipe["d"]["inlet"] == pytest.approx(0.4, abs=1e-6) # 유출은 기본 자리(225-1 — 관 길이 맞춤 이동 없음 · 종전 기본 = 경사길이 4 m · d 3.07) assert pipe["d"]["outlet"] < 1.0 inlet = next(w for w in pipe["walls"] if w["role"] == "inlet") - assert inlet["topBelowEdge"] == pytest.approx(0.5, abs=0.05) + assert inlet["topBelowEdge"] == pytest.approx(0.3, abs=0.05) assert round(pipe["lengthM"], 1) == pipe["lengthM"] # 225-1 — 실제 길이 0.1 m From 48cff44a0c12827e2bd6d8cca83da458a0a355bf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:47:24 +0900 Subject: [PATCH 14/15] =?UTF-8?q?chore(plan):=2010-10=20=EC=A1=B0=EC=82=AC?= =?UTF-8?q?=20AC(=EB=A1=9C=EC=A7=81=20=EA=B8=B0=EB=B3=B8=EA=B0=92)?= 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/요구_1010_2/조사_AC_로직기본값.md | 108 +++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 docs/raw/요구_1010_2/조사_AC_로직기본값.md diff --git a/docs/raw/요구_1010_2/조사_AC_로직기본값.md b/docs/raw/요구_1010_2/조사_AC_로직기본값.md new file mode 100644 index 000000000..47a6e13b8 --- /dev/null +++ b/docs/raw/요구_1010_2/조사_AC_로직기본값.md @@ -0,0 +1,108 @@ +# 조사 AC — 단가산출 로직 물성 기본값 (요구 86 · 96 · 조사만) + +대상 = M01 로직 `GF000156` 기계비빔타설(짝 `GF000157` 인력비빔타설)의 모래 · 자갈 단위중량 + 전 로직의 물성 상수 입력. 값 반영은 사용자 확인 뒤. + +## 1. 한눈 결론 + +- 기본값은 이미 있음 — 칸에 `0` 을 넣으면 원문표(건설품셈 공통 1-2-4 3 재료의 단위중량)의 「건조」 위끝(모래 1,700 · 자갈 1,800)을 씀. 그런데 M01 화면은 견본값으로 `1` 을 채움 → 배합 (B)를 고르면 모래 828㎥ · 자갈 1,011㎥(1㎥당), 모래값만 3천만 원대로 튐(실측). +- 원문 12-1-3 배합표는 kg 만 있어 원문 혼자서는 역산 못 함. 실무 두 곳(울진대흥 2024 일위대가 · STmate)의 같은 배합 ㎥ 값으로 역산하면 모래 ≈ 1,600 · 자갈 ≈ 1,700 — 원문 「건조」 범위의 가운데와 맞음. +- 지금 위끝(1,700 · 1,800)은 실무보다 모래 · 자갈 ㎥를 5~6% 적게 냄 → 기본을 「건조 가운데」(1,600 · 1,700)로 바꾸는 안 제안. +- 같은 꼴(물성 칸에 견본 0 · 1이 들어가 값이 망가지는 것)이 6무리 — 노체 살수(함수비 · 흙 단위중량) · 골재운반 4개(qt · f · E · Cm) · 덤프 자재운반 사이클시간 · 거푸집 높이감률 3개. +- 프로그램 쪽은 견본값 규칙이 줄 머리의 `시험입력` 을 안 보는 것이 뿌리 — 시험입력을 먼저 쓰게 하면 46개 로직 55칸이 한꺼번에 바로잡힘. + +## 2. GF000156 기계비빔타설 + +| 항목 | 내용 | 자리 | +|---|---|---| +| 이름 · 출처 | 기계비빔타설 · 산림품셈 12-1-2 · 원/㎥ | `로직_산림품셈_12장_철근콘크리트.json:47` | +| 시험입력 | 자재지역 울진 · 모래단위중량 0 · 자갈단위중량 0 | 같은 파일:53 | +| 입력 | 구분 · 믹서 · 지역 · 재료배합(계상 안 함 · (A)(B)(C)) · 골재최대치수(25 · 40) · 수중 · 모래상태 · 자갈상태(건조 · 습기 · 포화) · 모래단위중량 · 자갈단위중량(㎏/㎥ · 「0 이면 고른 상태의 표 위끝」) · 자재지역 | :55-160 (단위중량 :146 · :152) | +| 중간 | 모래밀도 = 만약(모래단위중량 > 0, 모래단위중량, 찾기(CC000337, 종별='모래', 형상=모래상태).위) · 자갈밀도 같은 꼴 | :163-164 | +| 쓰이는 식 | 모래(㎥) = 배합표(QF000353) 모래 kg ÷ 모래밀도 · 자갈(㎥) = 자갈 kg ÷ 자갈밀도 | :203-219 | +| 원문 배합표 | 12-1-3 주⑤ (A)(B)(C) × 25 · 40㎜, kg/㎥ | `소요량_산림품셈_12장_철근콘크리트.json:70-86` | +| 단위중량 표 | CC000337 모래 건조 1,500~1,700 · 자갈 건조 1,600~1,800 (아래 · 위 두 칸) | `계수_건설품셈_01장_적용기준.json:23-28` | +| 규칙 | 「0 = 자동 · 넣으면 그 값이 이김」 | `resources/master_data/_틀.md:241` | + +실측(읽기 전용 계산 · 울진 · (B) 25㎜ · 건조): + +| 단위중량 칸 | 모래밀도 · 자갈밀도 | 모래 ㎥ | 자갈 ㎥ | 모래 금액 | +|---|---|---|---|---| +| 1 (M01 화면 견본) | 1 · 1 | 828 | 1,011 | 30,636,000원 | +| 0 (자동 = 위끝) | 1,700 · 1,800 | 0.487 | 0.562 | 18,021원 | +| 1,600 · 1,700 (제안) | 1,600 · 1,700 | 0.518 | 0.595 | 19,148원 | + +- 견본 1의 까닭 — 견본값 규칙이 「나누는 자리에 오는 수 = 1」(`resources/master_data/scripts/master_auto.py:4` · :97). 모래밀도가 나누는 자리라 그 식에 든 모래단위중량도 1을 받음. 화면은 빈 칸에 이 값을 채움(`M01_MasterData/M01_MasterData_UI_Logic_Calc.ts:154-164`). +- 재료배합 견본이 「계상 안 함」이라 첫 화면에선 재료 0원으로 가려져 있다가, (A)(B)(C)를 고르는 순간 드러남. +- 곁가지 — 같은 시험 계산에서 시멘트 줄이 `MT003688` 프롬시멘트 PR(3,000원/kg)로 잡힘. 보통 시멘트(`MT004176` 시멘트, 전국 152~168원/kg)보다 18배 넘게 큼 — 검색어 「시멘트 전국」 후보 고르기 따로 확인할 것. + +## 3. 원문 근거 + +| 근거 | 값 | 자리 | +|---|---|---| +| 산림품셈 12-1-3 주⑤ 배합표 | 25㎜ (A) 357 · 893 · 931 / (B) 346 · 828 · 1,011 / (C) 340 · 779 · 1,049 · 40㎜ (A) 335 · 838 · 1,032 / (B) 323 · 775 · 1,101 / (C) 318 · 728 · 1,157 (시멘트 · 모래 · 자갈 kg) · 「모래는 건조상태 기준」 | `resources/knowledge/original/원가계산/산림_표준품셈/본문/제12장_철근콘크리트/12-01_콘크리트타설.md:60-71` | +| 건설품셈 공통 1-2-4 3 | 모래 건조 1,500∼1,700 · 습기 1,700∼1,800 · 포화 1,800∼2,000 / 자갈 건조 1,600∼1,800 · 습기 1,700∼1,800 · 포화 1,800∼1,900 (자연상태 · 「시험으로 정함이 원칙」) | `…/건설공사_표준품셈/본문/01_공통부문/제01장_적용기준/1-02_설계_및_수량.md:262-277` | +| 건설품셈 6-1(현장비빔타설) | 품만 있고 배합표 없음 — 역산 근거 못 됨 | `…/제06장_철근콘크리트공사/6-01_콘크리트.md:47-68` | +| 실무 울진대흥 2024 일위대가 B01190 「콘크리트비빔 기계비빔40mm」 | 시멘트 323kg · 모래 0.48㎥ · 자갈 0.65㎥ (= 40㎜ (B)) | `resources/knowledge/original/실무문서/2024년 간선임도(기번3-울진.대흥)(공통)/2024년 간선임도 신설사업(…).xlsx` 일위대가표 45~48행 | +| STmate 실무 레시피 | 같은 값(323 · 0.48 · 0.65) | `…/경쟁사 프로그램/STmate 분석/30_원자료/일위대가_레시피.json:10410` | +| 실무 울진소광 2025 원단위 | 채움 0.31㎥ · 친모래 0.17 · 친자갈 0.20 · 시멘트 2.69대 — 시멘트는 25㎜ (B) 346kg 과 맞고 모래는 1,600 근처, 자갈은 안 맞음(참고만 · 약함) | `…/실무문서/_원단위라이브러리_울진소광.md:88,96` | + +역산(배합 kg ÷ 실무 ㎥): + +- 모래 775 ÷ 0.48 = 1,615 · 반올림 폭(0.475~0.485)을 넣으면 1,598~1,632 → 1,600 이 들어감 · 지금 위끝 1,700 은 밖. +- 자갈 1,101 ÷ 0.65 = 1,694 · 폭(0.645~0.655) 1,681~1,707 → 1,700 이 들어감 · 지금 위끝 1,800 은 밖. +- 1,600 · 1,700 으로 나머지 다섯 배합을 셈 — 25㎜ (A) 0.56 · 0.55 / (B) 0.52 · 0.59 / (C) 0.49 · 0.62 · 40㎜ (A) 0.52 · 0.61 / (C) 0.46 · 0.68㎥. 옛 판 ㎥ 배합표(일위대가조합 메모 「실무 수는 옛 판 값」)가 이 두 값으로 만들어진 것으로 보임(옛 판 원문 자체는 지식DB에 없음). +- 재료 할증 — 원문 1-3-1 콘크리트용(시멘트 2 · 잔골재 10 · 굵은골재 3%, 정치식)은 어느 로직도 안 씀(`계수_산림품셈_01장_적용기준.json:434` CF000021 로직키 빈칸). 실무는 일위대가에 할증 없이 원문 값 그대로, 수량산출 쪽에서 모래 10%를 붙임(`…/울진.대흥)(1공구)/_분석_수량산출.md:83`) → 로직엔 안 넣는 것이 실무와 같음. + +## 4. 비슷한 로직 목록 + +훑은 범위 = 로직 파일 전부의 「고르기 · 범위 없는 수 입력」 688칸(가격 · 자재지역 뺌) · 견본값 규칙으로 실제 채워지는 값까지 셈. + +가. 물성 칸에 견본 0 · 1이 들어가 결과가 망가지는 것 + +| 로직 | 칸 | 견본 | 결과 | 자리 | +|---|---|---|---|---| +| GF000156 · GF000157 비빔타설 | 모래 · 자갈단위중량 | 1 | 재료 ㎥ 1,000배대 | `로직_산림품셈_12장_철근콘크리트.json:146,152,289,295` | +| GF000277 노체 살수 | 최적함수비 · 자연함수비 · 흙단위중량 | 1 · 1 · 1 | 살수량 0 → 물탱크 0원 (시험입력엔 13 · 8 · 1,600 있음) | `로직_산림품셈_09장_토공.json:2515` | +| GF000133 골재운반 시간당 작업량 | qt · f · E · Cm | 0 · 0 · 0 · 1 | Q = 0 | `로직_산림품셈_10장_자재․장비_운반.json:8` | +| GF000134~136 골재운반 모래 · 잡석 · 혼합골재 | qt · f · E · Cm | 1 · 1 · 1 · 1 | 덤프 0.0167시간/㎥(뜻 없는 값) · 칸에 설명 · 출처도 없음 | 같은 파일:20,64,108 | +| GF000283 덤프 자재 운반 | 사이클시간(분) | 0 | 운반비 0 (시험입력 30) | 같은 파일:699 | +| GC000211 · GC000214 · GF000210 거푸집 | 높이감률(%) | 1 | 시공량 1% 깎임(7m 이하면 0이어야) | `로직_건설품셈_06장_철근콘크리트공사.json:999,1411` · `로직_산림품셈_12장_철근콘크리트.json:4976` | + +나. 원문에 값이 있는데 입력으로만 받는 것 + +- GF000133~136 `E` — 건설품셈 8-2-8 「E : 작업효율(0.9)」. 이웃 GF000284 덤프운반은 0.9를 식에 박아 씀. +- GF000277 — 원문 주②③ 예시 E 0.9 · L 1.0㎞ · OMC 13% · NMC 8% · 단위중량 1,600 (`…/제09장_토공/9-16_노체.md:65-75`). E · V 는 이미 표(CF000101 · CF000102)로 씀, 함수비 · 단위중량만 빈칸. + +다. 표는 있는데 어느 로직도 안 쓰는 것 + +- CF000012 체적의 변화율 L · C · CF000013 체적환산계수 f (`계수_산림품셈_01장_적용기준.json:249,278`) — 골재운반 qt · f 의 근거인데 로직키 빈칸. +- CF000021 · CC000001 재료 할증 — 3장 끝 참고(실무와 같아 그대로 둠 제안). + +라. 기본 없음이 맞는 것(조치 없음) + +- 높이할증 11곳(GF000160 등 · 0 = 할증 없음) · 추가할증 · 부속비율 · 증가율 · 비산방지살포량 · 양생모래량(원문 「없으면 0」) · 관입률(0 = 계수 없음) · 그 밖 길이 · 면적 · 수량 칸 — 설계 조건이라 0 견본이 해롭지 않음. + +## 5. 제안 표 + +| 로직 코드 | 이름 | 입력 칸 | 지금 값 | 제안 기본값 | 근거(원문 파일:표 · 역산식) | 확신 | +|---|---|---|---|---|---|---| +| GF000156 · GF000157 | 기계 · 인력비빔타설 | 모래단위중량 | 0 = 자동(건조 위끝 1,700) · 화면 견본 1 | 1,600 ㎏/㎥ (건조 가운데) — 자동 규칙을 「위」→「(아래+위)/2」로 | 1-02_설계_및_수량.md:274 건조 1,500∼1,700 · 역산 775 ÷ 0.48 = 1,615(울진대흥 B01190 · STmate:10410) | 중간 | +| GF000156 · GF000157 | 〃 | 자갈단위중량 | 0 = 자동(1,800) · 견본 1 | 1,700 ㎏/㎥ (건조 가운데) | 같은 표:271 건조 1,600∼1,800 · 역산 1,101 ÷ 0.65 = 1,694 | 중간 | +| GF000156 · GF000157 | 〃 | 습기 · 포화 고를 때 | 위끝 | 가운데(모래 1,750 · 1,900 / 자갈 1,750 · 1,850) | 같은 표 · 실무 역산 근거 없음 | 낮음 · 사용자 확인 | +| GF000277 | 노체 살수 | 최적함수비 | 없음(견본 1) | 13 % | 9-16_노체.md:65 주② 예시 | 중간(현장 시험값 우선) | +| GF000277 | 〃 | 자연함수비 | 없음(견본 1) | 8 % | 같은 줄 | 중간 | +| GF000277 | 〃 | 흙단위중량 | 없음(견본 1) | 1,600 ㎏/㎥ | 9-16_노체.md:69 예시 (10-12-1 덤프 적재 토사 1.9t/㎥ 와 다름) | 중간 | +| GF000133~136 | 골재운반 | E | 없음(견본 0 · 1) | 0.9 | 건설품셈 8-02-08_덤프트럭.md:34 「작업효율(0.9)」 | 높음 | +| GF000133~136 | 〃 | f | 없음 | 1 (골재 ㎥를 흐트러진 상태로 셀 때) · 자연상태 기준이면 1/L | CF000013 기준 q 흐트러진 → 흐트러진 1 | 낮음 · 사용자 확인 | +| GF000133~136 | 〃 | qt | 없음 | 식으로 — 15 ÷ γt × L (예 모래 15 ÷ 1.6 × 1.25 ≈ 11.7㎥) | 8-02-08_덤프트럭.md:19 q = T/γt·L · CC000337 · CF000012 모래 L 1.20∼1.30 | 낮음 · 사용자 확인 | +| GF000133~136 | 〃 | Cm | 없음 | 고정값 근거 없음 — 운반거리 입력 + 8-2-8 식(GF000284 와 같은 꼴) | 8-02-08_덤프트럭.md:35-41 | 근거 없음 · 사용자 확인 | +| GF000283 | 덤프 자재 운반 | 사이클시간 | 없음(견본 0 · 시험 30) | 원문 값 없음 — 운반거리로 식 계산 또는 설계자 입력 | 10-09 머리 「작업량은 건설품셈 적용」 | 근거 없음 · 사용자 확인 | +| GC000211 · GC000214 · GF000210 | 합판 · 유로폼 거푸집 | 높이감률 | 없음(견본 1%) | 0 % (수직고 7m 이하) | 원문 비고 「7m 초과 매 3m 9%까지」 → 7m 이하 0 | 높음 | +| GF000156 · GF000157 | 비빔타설 | 재료 할증 | 안 붙임 | 지금대로 안 붙임(수량산출에서 붙임) | CF000021 정치식 2 · 10 · 3% · 실무 수량산출 모래 10% | 중간 · 사용자 확인 | + +## 6. 프로그램 쪽 개선 거리 + +- 견본값이 `시험입력` 을 먼저 쓰게 — `resources/master_data/scripts/master_auto.py:86-97` 에서 줄 머리 시험입력에 그 칸이 있으면 그 값. GF000156 · 157(0 → 표 자동) · GF000277(13 · 8 · 1,600) · GF000283(30) 포함 46개 로직 55칸이 바로잡힘. 고칠 줄 몇 줄. +- 「0 = 자동」 칸은 화면에 실제 쓰인 값을 보임 — 입력 옆 회색 글 「자동 1,700(건조 위끝)」(계산 응답 `middle` 의 모래밀도를 그대로 읽음 · `M01_MasterData_UI_Logic_Calc.ts:111-121` 입력 상자 자리). +- 물성 칸 경고 — 단위가 ㎏/㎥ · t/㎥ · % 이거나 무단위 계수(f · E · L)인데 고르기 · 범위 · 시험입력이 다 없는 칸은 M01 입력 이름 옆 ⚠ 「기본값 없음 — 원문 확인」 · `check_master` 검사에 같은 셈 한 줄. +- 기본을 바꿀 땐 `_틀.md:241` 「표 위끝」 규칙 글과 두 로직 설명(「0 이면 고른 상태의 표 위끝」)을 같이 고침. From 3f5bbe509e879a6adf5eb49eb7a3303400eafa0c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 16:48:47 +0900 Subject: [PATCH 15/15] =?UTF-8?q?chore(plan):=2010-10=20221~227=EC=9E=A5?= =?UTF-8?q?=20=EA=B8=B0=EB=A1=9D=20=EC=9D=B4=EA=B4=80?= 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/plans/2026-10-10_plan_221-227.md | 44 +++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 docs/raw/plans/2026-10-10_plan_221-227.md diff --git a/docs/raw/plans/2026-10-10_plan_221-227.md b/docs/raw/plans/2026-10-10_plan_221-227.md new file mode 100644 index 000000000..0dc8f340a --- /dev/null +++ b/docs/raw/plans/2026-10-10_plan_221-227.md @@ -0,0 +1,44 @@ +# 2026-10-10 계획 221~227장 기록 — 돋보기 CAD 빼기 · 스크롤 · 기계비빔타설 · 로직 기본값 조사 · 조합 연결 상단 표 · 관 0.1 m · CAD 축척 · CAD 줌아웃 + +근거 = `docs/raw/요구_1010_2/사용자요구_1010_2.md` 85~97 · 조사 AC(로직 기본값 · 사용자 결정 기다림). + +## 221. 돋보기 — CAD 영역에선 안 뜸 (sub4) +정함(브레인) = 사용자 85번 · CAD(iframe · cad_host) 위에 커서가 있으면 돋보기를 켜지 않고 · 켜진 채 CAD 위로 들어가면 숨김(나오면 다시) · 복제할 때 CAD 영역은 빼서 빈칸 · 휠 막음도 CAD 위에선 안 함 +- [x] 221-1 CAD 영역 빼기 — test_220_1 · 새 시험 + - 검증완료 — test_221_1(6) · test_220_1 · test_218_1 25 통과 · typecheck 통과 · CAD 위 켬 0회 · 휠 prevented 거짓 · 나오면 배율 2.25 그대로 · 복제 CAD 칸 자식 0 + +## 222. M02 구조물조합 일위대가 기계비빔타설(엄상준 SU-000007) — 자재 단위 · 별도계상 빼기 · 원문참고 둘 · 시멘트 출처 (sub7) +정함(브레인) = 사용자 87~90번 · 문제 둘 갈라 봄: ① 시멘트 · 모래 · 자갈 줄 단위 · 수량 · 원가가 틀린 까닭(로직 · 단위 환산 · 표시) ② 자재목록에서 별도계상 고른 자재 = 그 일위대가 단가에서 빠지고 일위대가 줄에서도 사라짐(나중에 본 프로그램에서 따로 계상 · 자재목록은 일위대가 상세 보기일 뿐 따로 셈하지 않음) · 원문참고 = 로직이 쓰는 원문 절 모두(12-1-2 · 12-1-3) · 시멘트가 어떻게 이어져 나오는지 보고 · 원본 단가산출 로직(master_data)은 고치지 않음(자료 문제면 보고) +- [x] 222-1 단위 까닭 · 별도계상 빼기 · 원문참고 여럿 · 시멘트 길 보고 — 새 시험 + - 검증완료 — test_222_1_m02_apart 12 · 관련 M02 · B08 · B09 시험 243 통과 · typecheck · 엄상준 SU-000007 사본 셈 기계비빔타설 기준 39,554,608 → 146,373원(시멘트 · 모래 · 자갈 줄 빠짐 · 자재목록 3줄 남음) · 원문 「산림품셈 12-1-2 · 12-1-3」 두 절 + +## 223. 단가산출 로직 입력값 기본값 점검 — GF000156 같은 것 (조사 AC · 조사 · 제안) +정함(브레인) = 사용자 86 · 96번 · 원본 로직은 사용자와 함께 고침(코드가 master_data 안 고침) → 모든 로직에서 물성 상수(단위중량 · 밀도 · 비중 · 할증 · 손실률 등) 입력이 빈칸이거나 기본값 없는 것 목록 + 원문(표준품셈 12-1-2 등 · 지식DB)에서 찾은 값 · 역산 값 · 근거 → 사용자 확인 뒤 반영 +- [x] 223-1 조사 문서 · 제안 표 · 사용자 보고 + - 조사완료 — 조사_AC_로직기본값.md · GF000156 기본값 있음(0 = 건조 위끝 1,700 · 1,800) · M01 견본값 1 이 망가뜨림 · 같은 꼴 6무리 · 사용자 결정 기다림 + +## 224. 구조물 조합 연결 모달에 M02 구조물조합 상단 표 (sub5) +정함(브레인) = 사용자 91번 · B05/B06 구조물 배치 → 구조물 조합 연결 모달 안에 M02 구조물조합 페이지 상단 표(조합 요약 표)를 같은 부품으로 보임(보기 전용) +- [x] 224-1 모달 상단 표 — 새 시험 + - 검증완료 — 새 test_224_1 9(고른 항목 층 · 주인의 집계표 보기 전용 · 같은 층 조합 = 열만 옮김 · 다른 층 = 표 바뀜 · 읽는 사이 바뀌면 마지막만 · 번호 실패해도 표 · 표 실패 = 까닭 글 · M02 와 같은 createSheet · numberLabels · comboView) · test_m02_combo_table(줄 글 도우미 단정 고침) · combo_screen · structure_panel · layer_screen · 173_2 · 170 · 163 · 156_2 통과 · typecheck 0 + +## 225. B06 관 정보 글 자리 · 관보호공 0.1 m 이동 · 관 길이 표시 반올림 (sub2) +정함(브레인) = 사용자 92 · 93번 · 세월교 · 관 자리 관 정보 글 = 노폭 · 노견 길이 라벨 위 · 관보호공 위치 = 관 길이와 무관하게 0.1 m 단위 이동 · 관 길이 표시(라벨 · 좌측 패널) = 반올림 정수 + 괄호 소수 첫째(예 「8 (7.6)」) · 구조물 집계표(B08)로 넘기는 값 = 반올림 정수 +- [x] 225-1 글 자리 · 0.1 m 이동 · 표시 · B08 값 — 새 시험 + - 검증완료 — 새 test_225_1 9(글 = 노폭 라벨 y 180 → 167 · 세월교 154 · 관보호공 0.1 m · 세월교 흙막이 1 m · ◀▶ 때 d 묶음 · 유출 벽 0.1 m 씩 → 관 7.9 · 8.0 · 8.1 · 8.2 · 「8 m (7.6)」 · B08 7.6 → 8 · 7.5 → 8 · 7.4 → 7 · 짝 거울 8값) · 정수 맞춤 없앰에 맞춰 139_6 · 186_1 · 108 · 79 기대값 고침 · 관 · B08 시험 250 통과 · typecheck 0 · test_79 유입 d 0.4≠0.6 은 변경 전부터 실패 + +## 226. CAD 축척 — 기준점 고르고 숫자 배율 입력 (sub6) +정함(브레인) = 사용자 94번 · 오토캐드 SCALE 꼴: 객체 고름 → 기준점 클릭 → 배율 숫자 입력(Enter) 또는 끌기 · 지금 끌기 축척은 그대로 +- [x] 226-1 기준점 · 숫자 배율 — vitest · CAD build + - 검증완료 — 새 vitest 5(기준점 (2,0) · 배율 2 = (-2,0)~(18,0) · 0.5 = (1,0)~(6,0) · 다른 객체 · 순서 그대로 · Esc 취소 · 배율 0 거절 · 되돌리기 한 번에 (0,0)~(10,0)) · 기존 포함 131 통과 · typecheck · CAD build 통과 + +## 227. CAD 많이 줌아웃하면 느려짐 — 까닭 · 개선 방향 (sub8 · 조사 · 제안) +정함(브레인) = 사용자 95번(질문) · 줌아웃 때 그리는 객체 수 · 글자 · 해치 · 격자 그리기 비용을 재고 개선안(작은 객체 생략 · 글자 생략 · 격자 간격 · 그리기 묶음 · 캐시) 제안 · 큰 효과 · 작은 위험 안은 바로 고쳐도 됨(전후 수치) +- [x] 227-1 측정 · 제안 · (바로 고칠 수 있는 것) 고침 + - 검증완료 — zoom-out-perf vitest(가짜 캔버스 · 횡단 40 + 등고선 178) 한 화면 다시 그리기 0.01배 19,348 → 16 ms · 0.1배 175 → 15 ms · 맞춤 23 → 21 ms(까닭 = 공간 색인 조회가 화면 상자 안 빈 칸 수억 개를 돎 → 칸 범위 자름) · 모눈 켜도 정지 화면 다시 그리기 0 회 · CAD vitest 129 · typecheck · build +- [x] 221-2 (사용자 97) 돋보기 = 페이지 · 안쪽 칸 스크롤에 따라 안 바뀌는 흠 — 복제 때 스크롤 칸들의 scrollTop · Left 를 복제본에 옮겨 적고 · 켜진 동안 스크롤(잡기 단계 · 모든 칸)이 나면 다시 맞춤(가벼우면 위치만 · 아니면 다시 뜸) — 새 시험 + - 검증완료 — test_221_2(3) · 221_1 · 220_1 · 218_1 합 28 통과 · typecheck 통과 · 스크롤 260 에서 다시 켬 = 복제 260(옛 코드 0) · 켠 채 400 + 문서 50 = rAF 1회에 400 · 페이지 -50px +- [x] 224-2 (sub1) 오늘 합친 뒤 빨강 둘 — test_m02_card_shell(214-2 Ctrl+S 판정이 key_shortcut 으로 바뀜 → 기대값) · test_modal_top_and_add_word(200-2 Bill_Level 안내 글 「더하기」 → 화면 글 규칙대로 「추가」) — 두 시험 통과 + - 검증완료 — test_m02_card_shell · test_modal_top_and_add_word 14 passed · test_200_2 4 passed · typecheck 통과 · 코드 글만 고침(Bill_Level 안내 「더하기」→「추가」) +- [x] 225-2 (sub1) test_79 관 보호공 기본 자리 d(유입) 0.6 → 0.4 로 바뀜 — 211(유입 흙막이 관 + 0.7)의 당연한 결과인지 · 흠인지 판정 · 결과면 기대값 · 흠이면 고침 — 시험 통과 + - 검증완료 — 판정 = 당연한 결과(흠 아님): 벽 바닥은 관 invert 상한(노견 − 관경 − 토피 0.5)에 고정 · 벽이 0.2 높아져 윗면 노견 − 0.5 → − 0.3 · d = 올림(1.2 × (1.5 − 1.2) · 0.1) = 0.4 · 225-1 관 길이와 무관 · 기대값 d 0.4 · 윗면 노견 − 0.3 으로 고침(시험 글에 까닭) · test_79 · 211_1 · 225_1 27 passed · typecheck 0