From cedc6a39a22a1057e47a7440f5bd14f1403e193b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 23:36:09 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(=EC=84=A4=EC=A0=95):=20[=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=EA=B3=B3=20=EB=8B=A8=EC=B6=95=ED=82=A4]=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=20=EC=B9=B8=20=EB=B9=88=20=EC=A4=84=20?= =?UTF-8?q?=EB=84=B7=EC=97=90=20=E3=80=8C=ED=82=A4=20=3D=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=E3=80=8D=20=EC=B1=84=EC=9B=80=20=C2=B7=20=EC=A4=84?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EB=8F=99=EC=9E=91=20=EC=9E=88=EB=8A=94?= =?UTF-8?q?=EC=A7=80=20=EC=8B=9C=ED=97=98=20(364-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_013QyNXY2TUKXCFMwAca2APG --- resources/tester/test_app_settings.py | 16 ++++++++++++++++ ui_template/ui_template_locale_common.ts | 16 ++++++++-------- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/resources/tester/test_app_settings.py b/resources/tester/test_app_settings.py index 9016b0b37..1bc7f5011 100644 --- a/resources/tester/test_app_settings.py +++ b/resources/tester/test_app_settings.py @@ -96,6 +96,22 @@ def _close(page): page.click(".app-settings .ui-modal__top > button") +def test_다른_곳_단축키_줄마다_동작_있음(): + """364-2 — [다른 곳 단축키] 모든 줄 = 「입력 = 동작」 · ko · en 줄 수 같음.""" + import re + + src = open( + os.path.join(ROOT, "ui_template", "ui_template_locale_common.ts"), encoding="utf8" + ).read() + blocks = re.findall(r'App_Settings_Keys_\w+_Keys: \[\s*"(.*?)",\s*"(.*?)",\s*\]', src, re.S) + assert len(blocks) == 7 + for ko, en in blocks: + ko_lines, en_lines = ko.split("\\n"), en.split("\\n") + assert len(ko_lines) == len(en_lines), ko + for line in ko_lines + en_lines: + assert " = " in line and not line.rstrip().endswith(" ="), line + + @pytest.mark.skipif(not _up(), reason="sub2 화면 서버(5177) 꺼짐") def test_설정_창_탭_저장_물음(): from playwright.sync_api import sync_playwright diff --git a/ui_template/ui_template_locale_common.ts b/ui_template/ui_template_locale_common.ts index 7a5e3a990..ddad8eb97 100644 --- a/ui_template/ui_template_locale_common.ts +++ b/ui_template/ui_template_locale_common.ts @@ -150,8 +150,8 @@ export const ui_locales_common = { ], App_Settings_Keys_Profile: ["B05 종단", "B05 profile"], App_Settings_Keys_Profile_Keys: [ - "Ctrl+Z = 되돌림\nCtrl+Shift+Z = 다시\n방향키 = 옮김\nEnter(자유지정) = 끝\nEsc · Backspace", - "Ctrl+Z = undo\nCtrl+Shift+Z = redo\nArrows = move\nEnter (free mode) = finish\nEsc · Backspace", + "Ctrl+Z = 되돌림\nCtrl+Shift+Z = 다시\n방향키 = 옮김\nEnter(자유지정) = 끝\nEsc(자유지정) = 취소\nBackspace(자유지정) = 마지막 점 빼기", + "Ctrl+Z = undo\nCtrl+Shift+Z = redo\nArrows = move\nEnter (free mode) = finish\nEsc (free mode) = cancel\nBackspace (free mode) = remove last point", ], App_Settings_Keys_RouteEdit: ["B05 노선 편집", "B05 route edit"], App_Settings_Keys_RouteEdit_Keys: [ @@ -160,13 +160,13 @@ export const ui_locales_common = { ], App_Settings_Keys_Section: ["B06 횡단", "B06 cross section"], App_Settings_Keys_Section_Keys: [ - "방향키 = 측점 옮김\n▲▼ = 측점 옮김\nEnter = 고름\nSpace = 고름\nEsc", - "Arrows = move station\n▲▼ = move station\nEnter = pick\nSpace = pick\nEsc", + "방향키 = 측점 옮김\n▲▼ = 측점 옮김\nEnter = 고름\nSpace = 고름\nEsc = 고름 풀기\nEsc(상세 창 고르기 중) = 고르기 취소", + "Arrows = move station\n▲▼ = move station\nEnter = pick\nSpace = pick\nEsc = clear selection\nEsc (detail window, while picking) = cancel picking", ], App_Settings_Keys_Cad: ["CAD", "CAD"], App_Settings_Keys_Cad_Keys: [ - "F3 = 스냅\nF7 = 그리드\nF8 = 직교\nF10 = 45°\nCtrl+Z = 되돌림\nCtrl+Y = 다시\nCtrl+S(M02) = 저장\nSpace = Enter\n↑↓ = 기록 · 최근 값\nShift+↑↓ = 화면 옮김\n글자 = 명령\nCtrl+A · Delete · Esc · Enter · Ctrl+C · X · 1 · ← →", - "F3 = snap\nF7 = grid\nF8 = ortho\nF10 = 45°\nCtrl+Z = undo\nCtrl+Y = redo\nCtrl+S (M02) = save\nSpace = Enter\n↑↓ = history · recent values\nShift+↑↓ = pan\nLetters = command\nCtrl+A · Delete · Esc · Enter · Ctrl+C · X · 1 · ← →", + "F3 = 스냅\nF7 = 그리드\nF8 = 직교\nF10 = 45°\nCtrl+Z = 되돌림\nCtrl+Y = 다시\nCtrl+S(M02) = 저장\nSpace = Enter\n↑↓ = 기록 · 최근 값\nShift+↑↓ = 화면 옮김\n글자 = 명령\nCtrl+A = 모두 고름\nDelete = 고른 것 지움\nEsc = 명령 취소 · 쉴 때 고름 풀기\nEnter = 입력 끝\nCtrl+C = 복사\nCtrl+X = 잘라 냄\nCtrl+V = 붙여넣기\nCtrl+1 = 특성\n← → = 화면 옮김", + "F3 = snap\nF7 = grid\nF8 = ortho\nF10 = 45°\nCtrl+Z = undo\nCtrl+Y = redo\nCtrl+S (M02) = save\nSpace = Enter\n↑↓ = history · recent values\nShift+↑↓ = pan\nLetters = command\nCtrl+A = select all\nDelete = delete selection\nEsc = cancel command · clear selection when idle\nEnter = submit input\nCtrl+C = copy\nCtrl+X = cut\nCtrl+V = paste\nCtrl+1 = properties\n← → = pan", ], App_Settings_Keys_Table: ["B08 · B09 표", "B08 · B09 tables"], App_Settings_Keys_Table_Keys: [ @@ -175,8 +175,8 @@ export const ui_locales_common = { ], App_Settings_Keys_M02: ["M02 양식", "M02 templates"], App_Settings_Keys_M02_Keys: [ - "Ctrl+S = 저장\nSpace+끌기 = 화면 옮김\nCtrl+휠 = 배율\n시트 안 = 엑셀 키 그대로\nCtrl+C · Ctrl+V · Delete · Esc", - "Ctrl+S = save\nSpace+drag = pan\nCtrl+wheel = zoom\nInside a sheet = Excel keys\nCtrl+C · Ctrl+V · Delete · Esc", + "Ctrl+S = 저장\nSpace+끌기 = 화면 옮김\nCtrl+휠 = 배율\n시트 안 = 엑셀 키 그대로\nCtrl+C(구조물 나무) = 복사\nCtrl+V(구조물 나무) = 붙여넣기\nDelete = 고른 항목 지움\nEsc = 연결 · 열린 창 취소", + "Ctrl+S = save\nSpace+drag = pan\nCtrl+wheel = zoom\nInside a sheet = Excel keys\nCtrl+C (structure tree) = copy\nCtrl+V (structure tree) = paste\nDelete = delete selected item\nEsc = cancel link · close open window", ], App_Footer_Copyright: [ "© 2026 임도설계 및 견적 자동화. All rights reserved.", From 6466904997767f7745776e88ccb1299e515a162b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 23:42:18 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(M02=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80):=20361-2=20=EC=A0=84=EA=B5=AD=20=EC=9E=90=EC=9E=AC?= =?UTF-8?q?=20=EC=A4=84=20=E2=80=94=20=EC=83=81=EC=84=B8=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC=EB=AA=A9=EB=A1=9D=20=EC=95=84=EB=9E=98=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=84=EC=9A=A9=20=ED=91=9C=20=C2=B7=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EC=B0=BD=20=EC=84=B8=20=EC=B9=B8=20=3D=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B8=B0=EC=97=90=20=EC=A0=81=ED=9E=90=20=EA=B0=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 파일 `M02_MasterTemplete_Labor_National`: 사전조합 `본뜬.전국자재` 를 자재목록 아래 표로(코드 「전국」 · 이름 · 규격 · 수량 · 단위 · 세 칸 글 · 「지역은 프로젝트에서」 · 금액 칸 비움 · 단추 없음) · 없으면 안 그림 · 셈 · 문서 꼴 · 저장 길 그대로 - 상세(`Labor_Detail`) 자재 칸에 부르는 줄 하나 · `Labor_Api` 본뜬 타입에 `전국자재` 칸 - 확인 창 자재 표 세 칸: 서버가 대상마다 `세칸`(`Material.marks` · 만들기와 같은 값)을 실음 → 전국-관급 줄 = 관급(옛 기본값 「사급」) - 시험: 전국 자재 줄 있는 것 · 없는 것 · 단추 없음 · 글 명사형 · 읽기 길 · 세 칸 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- M02_MasterTemplete/M02_Hopyo_Line.py | 5 +- .../M02_MasterTemplete_Hopyo_Api.ts | 3 + .../M02_MasterTemplete_Hopyo_CheckRows.ts | 2 +- .../M02_MasterTemplete_Labor_Api.ts | 9 +- .../M02_MasterTemplete_Labor_Detail.ts | 2 + .../M02_MasterTemplete_Labor_National.ts | 76 +++++++++++ .../tester/helper_m02_labor_national.cjs | 57 ++++++++ resources/tester/test_m02_hopyo_match.py | 3 +- resources/tester/test_m02_hopyo_screen.py | 5 +- resources/tester/test_m02_labor_national.py | 122 ++++++++++++++++++ 10 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_National.ts create mode 100644 resources/tester/helper_m02_labor_national.cjs create mode 100644 resources/tester/test_m02_labor_national.py diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index bfe215ac8..b53ae639e 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -92,6 +92,8 @@ def _pair(pair: dict, line: dict, ctx: Ctx) -> dict: {"호표": pair.get("호표")}, ) conv = {"배": pair.get("배"), "갈래": "별칭"} + if "자재" in head: # 세 칸(361-2) = 만들기에 적힐 값(`Material.marks`) + head["세칸"] = hmat.marks(line, one["이름"]) return {**head, "이름": one["이름"], "단위": one["단위"], "입력값": vals, "못읽음": []} | { "출처": {k: "별칭" for k in vals}, "환산": conv, @@ -225,9 +227,10 @@ def _material(line: dict, ctx: Ctx, full: bool) -> dict: def _mat_target(one: dict, line: dict, times: Any = None) -> dict: - """자재 · 「전국」 자리표 → 대상(배 = 환산 · `times` 주면 그것).""" + """자재 · 「전국」 자리표 → 대상(배 = 환산 · `times` 주면 그것 · 세칸 = 만들기에 적힐 세 칸).""" conv = hm.factor(line.get("단위"), one["단위"], line.get("규격")) target = {**hm._public(one), "입력값": {}, "못읽음": [], "출처": {}, "환산": conv} + target["세칸"] = hmat.marks(line, one["이름"]) ok = isinstance(times, (int, float)) and not isinstance(times, bool) and times > 0 return {**target, "몫": None, "배": times if ok else conv["배"]} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 7f6089c9d..3e2ddaa0f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -9,6 +9,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import { call, getActiveLayer, getActiveOwner, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import type { MatMarks } from "./M02_MasterTemplete_Labor_Api"; /** 층 · 주인(null = 자기 층) */ export interface HopyoScope { @@ -119,6 +120,8 @@ export interface HopyoPart extends HopyoTarget { 배: number | null; /** 블록 절 제목(조합 덧 로직) */ 어디?: string; + /** 자재 세 칸(361-2) — 만들기에 적힐 값(서버 `Material.marks`) */ + 세칸?: MatMarks; } /** 품 묶음 — 직종 품 · 장비 시간 · 자재 수량 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts index 7ef66d841..54b241eb5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts @@ -266,7 +266,7 @@ function matRow(line: HopyoLine, ctx: RowsContext): HTMLElement { }), qtyCell(line, parts), el("td", { className: "m02-labrow__unit", text: p?.단위 ?? line.단위 }), - ...markCells({ 별도계상: line.갈래 === "별도계상" ? "별도계상" : "직접계산" }), + ...markCells(p?.세칸 ?? { 별도계상: line.갈래 === "별도계상" ? "별도계상" : "직접계산" }), ...PRICE_SLOTS.map((slot) => el("td", { className: `m02-labrow__num m02-labrow__price${first?.기준칸 === slot ? " is-base-price" : ""}`, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 0df44097f..10e55f950 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -146,6 +146,13 @@ export const MAT_MARKS = { } as const; export type MatMarkKey = keyof typeof MAT_MARKS; export type MatMarks = { [K in MatMarkKey]?: (typeof MAT_MARKS)[K][number] }; +/** 「전국」 자재(361-1 · `본뜬.전국자재` · 키 · 단가 없음 · 보기만 · `Labor_National`) */ +export type NationalMat = MatMarks & { + 이름: string; + 규격: string; + 단위: string; + 수량: { 손: number }; +}; /** 읽을 때 기본값(162-1 · 서버 `MARK_DEFAULT` 와 같음) — 저장은 비움 */ export const MAT_MARK_DEFAULT: Required = { 자재구분: "사급", @@ -182,7 +189,7 @@ export interface LaborDoc { 열: string; 일위대가번?: string; 단위: string | null; - 본뜬?: { 층: string; 주인: string | null; 키: string } | null; + 본뜬?: { 층: string; 주인: string | null; 키: string; 전국자재?: NationalMat[] } | null; 담은로직: LaborRow[]; 자재: LaborMat[]; 계산값?: unknown; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 7664d8972..f1fe92e7b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -38,6 +38,7 @@ import { type Layer, } from "./M02_MasterTemplete_Api_Fetch"; import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick"; +import { nationalRows } from "./M02_MasterTemplete_Labor_National"; import { lineId, settle, type LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import { areaLinks, @@ -241,6 +242,7 @@ export function mountLaborDetail( }, edit ? { open: openMat, remove: removeMat, mark: markMat } : { open: openMat }, ), + ...nationalRows(cur.본뜬), // 361-2 — 전국 자재(보기만) ); drawDg(); showSource(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_National.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_National.ts new file mode 100644 index 000000000..5b72431c6 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_National.ts @@ -0,0 +1,76 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_National.ts + * 「전국」 자재 줄(PLAN 361-2) — 실무자료 만들기가 `본뜬.전국자재` 에 적은 자재(키 · 단가 없음 · 361-1)를 + * 상세 자재목록 아래 보기 전용 표로 · 이름 · 규격 · 수량 · 단위 · 세 칸(글만) · 금액 칸 비움 · 단추 없음. + * 없으면 아무것도 안 그림 · 셈 · 문서 꼴 · 저장 길은 그대로(보기만). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import type { LaborDoc } from "./M02_MasterTemplete_Labor_Api"; +import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; + +const TEXT = { + Title: [ + "전국 자재 — 지역은 프로젝트에서 · 금액 없음(보기만)", + "Nationwide materials — region set in the project · no price (view only)", + ], + Code: ["전국", "Nationwide"], + Region: ["지역은 프로젝트에서", "Region set in the project"], +} as const; +const say = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; + +/** `본뜬.전국자재` 표 — 없으면 [] */ +export function nationalRows(origin: LaborDoc["본뜬"]): HTMLElement[] { + const mats = origin?.전국자재 ?? []; + if (!mats.length) return []; + const heads = [ + tc("Labor_Code"), + tc("Col_Name"), + tc("Labor_Spec"), + tc("Labor_QtyCol"), + tc("Labor_Unit"), + ...markHeads(), + tc("Labor_AppliedSum"), + ]; + const rows = mats.map((m) => + el("tr", { + className: "m02-labrow__row", + attrs: { "data-labor-national": m.이름 }, + children: [ + el("td", { className: "m02-labrow__code", text: say("Code") }), + el("td", { + className: "m02-labrow__name", + children: [ + el("strong", { text: m.이름 }), + el("small", { className: "m02-labrow__note", text: say("Region") }), + ], + }), + el("td", { className: "m02-labrow__spec", text: m.규격, attrs: { title: m.규격 } }), + el("td", { className: "m02-labrow__num", text: String(m.수량?.손 ?? "") }), + el("td", { className: "m02-labrow__unit", text: m.단위 }), + ...markCells(m), + el("td", { className: "m02-labrow__num", text: "—" }), + ], + }), + ); + return [ + el("p", { className: "m02-labrow__muted", text: say("Title") }), + el("div", { + className: "ui-general-block__table-wrap", + attrs: { "data-labor-national-table": "" }, + children: [ + el("table", { + className: "ui-general-block__table m02-labrow", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: rows }), + ], + }), + ], + }), + ]; +} diff --git a/resources/tester/helper_m02_labor_national.cjs b/resources/tester/helper_m02_labor_national.cjs new file mode 100644 index 000000000..6cea2ca9c --- /dev/null +++ b/resources/tester/helper_m02_labor_national.cjs @@ -0,0 +1,57 @@ +/* M02 「전국」 자재 줄(361-2) 헬퍼 — `Labor_National.nationalRows` 를 트랜스파일해 Node 로 돌림 · + * `el` 대역 = 나무 꼴 {tag, className, text, attrs, children} · stdin = {origin} · stdout = 나무 JSON. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const el = (tag, o = {}) => ({ tag, ...o, children: o.children ?? [] }); +const STUBS = { + "@ui/ui_template_elements": { el, createSelectField: () => ({ root: el("select") }) }, + "@ui/ui_template_locale": { currentLanguageIndex: 0 }, + "@config/config_frontend": { API_BASE_URL: "/api" }, +}; +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(".") && !request.endsWith(".css")) + return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return new Proxy({}, { get: () => () => ({}) }); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const national = loadTs( + path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_Labor_National.ts"), +); +const text = (n) => (n.text ?? "") + (n.children ?? []).map(text).join(" "); +const find = (n, tag) => [ + ...(n.tag === tag ? [n] : []), + ...(n.children ?? []).flatMap((c) => find(c, tag)), +]; +const out = input.origins.map((origin) => { + const got = national.nationalRows(origin); + const table = got.flatMap((n) => find(n, "table"))[0]; + return { + count: got.length, + title: got[0]?.text ?? null, + heads: table ? find(table, "th").map((h) => h.text) : [], + rows: table + ? find(table, "tr") + .slice(1) + .map((r) => r.children.map((c) => text(c).trim())) + : [], + buttons: got.flatMap((n) => [...find(n, "button"), ...find(n, "select")]).length, + }; +}); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index 9a633a0c2..111e375b9 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -117,7 +117,7 @@ def test_새_상태_체크_제안(client): assert plywood["상태"] == "맞음" and set(plywood["갈리는칸"]) == {"측압", "폼타이규격"} mat = first["레미콘(25-21-120)"] # 358-2 · 361-1 — 「별도계상」 표시 · 지역 변형만 = 전국 assert mat["상태"] == "맞음" and mat["체크"] is True and "별도계상" in mat["꼬리표"] - assert mat["대상"][0] | {"환산": None} == { + assert mat["대상"][0] | {"환산": None, "세칸": None} == { "전국": True, "이름": "레미콘(전국)-관급", "규격": "25-21-120", @@ -126,6 +126,7 @@ def test_새_상태_체크_제안(client): "못읽음": [], "출처": {}, "환산": None, + "세칸": None, "몫": None, "배": 1, } # 특정 지역 키 없음 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 015cd2d70..d36d273b2 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -357,7 +357,7 @@ def test_좌측_판_자리와_권한(): def test_로직_추가_한_곳과_파일_규칙(): detail = _text("M02_MasterTemplete_Labor_Detail.ts") - assert len(detail.splitlines()) == 693 # 줄 수 그대로 + assert len(detail.splitlines()) == 695 # 줄 수 그대로(361-2 전국 자재 부르는 줄 둘) assert "withHopyo(library)," in detail pick = _text("M02_MasterTemplete_Labor_Pick.ts") assert "more?: () => Promise;" in pick and "...later" in pick @@ -485,7 +485,8 @@ def test_확인_창_표_일위대가_상세_꼴_359(): for word in ('"고르기"', '"원문 보기"', '"엑셀 보기"', '"옵션"', '"품 비교"'): assert word in rows, word assert 'import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";' in rows - assert "...markCells({ 별도계상:" in rows and "...markHeads()," in rows + assert "...markCells(p?.세칸 ?? { 별도계상:" in rows # 361-2 서버 세 칸 먼저 + assert "...markHeads()," in rows for head in ("Col_Order", "Sub", "Col_Name", "Col_Alias", "Labor_QtyCol", "Col_Unit"): assert f'tc("{head}")' in rows, head check = _text("M02_MasterTemplete_Hopyo_Check.ts") diff --git a/resources/tester/test_m02_labor_national.py b/resources/tester/test_m02_labor_national.py new file mode 100644 index 000000000..80f18a17c --- /dev/null +++ b/resources/tester/test_m02_labor_national.py @@ -0,0 +1,122 @@ +"""「전국」 자재 줄(PLAN 361-2 · `M02_MasterTemplete_Labor_National`) — 사전조합 `본뜬.전국자재` 를 +상세 자재목록 아래 보기 전용 표로(있으면 줄 · 없으면 안 보임 · 단추 없음 · 금액 칸 비움 · +글 명사형) · 읽기 길(사전조합 GET 의 `layers.read_template`)이 `본뜬.전국자재` 를 그대로 줌 · +확인 창 세 칸 = 만들기에 적힐 값(`세칸`). +층은 임시 폴더. +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +# 올리기 길 시험의 `client` 마당을 같이 씀 +# ruff: noqa: F811 +from resources.tester.test_m02_hopyo_build import _build +from resources.tester.test_m02_hopyo_excel import URL, _post, client # noqa: F401 + +HERE = os.path.dirname(os.path.abspath(__file__)) +M02 = Path(HERE).parents[1] / "M02_MasterTemplete" +REMICON = { + "이름": "레미콘(전국)-관급", + "규격": "25-21-120", + "단위": "㎥", + "수량": {"손": 2.5}, + "별도계상": "별도계상", + "자재구분": "관급", +} + + +def _rows(origins: list) -> list: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_labor_national.cjs")], + input=json.dumps({"origins": origins}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_전국_자재_줄_있는_것_없는_것(): + have, plain, empty, none = _rows( + [ + {"층": "personal", "주인": "7/42", "키": "HP-000001 제1호표", "전국자재": [REMICON]}, + {"층": "personal", "주인": "7/42", "키": "HP-000001 제3호표"}, + {"층": "personal", "주인": None, "키": "k", "전국자재": []}, + None, + ] + ) + assert have["title"] == "전국 자재 — 지역은 프로젝트에서 · 금액 없음(보기만)" + assert have["heads"] == [ + "코드", + "이름", + "규격", + "수량산출", + "단위", + "자재구분", + "별도계상", + "운반비", + "반영 금액", + ] + assert have["rows"] == [ + [ + "전국", + "레미콘(전국)-관급 지역은 프로젝트에서", + "25-21-120", + "2.5", + "㎥", + "관급", + "별도계상", + "포함", # 빈 칸 = 기본값 글 + "—", # 금액 칸 비움 + ] + ] + assert have["buttons"] == 0 # 고침 · 삭제 없음(보기만) + assert plain["count"] == empty["count"] == none["count"] == 0 # 없으면 안 보임 + + +def test_소스_보기만_명사형_자리(): + code = (M02 / "M02_MasterTemplete_Labor_National.ts").read_text(encoding="utf-8") + words = re.findall(r'"([^"]*[가-힣][^"]*)"', code) + assert words and not [w for w in words if re.search(r"(합니다|해요|한다|하세요)", w)] + assert "createButton" not in code and "fetch" not in code and "sessionStorage" not in code + detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text(encoding="utf-8") + assert "...nationalRows(cur.본뜬)" in detail and len(detail.splitlines()) <= 700 + api = (M02 / "M02_MasterTemplete_Labor_Api.ts").read_text(encoding="utf-8") + assert "전국자재?: NationalMat[]" in api + + +def test_만든_사전조합_읽기_길에_전국자재(client): + from M02_MasterTemplete import M02_Template_Layers as layers + + assert _post(client).status_code == 200 + got = _build(client, 1, [{"차례": 0, "대상": [{"전국": True, "배": 1}]}]) + key = got["참조"]["키"] + found = layers.read_template(layers.personal_dir(7, 42), "precombo", key) + doc = found.get("문서") or found + assert doc["본뜬"]["전국자재"][0] | {"수량": None} == REMICON | {"수량": None} + assert doc["자재"] == [] # 자재 칸(M01 키만)엔 없음 — 셈 · 문서 꼴 그대로 + + +def test_확인_창_세_칸_만들기_값(client): + from M02_MasterTemplete import M02_Hopyo_Fit as fit + from M02_MasterTemplete import M02_Template_Layers as layers + + assert _post(client).status_code == 200 + fit.run_job(layers.personal_dir(7, 42), "HP-000001") + lines = client.get(f"{URL.format('personal')}/HP-000001/1").json()["호표"]["줄"] + at = next(x["차례"] for x in lines if x["이름"] == "레미콘(25-21-120)") + mat = lines[at]["맞춤"]["대상"][0] + assert mat["세칸"] == { + "별도계상": "별도계상", + "자재구분": "관급", + } # 전국-관급 = 관급(옛 「사급」) + path = f"{URL.format('personal')}/HP-000001/1/guess" + seoul = client.post(path, json={"차례": at, "대상": [{"자재": "MT004258", "배": 1}]}).json() + assert seoul["대상"][0]["세칸"]["자재구분"] == "사급" # 고른 자재 이름(-사급) 먼저 + rows = (M02 / "M02_MasterTemplete_Hopyo_CheckRows.ts").read_text(encoding="utf-8") + assert "...markCells(p?.세칸 ??" in rows From 3e3215826eccbfad0eb49131ae59557720cec977 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 23:53:08 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(B05=C2=B7B07=20=ED=8F=89=EB=A9=B4):=203?= =?UTF-8?q?64-3=20=EC=A0=88/=EC=84=B1=ED=86=A0=EC=84=A0=20=EB=B9=84?= =?UTF-8?q?=ED=83=88=20=EB=81=9D=20=EB=AA=A8=EC=84=9C=EB=A6=AC=20=EB=91=A5?= =?UTF-8?q?=EA=B8=80=EB=A6=BC=20=C2=B7=20S=20=EC=9E=90=20=EC=B4=98?= =?UTF-8?q?=EC=B4=98=ED=9E=88=20(TS=20=E2=86=94=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=EC=8D=AC=20=EC=A7=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 까닭 셋: 접힘 버린 자리 건너뜀 모서리(898.6 → 908 m · 139°) · 촘촘히가 가운데 점만 봐 S 자 빠짐(860 → 861 · 70°) · 잔 갈지자 - 촘촘히 = 4분 점도 봄 · 비탈 끝 = 가장 많이 꺾인 점부터 앞뒤 3 m 를 두 접선에 붙는 2차 곡선으로(15° 넘음) · 측점 · 깎기↔쌓기 바뀜 점은 그대로 · 둥글린 자리 눈금 뺌 - 요구 239 세션 자료: 30° 넘는 꺾임 왼 14 · 오른 3 · 최대 140.7° → 0 · 1 · 34.8°(측점 860 · 861 사이 1 m 에 폭 4.4 → 6.0 m) - 시험 test_364_3_slope_end_round.py(전후 · 측점 그대로 · TS 같은 점) · 자료 fixtures/b05_364_3_footprint_req239.json Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- .../logic/B05_Profile_PlanFootprint.ts | 113 ++++++++++++++- .../B07_DesignDetail_Engine_Cad_PlanSlope.py | 118 +++++++++++++++- .../fixtures/b05_364_3_footprint_req239.json | 1 + .../tester/test_364_3_slope_end_round.py | 131 ++++++++++++++++++ 4 files changed, 350 insertions(+), 13 deletions(-) create mode 100644 resources/tester/fixtures/b05_364_3_footprint_req239.json create mode 100644 resources/tester/test_364_3_slope_end_round.py diff --git a/B05_Profile/logic/B05_Profile_PlanFootprint.ts b/B05_Profile/logic/B05_Profile_PlanFootprint.ts index 93d9c06c9..d27659b6a 100644 --- a/B05_Profile/logic/B05_Profile_PlanFootprint.ts +++ b/B05_Profile/logic/B05_Profile_PlanFootprint.ts @@ -4,7 +4,7 @@ * * ⚠⚠ 짝: `B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py` * (`route_frame` · `route_normal` · `side_is_cut` · `slope_end` · `slope_pieces` 와 그 아래 - * 단조 3차 보간 · 촘촘히 · 접힘 버림 · 353-1) — 한쪽만 고치면 + * 단조 3차 보간 · 촘촘히 · 접힘 버림 · 353-1 · 모서리 둥글림 · 364-3) — 한쪽만 고치면 * 종단 · 횡단 편집 중 평면과 B07 계획평면도가 갈린다. * `footprintRecord` = `B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄. * 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py` @@ -348,6 +348,91 @@ function turnDeg(a: number[], m: number[], b: number[]): number { ); } +/** 비탈 끝 모서리 둥글게(364-3) — 접힘 버린 자리 · 잔 갈지자가 이보다 꺾이면 둥글림(°). 짝 같은 이름. */ +export const ROUND_TURN_DEG = 15.0; +/** 둥글리는 앞뒤 길이(선 따라 m) — 측점 · 깎기↔쌓기 바뀜 점은 넘지 않음 · 그 틈이 ROUND_MIN_M 보다 좁으면 그대로. */ +export const ROUND_M = 3.0; +export const ROUND_MIN_M = 0.5; +/** 둥근 곡선 한 마디 꺾임(°) · 되풀이 상한. */ +export const ROUND_STEP_DEG = 5.0; +export const ROUND_PASSES = 200; + +const dist = (a: number[], b: number[]): number => + Math.sqrt((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2); + +/** 가장 많이 꺾인 점부터 앞뒤 같은 길이를 2차 베지어로 바꿈 — 측점(pins) · 바뀜 점 · 양 끝은 그대로. + * 돌려줌 = 새 점 · 점마다 깎기 여부 · 점마다 원래 번호(새로 만든 점 -1). 짝: `_round_corners`. */ +function roundCorners( + points: Line, + flags: boolean[], + pins: boolean[], +): [Line, boolean[], number[]] { + const pts = points.map((p) => [...p]); + const fl = [...flags]; + const src = points.map((_p, i) => i); + const pinned = (i: number): boolean => src[i] >= 0 && pins[src[i]]; + const fixed = (i: number): boolean => + i === 0 || i === pts.length - 1 || fl[i] !== fl[i - 1] || pinned(i); + /** i 에서 step 쪽으로 length(그 전에 붙박인 점이면 거기까지) — [남길 점 번호, 그 자리, 간 길이, 그 자리 선 방향(i 쪽)]. */ + const walk = (i: number, step: number, length: number): [number, number[], number, number[]] => { + let gone = 0.0; + let j = i; + for (;;) { + const k = j + step; + const seg = dist(pts[j], pts[k]); + const toward = [pts[j][0] - pts[k][0], pts[j][1] - pts[k][1]]; + if (gone + seg >= length) { + const ratio = (length - gone) / seg; + return [k, [pts[j][0] - toward[0] * ratio, pts[j][1] - toward[1] * ratio], length, toward]; + } + // 측점은 반만 다가감 — 끝까지 가면 꺾임이 측점에 몰림 + if (fixed(k)) return [k, pts[k], pinned(k) ? (gone + seg) / 2.0 : gone + seg, toward]; + gone += seg; + j = k; + } + }; + /** 두 끝 접선이 만나는 점(곡선이 앞뒤 선에 매끄럽게 붙음) — 못 쓰면 null. */ + const control = (a: number[], ta: number[], b: number[], tb: number[], reach: number) => { + const den = ta[0] * tb[1] - ta[1] * tb[0]; + if (Math.abs(den) < 1e-12) return null; + const s = ((b[0] - a[0]) * tb[1] - (b[1] - a[1]) * tb[0]) / den; + const u = ((b[0] - a[0]) * ta[1] - (b[1] - a[1]) * ta[0]) / den; + const c = [a[0] + ta[0] * s, a[1] + ta[1] * s]; + if (s <= 0 || u <= 0 || dist(a, c) > 2 * reach || dist(b, c) > 2 * reach) return null; + return c; + }; + for (let pass = 0; pass < ROUND_PASSES; pass += 1) { + let worst = ROUND_TURN_DEG; + let at = -1; + let reach = 0.0; + for (let i = 1; i < pts.length - 1; i += 1) { + if (fixed(i)) continue; + const turn = turnDeg(pts[i - 1], pts[i], pts[i + 1]); + if (turn > worst) { + const room = Math.min(walk(i, -1, ROUND_M)[2], walk(i, 1, ROUND_M)[2]); + if (room >= ROUND_MIN_M) [worst, at, reach] = [turn, i, room]; + } + } + if (at < 0) break; + const [before, a, , ta] = walk(at, -1, reach); + const [after, b, , tb] = walk(at, 1, reach); + const p = control(a, ta, b, tb, reach) ?? pts[at]; + const count = Math.max(2, Math.ceil(worst / ROUND_STEP_DEG)); + const curve: Line = []; + for (let n = 0; n <= count; n += 1) { + const t = n / count; + const point = [0, 1].map((k) => + roundTo((1 - t) ** 2 * a[k] + 2 * t * (1 - t) * p[k] + t * t * b[k], 4), + ); + if (dist(point, pts[before]) > 1e-6 && dist(point, pts[after]) > 1e-6) curve.push(point); + } + pts.splice(before + 1, after - before - 1, ...curve); + fl.splice(before + 1, after - before - 1, ...curve.map(() => fl[at])); + src.splice(before + 1, after - before - 1, ...curve.map(() => -1)); + } + return [pts, fl, src]; +} + /** 곡선 · 폭이 가파르게 바뀌는 자리 — 가운데 점에서 많이 꺾이는 사이를 반씩 나눔. 짝: `_densify`. */ function densify( chainages: number[], @@ -358,9 +443,17 @@ function densify( if (depth >= SPLIT_DEPTH) return []; const middle = (c0 + c1) / 2.0; const pm = place(middle, frameOf(middle)); + // 4분 점도 봄 — S 자(가운데가 현 위)는 가운데 점만으론 안 꺾여 보임(364-3) + const q1 = place((c0 + middle) / 2.0, frameOf((c0 + middle) / 2.0)); + const q3 = place((middle + c1) / 2.0, frameOf((middle + c1) / 2.0)); + const triples = [ + ...pm.map((m, k) => [p0[k], m, p1[k]]), + ...q1.map((q, k) => [p0[k], q, pm[k]]), + ...q3.map((q, k) => [pm[k], q, p1[k]]), + ]; // 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음 - const bends = pm.some((m, k) => { - const turn = turnDeg(p0[k], m, p1[k]); + const bends = triples.some(([a, m, b]) => { + const turn = turnDeg(a, m, b); return SPLIT_TURN_DEG < turn && turn < 90.0; }); if (!bends) return []; @@ -465,10 +558,18 @@ function sampleLines( left: kept.left_end_m.map((i) => cutAt(curves.left, chainages[i])), right: kept.right_end_m.map((i) => cutAt(curves.right, chainages[i])), }; - // 눈금은 도로 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아(납품 - // 도면 관례) · 접혀 버린 자리는 뺌. - const ticks: Line[] = []; const keptSets = { left: new Set(kept.left_end_m), right: new Set(kept.right_end_m) }; + for (const side of SIDES) { + const key = `${side}_end_m` as const; + const pins = kept[key].map((i) => byChainage.has(roundTo(chainages[i], 3))); + const [line, flags, src] = roundCorners(lines[key], cut[side], pins); + lines[key] = line; + cut[side] = flags; + keptSets[side] = new Set(src.filter((j) => j >= 0).map((j) => kept[key][j])); + } + // 눈금은 도로 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아(납품 + // 도면 관례) · 접혀 버리거나 둥글린 자리는 뺌. + const ticks: Line[] = []; const position = new Map(chainages.map((chainage, i) => [chainage, i])); [...base] .sort((a, b) => a - b) diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py index ddf0b615a..7624ceb54 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py @@ -3,8 +3,8 @@ `_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m. ⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · route_normal · side_is_cut · -slope_end · slope_pieces 와 그 아래 보간 · 촘촘히 · 접힘 버림) — 종단 · 횡단 편집 중 평면 -발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림. +slope_end · slope_pieces 와 그 아래 보간 · 촘촘히 · 접힘 버림 · 모서리 둥글림) — 종단 · 횡단 +편집 중 평면 발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림. 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py` """ @@ -226,6 +226,100 @@ def _turn_deg(a: list[float], m: list[float], b: list[float]) -> float: return abs(math.degrees(math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]))) +#: 비탈 끝 모서리 둥글게(364-3) — 접힘 버린 자리 · 잔 갈지자가 이보다 꺾이면 둥글림(°) +ROUND_TURN_DEG = 15.0 +#: 둥글리는 앞뒤 길이(선 따라 m) — 측점 · 깎기↔쌓기 바뀜 점은 넘지 않음 · 그 틈이 ROUND_MIN_M 보다 +#: 좁으면 그대로 +ROUND_M = 3.0 +ROUND_MIN_M = 0.5 +#: 둥근 곡선 한 마디 꺾임(°) · 되풀이 상한 +ROUND_STEP_DEG = 5.0 +ROUND_PASSES = 200 + + +def _dist(a: list[float], b: list[float]) -> float: + return math.sqrt((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2) + + +def _round_corners( + points: list[list[float]], flags: list[bool], pins: list[bool] +) -> tuple[list[list[float]], list[bool], list[int]]: + """가장 많이 꺾인 점부터 앞뒤 같은 길이를 2차 베지어로 바꿈. + + 측점(pins) · 깎기↔쌓기 바뀜 점 · 양 끝은 그대로 둔다. + + 돌려줌 = 새 점 · 점마다 깎기 여부 · 점마다 원래 번호(새로 만든 점 -1). + """ + pts, fl, src = [list(p) for p in points], list(flags), list(range(len(points))) + + def pinned(i: int) -> bool: + return src[i] >= 0 and pins[src[i]] + + def fixed(i: int) -> bool: + return i == 0 or i == len(pts) - 1 or fl[i] != fl[i - 1] or pinned(i) + + def walk(i: int, step: int, length: float) -> tuple[int, list[float], float, list[float]]: + """i 에서 step 쪽으로 length(그 전에 붙박인 점이면 거기까지). + + 돌려줌 = (남길 점 번호, 그 자리, 간 길이, 그 자리 선 방향 — i 쪽을 향함). + """ + gone, j = 0.0, i + while True: + k = j + step + seg = _dist(pts[j], pts[k]) + toward = [pts[j][0] - pts[k][0], pts[j][1] - pts[k][1]] + if gone + seg >= length: + ratio = (length - gone) / seg + at = [pts[j][0] - toward[0] * ratio, pts[j][1] - toward[1] * ratio] + return k, at, length, toward + if fixed(k): + # 측점은 반만 다가감 — 끝까지 가면 꺾임이 측점에 몰림 + return k, pts[k], (gone + seg) / 2.0 if pinned(k) else gone + seg, toward + gone, j = gone + seg, k + + def control(a: list[float], ta: list[float], b: list[float], tb: list[float], reach: float): + """두 끝 접선이 만나는 점(곡선이 앞뒤 선에 매끄럽게 붙음) — 못 쓰면 None.""" + den = ta[0] * tb[1] - ta[1] * tb[0] + if abs(den) < 1e-12: + return None + s = ((b[0] - a[0]) * tb[1] - (b[1] - a[1]) * tb[0]) / den + u = ((b[0] - a[0]) * ta[1] - (b[1] - a[1]) * ta[0]) / den + c = [a[0] + ta[0] * s, a[1] + ta[1] * s] + if s <= 0 or u <= 0 or _dist(a, c) > 2 * reach or _dist(b, c) > 2 * reach: + return None + return c + + for _ in range(ROUND_PASSES): + worst, at, reach = ROUND_TURN_DEG, -1, 0.0 + for i in range(1, len(pts) - 1): + if fixed(i): + continue + turn = _turn_deg(pts[i - 1], pts[i], pts[i + 1]) + if turn > worst: + room = min(walk(i, -1, ROUND_M)[2], walk(i, 1, ROUND_M)[2]) + if room >= ROUND_MIN_M: + worst, at, reach = turn, i, room + if at < 0: + break + before, a, _gone, ta = walk(at, -1, reach) + after, b, _gone, tb = walk(at, 1, reach) + p = control(a, ta, b, tb, reach) or pts[at] + count = max(2, math.ceil(worst / ROUND_STEP_DEG)) + curve = [] + for n in range(count + 1): + t = n / count + point = [ + round((1 - t) ** 2 * a[k] + 2 * t * (1 - t) * p[k] + t * t * b[k], 4) + for k in (0, 1) + ] + if _dist(point, pts[before]) > 1e-6 and _dist(point, pts[after]) > 1e-6: + curve.append(point) + pts[before + 1 : after] = curve + fl[before + 1 : after] = [fl[at]] * len(curve) + src[before + 1 : after] = [-1] * len(curve) + return pts, fl, src + + def _densify( chainages: list[float], frame_of: Any, place: Any ) -> tuple[list[float], list[tuple[float, float, float, float]]]: @@ -236,8 +330,12 @@ def _densify( return [] middle = (c0 + c1) / 2.0 pm = place(middle, frame_of(middle)) + # 4분 점도 봄 — S 자(가운데가 현 위)는 가운데 점만으론 안 꺾여 보임(364-3) + q1 = place((c0 + middle) / 2.0, frame_of((c0 + middle) / 2.0)) + q3 = place((middle + c1) / 2.0, frame_of((middle + c1) / 2.0)) + triples = [*zip(p0, pm, p1), *zip(p0, q1, pm), *zip(pm, q3, p1)] # 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음 - if not any(SPLIT_TURN_DEG < _turn_deg(a, m, b) < 90.0 for a, m, b in zip(p0, pm, p1)): + if not any(SPLIT_TURN_DEG < _turn_deg(a, m, b) < 90.0 for a, m, b in triples): return [] return ( split(c0, p0, middle, pm, depth + 1) + [middle] + split(middle, pm, c1, p1, depth + 1) @@ -264,7 +362,8 @@ def slope_pieces( left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 단조 3차 보간(측점 값 그대로 지남) · 법선은 측점과 같은 ±1 m 식 · 곡선은 촘촘히 · 진행을 거스르는 - 점은 버림 · 깎기↔쌓기 바뀜 자리에서 비탈 끝이 도로 끝으로 모인다(353-1). + 점은 버림 · 깎기↔쌓기 바뀜 자리에서 비탈 끝이 도로 끝으로 모인다(353-1). 버린 자리 · 잔 + 갈지자에 남은 비탈 끝 모서리는 측점을 두고 둥글린다(364-3). 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. """ sorted_records = sorted( @@ -333,10 +432,15 @@ def slope_pieces( cut = { side: [_cut_at(curves[side], chainages[i]) for i in kept[f"{side}_end_m"]] for side in sides } - # 눈금은 노견 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아 - # 선다(납품 도면 관례) · 접혀 버린 자리는 뺌. - ticks: list[list[list[float]]] = [] kept_sets = {key: set(kept[key]) for key in keys} + for side in sides: + key = f"{side}_end_m" + pins = [round(chainages[i], 3) in by_chainage for i in kept[key]] + lines[key], cut[side], src = _round_corners(lines[key], cut[side], pins) + kept_sets[key] = {kept[key][j] for j in src if j >= 0} + # 눈금은 노견 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아 + # 선다(납품 도면 관례) · 접혀 버리거나 둥글린 자리는 뺌. + ticks: list[list[list[float]]] = [] position = {chainage: i for i, chainage in enumerate(chainages)} for index, chainage in enumerate(sorted(base)): i = position[chainage] diff --git a/resources/tester/fixtures/b05_364_3_footprint_req239.json b/resources/tester/fixtures/b05_364_3_footprint_req239.json new file mode 100644 index 000000000..805b3e9a7 --- /dev/null +++ b/resources/tester/fixtures/b05_364_3_footprint_req239.json @@ -0,0 +1 @@ +{"note": "요구 239 브레인 검증 세션(검증_브레인232 · design-footprint) 측점 줄 70 + 노선 253 점 — 364-3", "route": [[0.0, 208403.2, 367920.751], [13.569, 208397.322, 367908.521], [18.854, 208395.249, 367903.66], [24.138, 208393.619, 367898.633], [29.423, 208392.445, 367893.481], [34.708, 208391.737, 367888.244], [39.992, 208391.5, 367882.965], [115.733, 208391.5, 367807.224], [119.441, 208391.672, 367803.52], [123.149, 208392.186, 367799.848], [126.857, 208393.038, 367796.239], [130.564, 208394.22, 367792.725], [134.272, 208395.723, 367789.335], [134.423, 208395.79, 367789.201], [140.756, 208398.923, 367783.696], [147.09, 208402.627, 367778.559], [153.424, 208406.861, 367773.848], [163.399, 208413.918, 367766.798], [166.51, 208416.234, 367764.72], [169.622, 208418.759, 367762.902], [172.733, 208421.464, 367761.366], [172.897, 208421.611, 367761.292], [174.748, 208423.303, 367760.543], [176.599, 208425.058, 367759.954], [178.45, 208426.86, 367759.529], [180.301, 208428.693, 367759.273], [182.152, 208430.542, 367759.188], [219.071, 208467.461, 367759.188], [224.633, 208473.017, 367759.445], [230.194, 208478.525, 367760.216], [235.756, 208483.938, 367761.494], [241.318, 208489.209, 367763.268], [246.88, 208494.294, 367765.522], [253.007, 208499.774, 367768.262], [256.344, 208502.824, 367769.614], [259.681, 208505.987, 367770.678], [263.018, 208509.235, 367771.445], [266.355, 208512.54, 367771.908], [269.692, 208515.873, 367772.062], [273.652, 208519.833, 367772.062], [275.503, 208521.682, 367771.977], [277.354, 208523.515, 367771.721], [279.205, 208525.317, 367771.296], [281.056, 208527.072, 367770.707], [282.907, 208528.764, 367769.958], [283.063, 208528.905, 367769.888], [286.179, 208531.614, 367768.348], [289.295, 208534.142, 367766.528], [292.41, 208536.46, 367764.446], [293.848, 208537.477, 367763.429], [297.811, 208540.125, 367760.481], [301.774, 208542.443, 367757.267], [305.737, 208544.403, 367753.823], [332.568, 208556.409, 367729.828], [342.551, 208561.349, 367721.153], [352.534, 208567.19, 367713.057], [362.517, 208573.866, 367705.634], [373.037, 208581.31, 367698.2], [376.146, 208583.625, 367696.125], [379.255, 208586.148, 367694.309], [382.364, 208588.851, 367692.773], [383.003, 208589.423, 367692.488], [384.669, 208590.946, 367691.814], [386.335, 208592.526, 367691.284], [388.0, 208594.147, 367690.901], [389.666, 208595.797, 367690.671], [391.332, 208597.461, 367690.594], [391.423, 208597.552, 367690.594], [393.09, 208599.217, 367690.671], [394.757, 208600.868, 367690.902], [396.424, 208602.49, 367691.285], [398.091, 208604.07, 367691.816], [399.758, 208605.595, 367692.491], [428.412, 208631.229, 367705.294], [438.373, 208639.889, 367710.215], [448.334, 208647.973, 367716.035], [458.294, 208655.388, 367722.685], [458.446, 208655.496, 367722.793], [468.427, 208662.17, 367730.214], [478.408, 208668.01, 367738.307], [488.388, 208672.949, 367746.98], [544.003, 208697.839, 367796.715], [547.954, 208699.793, 367800.148], [551.904, 208702.103, 367803.354], [555.855, 208704.74, 367806.295], [557.276, 208705.744, 367807.301], [560.408, 208708.073, 367809.394], [563.539, 208710.614, 367811.225], [566.671, 208713.337, 367812.77], [567.295, 208713.896, 367813.049], [568.959, 208715.418, 367813.721], [570.622, 208716.995, 367814.25], [572.285, 208718.614, 367814.631], [573.949, 208720.261, 367814.861], [575.612, 208721.923, 367814.938], [575.719, 208722.03, 367814.938], [577.382, 208723.692, 367814.861], [579.046, 208725.339, 367814.631], [580.709, 208726.958, 367814.25], [582.372, 208728.535, 367813.721], [584.036, 208730.057, 367813.049], [584.696, 208730.647, 367812.755], [586.544, 208732.338, 367812.008], [588.392, 208734.09, 367811.42], [590.24, 208735.889, 367810.997], [592.088, 208737.719, 367810.742], [593.936, 208739.565, 367810.656], [600.072, 208745.701, 367810.656], [604.239, 208749.864, 367810.849], [608.406, 208753.99, 367811.426], [612.573, 208758.046, 367812.383], [616.74, 208761.996, 367813.71], [620.907, 208765.806, 367815.397], [649.692, 208791.559, 367828.258], [661.693, 208801.993, 367834.188], [673.695, 208811.733, 367841.2], [685.697, 208820.667, 367849.214], [699.229, 208830.242, 367858.777], [702.33, 208832.315, 367861.082], [705.43, 208834.131, 367863.596], [708.531, 208835.668, 367866.289], [708.747, 208835.764, 367866.482], [711.833, 208837.293, 367869.162], [714.919, 208839.098, 367871.665], [718.004, 208841.159, 367873.962], [720.735, 208843.088, 367875.894], [723.866, 208845.417, 367877.988], [726.998, 208847.958, 367879.818], [730.13, 208850.681, 367881.364], [730.754, 208851.24, 367881.643], [732.417, 208852.762, 367882.315], [734.081, 208854.339, 367882.844], [735.744, 208855.958, 367883.225], [737.407, 208857.605, 367883.454], [739.071, 208859.267, 367883.531], [739.164, 208859.36, 367883.531], [740.833, 208861.027, 367883.454], [742.501, 208862.679, 367883.222], [744.17, 208864.303, 367882.839], [745.838, 208865.885, 367882.307], [747.507, 208867.41, 367881.631], [767.798, 208885.559, 367872.557], [769.466, 208887.084, 367871.88], [771.135, 208888.666, 367871.348], [772.803, 208890.29, 367870.965], [774.472, 208891.942, 367870.733], [776.14, 208893.609, 367870.656], [776.236, 208893.704, 367870.656], [777.904, 208895.371, 367870.733], [779.573, 208897.023, 367870.965], [781.241, 208898.647, 367871.348], [782.91, 208900.228, 367871.88], [784.578, 208901.754, 367872.557], [803.131, 208918.348, 367880.854], [807.087, 208921.786, 367882.809], [811.042, 208924.996, 367885.12], [814.997, 208927.94, 367887.761], [815.123, 208928.029, 367887.85], [819.091, 208930.684, 367890.8], [823.06, 208933.007, 367894.017], [827.028, 208934.972, 367897.465], [834.236, 208938.2, 367903.91], [838.182, 208940.153, 367907.339], [842.128, 208942.46, 367910.54], [846.074, 208945.095, 367913.477], [847.513, 208946.111, 367914.496], [850.637, 208948.435, 367916.585], [853.762, 208950.969, 367918.412], [856.887, 208953.686, 367919.956], [857.501, 208954.235, 367920.231], [859.167, 208955.759, 367920.905], [860.833, 208957.338, 367921.435], [862.499, 208958.959, 367921.817], [864.165, 208960.609, 367922.048], [865.831, 208962.274, 367922.125], [865.94, 208962.383, 367922.125], [867.606, 208964.047, 367922.048], [869.272, 208965.697, 367921.817], [870.938, 208967.318, 367921.435], [872.603, 208968.897, 367920.905], [874.269, 208970.421, 367920.231], [874.884, 208970.97, 367919.956], [878.008, 208973.687, 367918.412], [881.133, 208976.222, 367916.585], [884.258, 208978.545, 367914.496], [886.999, 208980.481, 367912.556], [890.085, 208982.542, 367910.259], [893.17, 208984.347, 367907.756], [896.256, 208985.876, 367905.075], [896.481, 208985.977, 367904.874], [899.576, 208987.511, 367902.186], [902.671, 208989.322, 367899.677], [905.765, 208991.39, 367897.375], [914.605, 208997.641, 367891.125], [917.699, 208999.709, 367888.823], [920.794, 209001.521, 367886.314], [923.889, 209003.054, 367883.626], [924.521, 209003.338, 367883.06], [926.195, 209004.018, 367881.531], [927.869, 209004.554, 367879.945], [929.543, 209004.939, 367878.316], [931.217, 209005.172, 367876.659], [932.89, 209005.25, 367874.987], [932.965, 209005.25, 367874.912], [934.636, 209005.172, 367873.243], [936.307, 209004.94, 367871.588], [937.978, 209004.556, 367869.962], [939.65, 209004.022, 367868.378], [941.321, 209003.344, 367866.851], [958.382, 208995.703, 367851.597], [965.066, 208992.99, 367845.487], [971.751, 208990.855, 367839.153], [978.435, 208989.317, 367832.648], [985.12, 208988.388, 367826.028], [991.805, 208988.078, 367819.35], [1004.36, 208988.078, 367806.799], [1006.22, 208988.165, 367804.942], [1008.08, 208988.423, 367803.1], [1009.93, 208988.852, 367801.29], [1011.79, 208989.447, 367799.528], [1013.66, 208990.203, 367797.829], [1013.8, 208990.27, 367797.695], [1016.91, 208991.807, 367795.002], [1020.01, 208993.622, 367792.488], [1023.11, 208995.696, 367790.183], [1040.5, 209008.002, 367777.892], [1049.95, 209014.321, 367770.864], [1059.4, 209019.849, 367763.198], [1068.85, 209024.524, 367754.984], [1074.84, 209027.199, 367749.632], [1078.17, 209028.552, 367746.582], [1081.51, 209029.616, 367743.419], [1084.85, 209030.383, 367740.171], [1088.18, 209030.845, 367736.866], [1091.52, 209031.0, 367733.533], [1095.43, 209031.0, 367729.628], [1097.28, 209030.914, 367727.776], [1099.13, 209030.657, 367725.94], [1100.99, 209030.231, 367724.135], [1102.84, 209029.64, 367722.378], [1104.7, 209028.889, 367720.683], [1105.33, 209028.603, 367720.113], [1107.0, 209027.927, 367718.588], [1108.67, 209027.395, 367717.006], [1110.34, 209027.012, 367715.382], [1112.01, 209026.78, 367713.73], [1113.68, 209026.703, 367712.063], [1113.79, 209026.703, 367711.947], [1115.86, 209026.805, 367709.879], [1117.93, 209027.11, 367707.831], [1120.0, 209027.616, 367705.824], [1122.07, 209028.316, 367703.875], [1147.47, 209038.08, 367680.426]], "records": [{"chainage_m": 0, "left_edge_m": 2, "left_end_m": 4.85, "right_edge_m": -2, "right_end_m": -2.79, "left_cut": false, "right_cut": true}, {"chainage_m": 20, "left_edge_m": 2, "left_end_m": 12.55, "right_edge_m": -2, "right_end_m": -5.69, "left_cut": false, "right_cut": true}, {"chainage_m": 40, "left_edge_m": 2, "left_end_m": 14.15, "right_edge_m": -2, "right_end_m": -4.94, "left_cut": false, "right_cut": true}, {"chainage_m": 60, "left_edge_m": 2, "left_end_m": 8.1, "right_edge_m": -2, "right_end_m": -6.04, "left_cut": false, "right_cut": true}, {"chainage_m": 80, "left_edge_m": 2, "left_end_m": 4.6, "right_edge_m": -2, "right_end_m": -6.99, "left_cut": false, "right_cut": true}, {"chainage_m": 96, "left_edge_m": 2, "left_end_m": 9.9, "right_edge_m": -2, "right_end_m": -15.94, "left_cut": false, "right_cut": true}, {"chainage_m": 100, "left_edge_m": 2, "left_end_m": 10.2, "right_edge_m": -2, "right_end_m": -4.99, "left_cut": false, "right_cut": true}, {"chainage_m": 120, "left_edge_m": 2, "left_end_m": 2.9, "right_edge_m": -2.25, "right_end_m": -4.99, "left_cut": false, "right_cut": true}, {"chainage_m": 140, "left_edge_m": 2, "left_end_m": 8.8, "right_edge_m": -2.1068, "right_end_m": -3.9968, "left_cut": false, "right_cut": true}, {"chainage_m": 160, "left_edge_m": 2, "left_end_m": 7.1, "right_edge_m": -2.4951, "right_end_m": -2.6451, "left_cut": false, "right_cut": false}, {"chainage_m": 180, "left_edge_m": 2, "left_end_m": 4.9, "right_edge_m": -3, "right_end_m": -5.69, "left_cut": false, "right_cut": true}, {"chainage_m": 194, "left_edge_m": 2, "left_end_m": 3.35, "right_edge_m": -2, "right_end_m": -3.59, "left_cut": false, "right_cut": true}, {"chainage_m": 200, "left_edge_m": 2, "left_end_m": 9.7, "right_edge_m": -2, "right_end_m": -4.69, "left_cut": false, "right_cut": true}, {"chainage_m": 220, "left_edge_m": 2, "left_end_m": 9.5, "right_edge_m": -2, "right_end_m": -4.04, "left_cut": false, "right_cut": true}, {"chainage_m": 240, "left_edge_m": 2, "left_end_m": 11.75, "right_edge_m": -2, "right_end_m": -5.79, "left_cut": false, "right_cut": true}, {"chainage_m": 260, "left_edge_m": 2.5, "left_end_m": 35, "right_edge_m": -2, "right_end_m": -5.54, "left_cut": false, "right_cut": true}, {"chainage_m": 280, "left_edge_m": 3, "left_end_m": 5.35, "right_edge_m": -2, "right_end_m": -6.49, "left_cut": false, "right_cut": true}, {"chainage_m": 291, "left_edge_m": 2.75, "left_end_m": 2.75, "right_edge_m": -2, "right_end_m": -8.24, "left_cut": false, "right_cut": true}, {"chainage_m": 300, "left_edge_m": 2.5, "left_end_m": 8.6, "right_edge_m": -2, "right_end_m": -4.49, "left_cut": false, "right_cut": true}, {"chainage_m": 320, "left_edge_m": 2, "left_end_m": 17.3, "right_edge_m": -2, "right_end_m": -5.84, "left_cut": false, "right_cut": true}, {"chainage_m": 340, "left_edge_m": 2, "left_end_m": 8.95, "right_edge_m": -2, "right_end_m": -5.74, "left_cut": false, "right_cut": true}, {"chainage_m": 360, "left_edge_m": 2, "left_end_m": 9.6, "right_edge_m": -2, "right_end_m": -4, "left_cut": false, "right_cut": true}, {"chainage_m": 380, "left_edge_m": 2, "left_end_m": 6.9, "right_edge_m": -2.8747, "right_end_m": -4.8647, "left_cut": false, "right_cut": true}, {"chainage_m": 386, "left_edge_m": 2, "left_end_m": 7.6, "right_edge_m": -3.25, "right_end_m": -7.04, "left_cut": false, "right_cut": true}, {"chainage_m": 400, "left_edge_m": 2, "left_end_m": 7.3, "right_edge_m": -3.2197, "right_end_m": -5.4097, "left_cut": false, "right_cut": true}, {"chainage_m": 420, "left_edge_m": 2, "left_end_m": 8.9, "right_edge_m": -2, "right_end_m": -6.74, "left_cut": false, "right_cut": true}, {"chainage_m": 440, "left_edge_m": 2, "left_end_m": 7.35, "right_edge_m": -2, "right_end_m": -6.89, "left_cut": false, "right_cut": true}, {"chainage_m": 460, "left_edge_m": 2, "left_end_m": 2.65, "right_edge_m": -2, "right_end_m": -7.09, "left_cut": false, "right_cut": true}, {"chainage_m": 480, "left_edge_m": 2, "left_end_m": 7.45, "right_edge_m": -2, "right_end_m": -4.59, "left_cut": false, "right_cut": true}, {"chainage_m": 482, "left_edge_m": 2, "left_end_m": 7.4, "right_edge_m": -2, "right_end_m": -4.89, "left_cut": false, "right_cut": true}, {"chainage_m": 500, "left_edge_m": 2, "left_end_m": 9.35, "right_edge_m": -2, "right_end_m": -4.34, "left_cut": false, "right_cut": true}, {"chainage_m": 520, "left_edge_m": 2, "left_end_m": 9.7, "right_edge_m": -2, "right_end_m": -2.8, "left_cut": false, "right_cut": false}, {"chainage_m": 540, "left_edge_m": 2.2998, "left_end_m": 7.0498, "right_edge_m": -2, "right_end_m": -4.14, "left_cut": false, "right_cut": true}, {"chainage_m": 560, "left_edge_m": 2.75, "left_end_m": 2.75, "right_edge_m": -2, "right_end_m": -4.19, "left_cut": false, "right_cut": true}, {"chainage_m": 579, "left_edge_m": 3.25, "left_end_m": 7.45, "right_edge_m": -2, "right_end_m": -4.19, "left_cut": false, "right_cut": true}, {"chainage_m": 580, "left_edge_m": 3.25, "left_end_m": 10.5, "right_edge_m": -2, "right_end_m": -4.24, "left_cut": false, "right_cut": true}, {"chainage_m": 600, "left_edge_m": 2, "left_end_m": 9.75, "right_edge_m": -2.3936, "right_end_m": -4.6336, "left_cut": false, "right_cut": true}, {"chainage_m": 620, "left_edge_m": 2, "left_end_m": 8.75, "right_edge_m": -2, "right_end_m": -4.89, "left_cut": false, "right_cut": true}, {"chainage_m": 640, "left_edge_m": 2, "left_end_m": 6.5, "right_edge_m": -2, "right_end_m": -3.79, "left_cut": false, "right_cut": true}, {"chainage_m": 660, "left_edge_m": 2, "left_end_m": 10, "right_edge_m": -2, "right_end_m": -4.49, "left_cut": false, "right_cut": true}, {"chainage_m": 673, "left_edge_m": 2, "left_end_m": 6.9, "right_edge_m": -2, "right_end_m": -4.24, "left_cut": false, "right_cut": true}, {"chainage_m": 680, "left_edge_m": 2, "left_end_m": 4.85, "right_edge_m": -2, "right_end_m": -4.84, "left_cut": false, "right_cut": true}, {"chainage_m": 700, "left_edge_m": 2, "left_end_m": 9.75, "right_edge_m": -2.75, "right_end_m": -5.5, "left_cut": false, "right_cut": true}, {"chainage_m": 720, "left_edge_m": 2.6949, "left_end_m": 7.7949, "right_edge_m": -2, "right_end_m": -4.74, "left_cut": false, "right_cut": true}, {"chainage_m": 740, "left_edge_m": 3.25, "left_end_m": 3.25, "right_edge_m": -2, "right_end_m": -7.84, "left_cut": true, "right_cut": true}, {"chainage_m": 760, "left_edge_m": 2, "left_end_m": 2, "right_edge_m": -2.2753, "right_end_m": -25, "left_cut": false, "right_cut": true}, {"chainage_m": 766, "left_edge_m": 2, "left_end_m": 30, "right_edge_m": -3.0253, "right_end_m": -7.0653, "left_cut": false, "right_cut": true}, {"chainage_m": 780, "left_edge_m": 2, "left_end_m": 14.15, "right_edge_m": -3.25, "right_end_m": -6.64, "left_cut": false, "right_cut": true}, {"chainage_m": 800, "left_edge_m": 2, "left_end_m": 25, "right_edge_m": -2.3434, "right_end_m": -30, "left_cut": false, "right_cut": true}, {"chainage_m": 820, "left_edge_m": 2, "left_end_m": 14.2, "right_edge_m": -2.5, "right_end_m": -12.64, "left_cut": false, "right_cut": true}, {"chainage_m": 840, "left_edge_m": 2.5, "left_end_m": 10.65, "right_edge_m": -2, "right_end_m": -8.44, "left_cut": false, "right_cut": true}, {"chainage_m": 860, "left_edge_m": 3.25, "left_end_m": 6.65, "right_edge_m": -2, "right_end_m": -6.39, "left_cut": false, "right_cut": true}, {"chainage_m": 861, "left_edge_m": 3.25, "left_end_m": 5.75, "right_edge_m": -2, "right_end_m": -7.99, "left_cut": false, "right_cut": true}, {"chainage_m": 880, "left_edge_m": 2.75, "left_end_m": 4.3, "right_edge_m": -2, "right_end_m": -7.09, "left_cut": false, "right_cut": true}, {"chainage_m": 900, "left_edge_m": 2, "left_end_m": 35, "right_edge_m": -2.75, "right_end_m": -10.09, "left_cut": false, "right_cut": true}, {"chainage_m": 920, "left_edge_m": 2.75, "left_end_m": 2.75, "right_edge_m": -2, "right_end_m": -9.69, "left_cut": false, "right_cut": true}, {"chainage_m": 940, "left_edge_m": 3.25, "left_end_m": 3.8, "right_edge_m": -2, "right_end_m": -6.79, "left_cut": false, "right_cut": true}, {"chainage_m": 956, "left_edge_m": 2, "left_end_m": 5.3, "right_edge_m": -2, "right_end_m": -5.24, "left_cut": false, "right_cut": true}, {"chainage_m": 960, "left_edge_m": 2, "left_end_m": 13.35, "right_edge_m": -2, "right_end_m": -4.19, "left_cut": false, "right_cut": true}, {"chainage_m": 980, "left_edge_m": 2, "left_end_m": 5.4, "right_edge_m": -2, "right_end_m": -6.04, "left_cut": false, "right_cut": true}, {"chainage_m": 1000, "left_edge_m": 2, "left_end_m": 13.85, "right_edge_m": -2.5644, "right_end_m": -5.2544, "left_cut": false, "right_cut": true}, {"chainage_m": 1020, "left_edge_m": 2, "left_end_m": 4.95, "right_edge_m": -2.75, "right_end_m": -6.94, "left_cut": false, "right_cut": true}, {"chainage_m": 1040, "left_edge_m": 2, "left_end_m": 18.8, "right_edge_m": -2, "right_end_m": -4.19, "left_cut": false, "right_cut": true}, {"chainage_m": 1052, "left_edge_m": 2, "left_end_m": 2, "right_edge_m": -2, "right_end_m": -4.64, "left_cut": true, "right_cut": true}, {"chainage_m": 1060, "left_edge_m": 2, "left_end_m": 2, "right_edge_m": -2, "right_end_m": -6.69, "left_cut": true, "right_cut": true}, {"chainage_m": 1080, "left_edge_m": 2.5, "left_end_m": 3, "right_edge_m": -2, "right_end_m": -7.44, "left_cut": true, "right_cut": true}, {"chainage_m": 1100, "left_edge_m": 3, "left_end_m": 30, "right_edge_m": -2, "right_end_m": -8.29, "left_cut": false, "right_cut": true}, {"chainage_m": 1120, "left_edge_m": 2, "left_end_m": 19.2, "right_edge_m": -3, "right_end_m": -5.94, "left_cut": false, "right_cut": true}, {"chainage_m": 1140, "left_edge_m": 2, "left_end_m": 10.15, "right_edge_m": -2, "right_end_m": -4.84, "left_cut": false, "right_cut": true}, {"chainage_m": 1147.47471, "left_edge_m": 2, "left_end_m": 4.75, "right_edge_m": -2, "right_end_m": -3.44, "left_cut": false, "right_cut": true}]} \ No newline at end of file diff --git a/resources/tester/test_364_3_slope_end_round.py b/resources/tester/test_364_3_slope_end_round.py new file mode 100644 index 000000000..119d24e5c --- /dev/null +++ b/resources/tester/test_364_3_slope_end_round.py @@ -0,0 +1,131 @@ +"""364-3 절/성토선 비탈 끝 모서리 둥글림 — 요구 239 브레인 검증 세션 자료로 전후 꺾임. + +자료 `fixtures/b05_364_3_footprint_req239.json` = 세션 측점 줄 70 + 노선. 고치기 전 세션 선: +비탈 끝 30° 넘는 꺾임 왼 14 · 오른 3 · 최대 140.7°(접힘 버린 자리 · S 자 촘촘히 빠짐 · 잔 갈지자). +TS(`B05_Profile_PlanFootprint.ts`) 도 같은 자료로 같은 점인지 본다. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_364_3_slope_end_round.py -q +""" + +from __future__ import annotations + +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)) + +import B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope as plan # noqa: E402 + +DATA = json.loads( + (ROOT / "resources/tester/fixtures/b05_364_3_footprint_req239.json").read_text(encoding="utf-8") +) +ROUTE = [tuple(point) for point in DATA["route"]] +RECORDS = DATA["records"] +END = ROUTE[-1][0] +TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_PlanFootprint.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + + +def _turns(lines: list) -> list[float]: + """조각 안 꺾임(°) — 조각 끝(깎기↔쌓기 바뀜 · 양 끝)은 뺌.""" + return [ + plan._turn_deg(line[i - 1], line[i], line[i + 1]) + for line in lines + for i in range(1, len(line) - 1) + ] + + +def _pieces() -> tuple: + return plan.slope_pieces(ROUTE, RECORDS, 0.0, END, 2.0) + + +def test_비탈_끝_30도_넘는_꺾임() -> None: + edge, fill, cut, _ticks = _pieces() + fill_turns, cut_turns = _turns(fill["lines"]), _turns(cut["lines"]) + over = sum(t > 30 for t in fill_turns + cut_turns) + # 남는 하나 = 측점 860 · 861 사이 1 m 에 비탈 폭 4.4 → 6.0 m(측점 자리라 그대로) + assert over <= 1, over + assert max(fill_turns + cut_turns) < 40.0 + assert sum(t > 15 for t in fill_turns + cut_turns) <= 25 + assert max(_turns(edge["lines"])) < 10.0 # 도로 끝은 손대지 않음 + + +def test_둥글림을_끄면_꺾임이_남음() -> None: + """기준이 바뀐 길을 재는지 — 둥글림만 끄면 30° 넘는 꺾임이 그대로 나옴.""" + keep = plan.ROUND_TURN_DEG + plan.ROUND_TURN_DEG = 1e9 + try: + _edge, fill, cut, _ticks = _pieces() + finally: + plan.ROUND_TURN_DEG = keep + turns = _turns(fill["lines"]) + _turns(cut["lines"]) + assert sum(t > 30 for t in turns) >= 5 and max(turns) > 90.0 + + +def test_측점_비탈_끝은_그대로() -> None: + """남은 측점 비탈 끝 점은 둥글림 뒤에도 같은 자리 점으로 남음.""" + keep = plan.ROUND_TURN_DEG + plan.ROUND_TURN_DEG = 1e9 + try: + _e, fill0, cut0, _t = _pieces() + finally: + plan.ROUND_TURN_DEG = keep + _e, fill, cut, _t = _pieces() + before = {tuple(p) for line in fill0["lines"] + cut0["lines"] for p in line} + after = {tuple(p) for line in fill["lines"] + cut["lines"] for p in line} + for record in RECORDS: + x, y, nx, ny = plan.route_normal(ROUTE, record["chainage_m"]) + for side in ("left", "right"): + offset = record[f"{side}_end_m"] + want = (round(x + nx * offset, 4), round(y + ny * offset, 4)) + if want in before: + assert want in after, (record["chainage_m"], side) + + +DRIVER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { slopePieces } from "./B05_Profile_PlanFootprint.js"; +const c = JSON.parse(readFileSync(process.argv[2], "utf-8")); +writeFileSync(process.argv[3], JSON.stringify(slopePieces(c.route, c.records, 0, c.end, 2))); +""" + + +@pytest.fixture(scope="module") +def ts_pieces(tmp_path_factory: pytest.TempPathFactory) -> dict: + out = tmp_path_factory.mktemp("ts_round") + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext", + "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", + "--noResolve"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") + payload = {"route": ROUTE, "records": RECORDS, "end": END} + (out / "in.json").write_text(json.dumps(payload), encoding="utf-8") + run = subprocess.run( # noqa: S603 + ["node", str(out / "driver.mjs"), str(out / "in.json"), str(out / "out.json")], + capture_output=True, text=True, cwd=str(out), + ) # fmt: skip + assert run.returncode == 0, run.stderr + return json.loads((out / "out.json").read_text(encoding="utf-8")) + + +def test_TS_도_같은_점(ts_pieces) -> None: + edge, fill, cut, ticks = _pieces() + for name, want, got in ( + ("도로 끝", edge["lines"], ts_pieces["edge"]), + ("쌓기", fill["lines"], ts_pieces["fill"]), + ("깎기", cut["lines"], ts_pieces["cut"]), + ("눈금", ticks["ticks"], ts_pieces["ticks"]), + ): + assert len(want) == len(got), f"{name} 선 수" + for line_w, line_g in zip(want, got): + assert len(line_w) == len(line_g), f"{name} 점 수" + for pw, pg in zip(line_w, line_g): + assert abs(pw[0] - pg[0]) < 2e-4 and abs(pw[1] - pg[1]) < 2e-4, (name, pw, pg)