From c8e861e90ae26ddf77cd6af4cb5b63107d79ce0b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 15:12:55 +0900 Subject: [PATCH 1/5] =?UTF-8?q?chore(plan):=2010-11=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20163(=ED=99=94=EB=A9=B4=20=EA=B2=80=EC=A6=9D=20=ED=9D=A0=20?= =?UTF-8?q?=EB=8B=B5)?= 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 1e942196d..e47c8d39a 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -191,3 +191,4 @@ 160. 추가로 단가연결을 할때는 구조물조합이나 일위대가 사전조합이라면 문제가 없겠지만 단가산출의 경우에는 연결할때 옵션값을 받아야하는 경우가 있을거야. 이때는 추가 모달창을 보여줘서 사용자가 선택할수 있게 반영해줘. 만약 내가 잘못알고 있는거라면 알아서 해주고 161. (원문) 다음은 B09에 신규 메인탭인 공기를 넣어주고 하위에 공기집계와 공기산출 탭을 만들어줘. 이때 공기관련내용은 초안이니 B09 폴더 내부에 draft폴더를 만들어서 코드를 작업해줘. 나중에 마음에 안들면 그냥 draft폴더를 삭제할거야. 일단 참고문서는 resources\knowledge\original\실무문서\01.산불거창(거창북상병곡)\01.내역서\00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx 파일 중에 공기집계와 공기산출 탭을 참고하면 될거야. 여기서 중요한 점은 참고를 보고 내용을 넣는게 중요한게 아니라 로직을 분석하고 어떻게 계산하는지 확인하고 구현하는게 중요한거야. 다시말하지만 참고 항목이 있다고 테이블을 만드는게 아니야. 우리 프로그램에 맞춰 적용하는게 중요한거야. 그래서 길게 생각하고 작업해도 되니까. 꼼꼼히 분석해서 반영해줘. 이건 현재 작업내용을 이어서 진행하면 됨. 나 쉬러 갈테니까 작업완료하고 내일 아침에 내가 아티팩트를 만들어줘. 일단 이번 구현에 대해서는 방향성 결정이 필요한 내용이 있다면 너가 결정해서 아티팩트로 알려줘. 162. (원문 · 10-11) 1. 거리이정표 - 설계내역서 운반공 연결 - 거리이정표의 항목행 하나당 하나씩 설계내역서의 운반공 하위의 노용으로 들어가야함. - 거리이정표와 설계내역서의 연결 후 선택된 항목에 대해 거리값을 입력받아야하는 경우 연결되거나 될 거리이정표의 거리와 연결되어야 하며 이때 아마 비포장과 포장이 다를것같은데 이부분에 대한 계산 절차를 보여주는 모달창이 있어야함. 이때 모달창에는 옵션값과 계산 절차를 같은 화면에서 보여주며 단가산출을 다른 항목으로 수정할수 있어야함. 단가연결이 안된경우와 된경우를 같은 모달창을 사용하면 좋을것같아. 2. 거리이정표의 하위탭중 항목별 경로는 나타내는 하위탭에서 거래처/기준점/시점을 옮긴다면 주로를 옮겨서 업데이트 3. 거리이정표의 시점에서 나오는 모달창의 중점 측점은 km 단위로 변경, 하위의 설명은 삭제, 맵은 공통 카카오맵으로 변경 4. 거리이정표의 다이어그램에서 항목의 폰트를 거래처의 폰트와 동일하게 변경, 아래 주소가 길경우 자동으로 줄변경하고 컨테이너 세로 높이 증가, 방위 배치나 기본 맵연결에 따라 위치를 잡을때 거리에 따른 선길이가 지정되면 좋겠음. 단 너무 널리떠러지면 안됨. 선, 위의 텍스트 중 포장타입과 거리는 2행 구조로 변경, 거래처 및 기준점에 대한 모달창에서 제목의 폰트사이지가 내용보다 작음, 소재지-주소찯기.지도에서 고르기(...) 항목은 삭제, 기준점의 모달창인 경유 기준점의 이름이 2행구조로 됨. , 주소 및 맵 영역은 상단에 가로선을 추가하여 구분할것. 5. 기준점의 다이어그램 컨테이너에도 주소 추가 6. 거리이정표의 다이어그램에서 선연결의 드래그하는 원을 박스의 좌측 상단에 배치, 단 테두리를 넘어가면 안됨. 추가로 컨테이너의 내부에 작은 주소를 찾은 그림을 캡쳐하여 프로젝트 영구저장소에 넣어주고 컨테이너 내부에 배치 이때 그림을 다른 곳에서도 사용할 예정임. 잘 보관할 것. 7. 거리이정표의 하위 탭의 경로보기 기능 중 그림을 캡쳐하여 프로젝트 영구저장소에 넣어줄것. 프론트엔드 기능변경이 아님. 그림은 나중에 다른곳에 사용할 예정 8. 거리이정표의 다이어그램을 누렀을때나오는 제목에 테이블 제목 행의 정보가 들어오는데 삭제. 9. 거리이정표의 테이블이 컨테이너가 다이어그램의 영역과 동일하게 브라우져의 폭에 맞춰 변경이 되지만 테이블은 맞춤이 안됨. 혹시 테이블 양식과 충돌이 있다면 알려줄것. 10. 거리이정표의 기준점은 2개 이상일 수도 있을것같음. 이럴경우를 대비하여 선연결 기능을 기준점에도 추가 11. B09 총괄설계내역서와 설계내역서를 하위탭으로 이동하고 상위 탭으로 설계내역 탭을 생성 12. B09 단가연결 열의 정보가 텍스트로 구현되어있음. 랩프로 변경(램프양식은 B08의 구조물집계표에 있음), 선택 기능은 유지, 단가연결 열 제목은 단가 > 줄바꿈 > 연결 로 변경하고 테이블 열 폭을 줄일것. 13. B09와 테이블 행선택에서 페이지이동 기능이 구현된 행의 경우 붉은색 박스 하이라이트가 반영안된게 있음. 14. 하이라이트 기능과 연결하여 조심할 부분은 셀에 수기 입력이 가능한 영역을 셀테두리로 표기한게 없어졌음. 조심 테두리의 값을 조금 더 옅게 표현. 15. 자재단가대비표에 선택한 적용단가인 경우에 해당 셀에 색상을 변경했는데 이걸 테두리를 넣는걸로 대체. 이때 입력셀 테두리와 혼돈이 될수 있으니 녹색계열로 변경 16. 자재단가대비표에서 최저가 일지라도 적용단가를 변경해야할 경우가 있음. 마우스 우클릭에 항목을 적용단가 적용 항목 변경 17. 설계내역서의 비고 열에 빈 칸XX 정보는 삭제. 18. 총괄설계내역서/공사원가계산서의 제일하단의 총공사비는 하단고정으로 변경. 양식은 테이블 양식 확인 19. B06의 횡단도에서 상세버튼 모당창의 내용 중 나눠진 면적지정에 대한 기능 구현안됨. 기준은 현재 선들이 교차가 되면 선 요소를 분할하였음. (암경계선도 분할 필요) 이렇게 됨면 오픈된 영역을 제외하면 먼저 지정도 가능해야함. 이때 선택된 면적은 선을 지정하였을때 길이관련 정보(기능 누락됨)보여주는 정보들처럼 면적 정보들을 보여주면 됨.(이때 테이블을 선택하면 그래프에서 선이나 면적을 지정하게 될텐데 이때도 길이나 면적이 보여야함. ) 20. 횡단도 상세버튼 모달창 테이블의 항목들을 선택하면 테이블 하이라이트가 되는데 여기서 셀과 셀 제목을 지정해주는건 좋은데 중복되는 선때문에 보기가 안좋음. 21. 횡단도 상세버튼 모달창에서의 그래프 영역하단은 텍스트 입력창으로 별도의 정보를 B07로 전달되어야하는데 내가 원하는 방향은(그림_162-21_횡단배수_글.png — 배수관 원 표기 · NO.71+13 · 관보호공 · 관유입부보강 글) 구조물이 없는 경우에는 당연히 빈값이어야하고 구조물 중 횡단배수 구조물에 대해서만 미리 넣는 스크립트를 만들어주면 됨. 이때 이질감이 있을수 있으니 텍스트창이아니라 캐드창을 보여주는데 그냥 배경만 있는 캐드창이기만 하면됨. 그럼 다음B07에 테이블 상단에 배치하면 이질감이 없을것같아. 기본적으로 설계자들은 대부분 캐드 단축키를 알고 있으니 빈화면의 캐드화면이라도 문제 없어보임. 22. B05/B06 공통 좌측 패널의 구조물 조합 지정 기능에서 미지정 항목에 대해 노란새 색상이 좋기는 한데 눈에 너무 띄는것같음. 이건 구조물리스트의 램프와 같은 색상으로 가는게 좋겠어. 회색이 될것같네. 23. 이제까지는 너가 직접 코드작업을 요청했지만 이제 폭넓은 부분에 코드작업을 해야하니 너가 조사작업을 하고 계획서를 작성하고 브레인으로써 다른워크트리에게 작업을 지시해줘. 그리고 완료시 너가 작업이 누락된게 없는지 확인해줘. 코더는 자기가 작업한 코드만 검증을 하고 화면 검증은 작업완료시 내가 진행할께. 너도 필요하다고 판단되면 브라우져 검증진행해도 됨. +163. (원문 · 브레인 화면 검증 흠 다섯에 대한 답) 고칠 흠중 1. 소수점 제어 확인 2. 보류 3. 제안대로 4. 제안대로 5. 운반공의 하위에는 단가연결이 연결이 되던안되던 일단 이정표의 항목명으로 운반공에 하위에 개별로 들어와야함. From f0f35c57497d8b305b533960d2d08fc4dea61ecb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 15:16:12 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix(b09):=20=EA=B3=B5=EC=82=AC=EC=9B=90?= =?UTF-8?q?=EA=B0=80=EA=B3=84=EC=82=B0=EC=84=9C=20=EC=A7=81=EC=A0=91?= =?UTF-8?q?=EC=9E=AC=EB=A3=8C=EB=B9=84=20=EC=9D=B4=EB=8F=99=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=EC=9D=84=20=EC=B4=9D=EA=B4=84=20=EA=B0=80.=20?= =?UTF-8?q?=EC=88=9C=EA=B3=B5=EC=82=AC=EB=B9=84=EA=B3=84(direct)=EB=A1=9C?= =?UTF-8?q?=20(261-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이동 대상 키 material 이 총괄 줄에 없어 빨강 상자가 안 뜸 - 시험 추가 - 모든 원가 줄 이동 대상이 총괄 줄 키에 있는지 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY --- .../B09_Estimation_UI_Tabs_Total.ts | 2 +- .../test_261_2_b09_cost_link_targets.py | 54 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_261_2_b09_cost_link_targets.py diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index 1ea78b13f..e41a21e1b 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -271,7 +271,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => { /** 원가계산서 줄 [키(cost · 총괄 줄), 구분, 비목, 세목] */ const COST_ROWS: Array<[string, string, string, string, string?]> = [ - ["direct_material", "순공사원가", "재료비", "직접재료비", "material"], + ["direct_material", "순공사원가", "재료비", "직접재료비", "direct"], ["indirect_material", "순공사원가", "재료비", "간접재료비"], ["scrap", "순공사원가", "재료비", "작업설 · 부산물 등(△)"], ["material", "순공사원가", "재료비", "소계", "direct"], diff --git a/resources/tester/test_261_2_b09_cost_link_targets.py b/resources/tester/test_261_2_b09_cost_link_targets.py new file mode 100644 index 000000000..7b6db0414 --- /dev/null +++ b/resources/tester/test_261_2_b09_cost_link_targets.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +"""261-2 B09 공사원가계산서 줄 이동 대상 — 모든 줄의 대상 키가 총괄 줄 키에 있음(빈 대상 = 이동 없음 허용). + +직접재료비 → 총괄 「가. 순공사비계」(direct) · 직접노무비 · 산출경비와 같은 자리. +""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +B09 = ROOT / "B09_Estimation" +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) + +SUMS = {"material": 194234417, "labor": 346800554, "expense": 169324165, "supply_net": 69474220} + +BODY = f""" +const user = {{ estimate: {{}} }}; +const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, setUser: () => {{}} }}; +const T = buildTotal(ctx), C = buildCost(ctx); +console.log(JSON.stringify({{ + totalKeys: T.rows.map((r) => r.key), + cost: C.rows.map((r, i) => [r.key, C.link(i)]), +}})); +""" + + +def _run() -> dict: + names = ("Calc_Base", "Calc_Total", "UI_Tabs", "UI_Tabs_Total") + script = "".join( + IMPORTS.sub("", (B09 / f"B09_Estimation_{n}.ts").read_text(encoding="utf-8")) for n in names + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript"], + input=script + BODY, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_모든_원가_줄_이동_대상이_총괄_줄에_있음(): + got = _run() + keys = set(got["totalKeys"]) + missing = [k for k, link in got["cost"] if link and link["key"] not in keys] + assert not missing, missing + assert len(got["cost"]) > 20 + + +def test_직접재료비_이동_대상_direct(): + links = {k: link for k, link in _run()["cost"]} + assert links["direct_material"] == {"tab": "total", "key": "direct"} + assert links["direct_labor"] == links["expense"] == links["direct_material"] From b751f29b011fe5ba6eda6261fe954e894856d38f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 15:19:06 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix(b09):=20=EC=9A=B4=EB=B0=98=EA=B3=B5=20?= =?UTF-8?q?=EC=A4=84=20=EC=9D=B4=EB=A6=84=20=3D=20=EA=B1=B0=EB=A6=AC?= =?UTF-8?q?=EC=9D=B4=EC=A0=95=ED=91=9C=20=ED=95=AD=EB=AA=A9=EB=AA=85=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20=ED=96=89=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EA=B0=9C=EB=B3=84=20=EC=A4=84=20(261-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「운반」 덧붙임 없앰 · 빈 항목명 = 거래처 · 그것도 없으면 묶음 이름 · 「운반」 - 자동 상자 둘째 행부터 `haul:{묶음}.n` 개별 줄(묶음 줄 바로 뒤 · 수량 빈칸) - 시험 기대값 고침(253_1 · 251_1 · 145_3) · 새 시험 test_261_4 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU --- B08_Quantity/B08_Quantity_Haul.py | 16 ++--- B09_Estimation/B09_Estimation_Bill_Items.py | 69 ++++++++++----------- resources/tester/test_145_3_b09_bill.py | 4 +- resources/tester/test_251_1_haul_groups.py | 4 +- resources/tester/test_253_1_haul_rows.py | 10 +-- resources/tester/test_261_4_haul_names.py | 65 +++++++++++++++++++ 6 files changed, 116 insertions(+), 52 deletions(-) create mode 100644 resources/tester/test_261_4_haul_names.py diff --git a/B08_Quantity/B08_Quantity_Haul.py b/B08_Quantity/B08_Quantity_Haul.py index 5a628cb43..baf49493b 100644 --- a/B08_Quantity/B08_Quantity_Haul.py +++ b/B08_Quantity/B08_Quantity_Haul.py @@ -131,16 +131,18 @@ def with_haul_routes(data: dict[str, Any], groups: list[dict[str, Any]]) -> dict def route_bill_key(route: dict[str, Any], index: int = 0) -> str: """거리이정표 행 → B09 운반공 줄 키(253-1) — 자동 상자 = 옛 키 · 사용자 상자 = `haul:r-{id}` · - 한 상자 둘째 행부터 `haul:r-{id}.{n}`(경로 id 에 「.」 없음 — 안 겹침).""" + 한 상자 둘째 행부터 `.{n}`(261-4 자동 상자도 · 경로 id · 묶음 키에 「.」 없음 — 안 겹침).""" + tail = f".{index}" if index else "" if route.get("haul"): - return bill_key(str(route["haul"])) - return f"haul:r-{route.get('id')}" + (f".{index}" if index else "") + return bill_key(str(route["haul"])) + tail + return f"haul:r-{route.get('id')}" + tail def haul_rows(data: dict[str, Any], table: list[dict[str, Any]]) -> list[dict[str, Any]]: - """거리이정표 표 행 하나 = 운반공 줄 하나(253-1) — [{key, name, ref, user, L1, L2}] · + """거리이정표 표 행 하나 = 운반공 줄 하나(253-1 · 261-4 단가연결 무관) — + [{key, name, ref, user, haul, L1, L2}] · name = 행 항목명 그대로(없으면 거래처 · 그것도 없으면 빈칸) · L1 = 포장 계(2차 + 1차 + 포장) · L2 = 비포장(`compute_milestone` 결과) · - user = 사용자 상자(자동 상자는 총괄자재 묶음 줄이 이미 있음 · 둘째 행은 안 씀).""" + user = 사용자 상자 · haul = 자동 상자 묶음 키(첫 행은 총괄자재 묶음 줄 이름 · 거리).""" routes = {r.get("id"): r for r in data.get("routes") or []} out, seen = [], set() for row in table: @@ -149,13 +151,13 @@ def haul_rows(data: dict[str, Any], table: list[dict[str, Any]]) -> list[dict[st if key in seen: continue seen.add(key) - item = str(row.get("item") or "").strip() out.append( { "key": key, - "name": f"{item}운반" if item else "운반", + "name": str(row.get("item") or "").strip() or str(route.get("name") or "").strip(), "ref": route.get("ref"), "user": not route.get("haul"), + "haul": route.get("haul"), "L1": row.get("paved"), "L2": row.get("unpaved"), } diff --git a/B09_Estimation/B09_Estimation_Bill_Items.py b/B09_Estimation/B09_Estimation_Bill_Items.py index 72ad89487..9282a4e92 100644 --- a/B09_Estimation/B09_Estimation_Bill_Items.py +++ b/B09_Estimation/B09_Estimation_Bill_Items.py @@ -111,48 +111,45 @@ def haul_items( routes: list[dict[str, Any]] | None = None, ) -> list[dict[str, Any]]: """운반공 줄 — B08 운반 묶음(251-1 · 장비 둘 + 총괄자재 묶음 · 옛 키 haul:1~6 그대로) + - 거리이정표 사용자 상자 행(253-1 · `B08_Quantity_Haul.haul_rows` · 수량 빈칸) · + 거리이정표 행(253-1 · `B08_Quantity_Haul.haul_rows` · 행 하나 = 줄 하나 · 둘째 행부터 수량 빈칸) · + 이름 = 거리이정표 항목명 그대로(261-4 · 「운반」 안 붙임 · 비면 거래처 · 그것도 없으면 묶음 이름 · 「운반」) · 연결 = 거리이정표 단가연결(251-5 · `refs{줄 키: ref}` · 설계내역서 연결 덮개가 이김) · `dist{L1, L2}` = 그 행 거리(253-3 「운반 단가」 모달 · 없으면 None).""" - dists = {r["key"]: {"L1": r.get("L1"), "L2": r.get("L2")} for r in routes or []} + rows = routes or [] + by_key = {r["key"]: r for r in rows} + extra: dict[str, list[dict[str, Any]]] = {} # 자동 상자 둘째 행부터 — 묶음 줄 바로 뒤 + for r in rows: + if r.get("haul") and r["key"] != bill_key(str(r["haul"])): + extra.setdefault(str(r["haul"]), []).append(r) + + def line(key: str, name: str, unit: str, qty: Any, note: str) -> dict[str, Any]: + ref = (refs or {}).get(key) + route = by_key.get(key) or {} + return { + "key": key, + "section": "3", + "name": name, + "spec": "", + "unit": unit, + "qty": qty, + "note": note, + "ref": ref, + "dist": {"L1": route["L1"], "L2": route["L2"]} if route else None, + "why": WHY_LOGIC if (ref or {}).get("tab") == "logic" else WHY_HAUL, + } + out = [] for g in haul_groups(materials): - qty = g["qty"] - ref = (refs or {}).get(bill_key(g["key"])) - logic = (ref or {}).get("tab") == "logic" + key = bill_key(g["key"]) + name = (by_key.get(key) or {}).get("name") or g["name"] out.append( - { - "key": bill_key(g["key"]), - "section": "3", - "name": f"{g['name']}운반", - "spec": "", - "unit": g["unit"], - "qty": qty, - "note": "B08 총괄자재" if qty is not None else "", - "ref": ref, - "dist": dists.get(bill_key(g["key"])), - "why": WHY_LOGIC if logic else WHY_HAUL, - } - ) - for r in routes or []: - if not r.get("user"): - continue - ref = (refs or {}).get(r["key"]) - logic = (ref or {}).get("tab") == "logic" - out.append( - { - "key": r["key"], - "section": "3", - "name": r["name"], - "spec": "", - "unit": "", - "qty": None, - "note": "B08 거리이정표", - "ref": ref, - "dist": dists.get(r["key"]), - "why": WHY_LOGIC if logic else WHY_HAUL, - } + line(key, name, g["unit"], g["qty"], "B08 총괄자재" if g["qty"] is not None else "") ) + for r in extra.get(g["key"], []): + out.append(line(r["key"], r.get("name") or g["name"], "", None, "B08 거리이정표")) + for r in rows: + if r.get("user"): + out.append(line(r["key"], r.get("name") or "운반", "", None, "B08 거리이정표")) return out diff --git a/resources/tester/test_145_3_b09_bill.py b/resources/tester/test_145_3_b09_bill.py index ec6ef4aad..1113830dd 100644 --- a/resources/tester/test_145_3_b09_bill.py +++ b/resources/tester/test_145_3_b09_bill.py @@ -190,8 +190,8 @@ def test_항목_짜기_B08_수량(): "key": "pipe:revet|돌쌓기(찰)|2.0", } haul = {v["name"]: v["qty"] for k, v in items.items() if k.startswith("haul:")} - assert haul == {"중기운반": None, "기계운반": None, "모래운반": 16, "자갈운반": None, - "시멘트운반": None, "철근운반": 0.75} # fmt: skip + assert haul == {"중기": None, "기계": None, "모래": 16, "자갈": None, + "시멘트": None, "철근": 0.75} # fmt: skip 261-4 묶음 이름 그대로 def test_거울_TS_가_같은_값(): diff --git a/resources/tester/test_251_1_haul_groups.py b/resources/tester/test_251_1_haul_groups.py index 3251d7a59..e7fc2efd1 100644 --- a/resources/tester/test_251_1_haul_groups.py +++ b/resources/tester/test_251_1_haul_groups.py @@ -44,8 +44,8 @@ def test_묶음_장비_옛_여섯_늘_수량_없는_새_묶음_없음(): def test_B09_운반_줄_옛_키_그대로_새_묶음_키(): items = {i["key"]: i for i in haul_items(_mats(ROWS))} assert [bill_key(k) for k in ("equip_heavy", "sand", "rebar")] == ["haul:1", "haul:3", "haul:6"] - assert items["haul:3"]["name"] == "모래운반" and items["haul:3"]["qty"] == 16 - assert items["haul:remicon"]["name"] == "레미콘운반" and items["haul:remicon"]["unit"] == "㎥" + assert items["haul:3"]["name"] == "모래" and items["haul:3"]["qty"] == 16 + assert items["haul:remicon"]["name"] == "레미콘" and items["haul:remicon"]["unit"] == "㎥" assert all(i["section"] == "3" for i in items.values()) assert {"haul:1", "haul:2", "haul:3", "haul:4", "haul:5", "haul:6"} <= set(items) diff --git a/resources/tester/test_253_1_haul_rows.py b/resources/tester/test_253_1_haul_rows.py index 514dcf8b4..574e91208 100644 --- a/resources/tester/test_253_1_haul_rows.py +++ b/resources/tester/test_253_1_haul_rows.py @@ -37,7 +37,7 @@ def test_rows_one_per_table_row(): assert list(got) == ["haul:3", "haul:r-u1", "haul:r-u1.1"] assert (got["haul:3"]["L1"], got["haul:3"]["L2"], got["haul:3"]["user"]) == (25.1, 0, False) assert (got["haul:r-u1"]["name"], got["haul:r-u1"]["L1"], got["haul:r-u1"]["L2"]) == ( - "혼합골재운반", 10, 0.8) # fmt: skip + "혼합골재", 10, 0.8) # fmt: skip 261-4 항목명 그대로 assert got["haul:r-u1.1"]["L2"] == 2.8 # 중점 = 시점 → 중점 2 km 비포장 더함 assert got["haul:r-u1"]["ref"] == LOGIC @@ -47,14 +47,14 @@ def test_bill_items_user_rows(): extra = haul_user(rows) items = {i["key"]: i for i in haul_items(None, extra["haul_refs"], rows)} assert ( - "haul:3" in items and items["haul:3"]["name"] == "모래운반" - ) # 자동 = 총괄자재 묶음 그대로 + "haul:3" in items and items["haul:3"]["name"] == "모래" + ) # 자동 = 상자 항목명(261-4) user = items["haul:r-u1"] assert (user["section"], user["name"], user["qty"], user["ref"]) == ( "3", - "혼합골재운반", + "혼합골재", None, LOGIC, ) - assert items["haul:r-u1.1"]["name"] == "깬돌운반" and items["haul:r-u1.1"]["ref"] == LOGIC + assert items["haul:r-u1.1"]["name"] == "깬돌" and items["haul:r-u1.1"]["ref"] == LOGIC assert extra["haul_dist"]["haul:r-u1"] == {"L1": 10, "L2": 0.8} diff --git a/resources/tester/test_261_4_haul_names.py b/resources/tester/test_261_4_haul_names.py new file mode 100644 index 000000000..57163c791 --- /dev/null +++ b/resources/tester/test_261_4_haul_names.py @@ -0,0 +1,65 @@ +"""261-4 운반공 줄 이름 = 거리이정표 항목명 그대로(「운반」 안 붙임) · 행마다 개별 줄 · 단가연결 무관.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from B08_Quantity.B08_Quantity_Haul import haul_rows # noqa: E402 +from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone # noqa: E402 +from B09_Estimation.B09_Estimation_Bill_Items import haul_items # noqa: E402 +from B09_Estimation.B09_Estimation_Router import haul_user # noqa: E402 + +LOGIC = {"tab": "logic", "code": "GF000134", "name": "골재운반", "given": {}} +DATA = { + "site": {"mid_station_m": 2000, "mid_road": "비포장"}, + "base": {"enabled": False, "legs": []}, + "routes": [ + # 자동 상자 — 항목명 고침 · 둘째 행(같은 이름) + {"id": "h-sand", "name": "골재상", "haul": "sand", "via_base": False, + "legs": [{"road": "포장", "km": 25.1}], + "rows": [{"item": "강모래", "dest": "start"}, {"item": "강모래", "dest": "mid"}]}, + # 자동 상자 — 항목명 빈칸 · 거래처 없음 = 묶음 이름 + {"id": "h-gravel", "name": "", "haul": "gravel", "via_base": False, + "legs": [], "rows": [{"item": "", "dest": "start"}]}, + # 사용자 상자 — 「운반」 이 든 항목명 · 같은 이름 두 행 · 빈 항목명(거래처) · 연결 없음 + {"id": "u1", "name": "석산", "via_base": False, "legs": [{"road": "포장", "km": 10}], + "rows": [{"item": "전석운반", "dest": "start"}, {"item": "전석운반", "dest": "start"}, + {"item": " ", "dest": "start"}]}, + # 사용자 상자 — 항목명 · 거래처 모두 빈칸 · 연결 있음 + {"id": "u2", "name": "", "ref": LOGIC, "via_base": False, "legs": [], + "rows": [{"item": "", "dest": "start"}]}, + ], +} # fmt: skip + + +def _items() -> dict[str, dict]: + rows = haul_rows(DATA, compute_milestone(DATA, None)) + return {i["key"]: i for i in haul_items(None, haul_user(rows)["haul_refs"], rows)} + + +def test_자동_상자_이름_항목명(): + items = _items() + assert items["haul:3"]["name"] == "강모래" + assert items["haul:4"]["name"] == "자갈" # 항목 · 거래처 빈칸 = 묶음 이름 + assert items["haul:1"]["name"] == "중기" # 상자 없음 = 묶음 이름 + + +def test_자동_상자_둘째_행_개별_줄_묶음_바로_뒤(): + items = _items() + keys = list(items) + assert keys.index("haul:3.1") == keys.index("haul:3") + 1 + extra = items["haul:3.1"] + assert (extra["name"], extra["qty"], extra["dist"]["L2"]) == ("강모래", None, 2) + + +def test_사용자_상자_이름_항목명_같은_이름_두_줄(): + items = _items() + assert items["haul:r-u1"]["name"] == items["haul:r-u1.1"]["name"] == "전석운반" + assert items["haul:r-u1.2"]["name"] == "석산" # 빈 항목명 = 거래처 + assert items["haul:r-u2"]["name"] == "운반" and items["haul:r-u2"]["ref"] == LOGIC + assert items["haul:r-u1"]["ref"] is None # 연결 없어도 줄 + + +def test_운반운반_없음(): + assert not any("운반운반" in i["name"] for i in _items().values()) From c55f614d50e2481bc2cb6c34daba9cb1af521103 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 15:36:55 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(b06):=20=EC=83=81=EC=84=B8=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=20=E2=80=94=20=EB=B2=BD=20=C2=B7=20=EC=A7=91=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=C2=B7=20=EB=B3=B8=EC=B2=B4=20=EB=91=98=EB=A0=88?= =?UTF-8?q?=EB=A5=BC=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=84=A0=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20(261-3=20=EA=B1=B8=EC=9D=8C=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설계선 끝 ~ 깎기 비탈 밑을 집수정 둘레가 이어 깎기 암석(암선 ↔ 설계선) · 깎기 토사(지반 ↔ 암선) 영역이 따로 닫힘. 배수관 측점(검증_랩탑메인 4+15) 깎기 암석 영역 18.24㎡ 생김 · 일반 깎기 측점은 그대로(그림 칠 면적과 같음). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- B06_Section/B06_Section_UI_Cross_Design_Pick.ts | 2 +- B06_Section/B06_Section_UI_Cross_Detail_Select.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index 36575f17f..2d8958dd7 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -33,7 +33,7 @@ const HIT_CLASS = "b06-chart__design-hit"; /** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */ export const DESIGN_LINE_ATTR = "data-design-line"; /** 보라 선을 덮는 벽 · 본체 폴리곤. */ -const OCCLUDER_CLASSES = [ +export const OCCLUDER_CLASSES = [ "b06-chart__culvert-revet", "b06-chart__culvert-basin", "b06-chart__ford-slab", diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index 98f4d5eba..057f8c09c 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -6,6 +6,7 @@ * 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이 · 둘레(안 닫히면 「닫히지 않음」). * 닫힌 영역(259-2) = 조각으로 둘러싸인 면(`findFaces` · 열린 영역 제외) · 안 누름 = 그 영역(둘레 조각) * 고름 · Shift/Ctrl = 영역 더하기 · 채움 강조 · 값 = 면적 / 둘레 · 선으로 한 영역을 닫아도 채움. + * 벽 · 집수정 · 본체 둘레도 구조물 선(설계선 끝 ~ 비탈 밑을 이어 깎기 토사 · 암석 영역이 닫힘 · 261-3). * 표 칸 누름 = 저장된 조각을 다시 고름(`choose`) · 띠 = 값 글만(`note`). * 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_Cross_Detail_Table` 이 [칸에 넣기] · Enter 로 씀). * ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때. @@ -17,6 +18,7 @@ import { CROSS_PAD } from "./B06_Section_UI_Section_Common"; import { DESIGN_LINE_ATTR, mergeStraightPieces, + OCCLUDER_CLASSES, pieceLabel, } from "./B06_Section_UI_Cross_Design_Pick"; import { @@ -84,6 +86,12 @@ export function collectPieces(elements: Element[], axes: CrossAxesOptions): Spli points: points.map(([offset, elevation]) => ({ offset, elevation })), structure: !el.hasAttribute(DESIGN_LINE_ATTR), }); + else if (OCCLUDER_CLASSES.some((name) => el.classList.contains(name))) + // 벽 · 집수정 · 본체 둘레(261-3) — 설계선 끝 · 비탈 밑을 잇는 구조물 선(닫힌 고리) + runs.push({ + points: [...points, points[0]].map(([offset, elevation]) => ({ offset, elevation })), + structure: true, + }); else if (el.classList.contains("b06-chart__cross-profile")) others.push({ line: "ground", points }); else if (el.classList.contains("b06-chart__spoil-fill")) others.push({ line: "spoil", points }); From 2bf429ec98589a04cf1a75ac44702ba6fa40e7c4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 15:37:19 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(b06):=20=EC=83=81=EC=84=B8=20=EB=88=84?= =?UTF-8?q?=EB=A6=84=20=ED=8C=90=EC=A0=95=20=EA=B1=B0=EB=A6=AC=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20=EB=B3=B4=ED=86=B5=20=EB=B0=B0=EC=9C=A8=20=EC=96=87?= =?UTF-8?q?=EC=9D=80=20=EB=9D=A0=20=3D=20=EC=98=81=EC=97=AD=20=C2=B7=20?= =?UTF-8?q?=EC=84=A0=20=EA=B0=80=EA=B9=8C=EC=9D=B4=20=3D=20=EC=84=A0=20(26?= =?UTF-8?q?1-3=20=EA=B1=B8=EC=9D=8C=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 누름 = 판정 선 굵기 대신 누른 자리 거리(`pickAt`) — 선 4 px 안 = 선 · 영역 안이면 맞은편 선까지 ⅓ 안이어야 선. 선 누름이 표 띠 누름보다 앞섬(stopImmediatePropagation) · 판정 선 굵기 10 → 8 px(걸침 표시만). 시험 test_261_3_detail_pick(견본 · 실측 4+15 그림) · 233-2 헬퍼 누름 꼴 맞춤. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- .../B06_Section_UI_Cross_Detail_Select.ts | 133 +++++++--- .../B06_Section_UI_Cross_Detail_Table.ts | 7 +- .../B06_Section_UI_Style_Cross_Detail.css | 3 +- .../tester/fixtures/b06_261_3_cross_415.json | 1 + resources/tester/helper_233_2_view_pick.cjs | 23 +- resources/tester/helper_261_3_detail_pick.cjs | 85 +++++++ resources/tester/test_261_3_detail_pick.py | 237 ++++++++++++++++++ 7 files changed, 453 insertions(+), 36 deletions(-) create mode 100644 resources/tester/fixtures/b06_261_3_cross_415.json create mode 100644 resources/tester/helper_261_3_detail_pick.cjs create mode 100644 resources/tester/test_261_3_detail_pick.py diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts index 057f8c09c..f6f8f101e 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Select.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Select.ts @@ -6,7 +6,8 @@ * 하나 = 길이 · 경사비율 / 수평 · 높이 · 여럿 = 길이 합 / 닫힌 넓이 · 둘레(안 닫히면 「닫히지 않음」). * 닫힌 영역(259-2) = 조각으로 둘러싸인 면(`findFaces` · 열린 영역 제외) · 안 누름 = 그 영역(둘레 조각) * 고름 · Shift/Ctrl = 영역 더하기 · 채움 강조 · 값 = 면적 / 둘레 · 선으로 한 영역을 닫아도 채움. - * 벽 · 집수정 · 본체 둘레도 구조물 선(설계선 끝 ~ 비탈 밑을 이어 깎기 토사 · 암석 영역이 닫힘 · 261-3). + * 누름 판정(261-3) = 판정 선 굵기 대신 누른 자리 거리(`pickAt`) — 보통 배율 얇은 띠도 영역 · + * 벽 · 집수정 · 본체 둘레도 구조물 선(설계선 끝 ~ 비탈 밑을 이어 깎기 토사 · 암석 영역이 닫힘). * 표 칸 누름 = 저장된 조각을 다시 고름(`choose`) · 띠 = 값 글만(`note`). * 칸에 넣기 = 표 우클릭 「그래프에서 고르기」 동안만(`_Cross_Detail_Table` 이 [칸에 넣기] · Enter 로 씀). * ponytail: 그림을 다시 그리면(칸 크기 · 카드 다시 그림) 고름을 지움 — 조각 키 기억은 필요할 때. @@ -49,12 +50,13 @@ export function pxToM(axes: CrossAxesOptions): (p: number[]) => Pt { }; } -function mToPx(axes: CrossAxesOptions): (p: Pt) => string { +/** m → 그림 px(`join` 하면 `x,y`). */ +function mToPx(axes: CrossAxesOptions): (p: Pt) => Pt { return ([offset, elevation]) => { const display = axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration; const x = CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter; const y = CROSS_PAD.top + (axes.displayMax - display) * axes.pixelsPerMeter; - return `${x},${y}`; + return [x, y]; }; } @@ -208,6 +210,67 @@ export interface DetailSelect { detach: () => void; } +/** 선 누름 거리(화면 px) — 이 안이면 선 · 영역 안이면 맞은편 선까지의 ⅓ 안이어야 선(261-3). */ +export const LINE_PICK_PX = 4; + +const segmentGap = (p: Pt, a: Pt, b: Pt): number => { + const d = [b[0] - a[0], b[1] - a[1]]; + const size2 = d[0] ** 2 + d[1] ** 2; + const t = size2 + ? Math.max(0, Math.min(1, ((p[0] - a[0]) * d[0] + (p[1] - a[1]) * d[1]) / size2)) + : 0; + return Math.hypot(p[0] - a[0] - t * d[0], p[1] - a[1] - t * d[1]); +}; +const pathGap = (p: Pt, path: Pt[]): number => + Math.min(...path.slice(1).map((q, i) => segmentGap(p, path[i], q))); +const inside = (p: Pt, ring: Pt[]): boolean => + ring.reduce((inn, a, i) => { + const b = ring[(i + 1) % ring.length]; + return a[1] > p[1] !== b[1] > p[1] && + p[0] < a[0] + ((p[1] - a[1]) * (b[0] - a[0])) / (b[1] - a[1]) + ? !inn + : inn; + }, false); + +/** + * 누른 자리 → 선 · 영역(261-3) — 좌표는 그림 px · `scale` = 화면 px / 그림 px · `areas` = 영역 넓이. + * 가장 가까운 선이 `LINE_PICK_PX` 안이면 선 — 단 영역 안이면 그 선과 끝이 안 닿는 맞은편 선보다 + * 절반 이하로 가까워야 선(얇은 띠 가운데 ⅓ = 영역) · 아니면 누른 자리를 품은 가장 작은 영역. + */ +export function pickAt( + point: Pt, + scale: number, + paths: Pt[][], + rings: Pt[][], + areas: number[], + pieces: SplitPiece[], +): { piece: number } | { face: number } | null { + const gaps = paths.map((path) => pathGap(point, path) * scale); + const nearest = gaps.reduce((best, gap, i) => (gap < gaps[best] ? i : best), 0); + const ends = (piece: SplitPiece): Pt[] => [ + piece.points[0], + piece.points[piece.points.length - 1], + ]; + const touches = (a: SplitPiece, b: SplitPiece): boolean => + ends(a).some((p) => + ends(b).some((q) => Math.hypot(p[0] - q[0], p[1] - q[1]) <= LINE_TOLERANCE_M), + ); + const across = Math.min( + Infinity, + ...gaps.filter((_, i) => i !== nearest && !touches(pieces[i], pieces[nearest])), + ); + const face = rings + .flatMap((ring, i) => (inside(point, ring) ? [i] : [])) + .sort((a, b) => areas[a] - areas[b])[0]; + if ( + paths.length && + gaps[nearest] <= LINE_PICK_PX && + (face === undefined || gaps[nearest] * 2 <= across) + ) + return { piece: nearest }; + return face === undefined ? null : { face }; +} + /** 조각의 자리 이름 — 선 종류 + mm 로 맞춘 꼭짓점(px → m 되짚기의 부동소수 흔들림 거름). */ const pieceKey = (piece: SplitPiece | undefined): string => piece ? `${piece.line}|${piece.points.map((p) => p.map((v) => v.toFixed(3))).join(";")}` : ""; @@ -228,6 +291,10 @@ export function attachDetailSelect(input: { let hits: SVGPolylineElement[] = []; let faces: Face[] = []; let faceEls: SVGPolygonElement[] = []; + /** 그림 층 · 조각 · 영역 고리(그림 px) — 누름 판정. */ + let layer: SVGGElement | null = null; + let paths: Pt[][] = []; + let rings: Pt[][] = []; let selected: number[] = []; /** 영역 누름으로 고른 면 — 비면 고른 조각이 한 면을 꼭 닫을 때 그 면. */ let chosenFaces: number[] = []; @@ -270,18 +337,25 @@ export function attachDetailSelect(input: { if (bar && label.parentElement !== bar) bar.prepend(label, put); }; + /** 누른 자리(그림 안만) → 선 · 영역 — 판정 선 굵기 대신 거리로(261-3). */ + const pickOf = (event: MouseEvent): ReturnType => { + const ctm = layer?.getScreenCTM(); + if (!ctm || !layer?.ownerSVGElement?.contains(event.target as Node)) return null; + const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(ctm.inverse()); + const areas = faces.map((face) => face.area); + return pickAt([p.x, p.y], Math.hypot(ctm.a, ctm.b), paths, rings, areas, pieces); + }; + const onClick = (event: MouseEvent): void => { - const target = event.target as Element; - const hit = target.closest?.(`.${PIECE_HIT_CLASS}`); + const pick = pickOf(event); const shift = event.shiftKey || event.ctrlKey || event.metaKey; const additive = shift || mode === "area"; noteText = null; - if (!hit) { - const face = target.closest?.(`.${FACE_HIT_CLASS}`); - if (face && !(mode === "area" && input.band?.(event))) { + if (!pick || !("piece" in pick)) { + if (pick && !(mode === "area" && input.band?.(event))) { // 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter) event.stopPropagation(); - const index = faceEls.indexOf(face as SVGPolygonElement); + const index = pick.face; chosenFaces = toggleSelection(chosenFaces, index, shift); selected = regionEdges(chosenFaces.map((i) => faces[i])); show(); @@ -292,9 +366,10 @@ export function attachDetailSelect(input: { show(); return; } - event.stopPropagation(); + // 선이 표 띠 누름(같은 cad 의 다음 듣기)보다 앞섬 + event.stopImmediatePropagation(); chosenFaces = []; - selected = toggleSelection(selected, hits.indexOf(hit as SVGPolylineElement), additive); + selected = toggleSelection(selected, pick.piece, additive); show(); input.picked(additive); }; @@ -305,31 +380,29 @@ export function attachDetailSelect(input: { // 다시 그려도(창 크기 · 카드 다시 세움) 같은 자리 조각은 고름 유지 — 지우면 바로 꺼졌음(233-2) const keep = new Set(selected.map((i) => pieceKey(pieces[i]))); const axes = input.axes(); - const layer = cad.querySelector("g.b06-chart__plot-layer"); + layer = cad.querySelector("g.b06-chart__plot-layer"); pieces = axes && layer ? collectPieces([...layer.querySelectorAll("*")], axes) : []; selected = pieces.flatMap((piece, i) => (keep.has(pieceKey(piece)) ? [i] : [])); chosenFaces = []; faces = findFaces(pieces); const toPx = axes ? mToPx(axes) : null; + paths = toPx ? pieces.map((piece) => piece.points.map(toPx)) : []; + rings = toPx ? faces.map((face) => face.ring.map(toPx)) : []; // 영역 판정 면 먼저(밑) → 조각 판정 선(위) - faceEls = toPx - ? faces.map((face) => { - const el = document.createElementNS(SVG_NS, "polygon"); - el.setAttribute("points", face.ring.map(toPx).join(" ")); - el.setAttribute("class", FACE_HIT_CLASS); - layer?.append(el); - return el; - }) - : []; - hits = toPx - ? pieces.map((piece) => { - const hit = document.createElementNS(SVG_NS, "polyline"); - hit.setAttribute("points", piece.points.map(toPx).join(" ")); - hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${piece.line}`); - layer?.append(hit); - return hit; - }) - : []; + faceEls = rings.map((ring) => { + const el = document.createElementNS(SVG_NS, "polygon"); + el.setAttribute("points", ring.join(" ")); + el.setAttribute("class", FACE_HIT_CLASS); + layer?.append(el); + return el; + }); + hits = paths.map((path, i) => { + const hit = document.createElementNS(SVG_NS, "polyline"); + hit.setAttribute("points", path.join(" ")); + hit.setAttribute("class", `${PIECE_HIT_CLASS} ${PIECE_HIT_CLASS}--${pieces[i].line}`); + layer?.append(hit); + return hit; + }); show(); }, setMode(kind) { diff --git a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts index 65cc99823..366264f28 100644 --- a/B06_Section/B06_Section_UI_Cross_Detail_Table.ts +++ b/B06_Section/B06_Section_UI_Cross_Detail_Table.ts @@ -24,7 +24,7 @@ import type { import type { CrossPickPiece, SectionSample } from "./B06_Section_Api_Types"; import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes"; import { pickLines, piecesValue } from "./B06_Section_UI_Cross_Detail_Geom"; -import { attachDetailSelect, PIECE_HIT_CLASS } from "./B06_Section_UI_Cross_Detail_Select"; +import { attachDetailSelect } from "./B06_Section_UI_Cross_Detail_Select"; /** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */ export const MAX_ROWS = 12; @@ -393,10 +393,9 @@ export function attachDetailTable(host: DetailTableHost): { document .elementsFromPoint(event.clientX, event.clientY) .find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR)); - // 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로 · 영역보다 앞) — 선 조각 누름은 위가 맡음 + // 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로 · 영역보다 앞) — 선 누름은 위가 막음(261-3 거리 판정) const onPick = (event: MouseEvent): void => { - if (picking?.kind !== "area" || (event.target as Element).closest(`.${PIECE_HIT_CLASS}`)) - return; + if (picking?.kind !== "area") return; const hit = bandAt(event); const source = hit ? areaSourceOf(hit) : null; if (!source) return; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css index be3ea6726..f9507d873 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Detail.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -185,7 +185,8 @@ .b06-detail__piece { fill: none; stroke: transparent; - stroke-width: 10; + /* 누름 판정은 거리(LINE_PICK_PX 4) — 굵기는 걸침 표시만 */ + stroke-width: 8; stroke-linecap: round; vector-effect: non-scaling-stroke; pointer-events: stroke; diff --git a/resources/tester/fixtures/b06_261_3_cross_415.json b/resources/tester/fixtures/b06_261_3_cross_415.json new file mode 100644 index 000000000..3e3c74f6e --- /dev/null +++ b/resources/tester/fixtures/b06_261_3_cross_415.json @@ -0,0 +1 @@ +[{"tag":"polyline","cls":"b06-chart__cross-profile","dl":false,"pts":[[949.954642190761,98.34482758620575],[943.9057761359919,103.41302728071172],[937.856910081223,108.574113362354],[931.8080440264539,113.79092159809379],[925.7591779716848,116.3634075493297],[919.7103119169159,116.29158331379654],[913.6614458621468,119.40724533901843],[907.6125798073778,128.6081150062668],[901.5637137526088,136.825681007652],[895.5148476978397,140.7823168804701],[889.4659816430707,144.57612937682438],[883.4171155883017,145.92762751944147],[877.3682495335327,147.27912566205856],[871.3193834787636,149.41340756082334],[865.2705174239946,151.6286595805964],[859.2216513692256,160.15366524808218],[853.1727853144566,170.78189793600617],[847.1239192596876,176.7764088804081],[841.0750532049185,179.99066742232273],[835.0261871501496,182.80751546443977],[828.9773210953805,185.17395284238637],[822.9284550406114,188.64809486547938],[816.8795889858425,194.55915565378862],[810.8307229310734,201.03251903054885],[804.7818568763043,209.94251082695544],[798.7329908215354,218.7469378129739],[792.6841247667663,224.27866211325696],[786.6352587119974,229.81038641354002],[780.5863926572283,240.940372540707],[774.5375266024593,252.87010344444045],[768.4886605476903,262.9864810868107],[762.4397944929212,272.4983955443274],[756.3909284381522,278.89896686698546],[750.3420623833832,283.1707123680587],[744.2931963286142,286.96572253989297],[738.2443302738451,290.14777691893215],[732.1954642190761,293.9478439427878],[726.1465981643071,299.107502492042],[720.097732109538,304.0510471548911],[714.0488660547691,307.82759618752465],[708,311.6041452201596],[701.951133945231,315.9430573299059],[695.902267890462,320.2819694396523],[689.8534018356929,323.72677446237947],[683.804535780924,327.04386372722547],[677.7556697261549,330.0219261474343],[671.7068036713858,332.86733693506136],[665.6579376166169,335.10343334726014],[659.6090715618478,336.86563739726404],[653.5602055070788,338.91952986616036],[647.5113394523098,341.34845203045313],[641.4624733975407,343.8562151149041],[635.4136073427718,346.56547802537034],[629.3647412880027,349.78795092738864],[623.3158752332337,356.6029058679937],[617.2670091784647,363.4178608085988],[611.2181431236957,368.15345755421663],[605.1692770689266,372.88905429983583],[599.1204110141576,374.600193822605],[593.0715449593886,375.75124464189906],[587.0226789046195,376.6134500093462],[580.9738128498506,377.3443707879392],[574.9249467950815,381.8309962067428],[568.8760807403125,389.2387158252522],[562.8272146855435,396.575312876689],[556.7783486307744,403.8079178229121],[550.7294825760055,409.86913566216543],[544.6806165212364,412.4161316918462],[538.6317504664673,415.7644452032679],[532.5828844116984,424.7219689891325],[526.5340183569293,433.67948067726644],[520.4851523021603,436.17899310841756],[514.4362862473913,438.47018259264297],[508.38742019262224,445.02930679232855],[502.33855413785324,452.5733436562465],[496.28968808308423,456.2258788370261],[490.24082202831516,458.10954411740977],[484.19195597354616,460.61677490164794],[478.14308991877715,463.67421054222643],[472.0942238640081,468.8769491155226],[466.0453578092391,477.65517241379195]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[732.1954642190761,325.66273823867266],[726.1465981643071,325.57200524785054]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[726.1465981643071,325.57200524785054],[720.097732109538,325.48127225702837]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[720.097732109538,325.48127225702837],[714.0488660547691,325.3905392662076]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[714.0488660547691,325.3905392662076],[708,325.2998062753855]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[708,325.2998062753855],[701.951133945231,325.20907328456474]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[701.951133945231,325.20907328456474],[695.902267890462,325.11834029374256]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[695.902267890462,325.11834029374256],[689.8534018356929,325.0276073029218]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[689.8534018356929,325.0276073029218],[683.804535780924,324.9368743120997]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[683.804535780924,324.9368743120997],[674.1263500932936,333.0020290517912]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[674.1263500932936,333.0020290517912],[665.7789149377124,342.6802147394211]]},{"tag":"line","cls":"b06-chart__design-cross","dl":true,"pts":[[665.7789149377124,342.6802147394211],[641.2227165723032,343.9671505633862]]},{"tag":"polyline","cls":"b06-chart__rock-boundary","dl":false,"pts":[[949.954642190761,104.39369364097477],[943.9057761359919,109.46189333548074],[937.856910081223,114.62297941712302],[931.8080440264539,119.83978765286282],[925.7591779716848,122.41227360409871],[919.7103119169159,122.34044936856556],[913.6614458621468,125.45611139378745],[907.6125798073778,134.65698106103582],[901.5637137526088,142.87454706242102],[895.5148476978397,146.8311829352391],[889.4659816430707,150.62499543159342],[883.4171155883017,151.9764935742105],[877.3682495335327,153.3279917168276],[871.3193834787636,155.46227361559238],[865.2705174239946,157.67752563536544],[859.2216513692256,166.20253130285118],[853.1727853144566,176.83076399077518],[847.1239192596876,182.82527493517713],[841.0750532049185,186.03953347709177],[835.0261871501496,188.8563815192088],[828.9773210953805,191.22281889715538],[822.9284550406114,194.6969609202484],[816.8795889858425,200.60802170855763],[810.8307229310734,207.0813850853179],[804.7818568763043,215.99137688172448],[798.7329908215354,224.79580386774293],[792.6841247667663,230.327528168026],[786.6352587119974,235.85925246830902],[780.5863926572283,246.98923859547602],[774.5375266024593,258.9189694992094],[768.4886605476903,269.03534714157973],[762.4397944929212,278.5472615990965],[756.3909284381522,284.94783292175447],[750.3420623833832,289.21957842282774],[744.2931963286142,293.014588594662],[738.2443302738451,296.19664297370116],[732.1954642190761,299.9967099975568],[726.1465981643071,305.15636854681105],[720.097732109538,310.0999132096601],[714.0488660547691,313.87646224229366],[708,317.6530112749286],[701.951133945231,321.991923384675],[695.902267890462,326.3308354944213],[689.8534018356929,329.7756405171485],[683.804535780924,333.0927297819945],[677.7556697261549,336.07079220220334],[671.7068036713858,338.91620298983037],[665.6579376166169,341.1522994020292],[659.6090715618478,342.91450345203305],[653.5602055070788,344.96839592092937],[647.5113394523098,347.39731808522214],[641.4624733975407,349.90508116967317],[635.4136073427718,352.61434408013935],[629.3647412880027,355.83681698215764],[623.3158752332337,362.65177192276275],[617.2670091784647,369.4667268633678],[611.2181431236957,374.20232360898564],[605.1692770689266,378.93792035460484],[599.1204110141576,380.649059877374],[593.0715449593886,381.8001106966681],[587.0226789046195,382.6623160641152],[580.9738128498506,383.39323684270823],[574.9249467950815,387.8798622615118],[568.8760807403125,395.2875818800212],[562.8272146855435,402.624178931458],[556.7783486307744,409.8567838776811],[550.7294825760055,415.91800171693444],[544.6806165212364,418.46499774661527],[538.6317504664673,421.81331125803695],[532.5828844116984,430.7708350439015],[526.5340183569293,439.72834673203545],[520.4851523021603,442.22785916318657],[514.4362862473913,444.51904864741203],[508.38742019262224,451.07817284709756],[502.33855413785324,458.6222097110155],[496.28968808308423,462.27474489179514],[490.24082202831516,464.1584101721788],[484.19195597354616,466.66564095641695],[478.14308991877715,469.7230765969955],[472.0942238640081,474.9258151702916],[466.0453578092391,483.704038468561]]},{"tag":"polygon","cls":"b06-chart__culvert-basin","dl":false,"pts":[[736.913579741796,343.80933640297974],[732.1954642190761,325.66273823867266],[734.6150106409838,325.66273823867266],[739.3331261637036,343.80933640297974]]},{"tag":"polygon","cls":"b06-chart__culvert-basin","dl":false,"pts":[[738.7040440940075,341.3897899810716],[753.221322625453,341.3897899810716],[752.4954586988808,343.80933640297974],[739.3331261637036,343.80933640297974]]},{"tag":"polyline","cls":"b06-chart__design-cross","dl":false,"pts":[[753.221322625453,341.3897899810716],[801.4280127212174,220.8730647416608],[843.7112234665693,178.58985399634633]]},{"tag":"polyline","cls":"b06-chart__design-cross","dl":false,"pts":[[665.7789149377124,342.6802147394211],[641.2227165723032,343.9671505633862]]},{"tag":"polygon","cls":"b06-chart__culvert-revet is-selectable","dl":false,"pts":[[676.8483398179395,348.72908079419017],[676.8483398179395,333.0020290517912],[668.6823706440014,333.0020290517912],[663.9642551212817,348.72908079419017]]}] diff --git a/resources/tester/helper_233_2_view_pick.cjs b/resources/tester/helper_233_2_view_pick.cjs index 0158756a5..f4c8623ca 100644 --- a/resources/tester/helper_233_2_view_pick.cjs +++ b/resources/tester/helper_233_2_view_pick.cjs @@ -275,6 +275,17 @@ const layerChildren = []; const layer = { querySelectorAll: () => [...layerChildren], append: (hit) => layerChildren.push(hit), + // 261-3 누름 = 거리 판정 — 화면 좌표 = 그림 px(배율 1) + getScreenCTM: () => ({ a: 1, b: 0, inverse: () => null }), + ownerSVGElement: { contains: () => true }, +}; +globalThis.DOMPoint = class { + constructor(x, y) { + [this.x, this.y] = [x, y]; + } + matrixTransform() { + return this; + } }; const drawGround = () => { layerChildren.length = 0; @@ -302,7 +313,17 @@ const select = DTS.attachDetailSelect({ drawGround(); select.redraw(); const hit = layerChildren.find((el) => el.classes.has(DTS.PIECE_HIT_CLASS)); -click({ target: hit, shiftKey: false, ctrlKey: false, metaKey: false, stopPropagation() {} }); +const [clientX, clientY] = hit.getAttribute("points").split(" ")[0].split(",").map(Number); +click({ + target: hit, + clientX, + clientY, + shiftKey: false, + ctrlKey: false, + metaKey: false, + stopPropagation() {}, + stopImmediatePropagation() {}, +}); out.detailPicked = select.source("length")?.pieces.length ?? 0; axes = { ...axes, pixelsPerMeter: 23 }; // 창 크기 바뀜 → 다시 그림 drawGround(); diff --git a/resources/tester/helper_261_3_detail_pick.cjs b/resources/tester/helper_261_3_detail_pick.cjs new file mode 100644 index 000000000..5e913780e --- /dev/null +++ b/resources/tester/helper_261_3_detail_pick.cjs @@ -0,0 +1,85 @@ +/* PLAN 261-3 단위검증 헬퍼 — [상세] 누름 판정(거리 · 얇은 띠) · 구조물 둘레로 닫히는 깎기 영역. + * 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 표준입력 JSON + * {cases: [{name, axes, elements: [{tag, cls, dl, pts(그림 px)}], clicks: [[x, y, scale]]}]} · + * 결과 = 표준출력 JSON {이름: {pieces, faces, clicks}}. 짝 = `test_261_3_detail_pick.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B06 = path.join(ROOT, "B06_Section"); +const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8"); +const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)(); +globalThis.window = { sessionStorage: new Map(), localStorage: new Map() }; +globalThis.document = { addEventListener: () => {}, removeEventListener: () => {} }; + +const STUBS = { + "@ui/ui_template_context_menu": { createMapContextMenu: () => ({}) }, + "./B06_Section_UI_Section_Common": { CROSS_PAD }, +}; +const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base); +function loadTs(full) { + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.endsWith(".css")) return {}; + if (request.startsWith("@util/")) + return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6)))); + return loadTs(resolve(path.join(path.dirname(full), request))); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const G = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Geom.ts")); +const S = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail_Select.ts")); + +const element = (e) => ({ + tagName: e.tag, + classList: { contains: (name) => e.cls.split(/\s+/).includes(name) }, + hasAttribute: (name) => name === "data-design-line" && e.dl, + getAttribute: (name) => { + if (name === "points") return e.pts.map((p) => p.join(",")).join(" "); + const m = /^([xy])([12])$/.exec(name); + return m ? String(e.pts[+m[2] - 1][m[1] === "x" ? 0 : 1]) : null; + }, +}); + +const { cases } = JSON.parse(fs.readFileSync(0, "utf8")); +const out = {}; +for (const { name, axes, elements, clicks } of cases) { + const pieces = S.collectPieces(elements.map(element), axes); + const faces = G.findFaces(pieces); + const toPx = ([offset, elevation]) => [ + CROSS_PAD.left + (axes.maxOffset - offset) * axes.pixelsPerMeter, + CROSS_PAD.top + + (axes.displayMax - + (axes.elevationMid + (elevation - axes.elevationMid) * axes.exaggeration)) * + axes.pixelsPerMeter, + ]; + const paths = pieces.map((piece) => piece.points.map(toPx)); + const rings = faces.map((face) => face.ring.map(toPx)); + const areas = faces.map((face) => face.area); + out[name] = { + pieces: pieces.map((piece) => ({ line: piece.line, points: piece.points })), + faces: faces.map((face) => { + const edges = face.edges.map((i) => pieces[i]); + const source = S.selectionSource("area", edges); + return { + area: face.area, + edges: face.edges, + lines: [...new Set(edges.map((piece) => piece.line))].sort(), + ring: face.ring, + source, + }; + }), + clicks: (clicks ?? []).map(([x, y, scale]) => + S.pickAt([x, y], scale, paths, rings, areas, pieces), + ), + px: { paths, rings }, + }; +} +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_261_3_detail_pick.py b/resources/tester/test_261_3_detail_pick.py new file mode 100644 index 000000000..91779b7ba --- /dev/null +++ b/resources/tester/test_261_3_detail_pick.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- +"""PLAN 261-3 — B06 [상세] 영역 고르기: 보통 배율 얇은 띠 누름 · 깎기 토사 / 암석 영역 나눔. + +못박는 것 + - 누름 판정 = 거리(`pickAt`) — 얇은 띠(6 px) 가운데 = 영역 · 선 1 px = 선 · 영역 밖 선 가까이 = 선 · + 넓은 영역은 선 4 px 안 = 선 · 확대 · 축소 배율에서도 같은 판정. + - 벽 · 집수정 둘레 = 구조물 선 — 설계선 끝 ~ 깎기 비탈 밑을 이어 깎기 암석(암선 ↔ 설계선) · + 깎기 토사(지반 ↔ 암선) 영역이 따로 닫힘 · 면적 = 사다리꼴 셈 · 서버 거울. + - 실측 그림(검증_랩탑메인 4+15 배수관 · 암 · 깎기 비탈) — 깎기 암석 영역이 생김 · 보통 배율 띠 누름 = 영역. + +./venv/Scripts/python.exe -m pytest resources/tester/test_261_3_detail_pick.py -q +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +import pytest + +from B07_DesignDetail.B07_DesignDetail_Engine_Cross_Table_Edit import cell_value + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +PAD = { + k: float(v) + for k, v in re.findall( + r"(\w+): (\d+)", + re.search( + r"export const CROSS_PAD = \{([^}]*)\}", + (ROOT / "B06_Section" / "B06_Section_UI_Section_Common.ts").read_text(encoding="utf-8"), + ).group(1), + ) +} + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치" +) + +# ── 견본: 암 지반(암선 = 지반 − 0.6 m · 보통 배율 6 px 띠) + 오른쪽 설계 비탈 + 왼쪽 집수정 · 깎기 비탈 ── +AXES = { + "pixelsPerMeter": 10, + "maxOffset": 14, + "displayMax": 106, + "elevationMid": 100, + "exaggeration": 1, +} +ROCK = -0.6 +DESIGN = { + "design_elevation_m": 98.0, + "geometry_preset": "rock", + "rock_boundary_offset_m": ROCK, + "design_line": [{"offset_m": x, "elevation_m": y} for x, y in [(6, 104), (2, 98), (-2, 98)]], +} +SAMPLES = [{"offset_m": x, "elevation_m": 100.0, "valid": True} for x in (-10, -5, 0, 5, 10)] + + +def px(offset: float, elevation: float) -> list[float]: + return [ + PAD["left"] + (AXES["maxOffset"] - offset) * AXES["pixelsPerMeter"], + PAD["top"] + (AXES["displayMax"] - elevation) * AXES["pixelsPerMeter"], + ] + + +def el(tag: str, cls: str, pts, dl: bool = False) -> dict: + return {"tag": tag, "cls": cls, "dl": dl, "pts": [px(*p) for p in pts]} + + +GROUND = [(x, 100.0) for x in (-10, -5, 0, 5, 10)] +ELEMENTS = [ + el("polyline", "b06-chart__cross-profile", GROUND), + el("polyline", "b06-chart__rock-boundary", [(x, y + ROCK) for x, y in GROUND]), + el("line", "b06-chart__design-cross", [(6, 104), (2, 98)], dl=True), + el("line", "b06-chart__design-cross", [(2, 98), (-2, 98)], dl=True), + el("polygon", "b06-chart__culvert-basin", [(-2, 98), (-2, 97), (-3, 97), (-3, 98)]), + el("polyline", "b06-chart__design-cross", [(-3, 98), (-4, 100)]), # 집수정 절토선(구조물) +] +RIGHT_ROCK = 2 + 1.4 * 4 / 6 # 설계 비탈(4 : 6) × 암선 99.4 +RIGHT_GROUND = 2 + 2 * 4 / 6 # 설계 비탈 × 지반 100 +LEFT_ROCK = -3 - 1.4 / 2 # 깎기 비탈(1 : 0.5) × 암선 +LEFT_GROUND = -4.0 +CUT_ROCK = ((RIGHT_ROCK - LEFT_ROCK) + 5) / 2 * 1.4 # 암선 ↔ 노면 · 집수정 윗변(-3 ~ 2) +CUT_SOIL = ((RIGHT_GROUND - LEFT_GROUND) + (RIGHT_ROCK - LEFT_ROCK)) / 2 * 0.6 + +# 누름(그림 px · 배율) — 지반 y 70 · 암선 y 76 · 노면 y 90 +CLICKS = [ + [198, 73, 1], # 띠 가운데 = 영역(토사) + [198, 71, 1], # 지반선 1 px = 선 + [198, 75, 1], # 암선 1 px = 선 + [198, 79, 1], # 넓은 암석 영역 · 암선 3 px = 선 + [198, 81, 1], # 암선 5 px = 영역(암석) + [198, 67, 1], # 영역 밖 · 지반선 3 px = 선 + [198, 60, 1], # 영역 밖 · 먼 곳 = 없음 + [198, 73, 3.7], # 확대 — 띠 가운데 = 영역 + [198, 73, 0.5], # 축소 — 띠 가운데 = 영역 +] + + +def run(cases: list[dict]) -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_261_3_detail_pick.cjs")], + input=json.dumps({"cases": cases}), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + cwd=os.fspath(ROOT), + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def out() -> dict: + basinless = [e for e in ELEMENTS if "basin" not in e["cls"]] + return run( + [ + {"name": "sample", "axes": AXES, "elements": ELEMENTS, "clicks": CLICKS}, + {"name": "basinless", "axes": AXES, "elements": basinless}, + ] + ) + + +def face_of(case: dict, lines: list[str]) -> tuple[int, dict]: + found = [(i, f) for i, f in enumerate(case["faces"]) if f["lines"] == lines] + assert len(found) == 1, [f["lines"] for f in case["faces"]] + return found[0] + + +def piece_line(case: dict, pick: dict) -> str: + return case["pieces"][pick["piece"]]["line"] + + +# ── 깎기 토사 · 암석 영역 따로 ───────────────────────────────────────────── + + +def test_깎기_암석_토사_영역_따로_면적(out): + case = out["sample"] + _, rock = face_of(case, ["design", "rock", "structure"]) + _, soil = face_of(case, ["design", "ground", "rock", "structure"]) + assert rock["area"] == pytest.approx(CUT_ROCK, abs=1e-6) + assert soil["area"] == pytest.approx(CUT_SOIL, abs=1e-6) + # 집수정 안 = 따로(넓이 1) · 셋뿐(열린 지반 · 암선 바깥 제외) + assert sorted(round(f["area"], 6) for f in case["faces"]) == sorted( + [round(CUT_ROCK, 6), round(CUT_SOIL, 6), 1.0] + ) + + +def test_비탈_끝이_지반선에_닿기만_해도_나눔(out): + ground_ends = { + round(p["points"][k][0], 4) + for p in out["sample"]["pieces"] + if p["line"] == "ground" + for k in (0, -1) + } + assert {LEFT_GROUND, round(RIGHT_GROUND, 4)} <= ground_ends + + +def test_집수정_없으면_암석_영역_안_닫힘(out): + # 고침 전 꼴 — 설계선 끝 ~ 비탈 밑이 안 이어져 토사 띠만 남음 + faces = out["basinless"]["faces"] + assert [f["lines"] for f in faces] == [["design", "ground", "rock", "structure"]] + assert faces[0]["area"] == pytest.approx(CUT_SOIL, abs=1e-6) + + +def test_암석_영역_서버_거울(out): + _, rock = face_of(out["sample"], ["design", "rock", "structure"]) + number, _text, again = cell_value(rock["source"], DESIGN, SAMPLES) + assert not again and number == pytest.approx(CUT_ROCK, abs=1e-3) + + +# ── 누름 판정(거리) ────────────────────────────────────────────────────── + + +def test_얇은_띠_보통_배율_가운데_누름_영역(out): + case = out["sample"] + soil, _ = face_of(case, ["design", "ground", "rock", "structure"]) + rock, _ = face_of(case, ["design", "rock", "structure"]) + picks = case["clicks"] + assert picks[0] == {"face": soil} + assert picks[4] == {"face": rock} + assert picks[7] == {"face": soil} and picks[8] == {"face": soil} + + +def test_선_가까이_누름_선(out): + case = out["sample"] + picks = case["clicks"] + assert piece_line(case, picks[1]) == "ground" + assert piece_line(case, picks[2]) == "rock" + assert piece_line(case, picks[3]) == "rock" + assert piece_line(case, picks[5]) == "ground" + assert picks[6] is None + + +# ── 실측 그림(검증_랩탑메인 4+15) ───────────────────────────────────────── + + +@pytest.fixture(scope="module") +def real() -> dict: + elements = json.loads( + (HERE / "fixtures" / "b06_261_3_cross_415.json").read_text(encoding="utf-8") + ) + ppm = 241.954642190761 / 20 # 눈금 20 m = 241.95 px · 과장 1 + axes = { + "pixelsPerMeter": ppm, + "maxOffset": (708 - PAD["left"]) / ppm, # 0 m 눈금 x 708 + "displayMax": 875 + (318.52362895766817 - PAD["top"]) / ppm, # 875 m 눈금 y 318.52 + "elevationMid": 870, + "exaggeration": 1, + } + case = {"name": "real", "axes": axes, "elements": elements} + first = run([case])["real"] + band = [(i, f) for i, f in enumerate(first["faces"]) if "ground" in f["lines"]] + soil, face = max(band, key=lambda item: item[1]["area"]) # 깎기 토사 띠 + # 띠의 지반 조각(가장 긴 것) 가운데 꼭짓점 — 그 밑 3 px(띠 가운데) · 1 px(지반선) + ground = max( + (i for i in face["edges"] if first["pieces"][i]["line"] == "ground"), + key=lambda i: len(first["pieces"][i]["points"]), + ) + path = first["px"]["paths"][ground] + x, y = path[len(path) // 2] + case["clicks"] = [[x, y + 3, 1], [x, y + 1, 1]] + return {**run([case])["real"], "soil": soil, "ground": ground} + + +def test_실측_깎기_암석_영역_생김(real): + rock = [f for f in real["faces"] if f["lines"] == ["design", "rock", "structure"]] + assert rock and max(f["area"] for f in rock) == pytest.approx(18.24, abs=0.05) + soil = real["faces"][real["soil"]] + assert soil["lines"] == ["design", "ground", "rock", "structure"] + assert soil["area"] == pytest.approx(6.00, abs=0.05) + + +def test_실측_보통_배율_띠_누름(real): + assert real["clicks"][0] == {"face": real["soil"]} + assert real["clicks"][1] == {"piece": real["ground"]}