From 837fe3d64a509257f9f35108db3516750fbb3ff4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 11:43:40 +0900 Subject: [PATCH 01/36] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20224=20?= =?UTF-8?q?=EC=9B=90=EB=AC=B8=20=E2=80=94=20B05=C2=B7B06=20=EC=9C=A0?= =?UTF-8?q?=ED=86=A0=EA=B3=A1=EC=84=A0=C2=B7=EB=B0=B0=EC=88=98=EC=9C=A0?= =?UTF-8?q?=EC=97=AD=20=EC=B0=BD=EB=B6=84=EB=A6=AC=20=C2=B7=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EB=85=B8=EC=84=A0=20=EC=B8=A1=EC=A0=90=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=20=C2=B7=20=EC=B6=94=EC=B2=9C=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=EC=98=81=EA=B5=AC=EC=A0=80=EC=9E=A5=20=C2=B7=20=ED=81=B0=20R?= =?UTF-8?q?=20=EC=9A=B0=EC=84=A0=20=C2=B7=20M02=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=97=91=EC=85=80?= =?UTF-8?q?=EA=BC=B4=20=EB=AA=A8=EB=8B=AC=20=C2=B7=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=8B=9D=C2=B7=EC=B6=9C=EC=B2=98=20=EB=A7=A4=EC=B9=AD=20=C2=B7?= =?UTF-8?q?=20CAD=20=EC=97=B0=EC=86=8D=20=EB=AA=85=EB=A0=B9=20=C2=B7=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=AA=A8=EB=8B=AC?= 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_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 1f52bbcf1..a3e1691fb 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -264,3 +264,13 @@ - 단위가 상이한 경우에는 우리의 단가산출 로직에 단위변환에 따른 결과 로직을 부여하여 맞출것 - 신규계획서를 작성이 필요하며 작성전 상세분석을 진행하여 코드의 영향을 분석을 진행. 6. 4번 전역 깃 싱크를 필히 한후 추가 검토 및 반영 업무 진행할 것. +224. (원문 · 10-15) 다름으로 이제 프로그램 개발 작업으로 돌아가자. +1. b05 b06의 공통 기능 중 하단 패널 분리가 있음. 여기에서 창분리를 추가로 하였으면 함. 종단 패널 전체에는 내부에 테이블, 유토곡선, 배수유역 이 보조 패널로 존재함. 이 중 유토곡선과 배수유역을 분리할 수 있게 반영. 버튼은 종단 그래프 좌측 상단의 버튼 양식 참고 +2. b05의 계획노선 편집에서 계획노선 제안이나 기본 노선 사용자 제어기능 중 호의 사이에 존재하는 측점은 호의 중심에 존재해야한다함. 일종의 스냅기능이 필요함. 계획노선 편잡창에 측점 스냅 버튼을 추가하여 활성화 비활성화 할수 있게 반영. +3. 계획노선의 추천기능은 현재 서버에서 1분여간 걔산하여 보여줌. 이기능은 시점과 종점이 변경되거나 옵션값이 변경되지 않는다면 변경되지 않음. 사용자 편의성을 위해 미리 영구저장소에 값을저장하여 보여주고 옵션이나 시종점이 바뀐경우에만 신규추가할것. 기존 기본값들은 항상보관. +4. 계획노선의 결국 초기값은 예정노선을 따라가는 커브임. 이때 추천 선택지애 따라 연산한 결과들을 보여주고 적용할텐데 이때 추천노선 전체의 r값은 최소 12m 가 아니라 클수록 평면 계획노선이 좋음. 무조건 좋다라기 보다는 결국 현재노선 로직에서 추가로직으로 정리하는 방향이 좋을 수도 있음. +5. m02의 외부문서의 일위대가를 가져온 기능에 초기에 모달창으로 해당정보를 엑셀에서 열은것처럼 모달창에서 보여주고 사용자가 호표를 선택하면 영역을 하이라이트 하거나 한번에 불러오는 기능이 있으면 좋겠음. +6. m02의 외부문서에사 일위대가를 가져올 경우 이름이 많이 다름. 다른 외부문서에서도 동일함. 그러니 이름을 보고 매칭하는 기능도 좋지만 연결된 단가산출 탭의 계산식과 출처들을 참조한다면 정합성을 높일수 있을것같음. +7. 내장 캐드프로그램에는 오토캐드 프로그램을 참고하여 만들었음. 기능 중 명령어 중에 연속 명령어로 제어할 수 있는 기능이 필요. 예를들어서 산 단축키 입력> 각도지정> 길이입력 등임. 연속적으로 키보드로 작동하는 기능임. 이건 인터넷 조사하여 오토캐드에서 어떤식으로 제어기능을 제공하는지 분석필요함. +8. 우리 프로그램에는 마우스로 팬, 줌인아웃, 선택 기능들이 사용자가 제어할 수 있는 기능이 있음. 일체반영을 위해 햄버거 메뉴 안에 설정버튼을 추가하고 별도모달창에는 탭으로 구분하여 마우스 제어방법이나 오토캐드의 단축키, 다른 곳에서의 단축키 설정, 나중에는 페이지들 기본 옵션값들을 사용자가 조절할 수 있는 기능을 부여하려고 함. (기본값은 지금은 아님. 내가 별도 요청) +9. 신규 계획서를 작성하기 전 현재의 코드나 외부정보를 먼저 정밀 분석하고 진행. 신규계획서를 작성 후에는 업무를 분배하여 진행하고 너는 최종 완료시점에 사용자처럼 브라우져 제어하고 보고서를 아티팩트로 공유. From b5b6264016f1d8b48c4d209b327fa95e86448983 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:12:08 +0900 Subject: [PATCH 02/36] =?UTF-8?q?chore(=EC=A1=B0=EC=82=AC):=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20224=20=EC=A0=95=EB=B0=80=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?=E2=80=94=20325=20=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0=C2=B7?= =?UTF-8?q?=EB=B0=B0=EC=88=98=EC=9C=A0=EC=97=AD=20=EC=B0=BD=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=20326=20=EC=B8=A1=EC=A0=90=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=20=C2=B7=20327-328=20=EC=B6=94=EC=B2=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=C2=B7=ED=81=B0=20R=20=C2=B7=20329-330=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=B0=BD=C2=B7=EA=B7=BC=EA=B1=B0=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=C2=B7=20331=20CAD=20=EC=97=B0=EC=86=8D=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9(=EC=98=A4=ED=86=A0=EC=BA=90=EB=93=9C=20=EC=A1=B0?= =?UTF-8?q?=EC=82=AC)=20=C2=B7=20332=20=EC=84=A4=EC=A0=95=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC?= 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_01C6BNBFXFndbqYxu6sZuknK --- ...2026-10-15_325_유토곡선_배수유역_창분리.md | 47 +++++++++++++ docs/raw/조사/2026-10-15_326_측점스냅.md | 35 ++++++++++ .../2026-10-15_327-328_추천노선_저장_큰R.md | 53 +++++++++++++++ ...15_329-330_외부일위대가_보기창_근거맞춤.md | 52 +++++++++++++++ docs/raw/조사/2026-10-15_331_CAD연속명령.md | 66 +++++++++++++++++++ docs/raw/조사/2026-10-15_332_설정모달.md | 64 ++++++++++++++++++ 6 files changed, 317 insertions(+) create mode 100644 docs/raw/조사/2026-10-15_325_유토곡선_배수유역_창분리.md create mode 100644 docs/raw/조사/2026-10-15_326_측점스냅.md create mode 100644 docs/raw/조사/2026-10-15_327-328_추천노선_저장_큰R.md create mode 100644 docs/raw/조사/2026-10-15_329-330_외부일위대가_보기창_근거맞춤.md create mode 100644 docs/raw/조사/2026-10-15_331_CAD연속명령.md create mode 100644 docs/raw/조사/2026-10-15_332_설정모달.md diff --git a/docs/raw/조사/2026-10-15_325_유토곡선_배수유역_창분리.md b/docs/raw/조사/2026-10-15_325_유토곡선_배수유역_창분리.md new file mode 100644 index 000000000..0a5ed5811 --- /dev/null +++ b/docs/raw/조사/2026-10-15_325_유토곡선_배수유역_창분리.md @@ -0,0 +1,47 @@ +# 325 유토곡선 · 배수유역 창분리 (요구 224-1) + +기준 = 837fe3d64. B05 · B06 종단도는 한 벌(`B05_Profile_UI_Profile_Panel.ts:30` · `B06_Section_UI_Section_View_Profile.ts:277` → `sharedProfile`) — 한 번 고치면 두 보기에 같이. + +## 지금 — 종단 [창 분할] + +- 단추: `A00_Common/design/design_shared.ts:153,158` 빈 button → `toolLead` · `:181` `attachProfilePopout`(`design_profile_popout.ts:25-29`) · 글 「창 분할」/「창 결합」 · 툴팁 `title` · 클래스 `ui-btn ui-btn--ghost b05-route-profile__popout`. +- 열기: `window.open("", "aislo-profile-popout", "popup,width=1400,height=720")`(`:52`) · 막히면 토스트(`:54`) · 스타일 · html 속성 · body 배경 열 때 한 번 베낌(`:61-70`) · 종단 뿌리 DOM 을 새 창 body 로 옮김(`:71-73`). +- 동기화: 따로 없음 — 코드 · 가게 · sessionStorage 는 메인 것 · 같은 함수가 다른 창 DOM 을 그림(BroadcastChannel · postMessage 안 씀) → 고름 · 커서 · 확대 · 스크롤 저절로 맞물림. +- 이벤트: 새 창 keydown/keyup/pointerdown/pointerup/pointercancel · blur 를 메인 document 로 다시 쏨(`:14,76-90`) · 새 창 ResizeObserver → `profile.redraw`(`:93-96`). +- 되돌림: 새 창 pagehide → `slot.append` + 다시 그림(`:31-38,97`) · 메인 pagehide · 페이지 떠남(`design_page.ts:263` → `profile.dispose`) → 새 창 닫고 되돌림(`:39-49`). +- 화면 틀 · 시계 = 요소가 놓인 창 것(`common_util_view_window.ts:11-18` · 230-1/2) · 창 자리 · 크기 기억 없음(매번 1400×720) · 테마는 열 때만 베낌 · CSS `design_page.css:43-56`. + +## 종단 안 보조 판 셋 + +| 판 | 만드는 곳 | 그리는 곳 | 접기 · 크기 | +|---|---|---|---| +| 표 | `design_profile_overlays.ts:61-63` · `_Profile_TableOverlay.ts` | `_Profile_Render.ts:173-199,409-411` | 손잡이 `table-open` · `table-height` | +| 유토곡선 | overlays `:48-59` → `B05_Profile_UI_Profile_MassHaul.ts:190`(손잡이 200-204 · 판 208-249) | `_Profile_Render.ts:415-458` → `MassHaul.draw :360-489` | `masshaul-open`(:220,264-279) · 높이 `masshaul-height`(:224-242) · 범례 `masshaul-visible`(:281-286) | +| 배수유역 | `design_profile.ts:181-199` `drainageSide`(`_Profile_SideTool.ts:11`) → `B05_Profile_UI_Drainage_Side.ts:26` → `_Drainage_Panel.ts:79` · 뼈대 `_Drainage_Chrome.ts:54-149` | 자체 캔버스 `_Drainage_Panel.ts:235-316` | `drainage-collapsed`(:556-567) · 폭 `drainage-width`(Chrome :139-147) | + +- 배치: 뿌리 = [「종단」 손잡이][높이 끌기][content](`design_profile.ts:207`) · content = [bodyWrap][배수 판](`:198-199`) · 유토 · 표 판은 bodyWrap 바닥에 absolute 로 겹침(`_Style_MassHaul.css:54-66,168-180`) · 높이 나눔 `_Profile_Heights.ts:66-179` · 유토는 종단 본문 크기 0 이면 건너뜀(`_Profile_Render.ts:125`). + +## 종단 그래프 좌측 상단 단추 줄 + +- DOM: `.b05-route-profile__balance`(28px · `_Style.css:533-547`) 맨 앞 `span.b05-route-profile__tools` 안 [창 분할] + `button.b05-route-profile__tool` 8개(초기화 ↶ ↷ 직선화 자유지정 쉬프트 ▲ ▼). +- 만드는 코드: `tools.render()`(`_Profile_Tools.ts:161-232` · 단추 함수 69-80) → `withToolLead`(`design_profile.ts:296-299`) → `renderBalanceBar`(`_Profile_Balance.ts:48-51`) · 그릴 때마다 새로. +- 양식: 글자 단추 · `title` 툴팁 · `--btn-bar-padding` · `--btn-bar-font-size`(`ui_template_theme.css:169-170` · 높이 16 · 글 12) · [창 분할] = ghost + `design_page.css:53-56`. + +## 뗄 때 손볼 곳 + +1. 높이 나눔 · 끌기가 「`overlay.hidden` 거짓 = 열림」만 봄(`_Profile_Heights.ts:70` · `_Profile_Drag.ts:146,172,194`) → 떼어도 메인이 자리 비움 · 높이 끌기 놓으면 새 창 높이가 저장 높이로 굳음 → 「열림 && 뿌리 안」. +2. 유토 손잡이가 메인에 남음 → 뗀 동안 숨김. +3. 「같이 줄이기」 인라인 높이(`_Profile_Heights.ts:147`)가 새 창에 남음 → 뗄 때 비움. +4. 유토 세로 범위가 메인 보이는 구간을 따름(`_Profile_Render.ts:441,453-458`) → 새 창이 넓으면 잘림 → 뗀 동안 `viewRange` 안 씀(`MassHaul.ts:441`). +5. 배수 지도 크기 관찰 = 메인 창 ResizeObserver(`_Drainage_Parts.ts:468`) → 놓인 창 것으로 바꿔 끼우는 `rehome`(직전 크기 지켜 배율 유지). +6. 방향키 ←→ 는 마지막 누름이 `.b05-route-profile` 안일 때만(`B06_Section_UI_Cross_Panel_Base.ts:92`) → 선택자에 두 판 클래스. +7. 종단 새 창 안에서 다시 떼면 메인 `window.open` 이 팝업 차단될 수 있음 → 단추가 놓인 창으로 엶. +8. 창 이름은 조각마다 달리(`aislo-masshaul-popout` · `aislo-drainage-popout`) — 같으면 앞 창 body 를 덮어 조각이 사라짐. +9. 한계: 메인 종단 패널 접으면 뗀 유토 멈춤 · 유토 창으로 메인을 다 가리면 메인 화면 틀이 멈춰 늦음 · 토스트 · 좌측 폼은 메인 창. + +700줄: `design_profile.ts` 700(시험 고정) · `design_view_profile.ts` 700 → 손대지 않음 · `_Drainage_Panel.ts` 700 → 한 줄 더하면 주석 합쳐 700. 다른 224 일감과 겹치는 파일 없음. + +## 시험 + +- 있음: `test_282_1_profile_toolbar.py`(+ helper · 가짜 require 가 `showToast` 만 → popout 이 `viewOf` 를 가져오면 helper 도 고치거나 `button.ownerDocument?.defaultView ?? window`) · `test_187_1_b05_head_button_size.py`(`.b05-route-profile__tools > .b05-route-profile__popout {` 블록 정규식 → 새 단추 규칙은 따로) · `test_230_1_popout_wheel.py` · `test_230_2_popout_frames.py`(`POPOUT_FILES` 메인 창 rAF/setTimeout 금지) · `test_219_1_arrow_owner.py` · `test_design_profile_single.py` · `test_b05_drainage_side.py`(배수 파일은 `UI_Profile_` · `B06_Section` import 금지 · ≤700) · `test_281_1_drainage_zoom_range.py`. +- 새로: `test_224_1a_drainage_rehome.py`(+ cjs) · `test_224_1b_masshaul_docked.py`(+ cjs) · `test_224_1c_subpanel_popout.py`(+ cjs · 단추 자리 · 글 · 클래스 · 창 이름 셋 다름 · 열림/닫힘/dispose 제자리 · 손잡이 숨김/보임). diff --git a/docs/raw/조사/2026-10-15_326_측점스냅.md b/docs/raw/조사/2026-10-15_326_측점스냅.md new file mode 100644 index 000000000..633ba2d57 --- /dev/null +++ b/docs/raw/조사/2026-10-15_326_측점스냅.md @@ -0,0 +1,35 @@ +# 326 계획노선 편집 — 측점 스냅 (요구 224-2) + +기준 = 837fe3d64. + +## 해석 + +- 「호의 사이」 = BC~EC 누가거리 구간(서버는 곡선표 BC · EC 를 노선에 내려 구함 `B05_Profile/B05_Profile_Engine_Sections_Core.py:241-275` · 5 m 밖 곡선 버림 `:221`). +- 「호의 중심」 = 원 중심(노선 밖)이 아니라 곡선 중점 MC(IP 에서 내각 2등분선으로 외할장 SL · 누가거리로 BC~EC 한가운데 · `resources/knowledge/original/산림과임업기술(임도)/2. 임도/5. 설계.md:191`). +- 근거: 곡선부 BC · MC · EC 보조측점(`…/4. 노선측량.md:144`) · 「측점간의 번호는 20m이내에서 조정」(`:143`) · 교재 예시 표(`5. 설계.md:182-191`). +- 후보: (가) 호 안 20 m 측점을 MC 로 옮김 — 채택 · (나) 편집 손잡이를 IP 대신 MC 에(「측점」 말과 안 맞음) · (다) 노선을 움직여 MC 를 격자에(곡선 수십 곳 동시 불가). +- 실제 어긋남(저장소 자료 셈): 1078 m · 곡선 26 · R 12~199 → 호 안 측점 14곳 · MC 와 최대 11.3 m(호 616.1~646.5 · MC 631.3 에 620 · 640 둘) · 1153 m · 곡선 50(R 12 · CL ≤ 5.6) → 호 안 11곳(2.7 m 안) · 측점 없는 호 39곳. 측점 = 노선 모양과 상관없는 20 m 격자라 어떤 조작이든 같음. + +## 지금 + +- 편집 창: `A00_Common/design/design_view_profile.ts:278-286` → `openRouteEditModal`(`B05_Profile/B05_Profile_UI_RouteEdit.ts:104-110`) · 단추 줄 `_RouteEdit_Chrome.ts:51-78`([노선 추천] | 분석 지형 · [1m 등고선] · [거리 재기] | 되돌리기 · 다시하기 · 초기화 · 취소 · 확인) · 켬끔 본보기 = [1m 등고선](`aria-pressed` + `is-active` `:63-65` · 배선 `_RouteEdit_Contour.ts:136-148`). +- 조작: 노드 끌기 `_RouteEdit_Pointer.ts:265-294` · 곡선 손잡이 BC · EC `:241-263` + `_RouteEdit_Curve.ts:89-109` · R · 곡선 길이 · 고정 `_RouteEdit_CurveBar.ts:61-116` · 두 번 = 노드 더함 · 오른쪽 = 지움(`RouteEdit.ts:494-512`) · 측점 눈금 누름 = 횡단 미리보기(`_Pointer.ts:186-201`) · 추천 [적용] `_RouteEdit_Initial.ts:132-160` → `RouteEdit.ts:428-434` · 기본 노선 `_ensure_planned_initial`(`B05_Profile_Router_Replan.py:339-379`) · [초기화] `_Initial.ts:164-197` · `Router_Replan.py:282-297`. +- 계산: 편집 중 TS 짝 `buildEditedPolyline`(`_Curve.ts:226-327`) · [확인] 서버 `replan`(`Router_Replan.py:580-669`) 전 체인. +- 곡선 자료: apex · radius_m · tangent_m · inner_angle_deg · start(BC) · end(EC)(서버 `common_util/common_util_route_polyline.py:104-141` · TS `_Curve.ts:32-42`) · 원 중심 셈만(`:130-152`) · MC 셈 코드 없음. +- 측점 정본 `generate_sections`: 20 m 격자 `_chainages`(`_Sections_Core.py:86-92,333`) · 관 · 구조물 0.005 m 안이면 격자에 얹음(`:338-357`) · 종류 bp/ep/irregular/regular(`:411-424`) · 곡선표는 확폭에만(`:368-385`) · BC · MC · EC 측점 안 심음 · 부르는 곳 둘 = `run_section_generation`(`B05_Profile_Engine_Sections.py:392-438`) · `sample_structure_sections`(`:513-551`) · 곡선표 읽기 `_load_design_curves`(`:71-96`). +- 편집 창 눈금: `drawStationTicks`(`B04_PreProcess_UI_MapOverlays.ts:225-260`) ← `_RouteEdit_Render.ts:461-482` · 같은 격자 따로 셈 = 누름 `_Input.ts:368-407` · 기울기 라벨 `_Slope.ts:54-60` · 종단 띠 `_ProfileStrip.ts:26-60` · 횡단 미리보기 서버 = 가장 가까운 격자(`Router_Terrain.py:172-184`). +- 측점 한 벌(272): `plantedChainages`(`common_util_structure_station.ts:235-242`) ↔ `resolve_extra_stations`(`Engine_Sections.py:250-275`) · `oneStationList`(`ts:254-274`) ↔ `_merge_irregular_into_longitudinal`(`Router_Confirm.py:295-343`) · `followMovedNodes`(`ts:276-298`) ↔ `_follow_moved_stations`(`Router_Confirm.py:204-292`) · 거울 시험 `test_272_1_station_list.py` · 정수 m = 구조물 · 관만 floor(x+0.5)(`common_util_structure_station.py:38-45` · `.ts:51-60`). + +## 구현 자리 (짝 ① · 조작 중 TS · [확인] 서버 정본) + +0. 쪼개기: `Router_Replan.py` 698줄 → 요청 모델 다섯(`:72-138`)을 `B05_Profile_Schema.py` 로(Replan 에서 다시 import · `replan.RouteInitialRequest` 그대로). +1. 짝 셈: `_Sections_Core.py` `snap_curve_stations(stations, spans)`(호 안 BC+0.005 < s < EC−0.005 측점 중 MC 에 가장 가까운 하나 → floor(MC+0.5) · 같은 m 하나로) · `generate_sections(..., station_snap=False)`(spans 셈을 앞으로 · 격자 붙임은 스냅 뒤 목록) ↔ `_RouteEdit_Frame.ts`(77줄 · Sections_Core 짝 · `snapCurveStations` · `curveSpans` · `stationsAlong` · import 없이). +2. 저장: 곡선표 `planned_route_curves.json` 에 `"station_snap"`([확인] 때만 · 작업본 · 스냅샷이 route 폴더째 복사 `common_util_initial_snapshot.py:39`) · 없으면 끔 · DB 옵션에 안 둠(`SectionGenerationOptions` 다시 짓는 곳 여섯) · `Router_Replan.py` 요청 칸 → `_write_planned_polyline`(`:300-336`) · GET `/route/plan` 돌려줌(`:558-577`) · 실패 되돌림(`:607-639`)에 `_curves.json` · `_nodes.csv` 도(기존 틈) · `Engine_Sections.py` `load_station_snap` → `run_section_generation` · `sample_structure_sections` 둘 다(한쪽만이면 [저장] 때 640 되살아남) · `Router_Terrain.py` 횡단 미리보기 `station_snap` + `design_curves=built.curves`. +3. 편집 창: `_RouteEdit_Chrome.ts` [측점 스냅](1m 등고선 양식 · [거리 재기] 옆) · `RouteEdit.ts`(635줄) 상태 = 메모리 모드(되돌리기 밖) · 열 때 GET 값 · `stations()` 한 곳 → 그리기 · 누름 · 기울기 · 횡단 요청 · [확인] `changed` + `station_snap` · 함께 `_RouteEdit_Render.ts` · `B04_PreProcess_UI_MapOverlays.ts`(`chainages` 선택 칸) · `_Input.ts`(`stationAtScreen` 선택 칸 · 라벨 판정 값으로) · `_Pointer.ts` · `_Slope.ts` · `_Apply.ts` · `B05_Profile_Api_Replan.ts` · 선택 칸은 뒤에(B04 지도 · 배수유역도 · `helper_b05_routeedit_pick.cjs` 그대로). + +영향: B05 종단 · 3D · B06 · B07(`Router_Support_Basin.py:280-290`) · B08 = 서버 측점 목록 따라감 · 옮긴 측점의 B06 사용자 입력은 누가거리 정확 맞춤 이월이라 안 넘어감(`B03_FileInput_Service_Chain.py:574-586`) · 추천 비교값 · 종단 띠 균형 종단 = 20 m 격자 그대로 · B04 지도 · 배수유역도 눈금 격자 그대로(`_Drainage_Panel.ts` 700줄) · `common_util_route_polyline.py`(745줄) 손대지 않음. + +## 시험 + +- 새로: `test_224_2_station_snap.py`(tsc 컴파일해 파이썬과 견줌 · 곡선 없음 · 측점 없는 호 · 하나 · 둘 · BC 위 · .5 올림 · 이웃 격자 같은 m · `generate_sections` 끔 = 지금과 같음 · 켬 = 640 없음 · 631 있음 · 631 에 관이면 `grid`). +- 있음: `test_74_route_confirm_initial` · `test_route_initial` · `test_64_1_profile_toggle` · `test_b05_routeedit_frame_mirror` · `test_b05_routeedit_pick` · `test_b05_routeedit_chrome_modal` · `test_b05_routeedit_cross_width` · `test_272_1_station_list` · `test_272_2_station_sync` · `test_238_1_save_keeps_structure_stations` · `test_273_2_preview_stations` · `test_b05_extra_stations_supplier` · `test_regeneration_extra_stations` · `test_b05_station_roundtrip` · `test_b06_curve_widening` · `test_75_profile_strip`. diff --git a/docs/raw/조사/2026-10-15_327-328_추천노선_저장_큰R.md b/docs/raw/조사/2026-10-15_327-328_추천노선_저장_큰R.md new file mode 100644 index 000000000..86771733b --- /dev/null +++ b/docs/raw/조사/2026-10-15_327-328_추천노선_저장_큰R.md @@ -0,0 +1,53 @@ +# 327 · 328 계획노선 추천 — 영구 저장 · 큰 R (요구 224-3 · 224-4) + +기준 = 837fe3d64. 경로는 저장소 뿌리 기준. + +## 지금 — 추천 계산 길 + +- 화면: 편집 창 [노선 추천] → 모달 `B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts:132-162` · 알약 다섯(예상노선 추적 · 영선 · 토공 최소 · 기울기 최소 · 비중 · `_Recommend.ts:66-74`) · 열면 지금 갈래 한 번 셈 `:552-554` · 알약 = `run() :423-480` · 서버 갈래 여덟 중 safe · crossings · cost 숨김(`_Initial_Model.ts:439`) · [적용] = 편집값 + sessionStorage `routeedit-initial`(`_Initial.ts:151-159` · `_Initial_Model.ts:561-564`) · 서버 안 씀 · 편집 창 [확인] → `POST /route/replan`(`_RouteEdit_Apply.ts:86-101`) → 작업본 `planned_route.csv` · `_nodes.csv` · `_curves.json` + `B05_Profile/route/initial_choice.json`(갈래 이름) + initial_snapshot 다시 뜸(`B05_Profile_Router_Replan.py:607-663`). +- API: `POST /api/projects/{id}/route/initial`(`B05_Profile_Router_RouteInitial.py:27-126` · 화면 대기 5분 `B05_Profile_Api_RouteInitial.ts:15`) · 입력 = 예상노선 `B05_Profile/route/expected_route.csv`(없으면 initial_snapshot) `:59-61` · 최소 R `_plan_criteria :67` · 종단 상한 `:68-70` · 제약 칸(기울기 · 최소 R · 계류 이격 100 · 지반 고름 5 · 영선 목표) `:72-79` · 지형(확정 지표면 + 분석 지형) `:80-83` → 비용면 캐시 `B05_Profile_Engine_Solver.py:214-257` · 계류 `도엽_하천중심선.geojson` 소하천 이상(`Engine_RouteInitial.py:87-137`) · 관 지점 · mode · weights(50/25/25) · with_profile · design_speed. +- 차례(`generate_initial_route` `Engine_RouteInitial.py:212-350`): ① 예상노선 추적 = 전처리 초기값 규칙(0.4 초) ② 나머지 = 후보 묶음(입력 지문 `_pool_key :361-372` · 메모리 4벌 `_POOLS :108-110`) — 격자 탐색(예상노선 ±100 m 복도 · 4 m 칸 · 16방향 Dijkstra · `Search.py:236-301`) · 비용(이격 · 토공 · 기울기² · 영선 · 위험 · 계류 · 길이 · 꺾임 R·θ) · 후보(목표 7 × 상한 3단 + 쓸기 11 · `Pool.py:50-213`) · `_trace Pool.py:99-114` → `route_metrics Metrics.py:115-293` · 균형 종단(후보 전부 대충 → 고른 하나 바로 · ±60 m 횡단 · 계획고 ±6 m · B06 `compute_cross_design` · `Profile.py:66-402`). +- 1분 까닭: 같은 입력 첫 셈 35~80 초 · 같은 묶음 다른 갈래 3~5 초 · 묶음 짜기 1.9~2.7 초 · 대부분 = 후보 전부 균형 종단(63-4 때 1.8 → 18.3 초) · 메모리만이라 재시작 · 리로드 · 입력 바뀜마다 다시. +- 저장 없음: 남는 것 = 메모리 `_POOLS` · sessionStorage 한 벌 · `initial_choice.json`. 머리글 「파일 안 씀」 = `Engine_RouteInitial.py:6` · `Router_RouteInitial.py:33` · `Router_Replan.py:29` · `Profile.py:4-5`. + +## 327 저장 설계 + +- 키: 시점 · 종점만으론 부족(추천은 예상노선 전체 씀) · 기존 `_pool_key`(지형 · 예상노선 · 계류 · 상한 · 최소 R · 이격 · 고름 · 목표 기울기 내용 해시) + mode · weights · with_profile · design_speed · `ALGO_VERSION`(코드 판 · 선례 `B04_PreProcess_Router_Watershed.py:82-87,227-242` `RESPONSE_SCHEMA_VERSION`) · 관 지점은 뺌([확인] 때마다 다시 냄 · 덮개 수는 꺼낼 때 다시 셈 `Profile.py:432-451`). + +| 자리 | 판단 | +|---|---| +| `B05_Profile/route_recommend/<키>.json` | 씀 — initial_snapshot 대상(`common_util_initial_snapshot.py:38-43`) 밖이라 [초기화](`:154-159,260-274`)에 안 휩쓸림 · 자료 교체(`common_util_project_reset.py:23-54`) · 프로젝트 삭제 때 같이 지워짐 | +| `B05_Profile/route/` | 안 됨 — [초기화]가 되돌림 | +| initial_snapshot | 안 됨 — 쓰는 때 정해짐 · [확인]마다 통째 지움(`Router_RouteInitial.py:284` · `B03_FileInput_Service_Chain.py:449`) | +| DB 표 | 안 씀 — 마이그레이션 · 정리 목록까지 | + +- 층 = 「서버 계산 캐시」(데이터 3층 밖 · 선례 비용면 npz `Solver.py:169-257` · 코리도 사전 생성 · 배수유역 응답) · 사용자 편집값 아님 → 자동저장 금지와 안 부딪힘. +- 꼴: 키마다 파일 하나 `{version, key, pool, default, created_at, request, result}` · 있으면 셈 안 함 · 없으면 셈해 더함(덮어쓰기 없음) · `default` = 빈 제약 칸 + 50/25/25 + 설계 지형 · 이 표시는 안 지움 · 한 벌 10~30 KB. +- 무효화: 지형 · 예상노선 · 계류 · 설정 바뀜 = 다른 키(옛 파일 남김) · 코드 바뀜 = `ALGO_VERSION` 올림 · 자료 교체 = 폴더째. +- 미리 셈: 체인 끝(`Service_Chain.py:225-227` 예상노선 · `:343-351` 스냅샷 뒤)에서 예약만(`_schedule_background_task` `B03_FileInput_Router_Helpers.py:279-299` · 체인이 [초기화] 요청 안에서도 불림 `B05_Profile_Router_Lifecycle.py:246`) · 다섯 알약 ≈ CPU 1~1.5 분 · 진행 중 같은 키 = 기다림. + +## 328 큰 R + +- R 정하는 자리: `common_util/common_util_route_polyline.py:670-717`(접선 자리 = 앞뒤 직선 짧은 쪽 절반 `:673` · 기본 최소 `:682` · 못박은 R `:683-685` · R 맞춤 `_fit_radius_m :358-391,686-697` = 최소 이상에서 예정노선과 평균 거리 최소 · 단순화 갈래에서만 · 같은 방향 꺾임 묶기 `:449-576`) · 추천은 예상노선 추적만 맞춤 · 나머지 `_trace` = 편집 갈래 → R = 최소 → **추천 곡선 전부 12 m**(자리 모자라면 더 작게 + 위반 `:698-716`). +- 12 m 출처: 설계속도 20 · 특수지형 `config/config_system_design.py:521-525` ← `B05_Profile_Engine_Grade.py:142-151` ← `Router_Replan.py:188-226`. +- 제약: 이웃 접선 겹침(절반씩) · 탐색 경로 이탈(외할 E = R(sec(IA/2)−1)) · 종단경사 · 계류(맞춤 뒤 다시 잼 · 위반 늘면 안 됨) · 토공(R 크면 확폭 줄어 토공 줄음 `Profile.py:127-151`) · 곡선 길이 하한(법정 없음 · 표시 5 m `config_system_design.py:548-557`). +- 방안 A(1단 · 「현재 노선 로직」 재사용): `_trace` 에서 탐색 경로를 예정노선처럼 폴리라인화 `build_planned_polyline(densify_route(nodes, 2 m), min_radius_m)` → 같은 단순화 · 묶음 · R 맞춤 · 최소 R 판보다 위반 많으면 최소 R 판 · 응답 꼴 같아 화면 `flattenServerPlan`(`_RouteEdit_Edits.ts:232-272`) 그대로 · +5~12 초. +- 방안 B(2단 · 「클수록」): A 곡선마다 R 을 [맞춘 R, 접선 자리 ÷ tan(IA/2)] 안에서 탐색 경로와 최대 거리 ≤ 허용 이탈인 가장 큰 값 → 못박아(`simplify=False · radii`) 다시 짬(브라우저 짝 있음) · +1~3 초 · 허용 이탈 값은 사용자 확정. +- 방안 C(탐색 자체 큰 R): 층 수 ∝ R · 1.5~2.5배 느림 · 안 씀. +- 붙일 곳 `_trace` 한 곳 → 영선 · 토공 최소 · 기울기 최소 · 비중(+숨긴 셋) · 예상노선 추적은 그대로(`test_route_initial.py:102` · `Router_Replan.py:300-336` · 09-07 확정 「예정노선에 가장 가까운 R」). +- 손대지 않음: `common_util_route_polyline.py`(745줄 · TS 짝) · `Router_Replan.py`(698줄). 설명 정정: `B05_Profile_UI_RouteEdit_Recommend_Help.ts:32` 추적 「최소 R」 → 「맞춘 R」. + +## 지식DB 근거 (값은 안 정함) + +- 법정 최소곡선반지름(별표2 Ⅰ.2.다): 설계속도 40 → 60/40 · 30 → 30/20 · 20 → 15/12(일반/특수) · 중심선 기준 · 내각 155° 이상 곡선 생략 · 배향곡선 10 m — `resources/knowledge/original/법률/산림자원의 조성 및 관리에 관한 법률 시행규칙/별표/별표2_…(제5조제1항 관련).md:151-165`(산불진화임도 `:436-450`). +- 정리본 `resources/knowledge/technical_info/01_임도/02_상세설계/평면선형.md:17-72`(작업임도 규정 없음 · 확폭 · 45 m 이상 확폭 불요 · 곡선반지름 확보 우선). +- 「곡선반지름은 … 가급적 큰 것이 좋음」 — `original/임도기술교본/1. 교본의 개요/3. 용어해설.md:95` · 확보 우선 `3. 임도 계획과 설계/3. 실시설계.md:229,273`. +- 산식 · 표 `original/산림과임업기술(임도)/2. 임도/2. 임도구조.md:65-106`(R = ℓ²/4B · 20 m 목재 · 노폭 4 m → 25 m · R = V²/127(i+f) · 20 km/h 14~16 · 30 → 31~35 · 40 → 55~63) · 곡선부 종단 G − 80/R > S `:221-226` · K = R/I ≥ 3.0 `:235-248` · 예각 내각은 R 크게 못 둠 `:250`. +- 25 m 이하 = 25톤 대형차 곤란 · 구조개량 대상 — `original/행정규칙/임도설치 및 관리 등에 관한 규정/별표/별표3_임도구조 개량사업(제17조 관련).md:25,55,77`. +- 곡선 요소 식(TL · CL · SL · M · 접선 겹침) — `technical_info/01_임도/03_계산정보/곡선설치_계산.md:19-34`. + +## 시험 + +- 추천: `test_route_initial.py` · `test_63_4_route_profile.py` · `test_77_recommend_widening.py` · `test_78_recommend_mass.py` · 화면 `test_b05_routeedit_initial.py`(+ cjs) · `test_b05_routeedit_initial_roundtrip.py`(+ cjs). +- R: `test_route_polyline*.py`(curve_fit · edit · apex_roundtrip · idempotent · handle_drag) · `test_user_values_survive.py` · `test_plan_curve_limits.py` · `test_b05_curve_radius_floor.py` · `test_initial_snapshot.py`. +- 새로: `test_224_3_recommend_store.py`(같은 입력 같은 키 · 하나만 바뀌어도 다른 키 · 두 번째 셈 0회 · 덮어쓰기 없음 · 스냅샷 복원 뒤 남음) · `test_224_4_recommend_radius.py`(R ≥ 최소 · 하나 이상 큼 · 위반 ≤ 최소 R 판 · 추적 그대로 · (B) 이탈 ≤ 허용). diff --git a/docs/raw/조사/2026-10-15_329-330_외부일위대가_보기창_근거맞춤.md b/docs/raw/조사/2026-10-15_329-330_외부일위대가_보기창_근거맞춤.md new file mode 100644 index 000000000..7bc52d850 --- /dev/null +++ b/docs/raw/조사/2026-10-15_329-330_외부일위대가_보기창_근거맞춤.md @@ -0,0 +1,52 @@ +# 329 · 330 외부 일위대가 — 엑셀 보기 창 · 단가산출 근거 맞춤 (요구 224-5 · 224-6) + +기준 = 9edaec82e(324 끝). M02 = `M02_MasterTemplete/`. + +## 지금 + +- 올리기: `Hopyo_Side.ts:38-72` → Router `:201-216` → `_upload :174-179` → `Excel.read_book` → `Store.save :72-87` — 읽은 결과만 `실무자료/HP-######.json`(원본 엑셀 안 남김). +- 본 화면: `Hopyo_View.ts:132-186` 호표 표 → `:207-268` 줄 표(11열) · 모달 = `choose() :359-394`(후보 5 + 다른 로직 찾기) 하나. +- 불러오기 길 둘 · 둘 다 호표 하나씩: ① build `:414-425` → `POST hopyo-build`(Router `:291-302` → `Build.py:191-204`) ② [로직 추가] `Labor_Detail.ts:496-512` → `Hopyo_Pick.ts:18-58`. +- 서버가 읽는 것: `Excel.py:69-97` 「일위대가」·「일위대가총괄표」 값만(xls formatting_info 없음 · xlsx read_only) → 호표 `{번, 이름, 규격, 단위, 금액}` · 줄 `{차례, 이름, 규격, 수량, 단위, 단가, 금액, 비고, 참조, 갈래}` · 검산. 격자 · 병합 · 열 폭 · 서식 · 행 번호 · 단가산출 시트 없음. +- xlrd `formatting_info=True`(북부청 .xls 0.05 초): 병합 11 · 열 폭 15열 · 행 높이 · 숫자 형식 `#,##0` · 가로 맞춤 · 테두리 · 굵게/채움 없음. xlsx 병합 · 폭은 openpyxl 비 read_only(무거움) → xlsx 는 값만. +- 크기: 북부청 일위대가 560행×15열(≈129 KB) · 단가산출 4,345행×6열(≈192 KB) · 거창 xlsx 단가산출 146열. 호표 행 범위 깔끔(제3호표 = 20–24행 · 68개 안 겹침). + +## 329 엑셀 보기 창 — 부품 + +| 후보 | 근거 | 판단 | +|---|---|---| +| A00 산출근거 스프레드시트 `createSpreadsheet(host, book, {readOnly, tabs, onSelect})` | `A00_Common/spreadsheet/spreadsheet.ts:56-115` · Workbook `spreadsheet_types.ts:165-242`(병합 · 열 폭 · 행 높이 · 서식 · 틀고정 · 여러 시트) · M02 모달에서 읽기 전용 사용 중 `AreaCellPick.ts:49-118` | **씀** — 엑셀 모양 · 보이는 칸만 그림 · 늦게 불러옴 · 손잡이에 `select(범위)` 하나 더함(안쪽 `select :250-266` · `selectSpan` `spreadsheet_selection.ts:89` 있음) | +| B08·B09 자료 표 `ui_template_data_table.ts` | focusRow · rowClass | 병합 · 열 글자 없음 | +| 집계표 `createSheet` | 양식 문서용 | 안 맞음 | + +## 330 근거 맞춤 — 자료 + +- 우리: 로직 1,469(돈 1,438) · 칸 = 키 · 원문번호 · 구분(산림품셈 · 건설품셈 공통/토목/건축/기계설비/유지관리) · 출처(「건설품셈 공통 3-2-1」) · 호표 줄 `{인력/재료/기계/로직, 요소 LB…, 단위, 수량식, 비목}` · 조항 묶음 `M02_Labor_Inputs.py:68-85` sources() · 인력 LB 378 · 기계 EQ 654. +- 엑셀: 일위대가 줄 비고 「단산N참조」 → 단가산출 B열 머리 「N.이름[단위]」(북부청 268/268 · 사방 139/139 이어짐) · 블록 안 출처 글(「산림사업표준품셈 9-14…」 「[건설표준품셈(3-1-3)]」 · 오타 「건설표줌품셈」) · 직종 줄(「보통인부 : 172,068 × 0.1인」 · 글자 사이 빈칸) · 출처 있는 블록 북부청 90/95 · 사방 24/67 · 직종 있는 블록 69/95 · 48/67. +- 지금 점수(`Match.py:297-325`): 두 글자 겹침 + 이름 같음 1 + 단위 0.15 + 산림 0.05 + 자재 규격 0.5 · 거름 `:307` 때문에 이름이 아주 다르면 후보에도 못 듦 · `_targets :215-256` 에 조항 · 직종 없음 · 북부청(기억 없음) 단가산출 268 = 같음 63 · 후보 200 · 없음 5. + +| 단산 | 엑셀 이름 · 근거 | 지금 1순위 | 근거 1순위 | +|---|---|---|---| +| 93 | 구조물터파기(인력100%)(토사) · 건설 3-2-1 · 특별인부 | 구조물터파기 육상토사 GF000101 | GC000083 굴착(인력/토사) | +| 21·22·23 | 레미콘타설 · 건설 6-1-1 · 콘크리트공+보통인부 | 펌프카 타설 | GC000198 레디믹스트콘크리트 타설 | +| 64 | 비탈면보호공(평떼) · 건설 4-1-1 · 조경공+보통인부 | 비탈면 보양 | GC000143 잔디붙임 | +| 49 | 흙깍기(토사)(굴삭기1.0㎥) · 건설 3-2-3 | 없음(깍/깎) | GC000085 흙깎기(기계) 보통토사 | + +함정: ① 건설품셈 번호에 부문 없음(잔토처리 3-1-3 ↔ 우리 유지관리 철골재 철거) → 공통 · 토목 덤 · 직종 · 낱말 하나도 안 겹치면 버림 ② 품셈 판 다름(산림 5-23-3 ↔ 우리 5-22-3) → 조항 같음은 덤 점수만 · 자동 확정 안 씀 ③ 보통인부는 겹침에서 뺌 ④ 직종 없는 기계 로직은 부문 덤이 가름. + +## 위험 · 지킬 것 + +- 줄 수: Match 530 · View 485 · Router 312 · Excel 269 · Build 204 · Api 190 · Side 128 · Pick 58 · spreadsheet.ts 363 · `Labor_Detail.ts` 693(시험 고정 · 손대지 않음 · 덧줄은 Hopyo_Pick). +- 원본 읽기만: `resources/master_data/*.json` · `일위대가조합.json`(시험 master_hash). +- 길 충돌: `/hopyo/{key}/{no:int}`(Router `:228`) → 격자 길은 `/hopyo-grid/{key}`. +- `Match.py:464` 요약 `h[k]` → 옛 파일 KeyError → `.get`. +- 격자는 따로 파일 `실무자료/격자/<키>.json`(HP json 에 넣으면 목록 · 고르기가 통째로 읽음) · 지우기 때 같이. +- 이미 올린 파일 = 격자 · 근거 없음 → 다시 올려야 보임. +- `A00 spreadsheet.ts` 공용(고쳐도 됨) · 계약 `spreadsheet_types.ts` 안 고침. +- 기대값 바뀜: `test_m02_hopyo_match.py:106-108` 제3호표 구조물터파기 1순위 → 굴착(인력/토사)(의도). +- STmate 꼴(「일위대가표」·「단가산출근거」 · 실무 xlsx 6개)은 지금 「시트 없음」 400 — 이번 밖. + +## 시험 + +- 있음: `test_m02_hopyo_excel.py` · `_match.py` · `_build.py` · `_screen.py`(+ `helper_m02_hopyo_pick.cjs`) · `spreadsheet/test_spreadsheet_d_input.py`(+ `helper_spreadsheet_d.cjs`). +- 새로: `test_m02_hopyo_grid.py`(병합 11 · 15열 · B20 제3호표 · `#,##0` · 행 범위 68 안 겹침 · [20,24] · 200/404 · 지우면 격자도 · 역할 · master_hash) · `test_m02_hopyo_basis.py`(블록 북부청 95 · 사방 67 · 단산93 → GC000083 · 21 → GC000198 · 64 → GC000143 위 둘 · 49 → GC000085 · 잔토처리에 철골재 철거 없음 · 근거 없는 줄 그대로 · master_hash). diff --git a/docs/raw/조사/2026-10-15_331_CAD연속명령.md b/docs/raw/조사/2026-10-15_331_CAD연속명령.md new file mode 100644 index 000000000..4fff5cbdd --- /dev/null +++ b/docs/raw/조사/2026-10-15_331_CAD연속명령.md @@ -0,0 +1,66 @@ +# 331 내장 CAD 키보드 연속 명령 (요구 224-7) + +기준 = 837fe3d64. 경로 `B07_DesignDetail/openwebcad/` 아래 `src/…`. + +## 지금 — 우리 코드 + +- 이미 됨: `L␣` → 첫 점 → `@100<30⏎`(한 줄 극좌표) · 마우스 방향 + `100⏎`(직접 거리). +- 안 됨: `<30⏎` → `100⏎`(각도 고정 뒤 길이 · `<30` 읽는 곳 없음 · 버려짐) · 이동 · 복사 · 회전은 클릭만. +- 버그: 시퀀스 도구(폴리선 등) 첫 점에 숫자 · `@x,y` → 기준점 없어 오류 → 「Error in tool actor」 · 도구 멈춤(`sequence-tool.ts:183-189,284-285` · `get-point-from-event.ts:26-28,42-44` · `state.ts:309-317`). + +| 길 | 자리 | +|---|---| +| 키 | `src/inputController/input-controller.ts:92` keydown → `handleKeyStroke :340-448` · 글자 대문자로 `this.text` 에 쌓음 `:441-447` | +| 커서 옆 칸 | `draw :127-142` → `input-field-draw.ts:37-86`(안내 · 절대 x,y · 친 글 · 후보 6) | +| 명령줄 | `components/CommandLine.tsx:17-39`(↑↓ 이력 · Tab 완성 `:78-81`) · 기본 숨김 `components/Toolbar.tsx:23` | +| 단추 | `ribbon/Ribbon.tsx:172` · `components/QuickAccessBar.tsx:33` → `commands/run-command.ts:26-53` · 손가락 `input-touch.ts:122-140` | +| 이름 · 별칭 | `commands/commands.{draw,modify,annotate,layer,utility,insert,view,file}.ts` 153개(별칭 82) → `commands/registry.ts:27-57` · 해석 `:116-120` = 이름 → 별칭 → 접두사 첫 후보 | +| 상태 기계 | xstate 5.18.1 · 원본 기계 20(선 `tools/line-tool.ts:50-110`) + 우리 공장 `tools/factories/sequence-tool.ts:161-359`(약 100 명령) | +| 프롬프트 | `meta.instructions` → `state.ts:297-308` → 커서 칸 · `CommandLine.tsx:87` | + +`handleEnterKey :474-599` 차례: 빈 Enter(대기면 마지막 명령 반복 `:483-486`) → TEXT_INPUT 단계면 글 그대로 `:498-508` → 명령 이름 `:509` → 숫자 `:516` → `x,y :531` → `@x,y :548` → `@d`) · 키보드 좌표 · 거리 시험 0건. + +규모: src 228 파일 ≈27,800 줄 · 700 넘음 `App.css` 1,102 · 가까움 `screenCanvas.drawController.ts` 691 · `state.ts` 647 · `MeasurementEntity.ts` 622 · `input-controller.ts` 620. 원본 OpenWebCAD(MIT · 77ab85739 들여옴 · 원본과 연결 없음 → 원본 파일 고쳐도 됨). + +## 조각 (코드 조사 제안) + +1. 각도 고정 `<각도` → 길이 — 새 `src/inputController/input-angle-lock.ts`(고정 저장 · `lockPoint(base, p)` · 안내) · `input-patterns.ts` `ANGLE_LOCK_REGEXP` · `input-controller.ts` `:509` 앞 분기 · draw 안내 줄 · Esc 풂 · `get-point-from-event.ts` 고정 중 미리보기 · 클릭 · 숫자를 고정 방향으로 · 한 점 쓰면 풂 · `line-tool.ts:134` · `sequence-tool.ts:179` 미리보기 · `run-command.ts:40` 새 명령이면 풂 — 확인 = `resources/tester/cad/keyboard-chain.test.ts`(`0,0` · `<30` · `100` → (86.603, 50) · `<90` · `50` → (86.603, 100) · 고정 없이 `100` = 마우스 방향). +2. 시퀀스 첫 점 버그 — `sequence-tool.ts:284-285` NUMBER · RELATIVE 에 `!!lastPoint(context.values)` 조건. +3. 이동 · 복사 키보드 값 — 시작 ABSOLUTE · 끝 NUMBER · ABSOLUTE · RELATIVE · `getPointFromEvent` 로. +4. 회전 각도 숫자 — `rotate-tool.ts:156-174` NUMBER_INPUT · 음수 허용(`input-patterns.ts:4`). +5. (선택) 선 중 `C` · `U` · 6. 원본 안내문 한글화 + 「(길이 · <각도 · @x,y)」 · 7. 동적 입력 `100 Tab 30⏎`. + +## 오토캐드 방식 (인터넷 조사 · Autodesk 도움말 우선) + +- 네 겹: 명령줄(글자) → 프롬프트 대기(점 · 값 · 선택지) → 점 입력 문법(`x,y` · `@` · `<` · 숫자만) → 보조 모드(직교 · 극 추적 · 스냅). 프롬프트 하나가 클릭 · 좌표 · 거리 · 선택지 글자 · 스냅 이름을 다 받음. +- 명령줄: 이름 · 별칭(acad.pgp `A, *ARC` · 대소문자 무관) → Enter/Space · 자동완성(자주 쓴 순 · 중간 글자 · 지연 300ms) · ↑↓ 최근 입력 · 끝난 뒤 Enter/Space = 직전 명령 · Esc 취소 · 우클릭 = Enter/메뉴(시간 감지 짧게 Enter · 길게 메뉴) · 투명 명령 `'ZOOM`. +- 프롬프트: `Specify next point or [Close/Undo]:` · 대괄호 = 선택지 · 대문자 글자 = 입력 키 · `<값>` = 기본값(Enter 면 유지) · 선택지 클릭 · 동적 입력 ↓ 목록. +- 점 입력: 절대 `100,200` · 상대 `@30,0` · 극 `30<45` · 상대 극 `@30<45` · 각도 잠금 `<45` Enter → 커서로 쪽 → `30` Enter(다음 한 점만 · "Angle Override") · 직접 거리(커서 방향 + `30` Enter · 직교 · 극 추적과 같이) · 각도 = 동쪽 0° 반시계 + · 도분초 `45d30'15"` · 방위 `N45d30'E` · 소수점 `.` 만(`,` = 좌표 구분). +- 동적 입력(F12): 둘째 점부터 기본 상대 극(`@` 생략) · 절대는 `#` · 치수 입력 = 길이 · 각도 칸 · 값 넣고 Tab = 그 칸 잠금 + 다음 칸 · Enter 확정. +- 보조 모드: 극 추적 F10(증분 90 · 60 · 45 · 30 · 22.5 · 18 · 15 · 10 · 5° · 추가각) · 직교 F8(Shift 누르는 동안 반전) · 객체 스냅 F3 + 점 프롬프트에서 END · MID · CEN · INT · PER · TAN · NEA · QUA · NOD(다음 한 점만) · NON · 스냅 추적 F11 · 임시 덮어쓰기 Shift+키. +- LINE 「선 → 각도 → 길이」 키 차례: 동적 입력 켬 = `L` Enter → 첫 점 → Tab `45` Tab(각도 잠김) `30` Enter 또는 `30` Tab `45` Enter 또는 `30<45` Enter · 끔 = `L` Enter → 첫 점 → `<45` Enter → 커서로 쪽 → `30` Enter 또는 `@30<45` Enter · `U` Enter = 마지막 선 취소 · `C` Enter = 첫 점으로 닫고 끝 · Enter · Esc = 끝 · 새 LINE 첫 점에서 Enter = 마지막 끝점에서 이어 그림. +- 비슷한 프로그램: BricsCAD(같은 명령줄 · 동적 치수 길이 → Tab 잠금 → 각도 · F12 = Quad) · DraftSight(Quick Input F12 · Tab) · LibreCAD(`@100<90` · `@` 없으면 절대) · nanoCAD(`300` Tab `30` Enter) · Onshape(명령줄 없음 · 그린 뒤 치수 구속) · AutoCAD Web(명령줄 · 선택지 단추). +- 웹 함정: 브라우저 F키(F1 · F3 · F5 · F6 · F7 · F10 · F11 · F12 · 막을 수 있는 건 캔버스 초점 때 preventDefault · 실측 필요) · Ctrl+W · T · N 못 막음 · 한글 IME(key "Process" · keyCode 229 · `event.code` 우선 · 조합 중 Enter 무시) · y축 아래(내부 수학각 하나 · 화면에서 뒤집기) · 우클릭 · 가운데 누름 기본 동작 막기. +- 사용자 지정(설정 모달 후보): 별칭 편집(acad.pgp User Defined · 같은 별칭은 마지막이 이김) · 단축키(CUI) · 우클릭 모드 · 시간 · 휠 줌 방향(ZOOMWHEEL 0 = 앞으로 굴림 확대 · 1 = 반대) · 비율(ZOOMFACTOR 60) · 가운데 끌기 = 팬(MBUTTONPAN 1) · 동적 입력 기본(상대/절대 · 극/직교) · 극 추적 각 · 각 형식. +- 추천 사양: 1단계 = 명령줄 · 별칭 + 자동완성 · 프롬프트 `[닫기(C)/명령취소(U)] <기본값>` · 점 파서 한 벌(`x,y` · `@dx,dy` · `d 서랍 900) · 저장 안 한 고침 물음 `ui_template/ui_template_leave_dialog.ts:13` `askLeave`(예 `B08_Quantity_UI_Drawing.ts:210`). +- 탭 공용 부품 없음 · 공용 모양 `.ui-subtabs`(`ui_template_general_layout.css:58-70`) + `createButton` filled/ghost + `role=tab`(예 `B08_Quantity_UI_Milestone.ts:180-200` · `B09_Estimation_UI_TabBar.ts:30-60` · `M02_DF_InputPanel.ts:144-155`). + +## 마우스 현황 (당김 = 휠을 몸 쪽으로 굴리면 확대) + +| 화면 | 왼쪽 | 가운데 | 오른쪽 | 휠 | 근거 | +|---|---|---|---|---|---| +| B04 2D 지도 | 고름 · 관 끌기 | 팬 | 메뉴 | 줌 · 당김 | `B04_PreProcess_UI_MapViewer.ts:536-625` | +| B04 · B05 3D | 회전 · 고름 | 팬 | 배치 메뉴 | 줌 · 당김 | `B04_PreProcess_UI_Camera.ts:140-254` · `B05_Profile_UI_Viewer.ts:145,266` | +| B05 배수유역도 | 관 끌기 · 고름 | 팬 | 메뉴 | 줌 · 당김 | `B05_Profile_UI_Drainage_Interact.ts:60-119` | +| B05 노선 편집 지도 | 노드 끌기 · 두 번 = 더함 | 팬 | 노드 지움 | 줌 · 당김 | `B05_Profile_UI_RouteEdit_Input.ts:49-146` | +| 노선 편집 횡단 · B06 [상세] | 고름 | 팬 | — | 줌 · 당김 · 두 번 = 원배율 | `A00_Common/b_svg_zoom_pan.ts:162-264` | +| B05 · B06 종단 · 유토 · 측점표 | 가로 이동 | 가로 이동 | 메뉴 | 가로 스크롤 · 확대는 단추 | `B05_Profile_UI_Profile_Wheel.ts:82-177` | +| B06 횡단 카드 | 고름 | 팬 | 메뉴 | 목록 스크롤 | `B06_Section_UI_Cross_View_Zoom.ts:51-100` | +| CAD(B07 · B06 상세 · M02 도면) | 고름 · 점 | 팬 | Enter | 줌 · 당김 | `openwebcad/src/inputController/input-controller.ts:150-300` · `input-wheel.ts:20-23` | +| B08 거리이정표 | 빈 곳 끌기 = 팬 | 팬 | — | 줌 · 당김 | `B08_Quantity_UI_Milestone_Diagram.ts:157,463-474` | +| B08 · B09 표 | 고름 · 두 번 = 입력 | — | 메뉴 | 스크롤 | `ui_template/ui_template_data_table.ts:514-529` | +| M02 DF 그림 | 상자 끌기 · Space+왼쪽 = 팬 | 팬 | — | 줌 · 밀기 | `M02_DF_Diagram.ts:57-66,453-476` | +| M02 노무 그림 | Space+왼쪽 = 팬 | 팬 | — | Ctrl+휠 · 밀기 | `M02_MasterTemplete_Labor_Diagram.ts:283-332` | +| M02 시트 | 칸 고름 | — | 메뉴 | Ctrl+휠 · 밀기 | `A00_Common/spreadsheet/spreadsheet_grid.ts:270-284` | +| 공용 돋보기 | — | — | 0.4초 누름(Z 키도) | 배율 · 밀기 | `ui_template/ui_template_loupe.ts:5-93` | + +- 팬 = 가운데 단추로 통일 · 휠 방향 둘로 갈림 · 공용 판정 `A00_Common/touch_bridge.ts:35-36` `wheelZoomsIn` 은 셋만 씀(B04 지도 · 배수유역도 · 노선 편집 지도) · 나머지 직접 박음(`b_svg_zoom_pan.ts:217` · `B04_PreProcess_UI_Camera.ts:253` · `input-wheel.ts:23` · `M02_DF_Diagram.ts:457` · `Labor_Diagram.ts:316` · `ui_template_loupe.ts:91` · `spreadsheet_grid.ts:274`). +- 지금 사용자가 바꿀 수 있는 것: 터치 모드(localStorage `frd_touch_mode` · `device_context.ts:33,91-101`) · B05 3D 투영 · B09 행 누름(pref `b_page_state.ts:71,107`) — 마우스 단추 · 휠 방향 칸 없음. CAD 휠 지수 · 스냅 거리 상수(`openwebcad/src/App.consts.ts:52,150-160`) · 토글은 메모리(`state.ts:82,179-180`). maplibre 안 씀(평면 = 자체 캔버스). + +## 단축키 현황 + +| 자리 | 키 | 근거 | +|---|---|---| +| 공용 판정 | Ctrl+Z · Shift+Z · Y · S · A(한글 자판 자리) | `A00_Common/key_shortcut.ts:28-49` | +| 전역 | Esc = ☰ · 창 닫힘 · 고름 풂 · Z 누름 = 돋보기 | `app_shell.ts:173` · `ui_template_modal.ts:53` · `design/design_pick_escape.ts:15-29` · `ui_template_loupe.ts:66-72` | +| B05 종단 | Ctrl+Z · Ctrl+Shift+Z · 방향키 · 자유지정 Enter · Esc · Backspace | `B05_Profile_UI_Profile_Panel_Tools.ts:310-349` | +| B05 노선 편집 | Ctrl+Z · Y · Shift+Z | `B05_Profile_UI_RouteEdit_History.ts:157-165` | +| B06 | 방향키 · ▲▼ · Enter/Space · Esc | `B06_Section_UI_Cross_Panel_Base.ts:264-290` · `_Cross_Design_Pick.ts:545-562` | +| CAD | F3 · F7 · F8 · F10 · Ctrl+Z · Y · A · S(M02) · V · Delete · Esc · Enter · Space = Enter · 방향키 · 글자 = 명령 | `input-controller.ts:364-447` | +| CAD 별칭 | 명령 153 · 별칭 82 · `commands.*.ts` `aliases` | `openwebcad/src/commands/registry.ts:50-57,116-135` | +| B08 · B09 · 표 | Enter · F2 · 방향키 · Esc | `ui_template_data_table.ts:520-529` 외 | +| M02 | Ctrl+S · Z · C/V · Delete · Esc · Space = 팬 · 시트 엑셀 키 | `M02_MasterTemplete_CardShell.ts:79-80` · `spreadsheet_keys.ts` | + +정의 표 = CAD 별칭 하나(엑셀 키 표 `spreadsheet_shortcuts.ts:1-10` 은 대조용) · 충돌 관리 없음(입력 칸 건너뜀 · `defaultPrevented` 양보 · 창 떠 있으면 양보 · CAD 는 iframe · 별칭 겹치면 먼저 것 `registry.ts:53-56` · 이름 > 별칭 `:119`). + +## 저장 — 새 표 · API 필요 없음 + +- 표 `user_ui_prefs`(`db_management/018_user_ui_prefs.sql:17-25`) · `GET/PUT /api/dashboard/me/ui-prefs`(`B01_Dashboard_Router.py:122-133` · 값 문자열 · 통째 덮음 `Repository.py:554-573`). +- 브라우저 `pref` 통 = localStorage `aislo:pref:*`(`A00_Common/b_page_state.ts:47-112,285-287`) · 바뀌면 1.5초 모아 PUT(`:337-375`) · 로그인 첫 세션에 서버 값 얹음(`A06_Login_Api_Fetch.ts:64-69` → `b_page_state.ts:378-404`) · CAD iframe 같은 오리진 → localStorage 같이 씀(테마 선례 `openwebcad/src/theme.ts:43-52`). + +## 설계 후보 · 위험 + +- 읽기 층 새 `A00_Common/input_prefs.ts`(의존 없음 · CAD 도 import) — `INPUT_PREFS_KEY` · `DEFAULT_INPUT_PREFS`(= 지금 동작) · `inputPrefs()`(부를 때마다 읽음 · 깨짐/Node = 기본값) · `wheelIn(deltaY)` · 모달 [저장] 때만 `writeState("input-prefs", 값)`. +- 배선 자리: 휠 = `touch_bridge.ts:36`(셋 자동) · `b_svg_zoom_pan.ts:217` · `B04_PreProcess_UI_Camera.ts:253` · `input-wheel.ts:23` · (일체면) M02 둘 · 돋보기 · 시트 · CAD 별칭 = `registry.ts:116-131`(이름 > 내 별칭 > 기본) · CAD 오른쪽 = `input-controller.ts:155`. +- 위험: 700줄(`B04_PreProcess_UI_MapViewer.ts` 699 안 만짐 · `M02_DF_Diagram.ts` 692 한 줄 · `input-controller.ts` 620) · 공용 파일(`b_page_state.ts` · `app_shell.ts` · `touch_bridge.ts` · locale) 한 창이 맡음 · CAD `input-controller.ts` · `registry.ts` 는 331 과 겹침 → 331 뒤 · 휠 일체면 M02 그림 · 시트 · 돋보기 기본 방향 반대 · 계정 전환 시 앞 사람 값 남을 수 있음(기존 한계) · Node 시험(helper_164 · 266 등)이 `b_svg_zoom_pan` · `touch_bridge` 를 불러 돌림 → `input_prefs` 는 불러올 때 window 안 만짐 · CAD 명령 목록은 `commands.*.ts` import 시 xstate 딸려 옴 → 목록 JSON 한 벌 + 거울 시험. + +## 시험 + +- 있음: `test_device_drawer.py`(서랍 차례) · `test_device_audit.py` · `test_touch_bridge.py` · `test_cad_touch.py` · `test_prefs_persist_across_tabs.py` · `test_b01_ui_prefs_dead_project.py` · `test_session_keys_registered.py` · 휠 · 팬(`test_164_detail_zoom.py` · `test_266_detail_home.py` · `test_281_1_drainage_zoom_range.py` · `test_230_1_popout_wheel.py` · `test_244_6/8_*` · `test_b05_routeedit_cross_width.py` · `test_cross_axes_zoom.mjs` · `test_cross_pan_bounds.mjs` · `openwebcad/src/inputController/input-wheel.test.ts`) · 돋보기 · 키 · 모달(`test_218_1_loupe.py` · `test_220_1` · `test_221_1` · `cad/ctrl-shortcut-hangul.test.ts` · `cad/ctrl-shortcut-sheet.test.ts` · `test_modal_head_actions.py` · `test_cad_host_tabs.py`). +- 새로: `test_input_prefs.py`(+ cjs) · `cad/input-prefs-cad.test.ts` · `test_app_settings.py`. From 6d5e5f911ed1346e71243b88bb63fc4651ab698b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:22:37 +0900 Subject: [PATCH 03/36] =?UTF-8?q?feat(=EC=A2=85=EB=8B=A8):=20325-1=20?= =?UTF-8?q?=EB=B0=B0=EC=88=98=20=ED=8C=90=20=EC=B0=BD=EA=B5=AC=20=E2=80=94?= =?UTF-8?q?=20=EC=A7=80=EB=8F=84=20=ED=81=AC=EA=B8=B0=20=EA=B4=80=EC=B0=B0?= =?UTF-8?q?=EC=9D=84=20=EB=86=93=EC=9D=B8=20=EC=B0=BD=20=EA=B2=83=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=94=EA=BF=94=20=EB=81=BC=EC=9A=B0=EB=8A=94=20?= =?UTF-8?q?rehome=20=C2=B7=20=EC=A7=81=EC=A0=84=20=ED=81=AC=EA=B8=B0=20?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EB=B0=9B=EC=95=84=20=EB=B0=B0=EC=9C=A8=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 4 +- .../B05_Profile_UI_Drainage_Panel_Types.ts | 2 + B05_Profile/B05_Profile_UI_Drainage_Parts.ts | 27 +++-- .../tester/helper_224_1a_drainage_rehome.cjs | 99 +++++++++++++++++++ .../tester/test_224_1a_drainage_rehome.py | 65 ++++++++++++ 5 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 resources/tester/helper_224_1a_drainage_rehome.cjs create mode 100644 resources/tester/test_224_1a_drainage_rehome.py diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index d8d32dcd5..75a2d3ab5 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -536,8 +536,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra selectBasin: (index) => selectBasin(index), }); - // 패널을 늘리면 지도를 더 보여 줄 뿐, 배율은 그대로 둔다 — 늘릴 때마다 확대되면 - // 방금 보던 자리를 다시 찾아야 한다(2026-08-02 사용자 지시). + // 패널을 늘리면 지도를 더 보여 줄 뿐, 배율은 그대로 둔다(늘릴 때마다 확대되면 보던 자리를 잃음). const resizeObserver = observeViewportSize(viewport, { meta: () => meta, anchor: () => ({ width: 0, height: 0, scale, offsetX, offsetY }), @@ -696,5 +695,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra resizeObserver.disconnect(); widthResizer.dispose(); }, + rehome: () => resizeObserver.rehome(), }; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index 7ed1fe8b7..365d8a1ef 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -52,6 +52,8 @@ export interface DrainagePanel { /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). * 종단 레인의 띠와 같은 뜻이고, 목록이 바뀔 때마다 통째로 넣어 준다. */ setIntervalSpans: (spans: ReadonlyArray) => void; + /** 판이 다른 창(새 창 · 제자리)으로 옮겨 간 뒤 — 지도 크기 관찰을 그 창 것으로 바꿔 낌(325-1). */ + rehome: () => void; dispose: () => void; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index 868e450c5..f6e4539ba 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -23,6 +23,7 @@ import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { viewOf } from "../common_util/common_util_view_window"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -454,7 +455,8 @@ export function preserveViewOnResize( }; } -/** 뷰포트 크기 변화를 지켜보며 배율을 지킨다. 반환값은 정리용 `ResizeObserver`. */ +/** 뷰포트 크기 변화를 지켜보며 배율을 지킨다. `rehome` = 판이 다른 창으로 옮겨 간 뒤 그 창의 + * 관찰자로 바꿔 낌(직전 크기를 지켜 배율 유지 · 325-1) · `disconnect` = 정리. */ export function observeViewportSize( viewport: HTMLElement, ports: { @@ -463,9 +465,9 @@ export function observeViewportSize( apply: (next: { scale: number; offsetX: number; offsetY: number }) => void; redraw: () => void; }, -): ResizeObserver { +): { rehome: () => void; disconnect: () => void } { let last: { width: number; height: number } | null = null; - const observer = new ResizeObserver(() => { + const onResize = (): void => { const rect = viewport.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width)); const height = Math.max(1, Math.floor(rect.height)); @@ -482,9 +484,22 @@ export function observeViewportSize( } last = { width, height }; ports.redraw(); - }); - observer.observe(viewport); - return observer; + }; + // 새 창 크기 변화는 그 창의 관찰자만 안다 — 판이 놓인 창의 것으로 단다. + const watch = (): ResizeObserver => { + const view = viewOf(viewport) as Window & typeof globalThis; + const next = new view.ResizeObserver(onResize); + next.observe(viewport); + return next; + }; + let observer = watch(); + return { + rehome: () => { + observer.disconnect(); + observer = watch(); + }, + disconnect: () => observer.disconnect(), + }; } /** 캔버스 버퍼를 뷰포트 크기·DPR에 맞춘다. 실제로 바뀔 때만 재할당한다(재할당 시 내용이 지워진다). */ diff --git a/resources/tester/helper_224_1a_drainage_rehome.cjs b/resources/tester/helper_224_1a_drainage_rehome.cjs new file mode 100644 index 000000000..1f3bbea85 --- /dev/null +++ b/resources/tester/helper_224_1a_drainage_rehome.cjs @@ -0,0 +1,99 @@ +/* 224-1a 배수 판 창구(rehome) — 진짜 `observeViewportSize`(B05_Profile_UI_Drainage_Parts.ts)를 + * 가짜 창 둘 위에서 돌려 JSON 으로 냄. + * · 처음 = 판이 놓인 창(메인)의 관찰자 + * · rehome = 옛 관찰자 끊고 지금 놓인 창(새 창)의 관찰자로 · 직전 크기 이어받아 배율 유지 + * · disconnect = 지금 관찰자 끊음 */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const stub = new Proxy({}, { get: () => () => "" }); + +/** TS 한 장을 CommonJS 로 — 상대 경로는 진짜(공용 창 · 지도 셈만), 나머지는 빈 함수. */ +function loadTs(full) { + 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) => + /common_util_view_window|B04_PreProcess_UI_MapRender/.test(request) + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : stub; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +/** 가짜 창 — 관찰자를 모아 두고 크기를 바꿔 울림. */ +function fakeWindow(name) { + const win = { name, observers: [] }; + win.ResizeObserver = class { + constructor(fn) { + this.fn = fn; + this.live = false; + win.observers.push(this); + } + observe() { + this.live = true; + } + disconnect() { + this.live = false; + } + }; + return win; +} +const main = fakeWindow("main"); +const popup = fakeWindow("popup"); +global.window = main; + +const parts = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Drainage_Parts.ts")); +const doc = { defaultView: main }; +let size = { width: 400, height: 300 }; +const viewport = { ownerDocument: doc, getBoundingClientRect: () => ({ ...size }) }; +const fire = (win) => win.observers.filter((o) => o.live).forEach((o) => o.fn()); + +let view = { scale: 2, offsetX: 10, offsetY: -5 }; +const applied = []; +let redraws = 0; +const META = { + x_min: 0, + x_max: 4000, + y_min: 0, + y_max: 3000, + width_meters: 4000, + height_meters: 3000, +}; +const watcher = parts.observeViewportSize(viewport, { + meta: () => META, + anchor: () => ({ width: 0, height: 0, ...view }), + apply: (next) => (applied.push(next), (view = next)), + redraw: () => (redraws += 1), +}); +const out = { firstOwner: main.observers.length === 1 && popup.observers.length === 0 }; +fire(main); // 처음 크기 기억 +out.firstApplied = applied.length; + +// 새 창으로 옮김 — 메인 관찰자 끊고 새 창 관찰자로 +doc.defaultView = popup; +watcher.rehome(); +out.mainLive = main.observers.some((o) => o.live); +out.popupLive = popup.observers.filter((o) => o.live).length; +size = { width: 800, height: 600 }; // 새 창에서 두 배로 커짐 +fire(popup); +out.afterGrow = { applied: applied.length, ...view }; +// 메인 창 관찰자가 울려도(이미 끊김) 아무 일 없음 +fire(main); +out.mainIgnored = applied.length; + +// 제자리 — 다시 메인 +doc.defaultView = main; +watcher.rehome(); +out.backPopupLive = popup.observers.some((o) => o.live); +out.backMainLive = main.observers.filter((o) => o.live).length; +watcher.disconnect(); +out.afterDispose = [...main.observers, ...popup.observers].some((o) => o.live); +out.redraws = redraws; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_224_1a_drainage_rehome.py b/resources/tester/test_224_1a_drainage_rehome.py new file mode 100644 index 000000000..c53de3f95 --- /dev/null +++ b/resources/tester/test_224_1a_drainage_rehome.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +"""224-1a(PLAN 325-1) 배수유역 판 창구 — 새 창으로 떼어도 지도 크기 관찰이 그 창을 따름. + +- 판 지도 크기 관찰(`observeViewportSize`)은 판이 놓인 창의 ResizeObserver 를 씀. +- `rehome()` = 옛 창 관찰자 끊고 지금 놓인 창 것으로 · 직전 크기를 이어받아 배율(화면 m 당 px) 유지. +- 판 창구(`DrainagePanel.rehome`)로 내보내고 패널 본체는 700줄 안. + +`helper_224_1a_drainage_rehome.cjs` 가 진짜 TS 를 가짜 창 둘 위에서 돌림. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +B05 = Path(HERE).parents[1] / "B05_Profile" + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_224_1a_drainage_rehome.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_처음은_놓인_창_관찰자(r): + assert r["firstOwner"] is True + assert r["firstApplied"] == 0 # 처음 크기는 기억만 + + +def test_rehome_은_새_창_관찰자로(r): + assert r["mainLive"] is False + assert r["popupLive"] == 1 + + +def test_새_창에서_커져도_배율_유지(r): + """400 → 800 px · 배율 2 → 1 = 화면 m 당 px 그대로.""" + grow = r["afterGrow"] + assert grow["applied"] == 1 + assert grow["scale"] == pytest.approx(1) + assert r["mainIgnored"] == 1 # 끊긴 메인 관찰자는 안 울림 + + +def test_제자리와_정리(r): + assert r["backPopupLive"] is False + assert r["backMainLive"] == 1 + assert r["afterDispose"] is False + assert r["redraws"] == 2 + + +def test_판_창구와_700줄(): + panel = (B05 / "B05_Profile_UI_Drainage_Panel.ts").read_text(encoding="utf-8") + assert "rehome: () => resizeObserver.rehome()," in panel + assert len(panel.splitlines()) <= 700 + types = (B05 / "B05_Profile_UI_Drainage_Panel_Types.ts").read_text(encoding="utf-8") + assert "rehome: () => void;" in types From 7bae5ebb7d34231efdaf43a1abdfd6cf06179ca8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:25:45 +0900 Subject: [PATCH 04/36] =?UTF-8?q?feat(=EC=8A=A4=ED=94=84=EB=A0=88=EB=93=9C?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8):=20329-2=20=EC=86=90=EC=9E=A1=EC=9D=B4=20se?= =?UTF-8?q?lect(=EB=B2=94=EC=9C=84,=20=EC=8B=9C=ED=8A=B8=3F)=20=E2=80=94?= =?UTF-8?q?=20=EB=B2=94=EC=9C=84=20=EA=B8=80=20=E2=86=92=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84(=EB=B3=91=ED=95=A9=20=EB=8A=98=EB=A6=BC=20=C2=B7=20?= =?UTF-8?q?=EC=97=AC=EB=9F=BF)=20=C2=B7=20=EA=B7=B8=20=EC=8B=9C=ED=8A=B8?= =?UTF-8?q?=20=ED=83=AD=EC=9C=BC=EB=A1=9C=20=C2=B7=20=EB=B3=B4=EC=9D=B4?= =?UTF-8?q?=EA=B2=8C=20=EA=B5=B4=EB=A6=BC=20=C2=B7=20=ED=8B=80=EB=A6=B0=20?= =?UTF-8?q?=EA=B8=80=20false?= 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_01XWh5wNNycMkbwMpACz1rte --- A00_Common/spreadsheet/spreadsheet.ts | 17 ++++++++++++++++- A00_Common/spreadsheet/spreadsheet_selection.ts | 14 ++++++++++++++ .../tester/spreadsheet/helper_spreadsheet_d.cjs | 5 +++++ .../spreadsheet/test_spreadsheet_d_input.py | 5 +++++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 27ac5da6a..6d7fefd78 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -28,7 +28,7 @@ import type { LinkOptions, Links } from "./spreadsheet_links"; import { linkCommand, type LinkInfo } from "./spreadsheet_link_set"; import { attachMenu } from "./spreadsheet_menu"; import { attachMouse } from "./spreadsheet_mouse"; -import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection"; +import { LAST_C, LAST_R, selectCell, selectRanges } from "./spreadsheet_selection"; import { createStatusBar } from "./spreadsheet_statusbar"; import { mountTabs } from "./spreadsheet_tabs"; import { mountToolbar } from "./spreadsheet_toolbar"; @@ -90,6 +90,9 @@ export interface SpreadsheetHandle { /** HTML 표를 지금 시트 고른 칸(왼위)부터 붙임(105-1 도면 → 면적) — 붙여넣기와 같은 길(병합 · 되돌리기 한 번) · * `check` = 덮을 자리의 값 칸 · 알약 칸 수 · 범위 → false 면 안 붙임 · 붙인 범위 A1 · 읽기만 · 못 하면 null */ pasteTable(html: string, check?: PasteCheck): Promise; + /** 범위 고름 · 보이게 굴림(329-2 엑셀 보기 창) — `시트` = id 나 이름(없으면 지금 시트 · 다르면 그 탭으로) · + * 범위 여럿 = 여러 고름 · 시트가 없거나 주소가 틀리면 false(고름 그대로) */ + select(범위: string | string[], 시트?: string): boolean; destroy(): void; } @@ -350,6 +353,18 @@ export function createSpreadsheet( return 이름; }, pasteTable: (html, check) => pasteTableAt(ctx, html, check), + select(range, sheetKey) { + const target = sheetKey + ? ctx.book.시트.find((s) => s.id === sheetKey || s.이름 === sheetKey) + : ctx.sheet(); + const next = target && selectRanges(target, typeof range === "string" ? [range] : range); + if (!target || !next) return false; + showSheet(target.id); + if (ctx.selection.시트 !== target.id) return false; // 편집기 유효성에 막힘 + select(next, true); + grid.reveal(next.활성.r, next.활성.c); // 범위가 판보다 크면 왼위가 보이게 + return true; + }, destroy() { clearTimeout(toastTimer); detachKeys(); diff --git a/A00_Common/spreadsheet/spreadsheet_selection.ts b/A00_Common/spreadsheet/spreadsheet_selection.ts index 46c0c3e8c..750318511 100644 --- a/A00_Common/spreadsheet/spreadsheet_selection.ts +++ b/A00_Common/spreadsheet/spreadsheet_selection.ts @@ -91,6 +91,20 @@ export function selectSpan(sheet: Sheet, anchor: CellAddress, to: CellAddress): return { 시트: sheet.id, 범위: [g], 활성: { ...anchor }, 기준: { ...anchor } }; } +/** A1 범위 글 여럿 → 고름(329-2 손잡이 `select`) — 병합만큼 늘림 · 활성 = 첫 범위 왼위 · 틀린 글 · 빈 목록 = null */ +export function selectRanges(sheet: Sheet, texts: string[]): Selection | null { + const list = merges(sheet); + const 범위: CellRange[] = []; + for (const t of texts) { + const g = parseRange(t); + if (!g) return null; + 범위.push(expand(g, list)); + } + if (!범위.length) return null; + const at = { r: 범위[0].r0, c: 범위[0].c0 }; + return { 시트: sheet.id, 범위, 활성: at, 기준: { ...at } }; +} + /** 행 전체(r0~r1) · 열 전체(c0~c1) · 전체 — 활성은 첫 보이는 칸 · 걸친 병합만큼 늘림(엑셀) */ export function selectRows(sheet: Sheet, a: number, b: number, active?: number): Selection { const r0 = Math.min(a, b); diff --git a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs index e93264998..792c7c4a1 100644 --- a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs +++ b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs @@ -65,6 +65,11 @@ const out = { })(), cycleSingle: sel.cycle(s1, cell(3, 2), 1, 0), usedEnd: addr.toA1(sel.usedEnd(s1).r, sel.usedEnd(s1).c), + // 손잡이 select(범위) — 범위 글 → 고름(병합 늘림 · 여럿 · 틀린 글 null) + ranges: [["B20:O24"], ["D4"], ["B20:O24", "A1:A2"], ["가1"], []].map((t) => { + const s = sel.selectRanges(s1, t); + return s && [addr.toA1(s.활성.r, s.활성.c), ...s.범위.map(addr.rangeToA1)].join("|"); + }), // 입력 글 → 칸 textToCell: ["12", "1,234.5", "50%", "-1e3", "true", "'=12", "=", "=A1+1", "", "1,23", "abc"].map( (t) => ed.textToCell(t, { 서식: 4 }), diff --git a/resources/tester/spreadsheet/test_spreadsheet_d_input.py b/resources/tester/spreadsheet/test_spreadsheet_d_input.py index ebd4f575b..4f8252708 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_d_input.py +++ b/resources/tester/spreadsheet/test_spreadsheet_d_input.py @@ -46,6 +46,11 @@ def test_cycle_in_selection(r): assert r["cycleSingle"] is None +def test_select_ranges(r): + # 엑셀 보기 창(329-2) 호표 영역 강조 — 활성 = 왼위 · 병합 C4:F4 · A1:H1 걸치면 늘림 · 틀린 글 · 빈 목록 null + assert r["ranges"] == ["B20|B20:O24", "C4|C4:F4", "B20|B20:O24|A1:H2", None, None] + + def test_text_to_cell(r): got = r["textToCell"] assert [c.get("값", c.get("식")) for c in got] == [ From 2d239f780e95b1a73bb35f7ded7a5d40c9879515 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:26:31 +0900 Subject: [PATCH 05/36] =?UTF-8?q?feat(=EC=A2=85=EB=8B=A8):=20325-2=20?= =?UTF-8?q?=EC=9C=A0=ED=86=A0=20=ED=8C=90=20=EB=B6=99=EC=96=B4=20=EC=9E=88?= =?UTF-8?q?=EC=9D=8C=20=ED=8C=90=EC=A0=95=20=E2=80=94=20=EB=86=92=EC=9D=B4?= =?UTF-8?q?=20=EB=82=98=EB=88=94=20=C2=B7=20=EB=81=8C=EA=B8=B0=EB=8A=94=20?= =?UTF-8?q?=EC=97=B4=EB=A6=BC=20&&=20=EC=A2=85=EB=8B=A8=20=EB=BF=8C?= =?UTF-8?q?=EB=A6=AC=20=EC=95=88=EB=A7=8C=20=C2=B7=20=EB=97=80=20=EC=9C=A0?= =?UTF-8?q?=ED=86=A0=EB=8A=94=20=EB=85=B8=EC=84=A0=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EC=84=B8=EB=A1=9C=20=C2=B7=20=EB=B0=A9=ED=96=A5=ED=82=A4=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=EC=97=90=20=EB=97=80=20=EC=9C=A0=ED=86=A0=20?= =?UTF-8?q?=C2=B7=20=EB=B0=B0=EC=88=98=20=ED=8C=90?= 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_01VmC34fsxxbweEG3RUjs3Mn --- B05_Profile/B05_Profile_UI_Profile_Drag.ts | 8 +- B05_Profile/B05_Profile_UI_Profile_Heights.ts | 3 +- .../B05_Profile_UI_Profile_MassHaul.ts | 7 +- .../B06_Section_UI_Cross_Panel_Base.ts | 5 +- resources/tester/helper_219_1_arrow_owner.cjs | 3 +- .../tester/helper_224_1b_masshaul_docked.cjs | 112 ++++++++++++++++++ .../tester/test_224_1b_masshaul_docked.py | 69 +++++++++++ 7 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 resources/tester/helper_224_1b_masshaul_docked.cjs create mode 100644 resources/tester/test_224_1b_masshaul_docked.py diff --git a/B05_Profile/B05_Profile_UI_Profile_Drag.ts b/B05_Profile/B05_Profile_UI_Profile_Drag.ts index b5c22166a..4bb99cbf0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Drag.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Drag.ts @@ -133,6 +133,8 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { }; // 위쪽 경계를 끌어 패널 높이를 조절한다. 늘어난 만큼은 그래프만 먹고 도면 테이블은 // 처음 잡힌 높이를 지킨다(사용자 지시) — 테이블 행이 늘었다 줄었다 하면 읽기 어려워서다. + // 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2). + const massDocked = (): boolean => !massHaul.overlay.hidden && root.contains(massHaul.overlay); const heightResizer = createPanelResizer({ axis: "vertical", target: root, @@ -143,7 +145,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { // 셋 다 최소가 되면 여기서 더는 못 내려가게 막는다(뒤로 넘어가는 역전 차단). min: () => { const chrome = Math.max(0, root.offsetHeight - body.clientHeight); - const massMin = massHaul.overlay.hidden ? 0 : MASSHAUL_MIN_HEIGHT; + const massMin = massDocked() ? MASSHAUL_MIN_HEIGHT : 0; const tableMin = tableOverlay.isOpen() ? TABLE_OVERLAY_MIN_HEIGHT : 0; return Math.max( MIN_PANEL_HEIGHT, @@ -169,7 +171,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { let mainDragRef: { chart: number; mass: number; table: number } | null = null; heightResizer.root.addEventListener("pointerdown", () => { mainPanelDragging = true; - const massH = massHaul.overlay.hidden ? 0 : massHaul.overlay.offsetHeight; + const massH = massDocked() ? massHaul.overlay.offsetHeight : 0; const tableH = tableOverlay.isOpen() ? tableOverlay.overlay.offsetHeight : 0; mainDragRef = { chart: Math.max( @@ -191,7 +193,7 @@ export function createProfileDrag(ctx: ProfileDragContext): ProfileDrag { // 비례 연동 결과를 서브패널 저장 높이로 확정한다 — 이후 모든 redraw의 판정 // 기준이 이 값이 되어 멱등성이 유지된다(확정 없이는 손을 떼는 순간 예전 // 저장 높이로 튄다). 확정 후 인라인은 걷는다(변수가 같은 값을 담는다). - if (!massHaul.overlay.hidden) massHaul.commitHeight(massHaul.overlay.offsetHeight); + if (massDocked()) massHaul.commitHeight(massHaul.overlay.offsetHeight); if (tableOverlay.isOpen()) tableOverlay.commitHeight(tableOverlay.overlay.offsetHeight); massHaul.overlay.style.height = ""; tableOverlay.overlay.style.height = ""; diff --git a/B05_Profile/B05_Profile_UI_Profile_Heights.ts b/B05_Profile/B05_Profile_UI_Profile_Heights.ts index 3771fdf1e..fe6035427 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Heights.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Heights.ts @@ -67,7 +67,8 @@ export function createHeightCascade(deps: HeightCascadeDeps): HeightCascade { // 구조물 알약 레인이 그래프 아래 한 줄을 차지한다 — 그만큼 먼저 떼어 놓고 나눈다 // (2026-08-17 사용자 지시 6: 그래프가 밀려도 되지만 여유는 최소). const available = Math.max(120, deps.body.clientHeight - STRUCTURE_LANE_HEIGHT_PX); - const massOpen = !deps.massHaul.overlay.hidden; + // 새 창으로 뗀 유토 판은 자리를 안 차지함 — 「열림 && 뿌리 안」(325-2). + const massOpen = !deps.massHaul.overlay.hidden && deps.root.contains(deps.massHaul.overlay); const tableOpen = deps.tableOverlay.isOpen(); // 판정 기준은 항상 **저장 높이**(리사이저 변수·기본값)다 — 임시 축소가 반영된 화면 // 높이(offsetHeight)로 재면 "줄임 → 충분해 보임 → 풀림 → 부족 → 다시 줄임"이 diff --git a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts index 0600b48e1..d219230b3 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MassHaul.ts @@ -438,7 +438,12 @@ export function createRouteMassHaulPanel( window: undefined, hideStations: true, } - : { ...params.axis, window: windowState }; + : { + ...params.axis, + // 새 창으로 뗀 판은 넓어 메인 보이는 구간 세로면 잘림 — 노선 전체 세로(325-2). + viewRange: overlay.classList.contains("is-popout") ? undefined : params.axis.viewRange, + window: windowState, + }; let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null; const chart = createMassHaulChart( diff --git a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts index 6833d8a20..53ff81b73 100644 --- a/B06_Section/B06_Section_UI_Cross_Panel_Base.ts +++ b/B06_Section/B06_Section_UI_Cross_Panel_Base.ts @@ -89,7 +89,10 @@ if (typeof document !== "undefined") /** 방향키가 종단도 몫인가 — 마지막으로 누른 자리가 종단도 안이면 참. */ export function arrowsBelongToProfile(): boolean { - return !!lastPointer?.closest(".b05-route-profile"); + // 새 창으로 뗀 유토 · 배수 판도 종단도 몫(325-2). + return !!lastPointer?.closest( + ".b05-route-profile, .b05-profile__masshaul-overlay, .b05-drainage", + ); } /** 조정창 없는 고름(관 · 도수로/돌붙임 띠)이 방향키를 삼키나 — 그 동안 방향키는 아무것도 안 옮김. diff --git a/resources/tester/helper_219_1_arrow_owner.cjs b/resources/tester/helper_219_1_arrow_owner.cjs index a1f70e5b9..5e95ef67b 100644 --- a/resources/tester/helper_219_1_arrow_owner.cjs +++ b/resources/tester/helper_219_1_arrow_owner.cjs @@ -102,7 +102,8 @@ tools.createPanelTools({ /** 누른 자리 — 종단도 안이면 closest 가 그 판을 줌(새 창 요소처럼 Element 가 아닌 것도). */ const spot = (inProfile) => ({ - closest: (sel) => (inProfile && sel === ".b05-route-profile" ? {} : null), + closest: (sel) => + inProfile && sel.split(",").some((s) => s.trim() === ".b05-route-profile") ? {} : null, }); const press = (target) => listeners.document.pointerdown?.forEach((fn) => fn({ target })); diff --git a/resources/tester/helper_224_1b_masshaul_docked.cjs b/resources/tester/helper_224_1b_masshaul_docked.cjs new file mode 100644 index 000000000..2c07b616a --- /dev/null +++ b/resources/tester/helper_224_1b_masshaul_docked.cjs @@ -0,0 +1,112 @@ +/* 224-1b 유토 판 「붙어 있음」 판정 — 진짜 높이 나눔(`createHeightCascade`)과 방향키 판정 + * (`arrowsBelongToProfile` 본문)을 가짜 DOM 위에서 돌려 JSON 으로 냄. + * · 열림 && 뿌리 안 = 자리 차지 · 열림 && 새 창(뿌리 밖) = 자리 0 · 인라인 높이 안 씀 + * · 방향키 = 마지막 누름이 종단 뿌리 · 뗀 유토 · 뗀 배수 판 안이면 종단도 몫 */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), "utf8"); +const transpile = (code) => + ts.transpileModule(code, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + +// ---- 가짜 요소(부모 사슬 · contains · closest) ---- +function el(className = "") { + const node = { className, parent: null, hidden: false, clientHeight: 0, offsetHeight: 0 }; + node.style = { + height: "", + setProperty: (k, v) => (node.style[k] = v), + }; + node.append = (kid) => ((kid.parent = node), kid); + node.contains = (other) => { + for (let at = other; at; at = at.parent) if (at === node) return true; + return false; + }; + node.closest = (selector) => { + const wanted = selector.split(",").map((s) => s.trim().replace(/^\./, "")); + for (let at = node; at; at = at.parent) + if (wanted.some((c) => at.className.split(/\s+/).includes(c))) return at; + return null; + }; + return node; +} + +// ---- 진짜 높이 나눔 ---- +const heightsExports = {}; +new Function( + "exports", + "require", + "window", + transpile(read("B05_Profile", "B05_Profile_UI_Profile_Heights.ts")), +)( + heightsExports, + (id) => + id.includes("MassHaul") + ? { MASSHAUL_MIN_HEIGHT: 160 } + : id.includes("TableOverlay") + ? { TABLE_OVERLAY_MIN_HEIGHT: 120 } + : id.includes("Structures_Marks") + ? { STRUCTURE_LANE_HEIGHT_PX: 40 } + : { frameOf: () => 0 }, + { innerHeight: 900 }, +); + +function run(docked) { + const root = el("b05-route-profile"); + root.offsetHeight = 500; + const body = root.append(el()); + body.clientHeight = 400; + const overlay = el("b05-profile__masshaul-overlay"); + if (docked) body.append(overlay); + else el("popup-body").append(overlay); // 새 창 body + const cascade = heightsExports.createHeightCascade({ + root, + body, + massHaul: { overlay, desiredHeight: () => 300, syncHandle() {} }, + tableOverlay: { + overlay: el(), + isOpen: () => false, + desiredHeight: () => 0, + setBottomOffset() {}, + }, + isMainDragging: () => false, + isSubDragging: () => false, + mainDragRef: () => null, + isMainDragCooldown: () => true, // 자동 키우기 끔 — 나눔만 봄 + hasDetail: () => true, + }); + const { chartHeight } = cascade.apply(false); + return { chartHeight, inline: overlay.style.height }; +} + +// ---- 진짜 방향키 판정 본문 ---- +const base = read("B06_Section", "B06_Section_UI_Cross_Panel_Base.ts"); +const at = base.indexOf("export function arrowsBelongToProfile"); +const fnSrc = base.slice(at, base.indexOf("\n}", at) + 2).replace("export ", ""); +const arrows = new Function( + `let lastPointer; ${transpile(fnSrc)}; return (p) => ((lastPointer = p), arrowsBelongToProfile());`, +)(); +const profileRoot = el("b05-route-profile"); +const profileChild = profileRoot.append(el("b05-profile__chart")); +const massPopped = el("b05-profile__masshaul-overlay is-popout"); +const massChild = massPopped.append(el("b05-profile__masshaul-scroll")); +const drainPopped = el("b05-drainage is-popout"); +const drainChild = drainPopped.append(el("b05-drainage__canvas")); +const card = el("b06-cross-card"); + +process.stdout.write( + JSON.stringify({ + docked: run(true), + popped: run(false), + arrows: { + profile: arrows(profileChild), + mass: arrows(massChild), + drainage: arrows(drainChild), + card: arrows(card), + none: arrows(null), + }, + }), +); diff --git a/resources/tester/test_224_1b_masshaul_docked.py b/resources/tester/test_224_1b_masshaul_docked.py new file mode 100644 index 000000000..d537ad087 --- /dev/null +++ b/resources/tester/test_224_1b_masshaul_docked.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""224-1b(PLAN 325-2) 유토곡선 판 「붙어 있음」 판정 · 방향키. + +- 높이 나눔 · 끌기는 「열림 && 종단 뿌리 안」만 자리를 줌 — 새 창으로 뗀 유토 판은 메인 자리 0 · + 임시 축소 인라인 높이도 안 씀(뗀 창 높이가 굳지 않게). +- 뗀 유토 판(`is-popout`)은 메인 보이는 구간 세로 범위(viewRange)를 안 씀 — 노선 전체 세로. +- 방향키 ←→ 판정은 마지막 누름이 종단 뿌리 · 뗀 유토 · 뗀 배수 판 안이면 종단도 몫. + +`helper_224_1b_masshaul_docked.cjs` 가 진짜 TS 를 가짜 DOM 위에서 돌림. +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +B05 = Path(HERE).parents[1] / "B05_Profile" + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_224_1b_masshaul_docked.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_붙은_유토는_자리를_차지(r): + """본문 400 − 알약 40 = 360 · 유토 300 은 예산 247 넘어 247 로 줄고 그래프 100.""" + assert r["docked"] == {"chartHeight": 100, "inline": "247px"} + + +def test_뗀_유토는_자리_0_인라인_없음(r): + assert r["popped"] == {"chartHeight": 347, "inline": ""} + + +def test_방향키_판정(r): + assert r["arrows"] == { + "profile": True, + "mass": True, + "drainage": True, + "card": False, + "none": False, + } + + +def test_끌기도_같은_판정(): + drag = (B05 / "B05_Profile_UI_Profile_Drag.ts").read_text(encoding="utf-8") + assert "massHaul.overlay.hidden" in drag # 판정 함수 한 곳에만 + assert drag.count("massHaul.overlay.hidden") == 1 + assert drag.count("massDocked()") == 3 + assert "root.contains(massHaul.overlay)" in drag + + +def test_뗀_유토는_노선_전체_세로(): + mass = (B05 / "B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8") + assert re.search( + r'viewRange: overlay\.classList\.contains\("is-popout"\) \? undefined : params\.axis\.viewRange', + mass, + ) From d33cf220f3d9973af776e0992335d105d7cd1c9d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:31:21 +0900 Subject: [PATCH 06/36] =?UTF-8?q?feat(=EC=A2=85=EB=8B=A8):=20325-3=20?= =?UTF-8?q?=EC=9C=A0=ED=86=A0=EA=B3=A1=EC=84=A0=20=C2=B7=20=EB=B0=B0?= =?UTF-8?q?=EC=88=98=EC=9C=A0=EC=97=AD=20[=EC=B0=BD=20=EB=B6=84=ED=95=A0]?= =?UTF-8?q?=20=E2=80=94=20=EB=AA=B8=ED=86=B5=20attachPopout=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=8C=20=C2=B7=20=EC=9C=A0=ED=86=A0=20=ED=8C=90=20=EC=A2=8C?= =?UTF-8?q?=EC=83=81=EB=8B=A8=20=C2=B7=20=EB=B0=B0=EC=88=98=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=EC=A4=84=20=EB=A7=A8=20=EC=95=9E=20=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=20=C2=B7=20=EC=B0=BD=20=EC=9D=B4=EB=A6=84=20=EB=94=B0=EB=A1=9C?= =?UTF-8?q?=20=C2=B7=20=EB=8B=A8=EC=B6=94=EA=B0=80=20=EB=86=93=EC=9D=B8=20?= =?UTF-8?q?=EC=B0=BD=EC=97=90=EC=84=9C=20=EC=97=B6=20=C2=B7=20=EB=97=80=20?= =?UTF-8?q?=ED=8C=90=20=EC=B0=BD=20=EA=BD=89=20=EC=B1=84=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- A00_Common/design/design_page.css | 29 +++ A00_Common/design/design_profile_popout.ts | 129 +++++++++-- A00_Common/design/design_shared.ts | 3 +- .../tester/helper_224_1c_subpanel_popout.cjs | 215 ++++++++++++++++++ .../tester/helper_282_1_profile_toolbar.cjs | 3 + .../tester/test_224_1c_subpanel_popout.py | 107 +++++++++ 6 files changed, 462 insertions(+), 24 deletions(-) create mode 100644 resources/tester/helper_224_1c_subpanel_popout.cjs create mode 100644 resources/tester/test_224_1c_subpanel_popout.py diff --git a/A00_Common/design/design_page.css b/A00_Common/design/design_page.css index 605935355..7ffb67950 100644 --- a/A00_Common/design/design_page.css +++ b/A00_Common/design/design_page.css @@ -55,6 +55,35 @@ font-size: var(--btn-bar-font-size); } +/* 유토곡선 · 배수유역 판 [창 분할](계획서 325-3) — 종단 [창 분할]과 같은 양식 · 유토는 판 좌상단에 띄움. */ +.b05-subpanel__popout { + flex: none; + padding: var(--btn-bar-padding); + font-size: var(--btn-bar-font-size); +} + +.b05-profile__masshaul-overlay > .b05-subpanel__popout { + position: absolute; + top: 4px; + left: var(--spacing-8); + z-index: 4; +} + +/* 새 창으로 뗀 유토곡선 · 배수유역 판 — 창 전체를 채움 · 높이 · 폭 끌기와 접기 손잡이는 감춤. */ +.b05-profile__masshaul-overlay.is-popout, +.b05-drainage.is-popout { + position: relative; + width: 100%; + max-width: none; + height: 100dvh; +} + +.b05-profile__masshaul-overlay.is-popout > .ui-resizer, +.b05-drainage.is-popout > .ui-resizer, +.b05-drainage.is-popout > .ui-workflow-overlay__handle { + display: none; +} + /* 횡단 조정(카드 조정창 사본 · 폼 칸에 옮겨 붙은 행)은 횡단 보기 몫 — 종단 보기에서는 한 벌 폼 안에서 가린다(패널 뿌리 클래스가 보기를 따라 바뀐다). */ .b05-route__panel .b06-adjust-dock, diff --git a/A00_Common/design/design_profile_popout.ts b/A00_Common/design/design_profile_popout.ts index ce69d30d5..304bdb07f 100644 --- a/A00_Common/design/design_profile_popout.ts +++ b/A00_Common/design/design_profile_popout.ts @@ -1,6 +1,7 @@ /* ============================================================================= * A00_Common/design/design_profile_popout.ts * 종단 하단 패널(종단도 · 표 · 유토곡선 · 배수유역도)을 **브라우저 새 창**으로 뺀다(계획서 54-35). + * 유토곡선 · 배수유역 판은 따로 또 뗀다(계획서 325-3) — 같은 몸통(`attachPopout`), 창 이름만 다름. * * 화면 조각(DOM)만 옮긴다 — 코드 · 자료 · 가게는 메인 창 그대로, 같은 함수가 그린다. * 창 전역에 걸린 듣기(방향키 · 포인터 놓기 · 메뉴 닫기)는 메인 창에만 있으므로 새 창에서 난 @@ -13,28 +14,39 @@ import type { DesignProfile } from "./design_profile"; /** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */ const FORWARD_TYPES = ["keydown", "keyup", "pointerdown", "pointerup", "pointercancel"]; -/** 종단 도구줄 [초기화] 왼쪽 단추(`toolLead`)를 [창 분할] 단추로 꾸민다 — 두 보기가 같은 한 벌을 쓴다. */ -export function attachProfilePopout( - profile: DesignProfile, - slot: HTMLElement, - button: HTMLButtonElement, -): void { - const { root } = profile; +/** 요소가 놓인 창(문서에 안 붙었으면 메인 창). */ +const viewOfNode = (node: Node): Window => node.ownerDocument?.defaultView ?? window; + +/** 조각 하나를 새 창으로 떼는 단추 — 창 이름은 조각마다 달라야 함(같으면 앞 창 body 를 덮어 조각이 사라짐). */ +interface PopoutSpec { + piece: HTMLElement; + button: HTMLButtonElement; + name: string; + features: string; + docTitle: string; + /** 조각을 제자리로 돌림. */ + home: () => void; + /** 뗐다 · 붙였다(조각이 옮겨 간 뒤). */ + onMove?: (popped: boolean) => void; +} + +/** 조각을 새 창으로 떼고 붙이는 몸통 — 새 창은 단추가 놓인 창에서 엶(종단 새 창 안에서 다시 떼기). */ +function attachPopout(profile: DesignProfile, spec: PopoutSpec): void { + const { piece, button } = spec; let popup: Window | null = null; button.type = "button"; - button.className = "ui-btn ui-btn--ghost b05-route-profile__popout"; button.textContent = "창 분할"; - button.title = "종단 하단 패널을 별도 창으로 분할 · 다시 누르면 결합"; button.addEventListener("click", () => (popup && !popup.closed ? closePopup() : open())); const restore = (): void => { - if (!root.classList.contains("is-popout")) return; - root.classList.remove("is-popout"); + if (!piece.classList.contains("is-popout")) return; + piece.classList.remove("is-popout"); button.textContent = "창 분할"; - slot.append(root); + spec.home(); popup = null; - requestAnimationFrame(profile.redraw); + spec.onMove?.(false); + viewOfNode(piece).requestAnimationFrame(profile.redraw); }; const closePopup = (): void => { popup?.close(); @@ -49,28 +61,31 @@ export function attachProfilePopout( }; function open(): void { - const next = window.open("", "aislo-profile-popout", "popup,width=1400,height=720"); + const opener = viewOfNode(button); + const next = opener.open("", spec.name, spec.features); if (!next) { showToast("새 창이 막힘 — 주소창의 팝업 허용을 켠 뒤 다시 누름", "error"); return; } popup = next; + const source = opener.document; const doc = next.document; - doc.title = "종단 — Aislo"; - // 스타일 · 테마 — 메인 창 시트 · html 속성을 그대로 옮겨 같은 색 · 글꼴로 그린다. + doc.title = spec.docTitle; + // 스타일 · 테마 — 단추가 놓인 창의 시트 · html 속성을 그대로 옮겨 같은 색 · 글꼴로 그린다. doc.head.replaceChildren( - ...Array.from(document.querySelectorAll('link[rel="stylesheet"], style'), (node) => + ...Array.from(source.querySelectorAll('link[rel="stylesheet"], style'), (node) => doc.importNode(node, true), ), ); - Array.from(document.documentElement.attributes).forEach((attr) => + Array.from(source.documentElement.attributes).forEach((attr) => doc.documentElement.setAttribute(attr.name, attr.value), ); - doc.body.className = document.body.className; - doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${getComputedStyle(document.body).backgroundColor}`; - root.classList.add("is-popout"); + doc.body.className = source.body.className; + doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${opener.getComputedStyle(source.body).backgroundColor}`; + piece.classList.add("is-popout"); button.textContent = "창 결합"; - doc.body.replaceChildren(root); + doc.body.replaceChildren(piece); + spec.onMove?.(true); // 새 창 키 · 포인터를 메인 문서로 — 대상은 새 창 요소 그대로(입력칸 판정 · 메뉴 안 판정). FORWARD_TYPES.forEach((type) => @@ -93,7 +108,75 @@ export function attachProfilePopout( new (next as Window & typeof globalThis).ResizeObserver(() => { next.cancelAnimationFrame(pending); pending = next.requestAnimationFrame(profile.redraw); - }).observe(root); + }).observe(piece); next.addEventListener("pagehide", restore); } } + +/** 종단 도구줄 [초기화] 왼쪽 단추(`toolLead`)를 [창 분할] 단추로 꾸민다 — 두 보기가 같은 한 벌을 쓴다. */ +export function attachProfilePopout( + profile: DesignProfile, + slot: HTMLElement, + button: HTMLButtonElement, +): void { + button.className = "ui-btn ui-btn--ghost b05-route-profile__popout"; + button.title = "종단 하단 패널을 별도 창으로 분할 · 다시 누르면 결합"; + attachPopout(profile, { + piece: profile.root, + button, + name: "aislo-profile-popout", + features: "popup,width=1400,height=720", + docTitle: "종단 — Aislo", + home: () => slot.append(profile.root), + }); +} + +/** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 유토 판 좌상단 · 배수 머리줄 맨 앞(계획서 325-3). */ +export function attachSubPanelPopouts(profile: DesignProfile): void { + const subButton = (title: string): HTMLButtonElement => { + const button = document.createElement("button"); + button.className = "ui-btn ui-btn--ghost b05-subpanel__popout"; + button.title = title; + return button; + }; + + const massOverlay = profile.root.querySelector(".b05-profile__masshaul-overlay"); + const massHandle = profile.root.querySelector(".b05-profile__masshaul-handle"); + if (massOverlay && massHandle) { + const button = subButton("유토곡선 창 분할"); + massOverlay.append(button); + attachPopout(profile, { + piece: massOverlay, + button, + name: "aislo-masshaul-popout", + features: "popup,width=1400,height=420", + docTitle: "유토곡선 — Aislo", + // 제자리 = 유토 손잡이 바로 앞(종단 본문 바닥 겹침 판). + home: () => massHandle.before(massOverlay), + onMove: (popped) => { + // 뗀 동안 메인 손잡이는 숨김 · 같이 줄이기 인라인 높이는 비움(새 창은 창 높이를 다 씀). + massHandle.hidden = popped; + massOverlay.style.height = ""; + }, + }); + } + + const drainage = profile.side; + const header = drainage?.root.querySelector(".b05-drainage__header"); + if (drainage && header) { + const button = subButton("배수유역 창 분할"); + header.prepend(button); + const content = drainage.root.parentElement; + attachPopout(profile, { + piece: drainage.root, + button, + name: "aislo-drainage-popout", + features: "popup,width=1000,height=720", + docTitle: "배수유역 — Aislo", + // 제자리 = 종단 content 끝(종단 본문 오른쪽). + home: () => content?.append(drainage.root), + // 지도 크기 관찰을 놓인 창 것으로(325-1). + onMove: () => drainage.rehome(), + }); + } +} diff --git a/A00_Common/design/design_shared.ts b/A00_Common/design/design_shared.ts index 9296eb899..3374528c1 100644 --- a/A00_Common/design/design_shared.ts +++ b/A00_Common/design/design_shared.ts @@ -24,7 +24,7 @@ import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_U import { drainageSide } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool"; import { createDesignPanel, type DesignPanel } from "./design_panel"; import { createDesignProfile, type DesignProfile } from "./design_profile"; -import { attachProfilePopout } from "./design_profile_popout"; +import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout"; import { saveDesign, type DesignSaveStep } from "./design_save"; import { resetDesign } from "./design_reset"; import type { DesignView } from "./design_dock"; @@ -179,6 +179,7 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile { }); shared.profileSlot.append(profile.root); attachProfilePopout(profile, shared.profileSlot, popoutButton); + attachSubPanelPopouts(profile); shared.profile = profile; return profile; } diff --git a/resources/tester/helper_224_1c_subpanel_popout.cjs b/resources/tester/helper_224_1c_subpanel_popout.cjs new file mode 100644 index 000000000..c48685322 --- /dev/null +++ b/resources/tester/helper_224_1c_subpanel_popout.cjs @@ -0,0 +1,215 @@ +/* 224-1c 유토곡선 · 배수유역 [창 분할] — 진짜 `attachSubPanelPopouts` · `attachProfilePopout` + * (design_profile_popout.ts)를 가짜 DOM · 가짜 창 위에서 돌려 JSON 으로 냄. + * · 단추 자리 · 글 · 클래스 · 툴팁 · 창 이름 셋 다름 + * · 뗌 = 새 창 body · 유토 손잡이 숨김 · 인라인 높이 비움 · 배수 rehome + * · 붙임(단추 · 새 창 닫힘 · dispose) = 제자리(유토 손잡이 앞 · content 끝) + * · 종단 새 창 안의 단추 = 그 창에서 엶 */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), "utf8"); + +// ---- 가짜 DOM — 부모 사슬 끝이 어느 문서 body 냐로 ownerDocument 를 정함 ---- +let mainDoc = null; +function el(tag, className = "") { + const node = { + tagName: tag.toUpperCase(), + className, + textContent: "", + title: "", + type: "", + hidden: false, + children: [], + parent: null, + listeners: {}, + style: { height: "", cssText: "" }, + }; + node.classList = { + list: () => node.className.split(/\s+/).filter(Boolean), + contains: (c) => node.classList.list().includes(c), + add: (c) => + !node.classList.contains(c) && (node.className = [...node.classList.list(), c].join(" ")), + remove: (c) => + (node.className = node.classList + .list() + .filter((x) => x !== c) + .join(" ")), + }; + Object.defineProperty(node, "parentElement", { get: () => node.parent }); + Object.defineProperty(node, "ownerDocument", { + get: () => { + let top = node; + while (top.parent) top = top.parent; + return top.doc ?? mainDoc; + }, + }); + node.detach = () => { + if (node.parent) node.parent.children = node.parent.children.filter((c) => c !== node); + node.parent = null; + }; + node.append = (...kids) => + kids.forEach((k) => (k.detach(), (k.parent = node), node.children.push(k))); + node.prepend = (...kids) => + kids.reverse().forEach((k) => (k.detach(), (k.parent = node), node.children.unshift(k))); + node.replaceChildren = (...kids) => { + node.children.slice().forEach((c) => c.detach()); + node.append(...kids); + }; + node.before = (other) => { + const parent = node.parent; + other.detach(); + other.parent = parent; + parent.children.splice(parent.children.indexOf(node), 0, other); + }; + node.querySelector = (sel) => { + const cls = sel.replace(/^\./, ""); + for (const kid of node.children) { + if (kid.classList.contains(cls)) return kid; + const deep = kid.querySelector(sel); + if (deep) return deep; + } + return null; + }; + node.addEventListener = (type, fn) => (node.listeners[type] ??= []).push(fn); + node.click = () => (node.listeners.click ?? []).forEach((fn) => fn({})); + return node; +} +function fakeDoc(view) { + const doc = { title: "", defaultView: view }; + doc.head = el("head"); + doc.body = el("body"); + doc.body.doc = doc; + doc.documentElement = Object.assign(el("html"), { attributes: [], setAttribute() {} }); + doc.querySelectorAll = () => []; + doc.importNode = (n) => n; + doc.createElement = (tag) => el(tag); + return doc; +} +const opened = []; // [여는 창 이름, 새 창 이름] +function fakeWindow(label) { + const win = { label, listeners: {}, closed: false, frames: 0 }; + win.document = fakeDoc(win); + win.addEventListener = (t, fn) => (win.listeners[t] ??= []).push(fn); + win.removeEventListener = () => {}; + win.getComputedStyle = () => ({ backgroundColor: "" }); + win.requestAnimationFrame = () => (win.frames += 1); + win.cancelAnimationFrame = () => {}; + win.ResizeObserver = class { + observe() {} + }; + win.close = () => (win.closed = true); + win.fire = (t) => (win.listeners[t] ?? []).forEach((fn) => fn({})); + win.open = (_url, name) => { + const next = fakeWindow(name); + opened.push([label, name]); + return next; + }; + return win; +} +const main = fakeWindow("main"); +mainDoc = main.document; + +const js = ts.transpileModule(read("A00_Common", "design", "design_profile_popout.ts"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const mod = {}; +new Function("exports", "require", "window", "document", js)( + mod, + () => ({ showToast() {} }), + main, + mainDoc, +); + +// ---- 종단 뿌리 = [content = [bodyWrap = [유토 판, 유토 손잡이], 배수 판 = [머리줄 = [제목]]]] ---- +const slot = el("div"); +mainDoc.body.append(slot); +const root = el("div", "b05-route-profile"); +slot.append(root); +const content = el("div", "b05-route-profile__content"); +const bodyWrap = el("div"); +const massOverlay = el("div", "b05-profile__masshaul-overlay"); +const massHandle = el("div", "b05-profile__masshaul-handle"); +bodyWrap.append(massOverlay, massHandle); +const drainageRoot = el("aside", "b05-drainage"); +const header = el("div", "b05-drainage__header"); +header.append(el("h3")); +drainageRoot.append(header); +content.append(bodyWrap, drainageRoot); +root.append(content); +let rehomes = 0; +let disposed = 0; +const profile = { + root, + redraw() {}, + dispose: () => (disposed += 1), + side: { root: drainageRoot, rehome: () => (rehomes += 1) }, +}; +const profileButton = el("button"); +mod.attachProfilePopout(profile, slot, profileButton); +mod.attachSubPanelPopouts(profile); + +const massButton = massOverlay.children.find((c) => c.tagName === "BUTTON"); +const drainButton = header.children[0]; +const btn = (b) => ({ text: b.textContent, title: b.title, className: b.className, type: b.type }); +const out = { mass: btn(massButton), drainage: btn(drainButton), profile: btn(profileButton) }; + +// 유토 뗌 +massOverlay.style.height = "123px"; +massButton.click(); +const massPopup = massOverlay.parent?.doc?.defaultView; +out.massOpened = { + inPopupBody: massOverlay.parent === massPopup?.document.body, + isPopout: massOverlay.classList.contains("is-popout"), + handleHidden: massHandle.hidden, + inline: massOverlay.style.height, + text: massButton.textContent, + docTitle: massPopup?.document.title, +}; +// 배수 뗌 +drainButton.click(); +const drainPopup = drainageRoot.parent?.doc?.defaultView; +out.drainOpened = { + inPopupBody: drainageRoot.parent === drainPopup?.document.body, + rehomes, + text: drainButton.textContent, +}; +// 유토 새 창 닫힘 → 제자리(손잡이 바로 앞) +massPopup.fire("pagehide"); +out.massClosed = { + order: bodyWrap.children.map((c) => c.className), + handleHidden: massHandle.hidden, + isPopout: massOverlay.classList.contains("is-popout"), + text: massButton.textContent, +}; +// 배수 단추 다시 누름 → 창 닫고 content 끝 +drainButton.click(); +out.drainClosed = { + popupClosed: drainPopup.closed, + last: content.children[content.children.length - 1] === drainageRoot, + rehomes, + text: drainButton.textContent, +}; + +// 종단 새 창 → 그 안에서 유토 뗌 = 종단 새 창이 엶 +profileButton.click(); +const profilePopup = root.parent?.doc?.defaultView; +massButton.click(); +const massPopup2 = massOverlay.parent?.doc?.defaultView; +out.nested = { profileInPopup: !!profilePopup && profilePopup !== main }; +// 페이지 떠남(dispose) → 셋 다 닫히고 제자리 +drainButton.click(); +const drainPopup2 = drainageRoot.parent?.doc?.defaultView; +profile.dispose(); +out.disposed = { + closed: [profilePopup.closed, massPopup2.closed, drainPopup2.closed], + profileInSlot: root.parent === slot, + massBeforeHandle: + bodyWrap.children.indexOf(massOverlay) === bodyWrap.children.indexOf(massHandle) - 1, + drainInContent: drainageRoot.parent === content, + handleHidden: massHandle.hidden, + original: disposed, +}; +out.opened = opened; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_282_1_profile_toolbar.cjs b/resources/tester/helper_282_1_profile_toolbar.cjs index e24598ca9..1fe0671b6 100644 --- a/resources/tester/helper_282_1_profile_toolbar.cjs +++ b/resources/tester/helper_282_1_profile_toolbar.cjs @@ -84,6 +84,9 @@ const window = { addEventListener: (t, fn) => (window.listeners[t] ??= []).push(fn), removeEventListener: () => {}, open: () => popup, + document, + getComputedStyle: () => ({ backgroundColor: "" }), + requestAnimationFrame: (fn) => frames.push(fn), }; const popupListeners = {}; const popup = { diff --git a/resources/tester/test_224_1c_subpanel_popout.py b/resources/tester/test_224_1c_subpanel_popout.py new file mode 100644 index 000000000..df926a112 --- /dev/null +++ b/resources/tester/test_224_1c_subpanel_popout.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +"""224-1c(PLAN 325-3) 유토곡선 · 배수유역 [창 분할] — 종단 [창 분할]과 같은 양식 · 따로 새 창. + +- 단추 = 유토 판 좌상단 · 배수 머리줄 맨 앞 · 글 [창 분할]/[창 결합] · 툴팁에 판 이름. +- 창 이름 셋 다름(같으면 앞 창 body 를 덮어 조각이 사라짐). +- 뗌 = 새 창 body · 유토 손잡이 숨김 · 인라인 높이 비움 · 배수 지도 관찰 rehome. +- 붙임(단추 · 새 창 닫힘 · 페이지 떠남) = 제자리 — 유토 손잡이 바로 앞 · 배수 content 끝. +- 종단 새 창 안에서 다시 떼면 그 창이 새 창을 엶(메인 창 팝업 차단 피함). + +`helper_224_1c_subpanel_popout.cjs` 가 진짜 TS 를 가짜 DOM · 가짜 창 위에서 돌림. +""" + +import json +import os +import re +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = Path(HERE).parents[1] +NAMES = ["aislo-profile-popout", "aislo-masshaul-popout", "aislo-drainage-popout"] + + +@pytest.fixture(scope="module") +def r(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_224_1c_subpanel_popout.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_단추_글_툴팁_클래스(r): + cls = "ui-btn ui-btn--ghost b05-subpanel__popout" + assert r["mass"] == { + "text": "창 분할", + "title": "유토곡선 창 분할", + "className": cls, + "type": "button", + } + assert r["drainage"] == { + "text": "창 분할", + "title": "배수유역 창 분할", + "className": cls, + "type": "button", + } + assert "b05-route-profile__popout" in r["profile"]["className"] + + +def test_유토_뗌(r): + assert r["massOpened"] == { + "inPopupBody": True, + "isPopout": True, + "handleHidden": True, + "inline": "", + "text": "창 결합", + "docTitle": "유토곡선 — Aislo", + } + + +def test_배수_뗌은_rehome(r): + assert r["drainOpened"] == {"inPopupBody": True, "rehomes": 1, "text": "창 결합"} + + +def test_새_창_닫힘과_단추로_제자리(r): + assert r["massClosed"] == { + "order": ["b05-profile__masshaul-overlay", "b05-profile__masshaul-handle"], + "handleHidden": False, + "isPopout": False, + "text": "창 분할", + } + assert r["drainClosed"] == {"popupClosed": True, "last": True, "rehomes": 2, "text": "창 분할"} + + +def test_창_이름_셋_다름과_여는_창(r): + names = [name for _, name in r["opened"]] + assert set(names) == set(NAMES) and len(set(NAMES)) == 3 + # 종단 새 창 안의 단추는 종단 새 창이 엶 + assert r["opened"][3] == ["aislo-profile-popout", "aislo-masshaul-popout"] + assert r["nested"]["profileInPopup"] is True + + +def test_페이지_떠나면_셋_다_닫고_제자리(r): + assert r["disposed"] == { + "closed": [True, True, True], + "profileInSlot": True, + "massBeforeHandle": True, + "drainInContent": True, + "handleHidden": False, + "original": 1, + } + + +def test_배선과_양식(): + shared = (ROOT / "A00_Common/design/design_shared.ts").read_text(encoding="utf-8") + assert "attachSubPanelPopouts(profile);" in shared + css = (ROOT / "A00_Common/design/design_page.css").read_text(encoding="utf-8") + block = re.search(r"\n\.b05-subpanel__popout \{([^}]*)\}", css).group(1) + assert "var(--btn-bar-padding)" in block and "var(--btn-bar-font-size)" in block + assert ".b05-profile__masshaul-overlay.is-popout," in css + assert ".b05-drainage.is-popout {" in css From 4c6658cf1c08dd761776890a5a42017172da17d2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:32:36 +0900 Subject: [PATCH 07/36] =?UTF-8?q?feat(B05):=20326-1=C2=B7326-2=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EC=8A=A4=EB=83=85=20=EC=84=9C=EB=B2=84=20=E2=80=94?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EB=AA=A8=EB=8D=B8=20Schema=20=EB=A1=9C?= =?UTF-8?q?=20=EC=AA=BC=EA=B0=AC=20=C2=B7=20=ED=98=B8=20=EC=95=88=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=ED=95=98=EB=82=98=EB=A5=BC=20=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=EC=A4=91=EC=A0=90=EC=9C=BC=EB=A1=9C=20=EC=98=AE?= =?UTF-8?q?=EA=B8=B0=EB=8A=94=20=EC=A7=9D=20=EC=85=88(snap=5Fcurve=5Fstati?= =?UTF-8?q?ons=20=E2=86=94=20snapCurveStations)=20=C2=B7=20[=ED=99=95?= =?UTF-8?q?=EC=9D=B8]=20=EB=95=8C=20=EA=B3=A1=EC=84=A0=ED=91=9C=20station?= =?UTF-8?q?=5Fsnap=20=EC=A0=80=EC=9E=A5=20=C2=B7=20GET=20=EB=8F=8C?= =?UTF-8?q?=EB=A0=A4=EC=A4=8C=20=C2=B7=20=EC=8B=A4=ED=8C=A8=20=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A6=BC=EC=97=90=20=EA=B3=A1=EC=84=A0=ED=91=9C=C2=B7?= =?UTF-8?q?=EB=85=B8=EB=93=9C=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=EC=85=88=20?= =?UTF-8?q?=EB=91=98=20=C2=B7=20=ED=9A=A1=EB=8B=A8=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EB=B0=98=EC=98=81?= 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_01TteK8mfYZetk5P3bEZkyVZ --- B05_Profile/B05_Profile_Engine_Sections.py | 25 +- .../B05_Profile_Engine_Sections_Core.py | 42 +- B05_Profile/B05_Profile_Router_Replan.py | 114 ++--- B05_Profile/B05_Profile_Router_Terrain.py | 11 +- B05_Profile/B05_Profile_Schema.py | 75 +++- B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts | 99 +++++ resources/tester/test_224_2_station_snap.py | 388 ++++++++++++++++++ 7 files changed, 662 insertions(+), 92 deletions(-) create mode 100644 resources/tester/test_224_2_station_snap.py diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index b9e354689..5dc47a85a 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -79,21 +79,31 @@ def _load_design_curves(project_root: Path) -> list[dict[str, Any]]: 곡선표를 읽으면 있지도 않은 자리에 확폭이 붙는다. 곡선표가 없으면 빈 목록을 돌려주고, 받는 쪽이 옛 방식(측점별 실측 반경)으로 물러선다. """ + curves = _read_design_curve_table(project_root).get("curves") + return ( + [curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else [] + ) + + +def load_station_snap(project_root: Path) -> bool: + """[측점 스냅] 켬 — 곡선표 `"station_snap"`(요구 224-2 · [확인] 때 적힘). 칸 없으면 끔.""" + return _read_design_curve_table(project_root).get("station_snap") is True + + +def _read_design_curve_table(project_root: Path) -> dict[str, Any]: + """설계가 쓰는 곡선표 파일 통째 — 수정본이 있으면 그 곁 것 · 없거나 못 읽으면 빈 dict.""" route_path = planned_route_working_path(project_root) if not route_path.is_file(): route_path = planned_route_initial_path(project_root) target = route_path.with_name(f"{route_path.stem}_curves.json") if not target.is_file(): - return [] + return {} try: data = json.loads(target.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): logger.warning("계획노선 곡선표를 읽지 못했습니다: %s", target) - return [] - curves = data.get("curves") if isinstance(data, dict) else None - return ( - [curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else [] - ) + return {} + return data if isinstance(data, dict) else {} # ── 횡단 이름 규칙(한 벌) — 파일 이름 · B07 도면 id · 찾기가 모두 이것을 씀 ──────────── @@ -435,6 +445,7 @@ def run_section_generation( # 곡선부 확폭의 정본 — 설계 곡선표(시·종점·반지름)를 그대로 쓴다. 없으면 빈 목록이라 # 종전의 측점별 실측 반경으로 물러선다. design_curves=_load_design_curves(project_root), + station_snap=load_station_snap(project_root), ) stage_root = project_root / _STAGE_SUBDIR @@ -548,6 +559,8 @@ def sample_structure_sections( crs=crs, # 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다. design_curves=_load_design_curves(project_root), + # 규칙 측점과 같은 스냅 — 한쪽만 켜면 [저장] 때 옮기기 전 격자(640)가 되살아남. + station_snap=load_station_snap(project_root), ) def structural(entry: dict[str, Any]) -> bool: diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 2505e8535..22e567aaa 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -6,6 +6,9 @@ ⚠ 파이썬·TS 짝 — `_interpolate_xy`·`_tangent_at` ↔ `B05_Profile_UI_RouteEdit_Frame.ts` (`stationFrameAt`). 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py`. +⚠ 파이썬·TS 짝 — 측점 스냅 `_chainages` · `_design_curve_spans` · `snap_curve_stations` ↔ +`B05_Profile_UI_RouteEdit_Frame.ts` `stationsAlong` · `curveSpans` · `snapCurveStations`. 거울 시험 +`resources/tester/test_224_2_station_snap.py`. """ import math @@ -275,6 +278,35 @@ def _design_curve_spans( return sorted(spans) +#: 호 끝(BC · EC)에서 이만큼 안쪽 측점만 「호 안」 — 관 · 구조물 겹침 판정과 같은 값(m). +CURVE_STATION_EDGE_M = 0.005 + + +def snap_curve_stations( + stations: np.ndarray | list[float], + spans: list[tuple[float, ...]], +) -> np.ndarray: + """측점 스냅(요구 224-2) — 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 정수 MC + (floor(MC+0.5))로 옮김. 측점 없는 호는 그대로 · 옮긴 자리가 다른 측점과 같은 m 이면 하나로. + + `spans` = `_design_curve_spans` 꼴(앞 두 칸 = BC · EC 누가거리). + """ + values = [float(value) for value in stations] + for span in spans: + start, end = float(span[0]), float(span[1]) + middle = (start + end) / 2.0 + inside = [ + index + for index, value in enumerate(values) + if start + CURVE_STATION_EDGE_M < value < end - CURVE_STATION_EDGE_M + ] + if not inside: + continue + nearest = min(inside, key=lambda index: abs(values[index] - middle)) + values[nearest] = float(math.floor(middle + 0.5)) + return np.unique(np.asarray(values, dtype=np.float64)) + + def _design_curve_widenings( station_chainage: np.ndarray, spans: list[tuple[float, float, float, str]], @@ -319,8 +351,12 @@ def generate_sections( source_snapshot: dict[str, Any] | None = None, crs: str | None = None, design_curves: list[dict[str, Any]] | None = None, + station_snap: bool = False, ) -> dict[str, Any]: - """확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다.""" + """확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다. + + `station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2). + """ options = options or SectionGenerationOptions() options.validate() points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64)) @@ -331,6 +367,9 @@ def generate_sections( long_z, long_valid = sampler.sample_xy(long_xy) station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint) + spans = _design_curve_spans(points, route_chainage, design_curves or []) + if station_snap: + station_chainage = snap_curve_stations(station_chainage, spans) # 규칙(격자) 측점 chainage 집합 — 비정규와 겹칠 때 규칙 측점을 우선 판정하는 데 쓴다. regular_rounded = {round(float(value), 6) for value in station_chainage} # 비정규 측점(구조물)을 격자 측점 배열에 병합한다. 정렬·중복 제거만 하면 이후 접선·횡단 @@ -372,7 +411,6 @@ def generate_sections( # 다시 재면 같은 곡선 안에서도 확폭이 갈리고, 곡선이 측점 간격보다 짧으면 통째로 # 빠진다(실측: 설계 12m 곡선에 1.50m 이 붙고 곡선 밖 직선까지 흘러나갔다). # 곡선표가 없는 옛 프로젝트만 종전의 측점별 실측으로 물러선다. - spans = _design_curve_spans(points, route_chainage, design_curves or []) if spans: plan_radii, plan_outer_sides, plan_widenings = _design_curve_widenings( station_chainage, spans diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 10e2f4559..67c62b68f 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -41,10 +41,17 @@ from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse -from pydantic import AliasChoices, BaseModel, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice +from B05_Profile.B05_Profile_Engine_Sections import load_station_snap +from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀 + RouteInitialCriteria, + RouteInitialRequest, + RouteInitialWeights, + RouteReplanRequest, + RouteVertexInput, +) from common_util.common_util_initial_snapshot import design_route_csv_path from common_util.common_util_route_geometry import ( densify_route, @@ -69,74 +76,6 @@ router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"]) _PROJECT_PATH_MISSING = {"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."} -class RouteVertexInput(BaseModel): - """계획노선 꺾임점 하나 — 사업지 좌표계(m). - - 사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) — - · **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것. - · **곡선 삭제·추가** = `curve` 를 끄고 켜는 것. - · **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다. - """ - - x: float - y: float - curve: bool = True - """이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다.""" - radius_m: float | None = None - """못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다.""" - - -class RouteInitialWeights(BaseModel): - """비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25).""" - - expected: float = Field(50.0, ge=0, le=100) - earthwork: float = Field(25.0, ge=0, le=100) - grade: float = Field(25.0, ge=0, le=100) - - -class RouteInitialCriteria(BaseModel): - """공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다. - - 기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소 - 곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름. - """ - - max_grade_pct: float | None = Field(None, ge=0, le=100) - min_radius_m: float | None = Field(None, ge=0) - stream_offset_m: float | None = Field(None, ge=0) - grade_smooth_m: float | None = Field(None, ge=0) - # 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5) - target_grade_pct: float | None = Field(None, ge=0, le=100) - - -class RouteInitialRequest(BaseModel): - """초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle · - safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5).""" - - mode: Literal[ - "follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted" - ] = Field("follow", validation_alias=AliasChoices("logic", "mode")) - weights: RouteInitialWeights | None = None - criteria: RouteInitialCriteria | None = None - # 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘. - with_profile: bool = True - # 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2). - terrain: Literal["las", "sheet"] | None = None - - -class RouteReplanRequest(BaseModel): - """고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m).""" - - vertices: list[RouteVertexInput] = Field(default_factory=list) - initial: RouteInitialRequest | None = None - """이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값).""" - balanced_profile: bool = False - """「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해 - 1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만.""" - terrain: Literal["las", "sheet"] | None = None - """편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로.""" - - async def _project_paths(project_id: UUID) -> tuple[Path, str] | None: """(프로젝트 실경로, 저장소 상대경로). 없으면 None.""" pool = get_db_pool() @@ -305,9 +244,12 @@ def _write_planned_polyline( simplify: bool = True, curve_flags: list[bool] | None = None, radii: list[float | None] | None = None, + station_snap: bool | None = None, ) -> dict: """점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다. + `station_snap` — [확인] 때만 곡선표에 적음(요구 224-2). None 이면 칸을 안 둠(끔). + **노드도 함께 남긴다** — 노드는 폴리라인에서 되뽑을 수 없다. 폴리라인에는 원호 위 점이 섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를 때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다. @@ -321,13 +263,13 @@ def _write_planned_polyline( ) write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices]) write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes]) - _curves_path(path).write_text( - json.dumps( - {"min_radius_m": round(radius_m, 3), "curves": [c.as_dict() for c in result.curves]}, - ensure_ascii=False, - ), - encoding="utf-8", - ) + table: dict[str, Any] = { + "min_radius_m": round(radius_m, 3), + "curves": [c.as_dict() for c in result.curves], + } + if station_snap is not None: + table["station_snap"] = bool(station_snap) + _curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8") return { "nodes": len(result.nodes), "curves": result.curve_count, @@ -572,6 +514,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: "curve_count": len(saved_curves) if saved_curves else outline.curve_count, "violation_count": outline.violation_count, "edited": bool(working), + "station_snap": await asyncio.to_thread(load_station_snap, project_root), "current_initial": await asyncio.to_thread(read_initial_choice, project_root), "initial_route": await asyncio.to_thread(_initial_shape, project_root, expected, radius_m), } @@ -604,11 +547,17 @@ async def replan_route( radii = [vertex.radius_m for vertex in request.vertices] # 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면 # 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측). + # 곡선표 · 노드도 함께 — 남기면 되돌린 노선에 새 곡선표(스냅 켬끔)가 붙음. working_path = planned_route_working_path(project_root) - previous = working_path.read_bytes() if working_path.is_file() else None + keep = (working_path, _curves_path(working_path), _nodes_path(working_path)) + previous = {path: path.read_bytes() if path.is_file() else None for path in keep} summary = await asyncio.to_thread( functools.partial( - _write_planned_polyline, simplify=False, curve_flags=curve_flags, radii=radii + _write_planned_polyline, + simplify=False, + curve_flags=curve_flags, + radii=radii, + station_snap=request.station_snap, ), working_path, nodes, @@ -633,10 +582,11 @@ async def replan_route( ) if "error" in result: # 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다. - if previous is None: - working_path.unlink(missing_ok=True) - else: - working_path.write_bytes(previous) + for path, saved in previous.items(): + if saved is None: + path.unlink(missing_ok=True) + else: + path.write_bytes(saved) logger.warning( "계획노선 갈아 끼우기 실패 — 노선을 되돌렸습니다: project_id=%s 사유=%s", project_id, diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index 559d64724..197578e4b 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -133,6 +133,8 @@ class CrossPreviewRequest(BaseModel): #: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값. cross_half_width_m: float | None = Field(None, gt=0) terrain: AnalysisTerrain | None = None + #: [측점 스냅] 켬(요구 224-2) — 편집 창 단추 값 그대로. + station_snap: bool = False def _cross_preview( @@ -177,7 +179,14 @@ def _cross_preview( else defaults.station_interval_m, cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m, ) - result = generate_sections(built.vertices, sampler, options) + # 곡선표 = 방금 지은 노선의 곡선 — 스냅 자리 · 확폭이 [확인] 뒤 서버 정본과 같은 규칙. + result = generate_sections( + built.vertices, + sampler, + options, + design_curves=[curve.as_dict() for curve in built.curves], + station_snap=request.station_snap, + ) sections = result["cross_sections"] if not sections: return None diff --git a/B05_Profile/B05_Profile_Schema.py b/B05_Profile/B05_Profile_Schema.py index 0578c7334..5221c9d7d 100644 --- a/B05_Profile/B05_Profile_Schema.py +++ b/B05_Profile/B05_Profile_Schema.py @@ -2,7 +2,7 @@ from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator from common_util.common_util_surface_sampler import SurfaceFilter, SurfaceMethod from config.config_system import ( @@ -309,3 +309,76 @@ class RouteLatestResponse(BaseModel): route_points: list[dict[str, Any]] = Field(default_factory=list) surface_params: dict[str, Any] route_params: dict[str, Any] | None = None + + +# ── 계획노선 편집 요청(`B05_Profile_Router_Replan`) ───────────────────────────── + + +class RouteVertexInput(BaseModel): + """계획노선 꺾임점 하나 — 사업지 좌표계(m). + + 사용자 편집을 그대로 나른다(2026-09-07 사용자 지시) — + · **직선 삭제·추가** = 이 목록에서 점을 빼거나 더하는 것. + · **곡선 삭제·추가** = `curve` 를 끄고 켜는 것. + · **반지름 변경** = `radius_m` 을 주는 것. 안 주면 서버가 고른다. + """ + + x: float + y: float + curve: bool = True + """이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다.""" + radius_m: float | None = None + """못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다.""" + + +class RouteInitialWeights(BaseModel): + """비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25).""" + + expected: float = Field(50.0, ge=0, le=100) + earthwork: float = Field(25.0, ge=0, le=100) + grade: float = Field(25.0, ge=0, le=100) + + +class RouteInitialCriteria(BaseModel): + """공통 제약 겹침 값 — 화면 네 칸(PLAN 20장 확정). 비우면(None) 프로젝트 기본값을 쓴다. + + 기본값(2026-09-29 사용자 결정 — 지식DB 후보 표 그대로 확정) — 종단기울기 상한 · 최소 + 곡선반지름은 프로젝트 설계속도 · 지형에 따른 표값, 계류 이격 100 m, 기울기 잴 지반 5 m 고름. + """ + + max_grade_pct: float | None = Field(None, ge=0, le=100) + min_radius_m: float | None = Field(None, ge=0) + stream_offset_m: float | None = Field(None, ge=0) + grade_smooth_m: float | None = Field(None, ge=0) + # 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5) + target_grade_pct: float | None = Field(None, ge=0, le=100) + + +class RouteInitialRequest(BaseModel): + """초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle · + safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5).""" + + mode: Literal[ + "follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted" + ] = Field("follow", validation_alias=AliasChoices("logic", "mode")) + weights: RouteInitialWeights | None = None + criteria: RouteInitialCriteria | None = None + # 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘. + with_profile: bool = True + # 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2). + terrain: Literal["las", "sheet"] | None = None + + +class RouteReplanRequest(BaseModel): + """고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m).""" + + vertices: list[RouteVertexInput] = Field(default_factory=list) + initial: RouteInitialRequest | None = None + """이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값).""" + balanced_profile: bool = False + """「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해 + 1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만.""" + terrain: Literal["las", "sheet"] | None = None + """편집 창에서 고른 분석 지형 = 설계 지형(PLAN 133-2). None 이면 지금 설계 지형 그대로.""" + station_snap: bool = False + """[측점 스냅] 켬(요구 224-2) — 곡선표 `"station_snap"` 으로 남아 측점 셈이 읽음.""" diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts index f7e1f4b56..67bc9aeb8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts @@ -5,6 +5,10 @@ * ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` 의 `_interpolate_xy`·`_tangent_at` * (`generate_sections` 가 측점 자리·좌측 축을 내는 식). 한쪽을 고치면 다른 쪽도 고치고 * 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py` 를 돌림. + * + * ⚠ 파이썬·TS 짝 — 측점 스냅(요구 224-2) `stationsAlong` · `curveSpans` · `snapCurveStations` + * ↔ `B05_Profile_Engine_Sections_Core.py` `_chainages` · `_design_curve_spans` · + * `snap_curve_stations`. 거울 시험 `resources/tester/test_224_2_station_snap.py`. * ========================================================================== */ type Vertex = [number, number]; @@ -75,3 +79,98 @@ export function stationFrameAt( const tangent: Vertex = [(to[0] - from[0]) / length, (to[1] - from[1]) / length]; return { origin: interpolate(points, chainage, chainageM), left: [-tangent[1], tangent[0]] }; } + +/** 곡선표 시 · 종점이 노선에서 이보다 멀면 그 노선 곡선이 아님(m) — 파이썬 같은 이름. */ +export const DESIGN_CURVE_MATCH_TOLERANCE_M = 5.0; +/** 호 끝(BC · EC)에서 이만큼 안쪽 측점만 「호 안」(m) — 파이썬 같은 이름. */ +export const CURVE_STATION_EDGE_M = 0.005; + +/** 곡선표 한 줄에서 스냅이 읽는 칸 — 서버 곡선표 · `EditedCurve` 모두 이 꼴을 품음. */ +export interface CurveEnds { + start?: ReadonlyArray | null; + apex?: ReadonlyArray | null; + end?: ReadonlyArray | null; + radius_m?: number | null; +} + +/** 규칙 측점 누가거리 — 0 · 간격 · 2·간격 · … (+ 끝 자투리 `includeEndpoint`). `_chainages` 짝. */ +export function stationsAlong( + line: ReadonlyArray, + intervalM: number, + includeEndpoint: boolean, +): number[] { + const { chainage } = cleanPolyline(line); + const total = chainage.length ? chainage[chainage.length - 1] : 0; + const values: number[] = []; + if (intervalM > 0) + for (let i = 0; i * intervalM < total + 1e-9; i += 1) values.push(i * intervalM); + if (!values.length || Math.abs(values[0]) > 1e-9) values.unshift(0); + if (includeEndpoint && total - values[values.length - 1] > 1e-6) values.push(total); + return values; +} + +/** 점을 노선 위로 내려 [떨어진 거리, 누가거리] — `_project_chainage` 짝(같으면 앞 선분). */ +function projectChainage( + points: Vertex[], + chainage: number[], + xy: ReadonlyArray, +): [number, number] { + let bestGap = Infinity; + let bestAt = 0; + for (let index = 0; index < points.length - 1; index += 1) { + const [sx, sy] = points[index]; + const vx = points[index + 1][0] - sx; + const vy = points[index + 1][1] - sy; + const length2 = vx * vx + vy * vy; + const safe = length2 > 1e-12 ? length2 : 1; + const ratio = Math.min(1, Math.max(0, ((xy[0] - sx) * vx + (xy[1] - sy) * vy) / safe)); + const gap = Math.hypot(sx + vx * ratio - xy[0], sy + vy * ratio - xy[1]); + if (gap < bestGap) { + bestGap = gap; + bestAt = chainage[index] + (chainage[index + 1] - chainage[index]) * ratio; + } + } + return [bestGap, bestAt]; +} + +/** 곡선표 → [BC, EC] 누가거리(앞쪽부터) — 노선 밖 곡선 · 방향 못 재는 곡선은 버림. + * `_design_curve_spans` 짝(반지름 · 바깥쪽 칸은 스냅이 안 써 뺌). */ +export function curveSpans( + line: ReadonlyArray, + curves: ReadonlyArray, +): Array<[number, number]> { + const { points, chainage } = cleanPolyline(line); + if (points.length < 2) return []; + const spans: Array<[number, number]> = []; + for (const { start, apex, end, radius_m } of curves) { + if (!start?.length || !apex?.length || !end?.length || typeof radius_m !== "number") continue; + const [startGap, startAt] = projectChainage(points, chainage, start); + const [endGap, endAt] = projectChainage(points, chainage, end); + if (Math.max(startGap, endGap) > DESIGN_CURVE_MATCH_TOLERANCE_M) continue; + const cross = + (apex[0] - start[0]) * (end[1] - apex[1]) - (apex[1] - start[1]) * (end[0] - apex[0]); + if (Math.abs(cross) < 1e-12) continue; + spans.push([Math.min(startAt, endAt), Math.max(startAt, endAt)]); + } + return spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]); +} + +/** 측점 스냅 — 호 안 측점 중 곡선 중점 MC 에 가장 가까운 하나를 floor(MC+0.5) 로 옮김 · + * 측점 없는 호는 그대로 · 같은 m 는 하나로(오름차순). `snap_curve_stations` 짝. */ +export function snapCurveStations( + stations: ReadonlyArray, + spans: ReadonlyArray, +): number[] { + const values = [...stations]; + for (const [start, end] of spans) { + const middle = (start + end) / 2; + let nearest = -1; + values.forEach((value, index) => { + if (value <= start + CURVE_STATION_EDGE_M || value >= end - CURVE_STATION_EDGE_M) return; + if (nearest < 0 || Math.abs(value - middle) < Math.abs(values[nearest] - middle)) + nearest = index; + }); + if (nearest >= 0) values[nearest] = Math.floor(middle + 0.5); + } + return [...new Set(values)].sort((a, b) => a - b); +} diff --git a/resources/tester/test_224_2_station_snap.py b/resources/tester/test_224_2_station_snap.py new file mode 100644 index 000000000..2e123b818 --- /dev/null +++ b/resources/tester/test_224_2_station_snap.py @@ -0,0 +1,388 @@ +"""요구 224-2 · PLAN 326 — 계획노선 편집 [측점 스냅]. + +짝: 파이썬 `B05_Profile_Engine_Sections_Core` (`_chainages` · `_design_curve_spans` · +`snap_curve_stations`) ↔ TS `B05_Profile_UI_RouteEdit_Frame.ts` (`stationsAlong` · +`curveSpans` · `snapCurveStations`). 켜면 호(BC~EC) 안 측점 중 곡선 중점 MC 에 가장 가까운 +하나를 floor(MC+0.5) 로 옮김 · 측점 없는 호는 안 심음 · 같은 m 하나로. +""" + +from __future__ import annotations + +import json +import math +import re +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402 + +TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Frame.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +GRID = [float(value) for value in range(0, 1001, 20)] + + +def _snap(spans: list[tuple[float, float]], stations: list[float] | None = None) -> list[float]: + return [float(v) for v in core.snap_curve_stations(stations or GRID, spans)] + + +# ── 파이썬 규칙 ──────────────────────────────────────────────────────────────── + + +def test_곡선_없음_그대로() -> None: + assert _snap([]) == GRID + + +def test_측점_없는_호는_안_심음() -> None: + assert _snap([(621.0, 639.0)]) == GRID + + +def test_호_안_하나를_정수_MC_로() -> None: + out = _snap([(612.0, 634.0)]) # MC 623 + assert 623.0 in out and 620.0 not in out and len(out) == len(GRID) + + +def test_호_안_둘이면_MC_에_가까운_하나만() -> None: + out = _snap([(616.1, 646.5)]) # MC 631.3 — 640(8.7) 이 620(11.3) 보다 가까움 + assert 631.0 in out and 640.0 not in out and 620.0 in out + + +def test_BC_위_측점은_호_밖() -> None: + assert _snap([(620.0, 630.0)]) == GRID + assert _snap([(610.0, 620.0)]) == GRID # EC 위도 밖 + + +def test_반_올림() -> None: + out = _snap([(611.0, 640.0)]) # MC 625.5 → 626 · 640 = EC 위라 밖 + assert 626.0 in out and 620.0 not in out and 640.0 in out + + +def test_같은_m_는_하나로() -> None: + # 20.3 이 MC 20.35 → 20 으로 옮겨 이웃 20 과 같은 m — 하나만 남음 + assert _snap([(20.1, 20.6)], [0.0, 20.0, 20.3, 40.0]) == [0.0, 20.0, 40.0] + + +# ── 파이썬 ↔ TS 거울 ───────────────────────────────────────────────────────── + + +def _arc(cx: float, cy: float, radius: float, start: float, end: float, count: int) -> list: + return [ + [cx + radius * math.cos(angle), cy + radius * math.sin(angle)] + for angle in np.linspace(start, end, count) + ] + + +def _bend_route() -> tuple[list, list]: + """직선 300 m → R 40 호 → 직선 — 곡선표 한 줄 + 노선 밖 곡선 · 방향 없는 곡선.""" + arc = _arc(300.0, 40.0, 40.0, -math.pi / 2, 0.3, 60) + line = [[0.0, 0.0], *arc, [arc[-1][0] - 200 * math.sin(0.3), arc[-1][1] + 200 * math.cos(0.3)]] + curves = [ + {"start": arc[0], "apex": [340.0, 0.0], "end": arc[-1], "radius_m": 40.0}, + {"start": [0.0, 500.0], "apex": [10.0, 510.0], "end": [20.0, 500.0], "radius_m": 30.0}, + {"start": [50.0, 0.0], "apex": [60.0, 0.0], "end": [70.0, 0.0], "radius_m": 30.0}, + {"start": [80.0, 0.0], "apex": [90.0, 1.0], "end": [100.0, 0.0], "radius_m": None}, + ] + return line, curves + + +CASES = [ + {"line": [[0, 0], [1000, 0]], "interval": 20, "end": True, "curves": []}, + { + "line": [[0, 0], [1000.3, 0]], + "interval": 20, + "end": True, + "curves": [{"start": [616.1, 0], "apex": [631.3, 3], "end": [646.5, 0], "radius_m": 40}], + }, + { + "line": [[0, 0], [500, 0], [500, 0], [700, 10]], + "interval": 20, + "end": False, + "curves": [ + {"start": [611, 0], "apex": [625.5, -2], "end": [640, 0], "radius_m": 25}, + {"start": [621, 0], "apex": [630, 2], "end": [639, 0], "radius_m": 25}, + {"start": [100, 0], "apex": [110, 2], "end": [112, 0], "radius_m": 25}, + ], + }, + {"line": _bend_route()[0], "interval": 20, "end": True, "curves": _bend_route()[1]}, + {"line": _bend_route()[0], "interval": 7.5, "end": True, "curves": _bend_route()[1]}, +] + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import * as frame from "./B05_Profile_UI_RouteEdit_Frame.js"; +const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = { + tolerance: frame.DESIGN_CURVE_MATCH_TOLERANCE_M, + edge: frame.CURVE_STATION_EDGE_M, + rows: cases.map((item) => { + const grid = frame.stationsAlong(item.line, item.interval, item.end); + const spans = frame.curveSpans(item.line, item.curves); + return { grid, spans, snapped: frame.snapCurveStations(grid, spans) }; + }), +}; +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + + +def _python_row(case: dict) -> dict: + points, route_chainage = core._clean_polyline(np.asarray(case["line"], dtype=np.float64)) + grid = core._chainages(float(route_chainage[-1]), float(case["interval"]), case["end"]) + spans = core._design_curve_spans(points, route_chainage, case["curves"]) + return { + "grid": [float(v) for v in grid], + "spans": [[span[0], span[1]] for span in spans], + "snapped": [float(v) for v in core.snap_curve_stations(grid, spans)], + } + + +def _ts_rows(tmp_path: Path) -> dict: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(TS_FILE), + "--outDir", + str(out), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + for emitted in out.glob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + payload = tmp_path / "input.json" + result = tmp_path / "output.json" + payload.write_text(json.dumps(CASES), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(payload), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치") +def test_스냅_셈이_두_쪽에서_같다(tmp_path: Path) -> None: + actual = _ts_rows(tmp_path) + assert actual["tolerance"] == core.DESIGN_CURVE_MATCH_TOLERANCE_M + assert actual["edge"] == core.CURVE_STATION_EDGE_M + moved = 0 + for index, case in enumerate(CASES): + expected = _python_row(case) + row = actual["rows"][index] + assert row["grid"] == pytest.approx(expected["grid"], abs=1e-9), f"사례 {index} 격자" + assert len(row["spans"]) == len(expected["spans"]), f"사례 {index} 곡선 수" + for got, want in zip(row["spans"], expected["spans"], strict=True): + assert got == pytest.approx(want, abs=1e-9), f"사례 {index} 곡선" + assert row["snapped"] == pytest.approx(expected["snapped"], abs=1e-9), f"사례 {index} 스냅" + moved += row["snapped"] != row["grid"] + assert moved >= 3 # 옮김이 실제로 일어난 사례를 견줌 + + +def test_짝_머리글이_서로를_가리킨다() -> None: + text = TS_FILE.read_text(encoding="utf-8") + assert "B05_Profile_Engine_Sections_Core.py" in text and "snap_curve_stations" in text + assert "snapCurveStations" in (core.__doc__ or "") + + +# ── generate_sections ──────────────────────────────────────────────────────── + + +class _Flat: + def sample_xy(self, xy): + count = len(np.asarray(xy, dtype=np.float64)) + return np.full(count, 100.0), np.ones(count, dtype=bool) + + +_LINE = [[0.0, 0.0, 100.0], [1000.3, 0.0, 100.0]] +_CURVES = [{"start": [616.1, 0.0], "apex": [631.3, 3.0], "end": [646.5, 0.0], "radius_m": 40.0}] + + +def _stations(snap: bool | None, extras: tuple = ()) -> list[dict]: + kwargs = {} if snap is None else {"station_snap": snap} + result = core.generate_sections( + _LINE, + _Flat(), + core.SectionGenerationOptions(extra_stations=extras), + design_curves=_CURVES, + **kwargs, + ) + return result["longitudinal"]["stations"] + + +def test_끔은_지금과_같음() -> None: + assert _stations(False) == _stations(None) + assert 640.0 in [s["chainage_m"] for s in _stations(False)] + + +def test_켬은_640_없고_631_있음() -> None: + by_chainage = {s["chainage_m"]: s for s in _stations(True)} + assert 640.0 not in by_chainage and 631.0 in by_chainage + assert by_chainage[631.0]["kind"] == "regular" + assert by_chainage[631.0]["plan_radius_m"] == 40.0 # 호 안 = 곡선표 반경 + assert len(by_chainage) == len(_stations(False)) + + +def test_631_에_관이면_grid() -> None: + by_chainage = {s["chainage_m"]: s for s in _stations(True, ((631.0, "관"),))} + assert by_chainage[631.0]["kind"] == "irregular" + assert by_chainage[631.0]["grid"] is True + # 640 에 놓인 관은 스냅 뒤 격자가 아니라 따로 선 측점 + off_grid = {s["chainage_m"]: s for s in _stations(True, ((640.0, "관"),))} + assert off_grid[640.0]["kind"] == "irregular" and "grid" not in off_grid[640.0] + + +# ── 서버 저장 · 정본 (326-2) ───────────────────────────────────────────────── + +_NODES = [(0.0, 0.0), (300.0, 0.0), (300.0, 300.0)] + + +def _working(tmp_path: Path) -> Path: + from common_util.common_util_route_geometry import planned_route_working_path + + path = planned_route_working_path(tmp_path) + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def test_확인_때_곡선표에_적고_읽음(tmp_path: Path) -> None: + from B05_Profile import B05_Profile_Router_Replan as replan + from B05_Profile.B05_Profile_Engine_Sections import load_station_snap + + path = _working(tmp_path) + assert load_station_snap(tmp_path) is False # 파일 없음 + replan._write_planned_polyline(path, _NODES, 30.0, simplify=False) + assert "station_snap" not in json.loads(replan._curves_path(path).read_text("utf-8")) + assert load_station_snap(tmp_path) is False # 칸 없음 = 끔(옛 프로젝트) + replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=True) + assert load_station_snap(tmp_path) is True + replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=False) + assert load_station_snap(tmp_path) is False + + +def _patch_replan(monkeypatch, tmp_path: Path): + from B05_Profile import B05_Profile_Router_Replan as replan + + async def paths(_project_id): + return tmp_path, "stored" + + async def criteria(_project_id): + return 30.0, 0.0, 0.0 + + monkeypatch.setattr(replan, "_project_paths", paths) + monkeypatch.setattr(replan, "_plan_criteria", criteria) + return replan + + +def test_실패하면_곡선표_노드도_되돌림(tmp_path: Path, monkeypatch) -> None: + import asyncio + from uuid import uuid4 + + replan = _patch_replan(monkeypatch, tmp_path) + + async def broken(*_args, **_kwargs): + return {"error": "체인 끊김"} + + monkeypatch.setattr(replan, "_recompute", broken) + path = _working(tmp_path) + replan._write_planned_polyline(path, _NODES, 30.0, simplify=False) + files = (path, replan._curves_path(path), replan._nodes_path(path)) + before = {file: file.read_bytes() for file in files} + request = replan.RouteReplanRequest( + vertices=[{"x": 0, "y": 0}, {"x": 320, "y": 40}, {"x": 300, "y": 300}], + station_snap=True, + ) + response = asyncio.run(replan.replan_route(uuid4(), request)) + assert response.status_code == 409 + assert {file: file.read_bytes() for file in files} == before + + +def test_GET_계획노선이_스냅_값을_돌려줌(tmp_path: Path, monkeypatch) -> None: + import asyncio + from uuid import uuid4 + + replan = _patch_replan(monkeypatch, tmp_path) + path = _working(tmp_path) + replan._write_planned_polyline(path, _NODES, 30.0, simplify=False) + assert asyncio.run(replan.read_route_plan(uuid4()))["station_snap"] is False + replan._write_planned_polyline(path, _NODES, 30.0, simplify=False, station_snap=True) + assert asyncio.run(replan.read_route_plan(uuid4()))["station_snap"] is True + + +def test_측점_셈_둘_다_스냅을_읽음(tmp_path: Path, monkeypatch) -> None: + from B05_Profile import B05_Profile_Engine_Sections as sections + from B05_Profile import B05_Profile_Router_Replan as replan + + path = _working(tmp_path) + replan._write_planned_polyline(path, _NODES, 30.0, simplify=False) + replan._curves_path(path).write_text( + json.dumps({"curves": _CURVES, "station_snap": True}), encoding="utf-8" + ) + monkeypatch.setattr(sections, "_load_route_polyline", lambda *_args: _LINE) + monkeypatch.setattr(sections, "build_surface_sampler", lambda *_args, **_kw: _Flat()) + # [저장] 때 구조물 측점 셈 — 631 은 스냅한 격자 자리 + stations, _ = sections.sample_structure_sections( + tmp_path, "route.geojson", "f", "m", False, extra_stations=((631.0, "관"),) + ) + assert [s["chainage_m"] for s in stations] == [631.0] and stations[0]["grid"] is True + + seen: dict = {} + + def stop(*_args, **kwargs): + seen.update(kwargs) + raise RuntimeError("여기까지") + + monkeypatch.setattr(sections, "generate_sections", stop) + monkeypatch.setattr(sections, "_load_pipe_points", lambda *_args: []) + monkeypatch.setattr(sections, "resolve_extra_stations", lambda *_args: ()) + with pytest.raises(RuntimeError): + sections.run_section_generation(tmp_path, "route.geojson", "f", "m", False) + assert seen["station_snap"] is True + + +def test_횡단_미리보기도_스냅(tmp_path: Path, monkeypatch) -> None: + from B05_Profile import B05_Profile_Router_Terrain as terrain + from common_util.common_util_route_polyline import build_planned_polyline + + monkeypatch.setattr(terrain, "build_surface_sampler", lambda *_args, **_kw: _Flat()) + built = build_planned_polyline(_NODES, min_radius_m=40.0, simplify=False) + points, chain = core._clean_polyline(np.asarray(built.vertices, dtype=np.float64)) + spans = core._design_curve_spans(points, chain, [c.as_dict() for c in built.curves]) + assert len(spans) == 1 + target = math.floor((spans[0][0] + spans[0][1]) / 2 + 0.5) + assert target % 20 # 격자 밖 자리여야 견줄 뜻이 있음 + + def ask(snap: bool) -> float: + request = terrain.CrossPreviewRequest( + vertices=[{"x": x, "y": y} for x, y in _NODES], + chainage_m=target, + min_radius_m=40.0, + station_interval_m=20.0, + station_snap=snap, + ) + params = {"source_filter": "f", "method": "m", "smooth": False} + return terrain._cross_preview(tmp_path, params, request)["chainage_m"] + + assert ask(True) == target + assert ask(False) % 20 == 0 From 6ae8bb4f6ae19dedd72fc1861b623ede6d293177 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:32:45 +0900 Subject: [PATCH 08/36] =?UTF-8?q?feat(B05=EC=B6=94=EC=B2=9C):=20328-1=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EB=85=B8=EC=84=A0=20R=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=E2=80=94=20=ED=83=90=EC=83=89=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99(=EB=8B=A8=EC=88=9C=ED=99=94=20=C2=B7=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20R=20=EB=A7=9E=EC=B6=A4)=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=C2=B7=20=EC=B5=9C=EC=86=8C=20R=20=ED=8C=90=EB=B3=B4?= =?UTF-8?q?=EB=8B=A4=20=EC=9C=84=EB=B0=98=20=EB=A7=8E=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EC=B5=9C=EC=86=8C=20R=20=ED=8C=90=20=C2=B7=20=EB=8F=84?= =?UTF-8?q?=EC=9B=80=EB=A7=90=20=EC=B6=94=EC=A0=81=20=E3=80=8C=EB=A7=9E?= =?UTF-8?q?=EC=B6=98=20=EA=B3=A1=EC=84=A0=EB=B0=98=EC=A7=80=EB=A6=84?= =?UTF-8?q?=E3=80=8D?= 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_01Se7ytqbohmBQnfYpwYFnYf --- .../B05_Profile_Engine_RouteInitial_Pool.py | 22 ++++++++++++++++--- ...B05_Profile_UI_RouteEdit_Recommend_Help.ts | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py index f09392ef6..9a7571509 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py @@ -43,6 +43,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( NoRoute, RouteGraph, ) +from common_util.common_util_route_geometry import densify_route from common_util.common_util_route_polyline import build_planned_polyline ZERO = {"expected": 0.0, "earthwork": 0.0, "grade": 0.0} @@ -70,6 +71,8 @@ MIN_RANGE_SHARE = 0.05 SIMILAR_M = 10.0 # 비중 계산의 제약 몫(기울기 · 계류 각각) = (1 − 예상 몫) × 이 값 — 크기 맞춘 목표 한 몫과 같은 세기 CONSTRAINT_SHARE = 1.0 +# 탐색 경로를 예정노선처럼 폴리라인화하기 전 점 간격(m) — R 맞춤이 꺾임점 사이 점으로 잼 +TRACE_DENSIFY_M = 2.0 Measure = Callable[..., dict[str, Any]] Route = tuple[Any, dict[str, Any]] @@ -103,14 +106,27 @@ def _trace( traced: dict[tuple, Route], **kw: Any, ) -> Route: - # 노드마다 곡선(묶지 않음) — [확인] 재구성(`/route/replan`)과 같은 규칙 → 적용 뒤도 같은 선. + # 큰 R(PLAN 328) — 1단 판(탐색 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤)이 위반이 + # 최소 R 판(노드마다 곡선 · 옛 규칙)보다 많지 않으면 그 판. 둘 다 화면이 [확인] 때 같은 선으로 + # 다시 짜는 꼴. # 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로). key = (tuple(nodes), tuple(sorted(kw.items()))) if key not in traced: - outline = build_planned_polyline( + floor = build_planned_polyline( nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=[True] * len(nodes) ) - traced[key] = (outline, measure(outline, **kw)) + least = (floor, measure(floor, **kw)) + path = densify_route(nodes, TRACE_DENSIFY_M) + fitted = build_planned_polyline(path, min_radius_m=min_radius_m) + larger = (fitted,) + traced[key] = next( + ( + route + for route in ((outline, measure(outline, **kw)) for outline in larger) + if route[1]["over_limit"]["total"] <= least[1]["over_limit"]["total"] + ), + least, + ) return traced[key] diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts index c3c713495..d088f80b7 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Help.ts @@ -29,7 +29,7 @@ const LOGIC_HELP: Record = { follow: { summary: "전처리 때 만든 예상노선(점선)을 그대로 따라가는 노선", how: [ - "새로 길을 찾지 않음 — 예상노선 꺾임점에 최소 곡선반지름으로 곡선만 넣음", + "새로 길을 찾지 않음 — 예상노선 꺾임점에 맞춘 곡선반지름(최소 이상 · 예상노선에 가장 가까운 값)으로 곡선만 넣음", "전처리 초기 계획노선과 같은 규칙이라 같은 선이 나옴", "종단기울기 · 계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음", ], From 394fb7ba604e064e05566659571aa34c58f761d4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:32:56 +0900 Subject: [PATCH 09/36] =?UTF-8?q?feat(B05=EC=B6=94=EC=B2=9C):=20328-2=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EB=85=B8=EC=84=A0=20=ED=81=B0=20R=20?= =?UTF-8?q?=E2=80=94=20=EA=B3=A1=EC=84=A0=EB=A7=88=EB=8B=A4=20[=EB=A7=9E?= =?UTF-8?q?=EC=B6=98=20R,=20=EC=A0=91=EC=84=A0=20=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=C3=B7=20tan(IA/2)]=20=EC=95=88=20=ED=83=90=EC=83=89=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=9D=B4=ED=83=88=20=E2=89=A4=204=20m=20?= =?UTF-8?q?=EC=9D=B8=20=EA=B0=80=EC=9E=A5=20=ED=81=B0=20R(1=20m=20?= =?UTF-8?q?=EB=82=B4=EB=A6=BC)=20=C2=B7=20=EB=AA=BB=EB=B0=95=EC=95=84=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EC=A7=AC(=ED=8E=B8=20=EB=85=B8=EB=93=9C?= =?UTF-8?q?=20=C2=B7=20[=ED=99=95=EC=9D=B8]=20=EC=9E=AC=EA=B5=AC=EC=84=B1?= =?UTF-8?q?=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EC=84=A0)=20=C2=B7=20?= =?UTF-8?q?=ED=97=88=EC=9A=A9=20=EC=9D=B4=ED=83=88=20=EC=83=81=EC=88=98=20?= =?UTF-8?q?=C2=B7=20test=5F224=5F4=5Frecommend=5Fradius?= 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_01Se7ytqbohmBQnfYpwYFnYf --- .../B05_Profile_Engine_RouteInitial_Pool.py | 9 +- .../B05_Profile_Engine_RouteInitial_Radius.py | 110 ++++++++++++ config/config_system_design.py | 2 + .../tester/test_224_4_recommend_radius.py | 156 ++++++++++++++++++ 4 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py create mode 100644 resources/tester/test_224_4_recommend_radius.py diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py index 9a7571509..1f7e2d3df 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py @@ -37,6 +37,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import ( COST_LENGTH_M, ) from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth +from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import widen from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( CROSSING_BAND, OBJECTIVES, @@ -106,9 +107,9 @@ def _trace( traced: dict[tuple, Route], **kw: Any, ) -> Route: - # 큰 R(PLAN 328) — 1단 판(탐색 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤)이 위반이 - # 최소 R 판(노드마다 곡선 · 옛 규칙)보다 많지 않으면 그 판. 둘 다 화면이 [확인] 때 같은 선으로 - # 다시 짜는 꼴. + # 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 1단 판(탐색 경로를 + # 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반이 최소 R 판(노드마다 곡선 · 옛 규칙) + # 보다 많지 않은 첫 판. 셋 다 화면이 [확인] 때 같은 선으로 다시 짜는 꼴. # 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로). key = (tuple(nodes), tuple(sorted(kw.items()))) if key not in traced: @@ -118,7 +119,7 @@ def _trace( least = (floor, measure(floor, **kw)) path = densify_route(nodes, TRACE_DENSIFY_M) fitted = build_planned_polyline(path, min_radius_m=min_radius_m) - larger = (fitted,) + larger = (widen(fitted, path, min_radius_m), fitted) traced[key] = next( ( route diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py new file mode 100644 index 000000000..f425eaa70 --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Radius.py @@ -0,0 +1,110 @@ +"""추천 노선 큰 R — 2단(PLAN 328-2 · 요구 224-4 「R 은 클수록 좋음」). + +`B05_Profile_Engine_RouteInitial_Pool._trace` 가 부름. 예상노선 추적(전처리 초기값)은 안 거침. + +1단(`_trace`)이 예정노선 규칙(단순화 · 같은 방향 묶음 · R 맞춤)으로 짠 선을 받아, 곡선마다 R 을 +[맞춘 R, 접선 자리 ÷ tan(IA/2)] 안에서 탐색 경로 점과 가장 먼 거리 ≤ `ROUTE_RECOMMEND_MAX_GAP_M` +인 가장 큰 값(1 m 내림)으로 고른다. 맞는 값이 없으면 맞춘 R 그대로. + +고른 R 은 편집 갈래(`simplify=False` · `radii`)로 못박아 다시 짠다 — 화면이 곡선을 교각점 하나로 +펴서(`flattenServerPlan`) [확인]이 다시 짜는 것과 같은 입력이라 저장 뒤 R 이 응답 R 과 같다. +""" + +from __future__ import annotations + +import math + +import numpy as np +from shapely.geometry import LineString, Point + +from common_util.common_util_route_polyline import ( + PlannedPolyline, + _inner_angle_deg, + build_planned_polyline, +) +from config.config_system_design import ROUTE_RECOMMEND_MAX_GAP_M + +Xy = tuple[float, float] + + +def _flat(outline: PlannedPolyline) -> tuple[list[Xy], list[bool], list[float | None]]: + """곡선 묶음을 교각점 하나로 편 노드 · 곡선 켬 · R — 화면 `flattenServerPlan` 과 같은 꼴.""" + starts = {curve.node_first: curve for curve in outline.curves} + inside = {i for c in outline.curves for i in range(c.node_first + 1, c.node_last + 1)} + points: list[Xy] = [] + flags: list[bool] = [] + radii: list[float | None] = [] + for index, node in enumerate(outline.nodes): + if index in inside: + continue + curve = starts.get(index) + points.append(curve.apex if curve else (node.x, node.y)) + flags.append(curve is not None) + radii.append(round(curve.radius_m, 2) if curve else None) + return points, flags, radii + + +def _cross(u: np.ndarray, v: np.ndarray) -> np.ndarray: + return u[..., 0] * v[..., 1] - u[..., 1] * v[..., 0] + + +def _gap(samples: np.ndarray, before: Xy, apex: Xy, after: Xy, radii: np.ndarray) -> np.ndarray: + """R 마다 점들이 「직선 · 원호 · 직선」(앞뒤 직선은 절반까지)에서 가장 먼 거리(m).""" + a = np.asarray(apex, dtype=float) + p = samples - a # (S, 2) 교각점 기준 + units = [(np.asarray(end, dtype=float) - a) for end in (before, after)] + halves = [float(np.hypot(*u)) / 2 for u in units] + units = [u / (2 * h) for u, h in zip(units, halves)] + inner = math.radians(_inner_angle_deg(before, apex, after)) + tangent = radii[:, None] * math.tan((math.pi - inner) / 2) # (M, 1) + legs = [] + for unit, half in zip(units, halves): + along = p @ unit + side = np.abs(_cross(unit, p)) + legs.append(np.hypot(side, np.maximum(0.0, np.maximum(tangent - along, along - half)))) + bisector = (units[0] + units[1]) / np.hypot(*(units[0] + units[1])) + center = (radii / math.sin(inner / 2))[:, None, None] * bisector # (M, 1, 2) + starts = [tangent[..., None] * unit - center for unit in units] # 중심 → 접선점 + q = p[None] - center # (M, S, 2) 중심 → 점 + turn = _cross(starts[0], starts[1]) + within = (_cross(starts[0], q) * turn >= 0) & (_cross(q, starts[1]) * turn >= 0) + arc = np.where(within, np.abs(np.hypot(q[..., 0], q[..., 1]) - radii[:, None]), np.inf) + return np.minimum(np.minimum(legs[0], legs[1]), arc).max(axis=1) + + +def widen( + outline: PlannedPolyline, + path: list[Xy], + min_radius_m: float, + max_gap_m: float = ROUTE_RECOMMEND_MAX_GAP_M, +) -> PlannedPolyline: + """곡선마다 허용 이탈 안 가장 큰 R(1 m 내림)로 못박아 다시 짠 선. 곡선이 없으면 그대로.""" + if not outline.curves: + return outline + points, flags, radii = _flat(outline) + line = LineString(path) + coords = np.asarray(path, dtype=float) + along = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(coords, axis=0).T))]) + for index in range(1, len(points) - 1): + if not flags[index]: + continue + before, apex, after = points[index - 1], points[index], points[index + 1] + half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, apex, after)) / 2) + if half_tan <= 1e-9: + continue + room = min(math.dist(before, apex), math.dist(apex, after)) / 2 / half_tan + candidates = np.arange(math.floor(room), float(radii[index]), -1.0) # 큰 것부터 + # 이 곡선이 쓰는 자리 = 앞뒤 직선 가운데점 사이 — 그 사이 탐색 경로 점만 잼 + ends = [ + line.project(Point((apex[0] + end[0]) / 2, (apex[1] + end[1]) / 2)) + for end in (before, after) + ] + samples = coords[(along >= min(ends)) & (along <= max(ends))] + if not candidates.size or not len(samples): + continue + ok = candidates[_gap(samples, before, apex, after, candidates) <= max_gap_m] + if ok.size: + radii[index] = float(ok[0]) + return build_planned_polyline( + points, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii + ) diff --git a/config/config_system_design.py b/config/config_system_design.py index 13fb522ef..4e7c43d93 100644 --- a/config/config_system_design.py +++ b/config/config_system_design.py @@ -38,6 +38,8 @@ ROUTE_DIRECT_LINK_CELL_FACTOR = float(os.getenv("ROUTE_DIRECT_LINK_CELL_FACTOR", # 예정노선을 자동 체인에 넘기기 전, 직결 문턱의 이 비율까지 정점 간격을 좁힌다. # 문턱과 정확히 같게 두면 부동소수 오차 한 번에 탐색으로 넘어가 노선이 바뀐다. ROUTE_PLANNED_DENSIFY_SAFETY = float(os.getenv("ROUTE_PLANNED_DENSIFY_SAFETY", "0.9")) +# 추천 노선 큰 R — 곡선을 키울 때 탐색 경로에서 벗어나도 되는 가장 먼 거리(m) · 탐색 칸 하나 +ROUTE_RECOMMEND_MAX_GAP_M = float(os.getenv("ROUTE_RECOMMEND_MAX_GAP_M", "4.0")) # 임도 종류 — 현행 규칙(별표2)의 3종. `branch`(지선)는 규칙에서 폐지됐으나 기존 # 저장분이 남아 있어 값으로는 계속 받는다(화면 선택지에서는 뺀다, 2026-08-19). ROUTE_GRADE_CLASSES = ("trunk", "fire", "work", "branch") diff --git a/resources/tester/test_224_4_recommend_radius.py b/resources/tester/test_224_4_recommend_radius.py new file mode 100644 index 000000000..befa3dae8 --- /dev/null +++ b/resources/tester/test_224_4_recommend_radius.py @@ -0,0 +1,156 @@ +"""추천 노선 큰 R(PLAN 328 · 요구 224-4) — 1단 R 맞춤 · 2단 허용 이탈 안 가장 큰 R. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_224_4_recommend_radius.py -q + +· 네 갈래(영선 · 토공 최소 · 기울기 최소 · 비중) R ≥ 최소(모자라면 위반) · 하나 이상 큼. +· 위반 ≤ 같은 노드로 짠 최소 R 판(노드마다 곡선 · 옛 규칙) — 묶음의 잰 노선 전부. +· 예상노선 추적은 전처리 초기값 그대로. +· 2단 = 탐색 경로 이탈 ≤ 허용(4 m) · 1 m 더 키우면 넘음 · [확인] 재구성 뒤 같은 R. +""" + +import math + +import numpy as np +import pytest +from shapely.geometry import LineString, Point + +import B05_Profile.B05_Profile_Engine_RouteInitial as engine +from B05_Profile.B05_Profile_Engine_RouteInitial import Terrain, generate_initial_route +from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, widen +from common_util.common_util_route_polyline import build_planned_polyline +from config.config_system_design import ROUTE_RECOMMEND_MAX_GAP_M + +GRADE = 0.14 +RADIUS = 12.0 +SHOWN = ["gradeline", "earthwork", "gentle", "weighted"] + + +def _line() -> list[tuple[float, float]]: + return [(float(x), 200.0) for x in np.arange(50.0, 752.0, 4.0)] + + +@pytest.fixture(scope="module") +def runs() -> dict: + """가운데 언덕(40 m) — 곧은 예상노선이 꼭대기를 넘어 갈래마다 돌아감. 묶음 · 잼도 붙잡음.""" + x = np.arange(0.0, 802.0, 2.0) + y = np.arange(0.0, 402.0, 2.0) + xx, yy = np.meshgrid(x, y) + z = 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2) + terrain = Terrain(x, y, z, np.ones(xx.shape, dtype=bool)) + caught: dict = {} + real = engine.build_pool + + def spy(graph, follow, max_grade, min_radius_m, measure): + caught["measure"] = measure + caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure) + return caught["pool"] + + engine._POOLS.clear() + engine.build_pool = spy + try: + results = { + mode: generate_initial_route( + _line(), terrain, [], mode=mode, weights=None, max_grade=GRADE, min_radius_m=RADIUS + ) + for mode in [*SHOWN, "follow"] + } + finally: + engine.build_pool = real + engine._POOLS.clear() + return {"results": results, **caught} + + +def test_shown_branches_radius_at_least_min_and_some_larger(runs: dict) -> None: + radii = [] + for mode in SHOWN: + for curve in runs["results"][mode]["curves"]: + assert curve["radius_m"] >= RADIUS - 1e-6 or curve["violations"], mode + radii.append(curve["radius_m"]) + assert max(radii) > RADIUS + 1.0 + + +def test_violations_not_above_min_radius_version(runs: dict) -> None: + measure = runs["measure"] + for (nodes, kw), (_, metrics) in runs["pool"].traced.items(): + floor = build_planned_polyline( + list(nodes), min_radius_m=RADIUS, simplify=False, curve_flags=[True] * len(nodes) + ) + least = measure(floor, **dict(kw))["over_limit"]["total"] + assert metrics["over_limit"]["total"] <= least + + +def test_follow_unchanged(runs: dict) -> None: + initial = build_planned_polyline(_line(), min_radius_m=RADIUS) + result = runs["results"]["follow"] + assert result["planned"] == [[round(x, 4), round(y, 4)] for x, y in initial.vertices] + assert result["curves"] == [curve.as_dict() for curve in initial.curves] + + +def _bend(radius: float) -> list[tuple[float, float]]: + """직선 100 m → 반지름 `radius` 원호 90° → 직선 100 m 를 2 m 간격 점으로.""" + points = [(float(x), 0.0) for x in np.arange(-100.0, 0.0, 2.0)] + steps = int(radius * math.pi / 2 / 2.0) + points += [ + (radius * math.sin(a), radius - radius * math.cos(a)) + for a in np.linspace(0.0, math.pi / 2, steps) + ] + points += [(radius, radius + float(y)) for y in np.arange(2.0, 102.0, 2.0)] + return points + + +def _window_gap(path, outline, curve) -> float: + """곡선 앞뒤 직선 가운데점 사이 경로 점이 노선에서 가장 먼 거리.""" + line = LineString(path) + drawn = LineString(outline.vertices) + apex = curve.apex + nodes = [(node.x, node.y) for node in outline.nodes] + at = nodes.index(min(nodes, key=lambda n: math.dist(n, apex))) + ends = [ + line.project(Point((apex[0] + n[0]) / 2, (apex[1] + n[1]) / 2)) + for n in (nodes[at - 1], nodes[at + 1]) + ] + return max( + drawn.distance(Point(p)) for p in path if min(ends) <= line.project(Point(p)) <= max(ends) + ) + + +def test_widen_largest_radius_within_gap() -> None: + path = _bend(80.0) + fitted = build_planned_polyline(path, min_radius_m=RADIUS) + wide = widen(fitted, path, RADIUS) + assert len(wide.curves) == 1 + curve = wide.curves[0] + assert curve.radius_m >= fitted.curves[0].radius_m + assert curve.radius_m == math.floor(curve.radius_m) # 1 m 내림 + assert curve.radius_m > 40.0 + assert _window_gap(path, wide, curve) <= ROUTE_RECOMMEND_MAX_GAP_M + 1e-6 + # 1 m 더 키우면 허용 이탈을 넘음(가장 큰 값) — 셈 자(shapely)로 따로 잼 + points, flags, radii = _flat(wide) + seat = flags.index(True) + radii[seat] = curve.radius_m + 1.0 + bigger = build_planned_polyline( + points, min_radius_m=RADIUS, simplify=False, curve_flags=flags, radii=radii + ) + assert _window_gap(path, bigger, bigger.curves[0]) > ROUTE_RECOMMEND_MAX_GAP_M + + +def test_widen_survives_confirm_rebuild() -> None: + """화면이 펴서 보내는 꼴(교각점 · 곡선 켬 · R)로 [확인]이 다시 짜도 같은 R.""" + path = _bend(60.0) + [(60.0 + float(x), 160.0) for x in np.arange(2.0, 120.0, 2.0)] + wide = widen(build_planned_polyline(path, min_radius_m=RADIUS), path, RADIUS) + points, flags, radii = _flat(wide) + again = build_planned_polyline( + points, min_radius_m=RADIUS, simplify=False, curve_flags=flags, radii=radii + ) + assert [c.radius_m for c in again.curves] == [c.radius_m for c in wide.curves] + assert again.vertices == wide.vertices + + +def test_widen_keeps_fitted_when_gap_already_over() -> None: + """허용 이탈 0 이면 키울 R 이 없어 맞춘 R 그대로.""" + path = _bend(80.0) + fitted = build_planned_polyline(path, min_radius_m=RADIUS) + same = widen(fitted, path, RADIUS, max_gap_m=0.0) + assert [round(c.radius_m, 2) for c in same.curves] == [ + round(c.radius_m, 2) for c in fitted.curves + ] From 8e96880880daa243858fd1cb5bc05081b4076cd6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:33:28 +0900 Subject: [PATCH 10/36] =?UTF-8?q?feat(CAD):=20331-1=20=EA=B0=81=EB=8F=84?= =?UTF-8?q?=20=EA=B3=A0=EC=A0=95=20`<=EA=B0=81=EB=8F=84`=20=E2=86=92=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=20=C2=B7=20=EC=8B=9C=ED=80=80=EC=8A=A4=20?= =?UTF-8?q?=EC=B2=AB=20=EC=A0=90=20=EC=88=AB=EC=9E=90=20=EB=AC=B4=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 input-angle-lock — 다음 한 점만 고정 방향(동쪽 0° 반시계) · 커서 · 클릭은 고정 직선 위로 · 숫자는 그 방향 길이 · 한 점 · Esc · 새 명령이면 풂 · 커서 옆 안내 「각도 30° 고정 — 길이 입력」 - input-patterns — 숫자 음수 · .5 허용 · 각도 고정 꼴 · 점 글 해석 한 곳(parsePointText) - Enter 해석 차례 — 자유 글 단계만 원문 먼저 → 각도 고정 → 점 · 거리 → 단계 선택지 → 명령 이름 - 선 · 시퀀스 미리보기도 고정 방향 · 시퀀스 첫 점의 거리 · 상대 좌표는 무시(도구 멈춤 · 「Error in tool actor」 없앰) - 시험 resources/tester/cad/keyboard-chain.test.ts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../openwebcad/src/commands/run-command.ts | 2 + .../src/helpers/get-point-from-event.ts | 10 +- .../src/inputController/input-angle-lock.ts | 31 ++++ .../src/inputController/input-controller.ts | 155 ++++++------------ .../src/inputController/input-patterns.ts | 58 ++++++- .../src/tools/factories/sequence-tool.ts | 10 +- .../openwebcad/src/tools/line-tool.ts | 2 +- resources/tester/cad/keyboard-chain.test.ts | 150 +++++++++++++++++ 8 files changed, 299 insertions(+), 119 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/inputController/input-angle-lock.ts create mode 100644 resources/tester/cad/keyboard-chain.test.ts diff --git a/B07_DesignDetail/openwebcad/src/commands/run-command.ts b/B07_DesignDetail/openwebcad/src/commands/run-command.ts index 3c905bb6b..0e405565e 100644 --- a/B07_DesignDetail/openwebcad/src/commands/run-command.ts +++ b/B07_DesignDetail/openwebcad/src/commands/run-command.ts @@ -2,6 +2,7 @@ import { toast } from 'react-toastify'; import { Actor } from 'xstate'; import { HtmlEvent } from '../App.types'; +import { setAngleLock } from '../inputController/input-angle-lock'; import { getSelectedEntities, isDrawingReadOnly, notifyWindow, setActiveToolActor } from '../state'; import type { CadCommand } from './command.types'; import { getCommandById, isViewOnlyCommand, resolveCommandInput } from './registry'; @@ -38,6 +39,7 @@ export function runCommand(command: CadCommand): string { } lastCommandId = command.id; + setAngleLock(null); // 앞 명령의 각도 고정은 새 명령에 넘기지 않음 if (command.machine) { setActiveToolActor(new Actor(command.machine)); diff --git a/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts b/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts index 76565a4e2..90a6693bf 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/get-point-from-event.ts @@ -1,4 +1,5 @@ import { type Point, Vector } from '@flatten-js/core'; +import { getAngleLock, pointAtLock, projectOnLock } from '../inputController/input-angle-lock.ts'; import { type AbsolutePointInputEvent, ActorEvent, @@ -12,21 +13,26 @@ import { /** * Various tools need to convert user input into a point * This function handles mouse click event, number events and in the future absoluteCoordinates and relativeCoordinate events + * 각도 고정(`<30`) 중이면 커서 · 클릭 · 숫자를 기준점에서 고정 방향으로. * @param startPoint * @param event */ export function getPointFromEvent(startPoint: Point | null, event: PointInputEvent): Point { + const locked = startPoint !== null && getAngleLock() !== null; if (event.type === ActorEvent.DRAW) { - return (event as DrawEvent).drawController.getWorldMouseLocation(); + const mouse = (event as DrawEvent).drawController.getWorldMouseLocation(); + return locked ? projectOnLock(startPoint, mouse) : mouse; } if (event.type === ActorEvent.MOUSE_CLICK) { - return (event as MouseClickEvent).worldMouseLocation; + const click = (event as MouseClickEvent).worldMouseLocation; + return locked ? projectOnLock(startPoint, click) : click; } if (event.type === ActorEvent.NUMBER_INPUT) { if (!startPoint) { throw new Error('Cannot get relative point by distance if no start point is provided'); } const distance = (event as NumberInputEvent).value; + if (locked) return pointAtLock(startPoint, distance); // Direction indicated by the startPoint and the mouse location const direction = new Vector( event.worldMouseLocation.x - startPoint.x, diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-angle-lock.ts b/B07_DesignDetail/openwebcad/src/inputController/input-angle-lock.ts new file mode 100644 index 000000000..bee46aac9 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/input-angle-lock.ts @@ -0,0 +1,31 @@ +/** + * 각도 고정 `<각도` — 오토캐드 각도 재지정. 다음 한 점만 기준점에서 그 방향(동쪽 0° 반시계 +). + * 커서 · 클릭은 고정 방향 직선 위로 내리고 · 숫자는 그 방향 길이. 점 하나 쓰면 · Esc · 새 명령이면 풂. + */ +import { Point } from '@flatten-js/core'; +import { polarToPoint } from './input-patterns.ts'; + +let lockedDegrees: number | null = null; + +export const getAngleLock = (): number | null => lockedDegrees; + +export const setAngleLock = (degrees: number | null) => { + lockedDegrees = degrees; +}; + +/** 커서 옆 안내 줄 */ +export const angleLockHint = (): string | null => + lockedDegrees === null ? null : `각도 ${lockedDegrees}° 고정 — 길이 입력`; + +/** 기준점에서 고정 방향으로 거리만큼 간 점 */ +export function pointAtLock(base: Point, distance: number): Point { + const offset = polarToPoint(distance, lockedDegrees ?? 0); + return new Point(base.x + offset.x, base.y + offset.y); +} + +/** 점을 기준점 · 고정 방향 직선 위로 내림(커서 · 클릭) */ +export function projectOnLock(base: Point, point: Point): Point { + const unit = polarToPoint(1, lockedDegrees ?? 0); + const along = (point.x - base.x) * unit.x + (point.y - base.y) * unit.y; + return new Point(base.x + unit.x * along, base.y + unit.y * along); +} diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index b3a2e3e5b..768d6fec9 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -44,14 +44,7 @@ import { } from '../state.ts'; import { Tool } from '../tools.ts'; import { SelectState, selectToolStateMachine } from '../tools/select-tool.ts'; -import { - type AbsolutePointInputEvent, - ActorEvent, - type MouseClickEvent, - type NumberInputEvent, - type RelativePointInputEvent, - type TextInputEvent, -} from '../tools/tool.types.ts'; +import { ActorEvent, type MouseClickEvent, type TextInputEvent } from '../tools/tool.types.ts'; import { activeToolAcceptsText, activeToolWantsFreeText, @@ -63,14 +56,8 @@ import { drawInputField } from './input-field-draw.ts'; import { ctrlShortcut } from './input-shortcut.ts'; import { wheelZoom } from './input-wheel.ts'; import { attachTouch } from './input-touch.ts'; -import { - ABSOLUTE_POINT_REGEXP, - ABSOLUTE_POLAR_REGEXP, - NUMBER_REGEXP, - polarToPoint, - RELATIVE_POINT_REGEXP, - RELATIVE_POLAR_REGEXP, -} from './input-patterns.ts'; +import { ANGLE_LOCK_REGEXP, parsePointText, type PointTextEvent } from './input-patterns.ts'; +import { angleLockHint, setAngleLock } from './input-angle-lock.ts'; /** 이만큼 넘게 끌면 클릭이 아니라 드래그로 본다 (화면 픽셀) */ const DRAG_SELECT_MIN_PX = 4; @@ -135,6 +122,8 @@ export class InputController { const roundedY = round(drawController.getWorldMouseLocation().y, 2); texts.push(`${roundedX},${roundedY}`); } + const lockHint = angleLockHint(); + if (lockHint) texts.push(lockHint); // 입력 중인 문자로 시작하는 명령 후보. 예: C => CIRCLE (C), COPY (CO) texts.push(...this.getCommandSuggestions()); // 입력 글(조합 중인 한글도 함께) @@ -213,7 +202,7 @@ export class InputController { ); const worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp; - getActiveToolActor()?.send({ + this.sendPointEvent({ type: ActorEvent.MOUSE_CLICK, worldMouseLocation, screenMouseLocation: screenCanvasDrawController.worldToTarget(worldMouseLocation), @@ -448,6 +437,7 @@ export class InputController { } public handleEscapeKey() { + setAngleLock(null); if (getSelectedEntities().length > 0) { // Deselect entities setSelectedEntityIds([]); @@ -495,17 +485,20 @@ export class InputController { if (wasSelectTool && getActiveToolActor()?.getSnapshot()?.status === 'done') { setActiveToolActor(new Actor(selectToolStateMachine)); } - } else if (activeToolCanHandleTextInput) { - console.log('TEXT_INPUT: ', { - text: this.text, - activeTool: getActiveToolActor(), - }); - // Send the text to the active tool - getActiveToolActor()?.send({ - type: ActorEvent.TEXT_INPUT, - value: this.text, - } as TextInputEvent); + } else if (activeToolWantsFreeText()) { + // 글 단계(TEXT · MTEXT · 표 칸 …)는 친 그대로 — 숫자 · 명령 이름도 글 + this.sendText(); + } else if (ANGLE_LOCK_REGEXP.test(this.text)) { + // 각도 고정 `<30` — 다음 한 점만 그 방향 + setAngleLock(Number.parseFloat((ANGLE_LOCK_REGEXP.exec(this.text) as RegExpExecArray)[1])); this.text = ''; + } else if (this.pointEventFromText()) { + // 거리 `100` · `x,y` · `@x,y` · `@d + getSnapPointOnAngleGuide()?.point || + getSnapPoint()?.point || + getScreenCanvasDrawController().getWorldMouseLocation() + ); + } + + /** 점 하나를 도구에 보냄 — 각도 고정은 이 한 점으로 끝 */ + private sendPointEvent(event: PointTextEvent | MouseClickEvent) { + getActiveToolActor()?.send(event); + setAngleLock(null); + } + public handleUndo(evt: KeyboardEvent) { evt.preventDefault(); undo(); diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-patterns.ts b/B07_DesignDetail/openwebcad/src/inputController/input-patterns.ts index d2483f459..0d92a49c9 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-patterns.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-patterns.ts @@ -1,17 +1,65 @@ -/** 명령 입력 해석 — 숫자 · 좌표 · 극좌표 꼴 */ +/** 명령 입력 해석 — 숫자 · 좌표 · 극좌표 · 각도 고정 꼴 */ import { Point } from '@flatten-js/core'; +import { + type AbsolutePointInputEvent, + ActorEvent, + type NumberInputEvent, + type RelativePointInputEvent, +} from '../tools/tool.types.ts'; -export const NUMBER_REGEXP = /^[0-9]+([.][0-9]+)?$/; -/** 부호 있는 실수 한 개 (좌표는 음수가 될 수 있다) */ -const SIGNED = '(-?[0-9]+(?:[.][0-9]+)?)'; +/** 부호 있는 실수 한 개 — `-5` · `.5` · `1.5` */ +const SIGNED = '(-?(?:[0-9]+(?:[.][0-9]+)?|[.][0-9]+))'; +export const NUMBER_REGEXP = new RegExp(`^${SIGNED}$`); export const ABSOLUTE_POINT_REGEXP = new RegExp(`^${SIGNED}[ ]*,[ ]*${SIGNED}$`); export const RELATIVE_POINT_REGEXP = new RegExp(`^@${SIGNED}[ ]*,[ ]*${SIGNED}$`); /** 극좌표 — 거리<각도(도). `@`가 붙으면 직전 점 기준 */ export const ABSOLUTE_POLAR_REGEXP = new RegExp(`^${SIGNED}[ ]*<[ ]*${SIGNED}$`); export const RELATIVE_POLAR_REGEXP = new RegExp(`^@${SIGNED}[ ]*<[ ]*${SIGNED}$`); +/** 각도 고정 — `<30` (다음 한 점만 그 방향) */ +export const ANGLE_LOCK_REGEXP = new RegExp(`^<[ ]*${SIGNED}$`); -/** 거리·각도(도)를 x·y 변위로 바꾼다 */ +/** 거리·각도(도)를 x·y 변위로 바꾼다 — 각도 = 동쪽 0° 반시계 + */ export function polarToPoint(distance: number, degrees: number): Point { const radians = (degrees * Math.PI) / 180; return new Point(distance * Math.cos(radians), distance * Math.sin(radians)); } + +export type PointTextEvent = NumberInputEvent | AbsolutePointInputEvent | RelativePointInputEvent; + +/** 친 글 → 점 · 거리 사건(숫자 · `x,y` · `@x,y` · `@d Point): PointTextEvent | null { + const num = (value: string) => Number.parseFloat(value); + let match = NUMBER_REGEXP.exec(text); + if (match) { + return { type: ActorEvent.NUMBER_INPUT, value: num(match[1]), worldMouseLocation: cursor() }; + } + match = ABSOLUTE_POINT_REGEXP.exec(text); + if (match) { + return { + type: ActorEvent.ABSOLUTE_POINT_INPUT, + value: new Point(num(match[1]), num(match[2])), + }; + } + match = RELATIVE_POINT_REGEXP.exec(text); + if (match) { + return { + type: ActorEvent.RELATIVE_POINT_INPUT, + value: new Point(num(match[1]), num(match[2])), + }; + } + match = RELATIVE_POLAR_REGEXP.exec(text); + if (match) { + return { + type: ActorEvent.RELATIVE_POINT_INPUT, + value: polarToPoint(num(match[1]), num(match[2])), + }; + } + match = ABSOLUTE_POLAR_REGEXP.exec(text); + if (match) { + return { + type: ActorEvent.ABSOLUTE_POINT_INPUT, + value: polarToPoint(num(match[1]), num(match[2])), + }; + } + return null; +} diff --git a/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts b/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts index c4c7859b6..78596b9e8 100644 --- a/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/factories/sequence-tool.ts @@ -176,7 +176,9 @@ export function createSequenceTool(config: SequenceToolConfig) { const drawPreview = ({ context, event }: { context: SequenceContext; event: StateEvent }) => { if (!config.preview) return; - const ghosts = config.preview(makeInput(context.values, cursorPoint(event), context.picks)); + // 각도 고정(`<30`) 중이면 커서를 고정 방향 위로 + const cursor = getPointFromEvent(lastPoint(context.values), event as DrawEvent); + const ghosts = config.preview(makeInput(context.values, cursor, context.picks)); setGhostHelperEntities(ghosts); }; @@ -281,8 +283,10 @@ export function createSequenceTool(config: SequenceToolConfig) { on.DRAW = { actions: drawPreview }; on.MOUSE_CLICK = { actions: pushPoint, target }; on.ABSOLUTE_POINT_INPUT = { actions: pushPoint, target }; - on.RELATIVE_POINT_INPUT = { actions: pushPoint, target }; - on.NUMBER_INPUT = { actions: pushPoint, target }; + // 거리 · 상대 좌표는 기준점(앞 점)이 있을 때만 — 첫 점이면 무시(도구가 멈추지 않게) + const hasBase = ({ context }: { context: SequenceContext }) => !!lastPoint(context.values); + on.RELATIVE_POINT_INPUT = { guard: hasBase, actions: pushPoint, target }; + on.NUMBER_INPUT = { guard: hasBase, actions: pushPoint, target }; if (config.repeatLastStep && index === lastIndex) { on.ENTER = { target: COMMIT_STATE }; } diff --git a/B07_DesignDetail/openwebcad/src/tools/line-tool.ts b/B07_DesignDetail/openwebcad/src/tools/line-tool.ts index 42dc7dd3d..4ea1cb27a 100644 --- a/B07_DesignDetail/openwebcad/src/tools/line-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/line-tool.ts @@ -131,7 +131,7 @@ export const lineToolStateMachine = createMachine( const activeLine = new LineEntity( getActiveLayerId(), context.startPoint as Point, - (event as DrawEvent).drawController.getWorldMouseLocation() + getPointFromEvent(context.startPoint, event as DrawEvent) ); activeLine.lineColor = getActiveLineColor(); activeLine.lineWidth = getActiveLineWidth(); diff --git a/resources/tester/cad/keyboard-chain.test.ts b/resources/tester/cad/keyboard-chain.test.ts new file mode 100644 index 000000000..be66f34d4 --- /dev/null +++ b/resources/tester/cad/keyboard-chain.test.ts @@ -0,0 +1,150 @@ +/** + * CAD 키보드 연속 명령(PLAN 331 · 요구 224-7) — 오토캐드 방식. + * 각도 고정 `<30` → 길이 · 시퀀스 첫 점 숫자 · 이동 · 복사 · 회전 키보드 값 · 선 중 C · U · 한 번 스냅 · 동적 입력 Tab. + * 시험 쪽 flatten-js 는 다른 사본(「Illegal Parameters」) — 점은 앱 코드로만 만들고 숫자로 판정. + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad keyboard-chain + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("react-toastify", () => ({ + toast: Object.assign(vi.fn(), { error: vi.fn(), info: vi.fn(), warn: vi.fn(), success: vi.fn() }), +})); + +import { toast } from "react-toastify"; +import { InputController } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-controller"; +import { getAngleLock } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-angle-lock"; +import { + getActiveToolActor, + getEntities, + setEntities, + setSelectedEntityIds, +} from "../../../B07_DesignDetail/openwebcad/src/state"; +import { runCommandInput } from "../../../B07_DesignDetail/openwebcad/src/commands/run-command"; +import { initApplication } from "../../../B07_DesignDetail/openwebcad/test/helpers/init-application"; +import { CANVAS_HEIGHT } from "../../../B07_DesignDetail/openwebcad/test/helpers/tests.consts"; + +type Xy = { x: number; y: number }; +type Seg = { start: Xy; end: Xy }; + +const key = (controller: InputController, k: string) => + controller.handleKeyStroke({ + key: k, + code: "", + keyCode: 0, + isComposing: false, + shiftKey: false, + ctrlKey: false, + altKey: false, + metaKey: false, + repeat: false, + target: null, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent); + +/** 글자를 치고 Space(= Enter) */ +const type = (controller: InputController, text: string) => { + for (const ch of text) key(controller, ch); + key(controller, " "); +}; + +/** 세계 좌표 (x, y) 로 커서 이동 — 시험 캔버스는 축척 1 · y 위 + */ +const move = (controller: InputController, x: number, y: number) => + controller.handleMouseMove({ clientX: x, clientY: CANVAS_HEIGHT - y } as MouseEvent); + +const clickAt = (controller: InputController, x: number, y: number) => { + move(controller, x, y); + controller.handleMouseUp({ + button: 0, + clientX: x, + clientY: CANVAS_HEIGHT - y, + target: null, + preventDefault: () => {}, + stopPropagation: () => {}, + } as unknown as MouseEvent); +}; + +const lines = () => + getEntities() + .filter((entity) => entity.getType() === "Line") + .map((entity) => (entity as unknown as { segment: Seg }).segment); + +const expectXy = (point: Xy, x: number, y: number) => { + expect(point.x).toBeCloseTo(x, 3); + expect(point.y).toBeCloseTo(y, 3); +}; + +let controller: InputController; + +beforeEach(() => { + vi.mocked(toast.error).mockClear(); + controller = initApplication(); + setEntities([], true); + setSelectedEntityIds([]); +}); + +describe("331-1 각도 고정 → 길이", () => { + it("L · 0,0 · <30 · 100 → (86.603, 50) · <90 · 50 → (86.603, 100)", () => { + type(controller, "l"); + type(controller, "0,0"); + move(controller, 500, 500); // 커서 방향과 무관하게 고정 각도로 + type(controller, "<30"); + expect(getAngleLock()).toBe(30); + type(controller, "100"); + expect(getAngleLock()).toBeNull(); // 한 점만 + type(controller, "<90"); + type(controller, "50"); + const drawn = lines(); + expect(drawn).toHaveLength(2); + expectXy(drawn[0].start, 0, 0); + expectXy(drawn[0].end, 86.603, 50); + expectXy(drawn[1].end, 86.603, 100); + }); + + it("고정 없이 숫자 = 마우스 방향", () => { + type(controller, "l"); + type(controller, "0,0"); + move(controller, 0, 300); + type(controller, "100"); + expectXy(lines()[0].end, 0, 100); + }); + + it("고정 중 클릭 = 고정 방향 위로 내림 · Esc · 새 명령이면 풂", () => { + type(controller, "l"); + type(controller, "0,0"); + type(controller, "<0"); + clickAt(controller, 60, 25); // 가이드에서 먼 자리(스냅 안 붙게) + expectXy(lines()[0].end, 60, 0); + type(controller, "<45"); + key(controller, "Escape"); + expect(getAngleLock()).toBeNull(); + type(controller, "<45"); + runCommandInput("CIRCLE"); + expect(getAngleLock()).toBeNull(); + }); + + it("음수 · .5 숫자 · 음수 각도", () => { + type(controller, "l"); + type(controller, "0,0"); + type(controller, "<-90"); + type(controller, ".5"); + expectXy(lines()[0].end, 0, -0.5); + type(controller, "<0"); + type(controller, "-10"); + expectXy(lines()[1].end, -10, -0.5); + }); + + it("PLINE 첫 점에 숫자 · @x,y → 무시 · 도구 살아 있음 · 알림 0", () => { + type(controller, "pline"); + type(controller, "100"); + type(controller, "@10,0"); + const snapshot = getActiveToolActor()?.getSnapshot(); + expect(snapshot?.status).toBe("active"); + expect(snapshot?.value).toBe("STEP_0"); + expect(toast.error).not.toHaveBeenCalled(); + type(controller, "0,0"); + type(controller, "<0"); + type(controller, "100"); + expect(getActiveToolActor()?.getSnapshot()?.context.values).toHaveLength(2); + }); +}); From e36fc5091d04120dc0e57e070a8a4da01aa321b0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:33:32 +0900 Subject: [PATCH 11/36] =?UTF-8?q?feat(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4):=20329-3=20=EC=97=91=EC=85=80=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=B0=BD=20=E2=80=94=20=EC=9D=BD=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EC=8B=9C=ED=8A=B8(=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=C2=B7=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=ED=83=AD)=20+=20=ED=98=B8=ED=91=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D(=EC=B2=B4=ED=81=AC=20=C2=B7=20=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=A7=9E=EC=B6=A4)=20=C2=B7=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EB=88=84=EB=A6=84=20=E2=86=92=20=EC=98=81=EC=97=AD=20=EA=B0=95?= =?UTF-8?q?=EC=A1=B0=20=C2=B7=20=EC=B9=B8=20=EB=88=84=EB=A6=84=20=E2=86=92?= =?UTF-8?q?=20=EA=B7=B8=20=ED=98=B8=ED=91=9C=20=C2=B7=20=EA=B3=A0=EB=A5=B8?= =?UTF-8?q?=20=ED=98=B8=ED=91=9C=20=ED=95=9C=20=EB=B2=88=EC=97=90=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=EC=A1=B0=ED=95=A9=20+=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=20=ED=91=9C=20=C2=B7=20=EC=97=AC=EB=8A=94=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=20=EB=84=B7(=EC=98=AC=EB=A6=B0=20=EC=A7=81=ED=9B=84=20=C2=B7?= =?UTF-8?q?=20=EC=97=91=EC=85=80=EB=A1=9C=20=EB=B3=B4=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=97=91=EC=85=80=EC=97=90=EC=84=9C=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20=EB=8D=A7?= =?UTF-8?q?=EC=A4=84)=20=C2=B7=20=EB=8A=A6=EA=B2=8C=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=B4?= 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_01XWh5wNNycMkbwMpACz1rte --- .../M02_MasterTemplete_Hopyo.css | 78 +++++ .../M02_MasterTemplete_Hopyo_Api.ts | 21 ++ .../M02_MasterTemplete_Hopyo_Pick.ts | 40 ++- .../M02_MasterTemplete_Hopyo_Sheet.ts | 282 ++++++++++++++++++ .../M02_MasterTemplete_Hopyo_Side.ts | 5 +- .../M02_MasterTemplete_Hopyo_View.ts | 25 +- resources/tester/helper_m02_hopyo_pick.cjs | 18 +- resources/tester/test_m02_hopyo_screen.py | 47 ++- 8 files changed, 508 insertions(+), 8 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 7a00414cc..134403225 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -149,3 +149,81 @@ gap: var(--spacing-4); margin-bottom: var(--spacing-8); } + +/* 엑셀 보기 창(329) — 왼쪽 읽기 전용 시트 · 오른쪽 호표 목록 · 불러오기 결과 */ +.m02-hopyo-sheet__dialog > .ui-modal__body { + overflow: hidden; +} + +.m02-hopyo-sheet { + display: grid; + grid-template-columns: minmax(0, 1fr) 22rem; + gap: var(--spacing-8); + flex: 1; + min-height: 0; +} + +.m02-hopyo-sheet__grid { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.m02-hopyo-sheet__side { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-height: 0; +} + +.m02-hopyo-sheet__list { + flex: 1; + min-height: 0; + overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} + +.m02-hopyo-sheet__item { + display: flex; + align-items: center; + gap: var(--spacing-4); + padding: var(--spacing-4) var(--spacing-8); + border-left: 3px solid transparent; + cursor: pointer; +} + +.m02-hopyo-sheet__item:hover { + background: var(--color-surface); +} + +.m02-hopyo-sheet__item.is-current { + border-left-color: var(--color-accent); + background: var(--color-mist-violet); +} + +.m02-hopyo-sheet__no { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.m02-hopyo-sheet__name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.m02-hopyo-sheet__result { + max-height: 40%; + overflow: auto; +} + +.m02-hopyo-sheet__result:empty { + display: none; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index e11e7dcec..c4ef47641 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -5,6 +5,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"; @@ -34,6 +35,22 @@ export interface HopyoBrief { 금액: Money; /** [들어감 수, 줄 수] */ 맞춤: [number, number]; + /** 일위대가 시트 행 [시작, 끝](엑셀 1부터 · 329 · 옛 파일 없음) */ + 행?: [number, number]; +} + +/** 엑셀 보기 창 목록 한 줄 — 호표 · 단가산출 블록 행 범위 */ +export interface HopyoSpan { + 번: number; + 이름: string; + 행: [number, number]; +} + +/** 엑셀 보기 창 격자(329) — 시트 「일위대가」 · 「단가산출」 */ +export interface HopyoGrid { + 책: Workbook; + 호표: HopyoSpan[]; + 단산: HopyoSpan[]; } export interface HopyoView extends HopyoFile { @@ -136,6 +153,10 @@ export const listHopyo = async (scope: HopyoScope): Promise => export const readHopyo = (scope: HopyoScope, key: string): Promise => call(at(scope, `/hopyo/${enc(key)}`)); +/** 격자 — 없으면 404 「격자 없음 — 다시 올리면 보임」(옛 파일) */ +export const readHopyoGrid = (scope: HopyoScope, key: string): Promise => + call(at(scope, `/hopyo-grid/${enc(key)}`)); + export const readHopyoOne = (scope: HopyoScope, key: string, no: number): Promise => call(at(scope, `/hopyo/${enc(key)}/${no}`)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts index 04cdd4c89..c460ce6a3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts @@ -2,7 +2,8 @@ * M02_MasterTemplete_Hopyo_Pick.ts * [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(맞춤 n/m) · * 고르면 서버가 그 호표를 문서 층 사전조합으로 만들고(`/hopyo-build` · 있던 것은 다시 씀) `lib.pick` 으로 - * 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림. 프로젝트 층엔 없음. + * 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림 · 파일마다 「엑셀로 보기 · 여러 호표」 줄(329 엑셀 보기 창 · + * 고른 호표마다 참조 줄). 프로젝트 층엔 없음. * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; @@ -25,7 +26,42 @@ export function withHopyo(lib: LibraryPick): LibraryPick { { value: "precombo", text: tc("Kind_Combo") }, { value: HOPYO_KIND, text: "실무 호표" }, ], - more: async () => (await listHopyoPicks(scope)).map((row) => hopyoExtra(row, lib)), + more: async () => { + const rows = await listHopyoPicks(scope); + const files = new Map(); + for (const row of rows) { + const key = `${row.층}|${row.주인 ?? ""}|${row.키}`; + files.set(key, [...(files.get(key) ?? []), row]); + } + return [ + ...rows.map((row) => hopyoExtra(row, lib)), + ...[...files].map(([key, list]) => sheetExtra(key, list[0], list.length, lib)), + ]; + }, + }; +} + +/** 파일 하나 — 엑셀 보기 창에서 여러 호표를 골라 한 번에(만드는 층 = 문서 층) */ +export function sheetExtra(key: string, row: HopyoPickRow, n: number, lib: LibraryPick): PickExtra { + return { + kind: HOPYO_KIND, + key: `${key}|엑셀`, + tag: "실무 호표", + name: "엑셀로 보기 · 여러 호표", + sub: `${row.파일} · ${LAYER_TEXT[row.층] ?? row.층}`, + unit: `호표 ${n}`, + onPick: () => + void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => + m.openHopyoSheet({ + scope: { layer: row.층, owner: row.주인 }, + key: row.키, + 파일: row.파일, + canWrite: true, + target: { layer: lib.layer, owner: lib.owner }, + onImport: (got, h) => + lib.pick(got.참조, got.이름, h.이름.slice(0, ALIAS_MAX).trim() || null), + }), + ), }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts new file mode 100644 index 000000000..e547de48e --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -0,0 +1,282 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_Sheet.ts + * 일위대가 실무자료 엑셀 보기 창(PLAN 329) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 + * 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 맞춤 n/m) + [고른 n개 불러오기]. + * · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름 + * · 불러오기 = 고른 호표마다 차례로 사전조합 만들기(`/hopyo-build`) → 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · + * `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄) + * · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 불러오기는 됨 + * ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게). + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types"; +import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; +import { + buildHopyo, + readHopyo, + readHopyoGrid, + type HopyoBrief, + type HopyoBuilt, + type HopyoGrid, + type HopyoScope, + type HopyoSpan, +} from "./M02_MasterTemplete_Hopyo_Api"; +import { builtWord } from "./M02_MasterTemplete_Hopyo_View"; +import "./M02_MasterTemplete_Hopyo.css"; + +export interface HopyoSheetOptions { + /** 실무자료 파일 층 */ + scope: HopyoScope; + key: string; + 파일: string; + /** 만들 층 쓰기 권한 — 없으면 체크 · 불러오기 없음(보기만) */ + canWrite: boolean; + /** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */ + target?: HopyoScope; + /** 처음 고를 호표 번(호표 화면 [엑셀에서 보기]) */ + 호표?: number; + /** 처음 보일 단가산출 행(330-3 근거 [엑셀에서 보기]) */ + 단산행?: number | [number, number]; + /** 호표 하나를 만들었음 — [로직 추가] 참조 줄 */ + onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void; +} + +const ILWI = "일위대가"; +const DANSAN = "단가산출"; + +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const won = (v: number | null | undefined): string => + v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); +const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text }); + +/** 엑셀 행(1부터)이 든 호표 번 · 없으면 null */ +export const hopyoAtRow = (list: HopyoSpan[], row: number): number | null => + list.find((h) => h.행[0] <= row && row <= h.행[1])?.번 ?? null; + +/** 호표 영역 A1 — B열 ~ 시트 끝 열(북부청 = `B20:O24`) */ +export const spanText = (행: [number, number], last: string): string => `B${행[0]}:${last}${행[1]}`; + +/** 시트 끝 열 글자 — 칸 · 열 정보 중 가장 오른쪽 */ +export function lastColumn(sheet: Pick): string { + let best = "A"; + for (const k of [...Object.keys(sheet.칸), ...Object.keys(sheet.열 ?? {})]) { + const c = /^[A-Z]+/.exec(k)?.[0]; + if (c && (c.length > best.length || (c.length === best.length && c > best))) best = c; + } + return best; +} + +/** 단가산출 행 → 행 전체 범위 A1(`4:9`) */ +export const rowsText = (행: number | [number, number]): string => + typeof 행 === "number" ? `${행}:${행}` : `${행[0]}:${행[1]}`; + +export function openHopyoSheet(opt: HopyoSheetOptions): void { + let sheet: SpreadsheetHandle | null = null; + let closed = false; + let briefs: HopyoBrief[] = []; + let spans: HopyoSpan[] = []; + let ilwi = ""; // 일위대가 시트 id + let last = "O"; + let at = { 시트: "", 칸: "", 범위: "" }; // 시트 고름 + let current: number | null = opt.호표 ?? null; + const checked = new Set(); + const items = new Map(); + + const left = el("div", { + className: "m02-hopyo-sheet__grid", + children: [note("엑셀을 읽는 중…")], + }); + const search = el("input", { + className: "ui-input ui-input--sm", + attrs: { type: "search", placeholder: "호표 · 품명 · 규격 찾기" }, + }) as HTMLInputElement; + const list = el("div", { className: "m02-hopyo-sheet__list" }); + const count = note(""); + const go = createButton({ label: "고른 0개 불러오기", variant: "filled", size: "sm" }); + go.disabled = true; + const result = el("div", { className: "m02-hopyo-sheet__result" }); + const right = el("div", { + className: "m02-hopyo-sheet__side", + children: [ + search, + el("div", { className: "m02-hopyo__bar", children: [count, ...(opt.canWrite ? [go] : [])] }), + list, + result, + ], + }); + + openModal({ + title: `엑셀 보기 — ${opt.파일}`, + closeLabel: "닫기", + wide: true, + dialogClass: "m02-modal m02-hopyo-sheet__dialog", + beforeClose: async () => { + closed = true; + sheet?.destroy(); + return true; + }, + mount: (body) => + body.append(el("div", { className: "m02-hopyo-sheet", children: [left, right] })), + }); + + const paintGo = (): void => { + go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 불러오기`; + go.disabled = !checked.size; + }; + + /** 목록 강조 · 보이게(시트 고름은 그대로) */ + function mark(no: number | null): void { + items.get(current ?? NaN)?.classList.remove("is-current"); + current = no; + const row = items.get(no ?? NaN); + row?.classList.add("is-current"); + row?.scrollIntoView({ block: "nearest" }); + } + + /** 호표 하나 강조 — 목록 · 시트 영역(같은 범위면 다시 안 부름) */ + function focus(no: number): void { + mark(no); + const span = spans.find((h) => h.번 === no)?.행 ?? briefs.find((h) => h.번 === no)?.행; + if (!sheet || !span) return; + const text = spanText(span, last); + if (at.시트 !== ilwi || at.범위 !== text) sheet.select(text, ilwi); + } + + function paintList(): void { + const q = search.value.trim().toLowerCase(); + items.clear(); + const shown = briefs.filter( + (h) => !q || `제${h.번}호표 ${h.이름} ${h.규격}`.toLowerCase().includes(q), + ); + list.replaceChildren( + ...shown.map((h) => { + const box = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement; + box.checked = checked.has(h.번); + box.hidden = !opt.canWrite; + box.addEventListener("click", (e) => e.stopPropagation()); + box.addEventListener("change", () => { + if (box.checked) checked.add(h.번); + else checked.delete(h.번); + paintGo(); + }); + const row = el("div", { + className: `m02-hopyo-sheet__item${h.번 === current ? " is-current" : ""}`, + attrs: { "data-hopyo-no": String(h.번), title: `${h.이름} ${h.규격}`.trim() }, + children: [ + box, + el("span", { className: "m02-hopyo-sheet__no", text: `제${h.번}호표` }), + el("span", { className: "m02-hopyo-sheet__name", text: `${h.이름} ${h.규격}`.trim() }), + note(`${h.맞춤[0]}/${h.맞춤[1]}`), + ], + }); + row.addEventListener("click", () => focus(h.번)); + items.set(h.번, row); + return row; + }), + ); + count.textContent = q ? `${shown.length} / ${briefs.length}` : `호표 ${briefs.length}`; + } + search.addEventListener("input", paintList); + + async function mountSheet(grid: HopyoGrid): Promise { + const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet"); + if (closed) return; + const book = structuredClone(grid.책); + const first = book.시트.find((s) => s.이름 === ILWI) ?? book.시트[0]; + if (!first) return void left.replaceChildren(note("시트 없음")); + ilwi = first.id; + last = lastColumn(first); + book.활성 = first.id; + left.replaceChildren(); + sheet = createSpreadsheet(left, book, { + readOnly: true, + onSelect: (s) => { + at = s; + if (s.시트 !== ilwi) return; + const no = hopyoAtRow(spans, Number(/\d+/.exec(s.칸)?.[0])); + if (no !== null && no !== current) mark(no); + }, + }); + // 칸을 눌러 뗌 → 그 호표 영역 고름(끌기 · 방향키 도중엔 안 바꿈) + left.querySelector(".ss-grid-host")?.addEventListener("mouseup", (e) => { + if ((e as MouseEvent).button !== 0 || at.시트 !== ilwi) return; + const no = hopyoAtRow(spans, Number(/\d+/.exec(at.칸)?.[0])); + if (no !== null) focus(no); + }); + } + + async function load(): Promise { + try { + const [file, grid] = await Promise.all([ + readHopyo(opt.scope, opt.key), + readHopyoGrid(opt.scope, opt.key).catch((error: unknown) => new Error(why(error))), + ]); + if (closed) return; + briefs = file.호표; + spans = grid instanceof Error ? [] : grid.호표; + paintList(); + if (grid instanceof Error) return void left.replaceChildren(note(grid.message)); + await mountSheet(grid); + if (opt.단산행 !== undefined) sheet?.select(rowsText(opt.단산행), DANSAN); + else if (current !== null) focus(current); + } catch (error) { + if (!closed) left.replaceChildren(note(`못 읽음 — ${why(error)}`)); + } + } + + async function importPicked(): Promise { + const nos = [...checked].sort((a, b) => a - b); + const target = opt.target ?? opt.scope; + const rows: HTMLElement[] = []; + let ok = 0; + go.disabled = true; + for (const no of nos) { + const h = briefs.find((x) => x.번 === no); + if (!h || closed) continue; + const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() }); + try { + const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no }; + const got = await buildHopyo(target, from); + ok += 1; + opt.onImport?.(got, h); + rows.push( + el("tr", { + attrs: { "data-built": got.참조.키 }, + children: [ + head, + el("td", { text: `${got.참조.키} · ${builtWord(got, false)}` }), + el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }), + el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }), + ], + }), + ); + } catch (error) { + rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] })); + } + result.replaceChildren( + el("table", { + className: "ui-general-block__table m02-hopyo__table", + children: [ + el("thead", { + children: [ + el("tr", { + children: ["호표", "사전조합", "빠진 줄", "값 차이"].map((t) => + el("th", { text: t }), + ), + }), + ], + }), + el("tbody", { children: rows }), + ], + }), + ); + } + paintGo(); + showToast(`불러옴 ${ok} / ${nos.length}`, ok === nos.length ? "success" : "warning"); + } + go.addEventListener("click", () => void importPicked()); + + void load(); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts index 74ce15703..3bd583efb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -2,7 +2,8 @@ * M02_MasterTemplete_Hopyo_Side.ts * 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) · * [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면 - * (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만. + * (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만 · + * 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶. * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; @@ -66,6 +67,8 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { showToast(`올림 — 호표 ${got.호표수}${wrong}`, got.어긋남 ? "warning" : "success"); await refresh(); await show(got); + const sheet = await import("./M02_MasterTemplete_Hopyo_Sheet"); + sheet.openHopyoSheet({ scope, key: got.키, 파일: got.파일, canWrite: opt.canWrite }); }) .catch((error) => showToast(why(error), "error")) .finally(() => (upload.disabled = !opt.canWrite)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts index 5601885cf..3aa5fe2c8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts @@ -2,7 +2,8 @@ * M02_MasterTemplete_Hopyo_View.ts * 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 · * [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 · - * [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기]. + * [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기] · + * [엑셀로 보기] · [엑셀에서 보기](329 엑셀 보기 창 · 늦게 불러옴 · 호표 화면은 그 호표 고른 채). * 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만. * ========================================================================== */ @@ -25,6 +26,7 @@ import { type HopyoTarget, type HopyoView, } from "./M02_MasterTemplete_Hopyo_Api"; +import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { pickMaterial } from "./M02_Material_Pick"; import "./M02_MasterTemplete_Hopyo.css"; @@ -52,6 +54,9 @@ const why = (error: unknown): string => (error instanceof Error ? error.message const won = (v: number | null | undefined): string => v === null || v === undefined ? "" : v.toLocaleString("ko-KR"); const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error"); +/** 엑셀 보기 창(329) — 열 때 불러옴 */ +const openSheet = (o: HopyoSheetOptions): void => + void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail); const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement => el("div", { @@ -176,6 +181,12 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement note(`호표 ${file.호표수}`), note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"), note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`), + createButton({ + label: "엑셀로 보기", + size: "sm", + onClick: () => + openSheet({ scope: opt.scope, key, 파일: file.파일, canWrite: opt.canWrite }), + }), ...(opt.canWrite ? [dropButton(file)] : []), ], }), @@ -240,6 +251,18 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement note(h.단위), note(`엑셀 단가 ${won(h.금액.계)}`), note(`맞춤 ${h.맞춤[0]} / ${h.맞춤[1]}`), + createButton({ + label: "엑셀에서 보기", + size: "sm", + onClick: () => + openSheet({ + scope: opt.scope, + key, + 파일: got.파일, + canWrite: opt.canWrite, + 호표: no, + }), + }), ...tools, ], }), diff --git a/resources/tester/helper_m02_hopyo_pick.cjs b/resources/tester/helper_m02_hopyo_pick.cjs index a061c282c..1a389f69f 100644 --- a/resources/tester/helper_m02_hopyo_pick.cjs +++ b/resources/tester/helper_m02_hopyo_pick.cjs @@ -1,6 +1,7 @@ /* M02 일위대가 실무자료(324-4) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) · - * `M02_MasterTemplete_Hopyo_View.ts` 글 도우미를 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 · - * stdin = {rows, built} · stdout = {calls, kinds, rows, picked, project, view} JSON. */ + * `M02_MasterTemplete_Hopyo_View.ts` 글 도우미 · `M02_MasterTemplete_Hopyo_Sheet.ts` 엑셀 보기 창 도우미(329 · 행 → 호표)를 + * 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 · + * stdin = {rows, built} · stdout = {calls, kinds, rows, picked, project, view, sheet} JSON. */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); @@ -55,6 +56,13 @@ function loadTs(file) { await pick.pickHopyo(input.rows[0], lib); const project = pick.withHopyo({ ...lib, layer: "project" }); const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts")); + const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts")); + const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts")); + await api.readHopyoGrid({ layer: "company", owner: "7" }, "HP-000001"); + const spans = [ + { 번: 3, 이름: "임도표지석", 행: [20, 24] }, + { 번: 4, 이름: "배수관", 행: [25, 30] }, + ]; const out = { calls, kinds: wrapped.kindFilter.map((k) => k.value), @@ -80,6 +88,12 @@ function loadTs(file) { view.builtWord({ 다시씀: true }, true), ], }, + sheet: { + at: [19, 20, 22, 24, 25, 30, 31].map((row) => sheet.hopyoAtRow(spans, row)), + span: sheet.spanText([20, 24], sheet.lastColumn({ 칸: { A1: {}, N24: {} }, 열: { O: {} } })), + last: [sheet.lastColumn({ 칸: { Z9: {}, AA1: {} } }), sheet.lastColumn({ 칸: {} })], + rows: [sheet.rowsText(4345), sheet.rowsText([93, 101])], + }, }; process.stdout.write(JSON.stringify(out)); })().catch((error) => { diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 46345114b..804e09399 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -3,6 +3,7 @@ - 고르기 창 덧줄(Node · fetch 대역): 이 층 · 위 층 호표 줄(맞춤 n/m) · 종류 고르기 · 고르면 문서 층에 만들기 길 → `lib.pick` 참조 줄(별칭 = 호표 품명) · 프로젝트 층엔 없음 · 본 화면 글 도우미 +- 엑셀 보기 창(329): 행 → 호표 · 호표 영역 A1 · 격자 길 · 여는 자리 넷 · 늦게 불러옴 - 소스: 좌측 판 자리(사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 · 숨은 file 입력) · Labor_Detail 줄 수 그대로 · 새 파일 이름 · 700 줄 · 색 = 공용 토큰 """ @@ -15,7 +16,7 @@ from pathlib import Path HERE = os.path.dirname(os.path.abspath(__file__)) M02 = Path(HERE).parents[1] / "M02_MasterTemplete" -NEW = ("Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", "Hopyo_View") +NEW = ("Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", "Hopyo_View", "Hopyo_Sheet") def _text(name: str) -> str: @@ -68,7 +69,15 @@ def test_고르기_창_실무_호표_줄과_고름(): "name": "임도표지석 910×600×150", "sub": "북부청.xls · 제3호표 · 마스터", "unit": "개소 · 맞춤 2/4", - } + }, + { + "kind": "hopyo", + "key": "system||HP-000001|엑셀", + "tag": "실무 호표", + "name": "엑셀로 보기 · 여러 호표", + "sub": "북부청.xls · 마스터", + "unit": "호표 1", + }, ] build = got["calls"][1] assert build["url"] == "/api/m02/layers/company/hopyo-build?owner=7" # 만드는 층 = 문서 층 @@ -145,3 +154,37 @@ def test_로직_추가_한_곳과_파일_규칙(): css = _text("M02_MasterTemplete_Hopyo.css") assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css) assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_View.ts") + + +def test_엑셀_보기_창_행과_호표(): + got = _run() + assert got["calls"][2] == { + "url": "/api/m02/layers/company/hopyo-grid/HP-000001?owner=7", + "method": "GET", + "body": None, + } + sheet = got["sheet"] + assert sheet["at"] == [None, 3, 3, 3, 4, 4, None] # 22행 → 제3호표 · 범위 밖 = 없음 + assert sheet["span"] == "B20:O24" # 제3호표 영역 = B열 ~ 끝 열(열 폭 정보 O) + assert sheet["last"] == ["AA", "A"] + assert sheet["rows"] == ["4345:4345", "93:101"] # 단가산출 행 전체 + + +def test_엑셀_보기_창_여는_자리와_늦게_불러옴(): + lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")' + side = _text("M02_MasterTemplete_Hopyo_Side.ts") + assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동 + view = _text("M02_MasterTemplete_Hopyo_View.ts") + assert lazy in view and '"엑셀로 보기"' in view and '"엑셀에서 보기"' in view + assert "호표: no" in view # 호표 화면 = 그 호표 고른 채 + pick = _text("M02_MasterTemplete_Hopyo_Pick.ts") + assert lazy in pick and "lib.pick(got.참조" in pick + static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"' + for text in (side, view, pick): + assert not re.search(static, text, re.M) # 정적 import 없음(타입만) + sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts") + assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet + assert not re.search( + r'^import \{[^}]*\} from "\.\./A00_Common/spreadsheet/spreadsheet"', sheet, re.M + ) + assert "readOnly: true" in sheet and "wide: true" in sheet From f127c0179e75983629b94f179a08a288458a4a53 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:34:58 +0900 Subject: [PATCH 12/36] =?UTF-8?q?feat(=EB=85=B8=EC=84=A0=EC=B6=94=EC=B2=9C?= =?UTF-8?q?):=20327-1=20=EC=B6=94=EC=B2=9C=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=98=81=EA=B5=AC=20=EC=A0=80=EC=9E=A5=20=E2=80=94=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EC=A7=80=EB=AC=B8=20=ED=82=A4=20=C2=B7=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EC=9E=85=EB=A0=A5=EC=9D=B4=EB=A9=B4=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EB=B3=B8=20=C2=B7=20=EB=B0=94=EB=80=90=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EB=A7=8C=20=EC=85=88=ED=95=B4=20=EB=8D=94=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 B05_Profile_Engine_RouteInitial_Store(ALGO_VERSION · request_fingerprint · 읽기 · 쓰기 · 묶음 잠금) · 자리 B05_Profile/route_recommend/<키>.json · 덮어쓰기 없음 · 상한 없음 - 엔드포인트 몸통 compute_initial(찾기 → 셈 · 더함 · 덮개 수 꺼낼 때 다시 셈 · stored 표시) · precompute_defaults(기본 다섯 알약) - resolve_inputs 로 기본값 풀이 한 곳 · 머리글 「파일 안 씀」 정정 - 시험 test_224_3_recommend_store Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- .../B05_Profile_Engine_RouteInitial.py | 26 +- ...B05_Profile_Engine_RouteInitial_Profile.py | 5 +- .../B05_Profile_Engine_RouteInitial_Store.py | 128 ++++++++++ B05_Profile/B05_Profile_Router_Replan.py | 2 +- .../B05_Profile_Router_RouteInitial.py | 110 +++++++-- .../tester/test_224_3_recommend_store.py | 231 ++++++++++++++++++ 6 files changed, 470 insertions(+), 32 deletions(-) create mode 100644 B05_Profile/B05_Profile_Engine_RouteInitial_Store.py create mode 100644 resources/tester/test_224_3_recommend_store.py diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index ba7278c84..13a1e0fd5 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -3,8 +3,9 @@ 예상노선 추종 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소 · 비중. -계산 자리 = 서버 단독(③). 결과는 화면 캐시용 — 이 모듈은 파일을 쓰지 않는다. 적용은 화면이 -받은 노드로 기존 `POST /route/replan`([확인])을 부른다. +계산 자리 = 서버 단독(③). 이 모듈은 파일을 쓰지 않는다 — 결과는 라우터가 입력 지문 키로 +`B05_Profile/route_recommend/` 에 남긴다(`..._Store` · PLAN 327). 적용은 화면이 받은 노드로 +기존 `POST /route/replan`([확인])을 부른다. 갈래(`mode`): · follow — 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 선. @@ -182,6 +183,20 @@ def write_initial_choice( return choice +def resolve_inputs( + max_grade: float, + stream_offset_m: float | None, + grade_smooth_m: float | None, + target_grade: float | None, +) -> tuple[float, float, float]: + """(계류 이격, 기울기 잴 지반 폭, 영선 목표 기울기) — 비운 값은 기본값 · 저장 키와 같은 풀이.""" + stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m) + grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m) + # 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만) + goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade) + return stream_offset, grade_smooth, goal_grade + + def resolve_weights(mode: str, weights: dict[str, float] | None) -> dict[str, float]: """갈래의 비중(합 100). weighted 가 아니면 갈래가 정한 값.""" if mode != "weighted": @@ -240,10 +255,9 @@ def generate_initial_route( if len(expected) < 2: raise ValueError("예상노선 정점이 2개 미만입니다.") used = resolve_weights(mode, weights) - stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m) - grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m) - # 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만) - goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade) + stream_offset, grade_smooth, goal_grade = resolve_inputs( + max_grade, stream_offset_m, grade_smooth_m, target_grade + ) def measure( outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py index 13ec86a8e..de8e9fc8f 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Profile.py @@ -1,8 +1,9 @@ """노선 추천 후보의 균형 종단(PLAN 63-4 · 63-2 안 A). 후보 노선 하나에 종단을 세워 절 · 성토를 **횡단 ㎥** 로 맞춘 계획선과 그 토량을 낸다. -편집 창 [노선 추천]의 점수 · 미리 보기에만 쓴다 — 파일 · 캐시 · initial_snapshot · 확정 종단을 -바꾸지 않는다. 확정 체인의 종단 규칙(1차 계획고 = 지반고 · 2026-09-02 사용자 확정)은 그대로다. +편집 창 [노선 추천]의 점수 · 미리 보기에만 쓴다 — 작업본 파일 · initial_snapshot · 확정 종단을 +바꾸지 않는다(추천 결과째 저장본에 남는 것은 라우터 몫 · PLAN 327). 확정 체인의 종단 규칙 +(1차 계획고 = 지반고 · 2026-09-02 사용자 확정)은 그대로다. 계산 자리 = 서버 단독(③) — 화면은 받은 측점 · 지반고 · 계획고를 그리기만 한다. diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py new file mode 100644 index 000000000..5f4cd944b --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py @@ -0,0 +1,128 @@ +"""계획노선 추천 결과 영구 저장(PLAN 327 · 요구 224-3). + +같은 입력이면 같은 결과 — 셈한 추천을 입력 지문 키로 프로젝트 폴더에 남기고, 같은 입력이 오면 +셈하지 않고 꺼낸다. 바뀐 입력만 새로 셈해 더한다(덮어쓰기 없음 · 상한 없음). + +층 = 서버 계산 캐시(데이터 3층 밖 · 사용자 편집값 아님 — 자동저장 금지와 무관). 자리 +`B05_Profile/route_recommend/<키>.json` — initial_snapshot 대상(`B05_Profile/route`) 밖이라 +[초기화]에 안 휩쓸리고, 자료 교체(`purge_output_folders`) · 프로젝트 삭제 때 폴더째 지워진다. + +키 = 후보 묶음 지문(`_pool_key` — 지형 · 예상노선 · 계류 · 상한 · 최소 R · 이격 · 고름 · 목표 +기울기) + 갈래 · 비중 · 종단 반영 · 설계속도 · `ALGO_VERSION`. 관 지점은 뺀다 — 덮개 수는 꺼낼 때 +다시 센다. 추천 결과를 바꾸는 코드 고침은 `ALGO_VERSION` 을 올린다(옛 파일은 다른 키라 안 쓰임). +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from contextlib import asynccontextmanager +from datetime import datetime +from pathlib import Path +from typing import Any, AsyncIterator + +from B05_Profile.B05_Profile_Engine_RouteInitial import ( + DEFAULT_WEIGHTS, + Terrain, + _pool_key, + resolve_inputs, + resolve_weights, +) +from common_util.common_util_json import atomic_write_json + +ALGO_VERSION = 1 +STORE_DIR = "B05_Profile/route_recommend" +# 업로드 체인 끝에 미리 셈할 기본 다섯 알약(화면 차례) · 추적은 묶음이 선 뒤라 `similar` 가 채워짐 +DEFAULT_MODES = ("gradeline", "earthwork", "gentle", "weighted", "follow") + +# 묶음 지문마다 잠금 — 진행 중 같은 묶음(같은 키 포함)은 기다렸다 꺼냄(두 번 셈 막음) +_LOCKS: dict[str, asyncio.Lock] = {} + + +def store_dir(project_root: Path) -> Path: + return Path(project_root) / STORE_DIR + + +def request_fingerprint( + expected: list[tuple[float, float]], + terrain: Terrain, + streams: list[list[tuple[float, float]]], + *, + mode: str, + weights: dict[str, float] | None, + max_grade: float, + min_radius_m: float, + stream_offset_m: float | None, + grade_smooth_m: float | None, + target_grade: float | None, + with_profile: bool, + design_speed: int, +) -> tuple[str, str]: + """(저장 키, 묶음 지문) — `generate_initial_route` 와 같은 기본값 풀이로 같은 입력 = 같은 키.""" + pool = _pool_key( + expected, + terrain, + streams, + max_grade, + min_radius_m, + *resolve_inputs(max_grade, stream_offset_m, grade_smooth_m, target_grade), + ).hex() + used = resolve_weights(mode, weights) + tail = (mode, sorted(used.items()), bool(with_profile), int(design_speed), ALGO_VERSION) + key = hashlib.blake2b((pool + repr(tail)).encode(), digest_size=16).hexdigest() + return key, pool + + +def is_default( + mode: str, weights: dict[str, float] | None, criteria: dict[str, Any] | None, terrain: Any +) -> bool: + """기본값 결과 — 빈 제약 · 50/25/25 · 설계 지형. 표시일 뿐 지우는 데 쓰지 않음.""" + empty = not any(value is not None for value in (criteria or {}).values()) + plain = mode != "weighted" or resolve_weights(mode, weights) == DEFAULT_WEIGHTS + return empty and plain and terrain is None + + +def read_stored(project_root: Path, key: str) -> dict[str, Any] | None: + """저장본 한 벌 — 없거나 못 읽거나 판이 다르면 None.""" + try: + data = json.loads((store_dir(project_root) / f"{key}.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(data, dict) or data.get("version") != ALGO_VERSION: + return None + return data + + +def write_stored( + project_root: Path, + key: str, + pool: str, + request: dict[str, Any], + result: dict[str, Any], + default: bool, +) -> bool: + """새 키만 적는다 — 이미 있으면 그대로 두고 False(덮어쓰기 없음).""" + path = store_dir(project_root) / f"{key}.json" + if path.exists(): + return False + atomic_write_json( + path, + { + "version": ALGO_VERSION, + "key": key, + "pool": pool, + "default": default, + "created_at": datetime.now().isoformat(timespec="seconds"), + "request": request, + "result": result, + }, + ) + return True + + +@asynccontextmanager +async def pool_lock(pool: str) -> AsyncIterator[None]: + # ponytail: 잠금은 지우지 않음 — 입력 묶음 수만큼(작음) · 다중 프로세스면 파일 잠금으로 + async with _LOCKS.setdefault(pool, asyncio.Lock()): + yield diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 10e2f4559..e3ae05f53 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -26,7 +26,7 @@ GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계) POST /api/projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산 POST /api/projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산 - POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(파일 안 씀) + POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(추천 저장본만 씀) """ import asyncio diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 88e90e6b1..5bb6f494e 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -30,14 +30,35 @@ async def initial_route( ) -> dict[str, Any] | JSONResponse: """초기 계획노선 갈래 하나를 만들어 비교값과 함께 돌려준다(PLAN 20장). - **파일을 쓰지 않는다** — 화면 캐시용. 적용은 받은 `nodes` 로 `POST /route/replan`([확인]). + 작업본은 쓰지 않는다 — 추천 저장본(`..._Store` · PLAN 327)만. 적용은 받은 `nodes` 로 + `POST /route/replan`([확인]). + """ + return await compute_initial(project_id, request) + + +async def compute_initial( + project_id: UUID, request: replan.RouteInitialRequest +) -> dict[str, Any] | JSONResponse: + """같은 입력이면 저장본을 꺼내고 없으면 셈해 더한다(PLAN 327) · 덮개 수는 늘 지금 관으로. + + 응답 `stored` = 저장본에서 꺼냈나. 진행 중인 같은 묶음은 기다렸다 꺼낸다(두 번 셈 막음). """ from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct from B05_Profile.B05_Profile_Engine_RouteInitial import ( generate_initial_route, load_terrain_and_streams, ) - from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import project_pipes + from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import ( + cover_shortfall, + project_pipes, + ) + from B05_Profile.B05_Profile_Engine_RouteInitial_Store import ( + is_default, + pool_lock, + read_stored, + request_fingerprint, + write_stored, + ) from common_util.common_util_surface_confirmation import ( analysis_terrain_params, get_surface_confirmation_params, @@ -81,37 +102,62 @@ async def initial_route( async with pool.acquire() as connection: selection = await get_surface_confirmation_params(connection, str(project_id)) selection = analysis_terrain_params(selection, request.terrain) + inputs = { + "mode": request.mode, + "weights": weights, + "max_grade": max_grade, + "min_radius_m": radius_m, + "stream_offset_m": stream_offset_m, + "grade_smooth_m": grade_smooth_m, + "target_grade": None if target_pct is None else target_pct / 100.0, + "design_speed": design_speed or 20, + "with_profile": request.with_profile, + } started = time.perf_counter() try: terrain, streams = await asyncio.to_thread( load_terrain_and_streams, project_root, selection ) - pipes = ( - await asyncio.to_thread(project_pipes, project_root) if request.with_profile else None - ) - result = await asyncio.to_thread( - functools.partial( - generate_initial_route, - [(x, y) for x, y in expected], - terrain, - streams, - mode=request.mode, - weights=weights, - max_grade=max_grade, - min_radius_m=radius_m, - stream_offset_m=stream_offset_m, - grade_smooth_m=grade_smooth_m, - target_grade=None if target_pct is None else target_pct / 100.0, - design_speed=design_speed or 20, - pipes=pipes, - with_profile=request.with_profile, - ) + route = [(x, y) for x, y in expected] + key, pool_key = await asyncio.to_thread( + functools.partial(request_fingerprint, route, terrain, streams, **inputs) ) + async with pool_lock(pool_key): + stored = await asyncio.to_thread(read_stored, project_root, key) + if stored is not None: + result = stored["result"] + else: + # 관 지점은 키 밖 — 덮개 수 없이 셈해 남기고 아래에서 지금 관으로 센다. + result = await asyncio.to_thread( + functools.partial( + generate_initial_route, route, terrain, streams, pipes=None, **inputs + ) + ) + await asyncio.to_thread( + write_stored, + project_root, + key, + pool_key, + request.model_dump(), + result, + is_default( + request.mode, + weights, + criteria.model_dump() if criteria else None, + request.terrain, + ), + ) + profile = result["metrics"].get("profile") + if request.with_profile and profile and "error" not in profile: + pipes = await asyncio.to_thread(project_pipes, project_root) + if pipes: + profile.update(cover_shortfall(profile, result["planned"], pipes)) except (FileNotFoundError, ValueError) as error: return JSONResponse(status_code=409, content={"status": "error", "message": str(error)}) logger.info( - "초기 계획노선 %s: project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm", + "초기 계획노선 %s(%s): project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm", request.mode, + "저장본" if stored is not None else "셈", project_id, time.perf_counter() - started, result["metrics"]["length_m"], @@ -122,10 +168,28 @@ async def initial_route( "status": "success", "project_id": str(project_id), **result, + "stored": stored is not None, "current_initial": current, } +async def precompute_defaults(project_id: UUID) -> None: + """업로드 체인 끝 예약(PLAN 327-2) — 기본 다섯 알약을 미리 셈해 남김 · 있으면 꺼내기만.""" + from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES + + started = time.perf_counter() + for mode in DEFAULT_MODES: + # 화면 기본과 같은 몸통 — 빈 제약 · 50/25/25 · 설계 지형 · 추적만 종단 끔(`withProfileOf`) + request = replan.RouteInitialRequest(mode=mode, with_profile=mode != "follow") + outcome = await compute_initial(project_id, request) + if isinstance(outcome, JSONResponse): + logger.warning( + "추천 미리 셈 멈춤(%s %s): project_id=%s", mode, outcome.status_code, project_id + ) + return + logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started) + + def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int: """종단 정본의 1차 변화점(`base_pvi`) 계획고를 `stations` · `plan` 직선 보간으로 바꿔 쓴다.""" import json diff --git a/resources/tester/test_224_3_recommend_store.py b/resources/tester/test_224_3_recommend_store.py new file mode 100644 index 000000000..528f34605 --- /dev/null +++ b/resources/tester/test_224_3_recommend_store.py @@ -0,0 +1,231 @@ +"""계획노선 추천 결과 영구 저장(PLAN 327 · 요구 224-3) — `B05_Profile_Engine_RouteInitial_Store`. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_224_3_recommend_store.py -q + +같은 입력 = 같은 키 · 하나만 바뀌어도 다른 키 · 두 번째 요청 셈 0회 · 덮어쓰기 없음 · +[초기화](스냅샷 복원) 뒤에도 남음 · 자료 교체 때 지워짐 · 덮개 수는 꺼낼 때 지금 관으로. +엔드포인트 몸통(`compute_initial`)은 DB · 지형 읽기만 갈아 끼우고 그대로 돌린다. +""" + +import asyncio +import json +import shutil +from contextlib import asynccontextmanager +from pathlib import Path +from uuid import uuid4 + +import numpy as np +import pytest + +from B05_Profile import B05_Profile_Router_Replan as replan +from B05_Profile import B05_Profile_Router_RouteInitial as router +from B05_Profile.B05_Profile_Engine_RouteInitial import STREAM_OFFSET_M, Terrain +from B05_Profile.B05_Profile_Engine_RouteInitial_Store import ( + DEFAULT_MODES, + request_fingerprint, + store_dir, + write_stored, +) +from common_util.common_util_initial_snapshot import ( + _FILE_TREES, + restore_snapshot_files, + snapshot_dir, +) +from common_util.common_util_project_reset import purge_output_folders + + +def _terrain(lift: float = 0.0) -> Terrain: + x = np.arange(0.0, 40.0, 2.0) + y = np.arange(0.0, 20.0, 2.0) + xx, yy = np.meshgrid(x, y) + return Terrain(x, y, 0.1 * xx + lift, np.ones(xx.shape, dtype=bool)) + + +EXPECTED = [(0.0, 10.0), (20.0, 10.0), (38.0, 10.0)] +STREAMS = [[(5.0, 0.0), (5.0, 18.0)]] +BASE = { + "mode": "weighted", + "weights": None, + "max_grade": 0.14, + "min_radius_m": 12.0, + "stream_offset_m": None, + "grade_smooth_m": None, + "target_grade": None, + "with_profile": True, + "design_speed": 20, +} + + +def _key(expected=EXPECTED, terrain=None, streams=STREAMS, **changes) -> str: + return request_fingerprint(expected, terrain or _terrain(), streams, **{**BASE, **changes})[0] + + +def test_same_input_same_key() -> None: + assert _key() == _key() + # 비운 값 = 기본값 · 비중 None = 50/25/25 — 화면이 채워 보내도 같은 키 + assert _key() == _key(stream_offset_m=STREAM_OFFSET_M) + assert _key() == _key(weights={"expected": 50.0, "earthwork": 25.0, "grade": 25.0}) + # 비중 갈래가 아니면 비중은 갈래가 정함 — 보낸 비중은 키에 안 듦 + assert _key(mode="earthwork") == _key(mode="earthwork", weights={"expected": 1.0}) + + +@pytest.mark.parametrize( + "change", + [ + {"mode": "earthwork"}, + {"weights": {"expected": 40.0, "earthwork": 30.0, "grade": 30.0}}, + {"max_grade": 0.12}, + {"min_radius_m": 15.0}, + {"stream_offset_m": 50.0}, + {"grade_smooth_m": 3.0}, + {"target_grade": 0.08}, + {"with_profile": False}, + {"design_speed": 30}, + {"expected": [(0.0, 10.0), (20.0, 11.0), (38.0, 10.0)]}, + {"terrain": _terrain(lift=0.5)}, + {"streams": []}, + ], + ids=lambda change: next(iter(change)), +) +def test_one_change_other_key(change: dict) -> None: + assert _key(**change) != _key() + + +def test_no_overwrite(tmp_path: Path) -> None: + assert write_stored(tmp_path, "k", "p", {}, {"n": 1}, True) + assert not write_stored(tmp_path, "k", "p", {}, {"n": 2}, False) + data = json.loads((store_dir(tmp_path) / "k.json").read_text(encoding="utf-8")) + assert data["result"] == {"n": 1} and data["default"] is True + assert set(data) == {"version", "key", "pool", "default", "created_at", "request", "result"} + + +def test_survives_reset_and_goes_with_data_swap(tmp_path: Path) -> None: + (tmp_path / "B05_Profile/route").mkdir(parents=True) + (tmp_path / "B05_Profile/route/initial_choice.json").write_text("{}", encoding="utf-8") + write_stored(tmp_path, "k", "p", {}, {}, True) + # 스냅샷 = 작업본 트리 사본(`save_initial_snapshot` 과 같은 자리) + for tree in _FILE_TREES: + source = tmp_path / tree + if source.is_dir(): + shutil.copytree(source, snapshot_dir(tmp_path) / tree.replace("/", "__")) + restore_snapshot_files(tmp_path) + assert (store_dir(tmp_path) / "k.json").is_file() + purge_output_folders(tmp_path) # 자료 교체 + assert not store_dir(tmp_path).exists() + + +class _Calls: + def __init__(self) -> None: + self.count = 0 + + +@pytest.fixture +def wired(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> _Calls: + """`compute_initial` 의 DB · 파일 읽기를 갈아 끼움 · 셈은 횟수만 세는 가짜.""" + import B05_Profile.B05_Profile_Engine_Grade as grade + import B05_Profile.B05_Profile_Engine_RouteInitial as engine + import B05_Profile.B05_Profile_Engine_RouteInitial_Profile as profile + import common_util.common_util_surface_confirmation as surface + + calls = _Calls() + + async def project_paths(_pid): + return tmp_path, "x" + + async def plan_criteria(_pid): + return 12.0, 12.0, 5.0 + + async def road_settings(_pid): + return "간선", 20, "일반" + + async def surface_params(_connection, _pid): + return {} + + class _Pool: + @asynccontextmanager + async def acquire(self): + yield None + + def generate(expected, terrain, streams, *, pipes, **_inputs): + assert pipes is None # 관은 키 밖 — 덮개 없이 셈해 남김 + calls.count += 1 + return { + "planned": [[x, y] for x, y in expected], + "metrics": { + "length_m": 38.0, + "offset_avg_m": 0.0, + "profile": { + "stations_m": [0.0, 38.0], + "plan_m": [1.0, 1.0], + "ground_m": [0.0, 0.0], + }, + }, + } + + monkeypatch.setattr(replan, "_project_paths", project_paths) + monkeypatch.setattr(replan, "_plan_criteria", plan_criteria) + monkeypatch.setattr(replan, "_road_settings", road_settings) + monkeypatch.setattr(replan, "_vertices_of", lambda _path: [list(p) for p in EXPECTED]) + monkeypatch.setattr(router, "get_db_pool", lambda: _Pool()) + monkeypatch.setattr(surface, "get_surface_confirmation_params", surface_params) + monkeypatch.setattr(surface, "analysis_terrain_params", lambda selection, _t: selection) + monkeypatch.setattr(grade, "legal_grade_limit_pct", lambda *_a: 14.0) + monkeypatch.setattr(engine, "load_terrain_and_streams", lambda *_a: (_terrain(), STREAMS)) + monkeypatch.setattr(engine, "generate_initial_route", generate) + # 덮개 여유 2 m 관 하나가 노선 위 — 계획고 − 지반고 1 m 라 모자람 1 + monkeypatch.setattr(profile, "project_pipes", lambda _root: [(19.0, 10.0, 2.0)]) + calls.root = tmp_path + return calls + + +def _ask(**fields) -> dict: + request = replan.RouteInitialRequest(**fields) + return asyncio.run(router.compute_initial(uuid4(), request)) + + +def test_second_request_computes_nothing(wired: _Calls) -> None: + first = _ask(mode="earthwork") + assert wired.count == 1 and first["stored"] is False + second = _ask(mode="earthwork") + assert wired.count == 1 and second["stored"] is True + assert second["planned"] == first["planned"] + # 덮개 수는 꺼낼 때마다 지금 관으로 · 저장본에는 없음 + assert second["metrics"]["profile"]["cover_short"] == 1 + files = list(store_dir(wired.root).glob("*.json")) + assert len(files) == 1 + saved = json.loads(files[0].read_text(encoding="utf-8")) + assert "cover_short" not in saved["result"]["metrics"]["profile"] + assert saved["default"] is True and saved["request"]["mode"] == "earthwork" + # 제약 하나 바꾸면 파일 하나 더 · 옛 파일 그대로 · 기본값 표시 꺼짐 + changed = _ask(mode="earthwork", criteria={"min_radius_m": 15.0}) + assert changed["stored"] is False and wired.count == 2 + assert len(list(store_dir(wired.root).glob("*.json"))) == 2 + assert json.loads(files[0].read_text(encoding="utf-8")) == saved + + +def test_same_pool_in_flight_waits(wired: _Calls) -> None: + async def both() -> list[dict]: + request = replan.RouteInitialRequest(mode="gentle") + project = uuid4() + return await asyncio.gather( + router.compute_initial(project, request), router.compute_initial(project, request) + ) + + results = asyncio.run(both()) + assert wired.count == 1 + assert sorted(result["stored"] for result in results) == [False, True] + + +def test_precompute_defaults_then_hits(wired: _Calls) -> None: + asyncio.run(router.precompute_defaults(uuid4())) + assert wired.count == len(DEFAULT_MODES) == 5 + saved = [ + json.loads(path.read_text(encoding="utf-8")) + for path in store_dir(wired.root).glob("*.json") + ] + assert sorted(item["request"]["mode"] for item in saved) == sorted(DEFAULT_MODES) + assert all(item["default"] for item in saved) + # 화면 첫 누름(비중은 화면이 50/25/25 를 채워 보냄 · 추적은 종단 끔) — 셈 없이 저장본 + hit = _ask(mode="weighted", weights={"expected": 50, "earthwork": 25, "grade": 25}) + follow = _ask(mode="follow", with_profile=False) + assert hit["stored"] and follow["stored"] and wired.count == 5 From b55bad1686db32b7f0139a4a8af32f0da1e307f2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:35:57 +0900 Subject: [PATCH 13/36] =?UTF-8?q?feat(B05):=20326-3=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=EC=B0=BD=20[?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EC=8A=A4=EB=83=85]=20=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=20=E2=80=94=20=EC=BC=AC=EB=81=94=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C(=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=EB=B0=96)=20=C2=B7=20=EC=97=B4=20=EB=95=8C=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EA=B0=92=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=ED=95=9C?= =?UTF-8?q?=20=EA=B3=B3(stations)=EC=9C=BC=EB=A1=9C=20=EB=88=88=EA=B8=88?= =?UTF-8?q?=20=C2=B7=20=EB=88=84=EB=A6=84=20=C2=B7=20=EA=B8=B0=EC=9A=B8?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=ED=9A=A1=EB=8B=A8=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=C2=B7=20[=ED=99=95=EC=9D=B8]=20=EB=95=8C?= =?UTF-8?q?=20station=5Fsnap=20=EC=8B=A4=EC=9D=8C?= 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_01TteK8mfYZetk5P3bEZkyVZ --- .../B04_PreProcess_UI_MapOverlays.ts | 7 +++- B05_Profile/B05_Profile_Api_Replan.ts | 4 ++ B05_Profile/B05_Profile_UI_RouteEdit.ts | 38 ++++++++++++++++++- B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts | 3 ++ .../B05_Profile_UI_RouteEdit_Chrome.ts | 7 ++++ B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 1 + B05_Profile/B05_Profile_UI_RouteEdit_Input.ts | 10 ++++- .../B05_Profile_UI_RouteEdit_Pointer.ts | 3 ++ .../B05_Profile_UI_RouteEdit_Render.ts | 3 ++ B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts | 22 +++++++++-- 10 files changed, 89 insertions(+), 9 deletions(-) diff --git a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts index 73543179e..e46176d19 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts @@ -211,6 +211,8 @@ export interface StationTickOptions { /** 라벨을 그릴 때마다 그 글자 상자를 알린다 — 누름 판정이 쓴다. 그림 좌표(회전 전)이고, * 상자는 `uprightRad` 만큼 (x, y) 를 축으로 돌아 있다. 안 주면 아무 일도 안 한다. */ onLabel?: (box: StationLabelBox) => void; + /** 그릴 측점 누가거리(오름차순) — 안 주면 간격마다(B05 편집 창 측점 스냅 · 요구 224-2). */ + chainages?: ReadonlyArray; } /** 그려진 측점 라벨 글자 상자 — 가운데 (x, y) · 가로 반폭 `half` · 세로 반폭 8 · 돌린 각. */ @@ -253,7 +255,10 @@ export function drawStationTicks( // 이미 그린 라벨과 겹치는 자리는 비켜 놓는다. const drawn: Array<{ x: number; y: number; half: number }> = []; let cursor = 1; - for (let chainage = 0; chainage <= total; chainage += interval) { + const marks: number[] = []; + if (options.chainages) marks.push(...options.chainages.filter((value) => value <= total)); + else for (let chainage = 0; chainage <= total; chainage += interval) marks.push(chainage); + for (const chainage of marks) { const stationNo = Math.round(chainage / interval); if (stationNo % step !== 0) continue; while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index 3285d710d..97ac66fb1 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -100,6 +100,8 @@ export interface RoutePlanResponse { violation_count: number; /** 사용자가 고친 계획노선이 저장돼 있으면 true. */ edited: boolean; + /** [측점 스냅] 켬(요구 224-2) — [확인] 때 곡선표에 적은 값. 칸 없으면 끔. */ + station_snap?: boolean; /** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */ current_initial?: { logic: string; weights: Record | null }; /** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */ @@ -220,6 +222,8 @@ export interface CrossPreviewRequest { station_interval_m: number; /** 횡단 반폭(m) — 없으면 서버 기본값. */ cross_half_width_m?: number; + /** [측점 스냅] 켬 — 호 안 측점 하나를 곡선 중점으로(요구 224-2). */ + station_snap?: boolean; } /** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index a69bcf658..65d2759b3 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -59,6 +59,7 @@ import { viewPxPerMeter, } from "./B05_Profile_UI_RouteEdit_View"; import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar"; +import { curveSpans, snapCurveStations, stationsAlong } from "./B05_Profile_UI_RouteEdit_Frame"; import { lockColumn, readRouteLocks, @@ -109,7 +110,7 @@ export async function openRouteEditModal( const stationIntervalM = options.stationIntervalM ?? 20; const chrome = createRouteEditChrome(); const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome; - const { minorContourButton, terrainSelect } = chrome; + const { minorContourButton, terrainSelect, stationSnapButton } = chrome; const context = canvas.getContext("2d")!; let expected: Vertex[] = []; @@ -138,6 +139,24 @@ export async function openRouteEditModal( let apexLock: boolean[] = []; /** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */ let picked = -1; + /** [측점 스냅](요구 224-2) — 보기 모드라 되돌리기 밖 · 열 때 서버 값 · [확인] 때 저장. */ + let stationSnap = false; + let savedStationSnap = false; + /** 편집 창 측점 한 곳 — 끔이면 null(간격마다) · 켬이면 호 안 하나를 곡선 중점으로. + * 그리기 · 누름 · 기울기가 이것을 봄 · 셈은 서버 짝(`_Frame`). */ + const stations = (): number[] | null => { + if (!stationSnap) return null; + const line = plannedLine.length ? plannedLine : planned; + return snapCurveStations( + stationsAlong(line, stationIntervalM, false), + curveSpans(line, curveInfo), + ); + }; + const setStationSnap = (on: boolean): void => { + stationSnap = on; + stationSnapButton.classList.toggle("is-active", on); + stationSnapButton.setAttribute("aria-pressed", String(on)); + }; /** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */ let history: RouteEditHistory | null = null; let meta: VWorldMeta | null = null; @@ -165,6 +184,7 @@ export async function openRouteEditModal( })), min_radius_m: minRadiusM || 12, station_interval_m: stationIntervalM, + station_snap: stationSnap, }), onShown: () => draw(), // 횡단이 뜨면 그 측점 반폭 선을 그린다. }); @@ -217,6 +237,11 @@ export async function openRouteEditModal( const slope = createSlopeLabels({ projectId, isClosed: () => closed, onChange: () => draw() }); /** 1m 간격 등고선 켬끔 — 기본 꺼짐(계획서 18 ②). */ const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw()); + stationSnapButton.addEventListener("click", () => { + setStationSnap(!stationSnap); + void crossPreview.refresh(); // 떠 있는 횡단도 옮긴 측점 규칙으로. + draw(); + }); /** 보던 화면 기억(계획서 18 ③) — 자료를 읽고 화면을 되살린 **뒤부터** 적는다. */ let viewReady = false; const viewMemory = createViewMemory(projectId, () => @@ -307,6 +332,7 @@ export async function openRouteEditModal( toScreen, uprightRad: rotation.uprightRad(), dragging: pointer.dragging(), + chainages: stations(), }), measure: measure.marks(), crossWidth: crossPreview.watched(), @@ -319,6 +345,7 @@ export async function openRouteEditModal( locked: apexLock, picked, stationIntervalM, + stations: stations(), }); // 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게. syncCurveBar(); @@ -448,6 +475,7 @@ export async function openRouteEditModal( canvas, status, stationIntervalM, + stations, state: () => ({ planned, plannedLine, @@ -530,8 +558,12 @@ export async function openRouteEditModal( projectId, nodes: () => ({ planned, curveOn, curveRadius }), // 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2). - changed: () => (history?.isDirty() ?? false) || pickedTerrain() !== designTerrain, + changed: () => + (history?.isDirty() ?? false) || + pickedTerrain() !== designTerrain || + stationSnap !== savedStationSnap, terrain: pickedTerrain, + stationSnap: () => stationSnap, initial, profileShown: strip.settle, close, @@ -554,6 +586,8 @@ export async function openRouteEditModal( minRadiusM = plan.min_radius_m ?? 0; limitRadiusM = plan.limit_radius_m ?? 0; limitArcM = plan.limit_curve_length_m ?? 0; + savedStationSnap = plan.station_snap === true; + setStationSnap(savedStationSnap); // 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다). const flat = flattenServerPlan(nodes, plan.curves ?? []); ({ planned, curveOn, curveRadius, curveLock, curveArc } = flat); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts index ed8036b2a..05f05f7d2 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts @@ -27,6 +27,8 @@ export interface RouteApplyParams { changed: () => boolean; /** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */ terrain: () => string | null; + /** [측점 스냅] 켬끔(요구 224-2) — 곡선표에 적혀 측점 셈이 읽음. */ + stationSnap: () => boolean; /** 초기노선 갈래 — [확인]에 지금 초기값을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */ initial: { current: () => unknown; @@ -97,6 +99,7 @@ export function bindRouteApply(params: RouteApplyParams): void { // 띠 응답 전에 눌렀으면 기다린다 — 서버는 같은 노선 · 같은 셈으로 띠 계획선을 다시 낸다. balanced_profile: await params.profileShown(), terrain: params.terrain(), + station_snap: params.stationSnap(), }, ), ); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index fb0c4208d..c6e66feee 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -36,6 +36,8 @@ export interface RouteEditChrome { measureText: HTMLElement; measureButton: HTMLButtonElement; minorContourButton: HTMLButtonElement; + /** [측점 스냅] 켬끔(요구 224-2) — `aria-pressed` · `is-active` 는 본체가 맞춤. */ + stationSnapButton: HTMLButtonElement; /** 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임(감싼 label 의 hidden). */ terrainSelect: HTMLSelectElement; } @@ -65,6 +67,9 @@ export function createRouteEditChrome(): RouteEditChrome { title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선 + @@ -136,6 +141,7 @@ export function createRouteEditChrome(): RouteEditChrome { '[data-act="contour-minor"]', )!; const terrainSelect = overlay.querySelector('[data-act="terrain"]')!; + const stationSnapButton = overlay.querySelector('[data-act="station-snap"]')!; return { overlay, @@ -146,6 +152,7 @@ export function createRouteEditChrome(): RouteEditChrome { measureText, measureButton, minorContourButton, + stationSnapButton, terrainSelect, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index 1c6a3648c..c0b491141 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -35,6 +35,7 @@ export interface CrossPreviewParams { vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; min_radius_m: number; station_interval_m: number; + station_snap?: boolean; }; /** 위 판에 횡단이 떴다 · 비었다 — 평면도의 반폭 선을 다시 그리라는 신호. */ onShown?: () => void; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index fbef8b083..4eef74ec3 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -321,13 +321,15 @@ export function stationAtScreen( py: number, maxPx: number, labels: ReadonlyArray = [], + /** 측점 누가거리 — 안 주면 간격마다(측점 스냅 · 요구 224-2). */ + chainages: ReadonlyArray | null = null, ): number | null { // 라벨이 그려진 측점만 눈금도 보인다(`drawStationTicks` 가 성기게 솎는다) — 안 보이는 눈금이 // 호·등고선 누름을 가로채지 않게 한다. 라벨 정보가 없으면 전부 본다. const shown = labels.length ? new Set(labels.map((box) => Math.round(box.chainage / intervalM))) : null; - const tick = stationTickAtScreen(line, toScreen, intervalM, px, py, maxPx, shown); + const tick = stationTickAtScreen(line, toScreen, intervalM, px, py, maxPx, shown, chainages); // 눈금과 라벨이 겹치면 눈금이 먼저다 — 라벨은 눈금을 못 잡았을 때만 본다. return tick ?? stationLabelAtScreen(labels, px, py); } @@ -373,6 +375,7 @@ function stationTickAtScreen( py: number, maxPx: number, shown: ReadonlySet | null, + chainages: ReadonlyArray | null, ): number | null { if (line.length < 2 || !(intervalM > 0)) return null; const cumulative: number[] = [0]; @@ -386,7 +389,10 @@ function stationTickAtScreen( let best: number | null = null; let bestDistance = maxPx; let cursor = 1; - for (let chainage = 0; chainage <= total; chainage += intervalM) { + const marks: number[] = []; + if (chainages) marks.push(...chainages.filter((value) => value <= total)); + else for (let chainage = 0; chainage <= total; chainage += intervalM) marks.push(chainage); + for (const chainage of marks) { if (shown && !shown.has(Math.round(chainage / intervalM))) continue; while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; const back = line[cursor - 1]; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index 3763161ea..2da4c4f10 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -64,6 +64,8 @@ export interface RouteEditPointerParams { canvas: HTMLCanvasElement; status: HTMLElement; stationIntervalM: number; + /** 편집 창 측점 — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */ + stations: () => ReadonlyArray | null; state: () => RouteEditPointerState; setPicked: (index: number) => void; setPickedContour: (index: number) => void; @@ -194,6 +196,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP py, pickPx(STATION_HIT_PX), stationLabelBoxes(canvas), + params.stations(), ); if (chainage === null) return false; void params.crossPreview.open(chainage); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 3a35c064e..90c6664ec 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -94,6 +94,8 @@ export interface RouteEditScene { picked: number; /** 규칙 측점 간격(m). */ stationIntervalM: number; + /** 그릴 측점 누가거리 — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */ + stations: ReadonlyArray | null; /** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 그 노선의 누가거리(계획서 0-9 ⑤). */ measure: ReadonlyArray; /** 오른쪽에 횡단이 떠 있는 측점과 좌·우 설계 반폭 — 없으면 null. */ @@ -478,6 +480,7 @@ function drawStationMarks( toScreen: (x, y) => scene.toScreen([x, y]), uprightRad: scene.uprightRad, onLabel: (box) => labels.push(box), + chainages: scene.stations ?? undefined, }, ); const total = polylineLengthM(line); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts index e35789f81..aa372ee5b 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts @@ -42,6 +42,8 @@ export interface SlopeDrawArgs { uprightRad: number; /** 노드·손잡이를 끄는 중 — 이때는 묻지 않는다. */ dragging: boolean; + /** 측점 누가거리(종점 뺌) — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */ + chainages?: ReadonlyArray | null; } export interface SlopeLabels { @@ -91,6 +93,8 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels { /** 높이를 받아 둔 노선·간격과 그 결과. 노선이 바뀌면(새 배열) 버린다. */ let cachedLine: ReadonlyArray | null = null; let cachedInterval = 0; + /** 받아 둔 측점 목록 열쇠 — 스냅을 켜고 끄면 같은 노선이어도 다시 묻는다. */ + let cachedKey = ""; let chainages: number[] = []; let heights: Array = []; /** 측점 자리와 구간 가운데 자리 — 받아 올 때 한 번 셈해 둔다(그리기마다 다시 안 셈). */ @@ -100,19 +104,27 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels { let asking: ReadonlyArray | null = null; let timer: ReturnType | null = null; - function schedule(line: ReadonlyArray, intervalM: number): void { + function schedule( + line: ReadonlyArray, + intervalM: number, + marks: ReadonlyArray | null, + ): void { if (asking === line) return; if (timer) clearTimeout(timer); asking = line; timer = setTimeout(() => { timer = null; - const wanted = stationChainages(lengthOf(line), intervalM); + const total = lengthOf(line); + const wanted = marks + ? [...marks.filter((value) => value < total - 1e-6), total] + : stationChainages(total, intervalM); const points = pointsAt(line, wanted); void fetchRouteElevations(params.projectId, points) .then((z) => { if (params.isClosed() || asking !== line) return; // 그 사이 노선이 또 바뀌었다. cachedLine = line; cachedInterval = intervalM; + cachedKey = marks?.join() ?? ""; chainages = wanted; heights = z; stationPoints = points; @@ -160,9 +172,11 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels { const { line, intervalM } = args; if (line.length < 2 || !(intervalM > 0)) return 0; if (intervalM * args.pxPerMeter < SLOPE_MIN_GAP_PX) return 0; // 멀리 보면 숨긴다. - const fresh = cachedLine === line && cachedInterval === intervalM; + const marks = args.chainages ?? null; + const fresh = + cachedLine === line && cachedInterval === intervalM && cachedKey === (marks?.join() ?? ""); if (!fresh) { - if (!args.dragging) schedule(line, intervalM); + if (!args.dragging) schedule(line, intervalM, marks); return 0; // 낡은 높이로 그리면 엉뚱한 기울기가 적힌다 — 받아 올 때까지 비운다. } // 화면 안 판정은 **캔버스 변환 그대로**(회전·고해상도 배율 포함) 옮긴 자리로 한다 — From b1e8d6cb7f5f8a8d4734cd3fdfeddc026ada1d0f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:36:04 +0900 Subject: [PATCH 14/36] =?UTF-8?q?feat(=EC=84=A4=EC=A0=95):=20332-1=20?= =?UTF-8?q?=ED=9C=A0=20=ED=99=95=EB=8C=80=20=EB=B0=A9=ED=96=A5=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EC=B8=B5=20=E2=80=94=20input=5Fprefs=20=C2=B7=20?= =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=ED=9C=A0=20=ED=99=94=EB=A9=B4=20=ED=95=9C?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95(=EA=B8=B0=EB=B3=B8=20=EB=8B=B9=EA=B9=80)?= =?UTF-8?q?=20=C2=B7=20=ED=95=80=EC=B9=98=EB=8A=94=20=EB=8A=98=20=EB=B2=8C?= =?UTF-8?q?=EB=A6=AC=EB=A9=B4=20=ED=99=95=EB=8C=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 A00_Common/input_prefs.ts(INPUT_PREFS_KEY · DEFAULT_INPUT_PREFS · inputPrefs · wheelIn · wheelEventIn · 의존 없음) - 등록표 input-prefs(pref · global) 한 줄 - touch_bridge wheelZoomsIn · b_svg_zoom_pan · B04 3D 카메라 · M02 DF 그림 · M02 노무 그림 · 돋보기 · 시트 배선 - M02 그림 · 시트 · 돋보기 기본 방향 = 당김(지금과 반대) - 시험: test_input_prefs 새로 · 돋보기 · 266 · 244-8 헬퍼가 읽기 층을 불러옴 · 돋보기 부호 당김 기본 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- A00_Common/b_page_state.ts | 2 + A00_Common/b_svg_zoom_pan.ts | 6 +- A00_Common/input_prefs.ts | 64 ++++++++++ A00_Common/spreadsheet/spreadsheet_grid.ts | 4 +- A00_Common/touch_bridge.ts | 7 +- B04_PreProcess/B04_PreProcess_UI_Camera.ts | 5 +- M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts | 3 +- .../M02_MasterTemplete_Labor_Diagram.ts | 3 +- resources/tester/helper_218_1_loupe.cjs | 32 ++--- resources/tester/helper_266_detail_home.cjs | 1 + resources/tester/helper_input_prefs.cjs | 63 ++++++++++ resources/tester/test_220_1_loupe_wheel.py | 6 +- .../test_244_8_b08_milestone_no_scroll.py | 5 +- resources/tester/test_input_prefs.py | 113 ++++++++++++++++++ ui_template/ui_template_loupe.ts | 5 +- 15 files changed, 288 insertions(+), 31 deletions(-) create mode 100644 A00_Common/input_prefs.ts create mode 100644 resources/tester/helper_input_prefs.cjs create mode 100644 resources/tester/test_input_prefs.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index b3745a035..2f14d7bab 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -103,6 +103,8 @@ export const STATE_REGISTRY = { "cross-columns": { bucket: "pref", scope: "global" }, /** B05 · B06 종단 누가토량 알약 자리(309-2) — 그래프 영역 안 비율 `{"x":0~1,"y":0~1}` · 없으면 처음 자리. */ "mass-haul-badge-pos": { bucket: "pref", scope: "global" }, + /** ☰ [설정] 마우스 · 단축키(332) — 읽기는 `input_prefs.ts`(같은 키 글자) · [저장] 때만 씀. */ + "input-prefs": { bucket: "pref", scope: "global" }, /** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */ "b05-3d-projection": { bucket: "pref", scope: "global" }, "section-panel-height": { diff --git a/A00_Common/b_svg_zoom_pan.ts b/A00_Common/b_svg_zoom_pan.ts index 9590e576c..ea40f76d9 100644 --- a/A00_Common/b_svg_zoom_pan.ts +++ b/A00_Common/b_svg_zoom_pan.ts @@ -14,6 +14,8 @@ * 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다. * ========================================================================== */ +import { wheelEventIn } from "./input_prefs"; + /** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */ export interface ZoomPanState { scale: number; @@ -213,8 +215,8 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle { const rect = svg.getBoundingClientRect(); const cx = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * widthPx; const cy = ((event.clientY - rect.top) / Math.max(rect.height, 1)) * heightPx; - // 노선 편집 지도와 같은 방향 — **당기면 확대**(`deltaY > 0`). - zoomAt(event.deltaY > 0 ? WHEEL_IN : WHEEL_OUT, cx, cy); + // ☰ [설정] 휠 방향(332 · 기본 당기면 확대) — 그림 면이 손가락을 받으므로 핀치는 트랙패드뿐. + zoomAt(wheelEventIn(event) ? WHEEL_IN : WHEEL_OUT, cx, cy); }, { passive: false }, ); diff --git a/A00_Common/input_prefs.ts b/A00_Common/input_prefs.ts new file mode 100644 index 000000000..49fff86db --- /dev/null +++ b/A00_Common/input_prefs.ts @@ -0,0 +1,64 @@ +/* ============================================================================= + * input_prefs.ts + * 마우스 · 단축키 설정 읽기 층(PLAN 332) — ☰ [설정] 창이 [저장] 때 계정 pref 로 씀. + * + * - 값 = `aislo:pref:input-prefs`(등록표 `input-prefs`) JSON 한 벌 · 부를 때마다 읽음(새로고침 없이 바로 따름) + * - 없음 · 깨짐 · Node(저장소 없음) = 기본값(= 지금 동작) · 의존 없음 · 불러올 때 window 안 만짐(CAD 도 import) + * - 휠 확대 방향 하나로 모든 화면 일체 — 당김 = 휠을 몸 쪽으로(deltaY > 0) 굴리면 확대 + * - 핀치(트랙패드 · 손가락)는 설정과 상관없이 늘 벌리면 확대(deltaY < 0) + * ========================================================================== */ + +export type WheelZoom = "pull" | "push"; + +export interface InputPrefs { + /** 휠 확대 방향 — pull = 당기면 확대 · push = 밀면 확대 */ + wheelZoom: WheelZoom; + /** CAD 내 별칭 `{명령: [별칭]}`(대문자 · acad.pgp 식 — 명령 이름 > 내 별칭 > 기본 별칭) */ + cadAliases: Record; +} + +/** `stateKey("input-prefs")` 와 같은 키(시험이 맞춰 봄) — 등록표를 불러오면 window 가 딸려 와서 글자로 둠 */ +export const INPUT_PREFS_KEY = "aislo:pref:input-prefs"; + +export const DEFAULT_INPUT_PREFS: InputPrefs = { wheelZoom: "pull", cadAliases: {} }; + +const cleanAliases = (raw: unknown): Record => { + const out: Record = {}; + if (!raw || typeof raw !== "object") return out; + for (const [id, list] of Object.entries(raw as Record)) { + if (!Array.isArray(list)) continue; + const aliases = list + .filter((a): a is string => typeof a === "string") + .map((a) => a.toUpperCase()); + if (aliases.length) out[id.toUpperCase()] = aliases; + } + return out; +}; + +/** 지금 설정 — 부를 때마다 저장소에서 읽음 */ +export function inputPrefs(): InputPrefs { + try { + const raw = globalThis.localStorage?.getItem(INPUT_PREFS_KEY); + if (!raw) return { ...DEFAULT_INPUT_PREFS, cadAliases: {} }; + const value = JSON.parse(raw) as Partial | null; + return { + wheelZoom: value?.wheelZoom === "push" ? "push" : "pull", + cadAliases: cleanAliases(value?.cadAliases), + }; + } catch { + return { ...DEFAULT_INPUT_PREFS, cadAliases: {} }; + } +} + +/** 휠 한 번이 확대인가 — 설정 방향을 따름 */ +export const wheelIn = (deltaY: number): boolean => + inputPrefs().wheelZoom === "push" ? deltaY < 0 : deltaY > 0; + +/** 트랙패드 핀치 — 브라우저가 Ctrl+휠 · 잘게 쪼갠 값으로 냄 + * ponytail: |deltaY| < 50 로 마우스 Ctrl+휠(한 칸 ≥ 약 100)과 가름 · 잘게 도는 마우스는 핀치로 봄 */ +export const isTrackpadPinch = (e: Pick): boolean => + e.ctrlKey && Math.abs(e.deltaY) < 50; + +/** 휠 사건이 확대인가 — 핀치(`pinch` 손가락 벌림 · 트랙패드)는 늘 벌리면 확대 · 나머지는 설정 방향 */ +export const wheelEventIn = (e: Pick, pinch = false): boolean => + pinch || isTrackpadPinch(e) ? e.deltaY < 0 : wheelIn(e.deltaY); diff --git a/A00_Common/spreadsheet/spreadsheet_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index da403b2d4..9e0b97db7 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -13,6 +13,7 @@ import "./spreadsheet_grid.css"; import { colIndex, toA1 } from "./spreadsheet_address"; +import { wheelZoomsIn } from "../touch_bridge"; import { buildColGeometry, buildOutlineRuns, @@ -271,7 +272,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { if (!ev.ctrlKey) return; ev.preventDefault(); const before = zoom; - const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(-ev.deltaY * 0.0015))); + const step = (wheelZoomsIn(ev) ? 1 : -1) * Math.abs(ev.deltaY) * 0.0015; // ☰ [설정] 방향(332) + const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(step))); if (next === before) return; zoom = next; colGeo = scaledGeometry(baseColGeo, zoom); diff --git a/A00_Common/touch_bridge.ts b/A00_Common/touch_bridge.ts index 6c83aa700..6fc8a810d 100644 --- a/A00_Common/touch_bridge.ts +++ b/A00_Common/touch_bridge.ts @@ -11,6 +11,8 @@ * 마우스 · 펜 이벤트는 건드리지 않음. * ========================================================================== */ +import { wheelEventIn } from "./input_prefs"; + const LONG_MS = 500; const SLOP_PX = 10; const DOUBLE_MS = 300; @@ -31,9 +33,8 @@ let contactPx = 0; /** 고름 반경(px) — 손가락으로 눌렀으면 접촉 크기만큼(최소 24px) · 마우스 · 펜은 그대로(기기대응 4-4) */ export const pickPx = (px: number): number => (contactPx ? Math.max(px, contactPx) : px); -/** 「당기면 확대」 휠 화면(B04 · B05 지도)의 확대 판정 — 손가락 벌림은 거꾸로라 뒤집음 */ -export const wheelZoomsIn = (e: WheelEvent): boolean => - isPinchWheel(e) ? e.deltaY < 0 : e.deltaY > 0; +/** 휠 화면(B04 · B05 지도 · M02 그림 · 시트)의 확대 판정 — ☰ [설정] 방향(332) · 손가락 벌림은 늘 벌리면 확대 */ +export const wheelZoomsIn = (e: WheelEvent): boolean => wheelEventIn(e, isPinchWheel(e)); const ownsTouch = (t: EventTarget | null): boolean => t instanceof Element && !!t.closest("[data-touch-own]"); diff --git a/B04_PreProcess/B04_PreProcess_UI_Camera.ts b/B04_PreProcess/B04_PreProcess_UI_Camera.ts index d5adca1d9..8bf42cc44 100644 --- a/B04_PreProcess/B04_PreProcess_UI_Camera.ts +++ b/B04_PreProcess/B04_PreProcess_UI_Camera.ts @@ -1,6 +1,7 @@ import * as THREE from "three"; import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import type { SurfaceBounds } from "./B04_PreProcess_Api_Fetch"; +import { wheelEventIn } from "../A00_Common/input_prefs"; export const SURFACE_CAMERA_FOV = 50; const LIGHT_VIEWER_BACKGROUND = 0xf5f7f9; @@ -246,11 +247,11 @@ export function bindCursorPivotControls(options: CursorPivotOptions): () => void } /** 휠 줌 — 커서가 가리키는 지점을 축으로 삼아 그 점이 화면에 고정된 채 멀어지고 가까워진다. - * 휠을 위로 올리면 멀어진다(사용자 지시). */ + * 방향 = ☰ [설정](332 · 기본 당기면 가까워짐 · 위로 올리면 멀어짐). */ function onWheel(event: WheelEvent): void { if (!controls.enabled || options.blocked?.()) return; event.preventDefault(); - zoomAround(event, event.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP); + zoomAround(event, wheelEventIn(event) ? ZOOM_STEP : 1 / ZOOM_STEP); } /** 그 자리를 축으로 멀어지고(factor > 1) 가까워짐 — 휠 · 두 손가락 벌림이 같이 씀 */ diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts index 76d4b028c..0623a007e 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts @@ -7,6 +7,7 @@ * 그리기만(바꾸는 것은 부른 쪽이 Model 로) · 순수 부분은 `M02_DF_Diagram_Parts.ts`. * ========================================================================== */ +import { wheelZoomsIn } from "../../A00_Common/touch_bridge"; import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; import type { CfBox, CfKind, CfLogic, CfPill, CfPort } from "./M02_DF_Model"; import { @@ -454,7 +455,7 @@ export function renderDiagram( "wheel", (ev) => { ev.preventDefault(); - setZoom(st.zoom * (ev.deltaY < 0 ? 1.1 : 1 / 1.1), ev.clientX, ev.clientY); + setZoom(st.zoom * (wheelZoomsIn(ev) ? 1.1 : 1 / 1.1), ev.clientX, ev.clientY); // ☰ [설정] 방향(332) }, { passive: false }, ); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts index b966ba85c..1d653f7a2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts @@ -7,6 +7,7 @@ * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { wheelZoomsIn } from "../A00_Common/touch_bridge"; import { createButton, el } from "@ui/ui_template_elements"; import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; import "./M02_MasterTemplete_Labor_Diagram.css"; @@ -313,7 +314,7 @@ function mountShell(host: HTMLElement): State { if (!e.ctrlKey) return; e.preventDefault(); const r = viewport.getBoundingClientRect(); - zoomAt(st, e.deltaY < 0 ? 1.1 : 1 / 1.1, e.clientX - r.left, e.clientY - r.top); + zoomAt(st, wheelZoomsIn(e) ? 1.1 : 1 / 1.1, e.clientX - r.left, e.clientY - r.top); // ☰ [설정] 방향(332) }, { passive: false }, ); diff --git a/resources/tester/helper_218_1_loupe.cjs b/resources/tester/helper_218_1_loupe.cjs index ac649dbf7..031fa6f4d 100644 --- a/resources/tester/helper_218_1_loupe.cjs +++ b/resources/tester/helper_218_1_loupe.cjs @@ -15,7 +15,9 @@ function load(file) { ? {} : p.endsWith("key_shortcut") // 공용 키 판정(214-2)도 그 자리에서 트랜스파일 ? load(path.join(ROOT, "A00_Common", "key_shortcut.ts")) - : require(p); + : p.endsWith("input_prefs") // 휠 방향(332 · Node = 기본 당김) + ? load(path.join(ROOT, "A00_Common", "input_prefs.ts")) + : require(p); new Function("exports", "module", "require", js)(box.exports, box, req); return box.exports; } @@ -100,30 +102,30 @@ r.consts = { r.listeners = win.calls.map((c) => [c.type, c.capture]); } { - // 220-1 휠 — 켜 있는 동안만 배율 · 스크롤 막음 · 켤 때마다 2배 + // 220-1 휠 — 켜 있는 동안만 배율 · 스크롤 막음 · 켤 때마다 2배 · 확대 = 당김(deltaY > 0 · 332 기본) const { win, view } = setup(); const wheel = (deltaY) => win.fire("wheel", { deltaY }); const zoomLog = () => view.log.filter((l) => l[0] === "zoom").map((l) => l[1]); - const offEv = wheel(-100); + const offEv = wheel(100); const offState = { prevented: offEv.prevented, stopped: offEv.stopped, log: view.log.length }; win.fire("keydown", { code: "KeyZ" }); - const up = wheel(-100); + const up = wheel(100); const upState = { prevented: up.prevented, stopped: up.stopped, zoom: zoomLog().at(-1) }; - wheel(100); - wheel(100); + wheel(-100); + wheel(-100); const down = zoomLog().at(-1); wheel(0); // 가로 굴림 — 배율 그대로 · 스크롤은 막음 const flat = zoomLog().at(-1); - for (let i = 0; i < 30; i++) wheel(-50); + for (let i = 0; i < 30; i++) wheel(50); const max = zoomLog().at(-1); - for (let i = 0; i < 30; i++) wheel(120); + for (let i = 0; i < 30; i++) wheel(-120); const min = zoomLog().at(-1); win.fire("keyup", { code: "KeyZ" }); const n = zoomLog().length; - const afterOff = wheel(-100); + const afterOff = wheel(100); const afterOffZoomed = zoomLog().length !== n; win.fire("keydown", { code: "KeyZ" }); - wheel(-100); + wheel(100); r.wheel = { off: offState, up: upState, @@ -134,17 +136,17 @@ r.consts = { afterOff: { prevented: afterOff.prevented, stopped: afterOff.stopped, zoomed: afterOffZoomed }, reopen: zoomLog().at(-1), // 다시 켜면 2배에서 한 칸 pure: [ - m.nextLoupeZoom(2, -1), m.nextLoupeZoom(2, 1), - m.nextLoupeZoom(6, -1), - m.nextLoupeZoom(1.5, 1), + m.nextLoupeZoom(2, -1), + m.nextLoupeZoom(6, 1), + m.nextLoupeZoom(1.5, -1), ], }; // 길게 누름으로 켠 동안도 같음 const b = setup(); b.win.fire("mousedown", { button: 2, buttons: 2 }); b.win.tick(400); - const press = b.win.fire("wheel", { deltaY: -1 }); + const press = b.win.fire("wheel", { deltaY: 1 }); r.wheel.press = { prevented: press.prevented, zoom: b.view.log.at(-1) }; } { @@ -298,7 +300,7 @@ r.origin = { // 켜진 채 CAD 위로(mouseover · mousemove 둘 다) — 숨김 · 휠 안 막음 · 나오면 배율 그대로 보임 const { win, view } = setup(); win.fire("keydown", { code: "KeyZ" }); - win.fire("wheel", { deltaY: -100 }); // 2.25 + win.fire("wheel", { deltaY: 100 }); // 2.25 win.fire("mouseover", { target: cad }); const hidden = last(view); const n = view.log.length; diff --git a/resources/tester/helper_266_detail_home.cjs b/resources/tester/helper_266_detail_home.cjs index a5dfdbdbf..9b02f78cb 100644 --- a/resources/tester/helper_266_detail_home.cjs +++ b/resources/tester/helper_266_detail_home.cjs @@ -28,6 +28,7 @@ function loadTs(full) { new Function("exports", "module", "require", js)(box.exports, box, localRequire); return box.exports; } +STUBS["./input_prefs"] = loadTs(path.join(ROOT, "A00_Common", "input_prefs.ts")); // 휠 방향(332 · Node = 기본 당김) const metrics = loadTs(path.join(B06, "B06_Section_UI_Cross_View_Metrics.ts")); const { attachSvgZoomPan } = loadTs(path.join(ROOT, "A00_Common", "b_svg_zoom_pan.ts")); diff --git a/resources/tester/helper_input_prefs.cjs b/resources/tester/helper_input_prefs.cjs new file mode 100644 index 000000000..860064bb5 --- /dev/null +++ b/resources/tester/helper_input_prefs.cjs @@ -0,0 +1,63 @@ +/* PLAN 332-1 단위검증 헬퍼 — 휠 방향 읽기 층(`A00_Common/input_prefs.ts`) · 손가락 다리(`touch_bridge.ts`) + * · 등록표 키(`b_page_state.ts`)를 그 자리에서 트랜스파일해 Node 로 돌림. 가짜 localStorage 로 설정을 바꿔 봄. + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_input_prefs.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 A00 = path.join(ROOT, "A00_Common"); +function load(file) { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const req = (p) => (p === "./input_prefs" ? prefs : {}); + new Function("exports", "module", "require", js)(box.exports, box, req); + return box.exports; +} + +const out = {}; +// ① Node(저장소 없음) — 불러올 때 window 를 안 만지고 기본값 +const prefs = load(path.join(A00, "input_prefs.ts")); +const bridge = load(path.join(A00, "touch_bridge.ts")); +const page = load(path.join(A00, "b_page_state.ts")); +const sample = () => ({ + prefs: prefs.inputPrefs(), + pull: prefs.wheelIn(100), + push: prefs.wheelIn(-100), + mouseCtrl: prefs.wheelEventIn({ ctrlKey: true, deltaY: 100 }), + padSpread: prefs.wheelEventIn({ ctrlKey: true, deltaY: -3.5 }), + padPinch: prefs.wheelEventIn({ ctrlKey: true, deltaY: 3.5 }), + fingerSpread: prefs.wheelEventIn({ ctrlKey: true, deltaY: -9.5 }, true), + bridgePull: bridge.wheelZoomsIn({ ctrlKey: false, deltaY: 100 }), +}); +out.node = { key: prefs.INPUT_PREFS_KEY, default: prefs.DEFAULT_INPUT_PREFS, ...sample() }; +out.registryKey = page.stateKey("input-prefs"); +out.registryEntry = page.STATE_REGISTRY["input-prefs"]; + +// ② 브라우저 저장소 흉내 — 값을 바꾸면 다음 부름부터 바로 따름 +const store = new Map(); +globalThis.localStorage = { + getItem: (k) => (store.has(k) ? store.get(k) : null), + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), +}; +const set = (v) => store.set(prefs.INPUT_PREFS_KEY, v); +set(JSON.stringify({ wheelZoom: "push", cadAliases: { line: ["ll", 3], ARC: [] } })); +out.push = sample(); +set("{깨짐"); +out.broken = sample(); +set(JSON.stringify({ wheelZoom: "sideways" })); +out.unknown = sample(); +store.delete(prefs.INPUT_PREFS_KEY); +out.empty = sample(); +// ③ 불러오는 동안 저장소가 던지는 브라우저(사생활 보호) — 기본값 +globalThis.localStorage = { + getItem: () => { + throw new Error("blocked"); + }, +}; +out.blocked = sample(); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_220_1_loupe_wheel.py b/resources/tester/test_220_1_loupe_wheel.py index a1c5bda7d..e70b9c4da 100644 --- a/resources/tester/test_220_1_loupe_wheel.py +++ b/resources/tester/test_220_1_loupe_wheel.py @@ -34,10 +34,10 @@ def test_지름_360_배율_한도(r): assert (c["zoom"], c["zoomMin"], c["zoomMax"], c["zoomStep"]) == (2, 1.5, 6, 0.25) -def test_휠_위_크게_아래_작게_한도(r): +def test_휠_당김_크게_밀기_작게_한도(r): # 332 기본 = 당김 확대 w = r["wheel"] - assert w["up"]["zoom"] == 2.25 # 위로 한 칸 - assert w["down"] == 1.75 # 아래로 두 칸 + assert w["up"]["zoom"] == 2.25 # 당겨 한 칸 + assert w["down"] == 1.75 # 밀어 두 칸 assert w["flat"] == 1.75 # 가로 굴림은 그대로 assert w["max"] == 6 and w["min"] == 1.5 assert w["pure"] == [2.25, 1.75, 6, 1.5] diff --git a/resources/tester/test_244_8_b08_milestone_no_scroll.py b/resources/tester/test_244_8_b08_milestone_no_scroll.py index 17c980d2a..e3c7ecce2 100644 --- a/resources/tester/test_244_8_b08_milestone_no_scroll.py +++ b/resources/tester/test_244_8_b08_milestone_no_scroll.py @@ -20,7 +20,10 @@ def _read(rel): def _node(script): - src = IMPORTS.sub("", _read("A00_Common/b_svg_zoom_pan.ts")) + # 휠 방향 읽기 층(332) 을 앞에 붙임 — Node 엔 저장소가 없어 기본(당기면 확대) + src = _read("A00_Common/input_prefs.ts") + IMPORTS.sub( + "", _read("A00_Common/b_svg_zoom_pan.ts") + ) result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", src + "\n" + script], diff --git a/resources/tester/test_input_prefs.py b/resources/tester/test_input_prefs.py new file mode 100644 index 000000000..c9c2cd0b7 --- /dev/null +++ b/resources/tester/test_input_prefs.py @@ -0,0 +1,113 @@ +"""PLAN 332-1 — 휠 확대 방향 한 설정(`A00_Common/input_prefs.ts`) · 모든 휠 화면이 그 판정을 거침. + +· 기본 = 당김(휠을 몸 쪽으로 = 확대) · 밀기 저장 뒤 뒤집힘 · 깨짐 · 모르는 값 · 막힌 저장소 · Node = 기본 +· 핀치(트랙패드 · 손가락)는 설정과 상관없이 늘 벌리면 확대 +· 키 = 등록표 `input-prefs`(pref · global) 와 같은 글자 +짝: `helper_input_prefs.cjs`(실제 TS 를 Node 로 돌림). +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") + +pytestmark = pytest.mark.skipif( + not os.path.isdir(os.path.join(ROOT, "config", "node_modules")), + reason="프론트엔드 의존성 미설치", +) + + +def _read(rel: str) -> str: + with open(os.path.join(ROOT, rel), encoding="utf-8") as f: + return f.read() + + +@pytest.fixture(scope="module") +def r() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_input_prefs.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +PULL = {"pull": True, "push": False, "mouseCtrl": True, "bridgePull": True} +PINCH = {"padSpread": True, "padPinch": False, "fingerSpread": True} + + +def _pick(case: dict, keys: dict) -> dict: + return {k: case[k] for k in keys} + + +def test_Node_기본_당김(r): + n = r["node"] + assert n["default"] == {"wheelZoom": "pull", "cadAliases": {}} + assert n["prefs"] == n["default"] + assert _pick(n, PULL) == PULL and _pick(n, PINCH) == PINCH + + +def test_밀기_저장_뒤_뒤집힘_핀치는_그대로(r): + p = r["push"] + assert p["prefs"]["wheelZoom"] == "push" + assert _pick(p, PULL) == {"pull": False, "push": True, "mouseCtrl": False, "bridgePull": False} + assert _pick(p, PINCH) == PINCH # 벌리면 확대는 설정과 상관없음 + # 별칭 = 대문자 · 글자 아닌 것 · 빈 목록 버림 + assert p["prefs"]["cadAliases"] == {"LINE": ["LL"]} + + +@pytest.mark.parametrize("case", ["broken", "unknown", "empty", "blocked"]) +def test_깨짐_모르는_값_없음_막힘_기본(r, case): + c = r[case] + assert c["prefs"] == {"wheelZoom": "pull", "cadAliases": {}} + assert _pick(c, PULL) == PULL and _pick(c, PINCH) == PINCH + + +def test_등록표_키(r): + assert r["node"]["key"] == r["registryKey"] == "aislo:pref:input-prefs" + assert r["registryEntry"] == {"bucket": "pref", "scope": "global"} + + +def test_읽기_층은_의존_없음_불러올_때_window_안_만짐(): + src = _read("A00_Common/input_prefs.ts") + assert not re.search(r"^import ", src, re.M) + assert "window." not in src + + +# 휠 줌 자리 — 직접 박은 방향(`deltaY < 0 ?` · `deltaY > 0 ?` · `-ev.deltaY`)이 없고 공용 판정을 부름 +WHEEL_SITES = { + "A00_Common/touch_bridge.ts": "wheelEventIn(e, isPinchWheel(e))", + "A00_Common/b_svg_zoom_pan.ts": "wheelEventIn(event)", + "B04_PreProcess/B04_PreProcess_UI_Camera.ts": "wheelEventIn(event)", + "M02_MasterTemplete/DataFlow/M02_DF_Diagram.ts": "wheelZoomsIn(ev)", + "M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts": "wheelZoomsIn(e)", + "ui_template/ui_template_loupe.ts": "wheelIn(deltaY)", + "A00_Common/spreadsheet/spreadsheet_grid.ts": "wheelZoomsIn(ev)", + # wheelZoomsIn 을 거쳐 따라오는 셋 + "B04_PreProcess/B04_PreProcess_UI_MapViewer.ts": "wheelZoomsIn(event)", + "B05_Profile/B05_Profile_UI_Drainage_Interact.ts": "wheelZoomsIn(event)", + "B05_Profile/B05_Profile_UI_RouteEdit_Input.ts": "wheelZoomsIn(event)", +} + + +@pytest.mark.parametrize("rel", sorted(WHEEL_SITES)) +def test_휠_자리_공용_판정(rel): + src = _read(rel) + assert WHEEL_SITES[rel] in src + assert not re.search(r"deltaY [<>] 0 \?", src), rel + assert "-ev.deltaY" not in src and "Math.sign(-deltaY)" not in src + + +def test_700줄_안(): + for rel in WHEEL_SITES: + assert _read(rel).count("\n") <= 700, rel diff --git a/ui_template/ui_template_loupe.ts b/ui_template/ui_template_loupe.ts index 16479963a..68a0cfee4 100644 --- a/ui_template/ui_template_loupe.ts +++ b/ui_template/ui_template_loupe.ts @@ -14,6 +14,7 @@ import "./ui_template_loupe.css"; import { keyLetter } from "../A00_Common/key_shortcut"; +import { wheelIn } from "../A00_Common/input_prefs"; /** 원 지름(px) */ export const LOUPE_DIAMETER = 360; @@ -86,9 +87,9 @@ export function isCadTarget(target: unknown): boolean { return !!(target as Partial | null)?.closest?.(LOUPE_SKIP); } -/** 휠 한 번 뒤 배율 — 위로 굴림(deltaY < 0) = 크게 · 한도 안 */ +/** 휠 한 번 뒤 배율 — ☰ [설정] 방향(332 · 기본 당김 = deltaY > 0 = 크게) · 한도 안 */ export function nextLoupeZoom(zoom: number, deltaY: number): number { - const step = Math.sign(-deltaY) * LOUPE_ZOOM_STEP; + const step = deltaY ? (wheelIn(deltaY) ? 1 : -1) * LOUPE_ZOOM_STEP : 0; return Math.max(LOUPE_ZOOM_MIN, Math.min(LOUPE_ZOOM_MAX, zoom + step)); } From 32ddaa8064105f3f56458a368b85804d8083cbce Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:36:19 +0900 Subject: [PATCH 15/36] =?UTF-8?q?feat(CAD):=20331-2=20=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=20=C2=B7=20=EB=B3=B5=EC=82=AC=20=C2=B7=20=ED=9A=8C=EC=A0=84=20?= =?UTF-8?q?=ED=82=A4=EB=B3=B4=EB=93=9C=20=EA=B0=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이동 · 복사 — 기준점 `x,y` · 둘째 점 거리 · `x,y` · `@x,y` · 각도 고정 · 미리보기도 같은 점 계산(getPointFromEvent) - 회전 — 기준점 `x,y` · 각도 숫자 바로(반시계 +) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../openwebcad/src/tools/copy-tool.ts | 43 ++++++++++++---- .../openwebcad/src/tools/move-tool.ts | 39 +++++++++++---- .../openwebcad/src/tools/rotate-tool.ts | 49 ++++++++++++++++--- resources/tester/cad/keyboard-chain.test.ts | 49 +++++++++++++++++++ 4 files changed, 154 insertions(+), 26 deletions(-) diff --git a/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts b/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts index 73b6811fb..25b7d8542 100644 --- a/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts @@ -10,7 +10,8 @@ import { setShouldDrawHelpers, } from '../state'; import { Tool } from '../tools'; -import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types'; +import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types'; +import { getPointFromEvent } from '../helpers/get-point-from-event'; import { assign, createMachine, sendTo } from 'xstate'; import { selectToolStateMachine } from './select-tool'; import type { Entity } from '../entities/Entity'; @@ -147,6 +148,10 @@ export const copyToolStateMachine = createMachine( actions: [CopyAction.RECORD_START_POINT, CopyAction.COPY_SELECTION_BEFORE_COPY], target: CopyState.WAITING_FOR_END_COPY_POINT, }, + ABSOLUTE_POINT_INPUT: { + actions: [CopyAction.RECORD_START_POINT, CopyAction.COPY_SELECTION_BEFORE_COPY], + target: CopyState.WAITING_FOR_END_COPY_POINT, + }, ESC: { actions: CopyAction.DESELECT_ENTITIES, target: CopyState.INIT, @@ -166,6 +171,18 @@ export const copyToolStateMachine = createMachine( actions: [CopyAction.COPY_SELECTION], target: CopyState.WAITING_FOR_END_COPY_POINT, }, + NUMBER_INPUT: { + actions: [CopyAction.COPY_SELECTION], + target: CopyState.WAITING_FOR_END_COPY_POINT, + }, + ABSOLUTE_POINT_INPUT: { + actions: [CopyAction.COPY_SELECTION], + target: CopyState.WAITING_FOR_END_COPY_POINT, + }, + RELATIVE_POINT_INPUT: { + actions: [CopyAction.COPY_SELECTION], + target: CopyState.WAITING_FOR_END_COPY_POINT, + }, ESC: { actions: CopyAction.DESELECT_ENTITIES, target: CopyState.INIT, @@ -191,10 +208,9 @@ export const copyToolStateMachine = createMachine( setShouldDrawHelpers(true); }, [CopyAction.RECORD_START_POINT]: assign(({ event }) => { - setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation); - return { - startPoint: (event as MouseClickEvent).worldMouseLocation, - }; + const startPoint = getPointFromEvent(null, event as PointInputEvent); + setAngleGuideOriginPoint(startPoint); + return { startPoint }; }), [CopyAction.COPY_SELECTION_BEFORE_COPY]: assign(({ context }) => { const selectedEntities = getSelectedEntities(); @@ -208,7 +224,9 @@ export const copyToolStateMachine = createMachine( return { startPoint: context.startPoint, // Make a copy of the selected entities before copying them, so we can restore them when the user cancels the copy action - originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity))), + originalSelectedEntities: compact( + selectedEntities.map((entity) => cloneKeepingLook(entity)) + ), copiedEntities: selectedEntities, }; }), @@ -217,11 +235,13 @@ export const copyToolStateMachine = createMachine( throw new Error('[COPY] Calling draw temp copy line without a start point'); } - const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation(); + const endPointTemp = getPointFromEvent(context.startPoint, event as DrawEvent); // Copy the entities to the new location // Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be copied; - const movedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity)); + const movedEntities = context.originalSelectedEntities.map((entity) => + cloneKeepingLook(entity) + ); moveEntities( movedEntities, endPointTemp.x - context.startPoint.x, @@ -245,8 +265,11 @@ export const copyToolStateMachine = createMachine( } // Copy the entities one final time - const currentEndPoint = (event as MouseClickEvent).worldMouseLocation; - const copiedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity)); + // 클릭 · 거리 · 절대 · 상대 좌표(키보드) + const currentEndPoint = getPointFromEvent(context.startPoint, event as PointInputEvent); + const copiedEntities = context.originalSelectedEntities.map((entity) => + cloneKeepingLook(entity) + ); moveEntities( copiedEntities, currentEndPoint.x - context.startPoint.x, diff --git a/B07_DesignDetail/openwebcad/src/tools/move-tool.ts b/B07_DesignDetail/openwebcad/src/tools/move-tool.ts index ba19c7073..a2fe3cbfd 100644 --- a/B07_DesignDetail/openwebcad/src/tools/move-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/move-tool.ts @@ -11,7 +11,8 @@ import { setShouldDrawHelpers, } from '../state'; import { Tool } from '../tools'; -import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types'; +import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types'; +import { getPointFromEvent } from '../helpers/get-point-from-event'; import { assign, createMachine, sendTo } from 'xstate'; import { selectToolStateMachine } from './select-tool'; import type { Entity } from '../entities/Entity'; @@ -148,6 +149,10 @@ export const moveToolStateMachine = createMachine( actions: [MoveAction.RECORD_START_POINT, MoveAction.COPY_SELECTION_BEFORE_MOVE], target: MoveState.WAITING_FOR_END_MOVE_POINT, }, + ABSOLUTE_POINT_INPUT: { + actions: [MoveAction.RECORD_START_POINT, MoveAction.COPY_SELECTION_BEFORE_MOVE], + target: MoveState.WAITING_FOR_END_MOVE_POINT, + }, ESC: { actions: MoveAction.DESELECT_ENTITIES, target: MoveState.INIT, @@ -167,6 +172,18 @@ export const moveToolStateMachine = createMachine( actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES], target: MoveState.WAITING_FOR_SELECTION, }, + NUMBER_INPUT: { + actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES], + target: MoveState.WAITING_FOR_SELECTION, + }, + ABSOLUTE_POINT_INPUT: { + actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES], + target: MoveState.WAITING_FOR_SELECTION, + }, + RELATIVE_POINT_INPUT: { + actions: [MoveAction.MOVE_SELECTION, MoveAction.DESELECT_ENTITIES], + target: MoveState.WAITING_FOR_SELECTION, + }, ESC: { actions: MoveAction.RESTORE_ORIGINAL_ENTITIES, target: MoveState.INIT, @@ -192,10 +209,9 @@ export const moveToolStateMachine = createMachine( setShouldDrawHelpers(true); }, [MoveAction.RECORD_START_POINT]: assign(({ event }) => { - setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation); - return { - startPoint: (event as MouseClickEvent).worldMouseLocation, - }; + const startPoint = getPointFromEvent(null, event as PointInputEvent); + setAngleGuideOriginPoint(startPoint); + return { startPoint }; }), [MoveAction.COPY_SELECTION_BEFORE_MOVE]: assign(({ context }) => { const selectedEntities = getSelectedEntities(); @@ -211,7 +227,9 @@ export const moveToolStateMachine = createMachine( return { startPoint: context.startPoint, // Make a copy of the selected entities before moving them, so we can restore them when the user cancels the move action - originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))), + originalSelectedEntities: compact( + selectedEntities.map((entity) => cloneKeepingLook(entity, true)) + ), movedEntities: selectedEntities, }; }), @@ -220,11 +238,13 @@ export const moveToolStateMachine = createMachine( throw new Error('[MOVE] Calling draw temp move line without a start point'); } - const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation(); + const endPointTemp = getPointFromEvent(context.startPoint, event as DrawEvent); // Move the entities to the new location // Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be moved; - const movedEntities = context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true)); + const movedEntities = context.originalSelectedEntities.map((entity) => + cloneKeepingLook(entity, true) + ); moveEntities( movedEntities, endPointTemp.x - context.startPoint.x, @@ -248,7 +268,8 @@ export const moveToolStateMachine = createMachine( } // Move the entities one final time - const currentEndPoint = (event as MouseClickEvent).worldMouseLocation; + // 클릭 · 거리 · 절대 · 상대 좌표(키보드) + const currentEndPoint = getPointFromEvent(context.startPoint, event as PointInputEvent); moveEntities( context.originalSelectedEntities, currentEndPoint.x - context.startPoint.x, diff --git a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts index 46888d7a5..c2930e017 100644 --- a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts @@ -10,7 +10,15 @@ import { setShouldDrawHelpers, } from '../state'; import { Tool } from '../tools'; -import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types'; +import type { + DrawEvent, + MouseClickEvent, + NumberInputEvent, + PointInputEvent, + StateEvent, + ToolContext, +} from './tool.types'; +import { getPointFromEvent } from '../helpers/get-point-from-event'; import { assign, createMachine, sendTo } from 'xstate'; import { selectToolStateMachine } from './select-tool'; import type { Entity } from '../entities/Entity'; @@ -41,6 +49,7 @@ export enum RotateAction { COPY_SELECTION_BEFORE_ROTATE = 'COPY_SELECTION_BEFORE_ROTATE', DRAW_TEMP_ROTATE_ENTITIES = 'DRAW_TEMP_ROTATE_ENTITIES', ROTATE_SELECTION = 'ROTATE_SELECTION', + ROTATE_BY_ANGLE = 'ROTATE_BY_ANGLE', DESELECT_ENTITIES = 'DESELECT_ENTITIES', RESTORE_ORIGINAL_ENTITIES = 'RESTORE_ORIGINAL_ENTITIES', } @@ -147,6 +156,10 @@ export const rotateToolStateMachine = createMachine( actions: [RotateAction.RECORD_ROTATION_ORIGIN], target: RotateState.WAITING_FOR_ANGLE_START_POINT, }, + ABSOLUTE_POINT_INPUT: { + actions: [RotateAction.RECORD_ROTATION_ORIGIN], + target: RotateState.WAITING_FOR_ANGLE_START_POINT, + }, ESC: { actions: RotateAction.DESELECT_ENTITIES, target: RotateState.INIT, @@ -166,6 +179,15 @@ export const rotateToolStateMachine = createMachine( ], target: RotateState.WAITING_FOR_ANGLE_END_POINT, }, + NUMBER_INPUT: { + // 각도(도) 바로 — 반시계 + + actions: [ + RotateAction.COPY_SELECTION_BEFORE_ROTATE, + RotateAction.ROTATE_BY_ANGLE, + RotateAction.DESELECT_ENTITIES, + ], + target: RotateState.WAITING_FOR_SELECTION, + }, ESC: { actions: RotateAction.RESTORE_ORIGINAL_ENTITIES, target: RotateState.INIT, @@ -204,11 +226,9 @@ export const rotateToolStateMachine = createMachine( setShouldDrawHelpers(true); }, [RotateAction.RECORD_ROTATION_ORIGIN]: assign(({ context, event }): RotateContext => { - setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation); - return { - ...context, - rotationOrigin: (event as MouseClickEvent).worldMouseLocation, - }; + const rotationOrigin = getPointFromEvent(null, event as PointInputEvent); + setAngleGuideOriginPoint(rotationOrigin); + return { ...context, rotationOrigin }; }), [RotateAction.RECORD_ROTATION_ANGLE_START_POINT]: assign( ({ context, event }): RotateContext => { @@ -232,7 +252,9 @@ export const rotateToolStateMachine = createMachine( return { ...context, // Make a copy of the selected entities before rotating them, so we can restore them when the user cancels the rotate action - originalSelectedEntities: compact(selectedEntities.map((entity) => cloneKeepingLook(entity, true))), + originalSelectedEntities: compact( + selectedEntities.map((entity) => cloneKeepingLook(entity, true)) + ), }; }), [RotateAction.DRAW_TEMP_ROTATE_ENTITIES]: ({ context, event }) => { @@ -279,6 +301,19 @@ export const rotateToolStateMachine = createMachine( setGhostHelperEntities([]); setSelectedEntityIds([]); }, + [RotateAction.ROTATE_BY_ANGLE]: ({ context, event }) => { + if (!context.rotationOrigin) { + throw new Error('[ROTATE] Calling rotate by angle without a rotation origin'); + } + const radians = ((event as NumberInputEvent).value * Math.PI) / 180; + const rotatedEntities = compact( + context.originalSelectedEntities.map((entity) => cloneKeepingLook(entity, true)) + ); + for (const entity of rotatedEntities) entity.rotate(context.rotationOrigin, radians); + addEntities(rotatedEntities, true); + setGhostHelperEntities([]); + setSelectedEntityIds([]); + }, [RotateAction.DESELECT_ENTITIES]: assign(({ context }): RotateContext => { setGhostHelperEntities([]); setSelectedEntityIds([]); diff --git a/resources/tester/cad/keyboard-chain.test.ts b/resources/tester/cad/keyboard-chain.test.ts index be66f34d4..af5d1a5d6 100644 --- a/resources/tester/cad/keyboard-chain.test.ts +++ b/resources/tester/cad/keyboard-chain.test.ts @@ -148,3 +148,52 @@ describe("331-1 각도 고정 → 길이", () => { expect(getActiveToolActor()?.getSnapshot()?.context.values).toHaveLength(2); }); }); + +/** 선 (x1,y1)-(x2,y2) 하나를 키보드로 그리고 고름 */ +const drawAndSelect = (x1: number, y1: number, x2: number, y2: number) => { + type(controller, "l"); + type(controller, `${x1},${y1}`); + type(controller, `${x2},${y2}`); + key(controller, "Escape"); + key(controller, "Escape"); + setSelectedEntityIds(getEntities().map((entity) => entity.id)); +}; + +describe("331-2 이동 · 복사 · 회전 키보드 값", () => { + it("MOVE · 0,0 · @10,0 → 오른쪽 10", () => { + drawAndSelect(0, 0, 20, 0); + type(controller, "move"); + type(controller, "0,0"); + type(controller, "@10,0"); + const drawn = lines(); + expect(drawn).toHaveLength(1); + expectXy(drawn[0].start, 10, 0); + expectXy(drawn[0].end, 30, 0); + }); + + it("COPY · 0,0 · <0 · 5 → 원본 + 오른쪽 5", () => { + drawAndSelect(0, 0, 20, 0); + type(controller, "copy"); + type(controller, "0,0"); + type(controller, "<0"); + type(controller, "5"); + const starts = lines() + .map((segment) => segment.start.x) + .sort((a, b) => a - b); + expect(starts).toHaveLength(2); + expect(starts[0]).toBeCloseTo(0, 3); + expect(starts[1]).toBeCloseTo(5, 3); + }); + + it("ROTATE · 0,0 · 90 → 반시계 90°", () => { + drawAndSelect(10, 0, 20, 0); + type(controller, "rotate"); + type(controller, "0,0"); + type(controller, "90"); + const drawn = lines(); + expect(drawn).toHaveLength(1); + expectXy(drawn[0].start, 0, 10); + expectXy(drawn[0].end, 0, 20); + expect(toast.error).not.toHaveBeenCalled(); + }); +}); From be86e3ee2c97c25d2b434cec532b43e50ede1f7b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:37:17 +0900 Subject: [PATCH 16/36] =?UTF-8?q?feat(=EB=85=B8=EC=84=A0=EC=B6=94=EC=B2=9C?= =?UTF-8?q?):=20327-2=20=EB=AF=B8=EB=A6=AC=20=EC=85=88=20=C2=B7=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=E2=80=94=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B2=B4=EC=9D=B8=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EB=92=A4=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EB=8B=A4=EC=84=AF=20=EC=95=8C=EC=95=BD=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=C2=B7=20=EC=B6=94=EC=B2=9C=20=EC=B0=BD=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=A4=84=20=E3=80=8C=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=90=9C=20=EA=B2=B0=EA=B3=BC=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B03 자동 설계 체인: 초기값 스냅샷 뒤 precompute_defaults 예약(기다리지 않음 · 실패는 비치명적) - 추천 창: 서버 stored 를 받아 미리 보기 줄에 「(저장된 결과)」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL --- B03_FileInput/B03_FileInput_Service_Chain.py | 12 ++++++++++++ .../B05_Profile_UI_RouteEdit_Initial_Model.ts | 3 +++ B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts | 4 +++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/B03_FileInput/B03_FileInput_Service_Chain.py b/B03_FileInput/B03_FileInput_Service_Chain.py index 703c27d4e..90791b126 100644 --- a/B03_FileInput/B03_FileInput_Service_Chain.py +++ b/B03_FileInput/B03_FileInput_Service_Chain.py @@ -350,6 +350,18 @@ async def run_auto_design_chain( logger.exception("초기값 스냅샷 실패: project_id=%s", project_id) mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}") + # 노선 추천 기본 다섯 알약 미리 셈(PLAN 327-2) — 예약만 · 체인이 [초기화] 요청 안에서도 + # 불리므로 기다리지 않는다. 같은 입력이면 저장본이 있어 셈 없이 끝난다. + try: + from B03_FileInput.B03_FileInput_Router_Helpers import _schedule_background_task + from B05_Profile.B05_Profile_Router_RouteInitial import precompute_defaults + + _schedule_background_task( + precompute_defaults(project_id), task_name=f"추천 노선 미리 셈 {project_id}" + ) + except Exception: # noqa: BLE001 — 미리 셈이 체인을 막지는 않는다 + logger.exception("추천 노선 미리 셈 예약 실패: project_id=%s", project_id) + return { "route_id": route_id, "length_m": float(solve_result.total_length_m or 0.0), diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index 6e63790ff..7199fa9b8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -189,6 +189,8 @@ export interface InitialChoice { similar?: SimilarRoute[] | null; /** 제약 안에서 갈 수 있는 길의 띠 폭 어림(m) — 서버 `band_width_m`. */ bandWidthM?: number | null; + /** 서버 추천 저장본에서 꺼낸 결과인가(계획서 327) — 서버 `stored`. */ + stored?: boolean; /** 균형 종단(계획서 63-4) — 모달 미리 보기에만. 캐시에는 안 적는다(`writeInitialChoice`). */ balance?: BalanceProfile | null; } @@ -336,6 +338,7 @@ export function normalizeRouteInitial( terrainBoundPct: body.relaxed === true ? num(body.terrain_bound_pct) : null, similar: parseSimilar(body.similar), bandWidthM: num(body.band_width_m), + stored: body.stored === true, balance: parseBalance(m.profile), criteria: body.criteria ? { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index 6d8a4f854..d42d94aef 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -462,7 +462,9 @@ export function openRecommendModal(params: RecommendParams): void { preview = choice; if (choice.balance) seen.set(label, choice.balance); else seen.delete(label); // 종단 없는 갈래(예상노선 추적) - stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)} — [적용]을 눌러야 편집 창에 놓입니다.`; + stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)}${ + choice.stored ? " (저장된 결과)" : "" + } — [적용]을 눌러야 편집 창에 놓입니다.`; } catch (error) { if (closed || mine !== seq) return; stateEl.textContent = "셈하지 못했습니다."; From 27c62ce22094358319bdf3e89e8e780af85430d4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:37:18 +0900 Subject: [PATCH 17/36] =?UTF-8?q?feat(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4):=20329-1=20=C2=B7=20330-1=20=C2=B7=20330-2?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=E2=80=94=20=EC=97=91=EC=85=80=EA=BC=B4?= =?UTF-8?q?=20=EA=B2=A9=EC=9E=90=20=C2=B7=20=ED=96=89=20=EB=B2=94=EC=9C=84?= =?UTF-8?q?=20=C2=B7=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 329-1 새 M02_Hopyo_Grid(xls formatting_info 값 · 병합 · 열 폭 · 행 높이 · 서식표 · 틀고정 / xlsx 값만) · read_book 호표 · 줄 「행」 · Store 격자 저장 · 읽기 · 지움(실무자료/격자/<키>.json) · GET hopyo-grid/{key}(옛 파일 404) · Match 요약 .get - 330-1 새 M02_Hopyo_Basis.read — 단산N 블록 이름 · 단위 · 행 · 출처(책 · 절) · 직종(우리 인력 이름 · 옛이름) · 금액 · 단산 줄 「근거」 - 330-2 Basis.ours · candidates(조항 1.0 · 앞 마디 0.5 · 건설 공통 · 토목 0.1 · 직종 0.5 · 단위 0.15 · 까닭) · match_line 이름 후보와 로직 키로 합침 · 상태 후보 그대로 - 시험 test_m02_hopyo_grid · test_m02_hopyo_basis 새로 · test_m02_hopyo_match 제3호표 구조물터파기 1순위 → 굴착(인력/토사) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- M02_MasterTemplete/M02_Hopyo_Basis.py | 231 +++++++++++++++++++++++ M02_MasterTemplete/M02_Hopyo_Excel.py | 26 ++- M02_MasterTemplete/M02_Hopyo_Grid.py | 211 +++++++++++++++++++++ M02_MasterTemplete/M02_Hopyo_Match.py | 19 +- M02_MasterTemplete/M02_Hopyo_Store.py | 30 ++- M02_MasterTemplete/M02_Labor_Router.py | 19 +- resources/tester/test_m02_hopyo_basis.py | 108 +++++++++++ resources/tester/test_m02_hopyo_grid.py | 120 ++++++++++++ resources/tester/test_m02_hopyo_match.py | 2 +- 9 files changed, 748 insertions(+), 18 deletions(-) create mode 100644 M02_MasterTemplete/M02_Hopyo_Basis.py create mode 100644 M02_MasterTemplete/M02_Hopyo_Grid.py create mode 100644 resources/tester/test_m02_hopyo_basis.py create mode 100644 resources/tester/test_m02_hopyo_grid.py diff --git a/M02_MasterTemplete/M02_Hopyo_Basis.py b/M02_MasterTemplete/M02_Hopyo_Basis.py new file mode 100644 index 000000000..e4fcf4458 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Basis.py @@ -0,0 +1,231 @@ +"""M02 일위대가 실무자료 단가산출 근거(PLAN 330) — 엑셀 「단가산출」 블록 → 출처 · 직종 · +우리 돈 로직과 견준 근거 후보. + +읽기(`read`) = B열(머리 「산출근거」) 머리 줄 「N.이름[단위]」(앞 빈칸 없음)부터 +다음 머리 앞까지 한 블록 · +출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 · +그 밖 = 건설품셈 · +직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로). +견줌(`candidates`) = 같은 책 조항 같음 1.0 · 앞 마디 같음 0.5(조항이 안 맞으면 후보 아님) · +건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 · +같은 점수는 글자 두 개 묶음(괄호 글 포함)이 가름. 자동 확정 없음 — 맞춤 차례는 `M02_Hopyo_Match`. +M01 · 원본 로직은 읽기만. +""" + +from __future__ import annotations + +import re +from functools import lru_cache +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match # 서로 부름 — 부를 때만 씀 +from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs + +SHEET = "단가산출" +COMMON = "보통인부" +BONUS_PARTS = ("공통", "토목") +_HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$") +_BOOK = re.compile(r"품[셈줌]") +_TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]") +_TYPOS = (("깍기", "깎기"),) +_MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"} + + +def _flat(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")) + + +# ── 엑셀 블록 읽기 ───────────────────────────────────────────────────── +@lru_cache(maxsize=2) +def _trade_names(whole_id: int) -> dict[str, str]: + """{빈칸 뺀 이름 · 옛이름: 지금 이름} — 우리 인력(LB).""" + out: dict[str, str] = {} + for row in m01.loaded()[1].numbers.get("LB", {}).values(): + name = str(row.get("이름") or "") + out[_flat(name)] = name + for old in row.get("옛이름") or []: + out.setdefault(_flat(old), name) + return out + + +def trade_names() -> dict[str, str]: + return _trade_names(id(m01.loaded()[1])) + + +def sources(text: str) -> list[list[str]]: + """글 → [[책, 절 번호]] — 「건설공사표준품셈 3-2-1」 · 「산림사업표준품셈(9-14)」.""" + out = [] + for hit in _BOOK.finditer(text): + num = labor_inputs._NUMBER.search(text, hit.end()) + if not num or num.start() - hit.end() > 20: + continue + before = text[max(0, hit.start() - 12) : hit.start()] + book = "산림품셈" if "산림" in before or "임도" in before else "건설품셈" + if [book, num.group()] not in out: + out.append([book, num.group()]) + return out + + +def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]: + """(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {}).""" + for at, row in enumerate(rows[:10]): + flat = [_flat(v) for v in row] + if "산출근거" in flat: + money = {key: flat.index(head) for head, key in _MONEY.items() if head in flat} + return at + 1, flat.index("산출근거"), money + return 0, 1, {} + + +def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]: + """단가산출 시트 줄 → `{N: {번, 이름, 단위, 행: [시작, 끝], 출처, 직종, 금액}}` + (행 = 엑셀 1부터).""" + if not rows: + return {} + start, col, money = _columns(rows) + names = trade_names() + blocks: dict[int, dict[str, Any]] = {} + block: dict[str, Any] | None = None + for at, row in enumerate(rows[start:], start + 1): + text = row[col] if col < len(row) else None + if not isinstance(text, str) or not text.strip(): + continue + head = None if text[:1].isspace() else _HEAD.match(text) + if head: + block = blocks.setdefault( + int(head.group(1)), + { + "번": int(head.group(1)), + "이름": head.group(2), + "단위": head.group(3).strip(), + "행": [at, at], + "출처": [], + "직종": [], + "금액": { + key: v + for key, c in money.items() + for v in [row[c] if c < len(row) else None] + if isinstance(v, (int, float)) and not isinstance(v, bool) + }, + }, + ) + continue + if block is None: + continue + block["행"][1] = at + block["출처"] += [one for one in sources(text) if one not in block["출처"]] + trade = _TRADE.match(text) + name = names.get(_flat(trade.group(1))) if trade else None + if name and name not in block["직종"]: + block["직종"].append(name) + return blocks + + +# ── 우리 쪽 조항 · 직종 ──────────────────────────────────────────────── +@lru_cache(maxsize=2) +def _ours(whole_id: int) -> dict[str, dict[str, Any]]: + """{로직 키: {책, 부문, 구분, 조항: {절 번호}, 직종: {이름}}} — 조항 = `sources()` 묶음 · + 직종 = 호표 인력(LB) 요소.""" + files, whole = m01.loaded() + lb = {str(row.get("키")): str(row.get("이름")) for row in whole.numbers.get("LB", {}).values()} + out = {} + for data in files.values(): + if data.get("그룹") != "로직": + continue + for row in data.get("줄") or []: + kind = str(row.get("구분") or "") + book, _, part = kind.partition(" ") + trades = { + lb[str(h.get("요소"))] + for h in row.get("호표") or [] + if isinstance(h, dict) and str(h.get("요소")) in lb + } + clauses = set(labor_inputs._NUMBER.findall(str(labor_inputs.sources(row) or ""))) + out[str(row.get("키"))] = { + "책": book, + "부문": part, + "구분": kind, + "조항": clauses, + "직종": trades, + } + return out + + +def ours() -> dict[str, dict[str, Any]]: + return _ours(id(m01.loaded()[1])) + + +def _head(number: str) -> str: + return number.rsplit("-", 1)[0] + + +def _clause(theirs: list[list[str]], our: dict[str, Any]) -> tuple[float, list[str], str]: + """(점수, 맞은 우리 조항 글, 꼬리표) — 같은 책만.""" + nums = [n for book, n in theirs if book == our["책"]] + same = sorted(n for n in nums if n in our["조항"]) + if same: + return 1.0, [f"{our['구분']} {n}" for n in same], "조항 같음" + heads = {_head(n) for n in nums} + near = sorted(n for n in our["조항"] if _head(n) in heads) + if near: + return 0.5, [f"{our['구분']} {n}" for n in near], "앞 마디 같음" + return 0.0, [], "" + + +def _text(line: dict[str, Any]) -> str: + text = f"{line.get('이름') or ''} {line.get('규격') or ''}" + for old, new in _TYPOS: + text = text.replace(old, new) + return text + + +def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any]]: + """근거 후보 — 줄 `근거.출처` 가 없으면 빈 것 · `{로직, 이름, 단위, 점수, 근거}` 점수 차례.""" + basis = line.get("근거") or {} + theirs = [list(one) for one in basis.get("출처") or []] + if not theirs: + return [] + trades = set(basis.get("직종") or []) - {COMMON} + text = _text(line) + mine = hopyo_match.words(text) # 괄호 글 뺌 — 「잔토처리(인력)」 ↔ 「철골재 철거(인력)」 + grams = hopyo_match._grams(hopyo_match.mm.plain(text)) # 괄호 글 포함 — 같은 점수 가름 + table = ours() + scored = [] + for one in got["로직"]: + our = table.get(one["로직"]) + if not our: + continue + score, clauses, tag = _clause(theirs, our) + if not score: + continue + shared = sorted(trades & our["직종"]) + if not shared and not any(a in b or b in a for a in mine for b in one["words"]): + continue + tags = [tag] + if our["책"] == "건설품셈" and our["부문"] in BONUS_PARTS: + score += 0.1 + tags.append(f"건설 {our['부문']}") + if shared: + score += 0.5 + tags.append("직종 " + " · ".join(shared)) + unit = hopyo_match.fits(line.get("단위"), one["단위"]) + if unit: + score += 0.15 + tags.append("단위 맞음") + dice = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"])) + why = {"조항": clauses, "직종": shared, "단위": unit, "점수": round(score, 3)} + scored.append( + (score, dice, {**hopyo_match._public(one), "근거": {**why, "까닭": " · ".join(tags)}}) + ) + scored.sort(key=lambda x: (-x[0], -x[1])) + return [one for _, _, one in scored] + + +def merge(basis: list[dict[str, Any]], named: list[dict[str, Any]]) -> list[dict[str, Any]]: + """근거 후보 + 이름 후보 → 로직 키로 합침(점수 = 이름 점수 + 근거 점수) · 점수 차례.""" + if not basis: + return named + by_name = {c.get("로직"): c["점수"] for c in named} + out = [{**c, "점수": round(c["근거"]["점수"] + by_name.pop(c["로직"], 0), 3)} for c in basis] + out += [c for c in named if c.get("로직") in by_name] + return sorted(out, key=lambda c: -c["점수"]) diff --git a/M02_MasterTemplete/M02_Hopyo_Excel.py b/M02_MasterTemplete/M02_Hopyo_Excel.py index 38d11a81e..9995de2b4 100644 --- a/M02_MasterTemplete/M02_Hopyo_Excel.py +++ b/M02_MasterTemplete/M02_Hopyo_Excel.py @@ -11,6 +11,9 @@ 검산 = 줄 금액 floor(단가 × 수량 × 10) / 10 · 합계 단가 = 셋 합 · % 줄 = floor(바탕 × 율 / 100 × 10) / 10 · 호표 비목 = floor(줄 금액 합) · 총괄표 합계 — 어긋남은 글 목록(막지 않음). +행(329-1) = 호표 [제N호표 줄, 마지막 구성 줄] · 구성 줄 한 줄(엑셀 1부터) · +근거(330-1) = 단산N참조 줄에 「단가산출」 블록 `{출처, 직종, 행}`(`M02_Hopyo_Basis.read`) · +`단산` = 블록마다 `{번, 이름, 단위, 행}`. """ from __future__ import annotations @@ -20,6 +23,8 @@ import re from decimal import ROUND_FLOOR, Decimal, InvalidOperation from typing import Any +from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis + SHEET = "일위대가" SUMMARY = "일위대가총괄표" COSTS = ("노무비", "재료비", "경비") @@ -67,8 +72,8 @@ def _out(value: Decimal | None) -> int | float | None: # ── 파일 → 시트 표(줄마다 값 목록) ──────────────────────────────────────── def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]: - """시트 이름 → 줄 목록 — 일위대가 · 총괄표만 읽음.""" - wanted = (SHEET, SUMMARY) + """시트 이름 → 줄 목록 — 일위대가 · 총괄표 · 단가산출만 읽음.""" + wanted = (SHEET, SUMMARY, hopyo_basis.SHEET) if not data: raise ExcelError("빈 파일") try: @@ -212,7 +217,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: start, cols = _columns(rows, ("합계", *COSTS)) hopyos: list[dict[str, Any]] = [] raw: list[list[dict[str, Any]]] = [] - for row in rows[start:]: + for at, row in enumerate(rows[start:], start + 1): head = _flat(_cell(row, cols.get("호표"))) name_ = _text(_cell(row, cols.get("품명"))) hit = _HOPYO.fullmatch(head) @@ -224,6 +229,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: "규격": _text(_cell(row, cols.get("규격"))), "단위": _text(_cell(row, cols.get("단위"))), "금액": _money(row, cols, 1), + "행": [at, at], } ) raw.append([]) @@ -244,9 +250,15 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: "참조": {"종류": ref.group(1), "번": int(ref.group(2))} if ref else None, } line["갈래"] = kind_of(line) + line["행"] = hopyos[-1]["행"][1] = at raw[-1].append(line) if not hopyos: raise ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음") + blocks = hopyo_basis.read(sheets.get(hopyo_basis.SHEET)) + for line in (one for lines in raw for one in lines): + ref = line["참조"] or {} + if ref.get("종류") == "단산" and (block := blocks.get(ref["번"])): + line["근거"] = {k: block[k] for k in ("출처", "직종", "행")} wrong: list[str] = [] totals = _summary(sheets.get(SUMMARY)) for hopyo, lines in zip(hopyos, raw, strict=True): @@ -258,7 +270,13 @@ def read_book(name: str, data: bytes) -> dict[str, Any]: {**_json(hopyo), "줄": [_json(line) for line in lines]} for hopyo, lines in zip(hopyos, raw, strict=True) ] - return {"시트": SHEET, "호표": out, "검산": {"어긋남": wrong, "총괄표": len(totals)}} + basis = [{k: b[k] for k in ("번", "이름", "단위", "행")} for b in blocks.values()] + return { + "시트": SHEET, + "호표": out, + "단산": basis, + "검산": {"어긋남": wrong, "총괄표": len(totals)}, + } def _json(value: Any) -> Any: diff --git a/M02_MasterTemplete/M02_Hopyo_Grid.py b/M02_MasterTemplete/M02_Hopyo_Grid.py new file mode 100644 index 000000000..39af897fa --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Grid.py @@ -0,0 +1,211 @@ +"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트 → +A00 산출근거 스프레드시트 Workbook(`A00_Common/spreadsheet/spreadsheet_types.ts` 꼴 · +읽기 전용 보기). + +xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) · +행 높이(÷20 pt · 시트 기본과 다른 것만) · 서식 표(글꼴 · 굵게 · 맞춤 · 줄바꿈 · 테두리 · 채움 · +숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행). +xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지. +결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 1부터 [시작, 끝]. +""" + +from __future__ import annotations + +import io +from typing import Any + +from openpyxl.utils import get_column_letter + +from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis +from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel + +SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET) +HEAD_ROWS = 4 +_HALIGN = {1: "left", 2: "center", 3: "right", 6: "centerContinuous", 7: "distributed"} +_VALIGN = {0: "top", 1: "center"} +_LINES = {1: "thin", 2: "medium", 3: "dashed", 4: "dotted", 5: "thick", 6: "double", 7: "hair"} +_SIDES = (("위", "top"), ("아래", "bottom"), ("왼", "left"), ("오른", "right")) + + +class _Styles: + """서식 표 — 같은 서식은 한 번만 · [0] = 기본 `{}`.""" + + def __init__(self) -> None: + self.table: list[dict[str, Any]] = [{}] + self._index: dict[str, int] = {"{}": 0} + + def add(self, style: dict[str, Any]) -> int: + key = repr(sorted(style.items())) + if not style: + return 0 + if key not in self._index: + self._index[key] = len(self.table) + self.table.append(style) + return self._index[key] + + +def _rgb(book: Any, index: int) -> str | None: + got = book.colour_map.get(index) + return "#{:02X}{:02X}{:02X}".format(*got) if got else None + + +def _xf_style(book: Any, xf: Any, base: Any) -> dict[str, Any]: + font = book.font_list[xf.font_index] + out: dict[str, Any] = {} + if font.name != base.name: + out["글꼴"] = font.name + if font.height != base.height: + out["크기"] = font.height / 20 + for key, on in ( + ("굵게", font.bold), + ("기울임", font.italic), + ("밑줄", font.underline_type), + ("취소선", font.struck_out), + ): + if on: + out[key] = True + color = _rgb(book, font.colour_index) + if color and color != "#000000": + out["글자색"] = color + if xf.background.fill_pattern == 1 and (fill := _rgb(book, xf.background.pattern_colour_index)): + out["채움"] = fill + align = xf.alignment + if align.hor_align in _HALIGN: + out["가로"] = _HALIGN[align.hor_align] + if align.vert_align in _VALIGN: + out["세로"] = _VALIGN[align.vert_align] + if align.text_wrapped: + out["줄바꿈"] = True + sides = {} + for name, side in _SIDES: + line = getattr(xf.border, f"{side}_line_style") + if line: + one = {"선": _LINES.get(line, "dashed")} + color = _rgb(book, getattr(xf.border, f"{side}_colour_index")) + if color and color != "#000000": + one["색"] = color + sides[name] = one + if sides: + out["테두리"] = sides + fmt = book.format_map.get(xf.format_key) + if fmt and fmt.format_str and fmt.format_str != "General": + out["형식"] = fmt.format_str + return out + + +def _value(cell: Any) -> Any: + import xlrd + + if cell.ctype == xlrd.XL_CELL_TEXT: + return cell.value or None + if cell.ctype in (xlrd.XL_CELL_NUMBER, xlrd.XL_CELL_DATE): + return int(cell.value) if cell.value == int(cell.value) else cell.value + if cell.ctype == xlrd.XL_CELL_BOOLEAN: + return bool(cell.value) + if cell.ctype == xlrd.XL_CELL_ERROR: + return xlrd.error_text_from_code.get(cell.value, "#N/A") + return None + + +def _xls_sheet(book: Any, sh: Any, styles: _Styles, sid: str) -> dict[str, Any]: + base = book.font_list[0] + by_xf: dict[int, int] = {} + cells: dict[str, dict[str, Any]] = {} + for r in range(sh.nrows): + for c in range(sh.ncols): + cell = sh.cell(r, c) + if cell.xf_index not in by_xf: + by_xf[cell.xf_index] = styles.add( + _xf_style(book, book.xf_list[cell.xf_index], base) + ) + one: dict[str, Any] = {} + if (value := _value(cell)) is not None: + one["값"] = value + if by_xf[cell.xf_index]: + one["서식"] = by_xf[cell.xf_index] + if one: + cells[f"{get_column_letter(c + 1)}{r + 1}"] = one + merged = [ + f"{get_column_letter(c0 + 1)}{r0 + 1}:{get_column_letter(c1)}{r1}" + for r0, r1, c0, c1 in sh.merged_cells + ] + cols = { + get_column_letter(c + 1): { + "폭": round(info.width / 256, 2), + **({"숨김": True} if info.hidden else {}), + } + for c, info in sh.colinfo_map.items() + if c < sh.ncols + } + rows = { + str(r + 1): {"높이": info.height / 20, **({"숨김": True} if info.hidden else {})} + for r, info in sh.rowinfo_map.items() + if r < sh.nrows and (info.height != sh.default_row_height or info.hidden) + } + frozen = sh.panes_are_frozen and (sh.horz_split_pos or sh.vert_split_pos) + freeze = {"행": sh.horz_split_pos, "열": sh.vert_split_pos} if frozen else {"행": HEAD_ROWS} + out = { + "id": sid, + "이름": sh.name.strip(), + "칸": cells, + "병합": merged, + "열": cols, + "행": rows, + "틀고정": {k: v for k, v in freeze.items() if v}, + } + if sh.default_row_height: + out["기본"] = {"행높이": sh.default_row_height / 20} + return out + + +def _xls(data: bytes) -> dict[str, Any]: + import xlrd + + book = xlrd.open_workbook(file_contents=data, formatting_info=True, logfile=io.StringIO()) + styles = _Styles() + found = {sh.name.strip(): sh for sh in book.sheets()} + sheets = [ + _xls_sheet(book, found[name], styles, f"s{at}") + for at, name in enumerate((n for n in SHEETS if n in found), 1) + ] + font = book.font_list[0] + return { + "기본": {"글꼴": font.name, "크기": font.height / 20}, + "서식": styles.table, + "시트": sheets, + } + + +def _xlsx(name: str, data: bytes) -> dict[str, Any]: + # ponytail: xlsx 는 값만(병합 · 폭 · 서식은 openpyxl 비 read_only 로 무거움) — 필요하면 그때 + found = hopyo_excel._sheets(name, data) + sheets = [] + for at, title in enumerate((n for n in SHEETS if n in found), 1): + rows = found[title] + width = max( + (c + 1 for row in rows[:HEAD_ROWS] for c, v in enumerate(row) if v not in (None, "")), + default=0, + ) + cells = { + f"{get_column_letter(c + 1)}{r + 1}": { + "값": v if isinstance(v, (int, float, bool)) else str(v) + } + for r, row in enumerate(rows) + for c, v in enumerate(row[:width]) + if v not in (None, "") + } + sheets.append({"id": f"s{at}", "이름": title, "칸": cells, "틀고정": {"행": HEAD_ROWS}}) + return {"서식": [{}], "시트": sheets} + + +def read(name: str, data: bytes, parsed: dict[str, Any]) -> dict[str, Any]: + """엑셀 → `{책: Workbook, 호표, 단산}` — `parsed` = `M02_Hopyo_Excel.read_book` 결과 + (행 범위).""" + xlsx = data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx") + book = _xlsx(name, data) if xlsx else _xls(data) + keep = ("번", "이름", "행") + return { + "책": {"종류": "통합문서", "판": 1, "열": "", **book, "활성": "s1"}, + "호표": [{k: h.get(k) for k in keep} for h in parsed.get("호표") or []], + "단산": [{k: b.get(k) for k in keep} for b in parsed.get("단산") or []], + } diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index dc894c8a4..c9ccbc557 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -4,6 +4,8 @@ 씨앗(일위대가조합.json 메모 · 읽기만) ③ 정리 이름 같음(괄호 · 기계 낱말) ④ 후보 5(글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 · 뜻 낱말이 하나도 안 겹치면 뺌 — 「잔토처리」 ↔ 「소금처리」) — ++ 근거 후보(330-2 · `M02_Hopyo_Basis` · 단산 줄 출처 · 직종 · 이름 거름 안 거침 · +로직 키로 합쳐 점수 더함) · ③ ④ 는 사용자 확정(층 기억) 뒤에만 들어감. 줄 갈래: 단가산출 · 기계 · 기타 = 로직 / 자재 · 별도계상 = 자재(M01 자재품목) / 호표 = 그 호표 / % = 안 들어감(로직 덧줄이 셈). @@ -27,6 +29,7 @@ from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Labor_Ref as refs @@ -295,9 +298,10 @@ def _exact(line: dict[str, Any], pool: str, got: dict[str, Any]) -> list[dict[st def candidates( - line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int = 5 + line: dict[str, Any], pool: str, got: dict[str, Any] | None = None, limit: int | None = 5 ) -> list[dict[str, Any]]: - """③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit`.""" + """③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit` + (None = 모두).""" me = tidy(line.get("이름")) grams, unit = _grams(me), line.get("단위") text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}") @@ -402,11 +406,9 @@ def match_line( return out top = [{**_public(x), "점수": 9} for x in found] same = [_public(x) for x in found] - rest = [ - c - for c in candidates(line, pool, got) - if {k: v for k, v in c.items() if k != "점수"} not in same - ] + basis = hopyo_basis.candidates(line, got) if pool == "로직" else [] + named = candidates(line, pool, got, None if basis else 5) + rest = [c for c in hopyo_basis.merge(basis, named) if _public(c) not in same] out["후보"] = (top + rest)[:5] return {**out, "상태": "후보" if out["후보"] else "없음"} @@ -461,7 +463,8 @@ def view(folder: Path, key: str, no: int | None = None) -> dict[str, Any]: if no is None: got = targets() hopyos = [match_hopyo(h, memo, units, False, got) for h in doc.get("호표") or []] - brief = [{k: h[k] for k in ("번", "이름", "규격", "단위", "금액", "맞춤")} for h in hopyos] + keep = ("번", "이름", "규격", "단위", "금액", "맞춤") + brief = [{k: h.get(k) for k in keep} for h in hopyos] ready = [sum(h["맞춤"][0] for h in hopyos), sum(h["맞춤"][1] for h in hopyos)] return {**hopyo_store.brief(doc), "검산": doc.get("검산"), "호표": brief, "맞춤": ready} hopyo = next((h for h in doc.get("호표") or [] if int(h["번"]) == int(no)), None) diff --git a/M02_MasterTemplete/M02_Hopyo_Store.py b/M02_MasterTemplete/M02_Hopyo_Store.py index c632a409d..b82ad7b51 100644 --- a/M02_MasterTemplete/M02_Hopyo_Store.py +++ b/M02_MasterTemplete/M02_Hopyo_Store.py @@ -1,5 +1,5 @@ """M02 일위대가 실무자료 저장(PLAN 324-1) — 층 폴더 `실무자료/<키>.json` · -맞춤 기억 `실무자료/맞춤.json`. +맞춤 기억 `실무자료/맞춤.json` · 엑셀꼴 격자 `실무자료/격자/<키>.json`(329-1 · 파일과 같이 지움). 파일 = 읽은 결과만(원본 엑셀 안 둠) `{키, 파일, 올린때, 올린이, 시트, 호표, 검산}` · 키 `HP-######` (층마다 다음 번호) · 맞춤 기억 = `{줄: {줄 열쇠: {로직?|자재?|빼기?, 입력값, 환산, 확정}}}`. @@ -20,6 +20,8 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store DIRNAME = "실무자료" MEMORY = "맞춤" +GRID = "격자" +NO_GRID = "격자 없음 — 다시 올리면 보임" _KEY = re.compile(r"^HP-(\d{6})$") # ponytail: 프로세스 안 잠금 — 서버를 여럿 띄우면 파일 잠금으로 _LOCK = threading.Lock() @@ -42,6 +44,10 @@ def _load(path: Path) -> Any: return None +def _grid_path(folder: str | Path, key: str) -> Path: + return _dir(folder) / GRID / _path(folder, key).name + + def brief(doc: dict[str, Any]) -> dict[str, Any]: """목록 한 줄 — `{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}`.""" keep = ("키", "파일", "올린때", "올린이", "시트") @@ -69,8 +75,14 @@ def read(folder: str | Path, key: str) -> dict[str, Any]: return doc -def save(folder: str | Path, name: str, parsed: dict[str, Any], who: Any) -> dict[str, Any]: - """읽은 결과 → 새 키로 씀 → 목록 한 줄.""" +def save( + folder: str | Path, + name: str, + parsed: dict[str, Any], + who: Any, + grid: dict[str, Any] | None = None, +) -> dict[str, Any]: + """읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄.""" with _LOCK: have = [ int(m.group(1)) for p in _dir(folder).glob("HP-*.json") if (m := _KEY.match(p.stem)) @@ -83,16 +95,28 @@ def save(folder: str | Path, name: str, parsed: dict[str, Any], who: Any) -> dic "올린이": who, **parsed, } + if grid is not None: + book = {**grid["책"], "열": key} + atomic_write_json(_grid_path(folder, key), {**grid, "책": book}) atomic_write_json(_path(folder, key), doc) return brief(doc) +def grid(folder: str | Path, key: str) -> dict[str, Any]: + """엑셀꼴 격자 `{책, 호표, 단산}` — 없으면(옛 파일) 404.""" + doc = _load(_grid_path(folder, key)) + if not isinstance(doc, dict): + raise store.StoreError(404, NO_GRID) + return doc + + def delete(folder: str | Path, key: str) -> None: path = _path(folder, key) with _LOCK: if not path.is_file(): raise store.StoreError(404, f"없는 실무자료 「{key}」") path.unlink() + _grid_path(folder, key).unlink(missing_ok=True) # ── 맞춤 기억 ───────────────────────────────────────────────────────── diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 1f028a4b1..55739aa3a 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -20,6 +20,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel +from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor @@ -176,7 +177,11 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: parsed = hopyo_excel.read_book(name, data) except hopyo_excel.ExcelError as error: raise store.StoreError(400, str(error)) from error - return hopyo_store.save(store.root(), name, parsed, who) + try: + grid = hopyo_grid.read(name, data, parsed) + except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404) + grid = None + return hopyo_store.save(store.root(), name, parsed, who, grid) @router.get("/layers/{layer}/hopyo") @@ -205,7 +210,7 @@ def hopyo_upload( owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: - """엑셀(xls · xlsx) 올리기 — 「일위대가」 시트를 읽어 결과만 저장 · 20 MB · + """엑셀(xls · xlsx) 올리기 — 「일위대가」 시트를 읽어 결과 · 격자 저장 · 20 MB · 엉터리 · 시트 없음 400.""" folder = _root(session, layer, owner, write=True) data = file.file.read(HOPYO_MAX_BYTES + 1) @@ -225,6 +230,16 @@ def hopyo_read( return _call(folder, hopyo_match.view, folder, key) +@router.get("/layers/{layer}/hopyo-grid/{key}") +def hopyo_grid_read( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """엑셀꼴 격자(329-1) `{책: Workbook, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` · + 옛 파일 404.""" + folder = _root(session, layer, owner) + return _call(folder, hopyo_store.grid, folder, key) + + @router.get("/layers/{layer}/hopyo/{key}/{no}") def hopyo_one( layer: Layer, diff --git a/resources/tester/test_m02_hopyo_basis.py b/resources/tester/test_m02_hopyo_basis.py new file mode 100644 index 000000000..526bfd4cb --- /dev/null +++ b/resources/tester/test_m02_hopyo_basis.py @@ -0,0 +1,108 @@ +"""일위대가 실무자료 단가산출 근거 맞춤(PLAN 330 · `M02_Hopyo_Basis` · 맞춤 후보 `근거`). + +블록 북부청 95 · 사방 67 · 단산93 근거 [건설 3-2-1] · [특별인부] · +후보 93 → GC000083 · 21 → GC000198 · 64 → GC000143 위 둘 · 49 → GC000085 · +잔토처리에 철골재 철거 없음 · 근거 없는 줄 그대로 · 상태 「후보」(자동 확정 없음) · +master_data 그대로. +""" + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Basis as hb +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Match as hm +from resources.tester import test_m02_hopyo_excel as base +from resources.tester.test_m02_hopyo_excel import ( + NORTH, + SABANG, + URL, + _post, + master_hash, +) + +client = base.client # 공용 고정물(임시 층) + + +@pytest.fixture(scope="module") +def north() -> dict: + return hx.read_book(NORTH.name, NORTH.read_bytes()) + + +def _line(book: dict, no: int) -> dict: + """단산 no 를 가리키는 첫 줄.""" + return next(x for h in book["호표"] for x in h["줄"] if x["참조"] == {"종류": "단산", "번": no}) + + +def test_블록_수_와_근거(north): + assert len(north["단산"]) == 95 + assert [b["번"] for b in north["단산"]] == list(range(1, 96)) + sabang = hx.read_book(SABANG.name, SABANG.read_bytes()) + assert len(sabang["단산"]) == 67 + assert _line(north, 93)["근거"] == { + "출처": [["건설품셈", "3-2-1"]], + "직종": ["특별인부"], + "행": [4229, 4244], + } + assert _line(north, 21)["근거"]["직종"] == ["콘크리트공", "보통인부"] # 「보 통 인부」 빈칸 뺌 + assert _line(north, 64)["근거"]["출처"][0] == ["건설품셈", "4-1-1"] + lines = [x for h in north["호표"] for x in h["줄"] if x["갈래"] == "단가산출"] + assert all("근거" in x for x in lines) # 268 줄 모두 블록 있음 + + +def test_출처_글(): + assert hb.sources("[건설공사표준품셈 3-2-1 인력굴착]") == [["건설품셈", "3-2-1"]] + assert hb.sources("건설표줌품셈(3-1-3) 자갈") == [["건설품셈", "3-1-3"]] + assert hb.sources("[2025년 산림사업표준품셈 9-14]") == [["산림품셈", "9-14"]] + assert hb.sources("흙운반 [임도표준품셈 4-2]") == [["산림품셈", "4-2"]] + assert hb.sources("[건설표준품셈 준용]") == [] + + +def _top(book: dict, no: int) -> list[dict]: + got = hm.match_line(_line(book, no), [], hm.units_of(book)) + assert got["상태"] == "후보" and got["들어감"] is False # 자동 확정 없음 + return got["후보"] + + +def test_근거_후보(north): + before = master_hash() + dig = _top(north, 93) + assert dig[0]["로직"] == "GC000083" + assert dig[0]["근거"] == { + "조항": ["건설품셈 공통 3-2-1"], + "직종": ["특별인부"], + "단위": True, + "점수": 1.75, + "까닭": "조항 같음 · 건설 공통 · 직종 특별인부 · 단위 맞음", + } + assert _top(north, 21)[0]["로직"] == "GC000198" + assert "GC000143" in [c["로직"] for c in _top(north, 64)[:2]] + assert _top(north, 49)[0]["로직"] == "GC000085" + soil = next(x for x in north["호표"][2]["줄"] if x["이름"].startswith("잔토처리")) + names = [c["이름"] for c in hm.match_line(soil, [], {})["후보"]] + assert not any("철골재" in n for n in names) + assert master_hash() == before + + +def test_근거_없는_줄_그대로(north): + bare = [ + x + for h in north["호표"] + for x in h["줄"] + if x["갈래"] == "단가산출" and not x["근거"]["출처"] + ] + assert bare + for line in bare: + plain = {k: v for k, v in line.items() if k != "근거"} + assert hm.match_line(line, [], {}) == hm.match_line(plain, [], {}) + + +def test_길_응답_근거_칸(client): + before = master_hash() + assert _post(client).status_code == 200 + res = client.get(f"{URL.format('personal')}/HP-000001/3") + lines = {x["이름"]: x for x in res.json()["호표"]["줄"]} + dig = lines["구조물터파기(인력100%)"] + assert dig["근거"]["출처"] == [["건설품셈", "3-2-1"]] + assert dig["맞춤"]["후보"][0]["근거"]["까닭"].startswith("조항 같음") + assert "근거" not in lines["임도표지석(받침,글새김포함)"] # 자재 줄 + assert master_hash() == before diff --git a/resources/tester/test_m02_hopyo_grid.py b/resources/tester/test_m02_hopyo_grid.py new file mode 100644 index 000000000..16ba2b303 --- /dev/null +++ b/resources/tester/test_m02_hopyo_grid.py @@ -0,0 +1,120 @@ +"""일위대가 실무자료 엑셀꼴 격자(PLAN 329-1 · `M02_Hopyo_Grid` · `GET hopyo-grid/{키}`). + +북부청 임도 xls — 병합 11 · 15열 · B20 「제3호표」 · `#,##0` · 호표 68 행 범위 안 겹침 · +제3호표 [20, 24] · 올리면 격자도 · 200 / 옛 파일 404 · 지우면 격자도 · 역할 · xlsx 값만 · +master_data 그대로. +""" + +import pytest + +from M02_MasterTemplete import M02_Hopyo_Excel as hx +from M02_MasterTemplete import M02_Hopyo_Grid as hg +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester import test_m02_hopyo_excel as base +from resources.tester.test_m02_hopyo_excel import ( + NORTH, + URL, + _post, + _xlsx, + master_hash, +) + +client = base.client # 공용 고정물(임시 층) +GRID = "/api/m02/layers/{}/hopyo-grid/{}" + + +@pytest.fixture(scope="module") +def north() -> dict: + data = NORTH.read_bytes() + return hg.read(NORTH.name, data, hx.read_book(NORTH.name, data)) + + +def _sheet(grid: dict, name: str) -> dict: + return next(s for s in grid["책"]["시트"] if s["이름"] == name) + + +def test_격자_모양(north): + book = north["책"] + assert (book["종류"], book["판"], book["활성"]) == ("통합문서", 1, "s1") + assert [s["이름"] for s in book["시트"]] == ["일위대가", "단가산출"] + sheet = _sheet(north, "일위대가") + assert len(sheet["병합"]) == 11 and "B1:O2" in sheet["병합"] + assert len(sheet["열"]) == 15 and sheet["열"]["C"]["폭"] == round(7208 / 256, 2) + assert sheet["틀고정"] == {"행": 4} + assert sheet["칸"]["B20"]["값"] == "제3호표" + assert sheet["칸"]["H5"]["값"] == 1040052 + styles = book["서식"] + assert styles[0] == {} and any(s.get("형식") == "#,##0" for s in styles) + assert any(s.get("테두리") for s in styles) + assert _sheet(north, "단가산출")["칸"]["B4"]["값"] == "1.유용토운반(무대)[㎥]" + + +def test_호표_단산_행_범위(north): + rows = [h["행"] for h in north["호표"]] + assert len(rows) == 68 + assert all(a[1] < b[0] for a, b in zip(sorted(rows), sorted(rows)[1:], strict=False)) + assert next(h for h in north["호표"] if h["번"] == 3) == { + "번": 3, + "이름": "임도표지석", + "행": [20, 24], + } + assert len(north["단산"]) == 95 + assert next(b for b in north["단산"] if b["번"] == 93)["행"] == [4229, 4244] + + +def test_줄_행(north): + book = hx.read_book(NORTH.name, NORTH.read_bytes()) + three = next(h for h in book["호표"] if h["번"] == 3) + assert three["행"] == [20, 24] and [x["행"] for x in three["줄"]] == [21, 22, 23, 24] + + +def test_올리기_격자_200_404_지우기(client): + before = master_hash() + assert _post(client).status_code == 200 + res = client.get(GRID.format("personal", "HP-000001")) + assert res.status_code == 200, res.text + got = res.json() + assert got["책"]["열"] == "HP-000001" and len(got["호표"]) == 68 + folder = layers.personal_dir(7, 42) + path = folder / hs.DIRNAME / hs.GRID / "HP-000001.json" + assert path.is_file() + assert [r["키"] for r in client.get(URL.format("personal")).json()["줄"]] == ["HP-000001"] + path.unlink() # 옛 파일(324) = 격자 없음 + old = client.get(GRID.format("personal", "HP-000001")) + assert old.status_code == 404 and old.json()["detail"] == "격자 없음 — 다시 올리면 보임" + assert _post(client).status_code == 200 + assert client.get(GRID.format("personal", "HP-000002")).status_code == 200 + assert client.delete(URL.format("personal") + "/HP-000002").status_code == 200 + assert not (folder / hs.DIRNAME / hs.GRID / "HP-000002.json").exists() + assert client.get(GRID.format("personal", "HP-000002")).status_code == 404 + assert client.get(GRID.format("personal", "../x")).status_code == 404 + assert client.get(URL.format("personal") + "/HP-000001/3").status_code == 200 # 길 안 부딪힘 + assert master_hash() == before + + +@pytest.mark.parametrize( + ("role", "layer", "owner", "want"), + [ + ({"role": "USER"}, "personal", None, 200), + ({"role": "USER"}, "personal", "7/43", 403), + ({"role": "USER"}, "company", None, 200), + ({"role": "USER"}, "system", None, 200), + ], +) +def test_역할_읽기(client, role, layer, owner, want): + client.who.update({"role": "SYSTEM_ADMIN"}) + assert _post(client, layer if want == 200 else "personal").status_code == 200 + client.who.update(role) + params = {"owner": owner} if owner else {} + assert client.get(GRID.format(layer, "HP-000001"), params=params).status_code == want + + +def test_xlsx_값만(): + data = _xlsx() + grid = hg.read("a.xlsx", data, hx.read_book("a.xlsx", data)) + sheet = grid["책"]["시트"][0] + assert sheet["이름"] == "일위대가" and sheet["틀고정"] == {"행": 4} + assert sheet["칸"]["B5"] == {"값": "제 1 호표"} and sheet["칸"]["E6"] == {"값": 0.5} + assert "K6" not in sheet["칸"] # 머리 글(앞 4 줄) 마지막 열 J 까지 + assert grid["호표"] == [{"번": 1, "이름": "표지석", "행": [5, 6]}] and grid["단산"] == [] diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index 0f1224240..69f8cd397 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -105,7 +105,7 @@ def test_이름_같음_후보_호표_퍼센트(client): assert all(c["규격"] == "25-21-120" for c in remicon["후보"][:3]) # 규격 같은 자재 먼저 dig = _hopyo(client, 3)["구조물터파기(인력100%)"] assert dig["상태"] == "후보" and len(dig["후보"]) == 5 - assert dig["후보"][0]["이름"].startswith("구조물터파기") + assert dig["후보"][0]["이름"] == "굴착(인력/토사)" # 330-2 근거(건설 3-2-1 · 특별인부) 1순위 ref = _hopyo(client, 45)["고화매트재료비"] assert ref["상태"] == "호표" and ref["대상"] == {"호표": 46, "단위": "㎡"} and ref["들어감"] pct = _hopyo(client, 46)["잡재료비"] From f044b8d0b47e91fe0c05cc62782a7728c82589dc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:38:41 +0900 Subject: [PATCH 18/36] =?UTF-8?q?feat(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4):=20330-3=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=B0=BD=20=EA=B7=BC=EA=B1=B0=20=E2=80=94=20=ED=9B=84=EB=B3=B4?= =?UTF-8?q?=20=EA=B7=BC=EA=B1=B0=20=EA=BC=AC=EB=A6=AC=ED=91=9C(=EC=A1=B0?= =?UTF-8?q?=ED=95=AD=20=C2=B7=20=EC=A7=81=EC=A2=85=20=C2=B7=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84)=20=C2=B7=20=EC=97=91=EC=85=80=20=EA=B7=BC=EA=B1=B0?= =?UTF-8?q?=20=EC=A4=84(=EC=B6=9C=EC=B2=98=20=C2=B7=20=EC=A7=81=EC=A2=85?= =?UTF-8?q?=20=C2=B7=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=ED=96=89)?= =?UTF-8?q?=20+=20[=EC=97=91=EC=85=80=EC=97=90=EC=84=9C=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0]=20=C2=B7=20[=EA=B7=BC=EA=B1=B0=20=EB=A7=9E=EC=9D=8C?= =?UTF-8?q?=20=EB=AA=A8=EB=91=90=20=ED=99=95=EC=A0=95](=EC=85=8B=20?= =?UTF-8?q?=EB=AA=A8=EB=91=90=20=EB=A7=9E=EB=8A=94=20=ED=9B=84=EB=B3=B4?= =?UTF-8?q?=EB=A7=8C)=20=C2=B7=20=EC=98=9B=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20=EC=97=86=EC=9D=8C?= 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_01XWh5wNNycMkbwMpACz1rte --- .../M02_MasterTemplete_Hopyo.css | 21 +++ .../M02_MasterTemplete_Hopyo_Api.ts | 20 +++ .../M02_MasterTemplete_Hopyo_View.ts | 120 ++++++++++++++++-- resources/tester/helper_m02_hopyo_pick.cjs | 28 ++++ resources/tester/test_m02_hopyo_screen.py | 21 +++ 5 files changed, 201 insertions(+), 9 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 134403225..8e771bad7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -227,3 +227,24 @@ .m02-hopyo-sheet__result:empty { display: none; } + +/* 고르기 창 근거(330) — 후보 꼬리표 · 엑셀 근거 줄 */ +.m02-hopyo__basis { + margin-left: var(--spacing-8); + padding: 0 var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + font-size: var(--text-caption); + white-space: nowrap; +} + +.m02-hopyo__basis.is-ok { + border-color: var(--color-success); + color: var(--color-success); +} + +.m02-hopyo__source { + padding-bottom: var(--spacing-8); + border-bottom: 1px solid var(--color-border); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index c4ef47641..12312fba2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -59,6 +59,22 @@ export interface HopyoView extends HopyoFile { 맞춤: [number, number]; } +/** 후보가 단가산출 근거와 맞는 것(330-2) — 맞은 조항 글 · 겹친 직종 · 단위 같음 · 점수 · 까닭 꼬리표 */ +export interface HopyoBasisHit { + 조항: string[]; + 직종: string[]; + 단위: boolean; + 점수: number; + 까닭: string; +} + +/** 단산 줄 근거(330-1) — 단가산출 블록의 출처([책, 번호]) · 직종 · 엑셀 행 [시작, 끝] */ +export interface HopyoLineBasis { + 출처: [string, string][]; + 직종: string[]; + 행: [number, number]; +} + /** 맞춤 대상 — 로직 · 자재 · 다른 호표 */ export interface HopyoTarget { 로직?: string; @@ -68,6 +84,8 @@ export interface HopyoTarget { 규격?: string; 단위?: string | null; 점수?: number; + /** 근거 맞춤(330 · 옛 파일 · 근거 없는 줄은 없음) */ + 근거?: HopyoBasisHit; } export interface HopyoMatch { @@ -92,6 +110,8 @@ export interface HopyoLine { 금액: Money; 비고: string; 맞춤: HopyoMatch; + /** 단가산출 근거(330 · 「단산N참조」 줄만) */ + 근거?: HopyoLineBasis; } export interface HopyoOne extends HopyoFile { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts index 3aa5fe2c8..63a5eef75 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts @@ -3,7 +3,8 @@ * 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 · * [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 · * [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기] · - * [엑셀로 보기] · [엑셀에서 보기](329 엑셀 보기 창 · 늦게 불러옴 · 호표 화면은 그 호표 고른 채). + * [엑셀로 보기] · [엑셀에서 보기](329 엑셀 보기 창 · 늦게 불러옴 · 호표 화면은 그 호표 고른 채) · + * 고르기 창 후보 근거 꼬리표 · 엑셀 근거 줄 + [엑셀에서 보기](단가산출 그 행) · [근거 맞음 모두 확정](330). * 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만. * ========================================================================== */ @@ -17,9 +18,11 @@ import { readHopyo, readHopyoOne, saveHopyoMemory, + type HopyoBasisHit, type HopyoBuilt, type HopyoGuess, type HopyoLine, + type HopyoLineBasis, type HopyoMemory, type HopyoOne, type HopyoScope, @@ -114,6 +117,42 @@ export function memoryOf( return null; } +/** 근거 맞음 — 조항 · 직종 · 단위 모두 맞음(330 [근거 맞음 모두 확정]) */ +export const basisOk = (b: HopyoBasisHit | undefined): boolean => + !!b && b.조항.length > 0 && b.직종.length > 0 && b.단위; + +/** 후보 근거 꼬리표 — 「조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음」 · 근거 없으면 "" */ +export const basisTag = (b: HopyoBasisHit | undefined): string => + b + ? [ + b.조항.length ? `조항 ${b.조항.join(", ")}` : "", + b.직종.length ? `직종 ${b.직종.join(", ")}` : "", + b.단위 ? "단위 같음" : "", + ] + .filter(Boolean) + .join(" · ") + : ""; + +/** 엑셀 근거 줄 — 「출처 건설표준품셈 3-2-1 · 직종 특별인부 · 단가산출 93–101행」 · 없으면 "" */ +export const sourceText = (b: HopyoLineBasis | undefined): string => + b + ? [ + b.출처.length ? `출처 ${b.출처.map((x) => x.join(" ").trim()).join(", ")}` : "", + b.직종.length ? `직종 ${b.직종.join(", ")}` : "", + `단가산출 ${b.행[0] === b.행[1] ? b.행[0] : `${b.행[0]}–${b.행[1]}`}행`, + ] + .filter(Boolean) + .join(" · ") + : ""; + +/** [근거 맞음 모두 확정] 대상 — 「후보」 줄의 지금 대상이 근거 맞으면 그것 · 아니면 근거 맞는 첫 후보 · 없으면 null */ +export function basisPick(line: HopyoLine): HopyoTarget | null { + const m = line.맞춤; + if (m.상태 !== "후보") return null; + if (m.대상 && basisOk(m.대상.근거)) return m.대상; + return (m.후보 ?? []).find((c) => basisOk(c.근거)) ?? null; +} + export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement { const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } }); let nav = 0; // 늦게 온 응답 버림 @@ -236,9 +275,19 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement const all = createButton({ label: `같음 모두 확정 (${same.length})`, size: "sm" }); all.disabled = !same.length; all.addEventListener("click", () => void confirmAll(no, same)); + tools.push(all); + // 근거(330) — 옛 파일 · 근거 없는 호표면 단추 없음 + if (h.줄.some((x) => x.근거 || x.맞춤.후보?.some((c) => c.근거))) { + const fit = h.줄.filter((x) => basisPick(x)); + const ok = createButton({ label: `근거 맞음 모두 확정 (${fit.length})`, size: "sm" }); + ok.title = "조항 · 직종 · 단위가 모두 맞는 후보만"; + ok.disabled = !fit.length; + ok.addEventListener("click", () => void confirmBasis(no, fit)); + tools.push(ok); + } const make = createButton({ label: "사전조합 만들기", variant: "filled", size: "sm" }); make.addEventListener("click", () => void build(no, false)); - tools.push(all, make); + tools.push(make); } const missing = h.줄.filter((x) => !x.맞춤.들어감); view.replaceChildren( @@ -330,7 +379,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement variant: "ghost", size: "sm", onClick: () => - choose(line, (t) => + choose(line, got.파일, (t) => guessHopyo(opt.scope, key, got.호표.번, line.차례, t).then( (g) => (picked.set(line.차례, g), repaint()), fail, @@ -378,8 +427,8 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement }); } - /** 대상 고르기 — 후보 5 + 다른 로직 · 자재 찾기 */ - function choose(line: HopyoLine, done: (t: HopyoTarget) => void): void { + /** 대상 고르기 — 엑셀 근거 줄 · 후보 5(근거 꼬리표) + 다른 로직 · 자재 찾기 */ + function choose(line: HopyoLine, 파일: string, done: (t: HopyoTarget) => void): void { const material = MATERIAL.has(line.갈래); openModal({ title: `고르기 — ${line.이름} ${line.규격}`.trim(), @@ -390,14 +439,48 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement close(); done(t); }; - const list = (line.맞춤.후보 ?? []).map((c) => - createButton({ + const list = (line.맞춤.후보 ?? []).map((c) => { + const button = createButton({ label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`, variant: "ghost", size: "sm", onClick: () => pick(c), - }), - ); + }); + const tag = basisTag(c.근거); + if (tag && c.근거) + button.append( + el("span", { + className: `m02-hopyo__basis${basisOk(c.근거) ? " is-ok" : ""}`, + text: tag, + attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` }, + }), + ); + return button; + }); + const basis = line.근거; + const source = basis + ? [ + el("div", { + className: "m02-hopyo__bar m02-hopyo__source", + children: [ + note(`엑셀 근거 — ${sourceText(basis)}`), + createButton({ + label: "엑셀에서 보기", + variant: "ghost", + size: "sm", + onClick: () => + openSheet({ + scope: opt.scope, + key, + 파일, + canWrite: opt.canWrite, + 단산행: basis.행, + }), + }), + ], + }), + ] + : []; const more = createButton({ label: material ? "다른 자재 찾기…" : "다른 로직 찾기…", size: "sm", @@ -408,6 +491,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement }, }); body.append( + ...source, el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "가까운 것 없음" }), el("div", { className: "m02-hopyo__cands", children: list }), more, @@ -434,6 +518,24 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement await remember(no, body); } + /** 근거 맞는 후보로 확정 — 지금 대상이 아니면 입력값 · 환산을 서버에서 다시 읽음 */ + async function confirmBasis(no: number, lines: HopyoLine[]): Promise { + const body: Record = {}; + for (const x of lines) { + const c = basisPick(x); + const m = x.맞춤; + if (!c) continue; + const g = + c === m.대상 + ? { 대상: c, 입력값: m.입력값, 환산: m.환산 } + : await guessHopyo(opt.scope, key, no, x.차례, c).catch(() => null); + const one = g && memoryOf(g.대상, g.입력값, g.환산?.배 ?? null); + if (one) body[m.열쇠] = one; + } + if (!Object.keys(body).length) return showToast("확정할 줄 없음 — 환산을 못 읽음", "warning"); + await remember(no, body); + } + async function build(no: number, fresh: boolean): Promise { try { const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no }; diff --git a/resources/tester/helper_m02_hopyo_pick.cjs b/resources/tester/helper_m02_hopyo_pick.cjs index 1a389f69f..78c8b60a9 100644 --- a/resources/tester/helper_m02_hopyo_pick.cjs +++ b/resources/tester/helper_m02_hopyo_pick.cjs @@ -88,6 +88,34 @@ function loadTs(file) { view.builtWord({ 다시씀: true }, true), ], }, + basis: (() => { + const hit = { + 조항: ["건설 공통 3-2-1"], + 직종: ["특별인부"], + 단위: true, + 점수: 1.65, + 까닭: "조항", + }; + const half = { ...hit, 직종: [] }; + const line = (상태, 대상, 후보) => ({ 맞춤: { 상태, 대상, 후보 } }); + const a = { 로직: "GF000101", 근거: half }; + const b = { 로직: "GC000083", 근거: hit }; + return { + ok: [hit, half, { ...hit, 단위: false }, undefined].map(view.basisOk), + tag: [hit, half, undefined].map(view.basisTag), + source: [ + view.sourceText({ 출처: [["건설표준품셈", "3-2-1"]], 직종: ["특별인부"], 행: [93, 101] }), + view.sourceText({ 출처: [], 직종: [], 행: [7, 7] }), + view.sourceText(undefined), + ], + pick: [ + line("후보", a, [a, b]), + line("후보", b, [b]), + line("같음", b, [b]), + line("후보", a, [a]), + ].map((x) => view.basisPick(x)?.로직 ?? null), + }; + })(), sheet: { at: [19, 20, 22, 24, 25, 30, 31].map((row) => sheet.hopyoAtRow(spans, row)), span: sheet.spanText([20, 24], sheet.lastColumn({ 칸: { A1: {}, N24: {} }, 열: { O: {} } })), diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 804e09399..c844f2d7d 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -3,6 +3,7 @@ - 고르기 창 덧줄(Node · fetch 대역): 이 층 · 위 층 호표 줄(맞춤 n/m) · 종류 고르기 · 고르면 문서 층에 만들기 길 → `lib.pick` 참조 줄(별칭 = 호표 품명) · 프로젝트 층엔 없음 · 본 화면 글 도우미 +- 고르기 창 근거(330-3): 꼬리표 · 엑셀 근거 줄 · [근거 맞음 모두 확정] 대상 - 엑셀 보기 창(329): 행 → 호표 · 호표 영역 A1 · 격자 길 · 여는 자리 넷 · 늦게 불러옴 - 소스: 좌측 판 자리(사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 · 숨은 file 입력) · Labor_Detail 줄 수 그대로 · 새 파일 이름 · 700 줄 · 색 = 공용 토큰 @@ -188,3 +189,23 @@ def test_엑셀_보기_창_여는_자리와_늦게_불러옴(): r'^import \{[^}]*\} from "\.\./A00_Common/spreadsheet/spreadsheet"', sheet, re.M ) assert "readOnly: true" in sheet and "wide: true" in sheet + + +def test_고르기_창_근거_꼬리표와_근거_맞음(): + basis = _run()["basis"] + assert basis["ok"] == [True, False, False, False] # 조항 · 직종 · 단위 모두 + assert basis["tag"] == [ + "조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음", + "조항 건설 공통 3-2-1 · 단위 같음", + "", + ] + assert basis["source"] == [ + "출처 건설표준품셈 3-2-1 · 직종 특별인부 · 단가산출 93–101행", + "단가산출 7행", + "", + ] + # 지금 대상이 근거 안 맞으면 근거 맞는 후보 · 「후보」 줄만 · 근거 맞는 것 없으면 없음 + assert basis["pick"] == ["GC000083", "GC000083", None, None] + view = _text("M02_MasterTemplete_Hopyo_View.ts") + assert "단산행: basis.행" in view and "근거 맞음 모두 확정" in view + assert "x.근거 || x.맞춤.후보?.some((c) => c.근거)" in view # 옛 파일 = 단추 없음 From ca11ee14560b5d536c840d3414089a19640ab8e0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:41:16 +0900 Subject: [PATCH 19/36] =?UTF-8?q?feat(CAD):=20331-3=20=EC=84=A0=20?= =?UTF-8?q?=EC=A4=91=20=EB=8B=AB=EA=B8=B0(C)=20=C2=B7=20=EB=AA=85=EB=A0=B9?= =?UTF-8?q?=EC=B7=A8=EC=86=8C(U)=20=C2=B7=20=ED=95=9C=20=EB=B2=88=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=ED=95=9C?= =?UTF-8?q?=EA=B8=80=20=EC=95=88=EB=82=B4=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 선 — 이어 그리기 첫 점 · 구간 기억 · C = 둘째 선부터 첫 점으로 닫고 끝 · U = 마지막 한 구간만 취소 - 점 단계 글 — 선택지 글자(meta.options)만 도구로 · 나머지는 명령 이름 그대로 · 한글 받개는 점 단계에서 안 잡음 - 새 input-snap-override — END · MID · CEN · INT · PER · NEA · NON 다음 한 점만 · F3 꺼져도 · 맞는 점 없으면 클릭 버림 · Esc 풂 · 커서 옆 안내 - 원본 도구 안내문 한글(선 · 원 · 사각형 · 이동 · 복사 · 회전 · 배열 · 정렬 · 지우개 · 그림 · 폴리선 합치기 · 선택) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../src/inputController/ime-sink.ts | 25 +++++- .../src/inputController/input-controller.ts | 43 +++++++--- .../inputController/input-snap-override.ts | 80 ++++++++++++++++++ .../src/tools/align-tool.helpers.ts | 2 +- .../openwebcad/src/tools/array-tool.ts | 14 ++-- .../openwebcad/src/tools/circle-tool.ts | 4 +- .../openwebcad/src/tools/copy-tool.ts | 10 +-- .../openwebcad/src/tools/eraser-tool.ts | 4 +- .../openwebcad/src/tools/image-import-tool.ts | 12 +-- .../openwebcad/src/tools/line-tool.ts | 61 +++++++++++++- .../openwebcad/src/tools/move-tool.ts | 10 +-- .../openwebcad/src/tools/pedit-tool.ts | 2 +- .../openwebcad/src/tools/rectangle-tool.ts | 8 +- .../openwebcad/src/tools/rotate-tool.ts | 14 ++-- .../openwebcad/src/tools/select-tool.ts | 8 +- resources/tester/cad/keyboard-chain.test.ts | 82 +++++++++++++++++++ 16 files changed, 318 insertions(+), 61 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/inputController/input-snap-override.ts diff --git a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts index 327220356..ae60c87a3 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/ime-sink.ts @@ -9,12 +9,33 @@ import { getActiveToolActor } from '../state.ts'; export const isImeKey = (evt: KeyboardEvent): boolean => evt.isComposing || evt.keyCode === 229 || evt.key === 'Process'; +/** 활성 도구의 지금 단계 설정(평평한 단계만) */ +const activeStateConfig = () => { + const snapshot = getActiveToolActor()?.getSnapshot(); + return snapshot?.machine?.states?.[snapshot?.value]?.config; +}; + /** 활성 도구의 지금 단계가 TEXT_INPUT 을 받는가 */ export function activeToolAcceptsText(): boolean { - const snapshot = getActiveToolActor()?.getSnapshot(); - return !!snapshot?.machine?.states?.[snapshot?.value]?.config?.on?.TEXT_INPUT; + return !!activeStateConfig()?.on?.TEXT_INPUT; } +/** 지금 단계가 점(`x,y`)을 받는가 — 한 번 스냅 이름 · 선택지 판정 */ +export function activeToolWantsPoint(): boolean { + return !!activeStateConfig()?.on?.ABSOLUTE_POINT_INPUT; +} + +/** 지금 단계가 이 글을 받는가 — 점 단계는 선택지 글자(meta.options · 선 C · U)만 · 나머지는 명령 이름으로 */ +export function activeToolTakesText(text: string): boolean { + if (!activeToolAcceptsText()) return false; + if (!activeToolWantsPoint()) return true; + const options = (activeStateConfig()?.meta as { options?: string[] } | undefined)?.options; + return !!options?.includes(text); +} + +/** 한글 받개에 초점을 줄 단계 — 글은 받되 점 단계 선택지(영문 C · U)는 아님 */ +export const activeToolWantsIme = (): boolean => activeToolAcceptsText() && !activeToolWantsPoint(); + /** 지금 단계가 자유 글(TEXT · MTEXT · 표 칸 · 이름 …)을 받는가 — Space 가 띄어쓰기(아니면 Enter) */ export function activeToolWantsFreeText(): boolean { const meta = getActiveToolActor()?.getSnapshot()?.getMeta?.() as diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index 768d6fec9..5c257cfc6 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -17,6 +17,7 @@ import { getClosestSnapPointWithinRadius } from '../helpers/get-closest-snap-poi import { pickRadius } from '../helpers/pick-radius.ts'; import { getActiveToolActor, + getAngleGuideOriginPoint, getAngleStep, getCanvas, getPickableEntities, @@ -46,8 +47,10 @@ import { Tool } from '../tools.ts'; import { SelectState, selectToolStateMachine } from '../tools/select-tool.ts'; import { ActorEvent, type MouseClickEvent, type TextInputEvent } from '../tools/tool.types.ts'; import { - activeToolAcceptsText, + activeToolTakesText, activeToolWantsFreeText, + activeToolWantsIme, + activeToolWantsPoint, ImeSink, isImeKey, isPlainSpace, @@ -58,6 +61,13 @@ import { wheelZoom } from './input-wheel.ts'; import { attachTouch } from './input-touch.ts'; import { ANGLE_LOCK_REGEXP, parsePointText, type PointTextEvent } from './input-patterns.ts'; import { angleLockHint, setAngleLock } from './input-angle-lock.ts'; +import { + getSnapOverride, + isSnapName, + setSnapOverride, + snapOverrideHint, + snapOverridePoint, +} from './input-snap-override.ts'; /** 이만큼 넘게 끌면 클릭이 아니라 드래그로 본다 (화면 픽셀) */ const DRAG_SELECT_MIN_PX = 4; @@ -112,7 +122,7 @@ export class InputController { } public draw(drawController: ScreenCanvasDrawController) { - this.ime.sync(activeToolAcceptsText()); + this.ime.sync(activeToolWantsIme()); const toolInstruction = getLastStateInstructions(); const texts: string[] = []; if (toolInstruction) { @@ -122,8 +132,7 @@ export class InputController { const roundedY = round(drawController.getWorldMouseLocation().y, 2); texts.push(`${roundedX},${roundedY}`); } - const lockHint = angleLockHint(); - if (lockHint) texts.push(lockHint); + texts.push(...compact([angleLockHint(), snapOverrideHint()])); // 입력 중인 문자로 시작하는 명령 후보. 예: C => CIRCLE (C), COPY (CO) texts.push(...this.getCommandSuggestions()); // 입력 글(조합 중인 한글도 함께) @@ -173,7 +182,7 @@ export class InputController { return; } this.sendMouseClick(evt); - this.ime.sync(activeToolAcceptsText()); // 클릭으로 문자 단계에 들면 바로 한글 받개로 + this.ime.sync(activeToolWantsIme()); // 클릭으로 문자 단계에 들면 바로 한글 받개로 } } @@ -200,7 +209,12 @@ export class InputController { const worldMouseLocationTemp = screenCanvasDrawController.targetToWorld( this.getCanvasPoint(evt) ); - const worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp; + let worldMouseLocation = closestSnapPoint ? closestSnapPoint.point : worldMouseLocationTemp; + if (getSnapOverride()) { + const snapped = snapOverridePoint(worldMouseLocationTemp, getAngleGuideOriginPoint()); + if (!snapped) return; // 맞는 점 없음 — 다시 누르기를 기다림 + worldMouseLocation = snapped; + } this.sendPointEvent({ type: ActorEvent.MOUSE_CLICK, @@ -408,12 +422,12 @@ export class InputController { } else if (evt.key === 'Enter') { // User wants to submit the input or submit the action this.handleEnterKey(true); - this.ime.sync(activeToolAcceptsText()); + this.ime.sync(activeToolWantsIme()); } else if (evt.key === ' ' && !activeToolWantsFreeText()) { // Space = Enter(오토캐드) · 글 단계는 아래 띄어쓰기 · 수정키 · 길게 눌러 반복은 무시 if (isPlainSpace(evt)) { this.handleEnterKey(true); - this.ime.sync(activeToolAcceptsText()); + this.ime.sync(activeToolWantsIme()); } } else if (evt.key === 'ArrowDown') { // Move the screen down @@ -438,6 +452,7 @@ export class InputController { public handleEscapeKey() { setAngleLock(null); + setSnapOverride(null); if (getSelectedEntities().length > 0) { // Deselect entities setSelectedEntityIds([]); @@ -463,7 +478,6 @@ export class InputController { /** @param repeatIfIdle 키보드 Enter · Space — 빈 입력 + 선택 도구 대기면 마지막 명령 반복(우클릭은 확정만) */ public handleEnterKey(repeatIfIdle = false) { // submit the text as input to the active tool and clear the input field - const activeToolCanHandleTextInput = activeToolAcceptsText(); if (this.text === '') { console.log('ENTER: ', { @@ -496,8 +510,12 @@ export class InputController { // 거리 `100` · `x,y` · `@x,y` · `@d> = { + END: SnapPointType.LineEndPoint, + MID: SnapPointType.LineMidPoint, + CEN: SnapPointType.CircleCenter, +}; + +let override: SnapName | null = null; + +export const getSnapOverride = (): SnapName | null => override; +export const setSnapOverride = (name: SnapName | null) => { + override = name; +}; +export const isSnapName = (text: string): text is SnapName => text in SNAP_LABELS; + +/** 커서 옆 안내 줄 */ +export const snapOverrideHint = (): string | null => + override ? `${SNAP_LABELS[override]}(${override}) 한 번 — 점 지정` : null; + +const closest = (points: Point[], mouse: Point, radius: number): Point | null => { + let best: Point | null = null; + let bestDistance = radius; + for (const point of points) { + const distance = Math.hypot(point.x - mouse.x, point.y - mouse.y); + if (distance <= bestDistance) { + best = point; + bestDistance = distance; + } + } + return best; +}; + +/** 한 번 스냅이 고른 점 · 맞는 점 없으면 null. base = 앞 점(PER 기준) */ +export function snapOverridePoint(mouse: Point, base: Point | null): Point | null { + if (override === 'NON') return mouse; + // ponytail: 일반 스냅(15px)의 두 배 둘레 — 오토캐드 조준 상자 크기 설정이 필요하면 설정 모달로 + const radius = (SNAP_POINT_DISTANCE * 2) / getScreenCanvasDrawController().getScreenScale(); + const entities = queryEntitiesNearPoint(mouse.x, mouse.y, radius).filter( + (entity) => !getLayerById(entity.layerId)?.isLocked + ); + const type = override ? SNAP_TYPES[override] : undefined; + if (type) { + const points = entities.flatMap((entity) => + entity + .getSnapPoints() + .filter((snap) => snap.type === type) + .map((snap) => snap.point) + ); + return closest(points, mouse, radius); + } + if (override === 'INT') return closest(getIntersectionPoints(entities), mouse, radius); + const near = findClosestEntity(mouse, entities); + if (!near.entity || near.distance > radius) return null; + if (override === 'NEA') return near.segment.start; + // PER — 앞 점에서 커서 아래 객체로 내린 발(선분이면 수선의 발) + if (!base) return null; + return near.entity.distanceTo(base)?.[1].start ?? null; +} diff --git a/B07_DesignDetail/openwebcad/src/tools/align-tool.helpers.ts b/B07_DesignDetail/openwebcad/src/tools/align-tool.helpers.ts index 0f1c2c495..fac184b20 100644 --- a/B07_DesignDetail/openwebcad/src/tools/align-tool.helpers.ts +++ b/B07_DesignDetail/openwebcad/src/tools/align-tool.helpers.ts @@ -57,7 +57,7 @@ export function GET_ALIGN_TOOL_STATE(type: Tool): MachineContext { [AlignState.WAITING_FOR_SELECTION]: { description: 'Select what you want to align', meta: { - instructions: 'Select what you want to align, then ENTER', + instructions: '정렬할 객체 선택 뒤 Enter', }, invoke: { id: 'selectToolInsideTheAlignTool', diff --git a/B07_DesignDetail/openwebcad/src/tools/array-tool.ts b/B07_DesignDetail/openwebcad/src/tools/array-tool.ts index 2fd83edff..be817e7d2 100644 --- a/B07_DesignDetail/openwebcad/src/tools/array-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/array-tool.ts @@ -91,7 +91,7 @@ export const arrayToolStateMachine = createMachine( [ArrayState.WAITING_FOR_SELECTION]: { description: 'Select what you want to copy', meta: { - instructions: 'Select what you want to copy, then ENTER', + instructions: '배열할 객체 선택 뒤 Enter', }, invoke: { id: 'selectToolInsideTheCopyTool', @@ -145,7 +145,7 @@ export const arrayToolStateMachine = createMachine( ], }, [ArrayState.ASK_COPY_MODE]: { - meta: { instructions: 'Choose copy mode: Linear (L) or Radial (R), ENTER for Linear' }, + meta: { instructions: '배열 방식 [직선(L)/원형(R)] <직선>:' }, on: { TEXT_INPUT: { actions: assign(({ event }) => { @@ -168,7 +168,7 @@ export const arrayToolStateMachine = createMachine( }, }, [ArrayState.ASK_NUMBER_OF_COPIES]: { - meta: { instructions: 'Enter number of copies' }, + meta: { instructions: '복사 개수 입력:' }, on: { NUMBER_INPUT: [ { @@ -196,7 +196,7 @@ export const arrayToolStateMachine = createMachine( }, }, [ArrayState.LINEAR_WAITING_FOR_START_POINT]: { - meta: { instructions: 'Click to define the start of the distance vector' }, + meta: { instructions: '간격 시작점 지정:' }, on: { ABSOLUTE_POINT_INPUT: { actions: assign(({ event }) => ({ @@ -216,7 +216,7 @@ export const arrayToolStateMachine = createMachine( }, }, [ArrayState.LINEAR_WAITING_FOR_END_POINT]: { - meta: { instructions: 'Click to define the end of the distance vector' }, + meta: { instructions: '간격 끝점 지정:' }, on: { RELATIVE_POINT_INPUT: { actions: assign(({ event, context }) => ({ @@ -245,7 +245,7 @@ export const arrayToolStateMachine = createMachine( }, }, [ArrayState.RADIAL_WAITING_FOR_PIVOT_POINT]: { - meta: { instructions: 'Click to set pivot point' }, + meta: { instructions: '회전 중심점 지정:' }, on: { ABSOLUTE_POINT_INPUT: { actions: assign(({ event }) => ({ @@ -265,7 +265,7 @@ export const arrayToolStateMachine = createMachine( }, }, [ArrayState.RADIAL_WAITING_FOR_ANGLE]: { - meta: { instructions: 'Enter angle per step (in degrees)' }, + meta: { instructions: '한 칸 각도(도) 입력:' }, on: { NUMBER_INPUT: { target: ArrayState.EXECUTE_COPY, diff --git a/B07_DesignDetail/openwebcad/src/tools/circle-tool.ts b/B07_DesignDetail/openwebcad/src/tools/circle-tool.ts index cbd81db8f..8f992f6c9 100644 --- a/B07_DesignDetail/openwebcad/src/tools/circle-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/circle-tool.ts @@ -60,7 +60,7 @@ export const circleToolStateMachine = createMachine( [CircleState.WAITING_FOR_CENTER_POINT]: { description: 'Select the center point of the circle tool', meta: { - instructions: 'Select the center point of the circle', + instructions: '중심점 지정 (x,y):', }, on: { MOUSE_CLICK: { @@ -79,7 +79,7 @@ export const circleToolStateMachine = createMachine( [CircleState.WAITING_FOR_POINT_ON_CIRCLE]: { description: 'Select a point on the circle', meta: { - instructions: 'Select the point on the circle', + instructions: '원 위의 점 지정 또는 반지름 입력:', }, on: { DRAW: { diff --git a/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts b/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts index 25b7d8542..2a24a8503 100644 --- a/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/copy-tool.ts @@ -97,7 +97,7 @@ export const copyToolStateMachine = createMachine( [CopyState.WAITING_FOR_SELECTION]: { description: 'Select what you want to copy', meta: { - instructions: 'Select what you want to copy, then ENTER', + instructions: '복사할 객체 선택 뒤 Enter', }, invoke: { id: 'selectToolInsideTheCopyTool', @@ -136,9 +136,9 @@ export const copyToolStateMachine = createMachine( }, }, [CopyState.WAITING_FOR_START_COPY_POINT]: { - description: 'Select the start of the copy line', + description: '기준점 지정 (x,y):', meta: { - instructions: 'Select the start of the copy line', + instructions: '기준점 지정 (x,y):', }, always: { actions: CopyAction.ENABLE_HELPERS, @@ -159,9 +159,9 @@ export const copyToolStateMachine = createMachine( }, }, [CopyState.WAITING_FOR_END_COPY_POINT]: { - description: 'Select the end of the copy line', + description: '둘째 점 지정 (길이 · <각도 · @x,y):', meta: { - instructions: 'Select the end of the copy line', + instructions: '둘째 점 지정 (길이 · <각도 · @x,y):', }, on: { DRAW: { diff --git a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.ts b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.ts index 5aedd0898..933a54d2e 100644 --- a/B07_DesignDetail/openwebcad/src/tools/eraser-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/eraser-tool.ts @@ -60,9 +60,9 @@ export const eraserToolStateMachine = createMachine( }, }, [EraserState.WAITING_FOR_FIRST_CLICK]: { - description: 'Select a line segment to delete', + description: '지울 선 선택', meta: { - instructions: 'Select a line segment to delete', + instructions: '지울 선 선택', }, on: { // TODO implement a DRAW action to draw the segment that will be erased in dotted line diff --git a/B07_DesignDetail/openwebcad/src/tools/image-import-tool.ts b/B07_DesignDetail/openwebcad/src/tools/image-import-tool.ts index 81f9b313b..ab3070427 100644 --- a/B07_DesignDetail/openwebcad/src/tools/image-import-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/image-import-tool.ts @@ -70,9 +70,9 @@ export const imageImportToolStateMachine = createMachine( }, }, [ImageImportState.WAIT_FOR_IMAGE_DATA]: { - description: 'Select an image file to import', + description: '가져올 그림 파일 선택', meta: { - instructions: 'Select an image file to import', + instructions: '가져올 그림 파일 선택', }, on: { [ActorEvent.FILE_SELECTED]: { @@ -85,9 +85,9 @@ export const imageImportToolStateMachine = createMachine( }, }, [ImageImportState.WAITING_FOR_START_POINT]: { - description: 'Select the start point of the imageImport', + description: '그림 첫 모서리 지정:', meta: { - instructions: 'Select the start point of the imageImport', + instructions: '그림 첫 모서리 지정:', }, on: { MOUSE_CLICK: { @@ -101,9 +101,9 @@ export const imageImportToolStateMachine = createMachine( }, }, [ImageImportState.WAITING_FOR_END_POINT]: { - description: 'Select the end point of the imageImport', + description: '그림 맞은 모서리 지정:', meta: { - instructions: 'Select the end point of the imageImport', + instructions: '그림 맞은 모서리 지정:', }, on: { DRAW: { diff --git a/B07_DesignDetail/openwebcad/src/tools/line-tool.ts b/B07_DesignDetail/openwebcad/src/tools/line-tool.ts index 4ea1cb27a..2f02d7f85 100644 --- a/B07_DesignDetail/openwebcad/src/tools/line-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/line-tool.ts @@ -2,6 +2,7 @@ import type { Point } from '@flatten-js/core'; import { LineEntity } from '../entities/LineEntity'; import { addEntities, + deleteEntities, getActiveLayerId, getActiveLineColor, getActiveLineDash, @@ -14,12 +15,22 @@ import { } from '../state'; import { Tool } from '../tools'; import { Actor, assign, createMachine } from 'xstate'; -import type { DrawEvent, PointInputEvent, StateEvent, ToolContext } from './tool.types'; +import type { + DrawEvent, + PointInputEvent, + StateEvent, + TextInputEvent, + ToolContext, +} from './tool.types'; import { selectToolStateMachine } from './select-tool.ts'; import { getPointFromEvent } from '../helpers/get-point-from-event.ts'; export interface LineContext extends ToolContext { startPoint: Point | null; + /** 이번 이어 그리기의 첫 점 — 닫기(C) */ + firstPoint: Point | null; + /** 이번 이어 그리기에서 그린 구간 — 명령취소(U) */ + segments: LineEntity[]; } export enum LineState { @@ -33,6 +44,8 @@ export enum LineAction { RECORD_START_POINT = 'RECORD_START_POINT', DRAW_TEMP_LINE = 'DRAW_TEMP_LINE', DRAW_FINAL_LINE = 'DRAW_FINAL_LINE', + CLOSE_LINE = 'CLOSE_LINE', + UNDO_SEGMENT = 'UNDO_SEGMENT', SWITCH_TO_SELECT_TOOL = 'SWITCH_TO_SELECT_TOOL', } @@ -44,6 +57,8 @@ export const lineToolStateMachine = createMachine( }, context: { startPoint: null, + firstPoint: null, + segments: [], type: Tool.LINE, }, initial: LineState.INIT, @@ -58,7 +73,7 @@ export const lineToolStateMachine = createMachine( [LineState.WAITING_FOR_START_POINT]: { description: 'Select the start point of the line', meta: { - instructions: 'Select the start point of the line', + instructions: '첫 점 지정 (x,y):', }, on: { MOUSE_CLICK: { @@ -77,7 +92,8 @@ export const lineToolStateMachine = createMachine( [LineState.WAITING_FOR_END_POINT]: { description: 'Select the end point of the line', meta: { - instructions: 'Select the end point of the line', + instructions: '다음 점 지정 또는 [닫기(C)/명령취소(U)]:', + options: ['C', 'U'], }, on: { DRAW: { @@ -99,6 +115,21 @@ export const lineToolStateMachine = createMachine( actions: LineAction.DRAW_FINAL_LINE, target: LineState.WAITING_FOR_END_POINT, }, + TEXT_INPUT: [ + { + // 닫기 — 둘째 선부터 · 첫 점으로 잇고 끝 + guard: ({ context, event }) => + (event as TextInputEvent).value === 'C' && context.segments.length >= 2, + actions: LineAction.CLOSE_LINE, + target: LineState.INIT, + }, + { + // 명령취소 — 마지막 구간만 + guard: ({ context, event }) => + (event as TextInputEvent).value === 'U' && context.segments.length > 0, + actions: LineAction.UNDO_SEGMENT, + }, + ], ESC: { target: LineState.INIT, }, @@ -118,6 +149,8 @@ export const lineToolStateMachine = createMachine( setAngleGuideOriginPoint(null); return { startPoint: null, + firstPoint: null, + segments: [], }; }), [LineAction.RECORD_START_POINT]: assign(({ event }) => { @@ -125,6 +158,8 @@ export const lineToolStateMachine = createMachine( setAngleGuideOriginPoint(startPoint); return { startPoint, + firstPoint: startPoint, + segments: [], }; }), [LineAction.DRAW_TEMP_LINE]: ({ context, event }) => { @@ -159,8 +194,28 @@ export const lineToolStateMachine = createMachine( setAngleGuideOriginPoint(endPoint); return { startPoint: endPoint, + segments: [...context.segments, activeLine], }; }), + [LineAction.CLOSE_LINE]: ({ context }) => { + const closing = new LineEntity( + getActiveLayerId(), + context.startPoint as Point, + context.firstPoint as Point + ); + closing.lineColor = getActiveLineColor(); + closing.lineWidth = getActiveLineWidth(); + closing.lineDash = getActiveLineDash(); + addEntities([closing], true); + }, + [LineAction.UNDO_SEGMENT]: assign(({ context }) => { + const last = context.segments[context.segments.length - 1]; + deleteEntities([last], true); + const startPoint = last.getStartPoint(); + setGhostHelperEntities([new LineEntity(getActiveLayerId(), startPoint, startPoint)]); + setAngleGuideOriginPoint(startPoint); + return { startPoint, segments: context.segments.slice(0, -1) }; + }), [LineAction.SWITCH_TO_SELECT_TOOL]: () => { setActiveToolActor(new Actor(selectToolStateMachine)); }, diff --git a/B07_DesignDetail/openwebcad/src/tools/move-tool.ts b/B07_DesignDetail/openwebcad/src/tools/move-tool.ts index a2fe3cbfd..3517c1ce4 100644 --- a/B07_DesignDetail/openwebcad/src/tools/move-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/move-tool.ts @@ -98,7 +98,7 @@ export const moveToolStateMachine = createMachine( [MoveState.WAITING_FOR_SELECTION]: { description: 'Select what you want to move', meta: { - instructions: 'Select what you want to move, then ENTER', + instructions: '이동할 객체 선택 뒤 Enter', }, invoke: { id: 'selectToolInsideTheMoveTool', @@ -137,9 +137,9 @@ export const moveToolStateMachine = createMachine( }, }, [MoveState.WAITING_FOR_START_MOVE_POINT]: { - description: 'Select the start of the move line', + description: '기준점 지정 (x,y):', meta: { - instructions: 'Select the start of the move line', + instructions: '기준점 지정 (x,y):', }, always: { actions: MoveAction.ENABLE_HELPERS, @@ -160,9 +160,9 @@ export const moveToolStateMachine = createMachine( }, }, [MoveState.WAITING_FOR_END_MOVE_POINT]: { - description: 'Select the end of the move line', + description: '둘째 점 지정 (길이 · <각도 · @x,y):', meta: { - instructions: 'Select the end of the move line', + instructions: '둘째 점 지정 (길이 · <각도 · @x,y):', }, on: { DRAW: { diff --git a/B07_DesignDetail/openwebcad/src/tools/pedit-tool.ts b/B07_DesignDetail/openwebcad/src/tools/pedit-tool.ts index d00c3874f..83c7f330b 100644 --- a/B07_DesignDetail/openwebcad/src/tools/pedit-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/pedit-tool.ts @@ -75,7 +75,7 @@ export const peditToolStateMachine = createMachine( [PeditState.WAITING_FOR_SELECTION]: { description: 'Select the entities that you want to combine into a polyline', meta: { - instructions: 'Select what you want to combine into a polyline, then ENTER', + instructions: '폴리선으로 합칠 객체 선택 뒤 Enter', }, invoke: { id: 'selectToolInsideThePeditTool', diff --git a/B07_DesignDetail/openwebcad/src/tools/rectangle-tool.ts b/B07_DesignDetail/openwebcad/src/tools/rectangle-tool.ts index aa375f5d6..afee826d8 100644 --- a/B07_DesignDetail/openwebcad/src/tools/rectangle-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/rectangle-tool.ts @@ -53,9 +53,9 @@ export const rectangleToolStateMachine = createMachine( }, }, [RectangleState.WAITING_FOR_START_POINT]: { - description: 'Select the start point of the rectangle', + description: '첫 모서리 지정 (x,y):', meta: { - instructions: 'Select the start point of the rectangle', + instructions: '첫 모서리 지정 (x,y):', }, on: { MOUSE_CLICK: { @@ -69,9 +69,9 @@ export const rectangleToolStateMachine = createMachine( }, }, [RectangleState.WAITING_FOR_END_POINT]: { - description: 'Select the end point of the rectangle', + description: '맞은 모서리 지정 (@x,y):', meta: { - instructions: 'Select the end point of the rectangle', + instructions: '맞은 모서리 지정 (@x,y):', }, on: { DRAW: { diff --git a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts index c2930e017..619c7e63e 100644 --- a/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/rotate-tool.ts @@ -105,7 +105,7 @@ export const rotateToolStateMachine = createMachine( [RotateState.WAITING_FOR_SELECTION]: { description: 'Select what you want to rotate', meta: { - instructions: 'Select what you want to rotate, then ENTER', + instructions: '회전할 객체 선택 뒤 Enter', }, invoke: { id: 'selectToolInsideTheRotateTool', @@ -144,9 +144,9 @@ export const rotateToolStateMachine = createMachine( }, }, [RotateState.WAITING_FOR_ROTATION_ORIGIN]: { - description: 'Select the origin of the rotate operation', + description: '기준점 지정 (x,y):', meta: { - instructions: 'Select the origin of the rotate operation', + instructions: '기준점 지정 (x,y):', }, always: { actions: RotateAction.ENABLE_HELPERS, @@ -167,9 +167,9 @@ export const rotateToolStateMachine = createMachine( }, }, [RotateState.WAITING_FOR_ANGLE_START_POINT]: { - description: 'Select the end of the base rotate line', + description: '회전 각도 입력 또는 기준선 끝점 지정:', meta: { - instructions: 'Select the end of the base rotate line', + instructions: '회전 각도 입력 또는 기준선 끝점 지정:', }, on: { MOUSE_CLICK: { @@ -195,9 +195,9 @@ export const rotateToolStateMachine = createMachine( }, }, [RotateState.WAITING_FOR_ANGLE_END_POINT]: { - description: 'Select the end of the rotate line', + description: '회전 방향 점 지정:', meta: { - instructions: 'Select the end of the rotate line', + instructions: '회전 방향 점 지정:', }, on: { DRAW: { diff --git a/B07_DesignDetail/openwebcad/src/tools/select-tool.ts b/B07_DesignDetail/openwebcad/src/tools/select-tool.ts index 55a339266..62aa98e41 100644 --- a/B07_DesignDetail/openwebcad/src/tools/select-tool.ts +++ b/B07_DesignDetail/openwebcad/src/tools/select-tool.ts @@ -57,7 +57,7 @@ export const selectToolStateMachine = createMachine( [SelectState.WAITING_FOR_FIRST_SELECT_POINT]: { description: 'Select a line or select the first point of a selection rectangle', meta: { - instructions: 'Select a line or start drawing a selection rectangle', + instructions: '객체 선택 또는 선택 상자 시작', }, on: { MOUSE_CLICK: { @@ -79,7 +79,7 @@ export const selectToolStateMachine = createMachine( [SelectState.CHECK_SELECTION]: { description: 'Checking to select one line or start drawing a selection rectangle', meta: { - instructions: 'Select one line or start drawing a selection rectangle', + instructions: '객체 하나 선택 또는 선택 상자 시작', }, always: [ { @@ -95,9 +95,9 @@ export const selectToolStateMachine = createMachine( ], }, [SelectState.WAITING_FOR_SECOND_SELECT_POINT]: { - description: 'Select the second point of a selection rectangle', + description: '선택 상자 맞은 모서리 지정', meta: { - instructions: 'Select the second point of a selection rectangle', + instructions: '선택 상자 맞은 모서리 지정', }, on: { DRAW: { diff --git a/resources/tester/cad/keyboard-chain.test.ts b/resources/tester/cad/keyboard-chain.test.ts index af5d1a5d6..a10e808fc 100644 --- a/resources/tester/cad/keyboard-chain.test.ts +++ b/resources/tester/cad/keyboard-chain.test.ts @@ -13,9 +13,11 @@ vi.mock("react-toastify", () => ({ import { toast } from "react-toastify"; import { InputController } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-controller"; import { getAngleLock } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-angle-lock"; +import { getSnapOverride } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-snap-override"; import { getActiveToolActor, getEntities, + getLastStateInstructions, setEntities, setSelectedEntityIds, } from "../../../B07_DesignDetail/openwebcad/src/state"; @@ -197,3 +199,83 @@ describe("331-2 이동 · 복사 · 회전 키보드 값", () => { expect(toast.error).not.toHaveBeenCalled(); }); }); + +describe("331-3 선 중 C · U · 한 번 스냅 · 한글 안내", () => { + const drawOpen = () => { + type(controller, "l"); + type(controller, "0,0"); + type(controller, "10,0"); + type(controller, "10,10"); + }; + + it("C = 첫 점으로 닫고 끝 · 안내 「[닫기(C)/명령취소(U)]」", () => { + drawOpen(); + expect(getLastStateInstructions()).toBe("다음 점 지정 또는 [닫기(C)/명령취소(U)]:"); + type(controller, "c"); + const drawn = lines(); + expect(drawn).toHaveLength(3); + expectXy(drawn[2].start, 10, 10); + expectXy(drawn[2].end, 0, 0); + expect(getActiveToolActor()?.getSnapshot()?.value).toBe("WAITING_FOR_START_POINT"); + expect(getLastStateInstructions()).toBe("첫 점 지정 (x,y):"); + }); + + it("C 는 둘째 선부터 — 한 구간이면 무시", () => { + type(controller, "l"); + type(controller, "0,0"); + type(controller, "10,0"); + type(controller, "c"); + expect(lines()).toHaveLength(1); + expect(getActiveToolActor()?.getSnapshot()?.value).toBe("WAITING_FOR_END_POINT"); + }); + + it("U = 마지막 한 구간만 취소 · 그 시작점에서 이어 그림", () => { + drawOpen(); + type(controller, "u"); + expect(lines()).toHaveLength(1); + type(controller, "20,0"); + const drawn = lines(); + expect(drawn).toHaveLength(2); + expectXy(drawn[1].start, 10, 0); + expectXy(drawn[1].end, 20, 0); + }); + + it("선 중 다른 명령 이름은 그대로 명령", () => { + drawOpen(); + type(controller, "circle"); + expect(getActiveToolActor()?.getSnapshot()?.context.type).toBe("CIRCLE"); + }); + + it("END 한 번 — 둘레 안 끝점으로 · 한 점 뒤 풂", () => { + type(controller, "l"); + type(controller, "0,0"); + type(controller, "100,0"); + key(controller, "Escape"); + type(controller, "end"); + expect(getSnapOverride()).toBe("END"); + clickAt(controller, 80, 20); // 끝점 (100, 0) 에서 28 — 일반 스냅(15)은 못 닿음 + expect(getSnapOverride()).toBeNull(); + type(controller, "@0,10"); + const drawn = lines(); + expect(drawn).toHaveLength(2); + expectXy(drawn[1].start, 100, 0); + expectXy(drawn[1].end, 100, 10); + }); + + it("END 둘레에 끝점 없으면 클릭 버림 · Esc 로 풂", () => { + type(controller, "l"); + type(controller, "end"); + clickAt(controller, 500, 500); + expect(getActiveToolActor()?.getSnapshot()?.value).toBe("WAITING_FOR_START_POINT"); + expect(getSnapOverride()).toBe("END"); + key(controller, "Escape"); + expect(getSnapOverride()).toBeNull(); + }); + + it("점 단계가 아니면 END 는 스냅 아님", () => { + key(controller, "Escape"); + key(controller, "Escape"); + type(controller, "end"); + expect(getSnapOverride()).toBeNull(); + }); +}); From 309be22ce0f84c5f5b21afabf9065c11ac83dade Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:44:23 +0900 Subject: [PATCH 20/36] =?UTF-8?q?feat(CAD):=20331-4=20=EB=8F=99=EC=A0=81?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=E2=80=94=20=EC=BB=A4=EC=84=9C=20?= =?UTF-8?q?=EC=98=86=20=EA=B8=B8=EC=9D=B4=20=C2=B7=20=EA=B0=81=EB=8F=84=20?= =?UTF-8?q?=EB=91=90=20=EC=B9=B8=20=C2=B7=20Tab=20=EC=9E=A0=EA=B8=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기준점 있는 점 단계에서만 Tab 가로챔(그 밖은 브라우저 초점 이동) · 숫자 넣고 Tab = 그 칸 잠금 + 다음 칸 · 빈 Tab = 칸만 바꿈 - 각도 칸 잠금 = 각도 고정 · 길이 잠근 채 Enter = 그 길이 · `100` Tab `30` Enter → (86.603, 50) - 커서 옆 줄 `▸[길이 100 🔒] [각도 30_]` — 잠금 🔒 · 지금 칸 ▸ · 나머지는 기준점→커서 실측 - 한 점 · Esc · 새 명령이면 각도 고정 · 길이 잠금 모두 풂(releaseOnePoint) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../openwebcad/src/commands/run-command.ts | 4 +- .../src/inputController/input-angle-lock.ts | 21 +++++ .../src/inputController/input-controller.ts | 87 +++++++++++++++---- .../src/inputController/input-field-draw.ts | 24 +++++ resources/tester/cad/keyboard-chain.test.ts | 58 +++++++++++++ 5 files changed, 177 insertions(+), 17 deletions(-) diff --git a/B07_DesignDetail/openwebcad/src/commands/run-command.ts b/B07_DesignDetail/openwebcad/src/commands/run-command.ts index 0e405565e..f8dae7a06 100644 --- a/B07_DesignDetail/openwebcad/src/commands/run-command.ts +++ b/B07_DesignDetail/openwebcad/src/commands/run-command.ts @@ -2,7 +2,7 @@ import { toast } from 'react-toastify'; import { Actor } from 'xstate'; import { HtmlEvent } from '../App.types'; -import { setAngleLock } from '../inputController/input-angle-lock'; +import { releaseOnePoint } from '../inputController/input-angle-lock'; import { getSelectedEntities, isDrawingReadOnly, notifyWindow, setActiveToolActor } from '../state'; import type { CadCommand } from './command.types'; import { getCommandById, isViewOnlyCommand, resolveCommandInput } from './registry'; @@ -39,7 +39,7 @@ export function runCommand(command: CadCommand): string { } lastCommandId = command.id; - setAngleLock(null); // 앞 명령의 각도 고정은 새 명령에 넘기지 않음 + releaseOnePoint(); // 앞 명령의 각도 고정 · 동적 입력 잠금은 새 명령에 넘기지 않음 if (command.machine) { setActiveToolActor(new Actor(command.machine)); diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-angle-lock.ts b/B07_DesignDetail/openwebcad/src/inputController/input-angle-lock.ts index bee46aac9..509ea4a4c 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-angle-lock.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-angle-lock.ts @@ -1,11 +1,15 @@ /** * 각도 고정 `<각도` — 오토캐드 각도 재지정. 다음 한 점만 기준점에서 그 방향(동쪽 0° 반시계 +). * 커서 · 클릭은 고정 방향 직선 위로 내리고 · 숫자는 그 방향 길이. 점 하나 쓰면 · Esc · 새 명령이면 풂. + * 동적 입력(커서 옆 길이 · 각도 두 칸) — 값 넣고 Tab = 그 칸 잠금 + 다음 칸 · 각도 칸 잠금 = 각도 고정. */ import { Point } from '@flatten-js/core'; import { polarToPoint } from './input-patterns.ts'; let lockedDegrees: number | null = null; +/** 동적 입력 — 지금 칸 · 잠근 길이 */ +let dynamicField: 'length' | 'angle' = 'length'; +let lockedLength: number | null = null; export const getAngleLock = (): number | null => lockedDegrees; @@ -13,6 +17,23 @@ export const setAngleLock = (degrees: number | null) => { lockedDegrees = degrees; }; +export const getDynamicField = () => dynamicField; +export const getLockedLength = () => lockedLength; + +/** 한 점을 쓴 뒤 · Esc · 새 명령 — 각도 고정 · 동적 입력 잠금을 모두 풂 */ +export function releaseOnePoint() { + lockedDegrees = null; + lockedLength = null; + dynamicField = 'length'; +} + +/** Tab — 친 숫자를 지금 칸에 잠그고 다음 칸으로(빈 Tab 은 칸만 바꿈) · 숫자 아니면 그대로 둠 */ +export function tabDynamicInput(value: number | null) { + if (value !== null && dynamicField === 'length') lockedLength = value; + if (value !== null && dynamicField === 'angle') lockedDegrees = value; + dynamicField = dynamicField === 'length' ? 'angle' : 'length'; +} + /** 커서 옆 안내 줄 */ export const angleLockHint = (): string | null => lockedDegrees === null ? null : `각도 ${lockedDegrees}° 고정 — 길이 입력`; diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index 5c257cfc6..896c616d1 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -55,12 +55,25 @@ import { isImeKey, isPlainSpace, } from './ime-sink.ts'; -import { drawInputField } from './input-field-draw.ts'; +import { drawInputField, dynamicInputLine } from './input-field-draw.ts'; import { ctrlShortcut } from './input-shortcut.ts'; import { wheelZoom } from './input-wheel.ts'; import { attachTouch } from './input-touch.ts'; -import { ANGLE_LOCK_REGEXP, parsePointText, type PointTextEvent } from './input-patterns.ts'; -import { angleLockHint, setAngleLock } from './input-angle-lock.ts'; +import { + ANGLE_LOCK_REGEXP, + NUMBER_REGEXP, + parsePointText, + type PointTextEvent, +} from './input-patterns.ts'; +import { + angleLockHint, + getAngleLock, + getDynamicField, + getLockedLength, + releaseOnePoint, + setAngleLock, + tabDynamicInput, +} from './input-angle-lock.ts'; import { getSnapOverride, isSnapName, @@ -132,6 +145,19 @@ export class InputController { const roundedY = round(drawController.getWorldMouseLocation().y, 2); texts.push(`${roundedX},${roundedY}`); } + const base = getAngleGuideOriginPoint(); + if (base && this.dynamicInputApplies()) { + texts.push( + dynamicInputLine( + base, + drawController.getWorldMouseLocation(), + getDynamicField(), + this.text, + getLockedLength(), + getAngleLock() + ) + ); + } texts.push(...compact([angleLockHint(), snapOverrideHint()])); // 입력 중인 문자로 시작하는 명령 후보. 예: C => CIRCLE (C), COPY (CO) texts.push(...this.getCommandSuggestions()); @@ -385,7 +411,13 @@ export class InputController { return; } if (evt.key === 'Tab') { - // Tab => move keyboard focus + // 점 단계(기준점 있음) = 동적 입력 칸 넘김 · 그 밖 = 브라우저 초점 이동 + if (!this.dynamicInputApplies()) return; + evt.preventDefault(); + const isNumber = NUMBER_REGEXP.test(this.text); + if (this.text && !isNumber) return; // 숫자 아닌 글(@x,y 등)은 그대로 둠 + tabDynamicInput(isNumber ? Number.parseFloat(this.text) : null); + this.text = ''; return; } if (evt.ctrlKey && evt.code === 'KeyV') { @@ -451,7 +483,7 @@ export class InputController { } public handleEscapeKey() { - setAngleLock(null); + releaseOnePoint(); setSnapOverride(null); if (getSelectedEntities().length > 0) { // Deselect entities @@ -479,7 +511,11 @@ export class InputController { public handleEnterKey(repeatIfIdle = false) { // submit the text as input to the active tool and clear the input field - if (this.text === '') { + const lockedLength = getLockedLength(); + if (this.text === '' && lockedLength !== null && this.dynamicInputApplies()) { + // 동적 입력 길이를 잠근 채 Enter — 그 길이(방향 = 각도 고정 · 아니면 커서) + this.sendPointEvent(this.distanceEvent(lockedLength)); + } else if (this.text === '') { console.log('ENTER: ', { text: this.text, activeTool: getActiveToolActor(), @@ -502,6 +538,15 @@ export class InputController { } else if (activeToolWantsFreeText()) { // 글 단계(TEXT · MTEXT · 표 칸 …)는 친 그대로 — 숫자 · 명령 이름도 글 this.sendText(); + } else if ( + getDynamicField() === 'angle' && + NUMBER_REGEXP.test(this.text) && + this.dynamicInputApplies() + ) { + // 동적 입력 각도 칸 — 각도 고정 · 길이를 잠갔으면 바로 그 점 + setAngleLock(Number.parseFloat(this.text)); + this.text = ''; + if (lockedLength !== null) this.sendPointEvent(this.distanceEvent(lockedLength)); } else if (ANGLE_LOCK_REGEXP.test(this.text)) { // 각도 고정 `<30` — 다음 한 점만 그 방향 setAngleLock(Number.parseFloat((ANGLE_LOCK_REGEXP.exec(this.text) as RegExpExecArray)[1])); @@ -538,21 +583,33 @@ export class InputController { this.text = ''; } - /** 친 글을 점 · 거리 사건으로 — 거리의 방향 = 스냅 · 가이드 · 커서 */ - private pointEventFromText(): PointTextEvent | null { - return parsePointText( - this.text, - () => - getSnapPointOnAngleGuide()?.point || - getSnapPoint()?.point || - getScreenCanvasDrawController().getWorldMouseLocation() + /** 거리의 방향 = 스냅 · 가이드 · 커서 */ + private distanceCursor() { + return ( + getSnapPointOnAngleGuide()?.point || + getSnapPoint()?.point || + getScreenCanvasDrawController().getWorldMouseLocation() ); } + private distanceEvent(value: number): PointTextEvent { + return { type: ActorEvent.NUMBER_INPUT, value, worldMouseLocation: this.distanceCursor() }; + } + + /** 친 글을 점 · 거리 사건으로 */ + private pointEventFromText(): PointTextEvent | null { + return parsePointText(this.text, () => this.distanceCursor()); + } + + /** 동적 입력(길이 · 각도 칸 · Tab) — 기준점이 있는 점 단계에서만 */ + private dynamicInputApplies(): boolean { + return activeToolWantsPoint() && !!getAngleGuideOriginPoint(); + } + /** 점 하나를 도구에 보냄 — 각도 고정 · 한 번 스냅은 이 한 점으로 끝 */ private sendPointEvent(event: PointTextEvent | MouseClickEvent) { getActiveToolActor()?.send(event); - setAngleLock(null); + releaseOnePoint(); setSnapOverride(null); } diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts b/B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts index ab2d70848..051f42ac2 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts @@ -84,3 +84,27 @@ export function drawInputField( ); }); } + +/** + * 동적 입력 두 칸 — `[길이 100 🔒] ▸[각도 30_]`. 잠근 칸은 🔒 · 지금 칸은 ▸ + 친 글 · + * 나머지는 기준점→커서 실측(각도 = 동쪽 0° 반시계 +). + */ +export function dynamicInputLine( + base: Point, + mouse: Point, + field: 'length' | 'angle', + typing: string, + lockedLength: number | null, + lockedDegrees: number | null +): string { + const live = { + length: Math.hypot(mouse.x - base.x, mouse.y - base.y), + angle: ((Math.atan2(mouse.y - base.y, mouse.x - base.x) * 180) / Math.PI + 360) % 360, + }; + const cell = (name: 'length' | 'angle', label: string, locked: number | null) => { + const active = field === name; + const value = active && typing ? `${typing}_` : (locked ?? Number(live[name].toFixed(2))); + return `${active ? '▸' : ' '}[${label} ${value}${locked !== null ? ' 🔒' : ''}]`; + }; + return `${cell('length', '길이', lockedLength)} ${cell('angle', '각도', lockedDegrees)}`; +} diff --git a/resources/tester/cad/keyboard-chain.test.ts b/resources/tester/cad/keyboard-chain.test.ts index a10e808fc..4f0004e26 100644 --- a/resources/tester/cad/keyboard-chain.test.ts +++ b/resources/tester/cad/keyboard-chain.test.ts @@ -279,3 +279,61 @@ describe("331-3 선 중 C · U · 한 번 스냅 · 한글 안내", () => { expect(getSnapOverride()).toBeNull(); }); }); + +describe("331-4 동적 입력 길이 Tab 각도", () => { + const tab = () => key(controller, "Tab"); + const enter = () => key(controller, "Enter"); + const typed = (text: string) => { + for (const ch of text) key(controller, ch); + }; + + it("100 Tab 30 ⏎ → (86.603, 50)", () => { + type(controller, "l"); + type(controller, "0,0"); + move(controller, 0, 300); + typed("100"); + tab(); + typed("30"); + enter(); + const drawn = lines(); + expect(drawn).toHaveLength(1); + expectXy(drawn[0].end, 86.603, 50); + expect(getAngleLock()).toBeNull(); + }); + + it("빈 Tab → 각도 칸 · 30 Tab(잠금) · 100 ⏎ → (86.603, 50)", () => { + type(controller, "l"); + type(controller, "0,0"); + move(controller, 0, 300); + tab(); + typed("30"); + tab(); + expect(getAngleLock()).toBe(30); + typed("100"); + enter(); + expectXy(lines()[0].end, 86.603, 50); + }); + + it("길이만 잠그고 빈 ⏎ = 커서 방향 그 길이", () => { + type(controller, "l"); + type(controller, "0,0"); + move(controller, 0, 300); + typed("40"); + tab(); + enter(); + expectXy(lines()[0].end, 0, 40); + }); + + it("기준점 없는 단계 · 숫자 아닌 글이면 Tab 은 손대지 않음", () => { + type(controller, "l"); + typed("12"); + tab(); + expect((controller as unknown as { text: string }).text).toBe("12"); + key(controller, "Escape"); // 친 글만 지움 + type(controller, "0,0"); + typed("@5,0"); + tab(); + enter(); + expectXy(lines()[0].end, 5, 0); + }); +}); From aaecfa34380a09d5da28bd23799b947d6bdc40a0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:45:03 +0900 Subject: [PATCH 21/36] =?UTF-8?q?feat(=EC=84=A4=EC=A0=95):=20332-2=20?= =?UTF-8?q?=E2=98=B0=20[=EC=84=A4=EC=A0=95]=20=EC=B0=BD=20=E2=80=94=20?= =?UTF-8?q?=EB=A7=88=EC=9A=B0=EC=8A=A4=20=C2=B7=20CAD=20=EB=8B=A8=EC=B6=95?= =?UTF-8?q?=ED=82=A4=20=C2=B7=20=EB=8B=A4=EB=A5=B8=20=EA=B3=B3=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=95=ED=82=A4=20=ED=83=AD=20=C2=B7=20[=EC=A0=80=EC=9E=A5]?= =?UTF-8?q?=20=EB=95=8C=EB=A7=8C=20=EA=B3=84=EC=A0=95=20pref=20=C2=B7=20?= =?UTF-8?q?=EB=8B=AB=EC=9D=84=20=EB=95=8C=20=EB=AC=BC=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app_shell ☰ 설정 묶음에 [설정] 단추(서랍 닫고 창은 처음 누를 때 불러옴) - 새 app_settings.ts(틀 · 탭 셋 · [저장] · askLeave) · app_settings_cad.ts(명령 표 · 내 별칭 · 검사) · app_settings.css - 새 openwebcad/src/commands/command-catalog.json(명령 153 · 한글 이름 · 기본 별칭) + 거울 시험 - locale 문구(ui_template_locale_common) - 시험: test_app_settings · cad/command-catalog 새로 · test_device_audit · test_touch_bridge 화면 주소 환경값(AISLO_UI_BASE · AISLO_UI_PROJECT) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- A00_Common/app_settings.css | 53 +++++ A00_Common/app_settings.ts | 159 +++++++++++++++ A00_Common/app_settings_cad.ts | 118 +++++++++++ A00_Common/app_shell.ts | 16 +- .../src/commands/command-catalog.json | 155 +++++++++++++++ resources/tester/cad/command-catalog.test.ts | 28 +++ resources/tester/helper_app_settings.cjs | 36 ++++ resources/tester/test_app_settings.py | 187 ++++++++++++++++++ resources/tester/test_device_audit.py | 6 +- resources/tester/test_touch_bridge.py | 3 +- ui_template/ui_template_locale_common.ts | 68 +++++++ 11 files changed, 824 insertions(+), 5 deletions(-) create mode 100644 A00_Common/app_settings.css create mode 100644 A00_Common/app_settings.ts create mode 100644 A00_Common/app_settings_cad.ts create mode 100644 B07_DesignDetail/openwebcad/src/commands/command-catalog.json create mode 100644 resources/tester/cad/command-catalog.test.ts create mode 100644 resources/tester/helper_app_settings.cjs create mode 100644 resources/tester/test_app_settings.py diff --git a/A00_Common/app_settings.css b/A00_Common/app_settings.css new file mode 100644 index 000000000..4ac71bdcf --- /dev/null +++ b/A00_Common/app_settings.css @@ -0,0 +1,53 @@ +/* ☰ [설정] 창(PLAN 332-2) — 탭 줄 · 휠 방향 고름 · 표 */ +.app-settings__tabs { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + padding-bottom: var(--spacing-8); + border-bottom: 1px solid var(--color-border); +} + +.app-settings__radios { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-16); +} + +.app-settings__radios > label { + display: inline-flex; + align-items: center; + gap: var(--spacing-8); + min-height: 32px; + cursor: pointer; +} + +.app-settings__table { + width: 100%; + border-collapse: collapse; +} + +.app-settings__table th, +.app-settings__table td { + padding: var(--spacing-4) var(--spacing-8); + border-bottom: 1px solid var(--color-border); + text-align: left; + vertical-align: middle; +} + +.app-settings__table thead th { + position: sticky; + top: 0; + background: var(--color-surface-raised); +} + +.app-settings__table input[aria-invalid="true"] { + border-color: var(--color-danger); +} + +.app-settings__error { + color: var(--color-danger); +} + +.app-settings__error:empty { + display: none; +} diff --git a/A00_Common/app_settings.ts b/A00_Common/app_settings.ts new file mode 100644 index 000000000..d5ab19607 --- /dev/null +++ b/A00_Common/app_settings.ts @@ -0,0 +1,159 @@ +/* ============================================================================= + * app_settings.ts + * ☰ [설정] 창(PLAN 332-2 · 요구 224-8) — 탭 셋: ① 마우스 ② CAD 단축키 ③ 다른 곳 단축키(읽기 전용). + * + * - 고침은 창 안 사본에만 · [저장] 때만 계정 pref(`input-prefs`)로 한 번 씀 — 읽기는 `input_prefs.ts` + * - 저장 안 한 고침이 있으면 닫을 때 물음(askLeave) · 잘못 있는 별칭은 저장 안 함 + * - 로그인 전엔 이 브라우저에만 남음(계정 올리기는 `b_page_state` 가 로그인 뒤에) + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { askLeave } from "@ui/ui_template_leave_dialog"; +import { t as L } from "@ui/ui_template_locale"; +import { openModal } from "@ui/ui_template_modal"; +import { writeState } from "./b_page_state"; +import { inputPrefs, type InputPrefs, type WheelZoom } from "./input_prefs"; +import { buildCadAliasTab, checkCadAliases } from "./app_settings_cad"; +import "./app_settings.css"; + +type LocaleKey = Parameters[0]; +type TabId = "mouse" | "cad" | "keys"; + +const TABS: [TabId, LocaleKey][] = [ + ["mouse", "App_Settings_Tab_Mouse"], + ["cad", "App_Settings_Tab_Cad"], + ["keys", "App_Settings_Tab_Keys"], +]; + +const WHEEL: [WheelZoom, LocaleKey][] = [ + ["pull", "App_Settings_Wheel_Pull"], + ["push", "App_Settings_Wheel_Push"], +]; + +const MOUSE_RULES: LocaleKey[] = [ + "App_Settings_Rule_Pan", + "App_Settings_Rule_Pick", + "App_Settings_Rule_Right", + "App_Settings_Rule_Pinch", +]; + +/** ③ 안내표 줄 — [자리, 키] */ +const KEY_ROWS: [LocaleKey, LocaleKey][] = [ + ["App_Settings_Keys_All", "App_Settings_Keys_All_Keys"], + ["App_Settings_Keys_Profile", "App_Settings_Keys_Profile_Keys"], + ["App_Settings_Keys_RouteEdit", "App_Settings_Keys_RouteEdit_Keys"], + ["App_Settings_Keys_Section", "App_Settings_Keys_Section_Keys"], + ["App_Settings_Keys_Cad", "App_Settings_Keys_Cad_Keys"], + ["App_Settings_Keys_Table", "App_Settings_Keys_Table_Keys"], + ["App_Settings_Keys_M02", "App_Settings_Keys_M02_Keys"], +]; + +/** 비교 · 저장 꼴 — 별칭 명령 차례를 맞춤 */ +const normalize = (p: InputPrefs): InputPrefs => ({ + wheelZoom: p.wheelZoom, + cadAliases: Object.fromEntries(Object.entries(p.cadAliases).sort(([a], [b]) => (a < b ? -1 : 1))), +}); +const snapshot = (p: InputPrefs): string => JSON.stringify(normalize(p)); + +function mouseTab(draft: InputPrefs): HTMLElement { + const group = el("div", { + className: "app-settings__radios", + attrs: { role: "radiogroup", "aria-label": L("App_Settings_Wheel") }, + }); + for (const [mode, key] of WHEEL) { + const input = el("input", { + attrs: { type: "radio", name: "app-settings-wheel", value: mode }, + }); + input.checked = draft.wheelZoom === mode; + input.addEventListener("change", () => (draft.wheelZoom = mode)); + group.append(el("label", { children: [input, L(key)] })); + } + return el("div", { + children: [ + el("h4", { text: L("App_Settings_Wheel") }), + group, + el("h4", { text: L("App_Settings_Rules") }), + el("ul", { children: MOUSE_RULES.map((key) => el("li", { text: L(key) })) }), + ], + }); +} + +function keysTab(): HTMLElement { + const rows = KEY_ROWS.map(([place, keys]) => + el("tr", { children: [el("th", { text: L(place) }), el("td", { text: L(keys) })] }), + ); + return el("table", { + className: "app-settings__table", + children: [el("tbody", { children: rows })], + }); +} + +/** ☰ [설정] — 창을 열고 지금 설정을 사본으로 고침 */ +export function openAppSettings(): void { + const draft = normalize(inputPrefs()); + let saved = snapshot(draft); + const cad = buildCadAliasTab(draft.cadAliases); + const panels: Record = { + mouse: mouseTab(draft), + cad: cad.root, + keys: keysTab(), + }; + const tabs = el("div", { + className: "app-settings__tabs ui-subtabs", + attrs: { role: "tablist" }, + }); + const show = (id: TabId): void => { + tabs.replaceChildren( + ...TABS.map(([tab, key]) => { + const button = createButton({ + label: L(key), + variant: tab === id ? "filled" : "ghost", + size: "sm", + onClick: () => show(tab), + }); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(tab === id)); + button.dataset.settingsTab = tab; + return button; + }), + ); + for (const [tab, panel] of Object.entries(panels)) panel.hidden = tab !== id; + }; + + /** [저장] — 잘못 있는 별칭이면 CAD 탭을 보이고 안 씀 */ + const save = (): boolean => { + const errors = checkCadAliases(draft.cadAliases); + cad.mark(errors); + if (Object.keys(errors).length) { + show("cad"); + showToast(L("App_Settings_Cad_Invalid"), "error"); + return false; + } + if (snapshot(draft) !== saved) writeState("input-prefs", normalize(draft)); + saved = snapshot(draft); + showToast(L("App_Settings_Saved"), "success"); + return true; + }; + + const saveButton = createButton({ + label: L("Common_Btn_Save"), + variant: "filled", + size: "sm", + onClick: () => void save(), + }); + openModal({ + title: L("App_Settings"), + closeLabel: L("Common_Btn_Close"), + dialogClass: "app-settings", + actions: [saveButton], + beforeClose: async () => { + if (snapshot(draft) === saved) return true; + const choice = await askLeave(L("App_Settings_Leave")); + return choice === "discard" || (choice === "save" && save()); + }, + mount: (body) => { + show("mouse"); + body.append(tabs, ...Object.values(panels)); + }, + }); +} diff --git a/A00_Common/app_settings_cad.ts b/A00_Common/app_settings_cad.ts new file mode 100644 index 000000000..217623f03 --- /dev/null +++ b/A00_Common/app_settings_cad.ts @@ -0,0 +1,118 @@ +/* ============================================================================= + * app_settings_cad.ts + * ☰ [설정] ② CAD 단축키 탭(PLAN 332-2) — 명령 표(명령 · 한글 이름 · 기본 별칭 · 내 별칭) · 검사. + * + * - 명령 목록 = `openwebcad/src/commands/command-catalog.json`(registry 거울 · commands.*.ts 는 xstate 가 딸려 와서 JSON 한 벌) + * - acad.pgp 식 덮어쓰기 — CAD 는 명령 이름 > 내 별칭 > 기본 별칭 순서로 찾음(배선은 332-3) + * - 검사 = 영문 · 숫자만 · 내 별칭끼리 겹침 금지 · 명령 이름과 같은 별칭 금지 + * ========================================================================== */ + +import catalog from "../B07_DesignDetail/openwebcad/src/commands/command-catalog.json"; +import { el } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; + +export interface CadCommandRow { + id: string; + label: string; + aliases: string[]; +} + +export const CAD_COMMANDS = catalog as CadCommandRow[]; + +export type AliasError = "chars" | "dup" | "name"; + +const ERROR_TEXT: Record[0]> = { + chars: "App_Settings_Cad_ErrChars", + dup: "App_Settings_Cad_ErrDup", + name: "App_Settings_Cad_ErrName", +}; + +/** 칸 글 → 별칭 목록(쉼표 · 빈칸으로 가름 · 대문자) */ +export const parseAliases = (text: string): string[] => + text + .toUpperCase() + .split(/[\s,]+/) + .filter(Boolean); + +/** 내 별칭 검사 — `{명령: 첫 잘못}` · 비면 통과 */ +export function checkCadAliases( + aliases: Record, + commands: CadCommandRow[] = CAD_COMMANDS, +): Record { + const ids = new Set(commands.map((c) => c.id)); + const owner = new Map(); + const errors: Record = {}; + for (const [id, list] of Object.entries(aliases)) { + for (const alias of list) { + const first = owner.get(alias); + const error: AliasError | null = !/^[A-Z0-9]+$/.test(alias) + ? "chars" + : ids.has(alias) + ? "name" + : first !== undefined + ? "dup" + : null; + if (!error) owner.set(alias, id); + else { + errors[id] ??= error; + if (first !== undefined) errors[first] ??= "dup"; + } + } + } + return errors; +} + +/** 탭 몸 — `aliases` 를 칸 입력대로 고쳐 씀 · `mark` 로 잘못 칸 표시 */ +export function buildCadAliasTab(aliases: Record): { + root: HTMLElement; + mark: (errors: Record) => void; +} { + const message = el("p", { className: "app-settings__error", attrs: { role: "alert" } }); + const inputs = new Map(); + const rows = CAD_COMMANDS.map((command) => { + const input = el("input", { + className: "ui-input ui-input--sm", + attrs: { type: "text", "aria-label": `${command.id} ${L("App_Settings_Cad_Mine")}` }, + }); + input.value = (aliases[command.id] ?? []).join(", "); + input.dataset.cadCommand = command.id; + input.addEventListener("input", () => { + const list = parseAliases(input.value); + if (list.length) aliases[command.id] = list; + else delete aliases[command.id]; + }); + inputs.set(command.id, input); + const cells = [command.id, command.label, command.aliases.join(", ")].map((text) => + el("td", { text }), + ); + return el("tr", { children: [...cells, el("td", { children: [input] })] }); + }); + const head = el("tr", { + children: ( + [ + "App_Settings_Cad_Command", + "App_Settings_Cad_Label", + "App_Settings_Cad_Default", + "App_Settings_Cad_Mine", + ] as const + ).map((key) => el("th", { text: L(key) })), + }); + const table = el("table", { + className: "app-settings__table", + children: [el("thead", { children: [head] }), el("tbody", { children: rows })], + }); + const mark = (errors: Record): void => { + for (const [id, input] of inputs) { + const error = errors[id]; + if (error) input.setAttribute("aria-invalid", "true"); + else input.removeAttribute("aria-invalid"); + input.title = error ? L(ERROR_TEXT[error]) : ""; + } + const kinds = [...new Set(Object.values(errors))]; + message.textContent = kinds.map((k) => L(ERROR_TEXT[k])).join(" · "); + }; + const root = el("div", { + children: [el("p", { text: L("App_Settings_Cad_Help") }), message, table], + }); + return { root, mark }; +} diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index 13aec256f..8f257690f 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -171,10 +171,14 @@ function buildDrawer(header: HTMLElement): HTMLElement[] { burger.addEventListener("click", () => setOpen(!header.classList.contains("is-drawer-open"))); scrim.addEventListener("click", () => setOpen(false)); header.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false)); // ☰ 에 포커스가 있어도 닫힘 - // 로고 · 진행 단계 · 메뉴 · 로그인 항목을 누르면 닫힘(언어 · 테마 · 스위치는 그대로 둠) + // 로고 · 진행 단계 · 메뉴 · 로그인 · [설정] 을 누르면 닫힘(언어 · 테마 · 스위치는 그대로 둠) drawer.addEventListener("click", (e) => { const hit = (e.target as Element).closest("button"); - if (hit?.closest(".app-drawer__brand, .app-drawer__steps, .app-drawer__nav, .app-drawer__auth")) + if ( + hit?.closest( + ".app-drawer__brand, .app-drawer__steps, .app-drawer__nav, .app-drawer__auth, .app-drawer__settings", + ) + ) setOpen(false); }); @@ -199,6 +203,14 @@ function buildDrawer(header: HTMLElement): HTMLElement[] { onClick: onApp_Theme_Toggle_Click, }), ); + // ☰ [설정](332) — 마우스 · 단축키 창 · 처음 누를 때 불러옴 + const settingsButton = createButton({ + label: L("App_Settings"), + variant: "pill", + onClick: () => void import("./app_settings").then((m) => m.openAppSettings()), + }); + settingsButton.classList.add("app-drawer__settings"); + prefs.append(settingsButton); const auth = document.createElement("div"); auth.className = "app-drawer__row app-drawer__auth"; void fillAuthActions(auth); diff --git a/B07_DesignDetail/openwebcad/src/commands/command-catalog.json b/B07_DesignDetail/openwebcad/src/commands/command-catalog.json new file mode 100644 index 000000000..b430c9d19 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/commands/command-catalog.json @@ -0,0 +1,155 @@ +[ + {"id": "LINE", "label": "선", "aliases": ["L"]}, + {"id": "PLINE", "label": "폴리선", "aliases": ["PL"]}, + {"id": "CIRCLE", "label": "원", "aliases": ["C"]}, + {"id": "ARC", "label": "호", "aliases": ["A"]}, + {"id": "RECTANG", "label": "직사각형", "aliases": ["REC"]}, + {"id": "POLYGON", "label": "다각형", "aliases": ["POL"]}, + {"id": "ELLIPSE", "label": "타원", "aliases": ["EL"]}, + {"id": "SPLINE", "label": "스플라인", "aliases": ["SPL"]}, + {"id": "MLINE", "label": "다중선", "aliases": ["ML"]}, + {"id": "MLSTYLE", "label": "다중선 스타일", "aliases": []}, + {"id": "XLINE", "label": "구성선", "aliases": ["XL"]}, + {"id": "RAY", "label": "광선", "aliases": []}, + {"id": "POINT", "label": "점", "aliases": ["PO"]}, + {"id": "DONUT", "label": "도넛", "aliases": ["DO"]}, + {"id": "DIVIDE", "label": "등분", "aliases": ["DIV"]}, + {"id": "MEASURE", "label": "길이분할", "aliases": ["ME"]}, + {"id": "HATCH", "label": "해치", "aliases": ["H", "BH"]}, + {"id": "GRADIENT", "label": "그라데이션", "aliases": ["GD"]}, + {"id": "BOUNDARY", "label": "경계", "aliases": ["BO"]}, + {"id": "REGION", "label": "영역", "aliases": ["REG"]}, + {"id": "WIPEOUT", "label": "와이프아웃", "aliases": []}, + {"id": "MOVE", "label": "이동", "aliases": ["M"]}, + {"id": "COPY", "label": "복사", "aliases": ["CO", "CP"]}, + {"id": "ROTATE", "label": "회전", "aliases": ["RO"]}, + {"id": "SCALE", "label": "축척", "aliases": ["SC"]}, + {"id": "ALIGN", "label": "정렬", "aliases": ["AL"]}, + {"id": "MIRROR", "label": "대칭", "aliases": ["MI"]}, + {"id": "OFFSET", "label": "간격띄우기", "aliases": ["O"]}, + {"id": "ARRAY", "label": "배열", "aliases": ["AR"]}, + {"id": "TRIM", "label": "자르기", "aliases": ["TR"]}, + {"id": "EXTEND", "label": "연장", "aliases": ["EX"]}, + {"id": "STRETCH", "label": "신축", "aliases": ["S"]}, + {"id": "FILLET", "label": "모깎기", "aliases": ["F"]}, + {"id": "CHAMFER", "label": "모따기", "aliases": ["CHA"]}, + {"id": "BLEND", "label": "곡선 혼합", "aliases": []}, + {"id": "BREAK", "label": "끊기", "aliases": ["BR"]}, + {"id": "BREAKATPOINT", "label": "점에서 끊기", "aliases": []}, + {"id": "JOIN", "label": "결합", "aliases": ["J"]}, + {"id": "EXPLODE", "label": "분해", "aliases": ["X"]}, + {"id": "ERASE", "label": "지우기", "aliases": ["E"]}, + {"id": "LENGTHEN", "label": "길이조정", "aliases": ["LEN"]}, + {"id": "PEDIT", "label": "폴리선 편집", "aliases": ["PE"]}, + {"id": "HATCHEDIT", "label": "해치 편집", "aliases": ["HE"]}, + {"id": "DRAWORDER", "label": "맨 앞으로", "aliases": ["DR"]}, + {"id": "DRAWORDERBACK", "label": "맨 뒤로", "aliases": []}, + {"id": "MATCHPROP", "label": "특성 일치", "aliases": ["MA"]}, + {"id": "OVERKILL", "label": "중복 객체 삭제", "aliases": []}, + {"id": "REVERSE", "label": "방향 반전", "aliases": []}, + {"id": "SETBYLAYER", "label": "ByLayer로", "aliases": []}, + {"id": "ALIGNLEFT", "label": "왼쪽", "aliases": []}, + {"id": "ALIGNCENTERH", "label": "가로 중앙", "aliases": []}, + {"id": "ALIGNRIGHT", "label": "오른쪽", "aliases": []}, + {"id": "ALIGNTOP", "label": "위", "aliases": []}, + {"id": "ALIGNCENTERV", "label": "세로 중앙", "aliases": []}, + {"id": "ALIGNBOTTOM", "label": "아래", "aliases": []}, + {"id": "MTEXT", "label": "여러 줄 문자", "aliases": ["T", "MT"]}, + {"id": "TEXT", "label": "단일 행 문자", "aliases": ["DT"]}, + {"id": "STYLE", "label": "문자 스타일", "aliases": ["ST"]}, + {"id": "TEXTEDIT", "label": "문자 편집", "aliases": ["TEDIT"]}, + {"id": "FIND", "label": "찾기·대치", "aliases": []}, + {"id": "DIM", "label": "치수", "aliases": []}, + {"id": "DIMLINEAR", "label": "선형 치수", "aliases": ["DLI"]}, + {"id": "DIMALIGNED", "label": "정렬 치수", "aliases": ["DAL"]}, + {"id": "DIMANGULAR", "label": "각도 치수", "aliases": ["DAN"]}, + {"id": "DIMARC", "label": "호 길이 치수", "aliases": ["DAR"]}, + {"id": "DIMRADIUS", "label": "반지름 치수", "aliases": ["DRA"]}, + {"id": "DIMDIAMETER", "label": "지름 치수", "aliases": ["DDI"]}, + {"id": "DIMORDINATE", "label": "세로좌표 치수", "aliases": ["DOR"]}, + {"id": "DIMBASELINE", "label": "기준선 치수", "aliases": ["DBA"]}, + {"id": "DIMCONTINUE", "label": "연속 치수", "aliases": ["DCO"]}, + {"id": "QDIM", "label": "빠른 치수", "aliases": []}, + {"id": "CENTERMARK", "label": "중심 표식", "aliases": []}, + {"id": "CENTERLINE", "label": "중심선", "aliases": []}, + {"id": "DIMSTYLE", "label": "치수 스타일", "aliases": ["D"]}, + {"id": "DIMUPDATE", "label": "치수 업데이트", "aliases": []}, + {"id": "DIMSPACE", "label": "치수 간격", "aliases": []}, + {"id": "MLEADER", "label": "다중 지시선", "aliases": ["MLD"]}, + {"id": "MLEADERSTYLE", "label": "지시선 스타일", "aliases": ["MLS"]}, + {"id": "MLEADERALIGN", "label": "지시선 정렬", "aliases": []}, + {"id": "TABLE", "label": "테이블", "aliases": ["TB"]}, + {"id": "TABLESTYLE", "label": "테이블 스타일", "aliases": ["TS"]}, + {"id": "TABLEEDIT", "label": "칸 편집", "aliases": []}, + {"id": "TABLEROW", "label": "행 넣기·지우기", "aliases": []}, + {"id": "TABLECOL", "label": "열 넣기·지우기", "aliases": []}, + {"id": "TABLEMERGE", "label": "칸 병합", "aliases": []}, + {"id": "TABLEUNMERGE", "label": "병합 해제", "aliases": []}, + {"id": "PLACETABLE", "label": "표 놓기", "aliases": []}, + {"id": "REVCLOUD", "label": "구름형 리비전", "aliases": []}, + {"id": "ANNOSCALE", "label": "주석 축척", "aliases": []}, + {"id": "LAYER", "label": "도면층 특성", "aliases": ["LA"]}, + {"id": "LAYCURSET", "label": "현재 도면층 설정", "aliases": []}, + {"id": "LAYOFF", "label": "도면층 끄기", "aliases": []}, + {"id": "LAYON", "label": "모두 켜기", "aliases": []}, + {"id": "LAYFRZ", "label": "동결", "aliases": []}, + {"id": "LAYTHW", "label": "동결 해제", "aliases": []}, + {"id": "LAYLCK", "label": "잠금", "aliases": []}, + {"id": "LAYULK", "label": "잠금 해제", "aliases": []}, + {"id": "LAYISO", "label": "분리", "aliases": []}, + {"id": "LAYUNISO", "label": "분리 해제", "aliases": []}, + {"id": "LAYERP", "label": "이전 상태", "aliases": []}, + {"id": "LAYERSTATE", "label": "도면층 상태 저장", "aliases": ["LAS"]}, + {"id": "LAYERSTATERESTORE", "label": "도면층 상태 복원", "aliases": []}, + {"id": "LAYMCH", "label": "도면층 일치", "aliases": []}, + {"id": "LAYCUR", "label": "현재 도면층으로", "aliases": []}, + {"id": "LAYMRG", "label": "도면층 병합", "aliases": []}, + {"id": "LAYDEL", "label": "도면층 삭제", "aliases": []}, + {"id": "LAYWALK", "label": "도면층 탐색", "aliases": []}, + {"id": "COLOR", "label": "색상", "aliases": ["COL"]}, + {"id": "LINETYPE", "label": "선종류", "aliases": ["LT"]}, + {"id": "LWEIGHT", "label": "선가중치", "aliases": ["LW"]}, + {"id": "PROPERTIES", "label": "특성", "aliases": ["CH", "MO", "PR"]}, + {"id": "QUICKPROPERTIES", "label": "빠른 특성", "aliases": ["QP"]}, + {"id": "TRANSPARENCY", "label": "투명도", "aliases": []}, + {"id": "GROUP", "label": "그룹", "aliases": ["G"]}, + {"id": "UNGROUP", "label": "그룹 해제", "aliases": []}, + {"id": "DIST", "label": "거리", "aliases": ["DI"]}, + {"id": "RADIUS", "label": "반지름", "aliases": []}, + {"id": "ANGLE", "label": "각도", "aliases": []}, + {"id": "AREA", "label": "면적", "aliases": ["AA"]}, + {"id": "MEASUREGEOM", "label": "빠른 측정", "aliases": ["MEA"]}, + {"id": "ID", "label": "점 좌표", "aliases": []}, + {"id": "LIST", "label": "리스트", "aliases": ["LI"]}, + {"id": "QSELECT", "label": "빠른 선택", "aliases": []}, + {"id": "QUICKCALC", "label": "계산기", "aliases": ["QC"]}, + {"id": "SELECTSIMILAR", "label": "유사 선택", "aliases": []}, + {"id": "ISOLATEOBJECTS", "label": "객체 분리", "aliases": []}, + {"id": "HIDEOBJECTS", "label": "객체 숨기기", "aliases": []}, + {"id": "UNISOLATEOBJECTS", "label": "객체 분리 종료", "aliases": ["UNHIDE", "UNISOLATE"]}, + {"id": "COPYCLIP", "label": "복사", "aliases": []}, + {"id": "CUTCLIP", "label": "잘라내기", "aliases": []}, + {"id": "PASTECLIP", "label": "붙여넣기", "aliases": []}, + {"id": "COPYBASE", "label": "기준점 복사", "aliases": []}, + {"id": "PASTEORIG", "label": "원래 좌표로 붙여넣기", "aliases": []}, + {"id": "PASTEBLOCK", "label": "그룹으로 붙여넣기", "aliases": []}, + {"id": "IMAGEATTACH", "label": "이미지 부착", "aliases": ["IAT"]}, + {"id": "BLOCK", "label": "블록 작성", "aliases": ["B"]}, + {"id": "INSERT", "label": "블록 삽입", "aliases": ["I"]}, + {"id": "SELECT", "label": "선택", "aliases": ["SE"]}, + {"id": "ZOOM", "label": "범위 줌", "aliases": ["Z"]}, + {"id": "ZOOMIN", "label": "확대", "aliases": []}, + {"id": "ZOOMOUT", "label": "축소", "aliases": []}, + {"id": "REGEN", "label": "재생성", "aliases": ["RE"]}, + {"id": "PAN", "label": "초점이동", "aliases": ["P"]}, + {"id": "QSAVE", "label": "저장", "aliases": ["SAVE"]}, + {"id": "RECOVER", "label": "백업 복구", "aliases": []}, + {"id": "EXPORT", "label": "JSON 내보내기", "aliases": ["EXP"]}, + {"id": "EXPORTDXF", "label": "DXF 내보내기", "aliases": []}, + {"id": "EXPORTDWG", "label": "DWG 내보내기", "aliases": []}, + {"id": "EXPORTSVG", "label": "SVG 내보내기", "aliases": []}, + {"id": "EXPORTPNG", "label": "PNG 내보내기", "aliases": []}, + {"id": "TOAREA", "label": "면적산출로 보내기", "aliases": []}, + {"id": "UNDO", "label": "실행 취소", "aliases": ["U"]}, + {"id": "REDO", "label": "다시 실행", "aliases": []} +] diff --git a/resources/tester/cad/command-catalog.test.ts b/resources/tester/cad/command-catalog.test.ts new file mode 100644 index 000000000..69da00b2a --- /dev/null +++ b/resources/tester/cad/command-catalog.test.ts @@ -0,0 +1,28 @@ +/** + * CAD 명령 목록 거울(PLAN 332-2) — `commands/command-catalog.json` 이 registry(`commands.*.ts`)와 같은지. + * ☰ [설정] CAD 단축키 탭은 xstate 가 딸려 오지 않게 JSON 만 읽음 — 명령을 더하거나 별칭을 바꾸면 JSON 도 같이. + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad command-catalog + */ +import { describe, expect, it } from "vitest"; + +import catalog from "../../../B07_DesignDetail/openwebcad/src/commands/command-catalog.json"; +import { getAllCommands } from "../../../B07_DesignDetail/openwebcad/src/commands/registry"; + +describe("command-catalog.json", () => { + it("registry 와 같은 명령 · 한글 이름 · 기본 별칭 · 차례", () => { + const live = getAllCommands().map((c) => ({ + id: c.id, + label: c.label, + aliases: c.aliases ?? [], + })); + expect(catalog).toEqual(live); + }); + + it("기본 별칭 = 영문 · 숫자 · 안 겹침 · 명령 이름과 다름", () => { + const ids = new Set(catalog.map((c) => c.id)); + const aliases = catalog.flatMap((c) => c.aliases); + expect(aliases.every((a) => /^[A-Z0-9]+$/.test(a))).toBe(true); + expect(new Set(aliases).size).toBe(aliases.length); + expect(aliases.filter((a) => ids.has(a))).toEqual([]); + }); +}); diff --git a/resources/tester/helper_app_settings.cjs b/resources/tester/helper_app_settings.cjs new file mode 100644 index 000000000..7c2fb6357 --- /dev/null +++ b/resources/tester/helper_app_settings.cjs @@ -0,0 +1,36 @@ +/* PLAN 332-2 단위검증 헬퍼 — ☰ [설정] CAD 별칭 검사(`A00_Common/app_settings_cad.ts`)를 Node 로 돌림. + * 결과 = 표준출력 JSON 한 벌. 짝 = `test_app_settings.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 file = path.join(ROOT, "A00_Common", "app_settings_cad.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, +}).outputText; +const box = { exports: {} }; +const req = (p) => + p.endsWith(".json") + ? require(path.join(ROOT, "A00_Common", p)) + : p === "@ui/ui_template_locale" + ? { t: (k) => k } + : {}; +new Function("exports", "module", "require", js)(box.exports, box, req); +const m = box.exports; + +const check = (aliases) => m.checkCadAliases(aliases); +process.stdout.write( + JSON.stringify({ + commands: m.CAD_COMMANDS.length, + parse: [m.parseAliases(" ll, Cc x1 "), m.parseAliases(""), m.parseAliases(",, ")], + ok: check({ LINE: ["LL"], CIRCLE: ["CC", "C2"], COPY: ["C"] }), + chars: check({ LINE: ["L!"], CIRCLE: ["원"] }), + dup: check({ LINE: ["LL"], CIRCLE: ["LL"], ARC: ["AA", "AA"] }), + name: check({ CIRCLE: ["LINE"] }), + }), +); diff --git a/resources/tester/test_app_settings.py b/resources/tester/test_app_settings.py new file mode 100644 index 000000000..f78cab044 --- /dev/null +++ b/resources/tester/test_app_settings.py @@ -0,0 +1,187 @@ +"""PLAN 332-2 — ☰ [설정] 창(`A00_Common/app_settings.ts` · `app_settings_cad.ts` · `app_shell.ts`). + +① CAD 별칭 검사(Node) — 영문 · 숫자만 · 겹침 금지 · 명령 이름 금지 · 칸 글 나눔 +② 실제 브라우저(sub2 화면 서버 5177 · 로그인 없음 = 이 브라우저만 · 새 창이라 남는 것 없음) + ☰ → [설정] = 서랍 닫히고 창 · 탭 셋 전환 · 고쳐도 [저장] 전엔 안 씀 · 닫을 때 물음(취소 = 머묾 · + 버림 = 안 씀 · 저장 = 한 번) · [저장] 한 번 = 한 번 씀 · 저장 뒤 읽기 층 방향 뒤집힘 · 잘못 별칭은 안 씀 +""" + +from __future__ import annotations + +import json +import os +import subprocess +import urllib.request + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = os.path.join(HERE, "..", "..") +BASE = "http://localhost:5177" # sub2 화면 서버(OWNERS 자기 줄) +KEY = "aislo:pref:input-prefs" + +# 앱 스크립트보다 먼저 — 설정 키에 쓰는 횟수를 셈 +COUNT_WRITES = f""" +window.__writes = []; +const set = Storage.prototype.setItem; +Storage.prototype.setItem = function (k, v) {{ + if (k === {json.dumps(KEY)}) window.__writes.push(v); + return set.call(this, k, v); +}}; +""" +# 앱이 불러온 그 읽기 층 사본(vite 가 ?t= 를 붙였을 수 있음) +WHEEL_IN = ( + "async (d) => (await import(performance.getEntriesByType('resource')" + ".map((e) => e.name).find((n) => n.includes('/input_prefs.ts')))).wheelIn(d)" +) + + +@pytest.fixture(scope="module") +def node() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_app_settings.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_별칭_검사(node): + assert node["commands"] == 153 + assert node["parse"] == [["LL", "CC", "X1"], [], []] + assert node["ok"] == {} # 남의 기본 별칭(C = CIRCLE)을 내 것으로 덮는 건 됨(acad.pgp 식) + assert node["chars"] == {"LINE": "chars", "CIRCLE": "chars"} + assert node["dup"] == {"LINE": "dup", "CIRCLE": "dup", "ARC": "dup"} + assert node["name"] == {"CIRCLE": "name"} + + +def _up() -> bool: + try: + urllib.request.urlopen(BASE, timeout=3) + return True + except Exception: + return False + + +def _open_settings(page): + page.click(".app-burger") + page.click(".app-drawer__settings") + page.wait_for_selector(".app-settings") + assert not page.evaluate( + "document.querySelector('.app-header, header')?.classList.contains('is-drawer-open')" + ) + + +def _writes(page) -> list: + return page.evaluate("window.__writes") + + +def _visible_panel_has(page, selector: str) -> bool: + return page.evaluate( + "(s) => [...document.querySelectorAll('.app-settings .ui-modal__body > div:not([role]), " + ".app-settings .ui-modal__body > table')].some((p) => !p.hidden && p.querySelector(s))", + selector, + ) + + +def _save(page): + page.click(".app-settings .ui-modal__actions button") + + +def _close(page): + page.click(".app-settings .ui-modal__top > button") + + +@pytest.mark.skipif(not _up(), reason="sub2 화면 서버(5177) 꺼짐") +def test_설정_창_탭_저장_물음(): + from playwright.sync_api import sync_playwright + + with sync_playwright() as p: + browser = p.chromium.launch(channel="msedge", headless=True) + ctx = browser.new_context( + viewport={"width": 390, "height": 844}, has_touch=True, is_mobile=True + ) + ctx.add_init_script(COUNT_WRITES) + page = ctx.new_page() + page.goto(f"{BASE}/#/a01-home") + page.wait_for_selector(".app-burger") + assert page.evaluate(WHEEL_IN, 100) is True # 기본 = 당김 + + # ☰ → [설정] · 탭 셋 · 첫 탭 = 마우스 + _open_settings(page) + tabs = page.evaluate( + "[...document.querySelectorAll('.app-settings [role=tab]')]" + ".map((b) => [b.dataset.settingsTab, b.getAttribute('aria-selected')])" + ) + assert tabs == [["mouse", "true"], ["cad", "false"], ["keys", "false"]] + assert _visible_panel_has(page, "input[type=radio]") + page.click("[data-settings-tab=cad]") + assert _visible_panel_has(page, "input[data-cad-command]") + assert not _visible_panel_has(page, "input[type=radio]") + assert page.locator(".app-settings input[data-cad-command]").count() == 153 + page.click("[data-settings-tab=keys]") + assert ( + page.evaluate( + "document.querySelector('[data-settings-tab=keys]').getAttribute('aria-selected')" + ) + == "true" + ) + page.click("[data-settings-tab=mouse]") + + # 밀기 고름 — [저장] 전엔 안 씀 · 닫으면 물음 · 취소 = 머묾 + page.check("input[name=app-settings-wheel][value=push]") + assert _writes(page) == [] + page.keyboard.press("Escape") + page.wait_for_selector(".ui-confirm") + page.click(".ui-confirm button >> nth=0") # 취소 + assert page.locator(".app-settings").count() == 1 and _writes(page) == [] + + # [저장] = 한 번 씀 · 읽기 층 방향 뒤집힘 · 닫을 때 안 물음 + _save(page) + assert len(_writes(page)) == 1 + assert json.loads(_writes(page)[0]) == {"wheelZoom": "push", "cadAliases": {}} + assert page.evaluate(WHEEL_IN, 100) is False and page.evaluate(WHEEL_IN, -100) is True + _save(page) # 고친 것 없음 — 또 안 씀 + assert len(_writes(page)) == 1 + _close(page) + assert page.locator(".app-settings").count() == 0 + assert page.locator(".ui-confirm").count() == 0 + + # CAD 별칭 — 잘못이면 안 씀 · 칸 표시 + _open_settings(page) + assert page.is_checked("input[name=app-settings-wheel][value=push]") # 저장값으로 엶 + page.click("[data-settings-tab=cad]") + line = "input[data-cad-command=LINE]" + circle = "input[data-cad-command=CIRCLE]" + for line_v, circle_v in (("L!", ""), ("LL", "ll"), ("", "LINE")): + page.fill(line, line_v) + page.fill(circle, circle_v) + _save(page) + assert page.inner_text(".app-settings__error").strip(), (line_v, circle_v) + assert page.locator(".app-settings input[aria-invalid=true]").count() >= 1 + assert len(_writes(page)) == 1 + page.fill(line, "ll") + page.fill(circle, "") + _save(page) + assert page.inner_text(".app-settings__error").strip() == "" + assert len(_writes(page)) == 2 + assert json.loads(_writes(page)[1]) == {"wheelZoom": "push", "cadAliases": {"LINE": ["LL"]}} + + # 닫을 때 물음 — 버림 = 안 씀 · 저장 = 한 번 더 + page.click("[data-settings-tab=mouse]") + page.check("input[name=app-settings-wheel][value=pull]") + _close(page) + page.click(".ui-confirm button >> nth=1") # 버리고 넘어감 + assert page.locator(".app-settings").count() == 0 and len(_writes(page)) == 2 + assert page.evaluate(WHEEL_IN, 100) is False + _open_settings(page) + page.check("input[name=app-settings-wheel][value=pull]") + _close(page) + page.click(".ui-confirm button >> nth=2") # 저장 후 넘어감 + assert page.locator(".app-settings").count() == 0 and len(_writes(page)) == 3 + assert json.loads(_writes(page)[2])["wheelZoom"] == "pull" + assert page.evaluate(WHEEL_IN, 100) is True + browser.close() diff --git a/resources/tester/test_device_audit.py b/resources/tester/test_device_audit.py index dae54241d..aaf6d0404 100644 --- a/resources/tester/test_device_audit.py +++ b/resources/tester/test_device_audit.py @@ -12,14 +12,16 @@ sub1 서버(5174)에서 모든 페이지를 네 크기로 열어 문제 수를 """ import json +import os import sys from pathlib import Path import pytest ROOT = Path(__file__).resolve().parents[2] -BASE = "http://localhost:5174" -PROJECT_NAME = "검증_sub1" +# 다른 창 서버로 돌릴 때 — AISLO_UI_BASE · AISLO_UI_PROJECT(OWNERS 자기 줄) +BASE = os.environ.get("AISLO_UI_BASE", "http://localhost:5174") +PROJECT_NAME = os.environ.get("AISLO_UI_PROJECT", "검증_sub1") PAGES = [ "a01-home", "a02-prog-detail", diff --git a/resources/tester/test_touch_bridge.py b/resources/tester/test_touch_bridge.py index a56c75620..d6c6b4f88 100644 --- a/resources/tester/test_touch_bridge.py +++ b/resources/tester/test_touch_bridge.py @@ -8,11 +8,12 @@ sub1 화면 서버(5174) 첫 화면을 터치 기기로 열고 시험용 상자 """ import json +import os import urllib.request import pytest -BASE = "http://localhost:5174" +BASE = os.environ.get("AISLO_UI_BASE", "http://localhost:5174") # 다른 창 서버 = 환경값 def _up() -> bool: diff --git a/ui_template/ui_template_locale_common.ts b/ui_template/ui_template_locale_common.ts index ed38a2035..f78b3f6c8 100644 --- a/ui_template/ui_template_locale_common.ts +++ b/ui_template/ui_template_locale_common.ts @@ -109,6 +109,74 @@ export const ui_locales_common = { "그림 위를 길게 누르면 메뉴 · 두 손가락으로 확대", "Long-press the drawing for the menu · pinch to zoom", ], + /* ☰ [설정] 창(332) — 마우스 · CAD 단축키 · 다른 곳 단축키 */ + App_Settings: ["설정", "Settings"], + App_Settings_Tab_Mouse: ["마우스", "Mouse"], + App_Settings_Tab_Cad: ["CAD 단축키", "CAD shortcuts"], + App_Settings_Tab_Keys: ["다른 곳 단축키", "Other shortcuts"], + App_Settings_Wheel: ["휠 확대 방향(모든 화면)", "Wheel zoom-in direction (all views)"], + App_Settings_Wheel_Pull: ["당기면 확대(휠을 몸 쪽으로)", "Pull to zoom in (wheel toward you)"], + App_Settings_Wheel_Push: ["밀면 확대(휠을 앞으로)", "Push to zoom in (wheel away)"], + App_Settings_Rules: ["마우스 단추", "Mouse buttons"], + App_Settings_Rule_Pan: ["화면 옮기기 = 가운데 단추 끌기", "Pan = drag with the middle button"], + App_Settings_Rule_Pick: ["고르기 = 왼쪽 단추", "Select = left button"], + App_Settings_Rule_Right: [ + "오른쪽 단추 = 메뉴 · CAD 에서는 Enter", + "Right button = menu · Enter in CAD", + ], + App_Settings_Rule_Pinch: [ + "트랙패드 · 손가락 벌림 = 늘 확대(휠 방향과 상관없음)", + "Trackpad · finger spread = always zoom in (whatever the wheel direction)", + ], + App_Settings_Cad_Help: [ + "CAD 는 명령 이름 > 내 별칭 > 기본 별칭 차례로 찾음 · 영문 · 숫자만 · 여러 개는 쉼표로", + "CAD looks up command name > my alias > default alias · letters and digits only · comma for several", + ], + App_Settings_Cad_Command: ["명령", "Command"], + App_Settings_Cad_Label: ["이름", "Name"], + App_Settings_Cad_Default: ["기본 별칭", "Default alias"], + App_Settings_Cad_Mine: ["내 별칭", "My alias"], + App_Settings_Cad_ErrChars: ["영문 · 숫자만 씀", "Letters and digits only"], + App_Settings_Cad_ErrDup: ["같은 별칭이 두 번 있음", "Same alias used twice"], + App_Settings_Cad_ErrName: ["명령 이름과 같은 별칭은 못 씀", "Alias cannot be a command name"], + App_Settings_Cad_Invalid: ["별칭에 잘못이 있어 저장 안 함", "Not saved — fix the aliases"], + App_Settings_Saved: ["설정 저장함", "Settings saved"], + App_Settings_Leave: ["저장 안 한 설정이 있음", "You have unsaved settings"], + App_Settings_Keys_All: ["모든 화면", "All views"], + App_Settings_Keys_All_Keys: [ + "Esc = ☰ · 창 닫기 · 고름 풀기 · Z 누른 채 또는 오른쪽 0.4초 누름 = 돋보기(휠 = 배율)", + "Esc = ☰ · close window · clear selection · hold Z or right button 0.4 s = loupe (wheel = zoom)", + ], + App_Settings_Keys_Profile: ["B05 종단", "B05 profile"], + App_Settings_Keys_Profile_Keys: [ + "Ctrl+Z 되돌림 · Ctrl+Shift+Z 다시 · 방향키 = 옮김 · 자유지정 Enter = 끝 · Esc · Backspace", + "Ctrl+Z undo · Ctrl+Shift+Z redo · arrows = move · free mode Enter = finish · Esc · Backspace", + ], + App_Settings_Keys_RouteEdit: ["B05 노선 편집", "B05 route edit"], + App_Settings_Keys_RouteEdit_Keys: [ + "Ctrl+Z 되돌림 · Ctrl+Y · Ctrl+Shift+Z 다시", + "Ctrl+Z undo · Ctrl+Y · Ctrl+Shift+Z redo", + ], + App_Settings_Keys_Section: ["B06 횡단", "B06 cross section"], + App_Settings_Keys_Section_Keys: [ + "방향키 · ▲▼ = 측점 옮김 · Enter · Space = 고름 · Esc", + "Arrows · ▲▼ = move station · Enter · Space = pick · Esc", + ], + App_Settings_Keys_Cad: ["CAD", "CAD"], + App_Settings_Keys_Cad_Keys: [ + "F3 스냅 · F7 그리드 · F8 직교 · F10 45° · Ctrl+Z · Ctrl+Y · Ctrl+A · Ctrl+S(M02) · Delete · Esc · Enter · Space = Enter · 방향키 = 화면 옮김 · 글자 = 명령", + "F3 snap · F7 grid · F8 ortho · F10 45° · Ctrl+Z · Ctrl+Y · Ctrl+A · Ctrl+S (M02) · Delete · Esc · Enter · Space = Enter · arrows = pan · letters = command", + ], + App_Settings_Keys_Table: ["B08 · B09 표", "B08 · B09 tables"], + App_Settings_Keys_Table_Keys: [ + "Enter · F2 = 입력 · 방향키 = 칸 옮김 · Esc = 그만", + "Enter · F2 = edit · arrows = move cell · Esc = cancel", + ], + App_Settings_Keys_M02: ["M02 양식", "M02 templates"], + App_Settings_Keys_M02_Keys: [ + "Ctrl+S 저장 · Ctrl+C · Ctrl+V · Delete · Esc · Space+끌기 = 화면 옮김 · Ctrl+휠 = 배율 · 시트 = 엑셀 키", + "Ctrl+S save · Ctrl+C · Ctrl+V · Delete · Esc · Space+drag = pan · Ctrl+wheel = zoom · sheet = Excel keys", + ], App_Footer_Copyright: [ "© 2026 임도설계 및 견적 자동화. All rights reserved.", "© 2026 Forest Road Design & Estimation. All rights reserved.", From 34659db663e16df46d1cf5e987b888e9cefa0e0b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:47:42 +0900 Subject: [PATCH 22/36] =?UTF-8?q?feat(CAD):=20332-3=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EB=B0=B0=EC=84=A0=20=E2=80=94=20=ED=9C=A0=20=ED=99=95?= =?UTF-8?q?=EB=8C=80=20=EB=B0=A9=ED=96=A5=20=C2=B7=20=EB=82=B4=20=EB=B3=84?= =?UTF-8?q?=EC=B9=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 휠 — A00_Common/input_prefs 방향(기본 당김 = 지금과 같음 · 밀기 저장 뒤 뒤집힘) · 트랙패드 핀치는 늘 벌리면 확대(옛 CAD 는 벌리면 축소) - 별칭 — 명령 이름 > 내 별칭(cadAliases) > 기본 별칭 · 자동완성 후보 맨 앞 · 부를 때마다 읽어 저장 즉시 따름 - tsconfig include 에 input_prefs.ts · 시험 resources/tester/cad/input-prefs-cad.test.ts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../openwebcad/src/commands/registry.ts | 21 +++- .../src/inputController/input-wheel.test.ts | 4 +- .../src/inputController/input-wheel.ts | 11 ++- B07_DesignDetail/openwebcad/tsconfig.app.json | 2 +- resources/tester/cad/input-prefs-cad.test.ts | 97 +++++++++++++++++++ 5 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 resources/tester/cad/input-prefs-cad.test.ts diff --git a/B07_DesignDetail/openwebcad/src/commands/registry.ts b/B07_DesignDetail/openwebcad/src/commands/registry.ts index f6974dcb2..581c2716b 100644 --- a/B07_DesignDetail/openwebcad/src/commands/registry.ts +++ b/B07_DesignDetail/openwebcad/src/commands/registry.ts @@ -23,6 +23,7 @@ import { } from './commands.utility'; import { ALIGN_COMMANDS, MODIFY_COMMANDS } from './commands.modify'; import { VIEW_COMMANDS } from './commands.view'; +import { inputPrefs } from '../../../../A00_Common/input_prefs'; const ALL_COMMANDS: CadCommand[] = [ ...DRAW_COMMANDS, @@ -112,11 +113,24 @@ export const TOOL_STATE_MACHINES = Object.fromEntries( ]) ) as Record; -/** 명령행 입력 해석 — 정확한 이름·별칭이 먼저, 없으면 접두사 후보의 첫 항목 */ +/** 내 별칭(☰ [설정] · `cadAliases`) 이 가리키는 명령들 — 부를 때마다 읽어 저장 즉시 따름 */ +function userAliasCommands(match: (alias: string) => boolean): CadCommand[] { + return Object.entries(inputPrefs().cadAliases).flatMap(([id, aliases]) => { + const command = COMMANDS_BY_ID.get(id); + return command && aliases.some(match) ? [command] : []; + }); +} + +/** 명령행 입력 해석 — 이름 > 내 별칭 > 기본 별칭(acad.pgp 식), 없으면 접두사 후보의 첫 항목 */ export function resolveCommandInput(input: string): CadCommand | undefined { const text = input.trim().toUpperCase(); if (!text) return undefined; - return COMMANDS_BY_ID.get(text) ?? COMMANDS_BY_ALIAS.get(text) ?? matchCommandPrefixes(text)[0]; + return ( + COMMANDS_BY_ID.get(text) ?? + userAliasCommands((alias) => alias === text)[0] ?? + COMMANDS_BY_ALIAS.get(text) ?? + matchCommandPrefixes(text)[0] + ); } /** 접두사가 같은 명령 후보 (커서 옆 자동완성 목록에 쓴다) */ @@ -127,7 +141,8 @@ export function matchCommandPrefixes(input: string): CadCommand[] { (command.aliases ?? []).some((alias) => alias.startsWith(text)) ); const byId = ALL_COMMANDS.filter((command) => command.id.startsWith(text)); - return [...new Set([...byAlias, ...byId])]; + const byUser = userAliasCommands((alias) => alias.startsWith(text)); + return [...new Set([...byUser, ...byAlias, ...byId])]; } /** 명령행 자동완성에 보여줄 문자열 (예: "LINE (L) 선") */ diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-wheel.test.ts b/B07_DesignDetail/openwebcad/src/inputController/input-wheel.test.ts index 3bc6389ce..299400f90 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-wheel.test.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-wheel.test.ts @@ -22,8 +22,8 @@ describe('CAD 휠 = 늘 줌(153-3)', () => { expect(wheel(-1, 2)).toEqual({ delta: WHEEL_PAGE_PX, exponent: WHEEL_ZOOM_EXPONENT }); }); - it('ctrl+휠(핀치)은 핀치 지수', () => { - expect(wheel(-7, 0, true)).toEqual({ delta: 7, exponent: PINCH_ZOOM_EXPONENT }); + it('ctrl+휠(핀치)은 핀치 지수 · 벌리면(deltaY < 0) 확대(332)', () => { + expect(wheel(-7, 0, true)).toEqual({ delta: -7, exponent: PINCH_ZOOM_EXPONENT }); }); it('델타 0 은 안 움직임', () => { diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-wheel.ts b/B07_DesignDetail/openwebcad/src/inputController/input-wheel.ts index f6ea49e8b..db4242522 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-wheel.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-wheel.ts @@ -4,6 +4,7 @@ import { WHEEL_PAGE_PX, WHEEL_ZOOM_EXPONENT, } from '../App.consts.ts'; +import { wheelEventIn } from '../../../../A00_Common/input_prefs'; /** * 휠 한 번 → 화면 확대(`zoomScreen` 인자). 휠은 늘 줌 · 커서 자리 고정(153-3) — 마우스 · 트랙패드를 @@ -17,8 +18,12 @@ export function wheelZoom( const unitPx = evt.deltaMode === 1 ? WHEEL_LINE_PX : evt.deltaMode === 2 ? WHEEL_PAGE_PX : 1; const deltaY = evt.deltaY * unitPx; if (deltaY === 0) return null; - // Aislo: wheel direction is inverted on purpose - pulling the wheel zooms in, pushing - // zooms out, matching the B04/B05 maps and the B06 cross sections (2026-08-02). + // 방향 = ☰ [설정] 휠 확대 방향(기본 당김 = 몸 쪽으로 굴리면 확대 · 모든 화면 일체) · + // 트랙패드 핀치는 늘 벌리면 확대. zoomScreen 은 음수 = 확대. // 핀치(ctrl+휠)는 델타가 잘아 지수만 크게. - return { delta: -deltaY, exponent: evt.ctrlKey ? PINCH_ZOOM_EXPONENT : WHEEL_ZOOM_EXPONENT }; + const size = Math.abs(deltaY); + return { + delta: wheelEventIn({ ctrlKey: evt.ctrlKey, deltaY }) ? -size : size, + exponent: evt.ctrlKey ? PINCH_ZOOM_EXPONENT : WHEEL_ZOOM_EXPONENT, + }; } diff --git a/B07_DesignDetail/openwebcad/tsconfig.app.json b/B07_DesignDetail/openwebcad/tsconfig.app.json index 5ce3e3e8e..87ee7f72c 100644 --- a/B07_DesignDetail/openwebcad/tsconfig.app.json +++ b/B07_DesignDetail/openwebcad/tsconfig.app.json @@ -25,6 +25,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src", "../../A00_Common/key_shortcut.ts"], + "include": ["src", "../../A00_Common/key_shortcut.ts", "../../A00_Common/input_prefs.ts"], "exclude": ["node_modules"] } diff --git a/resources/tester/cad/input-prefs-cad.test.ts b/resources/tester/cad/input-prefs-cad.test.ts new file mode 100644 index 000000000..18731237f --- /dev/null +++ b/resources/tester/cad/input-prefs-cad.test.ts @@ -0,0 +1,97 @@ +/** + * CAD 설정 배선(PLAN 332-3) — ☰ [설정] 휠 확대 방향 · 내 별칭(`A00_Common/input_prefs.ts`). + * 휠 = 기본 당김(지금과 같음) · 밀기 저장 뒤 뒤집힘 · 핀치는 늘 벌리면 확대. + * 별칭 = 명령 이름 > 내 별칭 > 기본 별칭 · 저장 즉시 따름. + * 실행: cd B07_DesignDetail/openwebcad && npx vitest run --dir ../../resources/tester/cad input-prefs-cad + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { INPUT_PREFS_KEY } from "../../../A00_Common/input_prefs"; +import { wheelZoom } from "../../../B07_DesignDetail/openwebcad/src/inputController/input-wheel"; +import { + matchCommandPrefixes, + resolveCommandInput, +} from "../../../B07_DesignDetail/openwebcad/src/commands/registry"; +import { getActiveToolActor } from "../../../B07_DesignDetail/openwebcad/src/state"; +import { initApplication } from "../../../B07_DesignDetail/openwebcad/test/helpers/init-application"; + +const store = new Map(); + +const savePrefs = (value: unknown) => store.set(INPUT_PREFS_KEY, JSON.stringify(value)); + +beforeEach(() => { + store.clear(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const wheel = (deltaY: number, ctrlKey = false) => + wheelZoom({ deltaY, deltaMode: 0, ctrlKey })?.delta; + +describe("휠 확대 방향", () => { + it("저장 없음 = 당김(몸 쪽 deltaY > 0 → 확대 = 음수 delta) — 지금과 같음", () => { + expect(wheel(100)).toBe(-100); + expect(wheel(-100)).toBe(100); + }); + + it("밀기 저장 → 뒤집힘 · 깨진 값 = 당김", () => { + savePrefs({ wheelZoom: "push" }); + expect(wheel(100)).toBe(100); + expect(wheel(-100)).toBe(-100); + store.set(INPUT_PREFS_KEY, "{깨짐"); + expect(wheel(100)).toBe(-100); + }); + + it("트랙패드 핀치(ctrl · 잔 델타)는 설정과 상관없이 벌리면(deltaY < 0) 확대", () => { + expect(wheel(-7, true)).toBe(-7); + expect(wheel(7, true)).toBe(7); + savePrefs({ wheelZoom: "push" }); + expect(wheel(-7, true)).toBe(-7); + // 마우스 Ctrl+휠(큰 델타)은 설정 방향 + expect(wheel(100, true)).toBe(100); + }); +}); + +describe("내 별칭", () => { + it("기본 별칭 그대로 · 내 별칭이 기본 별칭을 이김 · 명령 이름이 내 별칭을 이김", () => { + expect(resolveCommandInput("L")?.id).toBe("LINE"); + savePrefs({ cadAliases: { CIRCLE: ["L"], ARC: ["LINE"] } }); + expect(resolveCommandInput("l")?.id).toBe("CIRCLE"); + expect(resolveCommandInput("LINE")?.id).toBe("LINE"); + }); + + it("새 별칭 · 자동완성 후보 맨 앞 · 없는 명령 이름은 무시", () => { + savePrefs({ cadAliases: { RECTANG: ["QQ"], NOPE: ["QZ"] } }); + expect(resolveCommandInput("QQ")?.id).toBe("RECTANG"); + expect(matchCommandPrefixes("Q")[0]?.id).toBe("RECTANG"); + expect(resolveCommandInput("QZ")?.id).not.toBe("NOPE"); + }); + + it("CAD 키 입력 — 「qq␣」 = 사각형 명령", () => { + savePrefs({ cadAliases: { RECTANG: ["QQ"] } }); + const controller = initApplication(); + for (const k of ["q", "q", " "]) { + controller.handleKeyStroke({ + key: k, + code: "", + keyCode: 0, + isComposing: false, + shiftKey: false, + ctrlKey: false, + altKey: false, + metaKey: false, + repeat: false, + target: null, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent); + } + expect(getActiveToolActor()?.getSnapshot()?.context.type).toBe("RECTANGLE"); + }); +}); From 510b36eea2fe7cab21f431fa2265aa1d167f985f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:50:31 +0900 Subject: [PATCH 23/36] =?UTF-8?q?fix(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4):=20330=20=EB=8D=A7=EA=B3=A0=EC=B9=A8=20?= =?UTF-8?q?=E2=80=94=20=ED=9B=84=EB=B3=B4=20=EB=8B=A4=EC=84=AF=EC=97=90=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=201=EC=88=9C=EC=9C=84=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=20=C2=B7=20=EB=82=B1=EB=A7=90=20=EC=95=88=20=EA=B2=B9=EC=B9=98?= =?UTF-8?q?=EB=8A=94=20=EA=B7=BC=EA=B1=B0=20=ED=9B=84=EB=B3=B4=20=EB=92=A4?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20=EC=9C=84=20=EC=B8=B5=20=EA=B2=A9=EC=9E=90?= =?UTF-8?q?=20=EC=9D=BD=EA=B8=B0=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Basis.merge 같은 점수는 이름 겹침 큰 쪽 · 이름 · 낱말(괄호 글 포함 · 인력 · 기계 · 장비 뺌) 안 겹치는 근거 후보는 근거 맞음(조항 · 직종 · 단위)이 아니면 겹치는 근거 후보 뒤로 - Basis.pick 다섯 안에 이름 1순위 하나 늘 남김 · Match match_line 이 씀 - 시험 basis(제3호표 구조물터파기 다섯 = 굴착(인력/토사) 1위 · 육상토사 안 · 터널굴착 밖 · pick) · match(이름 1순위 자리) · grid(고르기 줄 층 · 주인 그대로 회사 · 시스템 격자 읽기) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- M02_MasterTemplete/M02_Hopyo_Basis.py | 51 ++++++++++++++++++++---- M02_MasterTemplete/M02_Hopyo_Match.py | 3 +- resources/tester/test_m02_hopyo_basis.py | 11 +++++ resources/tester/test_m02_hopyo_grid.py | 13 ++++++ resources/tester/test_m02_hopyo_match.py | 1 + 5 files changed, 70 insertions(+), 9 deletions(-) diff --git a/M02_MasterTemplete/M02_Hopyo_Basis.py b/M02_MasterTemplete/M02_Hopyo_Basis.py index e4fcf4458..7d615ee34 100644 --- a/M02_MasterTemplete/M02_Hopyo_Basis.py +++ b/M02_MasterTemplete/M02_Hopyo_Basis.py @@ -8,7 +8,10 @@ 직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로). 견줌(`candidates`) = 같은 책 조항 같음 1.0 · 앞 마디 같음 0.5(조항이 안 맞으면 후보 아님) · 건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 · -같은 점수는 글자 두 개 묶음(괄호 글 포함)이 가름. 자동 확정 없음 — 맞춤 차례는 `M02_Hopyo_Match`. +같은 점수는 글자 두 개 묶음(괄호 글 포함)이 가름 · 이름 · 낱말(괄호 글 포함 · 인력 · 기계 뺌)이 +하나도 안 겹치는 근거 후보는(근거 맞음 = 조항 · 직종 · 단위 다 맞으면 빼고) 겹치는 근거 후보 뒤로 · +다섯 안에 이름 1순위 하나는 늘 남김(`pick`). +자동 확정 없음 — 맞춤 차례는 `M02_Hopyo_Match`. M01 · 원본 로직은 읽기만. """ @@ -29,6 +32,7 @@ _HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$") _BOOK = re.compile(r"품[셈줌]") _TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]") _TYPOS = (("깍기", "깎기"),) +_PLAIN_WORDS = frozenset({"인력", "기계", "장비"}) # 방법 낱말 — 「터널굴착 1발파당 인력」 _MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"} @@ -179,8 +183,14 @@ def _text(line: dict[str, Any]) -> str: return text +def _loose_words(text: str) -> frozenset[str]: + """괄호 글까지 뜻 낱말 — 방법 낱말 뺌.""" + return hopyo_match.words(text.replace("(", " ").replace(")", " ")) - _PLAIN_WORDS + + def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any]]: - """근거 후보 — 줄 `근거.출처` 가 없으면 빈 것 · `{로직, 이름, 단위, 점수, 근거}` 점수 차례.""" + """근거 후보 — 줄 `근거.출처` 가 없으면 빈 것 · `{로직, 이름, 단위, 근거, _겹침, _낱말}` + 점수 차례(`_` 칸은 `merge` 가 쓰고 뺌).""" basis = line.get("근거") or {} theirs = [list(one) for one in basis.get("출처") or []] if not theirs: @@ -189,6 +199,7 @@ def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any] text = _text(line) mine = hopyo_match.words(text) # 괄호 글 뺌 — 「잔토처리(인력)」 ↔ 「철골재 철거(인력)」 grams = hopyo_match._grams(hopyo_match.mm.plain(text)) # 괄호 글 포함 — 같은 점수 가름 + loose = _loose_words(text) table = ours() scored = [] for one in got["로직"]: @@ -214,18 +225,42 @@ def candidates(line: dict[str, Any], got: dict[str, Any]) -> list[dict[str, Any] tags.append("단위 맞음") dice = 2 * len(grams & one["grams"]) / (len(grams) + len(one["grams"])) why = {"조항": clauses, "직종": shared, "단위": unit, "점수": round(score, 3)} - scored.append( - (score, dice, {**hopyo_match._public(one), "근거": {**why, "까닭": " · ".join(tags)}}) - ) + theirs_loose = _loose_words(one["이름"]) + overlap = any(a in b or b in a for a in loose for b in theirs_loose) + full = tag == "조항 같음" and unit and (bool(shared) or not trades) # 근거 맞음 + why["까닭"] = " · ".join(tags) + extra = {"_겹침": dice, "_낱말": overlap or full} + scored.append((score, dice, {**hopyo_match._public(one), "근거": why, **extra})) scored.sort(key=lambda x: (-x[0], -x[1])) return [one for _, _, one in scored] def merge(basis: list[dict[str, Any]], named: list[dict[str, Any]]) -> list[dict[str, Any]]: - """근거 후보 + 이름 후보 → 로직 키로 합침(점수 = 이름 점수 + 근거 점수) · 점수 차례.""" + """근거 후보 + 이름 후보 → 로직 키로 합침(점수 = 이름 점수 + 근거 점수) · 점수 차례 · + 같으면 이름 겹침 큰 쪽 · 이름 · 낱말 안 겹치는 근거 후보는 겹치는 근거 후보 뒤로.""" if not basis: return named by_name = {c.get("로직"): c["점수"] for c in named} - out = [{**c, "점수": round(c["근거"]["점수"] + by_name.pop(c["로직"], 0), 3)} for c in basis] + out = [] + for c in basis: + named_too = c["로직"] in by_name + score = round(c["근거"]["점수"] + by_name.pop(c["로직"], 0), 3) + out.append({**c, "점수": score, "_낱말": c["_낱말"] or named_too}) out += [c for c in named if c.get("로직") in by_name] - return sorted(out, key=lambda c: -c["점수"]) + out.sort(key=lambda c: (-c["점수"], -c.get("_겹침", 0))) + loose = [c for c in out if c.get("_낱말") is False] + if loose and any(c.get("_낱말") for c in out): + out = [c for c in out if c.get("_낱말") is not False] + last = max(at for at, c in enumerate(out) if c.get("_낱말")) + out[last + 1 : last + 1] = loose + return [{k: v for k, v in c.items() if not k.startswith("_")} for c in out] + + +def pick(ordered: list[dict[str, Any]], first: Any, room: int) -> list[dict[str, Any]]: + """후보 `room` 개 — 이름 1순위(`first` 로직 키) 하나는 늘 남기고 나머지는 차례대로.""" + if room <= 0: + return [] + if first is None or all(c.get("로직") != first for c in ordered): + return ordered[:room] + others = [c for c in ordered if c.get("로직") != first][: room - 1] + return [c for c in ordered if c.get("로직") == first or any(c is o for o in others)] diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index c9ccbc557..c45f24f13 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -409,7 +409,8 @@ def match_line( basis = hopyo_basis.candidates(line, got) if pool == "로직" else [] named = candidates(line, pool, got, None if basis else 5) rest = [c for c in hopyo_basis.merge(basis, named) if _public(c) not in same] - out["후보"] = (top + rest)[:5] + first = next((c.get("로직") for c in named if _public(c) not in same), None) + out["후보"] = top[:5] + hopyo_basis.pick(rest, first if basis else None, 5 - len(top)) return {**out, "상태": "후보" if out["후보"] else "없음"} diff --git a/resources/tester/test_m02_hopyo_basis.py b/resources/tester/test_m02_hopyo_basis.py index 526bfd4cb..94d21bf83 100644 --- a/resources/tester/test_m02_hopyo_basis.py +++ b/resources/tester/test_m02_hopyo_basis.py @@ -74,6 +74,9 @@ def test_근거_후보(north): "점수": 1.75, "까닭": "조항 같음 · 건설 공통 · 직종 특별인부 · 단위 맞음", } + codes = [c["로직"] for c in dig] + assert "GF000101" in codes and "GC000346" not in codes # 이름 1순위 남김 · 낱말 안 겹침 뒤로 + assert all("_겹침" not in c and "_낱말" not in c for c in dig) assert _top(north, 21)[0]["로직"] == "GC000198" assert "GC000143" in [c["로직"] for c in _top(north, 64)[:2]] assert _top(north, 49)[0]["로직"] == "GC000085" @@ -83,6 +86,14 @@ def test_근거_후보(north): assert master_hash() == before +def test_이름_1순위_자리(): + ordered = [{"로직": k} for k in "ABCDEFG"] + assert [c["로직"] for c in hb.pick(ordered, "G", 5)] == ["A", "B", "C", "D", "G"] + assert [c["로직"] for c in hb.pick(ordered, "B", 5)] == ["A", "B", "C", "D", "E"] + assert [c["로직"] for c in hb.pick(ordered, None, 2)] == ["A", "B"] + assert hb.pick(ordered, "G", 0) == [] + + def test_근거_없는_줄_그대로(north): bare = [ x diff --git a/resources/tester/test_m02_hopyo_grid.py b/resources/tester/test_m02_hopyo_grid.py index 16ba2b303..58c6cac90 100644 --- a/resources/tester/test_m02_hopyo_grid.py +++ b/resources/tester/test_m02_hopyo_grid.py @@ -110,6 +110,19 @@ def test_역할_읽기(client, role, layer, owner, want): assert client.get(GRID.format(layer, "HP-000001"), params=params).status_code == want +@pytest.mark.parametrize("layer", ["company", "system"]) +def test_위_층_격자_읽기(client, layer): + """아래 층 사용자가 고르기 줄(층 · 주인) 그대로 위 층 파일 격자를 읽음.""" + client.who.update({"role": "SYSTEM_ADMIN"}) + assert _post(client, layer).status_code == 200 + client.who.update({"role": "USER"}) + rows = client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"] + row = next(r for r in rows if r["층"] == layer) + params = {"owner": row["주인"]} if row["주인"] else {} + res = client.get(GRID.format(row["층"], row["키"]), params=params) + assert res.status_code == 200 and len(res.json()["호표"]) == 68 + + def test_xlsx_값만(): data = _xlsx() grid = hg.read("a.xlsx", data, hx.read_book("a.xlsx", data)) diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index 69f8cd397..1e8743fca 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -106,6 +106,7 @@ def test_이름_같음_후보_호표_퍼센트(client): dig = _hopyo(client, 3)["구조물터파기(인력100%)"] assert dig["상태"] == "후보" and len(dig["후보"]) == 5 assert dig["후보"][0]["이름"] == "굴착(인력/토사)" # 330-2 근거(건설 3-2-1 · 특별인부) 1순위 + assert any(c["이름"].startswith("구조물터파기") for c in dig["후보"]) # 이름 1순위 자리 ref = _hopyo(client, 45)["고화매트재료비"] assert ref["상태"] == "호표" and ref["대상"] == {"호표": 46, "단위": "㎡"} and ref["들어감"] pct = _hopyo(client, 46)["잡재료비"] From 20b2deeddd376ba04c3103001a693f8b5981a1ac Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:50:43 +0900 Subject: [PATCH 24/36] =?UTF-8?q?fix(B05=EC=B6=94=EC=B2=9C):=20328-2=20?= =?UTF-8?q?=ED=81=B0=20R=20=E2=80=94=20=EB=8B=A8=EC=88=9C=ED=99=94?= =?UTF-8?q?=EA=B0=80=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=20=EC=9C=84=EB=B0=98?= =?UTF-8?q?=EC=9D=84=20=EB=8A=98=EB=A6=AC=EB=8A=94=20=EA=B8=89=ED=95=9C=20?= =?UTF-8?q?=EC=A7=80=ED=98=95(=EA=B2=80=EC=A6=9D=5Fsub8)=EC=97=90=EC=84=9C?= =?UTF-8?q?=202=EB=8B=A8=EC=9D=84=20=EC=B5=9C=EC=86=8C=20R=20=ED=8C=90=20?= =?UTF-8?q?=EB=85=B8=EB=93=9C=EC=97=90=20=EB=B0=94=EB=A1=9C=20=EA=B1=B4=20?= =?UTF-8?q?=ED=8C=90=EC=9D=84=201=EB=8B=A8=EB=B3=B4=EB=8B=A4=20=EB=A8=BC?= =?UTF-8?q?=EC=A0=80=20=C2=B7=20=EC=8B=A4=EC=B8=A1=20=EB=84=A4=20=EA=B0=88?= =?UTF-8?q?=EB=9E=98=20R=2012=20=E2=86=92=2018/29/147?= 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_01Se7ytqbohmBQnfYpwYFnYf --- B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py index 1f7e2d3df..999230c66 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py @@ -107,9 +107,10 @@ def _trace( traced: dict[tuple, Route], **kw: Any, ) -> Route: - # 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 1단 판(탐색 경로를 - # 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반이 최소 R 판(노드마다 곡선 · 옛 규칙) - # 보다 많지 않은 첫 판. 셋 다 화면이 [확인] 때 같은 선으로 다시 짜는 꼴. + # 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 2단을 최소 R 판 + # 노드에 바로 건 판(단순화가 기울기 위반을 늘리는 급한 지형 · 검증_sub8 실측) → 1단 판(탐색 + # 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반이 최소 R 판(노드마다 곡선 · + # 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시 짜는 꼴. # 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로). key = (tuple(nodes), tuple(sorted(kw.items()))) if key not in traced: @@ -119,7 +120,7 @@ def _trace( least = (floor, measure(floor, **kw)) path = densify_route(nodes, TRACE_DENSIFY_M) fitted = build_planned_polyline(path, min_radius_m=min_radius_m) - larger = (widen(fitted, path, min_radius_m), fitted) + larger = (widen(fitted, path, min_radius_m), widen(floor, path, min_radius_m), fitted) traced[key] = next( ( route From c4a09c1ca228fd37ed3e09ab9d9293853146e4c4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:52:08 +0900 Subject: [PATCH 25/36] =?UTF-8?q?fix(B05=EC=B6=94=EC=B2=9C):=20328=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EC=A0=80=EC=9E=A5=EB=B3=B8=20ALGO=5FVERSI?= =?UTF-8?q?ON=201=20=E2=86=92=202=20=E2=80=94=20=ED=81=B0=20R=20=EB=A1=9C?= =?UTF-8?q?=20=EC=85=88=EC=9D=B4=20=EB=B0=94=EB=80=8C=EC=96=B4=20=EC=98=9B?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=EB=B3=B8(R=2012)=20=EB=AC=B4=ED=9A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf --- B05_Profile/B05_Profile_Engine_RouteInitial_Store.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py index 5f4cd944b..976917ab1 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Store.py @@ -31,7 +31,7 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import ( ) from common_util.common_util_json import atomic_write_json -ALGO_VERSION = 1 +ALGO_VERSION = 2 STORE_DIR = "B05_Profile/route_recommend" # 업로드 체인 끝에 미리 셈할 기본 다섯 알약(화면 차례) · 추적은 묶음이 선 뒤라 `similar` 가 채워짐 DEFAULT_MODES = ("gradeline", "earthwork", "gentle", "weighted", "follow") From a8d7832c8b7bc38a74af37bd4727fe6ce2c20430 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:52:42 +0900 Subject: [PATCH 26/36] =?UTF-8?q?test(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4):=20329=20=C2=B7=20330=20=EC=9D=B4=EC=9D=8C?= =?UTF-8?q?=EB=A7=A4=20=E2=80=94=20=EC=8B=A4=EC=A0=9C=20=EA=B2=A9=EC=9E=90?= =?UTF-8?q?=20=C2=B7=20=EB=A7=9E=EC=B6=A4=20=EC=9D=91=EB=8B=B5=EC=9D=84=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=8F=84=EC=9A=B0=EB=AF=B8(Hopyo=5FSheet?= =?UTF-8?q?=20=C2=B7=20Hopyo=5FView)=EC=97=90=20=EB=A8=B9=EC=9D=B4?= =?UTF-8?q?=EB=8A=94=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - helper_m02_hopyo_seam.cjs · test_m02_hopyo_seam — 행 → 호표(25 빈 줄) · 영역 B20:O24 · 단산93 행 · 엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 대상(GC000083 · GC000143) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- resources/tester/helper_m02_hopyo_seam.cjs | 55 ++++++++++++++++++++++ resources/tester/test_m02_hopyo_seam.py | 47 ++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 resources/tester/helper_m02_hopyo_seam.cjs create mode 100644 resources/tester/test_m02_hopyo_seam.py diff --git a/resources/tester/helper_m02_hopyo_seam.cjs b/resources/tester/helper_m02_hopyo_seam.cjs new file mode 100644 index 000000000..faa75042a --- /dev/null +++ b/resources/tester/helper_m02_hopyo_seam.cjs @@ -0,0 +1,55 @@ +/* M02 일위대가 실무자료 이음매(329 · 330) 헬퍼 — 실제 서버 응답(격자 · 호표 맞춤)을 + * `M02_MasterTemplete_Hopyo_Sheet.ts`(행 → 호표 · 영역 A1) · `M02_MasterTemplete_Hopyo_View.ts` + * (근거 꼬리표 · 엑셀 근거 줄 · 근거 맞음 대상)에 먹임 · + * stdin = {grid, lines} · stdout = {at, span, rows, lines: [{이름, source, tag, ok, pick}]} 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 STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } }; +const STUB_FILES = ["M02_MasterTemplete_Labor_Pick", "M02_Material_Pick"]; +const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) }); +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 (STUB_FILES.some((name) => request.endsWith(name))) return stub; + if (request.startsWith(".") && !request.endsWith(".css")) + return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); + return stub; + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const M02 = path.join(ROOT, "M02_MasterTemplete"); +const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts")); +const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts")); +const { grid } = input; +const ilwi = grid.책.시트.find((s) => s.이름 === "일위대가"); +const three = grid.호표.find((h) => h.번 === 3); +const out = { + at: [19, 20, 22, 24, 25, 26].map((row) => sheet.hopyoAtRow(grid.호표, row)), + span: sheet.spanText(three.행, sheet.lastColumn(ilwi)), + rows: sheet.rowsText(grid.단산.find((b) => b.번 === 93).행), + lines: input.lines.map((line) => { + const top = (line.맞춤.후보 ?? [])[0]; + return { + 이름: line.이름, + source: view.sourceText(line.근거), + tag: view.basisTag(top?.근거), + ok: view.basisOk(top?.근거), + pick: view.basisPick(line)?.로직 ?? null, + }; + }), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_m02_hopyo_seam.py b/resources/tester/test_m02_hopyo_seam.py new file mode 100644 index 000000000..0fd197766 --- /dev/null +++ b/resources/tester/test_m02_hopyo_seam.py @@ -0,0 +1,47 @@ +"""일위대가 실무자료 이음매(PLAN 329 · 330) — 실제 서버 응답(북부청 격자 · 호표 맞춤)을 +화면 도우미(`helper_m02_hopyo_seam.cjs` → Hopyo_Sheet · Hopyo_View)에 먹임. + +행 22 → 제3호표 · 25 빈 줄 · 26 → 제4호표 · 영역 B20:O24 · 단산93 행 4229:4244 · +엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 대상. +""" + +import json +import os +import subprocess + +from resources.tester import test_m02_hopyo_excel as base +from resources.tester.test_m02_hopyo_excel import URL, _post + +HERE = os.path.dirname(os.path.abspath(__file__)) +client = base.client # 공용 고정물(임시 층) + + +def _node(payload: dict) -> dict: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_hopyo_seam.cjs")], + input=json.dumps(payload, 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_실제_응답_화면_도우미(client): + assert _post(client).status_code == 200 + grid = client.get("/api/m02/layers/personal/hopyo-grid/HP-000001").json() + lines = [] + for no in (3, 12, 38, 58): + res = client.get(f"{URL.format('personal')}/HP-000001/{no}").json() + lines += [x for x in res["호표"]["줄"] if x.get("근거")] + got = _node({"grid": grid, "lines": lines}) + assert got["at"] == [None, 3, 3, 3, None, 4] # 25행 = 호표 사이 빈 줄 + assert got["span"] == "B20:O24" and got["rows"] == "4229:4244" + by = {x["이름"]: x for x in got["lines"]} + dig = by["구조물터파기(인력100%)"] + assert dig["source"] == "출처 건설품셈 3-2-1 · 직종 특별인부 · 단가산출 4229–4244행" + assert dig["tag"] == "조항 건설품셈 공통 3-2-1 · 직종 특별인부 · 단위 같음" + assert dig["ok"] is True and dig["pick"] == "GC000083" + assert by["비탈면 보호공"]["pick"] == "GC000143" From 1a029141f8a8ea7e9374f49bb3f3fa97c4f55bc6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 12:57:25 +0900 Subject: [PATCH 27/36] =?UTF-8?q?fix(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4):=20330=20=EB=8D=A7=EA=B3=A0=EC=B9=A8=202=20?= =?UTF-8?q?=E2=80=94=20=ED=99=94=EB=A9=B4=20=EA=B7=BC=EA=B1=B0=20=EB=A7=9E?= =?UTF-8?q?=EC=9D=8C=20=ED=8C=90=EC=A0=95=EC=9D=84=20=EC=A1=B0=ED=95=AD=20?= =?UTF-8?q?=EA=B0=99=EC=9D=8C=20=C2=B7=20=EB=8B=A8=EC=9C=84=20=C2=B7=20?= =?UTF-8?q?=EC=A7=81=EC=A2=85(=EC=A4=84=20=EC=A7=81=EC=A2=85=EC=9D=B4=20?= =?UTF-8?q?=EB=B3=B4=ED=86=B5=EC=9D=B8=EB=B6=80=EB=BF=90=20=C2=B7=20?= =?UTF-8?q?=EC=97=86=EC=9C=BC=EB=A9=B4=20=EC=95=88=20=EB=B4=84)=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hopyo_View basisOk(후보 근거, 줄 근거) — 앞 마디 같음은 근거 맞음 아님 · 엑셀 직종 없는 줄(기계)도 조항 같음 + 단위면 맞음 · basisPick · 고르기 창 꼬리표가 줄 근거를 넘김 - 시험 screen(앞 마디 · 보통인부뿐 · 직종 없음) · seam(북부청 실제 응답 1순위 근거 맞음 123 = 서버 기준 · 앞 마디 0) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- .../M02_MasterTemplete_Hopyo_View.ts | 16 ++++++++++------ resources/tester/helper_m02_hopyo_pick.cjs | 12 +++++++++--- resources/tester/helper_m02_hopyo_seam.cjs | 12 ++++++++++-- resources/tester/test_m02_hopyo_screen.py | 3 ++- resources/tester/test_m02_hopyo_seam.py | 12 ++++++++++-- 5 files changed, 41 insertions(+), 14 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts index 63a5eef75..4af98e977 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts @@ -117,9 +117,13 @@ export function memoryOf( return null; } -/** 근거 맞음 — 조항 · 직종 · 단위 모두 맞음(330 [근거 맞음 모두 확정]) */ -export const basisOk = (b: HopyoBasisHit | undefined): boolean => - !!b && b.조항.length > 0 && b.직종.length > 0 && b.단위; +/** 근거 맞음(330 [근거 맞음 모두 확정]) — 조항 같음(앞 마디 아님) · 단위 맞음 · 직종 겹침 + * (줄 근거 직종이 보통인부뿐이거나 없으면 — 기계 등 — 직종은 안 봄) */ +export const basisOk = (b: HopyoBasisHit | undefined, line?: HopyoLineBasis): boolean => + !!b && + b.까닭.startsWith("조항 같음") && + b.단위 && + (b.직종.length > 0 || !(line?.직종 ?? []).some((x) => x !== "보통인부")); /** 후보 근거 꼬리표 — 「조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음」 · 근거 없으면 "" */ export const basisTag = (b: HopyoBasisHit | undefined): string => @@ -149,8 +153,8 @@ export const sourceText = (b: HopyoLineBasis | undefined): string => export function basisPick(line: HopyoLine): HopyoTarget | null { const m = line.맞춤; if (m.상태 !== "후보") return null; - if (m.대상 && basisOk(m.대상.근거)) return m.대상; - return (m.후보 ?? []).find((c) => basisOk(c.근거)) ?? null; + if (m.대상 && basisOk(m.대상.근거, line.근거)) return m.대상; + return (m.후보 ?? []).find((c) => basisOk(c.근거, line.근거)) ?? null; } export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement { @@ -450,7 +454,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement if (tag && c.근거) button.append( el("span", { - className: `m02-hopyo__basis${basisOk(c.근거) ? " is-ok" : ""}`, + className: `m02-hopyo__basis${basisOk(c.근거, line.근거) ? " is-ok" : ""}`, text: tag, attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` }, }), diff --git a/resources/tester/helper_m02_hopyo_pick.cjs b/resources/tester/helper_m02_hopyo_pick.cjs index 78c8b60a9..fb5daaa03 100644 --- a/resources/tester/helper_m02_hopyo_pick.cjs +++ b/resources/tester/helper_m02_hopyo_pick.cjs @@ -94,14 +94,20 @@ function loadTs(file) { 직종: ["특별인부"], 단위: true, 점수: 1.65, - 까닭: "조항", + 까닭: "조항 같음 · 직종 특별인부 · 단위 맞음", }; const half = { ...hit, 직종: [] }; - const line = (상태, 대상, 후보) => ({ 맞춤: { 상태, 대상, 후보 } }); + const trade = { 출처: [["건설품셈", "3-2-1"]], 직종: ["특별인부", "보통인부"], 행: [1, 1] }; + const plain = { ...trade, 직종: ["보통인부"] }; + const line = (상태, 대상, 후보) => ({ 근거: trade, 맞춤: { 상태, 대상, 후보 } }); const a = { 로직: "GF000101", 근거: half }; const b = { 로직: "GC000083", 근거: hit }; return { - ok: [hit, half, { ...hit, 단위: false }, undefined].map(view.basisOk), + ok: [hit, half, { ...hit, 단위: false }, { ...hit, 까닭: "앞 마디 같음" }, undefined].map( + (b) => view.basisOk(b, trade), + ), + // 엑셀 직종이 보통인부뿐 · 없음(기계) = 직종 안 봄 + plain: [view.basisOk(half, plain), view.basisOk(half, { ...trade, 직종: [] })], tag: [hit, half, undefined].map(view.basisTag), source: [ view.sourceText({ 출처: [["건설표준품셈", "3-2-1"]], 직종: ["특별인부"], 행: [93, 101] }), diff --git a/resources/tester/helper_m02_hopyo_seam.cjs b/resources/tester/helper_m02_hopyo_seam.cjs index faa75042a..55616cebd 100644 --- a/resources/tester/helper_m02_hopyo_seam.cjs +++ b/resources/tester/helper_m02_hopyo_seam.cjs @@ -1,7 +1,8 @@ /* M02 일위대가 실무자료 이음매(329 · 330) 헬퍼 — 실제 서버 응답(격자 · 호표 맞춤)을 * `M02_MasterTemplete_Hopyo_Sheet.ts`(행 → 호표 · 영역 A1) · `M02_MasterTemplete_Hopyo_View.ts` * (근거 꼬리표 · 엑셀 근거 줄 · 근거 맞음 대상)에 먹임 · - * stdin = {grid, lines} · stdout = {at, span, rows, lines: [{이름, source, tag, ok, pick}]} JSON. */ + * stdin = {grid, lines, all} · stdout = {at, span, rows, lines: [{이름, source, tag, ok, pick}], + * count: {top, pick, half}} JSON — count = 「후보」 줄 1순위 근거 맞음 · 근거 맞음 대상 · 그 가운데 앞 마디. */ const fs = require("fs"); const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); @@ -47,9 +48,16 @@ const out = { 이름: line.이름, source: view.sourceText(line.근거), tag: view.basisTag(top?.근거), - ok: view.basisOk(top?.근거), + ok: view.basisOk(top?.근거, line.근거), pick: view.basisPick(line)?.로직 ?? null, }; }), }; +const open = (input.all ?? []).filter((line) => line.맞춤.상태 === "후보"); +const picks = open.map((line) => view.basisPick(line)).filter(Boolean); +out.count = { + top: open.filter((line) => view.basisOk((line.맞춤.후보 ?? [])[0]?.근거, line.근거)).length, + pick: picks.length, + half: picks.filter((c) => !c.근거.까닭.startsWith("조항 같음")).length, +}; process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index c844f2d7d..3eef9079e 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -193,7 +193,8 @@ def test_엑셀_보기_창_여는_자리와_늦게_불러옴(): def test_고르기_창_근거_꼬리표와_근거_맞음(): basis = _run()["basis"] - assert basis["ok"] == [True, False, False, False] # 조항 · 직종 · 단위 모두 + assert basis["ok"] == [True, False, False, False, False] # 조항 같음 · 직종 · 단위 모두 + assert basis["plain"] == [True, True] # 줄 직종이 보통인부뿐 · 없음 = 직종 안 봄 assert basis["tag"] == [ "조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음", "조항 건설 공통 3-2-1 · 단위 같음", diff --git a/resources/tester/test_m02_hopyo_seam.py b/resources/tester/test_m02_hopyo_seam.py index 0fd197766..54ac54462 100644 --- a/resources/tester/test_m02_hopyo_seam.py +++ b/resources/tester/test_m02_hopyo_seam.py @@ -2,7 +2,8 @@ 화면 도우미(`helper_m02_hopyo_seam.cjs` → Hopyo_Sheet · Hopyo_View)에 먹임. 행 22 → 제3호표 · 25 빈 줄 · 26 → 제4호표 · 영역 B20:O24 · 단산93 행 4229:4244 · -엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 대상. +엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 대상 · 북부청 「후보」 줄 1순위 근거 맞음 = 서버 기준 123 · +앞 마디 같음 0(330 덧고침 2). """ import json @@ -36,7 +37,13 @@ def test_실제_응답_화면_도우미(client): for no in (3, 12, 38, 58): res = client.get(f"{URL.format('personal')}/HP-000001/{no}").json() lines += [x for x in res["호표"]["줄"] if x.get("근거")] - got = _node({"grid": grid, "lines": lines}) + every = [] + for one in grid["호표"]: + res = client.get(f"{URL.format('personal')}/HP-000001/{one['번']}").json() + every += [x for x in res["호표"]["줄"] if x["갈래"] == "단가산출"] + got = _node({"grid": grid, "lines": lines, "all": every}) + assert got["count"]["top"] == 123 and got["count"]["half"] == 0 + assert got["count"]["pick"] >= 123 assert got["at"] == [None, 3, 3, 3, None, 4] # 25행 = 호표 사이 빈 줄 assert got["span"] == "B20:O24" and got["rows"] == "4229:4244" by = {x["이름"]: x for x in got["lines"]} @@ -45,3 +52,4 @@ def test_실제_응답_화면_도우미(client): assert dig["tag"] == "조항 건설품셈 공통 3-2-1 · 직종 특별인부 · 단위 같음" assert dig["ok"] is True and dig["pick"] == "GC000083" assert by["비탈면 보호공"]["pick"] == "GC000143" + assert by["흙깍기(토사)"]["ok"] is True # 엑셀 직종 없는 줄(기계) = 조항 같음 + 단위 From 31e6adf7de613c390a4e90daa5a0cb90fcf10616 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 15:12:15 +0900 Subject: [PATCH 28/36] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20225=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=C2=B7=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=84=A4=EC=A0=95=20=E2=98=B0=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C=EA=B0=80=EA=B8=B0=20=C2=B7=20CAD=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=EB=B3=B4=EA=B0=95=20=C2=B7=20=EC=99=B8=EB=B6=80=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=9D=B4=EB=A6=84=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=EA=B2=80=ED=86=A0=20=EC=9B=90=EB=AC=B8?= 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_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index a3e1691fb..4af9ac3fb 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -274,3 +274,9 @@ 7. 내장 캐드프로그램에는 오토캐드 프로그램을 참고하여 만들었음. 기능 중 명령어 중에 연속 명령어로 제어할 수 있는 기능이 필요. 예를들어서 산 단축키 입력> 각도지정> 길이입력 등임. 연속적으로 키보드로 작동하는 기능임. 이건 인터넷 조사하여 오토캐드에서 어떤식으로 제어기능을 제공하는지 분석필요함. 8. 우리 프로그램에는 마우스로 팬, 줌인아웃, 선택 기능들이 사용자가 제어할 수 있는 기능이 있음. 일체반영을 위해 햄버거 메뉴 안에 설정버튼을 추가하고 별도모달창에는 탭으로 구분하여 마우스 제어방법이나 오토캐드의 단축키, 다른 곳에서의 단축키 설정, 나중에는 페이지들 기본 옵션값들을 사용자가 조절할 수 있는 기능을 부여하려고 함. (기본값은 지금은 아님. 내가 별도 요청) 9. 신규 계획서를 작성하기 전 현재의 코드나 외부정보를 먼저 정밀 분석하고 진행. 신규계획서를 작성 후에는 업무를 분배하여 진행하고 너는 최종 완료시점에 사용자처럼 브라우져 제어하고 보고서를 아티팩트로 공유. +225. (원문 · 10-15) 추가수정 내용 +1. 대시보드의 햄버거 메뉴에는 좌측에 프로젝트 내부의 진행단계처럼 바로가기 버튼을 추가하고 페이지 스크롤 추가 +2. 시스템 설정컨테이너로 접근하는 4개의 페이지에는 햄버거메뉴에도 진행단계 처럼 바로 이동하는 버튼 추가 +3.오토캐드의 프로그램이 추가 보강 입력방법이 없는지 확인. +4. 외부에서 일위대가 가져오기 기능에서 이름이 달라 매칭이 안되는 문제를 개선 방법이 있는지 확인. +5. 3번과 4번은 코드 요청이 아니라 상세검토 요청임. 코드 작업은 검토 결과 보고 판단할 예정 From 422c449eb9360ec0c94456b23db3f6fce9c46e7e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 15:36:42 +0900 Subject: [PATCH 29/36] =?UTF-8?q?chore(=EC=A1=B0=EC=82=AC):=20334=20?= =?UTF-8?q?=E2=98=B0=20=EB=B0=94=EB=A1=9C=EA=B0=80=EA=B8=B0=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A1=B0=EC=82=AC=20=E2=80=94=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EA=B5=AC=EC=97=AD=20=C2=B7=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=84=A4=EC=A0=95=204=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- .../2026-10-15_334_햄버거메뉴_바로가기.md | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 docs/raw/조사/2026-10-15_334_햄버거메뉴_바로가기.md diff --git a/docs/raw/조사/2026-10-15_334_햄버거메뉴_바로가기.md b/docs/raw/조사/2026-10-15_334_햄버거메뉴_바로가기.md new file mode 100644 index 000000000..f4b075e43 --- /dev/null +++ b/docs/raw/조사/2026-10-15_334_햄버거메뉴_바로가기.md @@ -0,0 +1,230 @@ +# 요구 225-1 · 225-2 ☰ 바로가기 — 코드 조사 (PLAN 334) + +- 기준: `main_laptop_1` 31e6adf7d · 읽기만 · 근거 = 파일:줄 · 화면 판정은 코드로만(브라우저 · 서버 안 씀). +- 원문(요구 225 · 10-15): ① 대시보드 ☰ 좌측에 진행단계처럼 바로가기 + 페이지 스크롤 ② 시스템 설정 컨테이너로 가는 4 페이지 ☰ 에도 진행단계처럼 바로 이동 단추. + +## 0. 결론 요약 + +1. ☰ = 공용 한 벌(`A00_Common/app_shell.ts` `buildDrawer` 149-222) · 대시보드 ☰ 와 프로젝트 ☰ 는 같은 코드 · 다른 점은 「진행 단계」 칸을 페이지가 채우느냐(`setDrawerSteps`)뿐 · 서랍은 한 열(왼쪽 · 오른쪽 두 열 없음). +2. 진행단계 = `createStepBar`(`ui_template/ui_template_workflow_layout.ts` 57-164) · 🏠 대시보드 + 7단계 · ☰ 는 진행 판에 그린 **그 요소**를 열 때 빌려 감. +3. 시스템 설정 카드 넷(시스템 관리자) = M01 마스터 데이터 + M02 한 페이지의 `open=구조물조합 · 옵션 · 도면` · 그 밖 역할은 구조물 조합 하나 · 지금 그 페이지들 ☰ 의 진행 단계 칸은 비어 숨음. +4. 추천 해석: 대시보드 ☰ 진행 단계 자리에 「대시보드 컨테이너 바로가기」(역할별 화면에 있는 컨테이너 그대로 · 화면 차례) · 누르면 서랍 닫고 그 컨테이너로 굴림 · 본문 구조는 그대로. +5. 페이지 스크롤: 대시보드 본문 · ☰ 서랍 모두 지금도 굴러감(막는 코드 없음) → 「페이지 스크롤」 = 바로가기 누름 → 그 컨테이너로 굴림 + ☰ 자리 비킴(scroll-margin-top). +6. M01 · M02 ☰ = `createStepBar` 를 인자만 바꿔 재사용(🏠 + 카드 넷 · 지금 칸 강조) · 서랍이 「집 없는 요소」를 받게 `app_shell.ts` 두 줄. +7. 함정 셋: `createStepBar` 「둘째 칸 = 시스템 관리자 전용」이 칸 번호로 박힘 · M02 → M02 이동은 머리 가드가 안 물음 · B01 CSS 이미 789줄. +8. 최소안: 기반 2줄 + 몫 A(대시보드) 2 파일 · 몫 B(설정 4 페이지) 새 파일 1 + 4 파일 · 시험 1 고침 + 새 시험 1 · 한 창이 차례로가 나음. + +## 1. ☰ 메뉴 코드 + +### 한 벌 · 공용 + +- 그리기 = `A00_Common/app_shell.ts` `buildDrawer()` 149-222 — 모든 쪽(A · B · M) 같은 함수 · 페이지마다 따로 없음. +- 셸은 주소가 바뀔 때마다 새로 그림 — `A00_Common/main.ts` 67-80(`renderShell` → `renderCurrentRoute`) · `app_shell.ts` 323-337 · 325 에서 진행 단계 맡김(`drawerSteps`) 비움 → 새 페이지가 다시 맡김. B05↔B06 은 보기만 바꿔 셸을 안 다시 그림(`main.ts` 72-75). +- 대시보드 ☰ vs 프로젝트 ☰ = 같은 코드의 같은 갈래 · 다른 점은 「진행 단계」 칸뿐 — 프로젝트 쪽(B04~B09)은 `createWorkflowLayout` 이 `setDrawerSteps(progressContent)`(`ui_template_workflow_layout.ts` 202-203) · 대시보드(일반 레이아웃 · `B01_Dashboard/B01_Dashboard_UI_Page.ts` 315-320)는 안 맡겨 칸이 비고 CSS 로 숨음(`app_shell.ts` 486). +- B·M 쪽 표시: `html.is-bm-route` = 주소가 b·m + 숫자(`main.ts` 68-71 · `ui_template/ui_template_native_menu.ts` 10) → 위 줄 걷음(`app_shell.ts` 504-512) · ☰ 만 왼쪽 위에 띄움(513-525) · 서랍은 왼쪽에서 열림(526-531) · 토큰 `A00_Common/device_context.css` 89-98(`--app-header-h` 0 · `--app-burger-w` 36 · `--app-burger-inset` 52). + +### 칸 구성(한 열 · 위→아래 · `app_shell.ts` 185-220) + +| 차례 | 칸(class) | 내용 | 근거 | +| --- | --- | --- | --- | +| 1 | `.app-drawer__brand` | 로고 → A01 홈(대시보드 아님) | 102-117(115) · 186-188 | +| 2 | `.app-drawer__steps` | 진행 단계(맡긴 쪽만 · 비면 숨김) | 119-125 · 162-170 · 486 | +| 3 | `.app-drawer__nav` | 공개 메뉴 다섯(프로그램 · 회사소개 · 소식 · 교육 · 기술지원 → A 쪽) | 127-143 · 189-191 | +| 4 | 묶음(`.app-drawer__group`) | [EN/한] [☾/☀] [설정](332 · 처음 누를 때 `app_settings` 불러옴) | 192-213 | +| | `.app-drawer__auth` | [내 이름 → 대시보드] [로그아웃] | 214-216 · 262-311(273) | +| | `.app-drawer__touch` | 터치 모드 자동 · 켬 · 끔 | 224-259 · 217-220 | + +- 시험 `test_device_drawer.py` 76-81 이 이 차례(brand · steps · nav · settings)를 잼. + +### 여닫기 + +- ☰ 누름 = 토글(171) · 덮개 누름 = 닫힘(172) · Esc = 닫힘(173 · ☰ 초점에서도). +- 로고 · 진행 단계 · 메뉴 · 로그인 · [설정] 단추를 누르면 닫힘 · 언어 · 테마 · 터치는 열린 채(174-183). +- 진행 단계 빌림: 열 때 서랍으로 옮기고 닫을 때 제자리(진행 판)로(164-170) · 맡길 때 부모(집)가 없으면 받지 않음(124 `node?.parentElement ? … : null`). + +### 크기 · CSS(`SHELL_CSS` 344-537 · 주입 539-545) + +- 서랍: fixed · 위 = `--app-header-h`(B·M 0) · 아래 0 · 폭 `min(320px, 100vw)` · 안쪽 16 · 칸 사이 16 · **`overflow-y: auto`**(451-465) · 묶음 사이 선(482-485). +- 덮개: fixed · 반투명 보라(445-450). +- ☰: 36×36(손가락 높이 44) · 왼쪽 위 8,8 · 그림자(513-525). +- 머리 z = `z-dropdown + 1` = 201(504-509) → 좌측 패널 판(z 200 · `ui_template_overlay.css` 8) 위. +- 프로젝트 쪽 떠 있는 진행 판은 B·M 에서 숨김(`ui_template_overlay.css` 544-547) → B·M 의 진행 단계는 ☰ 안에만. +- 최신 모양 = 332-2 [설정] 단추 포함(sub2 aaecfa343 · HEAD 에 합쳐짐 · `A00_Common/app_settings.ts` 91~). + +## 2. 진행단계 바로가기 코드 + +- 그리는 함수: `createStepBar(steps, activeStep, options)` `ui_template_workflow_layout.ts` 57-164 · 세로형 `is-vertical`(65) · 프로젝트 쪽은 `createWorkflowLayout` 이 부름(184-192 · `homeButton: true` · `WORKFLOW_STEP_ICONS`). + +| 원천 | 값 | 근거 | +| --- | --- | --- | +| 표시 이름 | 파일입력 · 전처리 · 종단설계 · 횡단설계 · 상세설계 · 수량산출 · 원가계산 | `A00_Common/b_page_scaffold.ts` 139-149 · `ui_template_locale_b1.ts` 231 · `ui_template_locale_common.ts` 82-87 | +| 길 | b03-file-input · b04-preprocess · b05-profile · b06-section · b07-design-detail · b08-quantity · b09-estimation | `A00_Common/b_workflow_nav.ts` 24-32 | +| 아이콘 | 📁 🗺️ 🛣️ 📐 ∑ 🏗️ 📄 | `ui_template_workflow_layout.ts` 55 | +| 맨 위 | 🏠 대시보드 → b01-account(단계 아님 · 늘 켜짐 · 아래 선) | 66-83 · `ui_template_locale_common.ts` 54 · `ui_template_workflow_layout.css` 65-71 | + +- 지금 단계 강조: `is-active`(88 · css 83-86) · B05↔B06 은 `setActiveStep`(217-218). +- 잠금: 단계 상태 NOT_STARTED · STALE 면 막힘(지금 단계는 열림 · 103-117) · 개발(DEV)은 막힌 단계도 열고 표시(113 · 148-153) · 상태 테두리 색(css 93-108) · B07 은 운영에서 도면 다 확정 전 수량 · 원가 막음(`B07_DesignDetail_UI_Page.ts` 600-606). +- 역할: 둘째 칸(index 1 = 전처리)만 시스템 관리자 전용 — `fetchDashboardMe` 로 역할 보고 끔 · 풍선 「시스템 관리자 전용 단계입니다.」(119-129 · 9-14). ⚠ **칸 번호**로 박힘(길 아님) — 다른 목록에 쓰면 회사 관리자 · 일반에서 둘째 칸이 꺼짐. +- 누를 수 있는 조건: 켜짐 + `routes[index]` 있음(155-159) — 길 배열을 안 주면 누름이 안 걸림. +- 프로젝트 없이: 못 씀 — 단계 화면은 기억한 프로젝트로 엶(`router.ts` 107-116 · 223-233 「프로젝트 없음」 안내) · 회사 없으면(시스템 관리자 빼고) 대시보드로(`router.ts` 206-221) · 누름 = `goToWorkflowStage(projectId, 길)`(`b_workflow_nav.ts` 96-111) · projectId 없으면 아무것도 안 함(`b_page_scaffold.ts` 128-130 · `B07_DesignDetail_UI_Page.ts` 601 · `design_page.ts` 174-175 토스트). +- 대시보드 프로젝트 표의 단계 막대도 같은 함수(compact · `B01_Dashboard_UI_Projects.ts` 56-67). +- ⚠ 덧 발견(이번 밖): B03(파일입력)은 일반 레이아웃 + 오버레이를 직접 조립하고 `setDrawerSteps` 를 안 부름(`B03_FileInput_UI_Page.ts` 641-675) · B·M 에선 떠 있는 판도 숨음(`ui_template_overlay.css` 544-547) → **B03 ☰ 에 진행 단계 없음**(10-13 ☰ 이관 3fc50044e · fbe0f982d 가 B03 을 안 건드림) · 한 줄 고침 후보. + +## 3. 시스템 설정 컨테이너 · 4 페이지 + +- 컨테이너: `buildSystemSettingsSection` `B01_Dashboard_UI_Page.ts` 222-232(모든 역할 · 한 줄 통째 · 제목 눌러 접힘 · 시스템 관리자는 회사 알약) · 카드 = `buildSystemSettingsPanel` 165-200 · 카드 꼴 `settingCard` 149-163. +- 자리: 시스템 관리자 = 리소스 현황 아래(246) · 회사 관리자 · 일반 = 맨 위(281 · 294). + +| 카드(시스템 관리자 · 178-188) | 이동 | 파일 | +| --- | --- | --- | +| 마스터 데이터 | `#/m01-master-data` | `M01_MasterData/M01_MasterData_UI_Page.ts` | +| 구조물 조합 | `#/m02-master-template?open=구조물조합&root=personal`(회사 있음) · `master`(없음) `[&company=고른 회사]` | `M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts` | +| 구조물 옵션 | `…?open=옵션&root=master[&company=]` | 같은 M02 | +| 도면 양식 | `…?open=도면&root=master[&company=]` | 같은 M02 | + +- 이름 `ui_template_locale_m1.ts` 7 · `ui_template_locale_m2.ts` 7-9 · 설명 `ui_template_locale_b3.ts` 142-147 · 회사 인자 170-171. +- 그 밖 역할(190-198): 구조물 조합 카드 하나 — 회사 있으면 personal(177) · 없으면 회사 관리자 · 회사 마스터 = company · 일반 = personal. +- 「4 페이지」 = 실제 길은 둘 — M02 셋은 한 페이지의 진입 인자 `open` 만 다름(`M02_MasterTemplete_Layer.ts` 19 · 39-52) → 좌측 패널 그 컨테이너 펼침(`M02_MasterTemplete_UI_Page.ts` 240 · `M02_MasterTemplete_UI_Side.ts` 342-347) · 옵션 · 도면 컨테이너는 마스터 층에만(`UI_Side` 154-160). +- 역할: M01 = 시스템 관리자만(아니면 토스트 + 대시보드로 · M01 page 48-56) · M02 = 로그인만 · 고치기는 층 권한(`Layer` 99-113). +- 지금 그 페이지들 ☰: 진행 단계 칸 빔(숨음) — M01 · M02 는 `createWorkflowOverlays` 를 진행 판 없이 부름(M01 page 263-268 · M02 page 222-227 · `ui_template_overlay.ts` 270-282) · `setDrawerSteps` 없음 → ☰ = 로고(A01 홈) · 공개 메뉴 다섯 · [EN][☾][설정] · [이름 → 대시보드][로그아웃] · 터치 모드. 대시보드로 가는 길 = 이름 단추 하나뿐 · 설정 페이지끼리 오가는 단추 없음. + +## 4. 대시보드 ☰ 「좌측」 후보 + +대시보드 컨테이너 — 모두 `section()` = `.b01-dashboard__section` · 제목 h3 `.ui-card__title`(`ui_template_general_blocks.ts` 9-40) · `grid` 의 직속 자식: + +| 역할 | 컨테이너(화면 차례) | 근거 | +| --- | --- | --- | +| 시스템 관리자(10) | 리소스 현황 · 시스템 설정 · 프로젝트 · 임시 보관함 · 사용자 관리 · 가입 요청 · 회사 관리 · 기본정보 · 보안 · 시스템 로그 | 241-277 · 305-312 | +| 회사 관리자(8) | 시스템 설정 · 프로젝트 · 임시 보관함 · 팀원 관리 · 가입 요청 · 회사 정보 · 기본정보 · 보안 | 278-291 · 305-308 | +| 일반(6) | 시스템 설정 · 프로젝트 · 임시 보관함 · 회사 정보 · 기본정보 · 보안 | 292-308 | + +- 챗봇 단추 = 오른쪽 아래 떠 있음 · 컨테이너 아님(314 · B01 CSS 525). +- 대시보드에서 다른 화면 가는 곳: 시스템 설정 카드(M01 · M02) · 프로젝트 「+」(B02 · 249 · 283 · 296) · 프로젝트 표 단계 막대(B03~B09 · `Projects.ts` 56-67). + +**추천 해석(하나)** — ☰ 서랍 「진행 단계」 자리(로고 바로 아래 · 프로젝트 ☰ 와 같은 자리 · 같은 세로 단추 꼴)에 **대시보드 컨테이너 바로가기** · 그 역할 화면의 컨테이너 그대로 · 화면 차례 그대로 · 누르면 서랍 닫히고 그 컨테이너로 부드럽게 굴림. +- 「좌측에」 = 왼쪽에서 열리는 ☰ 서랍(`app_shell.ts` 526-531) · 「페이지 스크롤」 = 누르면 굴러감. +- 10-8 롤백 둘(해시 탭 2a2c4d0f6 → 1849a11b3 · 좌측 패널에서 고른 컨테이너만 보이기 d69686a0a → c72585016 · `docs/raw/plans/2026-10-08_plan_99_…` 11)은 컨테이너를 감춰 본문 구조를 바꿈 → 이 안은 본문 그대로 · ☰ 안 단추 + 굴림만. + +다른 해석(한 줄씩): +- B 다른 화면 가기(시스템 설정 4 · 새 프로젝트) — 본문 카드 · 「+」 와 겹침 · 「페이지 스크롤」 이 설명 안 됨. +- C 최근 프로젝트 · 그 단계로 — 프로젝트 고르기가 먼저 필요 · 표에 단계 막대가 이미 있음. +- D 「좌측에」 = 서랍 안 왼쪽 열(두 열) — 서랍 폭 320 이라 좁음 · 프로젝트 ☰ 와 꼴이 달라짐. +- E 「페이지 스크롤」 = 지금 안 굴러가서 — 5장 판정상 지금도 굴러감. + +## 5. 페이지 스크롤 판정 + +대시보드 본문 — 막는 코드 없음 → 문서가 굴러감: +- html · body = 여백만(`ui_template_theme.css` 471-475) · `A00_Common/index.html` 꾸밈 없음 · 출구 `.app-outlet` = 최소 높이만(`app_shell.ts` 396-401) · `.ui-general-layout` = `min-height: 100dvh`(`ui_template_general_layout.css` 1-5). +- overflow hidden · 고정 높이 · 100vh 잠금 없음 — 전역 html · body · #app · .app-outlet 규칙 검색 결과 위 셋뿐(B07 CAD `App.css` 의 html,body 는 iframe 별도 문서) · JS 로 body overflow 잠그는 곳 0. +- 휠 가로채기: 돋보기는 원이 보일 때만(`ui_template_loupe.ts` 210-220) · `touch_bridge` 는 마우스 · 휠 안 건드림. +- 안쪽 굴림만 따로: 표 4줄 뒤(`B01_Dashboard_UI_Common.ts` 15 · `ui_template_general_blocks.ts` 53~) · 챗봇 창(B01 CSS 559-570 · 610-618). + +☰ 서랍 — `overflow-y: auto` + 위 · 아래 고정(`app_shell.ts` 451-465) → 넘치면 서랍만 굴러감 · 시스템 관리자 바로가기 10개면 서랍 높이 약 900px 넘음 → 이 굴림이 일함 · 서랍 끝에서 뒤 페이지로 이어 굴림(overscroll) 막음은 없음. + +판정: 「페이지 스크롤 추가」 = 바로가기 누름 → 그 컨테이너로 굴림(`scrollIntoView` · smooth · block start). + +고칠 곳: +- 굴림 = `B01_Dashboard_UI_Page.ts`(바로가기 누름). +- ☰ 자리 비킴 = 맨 위로 굴린 컨테이너 제목이 왼쪽 위 ☰(52px)에 깔림(폭 1248 아래 · 카드 왼쪽 끝 24) → `html.is-bm-route .ui-general-block__section { scroll-margin-top: var(--app-burger-inset) }` 한 규칙 · 자리 = `ui_template_general_layout.css`(119줄 · 같은 ☰ 들여쓰기 규칙 115-119 곁) · B01 CSS(789줄 · 이미 700 넘음)는 손대지 않음. +- (선택) `app_shell.ts` `.app-drawer` 에 `overscroll-behavior: contain` 한 줄. + +## 6. 시스템 설정 4 페이지 ☰ + +- 꼴: 프로젝트 ☰ 와 같음 — 🏠 대시보드 + 카드 넷(마스터 데이터 · 구조물 조합 · 구조물 옵션 · 도면 양식) · 지금 칸 강조 · 누르면 그 페이지(M02 셋은 `open` 인자) · 그 밖 역할은 🏠 + 구조물 조합. +- 재사용: 됨 — `createStepBar(카드 이름들, 지금 칸, { orientation: "vertical", icons, routes: 카드 길들, homeButton: true, onStepClick })` 인자만. stages 없으면 모두 켜짐(114-115) · `homeButton` = 🏠 대시보드(68-83) · 둘째 칸 규칙은 시스템 관리자 넷엔 통과 · 다른 역할은 한 칸이라 안 걸림(대시보드 목록엔 걸림 → 8장 기반 a). +- 막는 것 둘: + 1. 서랍이 집 없는 요소를 안 받음(`app_shell.ts` 124) — M01 · M02 · B01 은 진행 판이 없음 → 두 줄 고침(집 없음 = 서랍이 늘 가짐 · 닫을 때 안 옮김 · 121-124 · 167-169). 대안(진행 판에 넣어 숨긴 채 집으로) = 떠 있는 판 · 끌기 · 저장 키가 헛되이 생김 → 안 권함. + 2. M02 → M02(다른 `open`) 이동은 머리 가드가 안 물음(`main.ts` 85 · 같은 길 = 그냥 다시 그림) → 저장 안 한 M02 고침이 말없이 사라짐. M02 자체 층 단추는 먼저 `main.confirmLeave()`(M02 page 99-106 · `M02_MasterTemplete_UI_Main.ts` 239-240 · 시험 `test_m02_layer_screen.py` 183) → 바로가기도 같은 길이면 `takeLeaveGuard()`(`router.ts` 101-105) 먼저 물음 · 다른 길(M02 → M01 · 🏠)은 `main.ts` 가 물음 · M01 은 초안이 sessionStorage 라 가드 없음(M01 page 7-8). +- 역할 규칙 원천은 한 곳으로: 지금 카드 규칙(165-200 · 177 comboRoot 가 까다로움)을 순수 목록 함수로 옮겨 카드 · ☰ 가 같이 씀(두 벌 금지). +- 지금 칸: M01 = 마스터 데이터 · M02 = `entry.open`(구조물조합 · 옵션 · 도면 · 「일위대가조합」은 강조 없음) · 페이지 안 좌측 패널에서 다른 컨테이너를 펼쳐도 강조는 안 따라감(진입 인자 기준 · 알려 둘 한계). +- company: 대시보드는 시스템 관리자 고른 회사를 해시에 실음(170-171) · M02 는 해시 없으면 sessionStorage `aislo.adminCompany` → 자기 회사(`Layer` 69-80) → M01 에서는 안 실어도 같음 · M02 에서는 지금 `entry.company` 를 이어 실음. + +## 7. 시험 · 줄 수 · 겹침 + +시험(`resources/tester`): + +| 파일 | 보는 것 | 서버 | +| --- | --- | --- | +| `test_device_drawer.py` | ☰ 위 줄 걷기 · 서랍 차례 · 진행 단계 빌림 · 단계 누름 이동 · Esc(B07 · A01) | 5174(sub1) · 꺼지면 건너뜀 | +| `test_app_settings.py` + `helper_app_settings.cjs` | ☰ [설정] 창 탭 · 저장 · 물음 | Node + 5177(sub2) | +| `test_b01_settings_cards.py` | 시스템 설정 카드 역할별(제목 · 설명 · 해시) — `settingCard` · `buildSystemSettingsPanel` 소스를 잘라 Node 로 돌림(26-62) · `buildPage` 차례 문자열(99-141) | Node | +| `test_197_1_dashboard_option_card.py` | 카드 해시 셋(위 `_cards` 씀) · M02 진입 · reveal | Node | +| `test_device_audit.py` | 모든 쪽 네 크기 점검(☰ 포함) | 5174 | +| `test_design_page_single.py` | workflow_layout 의 `setActiveStep` · `setTitle` 문자열(96-97) | 없음 | +| `test_m02_layer_screen.py` · `_combo_screen` · `_combo_table` · `_labor_tree` · `_precombo_scope` · `_structure_panel` | M02 page 소스 문자열 | 없음 | + +- 대시보드 바로가기 · 설정 4 페이지 ☰ 시험은 아직 없음. + +줄 수(700 제한): + +| 파일 | 지금 | 예상 | +| --- | --- | --- | +| `A00_Common/app_shell.ts` | 545 | +1~2 | +| `ui_template/ui_template_workflow_layout.ts` | 220 | 한 줄 바꿈 | +| `ui_template/ui_template_general_layout.css` | 119 | +3 | +| `B01_Dashboard/B01_Dashboard_UI_Page.ts` | 329 | +15~20(카드 함수는 줄어듦) | +| `B01_Dashboard/B01_Dashboard_UI_Style.css` | **789(이미 초과)** | 손대지 않음 | +| `M01_MasterData/M01_MasterData_UI_Page.ts` | 280 | +2 | +| `M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts` | 244 | +2 | +| 새 `A00_Common/m_settings_nav.ts` | — | ~50 | + +겹침: +- 원격 가지 전부(origin/sub_laptop_1~8 · main_desktop_1 · sub_desktop_1 · CODEX · 안티그래비티 · dev)에 HEAD 대비 위 파일의 안 합친 커밋 0 · 랩탑 보조 여덟 폴더 미커밋 0(읽기 diff) · 데스크탑 미커밋은 못 봄. +- PLAN 열린 장 중 이 파일을 쓰는 장 없음 · 332(☰ [설정] · sub2)는 끝 · 합침. +- 주의: `app_shell.ts` · `ui_template_workflow_layout.ts` = 모든 B · M 쪽 공용(B04~B09 진행 단계) · B01 = CODEX 챗봇 영역(`Knowledge_Chat` · CSS 520~ · PLAN 「CODEX」 장 열린 줄 「추가 커밋은 제 워크트리에만」) — 이번엔 `B01_Dashboard_UI_Page.ts` 만 · 챗봇 파일 안 건드림. +- 225-3 · 225-4 는 검토만(코드 없음) → 겹침 없음. + +## 8. 구현 최소안 + +기반(몫 A 가 먼저 · 둘 다 씀): +- (a) `ui_template_workflow_layout.ts` 119 — `index === 1` → `options?.routes?.[index] === ROUTES.B04_PREPROCESS`(전처리 시스템 관리자 전용 = 길 기준 · 지금 부르는 곳 B01 표 · B03 · B04 · B05/06 · B07 · B08 · B09 · 초안 모두 `WORKFLOW_STEP_ROUTES` 라 그대로 · `ROUTES` 이미 import 2). +- (b) `app_shell.ts` 121-124 · 167-169 — 집 없는 요소 받음(`home` null → 닫을 때 안 옮김). + +몫 A — 대시보드(225-1): +- `B01_Dashboard_UI_Page.ts` — `buildPage` 에서 grid 채운 뒤 새 함수 하나: grid 자식 컨테이너마다 제목(`.ui-card__title`) → `createStepBar(제목들, -1, { orientation: "vertical", icons, routes: 제목들, onStepClick: i → 컨테이너.scrollIntoView({ behavior: "smooth", block: "start" }) })` → `setDrawerSteps(…)`. 역할 규칙을 다시 안 씀(화면 차례를 읽음) · `homeButton` 없음(지금이 대시보드). +- `ui_template_general_layout.css` — scroll-margin-top 한 규칙(5장). +- 늦게 끝난 그림 막기: 맡기기 전 `currentRoute() === ROUTES.B01_ACCOUNT` 확인 한 줄(B01 은 자료를 기다린 뒤 그림 · 그 사이 떠나면 새 쪽 서랍에 대시보드 줄이 들어감 · `setDrawerSteps` 는 차례 검사 없음). + +몫 B — 시스템 설정 4 페이지(225-2): +- 새 `A00_Common/m_settings_nav.ts`(`b_workflow_nav.ts` 짝 이름 · B01 폴더도 가능하나 M → B01 의존이 생김): + - `settingLinks(user, companyId)` = B01 165-200 규칙을 옮긴 순수 목록(key · 이름 · 설명 · 길 · 인자) · M01 은 인자 `undefined`(`{}` 면 주소 끝에 「?」 · 시험 기대값 어긋남). + - `mountSettingsDrawer(user, activeKey, companyId)` = `createStepBar(…, { homeButton: true, … })` → `setDrawerSteps` · 누름 = 같은 길이면 `takeLeaveGuard()` 먼저 → `navigateTo(길, 인자)`. +- `B01_Dashboard_UI_Page.ts` 165-200 — `settingLinks` 로 카드만 그림(규칙 한 벌). +- M01 page 56 뒤 한 줄 — `mountSettingsDrawer(user, "m01", null)`. +- M02 page 236 뒤 한 줄 — `mountSettingsDrawer(user, entry.open, company)`. +- `test_b01_settings_cards.py` `_cards` — 옮긴 함수 소스(`export` 떼고)를 같이 실음 · 기대값 그대로(동작 안 바뀜 증명) · 지금 `_fn` 정규식은 `^function` 만 잡음. + +역할별 표시: +- 대시보드 ☰: 그 역할 화면의 컨테이너 그대로(10 · 8 · 6). +- 설정 ☰: 시스템 관리자 = 🏠 + 넷 · 회사 관리자 · 일반 = 🏠 + 구조물 조합(M02 만 · M01 은 못 들어옴). +- 새 locale 키 없음(카드 이름 · 컨테이너 제목 · 「대시보드」 다 있음). + +확인: +- 시험(고친 대상만): `test_b01_settings_cards` · `test_197_1_dashboard_option_card` · `test_device_drawer`(B07 그대로) · `test_design_page_single` · `test_m02_layer_screen` + 새 `test_225_drawer_shortcuts.py` + - Node: `settingLinks` 역할 셋 · 지금 칸 · 전처리 규칙이 길 기준(대시보드 둘째 칸이 회사 관리자 · 일반에서 켜짐). + - 브라우저(자기 vite 포트 · `test_device_audit` 의 `AISLO_UI_BASE` 꼴 · 꺼지면 건너뜀): 대시보드 ☰ 이름 = 컨테이너 제목 차례 · 「보안」 누름 → 서랍 닫힘 · 보안 위 끝 ≈ 52 · M01 ☰ = 대시보드 · 마스터 데이터 · 구조물 조합 · 구조물 옵션 · 도면 양식 · 강조 = 마스터 데이터 · 「도면 양식」 → 해시 `open=도면` · M02 고친 채 「구조물 옵션」 → 물음 창. 로그인 계정이 시스템 관리자인지 먼저 확인. +- `npm run typecheck` · prettier(고친 TS · CSS) · `ruff format`(새 시험). +- 화면 확인 거리(사용자): 역할 셋 대시보드 ☰ 바로가기 · 굴림 · 휴대폰 폭 · 설정 4 페이지 ☰ 강조 · 이동 · M02 저장 안 한 채 이동 물음. + +두 몫 파일 경계: + +| 몫 | 파일 | +| --- | --- | +| A 대시보드 | 기반 (a) · (b) · `B01_Dashboard_UI_Page.ts`(`buildPage` 쪽) · `ui_template_general_layout.css` · 새 시험 앞부분 | +| B 설정 4 페이지 | 새 `m_settings_nav.ts` · `B01_Dashboard_UI_Page.ts`(`buildSystemSettingsPanel` 쪽) · M01 page · M02 page · `test_b01_settings_cards.py` · 새 시험 뒷부분 | + +- 겹침 = `B01_Dashboard_UI_Page.ts`(서로 다른 함수) · 기반 (a)(b) → 권함: 한 창이 A → B 차례로(전체 100줄 안팎) · 나누면 A 합친 뒤 B. + +## 9. 정할 것(사용자) + +- 아이콘 쓸지 · 어떤 것 — 제안: 대시보드 = 📊 리소스 · ⚙️ 시스템 설정 · 🗂️ 프로젝트 · 📦 임시 보관함 · 👥 사용자/팀원 · 📨 가입 요청 · 🏢 회사 · 👤 기본정보 · 🔒 보안 · 📜 로그 / 설정 = 🗄️ 마스터 데이터 · 🧱 구조물 조합 · 🎛️ 구조물 옵션 · 📑 도면 양식(제목 글 ↔ 아이콘 짝은 같은 그림 때 `L()` 로 맞춤). +- 대시보드 ☰ 에 설정 4 페이지도 바로 넣을지(이 안 = 「시스템 설정」 한 줄 → 그 컨테이너로 굴림). +- 굴린 뒤 접혀 있던 시스템 설정 컨테이너를 펼칠지. +- (나중) 굴리는 동안 지금 컨테이너 강조 따라가기. + +## 10. 덧 발견(이번 밖) + +- B03 ☰ 진행 단계 없음(2장 끝). +- `B01_Dashboard_UI_Style.css` 789줄 — 700 초과. +- `.app-header.is-drawer-open` z-overlay(`app_shell.ts` 444)는 B·M 규칙(504-509 · 더 구체적)에 눌려 B·M 에선 안 먹음 — 지금은 201 로 좌측 패널(200) 위라 탈 없음. +- `setDrawerSteps` 에 차례 검사 없음 — 늦게 끝난 옛 페이지가 새 쪽 서랍에 맡길 수 있음(B 쪽도 같음 · 빠른 이동 때만). From c583dec29b1ac48e13f0d875c53e7e0190ab0b27 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 15:47:14 +0900 Subject: [PATCH 30/36] =?UTF-8?q?feat(=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?):=20334-1=20=E2=98=B0=20=EA=B5=AC=EC=97=AD=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C=EA=B0=80=EA=B8=B0=20=E2=80=94=20=EB=88=84=EB=A6=84=20?= =?UTF-8?q?=3D=20=EC=84=9C=EB=9E=8D=20=EB=8B=AB=ED=9E=98=20+=20=EA=B7=B8?= =?UTF-8?q?=20=EA=B5=AC=EC=97=AD=EC=9C=BC=EB=A1=9C=20=EA=B5=B4=EB=A6=BC=20?= =?UTF-8?q?=C2=B7=20=EC=A0=84=EC=B2=98=EB=A6=AC=20=EC=9E=A0=EA=B8=88=20?= =?UTF-8?q?=EA=B8=B8=20=EA=B8=B0=EC=A4=80=20=C2=B7=20=EC=84=9C=EB=9E=8D?= =?UTF-8?q?=EC=9D=B4=20=EC=A7=91=20=EC=97=86=EB=8A=94=20=EC=9A=94=EC=86=8C?= =?UTF-8?q?=20=EB=B0=9B=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui_template_workflow_layout: 시스템 관리자 전용 판정 = 칸 번호 대신 전처리 길 - app_shell: setDrawerSteps 가 부모 없는 요소도 받음(닫을 때 안 옮김) - B01 buildPage: 그린 구역 그대로 · 화면 차례 · 아이콘 구역 키 짝 · 떠난 뒤엔 안 맡김 - general_layout.css: B·M 구역 scroll-margin-top = ☰ 자리 - 새 시험 test_225_drawer_shortcuts(앞) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- A00_Common/app_shell.ts | 8 +- B01_Dashboard/B01_Dashboard_UI_Page.ts | 37 +++- resources/tester/test_225_drawer_shortcuts.py | 159 ++++++++++++++++++ ui_template/ui_template_general_layout.css | 5 + ui_template/ui_template_workflow_layout.ts | 2 +- 5 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_225_drawer_shortcuts.py diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index 8f257690f..0d95b131d 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -118,10 +118,11 @@ function buildBrand(): HTMLButtonElement { /* ☰ 서랍 진행 단계 묶음(6-2) — 프로젝트 화면이 진행 판에 그린 단계 목록 **그 요소**를 받아 둠. * 서랍을 열 때 서랍으로 옮기고 닫을 때 제자리(진행 판)로 — 그리기 · 누름 코드는 한 벌. */ -let drawerSteps: { node: HTMLElement; home: HTMLElement } | null = null; +let drawerSteps: { node: HTMLElement; home: HTMLElement | null } | null = null; +/** 집(진행 판) 없는 요소 = 서랍이 늘 가짐 · 닫을 때 안 옮김(B01 · M01 · M02 바로가기) */ export function setDrawerSteps(node: HTMLElement | null): void { - drawerSteps = node?.parentElement ? { node, home: node.parentElement } : null; + drawerSteps = node ? { node, home: node.parentElement } : null; } const NAV_ITEMS: [keyof typeof ui_locales, string][] = [ @@ -166,7 +167,8 @@ function buildDrawer(header: HTMLElement): HTMLElement[] { burger.setAttribute("aria-expanded", String(open)); if (!drawerSteps) return; if (open) steps.append(drawerSteps.node); - else if (steps.contains(drawerSteps.node)) drawerSteps.home.append(drawerSteps.node); + else if (drawerSteps.home && steps.contains(drawerSteps.node)) + drawerSteps.home.append(drawerSteps.node); }; burger.addEventListener("click", () => setOpen(!header.classList.contains("is-drawer-open"))); scrim.addEventListener("click", () => setOpen(false)); diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 4278b4777..a30713238 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -9,7 +9,9 @@ import { } from "@ui/ui_template_elements"; import { section } from "@ui/ui_template_general_blocks"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; -import { navigateTo } from "../A00_Common/router"; +import { createStepBar } from "@ui/ui_template_workflow_layout"; +import { setDrawerSteps } from "../A00_Common/app_shell"; +import { currentRoute, navigateTo } from "../A00_Common/router"; import { fetchAllCompanies, fetchAllUsers, @@ -231,6 +233,38 @@ function buildSystemSettingsSection(state: DashboardState): HTMLElement { return card; } +/** ☰ 구역 바로가기 아이콘(334) — 구역 제목 키로 짝(EN 글에서도 같음) */ +const SECTION_ICONS: [Parameters[0], string][] = [ + ["B01_Dashboard_Resources", "📊"], + ["B01_Dashboard_SystemSettings", "⚙️"], + ["B01_Dashboard_Projects", "🗂️"], + ["B01_Temp_Section", "📦"], + ["B01_Dashboard_Users", "👥"], + ["B01_Dashboard_Members", "👥"], + ["B01_Dashboard_JoinRequests", "📨"], + ["B01_Dashboard_Companies", "🏢"], + ["B01_Dashboard_Company", "🏢"], + ["B01_Dashboard_Profile", "👤"], + ["B01_Account_Section_Security", "🔒"], + ["B01_Dashboard_AuditLogs", "📜"], +]; + +/** ☰ 구역 바로가기(334) — 그린 구역 그대로 · 화면 차례 · 누름 = 서랍 닫힘 + 그 구역으로 굴림 */ +function mountSectionShortcuts(grid: HTMLElement): void { + // 자료 기다리는 사이 다른 쪽으로 떠났으면 맡기지 않음 + if (currentRoute() !== ROUTES.B01_ACCOUNT) return; + const sections = [...grid.children] as HTMLElement[]; + const titles = sections.map((s) => s.querySelector(".ui-card__title")?.textContent ?? ""); + const icons = titles.map((title) => SECTION_ICONS.find(([key]) => L(key) === title)?.[1] ?? ""); + const bar = createStepBar(titles, -1, { + orientation: "vertical", + icons, + routes: titles, + onStepClick: (index) => sections[index].scrollIntoView({ behavior: "smooth", block: "start" }), + }); + setDrawerSteps(bar); +} + function buildPage(state: DashboardState): HTMLElement { // 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양. const page = document.createElement("div"); @@ -311,6 +345,7 @@ function buildPage(state: DashboardState): HTMLElement { grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true)); } page.append(grid); + mountSectionShortcuts(grid); page.append(buildKnowledgeChat()); const layout = createGeneralLayout({ pageClass: "b01-dashboard", diff --git a/resources/tester/test_225_drawer_shortcuts.py b/resources/tester/test_225_drawer_shortcuts.py new file mode 100644 index 000000000..d4cb2d4b9 --- /dev/null +++ b/resources/tester/test_225_drawer_shortcuts.py @@ -0,0 +1,159 @@ +"""PLAN 334 — ☰ 바로가기(요구 225-1 · 225-2). + +① Node — 진행 단계 「시스템 관리자 전용」 = 전처리 길 기준(칸 번호 아님) · 대시보드 둘째 칸은 안 꺼짐 +② 실제 브라우저(sub2 화면 서버 5177 · 꺼지면 건너뜀) — 대시보드 ☰ 이름 = 구역 제목 차례(역할 셋) · + 「보안」 누름 → 서랍 닫힘 · 보안 위 끝 ≈ ☰ 자리(52) +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import urllib.request +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +BASE = os.environ.get("AISLO_UI_BASE", "http://localhost:5177") # sub2 화면 서버(OWNERS 자기 줄) +LAYOUT = ROOT / "ui_template" / "ui_template_workflow_layout.ts" +STEPS = ["파일입력", "전처리", "종단설계"] +WF_ROUTES = ["b03-file-input", "b04-preprocess", "b05-profile"] + + +def _fn(src: str, name: str) -> str: + found = re.search(rf"^(?:export )?function {name}\([\s\S]*?^}}", src, re.M) + assert found, f"{name} 를 못 찾음" + return found.group(0).removeprefix("export ") + + +def _node(script: str, *args: str): + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script, *args], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +FAKE_DOM = ( + "const mk = (): any => { const cls = new Set(); return {" + " children: [] as any[], textContent: '', title: '', disabled: false, on: null as any," + " set className(v: string) { cls.clear(); v.split(' ').filter(Boolean).forEach((c) => cls.add(c)); }," + " get className() { return [...cls].join(' '); }," + " classList: { add: (...c: string[]) => c.forEach((x) => cls.add(x))," + " remove: (c: string) => cls.delete(c), contains: (c: string) => cls.has(c)," + " toggle: (c: string, on: boolean) => (on ? cls.add(c) : cls.delete(c)) }," + " setAttribute() {}, append(...c: any[]) { this.children.push(...c); }," + " addEventListener(_: string, f: any) { this.on = f; } }; };\n" + "const document = { createElement: () => mk() };\n" +) + + +def _step_bar(role: str, routes: list[str]) -> list[bool]: + """역할 · 길 목록으로 그린 단계 단추의 꺼짐 — 역할 물음이 끝난 뒤""" + src = _fn(LAYOUT.read_text(encoding="utf-8"), "createStepBar") + script = ( + FAKE_DOM + + "const ROUTES = { B01_ACCOUNT: 'b01-account', B04_PREPROCESS: 'b04-preprocess' };\n" + "const t = (k: string) => k;\nconst navigateTo = () => {};\n" + "const getDashboardRole = async () => process.argv[1];\n" + + src.replace("import.meta.env.DEV", "false") + + "\nconst bar = createStepBar(JSON.parse(process.argv[2]), -1," + " { routes: JSON.parse(process.argv[3]) });" + "\nawait new Promise((r) => setTimeout(r, 0));" + "\nconsole.log(JSON.stringify(bar.children.map((b: any) => b.disabled)));" + ) + return _node(script, role, json.dumps(STEPS), json.dumps(routes)) + + +def test_전처리_잠금은_길_기준(): + # 프로젝트 진행 단계 — 그대로(전처리만 시스템 관리자 전용) + assert _step_bar("ADMIN", WF_ROUTES) == [False, True, False] + assert _step_bar("USER", WF_ROUTES) == [False, True, False] + assert _step_bar("SYSTEM_ADMIN", WF_ROUTES) == [False, False, False] + # 대시보드 구역 바로가기 — 둘째 칸이 회사 관리자 · 일반에서 안 꺼짐 + titles = ["시스템 설정", "프로젝트", "임시 보관함"] + assert _step_bar("ADMIN", titles) == [False, False, False] + assert _step_bar("USER", titles) == [False, False, False] + + +# ── 실제 브라우저 ───────────────────────────────────────────── + + +def _up() -> bool: + try: + urllib.request.urlopen(BASE, timeout=3) + return True + except Exception: + return False + + +def _cred(who: str) -> dict: + if who == "시스템 관리자": + return json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8")) + accounts = json.loads((ROOT / "tmp" / "verify_accounts.json").read_text(encoding="utf-8")) + return {k: accounts[who][k] for k in ("email", "password")} + + +def _page(browser, who: str, route: str, size=(1280, 700)): + ctx = browser.new_context(viewport={"width": size[0], "height": size[1]}) + r = ctx.request.post(f"{BASE}/api/auth/login/request", data=_cred(who), timeout=60000) + assert r.ok, f"로그인 실패 {r.status}" # 비밀번호는 안 찍음 + page = ctx.new_page() + page.goto(f"{BASE}/#/{route}", wait_until="domcontentloaded") + return page + + +DRAWER_LABELS = ( + "[...document.querySelectorAll('.app-drawer__steps .ui-workflow-layout__step-label')]" + ".map((e) => e.textContent.trim())" +) +DRAWER_DISABLED = ( + "[...document.querySelectorAll('.app-drawer__steps .ui-workflow-layout__step')]" + ".map((e) => e.disabled)" +) +SECTION_TITLES = ( + "[...document.querySelector('.b01-dashboard__grid').children]" + ".map((s) => s.querySelector('.ui-card__title').textContent.trim())" +) +DRAWER_OPEN = "!!document.querySelector('.is-drawer-open')" + + +def _open_drawer(page): + page.click(".app-burger") + page.wait_for_selector(".app-drawer__steps .ui-workflow-layout__step") + + +@pytest.mark.skipif(not _up(), reason="sub2 화면 서버(5177) 꺼짐") +@pytest.mark.parametrize("who, count", [("시스템 관리자", 10), ("회사관리자", 8), ("일반", 6)]) +def test_대시보드_구역_바로가기(who, count): + from playwright.sync_api import sync_playwright + + with sync_playwright() as p: + browser = p.chromium.launch(channel="msedge", headless=True) + page = _page(browser, who, "b01-account") + page.wait_for_selector(".b01-dashboard__grid", timeout=60000) + page.wait_for_load_state("networkidle") + titles = page.evaluate(SECTION_TITLES) + assert len(titles) == count, titles + _open_drawer(page) + assert page.evaluate(DRAWER_LABELS) == titles + assert page.evaluate(DRAWER_DISABLED) == [False] * count # 둘째 칸 안 꺼짐 + if who == "시스템 관리자": + page.click(".app-drawer__steps .ui-workflow-layout__step:has-text('보안')") + assert not page.evaluate(DRAWER_OPEN) + top = ( + "[...document.querySelector('.b01-dashboard__grid').children]" + ".find((s) => s.querySelector('.ui-card__title').textContent.trim() === '보안')" + ".getBoundingClientRect().top" + ) + try: # 부드러운 굴림이 멎을 때까지 + page.wait_for_function(f"() => Math.abs({top} - 52) <= 2", timeout=5000) + except Exception: + pass + assert abs(page.evaluate(top) - 52) <= 2, page.evaluate(top) + browser.close() diff --git a/ui_template/ui_template_general_layout.css b/ui_template/ui_template_general_layout.css index f5533249e..1cdc97764 100644 --- a/ui_template/ui_template_general_layout.css +++ b/ui_template/ui_template_general_layout.css @@ -117,3 +117,8 @@ html.is-bm-route .ui-general-layout__header { padding-left: max(0px, calc(var(--app-burger-inset) + var(--spacing-8) - (100vw - 100%) / 2)); } + +/* ☰ 바로가기(334) — 맨 위로 굴린 구역 제목이 ☰ 자리에 깔리지 않게 비킴 */ +html.is-bm-route .ui-general-block__section { + scroll-margin-top: var(--app-burger-inset); +} diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index 50b68bd70..bcee03cd3 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -116,7 +116,7 @@ export function createStepBar( button.classList.toggle("is-enabled", isEnabled); button.disabled = !isEnabled; - if (index === 1) { + if (options?.routes?.[index] === ROUTES.B04_PREPROCESS) { void getDashboardRole() .then((role) => { if (role !== "SYSTEM_ADMIN") { From 26fcebeb46c6a793d7c6f18d00d69a3b8a809548 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 15:53:07 +0900 Subject: [PATCH 31/36] =?UTF-8?q?feat(=EC=84=A4=EC=A0=95):=20334-2=20?= =?UTF-8?q?=E2=98=B0=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?4=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B0=94=EB=A1=9C=EA=B0=80?= =?UTF-8?q?=EA=B8=B0=20=E2=80=94=20=F0=9F=8F=A0=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20+=20=EC=B9=B4=EB=93=9C=20=C2=B7=20?= =?UTF-8?q?=EC=A7=80=EA=B8=88=20=EC=B9=B8=20=EA=B0=95=EC=A1=B0=20=C2=B7=20?= =?UTF-8?q?M02=20=E2=86=92=20M02=20=EC=9D=B4=EB=8F=99=EC=9D=80=20=EB=96=A0?= =?UTF-8?q?=EB=82=98=EA=B8=B0=20=EB=AC=BC=EC=9D=8C=20=EB=A8=BC=EC=A0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 A00_Common/m_settings_nav: settingLinks = 대시보드 카드 역할 규칙 한 벌 · mountSettingsDrawer = ☰ 진행 단계 자리 - B01 시스템 설정 카드가 settingLinks 로만 그림(화면 그대로) - M01 · M02 page 한 줄씩(M02 = 진입 open · company 이어 실음) - test_b01_settings_cards 가 옮긴 함수 소스를 같이 실음(기대값 그대로) · test_225 뒷부분 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- A00_Common/m_settings_nav.ts | 98 +++++++++++++++ B01_Dashboard/B01_Dashboard_UI_Page.ts | 38 ++---- M01_MasterData/M01_MasterData_UI_Page.ts | 2 + .../M02_MasterTemplete_UI_Page.ts | 2 + resources/tester/test_225_drawer_shortcuts.py | 115 +++++++++++++++++- resources/tester/test_b01_settings_cards.py | 7 +- 6 files changed, 228 insertions(+), 34 deletions(-) create mode 100644 A00_Common/m_settings_nav.ts diff --git a/A00_Common/m_settings_nav.ts b/A00_Common/m_settings_nav.ts new file mode 100644 index 000000000..432177a8e --- /dev/null +++ b/A00_Common/m_settings_nav.ts @@ -0,0 +1,98 @@ +/* ============================================================================= + * m_settings_nav.ts + * 시스템 설정 바로가기(334 · 요구 225-2) — 대시보드 「시스템 설정」 카드 · M01 · M02 ☰ 가 같이 쓰는 + * 역할 규칙 한 벌(`settingLinks`) · ☰ 진행 단계 자리에 🏠 대시보드 + 카드(`mountSettingsDrawer`). + * ========================================================================== */ + +import { ROUTES, type RoutePath } from "@config/config_frontend"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { createStepBar } from "@ui/ui_template_workflow_layout"; +import { setDrawerSteps } from "./app_shell"; +import { currentRoute, navigateTo, takeLeaveGuard } from "./router"; + +interface SettingUser { + role?: string; + is_master?: boolean; + company_id?: number | null; +} + +/** key = M01 은 「m01」 · M02 는 진입 인자 `open` */ +export interface SettingLink { + key: string; + title: string; + desc: string; + route: RoutePath; + query?: Record; +} + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + +const ICONS: Record = { m01: "🗄️", 구조물조합: "🧱", 옵션: "🎛️", 도면: "📑" }; + +/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */ +export function settingLinks(user: SettingUser, companyId: number | string | null): SettingLink[] { + // 시스템 관리자는 고른 회사를 해시에 실음(29-1) + const company: Record = + user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {}; + const m02 = (title: string, open: string, root: string, desc: string): SettingLink => ({ + key: open, + title, + desc, + route: ROUTES.M02_MASTER_TEMPLATE, + query: { open, root, ...company }, + }); + // 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙 + const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback); + if (user.role === "SYSTEM_ADMIN") { + const desc = L("B01_Dashboard_PermMasterWrite"); + return [ + { + key: "m01", + title: L("B01_Dashboard_MasterElements"), + desc: L("B01_Dashboard_PermSourceRead"), + route: ROUTES.M01_MASTER_DATA, + }, + m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc), + m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc), + m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc), + ]; + } + // 회사 수정 = 회사 관리자 · 회사 마스터(M02 accessFor 와 같은 규칙) + const companyAdmin = user.role === "ADMIN" || user.is_master; + const root = companyAdmin ? "company" : "personal"; + const desc = [ + L("B01_Dashboard_PermMasterRead"), + L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"), + L("B01_Dashboard_PermPersonalWrite"), + ].join(" · "); + return [m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc)]; +} + +/** ☰ 진행 단계 자리 — 🏠 대시보드 + 카드 · 지금 칸 강조 · 같은 길(M02 → M02)은 머리 가드가 안 물어 여기서 먼저 */ +export function mountSettingsDrawer( + user: SettingUser, + activeKey: string, + companyId: number | string | null, +): void { + const links = settingLinks(user, companyId); + const bar = createStepBar( + links.map((link) => link.title), + links.findIndex((link) => link.key === activeKey), + { + orientation: "vertical", + icons: links.map((link) => ICONS[link.key] ?? ""), + routes: links.map((link) => link.route), + homeButton: true, + onStepClick: (index) => void go(links[index]), + }, + ); + setDrawerSteps(bar); +} + +async function go(link: SettingLink): Promise { + const guard = link.route === currentRoute() ? takeLeaveGuard() : null; + if (guard && !(await guard())) return; + navigateTo(link.route, link.query); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index a30713238..82515ecfc 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -11,6 +11,7 @@ import { section } from "@ui/ui_template_general_blocks"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { createStepBar } from "@ui/ui_template_workflow_layout"; import { setDrawerSteps } from "../A00_Common/app_shell"; +import { settingLinks } from "../A00_Common/m_settings_nav"; import { currentRoute, navigateTo } from "../A00_Common/router"; import { fetchAllCompanies, @@ -164,40 +165,15 @@ function settingCard(title: string, desc: string, go: () => void): HTMLElement { return card; } -/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */ +/** 시스템 설정 — 카드 규칙은 `settingLinks` 한 벌(☰ 바로가기와 같이 씀 · 334) */ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b01-dashboard__setting-grid"; - // 시스템 관리자는 고른 회사를 해시에 실음(29-1) - const company: Record = - user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {}; - const m02 = (title: string, open: string, root: string, desc: string): HTMLElement => - settingCard(title, desc, () => - navigateTo(ROUTES.M02_MASTER_TEMPLATE, { open, root, ...company }), - ); - // 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙 - const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback); - if (user.role === "SYSTEM_ADMIN") { - const desc = L("B01_Dashboard_PermMasterWrite"); - wrap.append( - settingCard(L("B01_Dashboard_MasterElements"), L("B01_Dashboard_PermSourceRead"), () => - navigateTo(ROUTES.M01_MASTER_DATA), - ), - m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc), - m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc), - m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc), - ); - return wrap; - } - // 회사 수정 = 회사 관리자 · 회사 마스터(M02 accessFor 와 같은 규칙) - const companyAdmin = user.role === "ADMIN" || user.is_master; - const root = companyAdmin ? "company" : "personal"; - const desc = [ - L("B01_Dashboard_PermMasterRead"), - L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"), - L("B01_Dashboard_PermPersonalWrite"), - ].join(" · "); - wrap.append(m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc)); + wrap.append( + ...settingLinks(user, companyId).map((link) => + settingCard(link.title, link.desc, () => navigateTo(link.route, link.query)), + ), + ); return wrap; } diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index 26525e3d2..c43c90a4a 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -19,6 +19,7 @@ import { import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { ROUTES } from "@config/config_frontend"; +import { mountSettingsDrawer } from "../A00_Common/m_settings_nav"; import { navigateTo } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { saveFiles, type FileInfo } from "./M01_MasterData_Api_Fetch"; @@ -54,6 +55,7 @@ export async function renderM01MasterData(root: HTMLElement): Promise { } root.innerHTML = ""; root.append(buildPage()); + mountSettingsDrawer(user, "m01", null); // ☰ 시스템 설정 바로가기(334) } function buildPage(): HTMLElement { diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 95f647195..c44e253e8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -13,6 +13,7 @@ import { ROUTES } from "@config/config_frontend"; import { el } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; +import { mountSettingsDrawer } from "../A00_Common/m_settings_nav"; import { navigateTo, type MountedPage } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch"; @@ -238,6 +239,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise false, show: () => {}, dispose: () => setActiveLayer("system") }; diff --git a/resources/tester/test_225_drawer_shortcuts.py b/resources/tester/test_225_drawer_shortcuts.py index d4cb2d4b9..c7fdd1550 100644 --- a/resources/tester/test_225_drawer_shortcuts.py +++ b/resources/tester/test_225_drawer_shortcuts.py @@ -1,8 +1,10 @@ """PLAN 334 — ☰ 바로가기(요구 225-1 · 225-2). ① Node — 진행 단계 「시스템 관리자 전용」 = 전처리 길 기준(칸 번호 아님) · 대시보드 둘째 칸은 안 꺼짐 + settingLinks 역할 셋 · 설정 ☰ 지금 칸(진입 인자) ② 실제 브라우저(sub2 화면 서버 5177 · 꺼지면 건너뜀) — 대시보드 ☰ 이름 = 구역 제목 차례(역할 셋) · - 「보안」 누름 → 서랍 닫힘 · 보안 위 끝 ≈ ☰ 자리(52) + 「보안」 누름 → 서랍 닫힘 · 보안 위 끝 ≈ ☰ 자리(52) · M01 ☰ 다섯 · 강조 · 「도면 양식」 → open=도면 · + M02 → M02 이동은 떠나기 물음 먼저(가드 자리에 물음 창을 심어 확인 · 취소 = 머묾) """ from __future__ import annotations @@ -19,6 +21,7 @@ import pytest ROOT = Path(__file__).resolve().parents[2] BASE = os.environ.get("AISLO_UI_BASE", "http://localhost:5177") # sub2 화면 서버(OWNERS 자기 줄) LAYOUT = ROOT / "ui_template" / "ui_template_workflow_layout.ts" +NAV = ROOT / "A00_Common" / "m_settings_nav.ts" STEPS = ["파일입력", "전처리", "종단설계"] WF_ROUTES = ["b03-file-input", "b04-preprocess", "b05-profile"] @@ -81,6 +84,58 @@ def test_전처리_잠금은_길_기준(): assert _step_bar("USER", titles) == [False, False, False] +def _settings(user: dict, active: str, company) -> dict: + """settingLinks 목록 + ☰ 에 맡긴 단계 막대(이름 · 지금 칸 · 아이콘 · 🏠)""" + src = NAV.read_text(encoding="utf-8") + icons = re.search(r"^const ICONS.*$", src, re.M).group(0) + script = ( + "const ROUTES = { M01_MASTER_DATA: 'm01-master-data'," + " M02_MASTER_TEMPLATE: 'm02-master-template' };\n" + "const L = (k: string) => k;\nlet bar: any = null;\n" + "const createStepBar = (titles: string[], active: number, o: any) =>" + " ({ titles, active, icons: o.icons, home: o.homeButton, routes: o.routes });\n" + "const setDrawerSteps = (b: any) => { bar = b; };\nconst go = () => {};\n" + + icons + + "\n" + + _fn(src, "settingLinks") + + "\n" + + _fn(src, "mountSettingsDrawer") + + "\nconst [user, active, company] = JSON.parse(process.argv[1]);" + "\nmountSettingsDrawer(user, active, company);" + "\nconsole.log(JSON.stringify({ links: settingLinks(user, company).map((l) =>" + " [l.key, l.route, l.query ?? null]), bar }));" + ) + return _node(script, json.dumps([user, active, company])) + + +M02 = "m02-master-template" + + +def test_설정_바로가기_역할_셋_지금_칸(): + admin = _settings({"role": "SYSTEM_ADMIN", "company_id": 1}, "옵션", "3") + assert admin["links"] == [ + ["m01", "m01-master-data", None], + ["구조물조합", M02, {"open": "구조물조합", "root": "personal", "company": "3"}], + ["옵션", M02, {"open": "옵션", "root": "master", "company": "3"}], + ["도면", M02, {"open": "도면", "root": "master", "company": "3"}], + ] + bar = admin["bar"] + assert bar["active"] == 2 and bar["home"] is True + assert bar["icons"] == ["🗄️", "🧱", "🎛️", "📑"] + assert _settings({"role": "SYSTEM_ADMIN"}, "m01", None)["bar"]["active"] == 0 + # 일위대가조합 = 강조 없음 + assert _settings({"role": "SYSTEM_ADMIN"}, "일위대가조합", None)["bar"]["active"] == -1 + # 그 밖 역할 = 🏠 + 구조물 조합 하나(회사 인자 안 실음) + company = _settings({"role": "ADMIN", "company_id": 1}, "구조물조합", "3") + assert company["links"] == [["구조물조합", M02, {"open": "구조물조합", "root": "personal"}]] + assert company["bar"]["active"] == 0 + assert _settings({"role": "USER", "is_master": True}, "구조물조합", None)["links"][0][2] == { + "open": "구조물조합", + "root": "company", + } + assert _settings({"role": "USER"}, "구조물조합", None)["links"][0][2]["root"] == "personal" + + # ── 실제 브라우저 ───────────────────────────────────────────── @@ -157,3 +212,61 @@ def test_대시보드_구역_바로가기(who, count): pass assert abs(page.evaluate(top) - 52) <= 2, page.evaluate(top) browser.close() + + +ACTIVE = "document.querySelector('.app-drawer__steps .is-active .ui-workflow-layout__step-label')?.textContent" +HASH = "decodeURIComponent(location.hash)" + + +def _drawer_click(page, label: str): + _open_drawer(page) + page.click(f".app-drawer__steps .ui-workflow-layout__step:has-text('{label}')") + + +@pytest.mark.skipif(not _up(), reason="sub2 화면 서버(5177) 꺼짐") +def test_설정_4_페이지_바로가기(): + from playwright.sync_api import sync_playwright + + with sync_playwright() as p: + browser = p.chromium.launch(channel="msedge", headless=True) + page = _page(browser, "시스템 관리자", "m01-master-data") + page.wait_for_selector(".m01-master", timeout=60000) + _open_drawer(page) + assert page.evaluate(DRAWER_LABELS) == [ + "대시보드", + "마스터 데이터", + "구조물 조합", + "구조물 옵션", + "도면 양식", + ] + assert page.evaluate(ACTIVE) == "마스터 데이터" + page.click(".app-drawer__steps .ui-workflow-layout__step:has-text('도면 양식')") + page.wait_for_function("decodeURIComponent(location.hash).includes('open=도면')") + assert M02 in page.evaluate(HASH) + page.wait_for_selector(".m02-master", timeout=60000) + page.wait_for_load_state("networkidle") + _open_drawer(page) + assert page.evaluate(ACTIVE) == "도면 양식" + page.click(".app-burger") # 닫음 + + # M02 → M02 — 저장 안 한 고침 대신 가드 자리에 앱의 물음 창을 심음 + page.evaluate( + """async () => { + const url = (p) => performance.getEntriesByType('resource') + .map((e) => e.name).find((n) => n.includes(p)); + const router = await import(url('/router.ts')); + const ui = await import(url('/ui_template_elements.ts')); + router.setLeaveGuard(() => ui.showConfirmDialog('저장 안 한 고침')); + }""" + ) + before = page.evaluate(HASH) + _drawer_click(page, "구조물 옵션") + page.wait_for_selector(".ui-confirm") + page.click(".ui-confirm__actions button >> nth=0") # 취소 = 머묾 + page.wait_for_timeout(300) + assert page.evaluate(HASH) == before + _drawer_click(page, "구조물 옵션") + page.wait_for_selector(".ui-confirm") + page.click(".ui-confirm__actions button >> nth=1") # 버림 = 이동 + page.wait_for_function("decodeURIComponent(location.hash).includes('open=옵션')") + browser.close() diff --git a/resources/tester/test_b01_settings_cards.py b/resources/tester/test_b01_settings_cards.py index 004a0013d..7798ddfd2 100644 --- a/resources/tester/test_b01_settings_cards.py +++ b/resources/tester/test_b01_settings_cards.py @@ -17,6 +17,7 @@ import pytest ROOT = Path(__file__).resolve().parents[2] PAGE = ROOT / "B01_Dashboard" / "B01_Dashboard_UI_Page.ts" +NAV = ROOT / "A00_Common" / "m_settings_nav.ts" # 카드 규칙 한 벌(334) def _text(path: Path) -> str: @@ -24,9 +25,9 @@ def _text(path: Path) -> str: def _fn(src: str, name: str) -> str: - found = re.search(rf"^function {name}\([\s\S]*?^}}", src, re.M) + found = re.search(rf"^(?:export )?function {name}\([\s\S]*?^}}", src, re.M) assert found, f"{name} 를 못 찾음" - return found.group(0) + return found.group(0).removeprefix("export ") def _cards(user: dict, company_id: int | None = None) -> list: @@ -46,6 +47,8 @@ def _cards(user: dict, company_id: int | None = None) -> list: "const document = { createElement: () => mk() };\n" + _fn(src, "settingCard") + "\n" + + _fn(_text(NAV), "settingLinks") + + "\n" + _fn(src, "buildSystemSettingsPanel") + "\nconst user = JSON.parse(process.argv[1]);" + "\nconst panel = buildSystemSettingsPanel(user, JSON.parse(process.argv[2]));" From 04021d1f73b69b4a1f991ae9bd148bc845bb5b9d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 16:13:03 +0900 Subject: [PATCH 32/36] =?UTF-8?q?chore(=EC=A1=B0=EC=82=AC):=20335=20CAD=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EB=B3=B4=EA=B0=95=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=E2=80=94=20=EC=98=A4=ED=86=A0=EC=BA=90?= =?UTF-8?q?=EB=93=9C=2071=20=EA=B0=80=EC=A7=80=20=EB=8C=80=20=EC=9A=B0?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=20=ED=9D=A0=2012=20=C2=B7=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=EC=9D=BC=EA=B3=B1=20=C2=B7=20=EB=B6=80=EB=A1=9D=20?= =?UTF-8?q?=EB=91=98?= 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_01C6BNBFXFndbqYxu6sZuknK --- .../조사/2026-10-15_335_CAD입력보강_검토.md | 297 +++++++++++++++ .../조사/2026-10-15_335_부록1_오토캐드입력.md | 354 ++++++++++++++++++ .../조사/2026-10-15_335_부록2_우리CAD현황.md | 261 +++++++++++++ 3 files changed, 912 insertions(+) create mode 100644 docs/raw/조사/2026-10-15_335_CAD입력보강_검토.md create mode 100644 docs/raw/조사/2026-10-15_335_부록1_오토캐드입력.md create mode 100644 docs/raw/조사/2026-10-15_335_부록2_우리CAD현황.md diff --git a/docs/raw/조사/2026-10-15_335_CAD입력보강_검토.md b/docs/raw/조사/2026-10-15_335_CAD입력보강_검토.md new file mode 100644 index 000000000..a24b5d503 --- /dev/null +++ b/docs/raw/조사/2026-10-15_335_CAD입력보강_검토.md @@ -0,0 +1,297 @@ +# CAD 입력 보강 — 상세 검토 (요구 225-3 · PLAN 335) + +- 기준 = `main_laptop_1` 422c449eb · 코드 읽기만(실행 · 빌드 · 화면 실측 안 함) · 코드 고침 없음. +- 재료 = B1(오토캐드 쪽 · 쓸모 높음 32 · 중간 39 = 71 줄) · B2(우리 현황) · 331(연속 명령) · 332(설정 창) 조사. +- B1 = 같은 폴더 `2026-10-15_335_부록1_오토캐드입력.md`(출처 [S..] 목록) · B2 = `2026-10-15_335_부록2_우리CAD현황.md`. +- 경로 줄임 — 파일 이름만 = `B07_DesignDetail/openwebcad/src/` 아래 · `A00/` = `A00_Common/` · 페이지 파일은 저장소 뿌리부터. +- 기호 — ● 있음 · ◐ 일부 · ○ 없음 · ⚠ 흠 · (추정) = 코드 흐름 판단 · 화면 실측 필요. +- 크기 — 작음 = 반나절 안 · 중간 = 하루~이틀 · 큼 = 사흘 넘음(시험 포함 · 화면 확인 별도 · 코드 읽기 어림). + +## 0. 결론 요약 + +1. 오토캐드 입력 방법 중 쓸모 높음 · 중간 71 가지를 우리 CAD 와 맞대 봄 — 있음 2 · 일부 28 · 없음 41(331 에서 넣은 좌표 꼴 · 각도 고정 · Tab 칸 · 한 번 스냅 등은 이번 표 밖). +2. 보강보다 먼저 고칠 흠 12 — 급한 셋: ① Ctrl+C · Ctrl+X 같은 Ctrl 조합이 글자로 쌓여 Enter · Space · 우클릭 때 원 · 분해 · 지우기 명령이 돎 ② 확정 도면 · 미리보기가 그립 · Delete · 특성 칸으로 고쳐짐(저장은 안 되나 DXF 내보내기로 새어 나감) ③ 그립을 끌지 않고 누르기만 해도 점이 누른 자리로 밀림(실측 필요). +3. 흠은 거의 다 작음 — 한 창이 몰아 하면 사흘~엿새. +4. 보강은 일곱 묶음 — ① 좌표 · 값 ② 스냅 · 추적 ③ 고르기 ④ 그립 ⑤ 편집 손버릇 ⑥ 명령줄 ⑦ 자동화. +5. 먼저 셋 — ⓪ 흠 막기 → ③ 고르기 손버릇(상자로 더하기 · 명령 중 누르는 대로 더함 · 앞 선택 P) → ② 극 추적 각 고르기 · 비탈 비율 각(1:1.5 꼴) · Shift 직교. +6. 그다음 ④ 그립(누르고 · 옮기고 · 누르기 · 그립 중 숫자 · 치수 글자 비키기) · ① FROM · 두 점 가운데 · 식 그대로(3.2*1.5). +7. ⑥ 명령줄 · ⑦ 자동화는 임도 도면 손질에 쓸모가 낮아 뒤로. +8. B06 상세 · M02 카드에는 리본 · 상태막대 · 명령행이 없음 → 새 기능은 키 · 우클릭 메뉴 · ☰ 설정으로도 넣어야 세 곳 모두 씀. +9. 정할 것 14 — 빼기 키(Ctrl 대 Shift) · 우클릭(Enter 대 메뉴) · 극 추적 각 목록 · 직교 강제 · 그립 방식 등(5장). +10. 근거 = 코드 읽기 — 흠 중 그립 어긋남 · 안 보이는 것 고름 · 가이드 거리 · 명령행 한글은 화면 실측 필요. + +## 1. 흠 — 보강 전에 고칠 것 + +### 1-1. B2 주장 다시 확인 + +| B2 주장 | 다시 본 결과 | 근거 | +|---|---|---| +| Ctrl+C · Ctrl+X 가 입력 글로 쌓여 Enter 때 원 · 분해 | 맞음 · 더 넓음 — Ctrl · Alt 와 함께 누른 글자 전부(Z · Y · S · A 빼고)가 글로 쌓임 · Enter 뿐 아니라 Space · 우클릭도 제출 · 분해는 고른 것이 있으면 Enter 한 번 더에 실제로 나뉨 | `input-controller.ts:374,423-428,476-482` · `A00/key_shortcut.ts:31-49` · `commands.draw.ts:53` · `commands.modify.ts:202,211` · `tools/modify/structure-tools.ts:46-68` · `sequence-tool.ts:321-337` | +| 보기 전용(readOnly)에서 그립 · Delete · 특성 칸이 안 막힘 | 맞음 · 덧 — Ctrl+그립(폴리선 꼭짓점 지움) · 도면층 관리 층째 지움도 안 막힘 · 그립이 보기 전용에서도 그려짐 · 미리보기(viewOnly) Ctrl+A → Delete 맞음 | `select-tool.helpers.ts:55-72` · `grip-edit-tool.ts:96-105` · `select-tool.ts:73-76,167-171` · `input-controller.ts:438-450` · `PropertiesEditor.tsx:12-17` · `RibbonWidgets.tsx:39-47` · `LayerManager.tsx:41-60` · `helpers/draw.ts:56` · `state.ts:340-350` | +| 저장까지 가는지 미확인(B2 9-2) | 확인 — B07 = 저장 길 없음([수정]이 서버 원본을 다시 실음) · M02 = 부모 [저장]이 막음 · 단 B07 DXF · DWG 내보내기는 CAD 화면 그대로 보냄 → 확정본과 다른 파일이 나갈 수 있음 · 탭에 「미저장」 | `B07_DesignDetail/B07_DesignDetail_UI_Page.ts:122-126,476-489,505-510` · `M02_MasterTemplete/M02_MasterTemplete_Drawing.ts:231-233` | +| 극 추적 45° 고정 | 맞음 · 덧 — 직교와 한 값(둘 중 하나) · F3 끄면 같이 꺼짐 · 가이드 잡는 거리 = 도면 단위 15(화면 px 아님) | `input-controller.ts:277-280,403-412` · `state.ts:82` · `StatusBar.tsx:22-23,44-51` · `get-draw-guides.ts:36-49` · `App.consts.ts:57` | +| 고르기 상자 더하기 안 됨 | 맞음 · Ctrl+상자 = 「상자 안의 안 고른 것만」 남음도 맞음 · Shift+상자는 주석 TODO | `select-tool.helpers.ts:114-169` · `select-tool.ts:159-164` | +| 우클릭 = Enter 만 | 맞음 · 덧 — 쳐 둔 글도 제출(Ctrl+C 로 쌓인 「C」 → 우클릭 = 원) · iframe 전체 기본 메뉴 막음 | `input-controller.ts:116-119,174-180,511-575` · `ui_template/cad_host/cad_host.ts:149` | +| 그립 = 누른 채 끌어 놓기만(B2 추정) | 확인(추정 풂) · 덧 — 안 끌고 떼도 그 자리에 적용(H3) | `input-controller.ts:195-211,311-330` · `select-tool.helpers.ts:56-71` · `grip-edit-tool.ts:64-112` | +| 손가락으로 그립 못 옮김(B2 추정) | 확인 · 덧 — 톡 = 그립 근처 누른 자리로 점이 밀림(옮김 아닌 어긋남) | `input-touch.ts:46-50,106-112` · `pick-radius.ts:17-22` | +| 겹친 것 돌려 고르기 「Shift+Space」 | 고침 — Shift+Space 처리 없음(Shift 붙은 Space 는 아무 일 없음) · 같은 자리 다시 누르기만 | `ime-sink.ts:47-51` · `input-controller.ts:458-463` · `select-tool.helpers.ts:34-49` | +| 명령행 칸 한글 자판이면 명령 못 찾음(B2 추정) | 그대로 추정 — 일반 입력 칸 · 자모 → 영문 바꿈 없음 | `CommandLine.tsx:51-57` | +| 끝점 스냅 「폴리선 선 구간」 | 작게 고침 — 폴리선은 토막마다 맡김 → 호 토막도 끝점 · 중심 냄 | `PolyLineEntity.ts:114-116` · `ArcEntity.ts:132-148` | + +### 1-2. 흠 목록 + +| # | 무엇이 틀림(화면 말) | 근거 | 어디서 | 고침(한 줄) | 크기 | 급함 | +|---|---|---|---|---|---|---| +| H1 ⚠ | Ctrl · Alt+글자가 명령 글자로 쌓임 — Ctrl+C → 「C」 → Enter · Space · 우클릭 = 원 명령(선 그리는 중이면 닫기) · Ctrl+X → 「X」 → 분해(고른 것 있으면 Enter 한 번 더에 분해) · Ctrl+E → 지우기 · Ctrl+F → 모깎기 · Ctrl+L → 선 · Ctrl+1 → 「1」 → 점 단계면 거리 1 인 점이 찍힘 | 글자 받는 줄이 수정키를 안 봄 `input-controller.ts:476-482` · Ctrl 이름표 = Z · Y · S · A 넷 `A00/key_shortcut.ts:31-49` · Ctrl+V 만 따로 `input-controller.ts:423-426` | 세 곳 모두 · B06 상세는 고친 것이 곧바로 세션 초안 | 그 줄에 Ctrl · Alt · ⌘ 거름 + Ctrl+C/X = COPYCLIP · CUTCLIP | 작음 | 높음 | +| H2 ⚠ | 보기 전용인데 고쳐짐 — 확정 도면 · 권한 없는 양식에서 그립 끌기(+Ctrl+그립 = 꼭짓점 지움) · Delete · 손가락 🗑 · 특성 칸 · 리본 특성 · 도면층 관리(층 옮김 · 층째 지움)가 먹음 · 미리보기는 Ctrl+A → Delete · 저장은 안 되나 B07 DXF · DWG 내보내기에 그대로 실림 | 명령 길만 막음 `run-command.ts:28-34` · 직접 고치는 길에 문 없음 `state.ts:340-350` · 1-1 표 둘째 · 셋째 줄 | B07 확정본 · M02 권한 없음 · B07 평면도 관리 · M02 카드 보기 · 면적산출 패널 | 고치는 길 넷(그립 · Delete · 특성 · 도면층)에 문 한 줄씩 또는 공용 「고칠 수 있나」 판정 하나 · 보기 전용이면 그립 안 그림 | 작음~중간 | 높음 | +| H3 ⚠ | 그립을 누르기만 해도 점이 밀림(추정) — 그립을 누르면 그립 도구로 바뀌며 스냅이 지워짐 → 마우스를 안 움직이고 떼면 스냅 없이 「뗀 자리」로 적용 → 그립 중심에서 벗어난 만큼(마우스 최대 10px · 손가락 24px 넘게) 끝점 · 선 전체(가운데 그립) · 원 중심 · 글 기준점이 밀리고 「고침」 표시 | `state.ts:386-391` · `grip-edit-tool.ts:85-105` · `input-controller.ts:195-211,228-251,277-280` · 손가락 톡은 늘 이 길 `input-touch.ts:46-50` | 세 곳 모두 · 손가락 더 큼 | 안 끌고 떼면 적용하지 않음(오토캐드 = 그립이 「뜨거워짐」 · ④ 첫걸음) | 작음 | 높음 | +| H4 | 상자 고르기 — Ctrl+상자 = 상자 안의 「안 고른 것만」 남고 나머지 고름은 버려짐(더하기도 빼기도 아님) · Shift+상자 = 새로 고름 · 상자는 늘 앞 고름을 버림 | `select-tool.helpers.ts:118,131-168` · `select-tool.ts:159-164` | 세 곳 모두 | 상자 결과를 앞 고름에 더함 · 빼기 키 상자 = 뺌(정할 것 1) | 작음 | 중간 | +| H5 | 명령 중 고르기가 누를 때마다 바뀜 — 이동 · 복사 · 지우기 등 「객체 선택」 단계에서 하나씩 누르면 앞 것이 빠짐 → 오토캐드 손버릇으로 셋 누르고 Enter 하면 하나만 옮겨짐 · 지워짐 · 끌기 상자 안 됨(두 번 누름) · 스침 강조 없음 | `select-tool.helpers.ts:80-82` · `input-controller.ts:283,325-327` · `move-tool.ts:98-138` | 세 곳 모두 | 명령 안 고르기에선 누름 = 더함(정할 것 2) | 작음 | 중간 | +| H6 | 안 보이는 것이 골라지고 스냅됨(실측 필요) — 꺼진 층 · 동결 층 · 숨긴 객체(HIDEOBJECTS · ISOLATE)를 그리기에서만 뺌 → Ctrl+A → Delete 면 안 보이는 것까지 지워짐 · 빈 곳에 스냅 표지 · 그립 · 동결은 「선택에서도 뺀다」고 적혀 있으나 안 뺌 | 그리기만 뺌 `helpers/draw-functions.ts:32-37` · 고르기 · 스냅은 잠금 층만 뺌 `state.ts:220-221` · `select-tool.helpers.ts:123-131` · `calculate-angle-guides-and-snap-points.ts:29-33` · 적힌 뜻 `App.types.ts:68` | 세 곳 모두(층 끄기 · 숨기기 쓴 때) | 집을 수 있는 목록 · 상자 · 스냅 거름에 「보이는가」 한 줄 | 작음 | 중간 | +| H7 | 폴리선 등 점 찍는 명령 중 C · U — 선(LINE)만 C(닫기) · U(한 토막 취소)를 받음 · 폴리선 · 호 · 치수 등에서 `C` = 원 명령으로 바뀌며 그리던 폴리선이 버려짐 · `U` = 앞서 끝낸 편집 하나를 되돌림(명령은 그대로) | 점 단계에 글 받기 없음 `sequence-tool.ts:282-293` · 명령 이름으로 넘어감 `input-controller.ts:562-571` · `run-command.ts:44-49` · UNDO 별칭 U `commands.file.ts:100-107` · 폴리선 `tools/draw/basic-draw-tools.ts:21-36` | 세 곳 모두 | 폴리선에 C · U 선택지(선과 같은 틀 meta.options) · 점 단계에서 C · U 는 명령 이름으로 안 봄 | 작음 | 중간 | +| H8 | 직교가 직교가 아님 — ① F3(객체 스냅) 끄면 직교 · 극 추적 가이드도 계산 안 함(상태막대 「직교」는 켜진 채 · 손가락 ⌖ 도 같음) ② 직교는 축 15px 안일 때만 붙음 → 직교 켜고 거리 숫자를 쳐도 커서가 축에서 벗어나 있으면 비스듬한 선 | ① `input-controller.ts:277-280` · `state.ts:481-490` ② `input-controller.ts:587-593` · `get-draw-guides.ts:62-88` | 세 곳 모두 | 가이드 계산을 F3 와 뗌 · 직교(90)일 땐 커서를 늘 축으로 내림 | 작음 | 중간 | +| H9 | 가이드 잡는 거리가 줌 따라 바뀜(추정) — 직교 · 극 추적 가이드는 「도면 단위 15」 안에서 잡음 · 점 스냅 · 고르기는 화면 px 로 나눠 씀 → 크게 줄인 평면도에선 가이드가 거의 안 잡히고 크게 키운 횡단에선 늘 보임 | `App.consts.ts:57` · `get-draw-guides.ts:42` 대 `calculate-angle-guides-and-snap-points.ts:28` · `pick-radius.ts:17-22` | 세 곳 모두 | 15 를 화면 배율로 나눔 한 줄 | 작음 | 낮음~중간 | +| H10 | 그리드(F7)가 눈금이 아님 — 화면에 박힌 24px 무늬 · 팬 · 줌 · 도면 단위와 무관 → 눈금으로 오해 | `drawControllers/screenCanvas.drawController.ts:338-352` | 상태막대 있는 곳 · F7 | 그리드 뺌(작음) 또는 도면 단위 그리드 + 격자 스냅(중간 · 정할 것 10) | 작음 | 낮음 | +| H11 | 명령행 ↑ 기록에 알림 줄이 섞임 — 「ERASE: 확정 도면 — 실행하지 않음」 같은 줄도 기록 → ↑ 로 부르면 「ERASE:」 꼴 → 실행 안 됨 | `run-command.ts:31-38` · `CommandLine.tsx:38` | 명령행 있는 곳(B07 · M02 양식) | 기록에 명령 이름만 | 작음 | 낮음 | +| H12 | 명령행 칸에 한글 자판으로 치면 「ㅣ」 꼴 → 명령 못 찾음(추정) · 캔버스에 바로 치기는 괜찮음 | `CommandLine.tsx:51-57` · 캔버스 쪽 `ime-sink.ts:1-5` | 명령행 있는 곳 | 두벌식 자모 → 영문 자리 바꿈 표로 찾기(⑥ D13 과 같음) | 작음 | 낮음 | + +### 1-3. 고치는 차례 + +1. H1 · H2 · H3 — 도면을 몰래 바꾸는 것부터(B06 상세는 고친 것이 곧바로 세션 초안 `B06_Section/B06_Section_UI_Cross_Detail_Cad.ts:317-330`). +2. H5 · H4 · H7 — 오토캐드 손버릇이 사고로 이어지는 것. +3. H6 · H8 — 보이는 것과 실제가 다른 것. +4. H9~H12 — 나머지. +- 시험 — 고르기 · 그립 누르기 흐름 · F키 · 보기 전용 새는 길 시험이 0건(B2 10장) → 흠 고칠 때 `resources/tester/cad/` 에 시험 틀부터. + +## 2. 묶음 추천 + +### 2-1. 묶음 표 + +| 묶음 | 무엇이 생기나(예) | 담는 줄(3장 번호) | 크기 | 앞몫(먼저 할 몫) | 임도 쓸모 | +|---|---|---|---|---|---| +| ⓪ 흠 막기 | 확정 도면이 안 고쳐짐 · Ctrl+C 가 복사로 · 그립을 눌러도 안 밀림 · 상자로 더해 고르기 — 예: 확정 횡단에서 Delete 를 눌러도 그대로 | 1장 H1~H12 · 6-4 · D6(C · U) | 작음 11 · 작음~중간 1 → 어림 3~6일(한 줄짜리 많음) | 전부 | 바닥 | +| ① 좌표 · 값 | 기준점에서 띄운 점 · 두 점 가운데 · 식 그대로 · 한 점 X + 다른 점 Y — 예: `FROM` → 옹벽 끝 → `@0.5,0` | 1-1 · 1-4 · 1-6~1-9 · 1-11 · D5 · D6(A) · D7(1-5 는 맨 뒤) | 작음 7 · 중간 4 → 어림 10일 | FROM · 두 점 가운데 · 식 · `@` · QC 값 넣기 · 선 이어 그리기 → 3일 | 상세 · 횡단 높음 | +| ② 스냅 · 추적 | 극 추적 각 고르기 · 비탈 비율 각 · Shift 직교 · 글자 기준점 스냅 · 연장선 · 평행 · Tab 후보 — 예: `<1:1.5` Enter → 비탈 방향 고정 → `3.2` Enter | 1-10 · 2-1~2-7 · D1~D4 · D12 | 작음 11 · 중간 6 → 어림 15일 | D1 · D2 · D3 · 2-1 · 글자 기준점(INS) · D12 → 3일 | 횡단 높음 | +| ③ 고르기 | 상자로 더하기 · 빼기 · 명령 중 누르는 대로 더함 · 앞 선택 P · 울타리 F · 올가미 — 예: 회전 명령에서 `P` → 방금 옮긴 것 | 3-7 · 4-1~4-14 · D14 | 작음 11 · 중간 4 → 어림 12일 | 4-1 · 4-5~4-8 · 4-14 · 3-7 · D14 → 4일 · 다음 울타리 | 전부 높음 | +| ④ 그립 | 누르고 · 옮기고 · 누르기 · 그립 중 숫자 · Space 로 옮기기 · 돌리기 · 치수 글자 비키기 — 예: 선 끝 그립 누르고 `2.5` Enter | 5-1~5-9 · 8-1 | 작음 4 · 중간 6 · 큼 2 → 어림 19일 | 핫 그립 · 그립 숫자 · 끝 늘이기 → 3일 · 다음 치수 글자 그립 | 치수 · 글자 높음 | +| ⑤ 편집 손버릇 | 우클릭 메뉴 · 휠 두 번 = 전체 · 두 번 눌러 치수 · 표 고치기 · 빠른 속성에 길이 · 글 높이 · 특성 맞추기 여러 개 · 간격띄우기 미리보기 — 예: 고른 채 우클릭 → [복사] | 3-6 · 6-1 · 6-2 · 6-4~6-9 · 8-2 · 8-3 · 9-1 · 9-3 · D15 | 작음 6 · 중간 8 · 큼 1 → 어림 19일 | 우클릭 메뉴 · 휠 두 번 · 두 번 눌러 고치기 · 특성 맞추기 여러 · 간격띄우기 미리보기 → 4~5일 | 중간~높음 | +| ⑥ 명령줄 · 안내 | 한글 자판 그대로 명령 · 한글 이름으로 찾기 · 선택지 단추 · ↑ 로 좌표도 다시 · 창 확대 — 예: 명령행에 「ㅣ」 → 선 | 1-2 · 1-3 · 3-1~3-4 · 6-3 · 9-2 · D8~D11 · D13 | 작음 7 · 중간 5 → 어림 11일 | D13 · 3-2 · D11 · 1-3 → 2일 | 중간 | +| ⑦ 자동화 | 글 여러 줄 붙여 차례 실행 · 내 단추 · 블록에 층 · 축척 미리 — 예: 표기 다섯 줄을 한 번에 | 3-5 · 7-1~7-3 · 8-4~8-8 | 중간 5 · 큼 3 → 어림 20일 | 7-3 블록 기본값만 | 낮음 | + +### 2-2. 차례 추천 + +- 먼저 셋 + 1. ⓪ 흠 막기 — 도면을 몰래 바꾸는 길을 막고 다른 묶음의 바닥(보기 전용 문 · Ctrl 거름 · 그립 대기 · 고르기 더함)을 깖. + 2. ③ 고르기 앞몫 — 모든 편집의 시작 · 작음 위주 · 오토캐드 손버릇과 가장 자주 부딪히는 곳. + 3. ② 스냅 앞몫 — 횡단 비탈을 키보드로(요구 224-7 「각도 → 길이」의 연장) · 작음 위주. +- 그다음 — ④ 그립 앞몫(3일) → ① 좌표 앞몫(3일) → ⑤ 앞몫(4~5일) — 앞몫 합 어림 20~23일. +- 나머지 몫 · ⑥ · ⑦ — 앞몫을 써 본 뒤 고름. + +### 2-3. 묶음끼리 먼저 할 관계 + +- ⓪ → 전부 — 새 고치기 길(그립 모드 · Ctrl+화살표 · 빠른 속성 칸 · 우클릭 메뉴)은 모두 H2 의 보기 전용 문을 지나야 함 · H1 Ctrl 거름은 ⑤ Ctrl 조합의 바닥 · H3 그립 대기는 ④ 첫걸음 · H4 · H5 는 ③ 첫걸음. +- ⑤ 우클릭 메뉴 틀(D15) → ② Shift+우클릭 스냅 메뉴(2-5) · ③ 겹친 것 목록(4-10) · ④ 그립 올림 메뉴(5-5) · ⑥ 선택지 단추(D8) — 메뉴 틀 하나를 같이 씀 · 정할 것 3 먼저. +- ④ 핫 그립(5-1) → 그립 숫자(5-9) · 모드 돌림(5-2) · B · C(5-3) · 손가락 그립. +- ③ 앞 선택 기억(4-6) → ⑤ 우클릭 [앞 선택] · ⑦ 스크립트. +- ① 마지막 점(1-1) → D5 선 이어 그리기. +- ② 극 추적 각(D1) · 켬 상태 기억(D12) → ☰ 설정 CAD 탭(332 · 이미 있음). +- ⓪ H7 ↔ 정할 것 7(점 단계에서 명령 이름 거절). +- ⑦ → ⑥(명령줄 해석 · 선택지 · 기본값이 안정된 뒤). + +## 3. 차이표(전체) + +- 줄 = B1 의 쓸모 높음 · 중간 71 줄(B1 차례 그대로) + 3-10 덧(331 에서 남은 빈 곳 · 임도 맞춤). +- 위험 = 깨짐(다른 기능) · 한글(자판) · 터치 중 걸리는 것만. + +### 3-1. 좌표 입력(B1 1장) + +| # | 오토캐드(쓸모) | 우리 · 근거 | 빠진 점 | 넣는다면(어디 · 어떻게) | 크기 | 위험 | 임도 쓸모 | 묶음 | +|---|---|---|---|---|---|---|---|---| +| 1-1 | 마지막 점 `@`(중간) | ○ — `@` 홀로 = 어느 꼴에도 안 맞음 `input-patterns.ts:30-65` · 도구마다 자기 앞 점 | 명령 사이 마지막 점 · ID 로 찍은 점 이어 쓰기 | 새 `inputController/input-last-point.ts`(점 보낼 때 적음 `input-controller.ts:610-614`) + `@` 홀로 = 그 점 | 작음 | 낮음 | 중간 · 상세 · 치수 | ① | +| 1-2 | 포인터 칸 Tab(`x` Tab `y`)(중간) | ◐ — Tab 칸 = 길이 · 각도뿐(331-4 `input-controller.ts:413-422`) · 첫 점엔 칸 없음 · 켬/끔 없음 | 첫 점 X · Y 칸 · 켬/끔 | `input-angle-lock.ts` 칸에 X · Y 갈래 · 그림 `input-field-draw.ts` | 중간 | 깨짐: Tab 이 브라우저 초점 이동을 뺏는 범위 늘어남 | 낮음 | ⑥ | +| 1-3 | 동적 프롬프트 · 추가 팁(중간) | ◐ — 커서 곁 상자 = 안내 · 좌표 · 길이/각도 · 후보 6 `input-field-draw.ts:37-110` · 그립 팁 · 끄기 없음 | 단계마다 쓸 수 있는 키 · 끄기 | `input-field-draw.ts` 한 줄 더 · 켬/끔 = ☰ 설정 | 작음 | 낮음 | 중간 · 오토캐드 모르는 사용자 | ⑥ | +| 1-4 | 식 입력 `'CAL`(높음) | ○ — 숫자 꼴만 `input-patterns.ts:10-12` · QUICKCALC 는 결과를 알림에만 `tools/utility/inquiry-tools.ts:120-137` | 거리 · 좌표 칸에 `3.2*1.5` | 이미 있는 QC 계산(숫자 · 사칙 · 괄호만 거른 뒤 계산)을 함수로 빼서 `parsePointText` 숫자 자리에서 씀 | 작음 | 깨짐: `-` 음수 · `,` 좌표 가름과 겹침 → 식 안 쉼표 금지 | 높음 · 횡단(높이 × 비율 길이) | ① | +| 1-5 | CAL 스냅 함수 `(end+end)/2`(중간) | ○ | 식 안에서 물건 점 집기 | 식 해석 + 점 집기 단계 | 큼 | 중간 | 낮음(두 점 가운데로 대부분 대신) | ① 맨 뒤 | +| 1-6 | QuickCalc 값 바로 넣기(중간) | ◐ — 계산은 됨 · 입력에 못 씀 `inquiry-tools.ts:120-137` | 결과를 다음 입력으로 | QC 결과를 커서 곁 입력 글에 넣음 | 작음 | 낮음 | 중간 | ① | +| 1-7 | 좌표 필터 `.x` `.y`(높음) | ○ — 해석 없음 | 한 점 X + 다른 점 Y | 새 `inputController/input-point-modifiers.ts`(FROM · 두 점 가운데와 같은 자리) · `.X` → 다음 점 X 만 · 「(Y 필요)」 → 다음 점 Y · Enter 분기는 한 줄만 `input-controller.ts`(635줄) | 중간 | 깨짐: `.5` 소수 꼴과 겹침 → `.X` · `.Y` 글자만 | 높음 · 횡단(같은 계획고 · 같은 측점 X) | ① | +| 1-8 | FROM(높음) | ○ — 해석 없음 | 기준점에서 `@dx,dy` 띄운 점 | 같은 새 파일 · 다음 점 = 임시 기준 · 이어 오는 `@` · `@d` · 투명 `'ZOOM` | 331 조사에 있음 | — | — | — | +| 명령줄 | 자동 고침(AutoCorrect) | 같은 오타를 정한 횟수만큼 고치면 기억 → 다음부터 바른 명령 제안 · 목록 파일 autoCorrectUserDB.pgp 에 `BOLCK, *BLOCK` | 오타 다시 치기 줄임 | 중간 | [S25] [S26] | +| 명령줄 | 비슷한 말(동의어) | acadSynonymsGlobalDB.pgp 에 `SYMBOL, *BLOCK` → SYMBOL 치면 BLOCK 제안 · 관리 탭 [별칭 편집 › 동의어 목록 편집] | 다른 말로도 명령 찾기 — 우리 CAD 라면 한글 낱말 짝으로 응용 가능 | 중간 | [S25] | +| 명령줄 | 이름 있는 것 찾기(내용 찾기) | 명령줄에 블록 · 해치 · 레이어 · 글자 스타일 · 치수 스타일 이름, 또는 `layer` · `hatch` · `block` 뒤 Tab → 목록에서 고름 → 레이어 · 스타일 = 현재로 · 블록 = 삽입 · 해치 = 칠할 곳 클릭 | 레이어 바꾸기 · 블록 넣기를 이름 몇 글자로 | 높음 | [S27] [S26] | +| 명령줄 | 시스템 변수 함께 · 목록 지연 | 제안 목록에 시스템 변수 넣기 · 따로 보이기 · 목록 나오는 시간(ms) | — | 낮음 | [S26] | +| 명령줄 | 반복 Ctrl+J · Ctrl+M · 취소 Ctrl+[ · Ctrl+\ | 마지막 명령 다시 · 현재 명령 취소(Esc 와 같음) | — | 낮음 | [S30] | +| 명령줄 | F2 기록 · Ctrl+9 명령줄 | F2 = 지난 명령 · 프롬프트 펼쳐 봄 · Ctrl+9 = 명령줄 창 보이기/숨기기 | 지나간 입력 확인 | 낮음 | [S22] [S30] | +| 투명 명령 | 보충 | 331 조사에 있음 + 보충: 쓰는 중 프롬프트 앞 `>>` · 물건 고르기 · 만들기 · 끝내기 명령은 안 됨 · 대화상자로 바꾼 값은 원래 명령 끝난 뒤 적용 · 예 `'grid` | — | — | [S24] | +| 반복 | MULTIPLE | `MULTIPLE` Enter → 명령 이름 → 취소할 때까지 그 명령 되풀이(옛 문서 = Ctrl+C · 지금 판 취소 키 = Esc) · 명령 이름만 반복(값은 매번) | 한 번 끝나는 명령(CIRCLE · INSERT · FILLET)을 연달아 | 중간 | [S67] (옛 문서 · 제3자) [S52] | +| 접두사 | `-` 명령줄판 | `-LAYER` 처럼 앞에 `-` → 대화상자 대신 명령줄 질문 | 스크립트 · 매크로에서 값 차례로 | 중간 | [S28] | +| 접두사 | `_` · `.` · `'` · `+` | `_LINE` = 지역판에서 영문 원래 이름 · `.LINE` = 재정의 · UNDEFINE 무시하고 원래 명령 · `'` = 투명 · `+DSETTINGS` = 대화상자 특정 탭(문서 밖 기능) | 언어판에 상관없는 매크로 · 탭 바로 | 낮음 | [S52] [S68] | +| 접두사 | `~` 파일 대화상자 | FILEDIA 0 일 때 첫 프롬프트에 `~` → 파일 대화상자 | — | 낮음 | [S28] | +| 미리보기 | 명령 미리보기 | TRIM · EXTEND · FILLET · CHAMFER · OFFSET · BREAK · LENGTHEN · MATCHPROP 중 커서를 물건에 대면 결과 임시 표시 · 안 되면 금지 배지 · 기본 켜짐(COMMANDPREVIEW) | 자르기 · 늘리기 · 모깎기 · 간격띄우기 결과를 누르기 전에 확인 | 높음 | [S29] [S33] | +| 미리보기 | 고르기 · 속성 미리보기 | 커서 아래 물건 강조(SELECTIONPREVIEW · 명령 중/없을 때 따로) · 리본 · 속성 창 목록 위에 올리면 바뀐 모양(PROPERTYPREVIEW) | 무엇이 골라질지 · 색 · 레이어 바꾸기 전 확인 | 중간 | [S33] | + +## 4. 고르기 + +| 분류 | 이름 | 쓰는 법 | 무엇에 좋나 | 쓸모 | 출처 | +|---|---|---|---|---|---| +| 기본 | 창 · 걸침 | 빈 곳 두 점: 왼쪽 → 오른쪽 = 완전히 안에 든 것만 · 오른쪽 → 왼쪽 = 걸친 것까지 · `BOX` 도 같은 규칙 | 모든 편집의 기본 | 높음 | [S32] [S31] | +| 올가미 | 누르고 끌기 | 누른 채 끌면 자유 모양 · 오른쪽으로 = 창 · 왼쪽으로 = 걸침 · 끄는 중 Space = 창 → 걸침 → 울타리 돌림 · PICKAUTO 로 끔 | 굽은 노선 따라 필요한 것만 둘러 고르기 | 높음 | [S32] [S33] | +| 다각형 | `WP` · `CP` | 고르기 단계에서 `WP` / `CP` → 점 여러 개로 다각형(선이 서로 엇갈리면 안 됨) | 비스듬한 영역 고르기 | 중간 | [S31] [S32] | +| 울타리 | `F` | `F` → 꺾은 선 → 그 선에 닿은 것 모두 | 여러 횡단에 걸친 지반선만 줄로 고르기 | 높음 | [S31] [S32] | +| 모두 | `ALL` · Ctrl+A | 얼림 · 잠금 레이어 빼고 모두 | — | 중간 | [S31] [S30] | +| 앞 선택 | `P` · Ctrl+Shift+L | 바로 앞 선택 묶음 다시 | 옮긴 것을 이어서 회전 · 복사 | 높음 | [S31] [S30] | +| 마지막 | `L` | 마지막에 만든(보이는) 물건 | 방금 그린 것 바로 | 중간 | [S31] | +| 빼기 · 더하기 | `R` · `A` · Shift+클릭 | `R` = 빼기 방식 · `A` = 더하기 방식 · 아무 때나 Shift+클릭(Shift+걸침창) = 빼기 | 크게 고른 뒤 몇 개 빼기 | 높음 | [S31] [S32] | +| 그 밖 | Undo `U` · SIngle `SI` · AUto `AU` · Multiple `M` · Group `G` | `U` = 마지막 더한 것 취소 · `SI` = 한 번 고르면 끝 · `AU` = 물건 위면 그것 · 빈 곳이면 창 · `M` = 강조 없이 여러 점 · `G` = 그룹 이름으로 · 전체 목록은 `?` | — | 낮음 | [S31] | +| 방식 | 더하기 방식(PICKADD) | 2(기본) = 계속 더함 · Shift 로 뺌 · 0 = 새로 고르면 바꿔치기 · Shift 로 더함 | 사용자 습관에 맞춤 | 낮음 | [S33] [S32] | +| 방식 | 암시 창(PICKAUTO) | 빈 곳 클릭 = 창 시작 · 물건 위에서 눌러 끌기 허용 여부 · 빈 곳 창으로 선택 비우기 | — | 중간 | [S33] | +| 겹침 | 선택 순환 | Ctrl+W 로 켜면 겹친 곳에 배지 → 누르면 목록에서 고름 · 꺼져 있어도 Shift+Space 로 임시 | 겹친 지반선 · 계획선 · 치수선 중 하나 | 높음 | [S34] [S30] | +| 조건 | 빠른 고르기(QSELECT) | 범위(도면 전체/현재 선택) · 물건 종류 · 속성 · 연산자(= ≠ > < 와일드카드 · 모두) · 값 · 포함/제외 · 현재 선택에 더하기 | 「레이어 X 의 글자」 「색 Y 의 선」만 | 중간 | [S35] | +| 조건 | 비슷한 것 고르기(SELECTSIMILAR) | 견본 고른 뒤 명령 → 같은 레이어 · 같은 종류 모두 · Settings 로 맞출 속성(색 · 블록 이름 등) 더함 | 같은 블록 · 같은 레이어 선 한꺼번에 | 높음 | [S36] | +| 묶음 | 그룹 | 그룹 고르기 켜면 하나만 눌러도 묶음 전체 · Ctrl+Shift+A = 그룹 고르기 켬/끔 · Ctrl+H = PICKSTYLE · 그룹에 손잡이 하나 · 테두리 표시 · 연관 해치는 경계 같이 | 측점 표시(글자 + 선) 한 덩어리 | 중간 | [S33] [S31] [S30] | +| 차례 | 고른 뒤 명령(PICKFIRST) 대 명령 뒤 고르기 | 켜면 먼저 고르고 명령 · 꺼도 명령 뒤 고르기는 됨 · 빠른 속성 · 두 번 눌러 고치기는 켜져야 | 고른 채 바로 지우기 · 옮기기 | 높음 | [S33] [S46] | + +## 5. 그립 편집 + +| 분류 | 이름 | 쓰는 법 | 무엇에 좋나 | 쓸모 | 출처 | +|---|---|---|---|---|---| +| 기본 | 핫 그립 · 여러 핫 그립 | 그립 클릭 = 핫 그립(기본 늘리기) · Shift 누른 채 여러 그립 → 함께 늘림(그 사이 모양 그대로) | 선 끝 여러 개 동시 이동 | 높음 | [S37] | +| 모드 | Space/Enter 돌림 · 우클릭 | 핫 그립 뒤 Space(또는 Enter)마다 늘리기 → 옮기기 → 돌리기 → 크기 → 대칭 · 우클릭 메뉴로 바로 고르기 | 명령 안 치고 이동 · 회전 · 대칭 | 높음 | [S37] | +| 옵션 | `B` 기준점 · `C` 복사 | 모드 중 `B` = 기준점 다시 · `C` = 복사하며 여러 개(그립 끌 때까지) | 원본 두고 여러 개 | 높음 | [S37] [S38] | +| 복사 | Ctrl 간격 · 각 맞춤 | 첫 복사 뒤 Ctrl 누른 채 찍으면 같은 간격(돌리기 모드에선 같은 각) | 등간격 표시 · 블록 복사 | 중간 | [S38] [S37] | +| 다기능 | 올림 메뉴 · Ctrl 로 항목 돌림 | 그립 위에 멈추면 물건별 메뉴 · 그립 누른 뒤 Ctrl = 메뉴 항목 차례로 · 옵션 [동적 그립 메뉴] · [Ctrl 돌림 허용] | 그 자리에서 맞는 편집 | 높음 | [S37] [S33] | +| 폴리선 | 정점 추가 · 제거 · 끝 늘이기 · 호로 · 선으로 | 정점 그립 [Add Vertex] · [Remove Vertex] · 끝 그립 [Extend Vertex](2023 · 방향 안 뒤집힘) · 중간 그립 [Convert to Arc] · [Convert to Line] | 노선 · 지반선 폴리선 손질 | 높음 | [S39] [S57] | +| 치수 글자 | 글자 자리 메뉴 | 글자 그립 메뉴: 늘리기 · 치수선과 함께 · 글자만 · 지시선과 · 치수선 위 · 세로 가운데 · 글자 자리 되돌림 | 겹친 치수 글자 정리 | 높음 | [S40] | +| 치수 화살표 | 이어 치수 · 기준 치수 · 화살표 뒤집기 | 화살표 그립에서 그 끝부터 연속 · 기준선 치수 만들기 · 화살표 뒤집기(AutoCAD 2012~) | 횡단 폭 치수 이어 만들기 | 높음 | [S69] (제3자) | +| 치수 입력 | 그립 치수 칸(DYNDIVIS · DYNDIGRIP) | 그립으로 늘릴 때 보일 칸: 길이 변화 1칸 / 길이 변화 + 결과 길이 2칸 / 결과 치수 · 길이 변화 · 절대 각 · 각 변화 · 호 반지름 중 골라 동시 · Tab 으로 칸 옮겨 숫자 | 「늘린 길이」 또는 「최종 길이」를 숫자로 | 높음 | [S12] | +| 설정 | 그립 크기 · 블록 안 그립 · 팁 · 개수 제한 | GRIPSIZE · GRIPBLOCK · GRIPTIPS · GRIPOBJLIMIT(기본 100개 넘게 고르면 그립 숨김) | 많이 고를 때 느려짐 막기 | 낮음 | [S33] | + +## 6. 마우스 · 키보드 · 터치 + +| 분류 | 이름 | 쓰는 법 | 무엇에 좋나 | 쓸모 | 출처 | +|---|---|---|---|---|---| +| 마우스 | 휠 굴려 확대 · 휠 눌러 끌어 이동 · ZOOMWHEEL · ZOOMFACTOR · MBUTTONPAN | 331 조사에 있음 | — | — | — | +| 마우스 | 휠 두 번 = 전체 보기 | 휠 단추 두 번 클릭 = 범위 확대 | 길 잃었을 때 한 번에 전체 | 높음 | [S41] | +| 마우스 | Ctrl+휠 끌기 · 휠 클릭 | Ctrl + 휠 눌러 끌기 = 조이스틱 이동 · MBUTTONPAN 0 이면 휠 클릭 = 스냅 메뉴 | — | 낮음 | [S41] | +| 키보드 | Ctrl+화살표 미세 이동 | 고른 물건을 직교로 조금씩 · 스냅 꺼짐 = 화면 2픽셀 · 스냅 켜짐 = 스냅 간격 | 글자 · 기호 위치 눈대중 조정 | 중간 | [S42] | +| 기능 키 | F1~F12 | F1 도움말 · F2 명령 기록 · F4 3D 스냅 · F5 등각 평면 · F6 동적 UCS · F7 그리드 · F9 스냅 · F8 과 F10 은 서로 끔(F3 · F8 · F10 · F11 · F12 는 331 조사에 있음) | 켬/끔 한 손가락 | 중간 | [S22] | +| 키보드 | Ctrl 조합(편집) | Ctrl+C/X/V · Ctrl+Z/Y · Ctrl+A 모두 · Ctrl+Shift+L 앞 선택 · Ctrl+J/M 반복 · Ctrl+1 속성 창 · Ctrl+Shift+P 빠른 속성 · Ctrl+8 계산기 · Ctrl+3 도구 팔레트 · Ctrl+2 디자인센터 · Ctrl+0 화면 정리 · Ctrl+9 명령줄 | — | 중간 | [S30] | +| 키보드 | Ctrl 조합(제도 켬/끔) | Ctrl+B 스냅 · Ctrl+G 그리드 · Ctrl+L 직교 · Ctrl+U 극 추적 · Ctrl+F 상시 스냅 · Ctrl+W 선택 순환 · Ctrl+Shift+A 그룹 · Ctrl+H PICKSTYLE · Ctrl+I 좌표 표시 · Ctrl+Shift+I 구속 추론 | F 키 대신 | 낮음 | [S30] | +| 클립보드 | 기준점 복사 · 블록으로 붙여넣기 · 원래 좌표로 붙여넣기 · 기준점 잘라내기 | Ctrl+Shift+C = 기준점 찍고 복사(붙일 때 그 점으로) · Ctrl+Shift+V = 붙인 것을 블록 하나로 · PASTEORIG = 다른 도면 같은 좌표에 · CUTBASE(2023) · PASTESPEC = 형식 골라 붙이기 | 다른 도면 · 다른 횡단으로 정확히 옮기기 | 높음 | [S44] [S30] [S57] | +| 끌어 놓기 | 왼쪽 끌기 · Ctrl 끌기 · 오른쪽 끌기 메뉴 | 고른 것을 왼쪽으로 끌면 옮김(도면 사이 · 다른 앱도) · Ctrl = 복사 · 오른쪽으로 끌면 놓을 때 [여기로 옮기기 · 여기로 복사 · 블록으로 붙여넣기 · 취소] · 스냅 무시 | 대충 위치 옮기기 | 낮음 | [S43] [S42] | +| 고치기 | 두 번 눌러 고치기 | 물건 두 번 클릭 → 종류별 편집기 또는 빠른 속성 · CUI [Double Click Actions] 에서 물건 종류(DXF 이름)마다 명령 하나 지정 | 글자 · 치수 · 블록 속성 바로 고치기 | 높음 | [S45] | +| 고치기 | 빠른 속성 | 고르면 커서 옆 작은 속성 창(QPMODE · 모든 물건/지정 종류) · 커서 따라/고정 자리 · 접힘 행 수 · PICKFIRST 켜져야 · Ctrl+Shift+P | 레이어 · 색 · 글자 높이 그 자리에서 | 높음 | [S46] [S30] | +| 고치기 | 속성 맞추기(MATCHPROP) | 원본 하나 → 대상 여러 개(창 고르기도) · `S` 설정으로 옮길 속성(레이어 · 색 · 선 종류 · 축척 + 같은 종류끼리 치수 스타일 · 글자 스타일) · 설정은 다음에도 유지 · 미리보기 | 레이어 · 스타일 한 번에 통일 | 높음 | [S47] | +| 고치기 | 속성 창 Ctrl+1 | 속성 창 켬/끔 · 숫자 칸에서 계산기 | — | 중간 | [S30] [S04] | +| 터치 | 데스크톱 터치(2022) | 명령 없을 때 한 · 두 손가락 끌기 = 이동 · 두 손가락 오므리기 = 확대 · 탭 = 고르기 · 한 손가락 끌기 = 창 · 걸침 · 한 손가락 두 번 탭 = 명령 끝 · 선택 해제 · 점 단계 탭 = 점 · 명령 중 두 손가락 이동 · 확대 · 손가락을 물건 위로 끌어 원하는 스냅이 보이면 뗌 | 태블릿 PC 현장 수정 | 낮음 | [S48] | +| 터치 | 모바일 앱 돋보기 · 숫자판 | 스냅 점 근처를 누른 채 있으면 손가락 위에 작은 확대창(줌 루페) → 손가락을 살짝 흔들어 원하는 점에 붙인 뒤 뗌 · 그린 뒤 길이 · 각도 수치를 눌러 숫자판으로 정확한 값 · 고른 뒤에만 편집 도구가 나옴 | 손가락에 가린 점 정확히 찍기 | 낮음 | [S49] (AU 강의 자료) | +| 터치 | 웹 앱 | 331 조사에 있음(명령줄 · 선택지 단추 · 약한 근거) · 웹 앱 전용 터치 도움말은 이번에도 못 엶 | — | — | — | + +## 7. 묶음 · 자동화 + +| 분류 | 이름 | 쓰는 법 | 무엇에 좋나 | 쓸모 | 출처 | +|---|---|---|---|---|---| +| 스크립트 | `.scr` · SCRIPT | 글자 파일 한 줄 = 명령줄 입력 · 빈칸 · 줄바꿈 = Enter(빈칸 든 글은 큰따옴표) · `-LAYER` 같은 명령줄판 · `;` 주석 · 끝에 빈 줄 · SCRIPT 실행 · 도면 창에 파일 끌어 놓기 · 시작 단추 `/b` · RSCRIPT 반복 · DELAY 멈춤 · Backspace 로 멈추고 RESUME | 같은 설정 · 그리기를 여러 도면에 | 중간 | [S50] | +| 동작 기록기 | ACTRECORD · ACTSTOP | 명령 · 입력값 녹화 → 이름 붙여 저장(.actm · ACTRECPATH) → 다시 실행 · 사용자 입력 요청(ACTUSERINPUT) · 메시지(ACTUSERMESSAGE) 끼워 넣기 · 대화상자 안 변경은 기록 안 됨 | 코딩 없이 반복 작업 묶기 | 낮음 | [S51] | +| 매크로 | 단추 · 메뉴 명령 문자열 | `^C^C` = 진행 중 명령 취소 · `;` = Enter · 빈칸 = Space · `\` = 사용자 입력 기다림 · `_` = 영문 이름 · `.` = 원래 명령 · `'` = 투명 · `*` = 반복 · `^]`(2023) = 고른 것 없으면 고르기 묻기 · 예 `^C^C_.text \.4 0 DRAFT Inc;;;Main St.;;;City, State;` | 자주 쓰는 순서를 단추 하나로 | 중간 | [S52] | +| 매크로 제안 | My Insights · Macro Advisor(2023) | 자주 쓰는 명령 순서(예 COPY → ROTATE)를 보고 상태 막대 알림 → 명령 매크로 창 [Insights] 에서 [Try] → 저장 · 문자열은 CUI 에서 고침 | 내 습관에서 매크로 자동 제안 | 낮음 | [S56] [S57] | +| AutoLISP | (참고) | `defun` + `c:이름` 으로 새 명령 · 내장 명령 실행 · 입력 장치 입력 받기 · 물건 읽고 고침 · 대화상자(DCL) | 맞춤 명령 개발 | 낮음 | [S53] | +| 도구 팔레트 | Ctrl+3 | 새 팔레트 · 디자인센터에서 도면 · 블록 · 해치 끌어 넣기 · 구분선 · 글 · 도구 속성으로 레이어 · 색 · 선 종류 강제 + 블록 축척 · 회전 · 해치 각 미리 정함 · 없는 레이어는 넣을 때 자동 생성 | 표준 블록(배수관 · 옹벽 등)을 정해진 레이어 · 축척으로 한 번에 | 중간 | [S54] [S55] [S30] | +| 사용자 지정 | 별칭 편집 · 단축키(CUI) | 331 조사에 있음 | — | — | — | + +## 8. 2023~2027 새 입력 보조 + +| 분류 | 이름 | 쓰는 법 | 무엇에 좋나 | 쓸모 | 출처 | +|---|---|---|---|---|---| +| 2023 | 폴리선 끝 정점 늘이기 그립 | 끝 그립 [Extend Vertex] — 폴리선 방향 안 뒤집고 끝에 정점 더함 | 노선 · 지반선 끝 이어 그리기 | 높음 | [S57] [S39] | +| 2023 | CUTBASE | 기준점 찍고 잘라내기(클립보드) | 다른 도면으로 정확히 옮기기 | 중간 | [S57] | +| 2023 | MLEADER 기존 글 고르기 | 새 지시선에 기존 MTEXT 를 골라 붙임 | 이미 쓴 설명 글에 지시선 | 중간 | [S57] | +| 2023 | Markup Import · Markup Assist | PDF · PNG · JPG 수정 지시를 트레이스로 겹침(MARKUPIMPORT) → AI 가 글자 · 지시선 · 수정 구름을 찾아 MTEXT · MLEADER · REVCLOUD 로 넣기(MARKUPASSIST) · 2025 Autodesk Docs 의 PDF 마크업 연동 · 2026 줄 긋기 · 경계 표시도 찾음 | 검토 의견을 다시 타이핑 않고 넣기 | 낮음 | [S62] [S57] [S76] [S64] | +| 2023 | Count · Trace | 영역 안 블록 · 물건 개수 세기 · 도면을 안 바꾸고 위에 검토용 겹 그리기 | — | 낮음 | [S57] | +| 2023.1~ | Autodesk Assistant | AI 도우미(2023.1 영 · 독 · 일 → 2024.1 AutoCAD · LT) · 자연어로 명령 · 도움 찾기 · 2027 MCP 기반 기술 미리보기(그리는 내용 문맥 이해) | 명령 이름 몰라도 질문으로 | 낮음 | [S57] [S58] [S65] [S66] | +| 2024 | Smart Blocks: Placement | Blocks 팔레트에서 넣을 때 같은 블록이 앞서 놓인 자리와 비슷한 곳 제안(위치 · 회전 · 축척 맞춤) · 클릭 = 받기 · Ctrl = 다른 제안 · Shift+W 또는 Shift+[ = 잠시 끔 · `AUTOPLACEMENT` 1 = 켬 | 반복 블록을 비슷한 자리에 빠르게 | 중간 | [S60] [S61] [S58] | +| 2024 | Smart Blocks: Replacement | BREPLACE → 고른 블록 참조를 비슷한 블록 제안 팔레트에서 골라 바꿈 · 축척 · 회전 · 속성값 유지 · `-BREPLACE` | 블록 교체 | 중간 | [S59] [S58] | +| 2024 | Activity Insights · COPYFROMTRACE | 도면에 누가 무엇을 했는지 이력 창 · 트레이스에서 도면으로 복사 | — | 낮음 | [S58] | +| 2025 | Smart Blocks: Search and Convert | BCONVERT → 모양 고르면 도면 안 같은 모양 모두 강조 → 전부/골라서 새 블록 · 기존 블록으로 바꿈 | 낱선으로 그린 반복 기호를 블록화 | 중간 | [S76] [S63] | +| 2025~2026 | Smart Blocks: Object Detection → Detect and Convert | AI 가 블록으로 바꿀 만한 반복 물건을 스스로 찾아 팔레트에 묶음 → 검토 막대로 확인 · 2025 기술 미리보기(평면도 건축 물건에 잘 맞음) · 2026 감지 개선 | — | 낮음 | [S76] [S63] [S64] | +| 2025 | 해치 경계 없이 · 따라 그리기 | 원 · 사각형 · 점 찍은 폴리선으로 영역 주면 경계 물건 없이 해치 · 경계 따라 그리며 폭 지정 | 띠 모양 해치(법면 표시 등) | 중간 | [S76] [S63] | +| 2026 | Centerline · Centermark 레이어 지정 | 중심선 · 중심 표시 명령 안에서 레이어 지정 | — | 낮음 | [S64] | +| 2027 | Geometry Cleanup | 고른 물건의 틈 · 넘침 · 모자람 · 각도 어긋남을 찾아 고침 제안 | 끊긴 지반선 · 노선 정리 | 중간 | [S65] [S66] | +| 2027 | Checkout | 여러 사람이 물건 · 영역을 예약해 같은 DWG 동시 편집 → 트레이스로 제출 | — | 낮음 | [S66] | +| 찾지 못함 | 명령줄 · 좌표 입력 자체 개선 | 연 What's New 쪽(2023 · 2024)에 자동 완성 · 좌표 입력 자체의 새 기능 없음 · 2025~2027 은 업계 소개 글 기준 | — | — | [S57] [S58] | + +## 9. 호환 CAD 가 더한 입력 보조(참고) + +| 분류 | 이름 | 쓰는 법 | 무엇에 좋나 | 쓸모 | 출처 | +|---|---|---|---|---|---| +| BricsCAD | Quad 커서 메뉴 | 물건 위에 커서 → 그 종류에 최근 쓴 명령 단추 · 펼치면 최근 6개 + 탭 묶음 · 겹친 물건은 Tab 으로 돌림 · Rollover Tips = 커서 아래 물건 속성 · 길게 우클릭 = 그리기 명령 Quad · F12 켬/끔 | 명령줄 없이 커서 옆에서 바로 | 중간 | [S70] (331 이름만) | +| BricsCAD | Manipulator | 길게 눌러 고르면 손잡이 위젯 → 축 = 한 방향 이동 · 호 = 회전 · 평면 사각 = 평면 이동 · 화살촉 = 대칭 · Ctrl = 복사 | 그립 모드 돌림 없이 바로 | 낮음 | [S71] | +| BricsCAD | Hotkey Assistant | 일부 명령 중 화면 아래 가운데에 쓸 수 있는 단축키 안내 · HOTKEYASSISTANT 1(기본) | 숨은 키 배우기 — 우리 CAD 안내줄에 응용 가능 | 중간 | [S72] | +| BricsCAD | 계산기 패널 스냅 함수 | `(end+mid)/2` · `cur` · `dist(cur,end)` 결과를 명령줄로 붙임 — 오토캐드 CAL 과 같은 꼴 | — | 낮음 | [S73] | +| ZWCAD | Smart Mouse(마우스 제스처) | 우클릭 끌기 방향(↑ · ← · ↓→)이나 글자 모양(C · L · 8 등)으로 명령 실행 · SMARTMOUSE 켬 · SMARTMOUSECONFIG 에서 제스처마다 명령 지정 · 비슷한 모양 동시 사용 비권장 | 키보드 없이 자주 쓰는 명령 | 중간 | [S74] | +| ZWCAD | Smart Voice · Smart Select | 물건 · 점 · 영역에 음성 메모(1~30초 · VOICEMAN 으로 글로 바꿈 · 출력 안 됨) · SMARTSEL = 종류 · 색 · 레이어 · 선 종류로 걸러 고르기(QSELECT 비슷) | 현장 의견 남기기 | 낮음 | [S74] | +| GstarCAD | 음성 주석(2025) | Voice Manager 로 도면에 음성 메모 녹음 · 재생 · 관리 — 그 밖 2025 입력 기능(올가미 · WP/CP · DIM 미리보기 · 구속)은 오토캐드에 이미 있음 | — | 낮음 | [S75] | + +## 확인 못함 · 뺀 것 + +- `*` 앞붙이 세계 좌표(WCS) 입력 — 연 공식 쪽(좌표 입력 개요 · 동적 입력)에 없음 → 뺌. +- `<<` · `<<<` 각도 덮어쓰기 — 연 쪽에서 확인 안 됨 → 뺌. +- AutoCAD 웹 앱 전용 터치 · 입력 도움말 · Autodesk 블로그(autodesk.com/blogs) · 기능 소개 쪽 — 접속 막힘(403) → 웹 앱은 331 의 약한 근거만. +- 2027.1 새 Lengthen 옵션 · 속성 편집기 Ctrl+A 등 — 원문(Autodesk 블로그) 403 → 뺌. +- 2023~2027 「명령줄 자동 완성 개선」 — 연 What's New 쪽에 해당 항목 없음. + +## 출처 목록 + +- [S01] 좌표 필터 개요(About Combining Coordinate Values) — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-A3F6F0F9-A085-4176-972C-8D11068E7355.htm +- [S02] FROM (Command Modifier) — https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-0EB8FEA1-4C14-40C6-8254-DB6693DB481C.htm +- [S03] TRACKING (Command Modifier) — https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-C689F165-7C9E-4F09-96C3-BCA91DC4297C.htm +- [S04] About Accessing the QuickCalc Calculator — https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-577F070C-7E5B-4A03-90BD-DDFB3B3C6AAB.htm +- [S05] Use Snap Modes in Arithmetic Expressions (CAL) — https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-A75D5C9A-7F72-4404-8BDF-676490B17FBE.htm +- [S06] Syntax and Functions Reference (CAL · QUICKCALC) — https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-LT/files/GUID-B93BD7BF-B69D-40D4-968E-1EB60F90D93E.htm +- [S07] About Unit Format Conventions — https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-FD3A36E1-FC7F-43CB-B7B5-1806FAAC12B2.htm +- [S08] -UNITS (Command) — https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-D396FBFE-6171-4A89-9E68-6CB082EBE0E1.htm +- [S09] ID (Command) — https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-LT-MAC/files/GUID-F258AC00-5E9F-4B6B-A670-33F7708E3FB6.htm +- [S10] Dynamic Input Tab (Drafting Settings) — https://help.autodesk.com/cloudhelp/2021/ENU/AutoCAD-LT/files/GUID-38EC86CF-D96A-455F-A5DE-2CDA23C28FC4.htm +- [S11] Coordinate Entry with Dynamic Input (Did You Know) — https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DidYouKnow/files/GUID-683349C0-E5C2-4E16-8846-5523E71172A9.htm +- [S12] Dimension Input Settings Dialog Box — https://help.autodesk.com/cloudhelp/2021/ENU/AutoCAD-LT/files/GUID-65DF3E4D-ED53-4EBB-A6C4-2963595CE896.htm +- [S13] About Coordinate Entry — https://help.autodesk.com/cloudhelp/2027/ENU/AutoCAD-LT/files/GUID-0A0135DB-3216-482B-81DD-74E6DB8CA3E3.htm +- [S14] To Specify a Precise Point Location — https://help.autodesk.com/cloudhelp/2027/ENU/AutoCAD-LT/files/GUID-392167BC-8032-44D9-B4A9-DF4AC00DF5C4.htm +- [S15] OSNAP (Command) — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-CF5780AD-D1AB-4526-9608-83D7952749E7.htm +- [S16] Object Snaps (Command Modifier) — https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-76B81C1A-373E-4BCD-975A-789FB36C89FE.htm +- [S17] Object Snap Tracking (Did You Know) — https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-DidYouKnow/files/GUID-6FB309A8-2696-4383-8277-950E9E2756A8.htm +- [S18] About Tracking Points Automatically Using Object Snaps — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-665DC37F-8C3E-414A-9369-72A13C0BE07A.htm +- [S19] Drafting Tab (Options) — https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-LT/files/GUID-33A0F8E8-9981-4111-AD73-128B20D7975C.htm +- [S20] Polar Tracking Tab (Drafting Settings) — https://help.autodesk.com/cloudhelp/2021/ENU/AutoCAD-LT/files/GUID-D7CBB7B0-9140-4C53-88EF-08EAA09FA9D7.htm +- [S21] Snap and Grid Tab (Drafting Settings) — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-66D637C9-6C47-420C-ADD1-83B64C73217A.htm +- [S22] Function Key Reference — https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-ACAA0279-047D-458E-889F-60BBFDD40489.htm +- [S23] About Inferring Geometric Constraints — https://help.autodesk.com/cloudhelp/2019/ENU/AutoCAD-LT/files/GUID-3CEDC74D-742F-465D-BD83-DD2B4E536CF7.htm +- [S24] About Entering Commands in the Command Window — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-BB0C3E79-66AF-4557-9140-D31B4CF3C9CF.htm +- [S25] To Work With the Command Line Suggestion List — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-74478DCE-92D4-49B1-93E9-DCF09D57FA0D.htm +- [S26] Input Search Options Dialog Box — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-3179393F-958F-42EA-8A92-E9975A1D160B.htm +- [S27] About Searching for Named Objects — https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-C5D1D7EC-8BEC-4760-9993-FD2C809456BB.htm +- [S28] About Switching Between Dialog Boxes and the Command Line — https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-A02E1B47-AED7-4308-A6A3-94368852C2D8.htm +- [S29] Command Preview (Did You Know) — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT-DidYouKnow/files/GUID-2EBF913D-2FDC-4D66-A397-068899D492DC.htm +- [S30] 기본 단축키 표(Customization) — https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-Customization/files/GUID-B6A9C126-230D-47F5-8973-464BC80E3736.htm +- [S31] SELECT (Command) — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-0DD5DA73-9DC5-4424-8FED-7BBE3BE52A4D.htm +- [S32] Window, Fence, Lasso, and More (Did You Know) — https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-DidYouKnow/files/GUID-D0D5C0C3-F092-448A-8E81-D38F27094639.htm +- [S33] Selection Tab (Options) — https://help.autodesk.com/cloudhelp/2023/ENU/AutoCAD-LT/files/GUID-DD2A36B6-1196-4205-9A07-32056292F0B5.htm +- [S34] Work with Stacked Objects (Did You Know) — https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-DidYouKnow/files/GUID-D6B80776-EB76-4B70-BAC3-1E3778B910DA.htm +- [S35] Quick Select Dialog Box — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-84E1116E-DEAE-4A0B-9364-F61DACF5C300.htm +- [S36] SELECTSIMILAR (Command) — https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-C160A9C9-1287-4111-8D27-05AFBAA7C29F.htm +- [S37] Grips (Did You Know) — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT-DidYouKnow/files/GUID-BBEA1F71-EB16-4D49-80D9-970A6909F508.htm +- [S38] About Making Multiple Copies With Grips — https://help.autodesk.com/cloudhelp/2021/ENU/AutoCAD-LT/files/GUID-BCD1FCDB-67A2-45B0-9679-063505733210.htm +- [S39] To Modify Polylines — https://help.autodesk.com/cloudhelp/2027/ENU/AutoCAD-LT/files/GUID-6242DF04-BD00-4A5D-891C-B131184D0D36.htm +- [S40] About Modifying Dimension Text — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-C48417B4-5D9E-455D-96B2-C33A1AAAE690.htm +- [S41] About Pointing Devices — https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-B1CDEE41-F8BF-48A4-A895-273DBB295378.htm +- [S42] About Moving Objects — https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-11AB8C73-D35A-45DF-A7AE-4D03313DDACA.htm +- [S43] About Copying Objects — https://help.autodesk.com/cloudhelp/2021/ENU/AutoCAD-LT/files/GUID-0BFF5C5E-B1B4-4996-9AA4-57396FD6F6F2.htm +- [S44] Accessing Data from the Clipboard (Did You Know) — https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-DidYouKnow/files/GUID-39A97950-6A89-4A01-A713-255600556C76.htm +- [S45] To Customize Double-Click Actions — https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-1F2689C9-9125-4596-AD52-73FC12716FFC.htm +- [S46] Quick Properties Tab (Drafting Settings) — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-5D1EA6CC-2F6A-42D2-B49F-6F1B2634E32D.htm +- [S47] Matching Properties (Did You Know) — https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DidYouKnow/files/GUID-B40824DE-CE30-4664-8958-82DB21205AC7.htm +- [S48] Touch Enhancements (What's New 2022) — https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-WhatsNew/files/GUID-D5F8C831-6152-485C-8AAD-970D3ADF47BE.htm +- [S49] AU 강의 자료 AS323988 「Road Dogs: Mobile Apps for Designers and Drafters」(Jim LaPier · PDF) — https://static.au-uw2-stg.autodesk.com/Class_Handout_AS323988_Jim_LaPier.pdf +- [S50] Scripts (Did You Know) — https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DidYouKnow/files/GUID-BE44AE86-7638-48C9-BE5B-C1DF8E4C8808.htm +- [S51] Action Recorder (Customization) — https://help.autodesk.com/cloudhelp/2015/ENU/AutoCAD-Customization/files/GUID-FEAD3614-CD33-4B60-BC00-4CBC98D8CBCB.htm +- [S52] About Special Control Characters in Command Macros — https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-Customization/files/GUID-DDDB6E26-75E1-4643-8C6A-BEAEBA83A424.htm +- [S53] AutoLISP Developer's Guide — https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-LT-AutoLISP/files/GUID-265AADB3-FB89-4D34-AA9D-6ADF70FF7D4B.htm +- [S54] To Work With Creating and Modifying Tool Palettes — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-7946B6B8-654A-4708-BEBE-F4142E555DF0.htm +- [S55] About Setting Tool Properties — https://help.autodesk.com/cloudhelp/2021/ENU/AutoCAD-LT/files/GUID-021B36D2-5009-43FE-A78C-E4BE8EED0960.htm +- [S56] About Macro Insights and Command Macros — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-Core/files/GUID-F14ADDEB-A704-4793-BD39-0DA970165A2E.htm +- [S57] What's New in AutoCAD 2023(2026 도움말 안) — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-WhatsNew/files/GUID-C6EC4B39-2588-43A2-99EB-A97EFDCEE8C6.htm +- [S58] What's New in AutoCAD 2024(2025 도움말 안) — https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-WhatsNew/files/GUID-3890D5F7-04CB-4F3B-97FF-7A9577D41852.htm +- [S59] Smart Blocks: Replacement (What's New) — https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-WhatsNew/files/GUID-4F8E11D6-917A-43F3-B596-C8F296D32519.htm +- [S60] About Block Insertion With Placement Suggestions — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-BB2E7F62-C6D1-4D96-B78A-9810B9749CBC.htm +- [S61] To Insert a Block Using Automatic Placement(Mac 판 · Ctrl 대신 Cmd) — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT-MAC/files/GUID-F873E4E7-FD92-442F-A9E9-E0484505D311.htm +- [S62] Markup Import and Markup Assist (What's New) — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-WhatsNew/files/GUID-64CFD65E-ABD0-49A8-9218-D4E2D22BC070.htm +- [S63] Hagerman 「What's New in AutoCAD 2025」(제3자) — https://blog.hagerman.com/discover-whats-new-in-autocad-2025 +- [S64] Hagerman 「What's New in AutoCAD 2026」(제3자) — https://blog.hagerman.com/whats-new-in-autocad-2026 +- [S65] Hagerman 「What's New in AutoCAD 2027」(제3자) — https://blog.hagerman.com/draw-smarter-deliver-faster-whats-new-in-autocad-2027 +- [S66] Architosh 「Autodesk 2027 adds strong new features」(제3자) — https://architosh.com/2026/04/autodesk-2027-adds-strong-new-features/ +- [S67] Fourmilab · John Walker 옛 AutoCAD 문서 MULTIPLE(제3자) — https://fourmilab.ch/autofile/www/subsectionstar2_52_0_4.html +- [S68] draftsperson.net 명령 접두사(제3자) — https://blog.draftsperson.net/what-prefixes-are-available-for-commands-autocad-tip +- [S69] Dummies 「Tips for Editing Dimensions in AutoCAD」(제3자) — https://www.dummies.com/article/technology/software/design-software/autocad/tips-editing-dimensions-autocad-222017 +- [S70] BricsCAD The Quad cursor menu — https://help.bricsys.com/en-us/document/bricscad/getting-started-with-bricscad/exploring-the-interface/the-quad-cursor-menu +- [S71] BricsCAD Using the Manipulator — https://help.bricsys.com/en-us/document/bricscad/getting-started-with-bricscad/working-in-bricscad/using-the-manipulator +- [S72] BricsCAD HOTKEYASSISTANT — https://help.bricsys.com/en-us/document/system-variable-reference/h/hotkeyassistant-system-variable +- [S73] BricsCAD Calculator panel — https://help.bricsys.com/en-us/document/panels/calculator-panel +- [S74] ZWCAD Smart Features — https://www.zwsoft.com/user-guide/zwcad-smart-features +- [S75] Digital Engineering 24/7 「GstarCAD 2025 is released」(제3자) — https://www.digitalengineering247.com/article/gstarcad-2025-is-released +- [S76] Digital Engineering 24/7 「What's New in AutoCAD 2025」(제3자) — https://www.digitalengineering247.com/article/whats-new-in-autocad-2025/design +- [S77] CADTutor 포럼 M2P · MTP(제3자) — https://www.cadtutor.net/forum/topic/51390-object-snap-mid-between-2-points-m2p-mtp + +[S01]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-A3F6F0F9-A085-4176-972C-8D11068E7355.htm +[S02]: https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-0EB8FEA1-4C14-40C6-8254-DB6693DB481C.htm +[S03]: https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-C689F165-7C9E-4F09-96C3-BCA91DC4297C.htm +[S04]: https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-577F070C-7E5B-4A03-90BD-DDFB3B3C6AAB.htm +[S05]: https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-A75D5C9A-7F72-4404-8BDF-676490B17FBE.htm +[S06]: https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-LT/files/GUID-B93BD7BF-B69D-40D4-968E-1EB60F90D93E.htm +[S07]: https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-FD3A36E1-FC7F-43CB-B7B5-1806FAAC12B2.htm +[S08]: https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-D396FBFE-6171-4A89-9E68-6CB082EBE0E1.htm +[S09]: https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-LT-MAC/files/GUID-F258AC00-5E9F-4B6B-A670-33F7708E3FB6.htm +[S10]: https://help.autodesk.com/cloudhelp/2021/ENU/AutoCAD-LT/files/GUID-38EC86CF-D96A-455F-A5DE-2CDA23C28FC4.htm +[S11]: https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DidYouKnow/files/GUID-683349C0-E5C2-4E16-8846-5523E71172A9.htm +[S12]: https://help.autodesk.com/cloudhelp/2021/ENU/AutoCAD-LT/files/GUID-65DF3E4D-ED53-4EBB-A6C4-2963595CE896.htm +[S13]: https://help.autodesk.com/cloudhelp/2027/ENU/AutoCAD-LT/files/GUID-0A0135DB-3216-482B-81DD-74E6DB8CA3E3.htm +[S14]: https://help.autodesk.com/cloudhelp/2027/ENU/AutoCAD-LT/files/GUID-392167BC-8032-44D9-B4A9-DF4AC00DF5C4.htm +[S15]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-CF5780AD-D1AB-4526-9608-83D7952749E7.htm +[S16]: https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-76B81C1A-373E-4BCD-975A-789FB36C89FE.htm +[S17]: https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-DidYouKnow/files/GUID-6FB309A8-2696-4383-8277-950E9E2756A8.htm +[S18]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-665DC37F-8C3E-414A-9369-72A13C0BE07A.htm +[S19]: https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-LT/files/GUID-33A0F8E8-9981-4111-AD73-128B20D7975C.htm +[S20]: https://help.autodesk.com/cloudhelp/2021/ENU/AutoCAD-LT/files/GUID-D7CBB7B0-9140-4C53-88EF-08EAA09FA9D7.htm +[S21]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-66D637C9-6C47-420C-ADD1-83B64C73217A.htm +[S22]: https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-ACAA0279-047D-458E-889F-60BBFDD40489.htm +[S23]: https://help.autodesk.com/cloudhelp/2019/ENU/AutoCAD-LT/files/GUID-3CEDC74D-742F-465D-BD83-DD2B4E536CF7.htm +[S24]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-BB0C3E79-66AF-4557-9140-D31B4CF3C9CF.htm +[S25]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-74478DCE-92D4-49B1-93E9-DCF09D57FA0D.htm +[S26]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-3179393F-958F-42EA-8A92-E9975A1D160B.htm +[S27]: https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-C5D1D7EC-8BEC-4760-9993-FD2C809456BB.htm +[S28]: https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-A02E1B47-AED7-4308-A6A3-94368852C2D8.htm +[S29]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT-DidYouKnow/files/GUID-2EBF913D-2FDC-4D66-A397-068899D492DC.htm +[S30]: https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-Customization/files/GUID-B6A9C126-230D-47F5-8973-464BC80E3736.htm +[S31]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-0DD5DA73-9DC5-4424-8FED-7BBE3BE52A4D.htm +[S32]: https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-DidYouKnow/files/GUID-D0D5C0C3-F092-448A-8E81-D38F27094639.htm +[S33]: https://help.autodesk.com/cloudhelp/2023/ENU/AutoCAD-LT/files/GUID-DD2A36B6-1196-4205-9A07-32056292F0B5.htm +[S34]: https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-DidYouKnow/files/GUID-D6B80776-EB76-4B70-BAC3-1E3778B910DA.htm +[S35]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-84E1116E-DEAE-4A0B-9364-F61DACF5C300.htm +[S36]: https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-C160A9C9-1287-4111-8D27-05AFBAA7C29F.htm +[S37]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT-DidYouKnow/files/GUID-BBEA1F71-EB16-4D49-80D9-970A6909F508.htm +[S38]: https://help.autodesk.com/cloudhelp/2021/ENU/AutoCAD-LT/files/GUID-BCD1FCDB-67A2-45B0-9679-063505733210.htm +[S39]: https://help.autodesk.com/cloudhelp/2027/ENU/AutoCAD-LT/files/GUID-6242DF04-BD00-4A5D-891C-B131184D0D36.htm +[S40]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-C48417B4-5D9E-455D-96B2-C33A1AAAE690.htm +[S41]: https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-B1CDEE41-F8BF-48A4-A895-273DBB295378.htm +[S42]: https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-11AB8C73-D35A-45DF-A7AE-4D03313DDACA.htm +[S43]: https://help.autodesk.com/cloudhelp/2021/ENU/AutoCAD-LT/files/GUID-0BFF5C5E-B1B4-4996-9AA4-57396FD6F6F2.htm +[S44]: https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-DidYouKnow/files/GUID-39A97950-6A89-4A01-A713-255600556C76.htm +[S45]: https://help.autodesk.com/cloudhelp/2018/ENU/AutoCAD-LT/files/GUID-1F2689C9-9125-4596-AD52-73FC12716FFC.htm +[S46]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-5D1EA6CC-2F6A-42D2-B49F-6F1B2634E32D.htm +[S47]: https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DidYouKnow/files/GUID-B40824DE-CE30-4664-8958-82DB21205AC7.htm +[S48]: https://help.autodesk.com/cloudhelp/2022/ENU/AutoCAD-WhatsNew/files/GUID-D5F8C831-6152-485C-8AAD-970D3ADF47BE.htm +[S49]: https://static.au-uw2-stg.autodesk.com/Class_Handout_AS323988_Jim_LaPier.pdf +[S50]: https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DidYouKnow/files/GUID-BE44AE86-7638-48C9-BE5B-C1DF8E4C8808.htm +[S51]: https://help.autodesk.com/cloudhelp/2015/ENU/AutoCAD-Customization/files/GUID-FEAD3614-CD33-4B60-BC00-4CBC98D8CBCB.htm +[S52]: https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-Customization/files/GUID-DDDB6E26-75E1-4643-8C6A-BEAEBA83A424.htm +[S53]: https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-LT-AutoLISP/files/GUID-265AADB3-FB89-4D34-AA9D-6ADF70FF7D4B.htm +[S54]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-7946B6B8-654A-4708-BEBE-F4142E555DF0.htm +[S55]: https://help.autodesk.com/cloudhelp/2021/ENU/AutoCAD-LT/files/GUID-021B36D2-5009-43FE-A78C-E4BE8EED0960.htm +[S56]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-Core/files/GUID-F14ADDEB-A704-4793-BD39-0DA970165A2E.htm +[S57]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-WhatsNew/files/GUID-C6EC4B39-2588-43A2-99EB-A97EFDCEE8C6.htm +[S58]: https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-WhatsNew/files/GUID-3890D5F7-04CB-4F3B-97FF-7A9577D41852.htm +[S59]: https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-WhatsNew/files/GUID-4F8E11D6-917A-43F3-B596-C8F296D32519.htm +[S60]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT/files/GUID-BB2E7F62-C6D1-4D96-B78A-9810B9749CBC.htm +[S61]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-LT-MAC/files/GUID-F873E4E7-FD92-442F-A9E9-E0484505D311.htm +[S62]: https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-WhatsNew/files/GUID-64CFD65E-ABD0-49A8-9218-D4E2D22BC070.htm +[S63]: https://blog.hagerman.com/discover-whats-new-in-autocad-2025 +[S64]: https://blog.hagerman.com/whats-new-in-autocad-2026 +[S65]: https://blog.hagerman.com/draw-smarter-deliver-faster-whats-new-in-autocad-2027 +[S66]: https://architosh.com/2026/04/autodesk-2027-adds-strong-new-features/ +[S67]: https://fourmilab.ch/autofile/www/subsectionstar2_52_0_4.html +[S68]: https://blog.draftsperson.net/what-prefixes-are-available-for-commands-autocad-tip +[S69]: https://www.dummies.com/article/technology/software/design-software/autocad/tips-editing-dimensions-autocad-222017 +[S70]: https://help.bricsys.com/en-us/document/bricscad/getting-started-with-bricscad/exploring-the-interface/the-quad-cursor-menu +[S71]: https://help.bricsys.com/en-us/document/bricscad/getting-started-with-bricscad/working-in-bricscad/using-the-manipulator +[S72]: https://help.bricsys.com/en-us/document/system-variable-reference/h/hotkeyassistant-system-variable +[S73]: https://help.bricsys.com/en-us/document/panels/calculator-panel +[S74]: https://www.zwsoft.com/user-guide/zwcad-smart-features +[S75]: https://www.digitalengineering247.com/article/gstarcad-2025-is-released +[S76]: https://www.digitalengineering247.com/article/whats-new-in-autocad-2025/design +[S77]: https://www.cadtutor.net/forum/topic/51390-object-snap-mid-between-2-points-m2p-mtp diff --git a/docs/raw/조사/2026-10-15_335_부록2_우리CAD현황.md b/docs/raw/조사/2026-10-15_335_부록2_우리CAD현황.md new file mode 100644 index 000000000..5addf9666 --- /dev/null +++ b/docs/raw/조사/2026-10-15_335_부록2_우리CAD현황.md @@ -0,0 +1,261 @@ +# B2 우리 CAD 입력 방법 현황 (요구 225 · 10-15) + +- 기준 = `main_laptop_1` 31e6adf7d(331 · 332 들어간 뒤) · 코드 읽기만(실행 · 빌드 · 화면 실측 안 함). +- 경로 줄임 — `src/` = `B07_DesignDetail/openwebcad/src/` · `cad/` = `resources/tester/cad/` · `py/` = `resources/tester/` · `A00/` = `A00_Common/` · 줄만 적은 근거(`:123`) = 바로 앞 파일. +- 기호 — ● 있음 · ◐ 일부 · ○ 없음 · (추정) = 코드 흐름 읽기로 낸 판단 · 실측 필요 · ⚠ = 흠으로 보이는 것. + +## 0. 우리에게 없는 것 · 약한 것 (오토캐드 일반 기능 기준 짐작) + +1. 좌표 꼴 — `@`(마지막 점) · `#`(절대) · 도분초 · 방위각 · 프롬프트 안 식 계산 · 단위 붙은 숫자 없음 · 첫 점에 `@` · 거리 숫자는 조용히 버림. +2. 동적 입력 — 첫 점 X · Y 칸 없음 · 켜고 끄기(F12) 없음 · 캔버스에 그린 글 상자라 칸을 눌러 고칠 수 없음. +3. FROM · 두 점 가운데(M2P) · 임시 추적점(TT) 없음. +4. 객체 스냅 — 접점 · 연장 · 평행 · 삽입점 · 겉보기 교차 없음 · 호 중간점 · 호 사분점 없음 · 종류별 켜고 끄기 없음 · 한 번 스냅에 QUA · NOD 없음 · Shift+우클릭 스냅 메뉴 없음. +5. 극 추적 = 45° 고정(각 증분 · 추가 각 못 고름) · 직교 = 90° 가이드 가까이일 때만 붙음(강제 아님 · Shift 임시 직교 없음) · F3 끄면 직교 · 극 · 스냅 추적 가이드도 같이 꺼짐. +6. 그리드 = 화면 고정 24px 무늬(도면 단위 아님 · 팬 · 줌 안 따름) · 격자 스냅(F9) 없음 · F11 = 브라우저 전체 화면(스냅 추적 아님). +7. 고르기 — 올가미 · 울타리 · 앞 선택(P) · 마지막(L) · `ALL` 글자 없음 · 상자는 늘 새로 고름(더하기 안 됨) · 명령 안 고르기는 누를 때마다 바뀜(Shift 로 더함) · 끌기 상자 안 됨(두 번 누름) · 빼기 = Ctrl(오토캐드 = Shift). +8. 그립 — 누른 채 끌어 놓기만(추정) → 그립 중 키보드 값 사실상 못 넣음 · 거리 숫자 안 받음 · 이동 · 회전 · 축척 · 대칭 그립 모드 · 복사 · 여러 그립 없음 · 호 · 해치 · 치수 · 타원 · 스플라인 그립 없음 · 손가락으로 그립 못 옮김(추정). +9. ⚠ Ctrl+C · Ctrl+X 연결 없음 — 글자 `C` · `X` 가 입력 칸에 쌓임(Enter 면 원 · 분해 명령) · Ctrl+Shift+C · Ctrl+Shift+V · Ctrl+1 없음 · 클립보드 = 앱 안 메모리. +10. 우클릭 메뉴 없음(우클릭 = Enter · 반복 안 함) · 가운데 두 번 = 전체 보기 없음 · 화살표 = 화면 이동(객체 미세 이동 없음). +11. 명령 — 투명 명령 `'` 없음 · ZOOM = 범위만(창 · 이전 · 배율 없음) · PAN = 안내 글만 · 명령 중 다른 명령 이름 = 지금 명령 버리고 새 명령 · 제도 설정 명령(OSNAP · DSETTINGS · UNITS) 없음. +12. 선택지 — LINE(C · U) · ARRAY(L · R) 둘뿐 · 눌러 고르는 선택지 없음 · PLINE 닫기 · 취소 · 호 없음 · LINE 첫 점 Enter 이어 그리기 없음 · SCALE 기준점 · ROTATE 방향점 키보드 안 받음 · 참조(R) · 복사(C) 없음. +13. 자동 완성 = 앞글자만(중간 글자 · 자주 쓴 순 없음) · 앞글자만 쳐도 첫 후보 실행 · ↑ 기록 = 아래 명령행(기본 숨김)에서만 · 명령 이름만 기억 · 새로고침에 사라짐 · 명령행 칸은 한글 자판이면 명령 못 찾음(추정). +14. 속성 칸 = 색 · 선가중치 · 선종류 · 층 · 투명도만(좌표 · 길이 · 반지름 · 글 높이 · 글 내용 못 고침) · 두 번 눌러 고치기 = 글자만. +15. 스크립트 · 매크로 · 동작 기록 없음 · 바꿀 수 있는 것 = 휠 방향 · 별칭뿐(F키 · Ctrl · 우클릭 · 스냅 거리 · 극 각 못 바꿈) · 스냅 · 직교 · 극 켬 상태 저장 안 됨. +16. ⚠ 확정 · 권한 없음(readOnly)에서 그립 · Delete · 특성 칸 · 리본 특성 · 도면층 관리가 막히지 않음 · 미리보기(viewOnly)도 Ctrl+A → Delete 가 먹음(코드 읽기 · 실측 필요). +17. B06 상세 · M02 카드 고치기 = 상태막대 · 명령행 없음 → 직교 · 극 · 스냅 · 그리드 = F키만 · 스냅 추적 켜고 끄기 · ↑ 기록 못 씀. + +## 1. 좌표 입력 꼴 + +| 항목 | 상태 | 어떻게 | 근거 | 시험 | +|---|---|---|---|---| +| 절대 `x,y` | ● | 점 단계 어디서나 · 쉼표 둘레 빈칸 · 음수 · `.5` 허용 | `src/inputController/input-patterns.ts:10-13,36-42` · `src/inputController/input-controller.ts:554-557` | `cad/keyboard-chain.test.ts:88-137` | +| 상대 `@dx,dy` | ◐ | 앞 점 있는 단계만 · 첫 점이면 조용히 버림(도구 안 멈춤) | `input-patterns.ts:14,43-49` · `src/helpers/get-point-from-event.ts:47-53` · `src/tools/factories/sequence-tool.ts:286-289` | `keyboard-chain.test.ts:139-175` | +| 극좌표 `@d 객체 스냅점 > 커서 · LINE · 시퀀스 도구(둘째 점부터) · MOVE · COPY 끝점 · RECTANG(대각 길이) · CIRCLE(반지름) · 그립은 안 받음 | `input-controller.ts:587-597` · `get-point-from-event.ts:30-43` · `src/tools/grip-edit-tool.ts:64-80` | `keyboard-chain.test.ts:106-113,176-189` | +| 마지막 점 `@` 홀로 | ○ | 어느 꼴에도 안 맞아 글로 버려짐 · 명령 사이 「마지막 점」 기억 없음(도구마다 자기 앞 점) | `input-patterns.ts:30-65` · `input-controller.ts:572-574` | 없음 | +| `#` 절대(동적 입력 꼴) | ○ | `x,y` 가 늘 절대 · `#` 해석 없음 | `input-patterns.ts:12-19` | 없음 | +| Tab 칸 입력 | ● (331-4) | 앞 점 있는 점 단계 — 숫자 + Tab = 지금 칸 잠금 + 다음 칸 · 빈 Tab = 칸만 바꿈 · 각도 칸 잠금 = 각도 고정 · 길이 잠근 채 빈 Enter = 그 길이 · Shift+Tab 도 같은 쪽 · 그 밖 단계 Tab = 브라우저 초점 이동 | `input-controller.ts:413-422,514-517,541-549` · `input-angle-lock.ts:30-35` | `keyboard-chain.test.ts:283-330` | +| 커서 곁 동적 입력 칸 | ◐ | 캔버스에 그린 글 상자(진짜 입력 칸 아님) — 친 글 · 안내문 · 커서 절대 좌표 · `[길이 ▸ 🔒] [각도]` 두 칸(앞 점→커서 실측) · 각도 고정 · 한 번 스냅 안내 · 명령 후보 6 · 첫 점 X · Y 칸 없음 · 끄기 없음 · 미리보기엔 안 그림 | `input-controller.ts:137-166` · `src/inputController/input-field-draw.ts:37-110` · `src/helpers/draw.ts:75-78` | `keyboard-chain.test.ts:283-330` | +| 식 계산 `1+2*3` | ○ | 점 · 숫자 단계 해석 없음 · 따로 QUICKCALC(QC) = 결과를 알림에만(입력에 못 씀) | `input-patterns.ts:11` · `src/tools/utility/inquiry-tools.ts:120-140` | 없음 | +| 단위(`mm` · `m` · 피트-인치) | ○ | 숫자만 · 도면 단위 그대로 · 소수점 `.` 만(`,` = 좌표 가름) · `1e3` · 분수 안 됨 | `input-patterns.ts:10-11` | 없음 | +| 도분초 · 방위각 · 라디안 | ○ | 십진 도만 | `input-patterns.ts:15-25` | 없음 | + +### 1-1. 도구별 키보드 값 받는 자리 + +| 도구 | 첫 값 | 다음 값 | 근거 | +|---|---|---|---| +| LINE | 클릭 · `x,y` | 클릭 · 숫자 · `x,y` · `@` · `<` · `C` · `U` · Enter = 끊고 새 첫 점 | `src/tools/line-tool.ts:73-139` | +| 시퀀스 공장(PLINE · ARC · 치수 · DIST 등 약 100) | 클릭 · `x,y`(숫자 · `@` 버림) | 점 = 전부 · 숫자 단계 = 숫자 · 기본값 Enter · 글 단계 = 자유 글 · 객체 단계 = 클릭만 | `src/tools/factories/sequence-tool.ts:276-337` | +| CIRCLE | 중심 클릭 · `x,y` | 반지름 숫자 · `x,y` · `@` | `src/tools/circle-tool.ts:63-100` | +| RECTANG | 클릭 · `x,y` | 숫자(커서 쪽 대각 길이) · `x,y` · `@` | `src/tools/rectangle-tool.ts:58-92` | +| MOVE · COPY | 기준점 클릭 · `x,y` | 클릭 · 숫자 · `x,y` · `@` · `<`(COPY 는 여러 번) | `src/tools/move-tool.ts:139-191` · `src/tools/copy-tool.ts:161-191` | +| ROTATE | 기준점 클릭 · `x,y` | 각도 숫자(반시계 +) 또는 클릭 두 번(기준선 · 방향 — 방향점 키보드 안 받음) | `src/tools/rotate-tool.ts:146-215` | +| SCALE | 기준점 **클릭만** | 배율 숫자 또는 클릭 두 번 | `src/tools/scale-tool.ts:151-215` | +| ARRAY | — | `L` · `R`(Enter = 직선) · 개수 · 간격 점(`x,y` · `@`) · 한 칸 각도 | `src/tools/array-tool.ts:147-280` | +| 그립 | (누름 = 집기) | 클릭 · `x,y` · `@` · 숫자 안 받음 | `src/tools/grip-edit-tool.ts:64-80` | +| 블록 놓기 | 클릭 · `x,y` | — | `src/tools/insert-block-tool.ts:72-84` | + +## 2. 제도 보조 + +| 항목 | 상태 | 어떻게 | 근거 | 시험 | +|---|---|---|---|---| +| 직교 F8 | ◐ | 90° 간격 가이드 켬/끔 · 가이드 가까이(약 15px) 올 때만 붙음(강제 아님) · Shift 임시 직교 없음 · 상태막대 「직교」 | `input-controller.ts:403-407` · `src/helpers/get-draw-guides.ts:36-49` · `src/components/StatusBar.tsx:36-43` | 없음 | +| 극좌표 추적 F10 | ◐ | 45° 고정 · 각 증분 · 추가 각 못 고름 · F8 과 한 값(`angleStep`)이라 둘 중 하나 · 기본 45 켜짐 | `input-controller.ts:408-412` · `src/state.ts:82` · `StatusBar.tsx:44-51` · `src/helpers/get-angle-guide-lines.ts:7-30` | 없음 | +| 객체 스냅 상시 F3 | ◐ | 켬/끔 하나(종류별 고르기 없음 · 종류표 꼴만 있고 안 씀) · 둘레 15px · 끄면 직교 · 극 · 스냅 추적 가이드도 꺼짐 · 상태막대 「객체 스냅」 · 손가락 ⌖ | `input-controller.ts:278-280,393-397` · `src/state.ts:481-490` · `src/App.consts.ts:52` · `src/App.types.ts:21` | 없음 | +| 객체 스냅 한 번 | ◐ (331-3) | 점 단계에서 `END` `MID` `CEN` `INT` `PER` `NEA` `NON` + Enter → 다음 한 점만 · 둘레 30px · 맞는 점 없으면 클릭 버림 · F3 꺼져도 먹음 · `QUA` `TAN` `NOD` 없음 · Shift+우클릭 메뉴 없음 | `src/inputController/input-snap-override.ts:1-80` · `input-controller.ts:239-243,558-561` | `keyboard-chain.test.ts:249-281` | +| FROM · 두 점 가운데(M2P) · 임시 추적점(TT) | ○ | 해석 · 도구 없음 | (src 검색 0건) | 없음 | +| 객체 스냅 추적 | ◐ | 스냅점에 1초 머물면 표시(최대 3점) · 그 점에서 같은 각 간격 가이드 · 가이드끼리 · 객체와 교차점 스냅 · 상태막대 「스냅 추적」만(F11 아님) · 각 간격 0(직교 · 극 끔)이면 안 뻗음 | `src/helpers/calculate-angle-guides-and-snap-points.ts:36-58` · `src/helpers/track-hovered-snap-points.ts:9-70` · `App.consts.ts:77,83` · `StatusBar.tsx:52-59` | 없음 | +| 그리드 F7 | ◐ | 화면 고정 24px 무늬만 — 도면 단위 아님 · 팬 · 줌 안 따름 | `input-controller.ts:398-402` · `src/drawControllers/screenCanvas.drawController.ts:339-354` | `src/helpers/zoom-out-perf.test.ts:183-188` | +| 스냅(F9 · 격자 스냅) | ○ | 없음 | — | 없음 | +| 바꿀 수 있는 수치 | ○ | 스냅 15px · 한 번 30px · 집기 10px · 머묾 1초 · 그리드 24px 모두 상수 | `App.consts.ts:52-83` · `input-snap-override.ts:58-59` | — | + +### 2-1. 객체 스냅 종류 + +| 종류 | 상시 | 한 번 | 만드는 객체 · 근거 | +|---|---|---|---| +| 끝점 END | ● | ● | 선 · 호 끝 · 사각형 · 그림 모서리 · 해치 꼭짓점 · 표 격자 · 폴리선 선 구간 — `src/entities/LineEntity.ts:104-118` · `ArcEntity.ts:132-146` · `RectangleEntity.ts:109-145` · `HatchEntity.ts:183-185` · `TableEntity.ts:293-298` · `PolyLineEntity.ts:114-116` | +| 중간점 MID | ◐ | ● | 선 · 사각형 · 그림 변(호 중간 없음) — 같은 곳 | +| 중심 CEN | ● | ● | 원 · 호 — `CircleEntity.ts:98-123` · `ArcEntity.ts:132-137` | +| 사분점 QUA | ◐ | ○ | 원만(호 없음) — `CircleEntity.ts:106-122` | +| 교차 INT | ● | ● | 커서 둘레 객체끼리 + 가이드와 — `src/helpers/get-draw-guides.ts:51-88` | +| 노드 NOD | ● | ○ | 점 객체 — `PointEntity.ts:87-94` | +| 수직 PER | ○ | ● | 앞 점에서 내린 발 — `input-snap-override.ts:77-79` | +| 근처점 NEA | ○ | ● | `input-snap-override.ts:74-76` | +| 접점 TAN | ○ | ○ | 이름만(`App.types.ts:11`) · 만드는 곳 0 | +| 연장 · 평행 · 삽입점 · 겉보기 교차 · 기하 중심 | ○ | ○ | 글자 · 치수 · 화살표는 스냅점 0(`TextEntity.ts:191-193` · `MeasurementEntity.ts:483-485`) | + +### 2-2. 기능키 + +| 키 | 우리 | 근거 | +|---|---|---| +| F1 · F2 · F4 · F6 · F9 | 아무 일 없음(키 삼킴) | `input-controller.ts:427-428` | +| F3 | 객체 스냅 켬/끔 | `:393-397` | +| F5 | 브라우저 새로고침 | `:382-385` | +| F7 | 그리드 무늬 | `:398-402` | +| F8 | 직교(90°) | `:403-407` | +| F10 | 극 추적(45°) | `:408-412` | +| F11 | 브라우저 전체 화면(스냅 추적 아님) | `:386-391` | +| F12 | 개발자 도구(동적 입력 끄기 아님) | `:378-381` | + +## 3. 명령 입력 + +| 항목 | 상태 | 어떻게 | 근거 | 시험 | +|---|---|---|---|---| +| 캔버스에 바로 치기 | ● | 아무 글자 → 대문자로 쌓임(글 단계는 친 그대로) · Backspace 한 자 지움 · 커서 곁 상자에 보임 | `input-controller.ts:441-444,476-482` | `src/inputController/ime-sink.test.ts:58-140` | +| 아래 명령행 | ◐ | 기본 숨김 · 상태막대 「명령행」 단추로 켬 · 최근 3줄 · 안내문 · 후보 단추 8 · 빈 Enter = 확정 · 반복 | `src/components/Toolbar.tsx:23,70` · `src/components/CommandLine.tsx:8-108` · `StatusBar.tsx:68-75` | 없음 | +| 자동 완성 | ◐ | 앞글자 일치만(내 별칭 → 기본 별칭 → 이름 차례) · 커서 곁 6 · 명령행 8(눌러 실행) · 명령행 Tab = 첫 후보 이름 채움 · 중간 글자 · 자주 쓴 순 없음 | `src/commands/registry.ts:137-150` · `input-controller.ts:503-508` · `CommandLine.tsx:15,78-81,89-105` | `cad/input-prefs-cad.test.ts:69-75` | +| 이름 해석 차례 | ● | 이름 > 내 별칭 > 기본 별칭 > 앞글자 첫 후보(앞글자만 쳐도 실행) · 대소문자 무관 | `registry.ts:124-134` | `input-prefs-cad.test.ts:61-80` | +| 별칭 표 원천 | ● | `src/commands/commands.{draw,modify,annotate,layer,utility,insert,view,file}.ts` 의 `aliases` → `registry.ts:51-58`(겹치면 먼저 것) · 거울 `src/commands/command-catalog.json`(153 명령 · 별칭 88 · 별칭 있는 명령 82) | 같은 곳 | `cad/command-catalog.test.ts:11-30` | +| 내 별칭 | ● (332) | ☰ [설정] ② CAD 단축키 표 · 영문 · 숫자만 · 내 별칭끼리 · 명령 이름과 겹침 금지 · [저장] 때 계정 pref(`aislo:pref:input-prefs` `cadAliases`) · 부를 때마다 읽어 바로 따름 | `A00/app_settings_cad.ts:38-118` · `A00/input_prefs.ts:13-51` · `registry.ts:116-122` | `input-prefs-cad.test.ts:61-80` · `py/test_app_settings.py` · `py/test_input_prefs.py` | +| 마지막 명령 반복 | ◐ | 빈 Enter · Space = 선택 도구 대기일 때 직전 명령 · 우클릭 = 확정만(반복 안 함) · 명령행 빈 Enter 도 반복 | `input-controller.ts:518-526` · `run-command.ts:66-72` · `CommandLine.tsx:20-24` | `ime-sink.test.ts:115-140` | +| 입력 기록 ↑↓ | ◐ | 아래 명령행 칸에서만 · 실행한 명령 이름만(좌표 · 값 아님) · 200줄 메모리(새로고침에 사라짐) · 캔버스 ↑↓ = 화면 이동 | `run-command.ts:10-24` · `CommandLine.tsx:31-39,70-77` | 없음 | +| 투명 명령 | ◐ | 즉시형 명령(ZOOM · ZOOMIN · ZOOMOUT · REGEN · LAYER 창 등)은 지금 도구를 안 끊음 · 휠 · 가운데 팬 · 화살표는 언제나 · `'ZOOM` 꼴 없음 · 각도 고정 · 동적 잠금은 풀림 | `run-command.ts:41-54` · `input-controller.ts:565-571` | 없음 | +| 명령 중 다른 명령 이름 | ● (오토캐드와 다름) | 도구형 명령 이름 = 지금 명령 버리고 새 명령(오토캐드 = 점 아님으로 거절) | `run-command.ts:44-50` | `keyboard-chain.test.ts:243-248` | +| 옵션 키워드 | ◐ | 글자 + Enter — LINE `[닫기(C)/명령취소(U)]` · ARRAY `[직선(L)/원형(R)] <직선>` 둘뿐 · 눌러 고르는 선택지 없음 · 점 단계는 `meta.options` 글자만 도구로 · 나머지는 명령 이름으로 | `line-tool.ts:92-132` · `array-tool.ts:147-169` · `src/inputController/ime-sink.ts:28-34` · `input-controller.ts:562-564` | `keyboard-chain.test.ts:203-248` | +| 기본값 `<…>` | ◐ | 숫자 · 글 단계 `defaultValue` 만 빈 Enter 로 채택 | `sequence-tool.ts:302-310` | 없음 | +| 안내문 | ◐ | 우리 도구 · 331 손본 도구 = 한글 · 대괄호 선택지 표기는 LINE · ARRAY 만 | `line-tool.ts:76,95` · `array-tool.ts:148` | `keyboard-chain.test.ts:211-222` | +| Space = Enter | ● | 글 단계(TEXT · MTEXT · 표 칸 등)는 띄어쓰기 · Shift/Ctrl+Space · 길게 눌러 반복 무시 | `input-controller.ts:458-463` · `ime-sink.ts:39-51` | `ime-sink.test.ts:58-113` | +| Esc 차례 | ● | 각도 고정 · 한 번 스냅 풂 → 고름 풂 → 글 없으면 도구 취소 · 있으면 글 지움 | `input-controller.ts:485-500` | `keyboard-chain.test.ts:114-127,265-274` | +| 한글 자판 | ◐ | 명령 단계 = 숨은 IME 받개 초점 풀어 영문 글자로 받음 · 글 단계 = 숨은 칸에 초점 · 조합 글 커서 곁에 보임 · 조합 중 키는 단축키로 안 먹음 · Ctrl 단축키 = 자판 자리(`code`) · 명령행 칸은 일반 입력 칸이라 한글 자판이면 「ㅣ」 꼴로 들어가 명령 못 찾음(추정) · 내 별칭에 한글 못 씀 | `ime-sink.ts:1-103` · `input-controller.ts:374-376` · `A00/key_shortcut.ts:11-49` · `CommandLine.tsx:51-57` · `A00/app_settings_cad.ts:48` | `ime-sink.test.ts:11-56` · `cad/ctrl-shortcut-hangul.test.ts` | + +## 4. 고르기 + +| 항목 | 상태 | 어떻게 | 근거 | +|---|---|---|---| +| 한 번 누름 | ● | 집기 반경 10px(손가락 = 접촉 크기 · 최소 24px) · 기존 고름을 **바꿈** · 그룹은 통째 · 잠금층 못 고름 | `src/tools/select-tool.helpers.ts:74-82` · `src/helpers/pick-radius.ts:17-22` · `src/state.ts:365-376` | +| 창(왼→오 · 파랑 · 다 든 것) · 걸침(오→왼 · 초록 · 걸친 것) | ● | 시작점 기준 방향으로 가름 | `select-tool.helpers.ts:114-169,185-200` | +| 끌기 상자 | ◐ | 선택 도구일 때 = 누른 채 끌기(4px 넘음) 또는 두 번 누름 · **명령 안 고르기(MOVE · ERASE 등) = 두 번 누름만**(누름은 놓을 때 한 점) | `input-controller.ts:195-209,311-330` | +| 올가미 · 울타리(F) | ○ | — | — | +| 전체 | ◐ | Ctrl+A(잠금층 뺌) · 고르기 중 `ALL` 글자 없음 | `input-controller.ts:438-440` | +| 앞 선택(P) · 마지막(L) | ○ | 고르기 중 `P` = PAN 안내 · `L` = LINE 실행(지금 명령 버림) | `input-controller.ts:565-571` · `registry.ts:125-134` | +| 더하기 | ◐ | Shift+누름 · Ctrl+누름 · **상자는 늘 새로 고름**(기존 고름 버림) · Shift+상자 미구현 | `select-tool.helpers.ts:83-99,114-118,168` | +| 빼기 | ◐ | Ctrl+누름 = 넣고 빼기(오토캐드 = Shift+누름) · ⚠ Ctrl+상자 = 상자 안 안 고른 것만 남김 | `select-tool.helpers.ts:83-93,131-164` | +| 겹친 것 돌려 고르기 | ● | 같은 자리 다시 누르면 다음 후보(고름 바뀜) · Shift+Space · 후보 목록 창 없음 | `select-tool.helpers.ts:34-49` | +| 비슷한 것 | ◐ | SELECTSIMILAR(종류 · 색 · 층 같은 것) · QSELECT(종류 이름 글로) | `src/tools/utility/selection-tools.ts:9-48` | +| 고른 뒤 명령 | ◐ | MOVE · COPY · ROTATE · SCALE · ARRAY · PEDIT = 고르기 건너뜀 · 시퀀스 「고르기」 단계(ERASE · GROUP · AREA 등) = Enter 한 번 더 · BLOCK · TOAREA = 먼저 골라야 · Delete 키 = 바로 지움 | `move-tool.ts:81-97` · `sequence-tool.ts:321-337` · `run-command.ts:35-39` · `src/tools/modify/structure-tools.ts:71-81` | +| 명령 뒤 고르기 | ◐ | 속에 선택 도구 · 누를 때마다 바뀜(Shift 로 더함) · Enter · Space · 우클릭으로 끝 | `move-tool.ts:98-138` · `sequence-tool.ts:321-337` | +| 마우스 스침 강조 | ◐ | 선택 도구 활성일 때만(명령 안 고르기 땐 없음) | `input-controller.ts:283-293` · `src/main.tsx:89-111` | + +- 고르기 시험 = 없음(그룹 · 잠금층만 다른 시험이 간접으로 봄). + +## 5. 그립 + +| 항목 | 상태 | 어떻게 | 근거 | 시험 | +|---|---|---|---|---| +| 그립 종류 | ◐ | 선 = 끝 2 · 가운데(옮김) · 사각형 = 모서리 4(맞은편 고정) · 원 = 중심(옮김) · 사분 4(반지름) · 폴리선(선 구간만) = 꼭짓점 · 구간 가운데 · 표 = 왼쪽 위(옮김) · 열 · 행 경계 · 칸 있는 글 · 그림 = 모서리 4(크기) · 가운데(옮김) · 글 · 점 = 기준점 · 호 · 해치 · 치수 · 타원 · 스플라인 · 호 섞인 폴리선 = 없음 | `src/helpers/grips.ts:1-8,74-148` | `cad/grips.test.ts:25-80` | +| 조작 | ◐ | 고른 뒤 그립 위 누름 = 그립 도구로 바뀜 → 놓는 자리에 적용 → 곧 선택 도구 = **누른 채 끌어 놓기만**(클릭 두 번 방식 아님 · 주석 「집어서 놓는 방식」과 다름 · 추정) · 끄는 동안 스냅 · 가이드 · 미리보기 | `select-tool.helpers.ts:55-72` · `input-controller.ts:195-211,311-330` · `grip-edit-tool.ts:1-4,26-30,64-112` | 없음(흐름 시험 0) | +| 다기능 | ◐ | 폴리선 구간 가운데 끌기 = 꼭짓점 더함 · Ctrl+꼭짓점 누름 = 꼭짓점 뺌 · 선 · 원 가운데 = 옮김 · Space 로 이동 · 회전 · 축척 · 대칭 바꾸기 · 복사 · 여러 그립 · 기준점 · 올림 메뉴 없음 | `grips.ts:151-241` · `select-tool.helpers.ts:58-67` | `grips.test.ts:55-75` | +| 그립 중 숫자 | ◐ | 도구는 `x,y` · `@` · `<` 받음 · 거리 숫자 안 받음 · 놓는 순간 끝나 누른 채 쳐야 함(사실상 못 씀 · 추정) | `grip-edit-tool.ts:64-80` | 없음 | +| 손가락 | ○ (추정) | 톡 = 같은 자리 누름 + 놓음이라 그립이 바로 끝남 · 끌기 = 화면 이동 | `src/inputController/input-touch.ts:46-50,85-111` | 없음 | +| 칸 모서리 끌기(따로) | ● | 고른 칸 있는 글 · 그림 모서리 9px 안 = 진짜 끌기로 크기 · 확정 도면 막음 | `src/integration/box-resize-drag.ts:14-26,67` | 없음 | + +## 6. 마우스 · 키보드 · 터치 · 펜 + +| 항목 | 상태 | 어떻게 | 근거 | 시험 | +|---|---|---|---|---| +| 휠 | ● | 커서 자리 고정 줌 · 방향 = ☰ [설정] 당김 · 밀기(모든 화면 한 값) · 줄 · 쪽 단위 환산 · 한 칸 10% | `src/inputController/input-wheel.ts:14-29` · `A00/input_prefs.ts:54-64` · `App.consts.ts:150-160` | `src/inputController/input-wheel.test.ts:13-33` · `input-prefs-cad.test.ts:37-59` | +| 트랙패드 핀치 | ● | Ctrl+휠 잔 델타 = 늘 벌리면 확대 · 브라우저 확대 막음 | `input-controller.ts:111-113,125-133` · `A00/input_prefs.ts:59-64` | `input-wheel.test.ts:25-28` | +| 가운데 단추 끌기 | ● | 팬 · 자동 스크롤 · 붙여넣기 막음 | `input-controller.ts:120-124,264-272,312-316` | 없음 | +| 가운데 두 번 = 전체 보기 | ○ | 전체 보기 = `Z` · 우하단 ⛶ 뿐 | `src/commands/commands.view.ts:42-51` · `src/components/ViewControls.tsx:28-35` | — | +| 왼쪽 두 번 누름 | ◐ | 글자 위 = 그 자리 입력 칸(Enter 적용 · 여러 줄 Ctrl+Enter · Esc 취소 · 수량표 글 제외) · 미리보기 = 전체 맞춤 · 선 · 폴리선 등 = 아무 일 없음 | `src/integration/aislo-drawing-bridge.ts:114-118` · `src/integration/text-inplace-edit.ts:1-5,210-247` | `cad/text-inplace-edit.test.ts:124-220` | +| 우클릭 | ◐ | = Enter(확정) · 메뉴 없음 · 길게 누름 메뉴 없음 | `input-controller.ts:116-119,175-180` | `ime-sink.test.ts:115-140` | +| 화살표 | ◐ | 화면 이동(Shift = 크게) · 고른 객체 미세 이동 없음 | `input-controller.ts:346-367,464-475` | 없음 | +| Ctrl+Z · Ctrl+Y · Ctrl+Shift+Z | ● | 되돌리기 · 다시(한글 자판도 자리로) | `input-controller.ts:429-434,616-634` · `A00/key_shortcut.ts:31-49` | `cad/ctrl-shortcut-hangul.test.ts:38-116` | +| Ctrl+A | ● | 잠금층 뺀 전부 고름 | `input-controller.ts:438-440` | `ctrl-shortcut-hangul.test.ts:46-50` | +| Ctrl+S | ◐ | M02(부모 [저장])만 · B07 · B06 = 아무 일 없음(브라우저 저장 창도 막음) | `input-controller.ts:435-437` | 같은 곳 | +| Ctrl+V | ● | 시스템 클립보드 글이 DXF = 서버가 도형으로 바꿔 원좌표 붙임 · 아니면 앱 안 클립보드 PASTECLIP(놓을 자리 지정) | `input-controller.ts:423-426` · `src/integration/dxf-clip-paste.ts:76-87` | `cad/dxf-clip.test.ts` · `py/test_b07_dxf_clip.py` | +| Ctrl+C · Ctrl+X | ○ ⚠ | 연결 없음 — 글자 `C` · `X` 가 입력 칸에 쌓임(Enter 면 CIRCLE · EXPLODE · 선 중이면 닫기) · 복사 = COPYCLIP 명령 · 리본만 | `input-controller.ts:423-428,476-482` · `A00/key_shortcut.ts:37-48` | 없음 | +| Ctrl+Shift+C · Ctrl+Shift+V · Ctrl+1 | ○ | 명령(COPYBASE · PASTEBLOCK · PROPERTIES)은 있고 키 없음 · 클립보드 = 앱 안 메모리(창끼리 · 새로고침 안 넘음) | `src/commands/commands.utility.ts:225-270` · `src/helpers/cad-clipboard.ts:1-34` | 없음 | +| Delete · Backspace | ● | Delete = 선택 도구에서 고른 것 지움 · Backspace = 친 글 한 자 | `input-controller.ts:441-450` · `src/tools/select-tool.ts:73-76,167-171` | 없음 | +| 속성 칸(특성 팔레트) | ◐ | 색 · 선가중치 · 선종류 · 층 · 투명도만 고침 · 길이 · 크기 · 시작점 = 읽기만 · 좌표 · 반지름 · 글 높이 · 글 내용 칸 없음 · 여러 개면 첫 객체 값 | `src/components/PropertiesEditor.tsx:12-147` · `src/components/QuickProperties.tsx:7-21` | 없음 | +| 리본 특성 · 도면층 관리 | ● | 고른 것에 바로 색 · 선 · 층 옮김 · 층째 지움 | `src/components/RibbonWidgets.tsx:39-122` · `src/components/LayerManager.tsx:41-60` | 없음 | +| 특성 맞추기 | ● | MATCHPROP(MA) 명령 | `src/commands/commands.modify.ts:262-270` | 없음 | +| 파일 끌어 놓기 | ● | DWG · DXF → 서버 변환 → 원좌표 붙임 · 고른 상태 | `dxf-clip-paste.ts:88-105` | `py/test_b07_dxf_clip.py` | +| 손가락 | ● | 톡 = 누름(스냅) · 한 손가락 끌기 = 팬 · 두 손가락 = 벌려 확대 + 같이 팬 · 길게(0.45초) = 손가락 위 70px 십자선으로 맞춰 떼면 누름 · 고름 반경 = 접촉 크기(최소 24px) · 오른쪽 단추 6(Esc · Enter · 지움 · 되돌리기 · 다시 · 스냅 F3) — 손가락 기기에서만 | `input-touch.ts:7-144` · `src/inputController/input-touch.css:1-29` | `py/test_cad_touch.py` | +| 펜 | ◐ | 마우스와 같음(손가락 처리에서 빠짐) · 필압 · 옆 단추 전용 처리 없음 | `input-touch.ts:56-60` | 없음 | +| 키 초점 | ● | 포인터가 CAD 위로 오면 iframe 초점(부모 입력 칸에서 치는 중이면 안 뺏음) · CAD 안 입력 칸에서는 CAD 키 안 먹음 | `ui_template/cad_host/cad_host.ts:152-156` · `input-controller.ts:370-373` | `py/test_221_1_loupe_cad.py` | + +## 7. 명령 목록 — 153 · 별칭 88(별칭 있는 명령 82) + +| 갈래 | 수 | 명령(별칭) | 근거 | +|---|---|---|---| +| 그리기 | 21 | LINE(L) · PLINE(PL) · CIRCLE(C) · ARC(A) · RECTANG(REC) · POLYGON(POL) · ELLIPSE(EL) · SPLINE(SPL) · MLINE(ML) · MLSTYLE · XLINE(XL) · RAY · POINT(PO) · DONUT(DO) · DIVIDE(DIV) · MEASURE(ME) · HATCH(H · BH) · GRADIENT(GD) · BOUNDARY(BO) · REGION(REG) · WIPEOUT | `src/commands/commands.draw.ts:31-218` | +| 고치기 | 28 | MOVE(M) · COPY(CO · CP) · ROTATE(RO) · SCALE(SC) · ALIGN(AL) · MIRROR(MI) · OFFSET(O) · ARRAY(AR) · TRIM(TR) · EXTEND(EX) · STRETCH(S) · FILLET(F) · CHAMFER(CHA) · BLEND · BREAK(BR) · BREAKATPOINT · JOIN(J) · EXPLODE(X) · ERASE(E) · LENGTHEN(LEN) · PEDIT(PE) · HATCHEDIT(HE) · DRAWORDER(DR) · DRAWORDERBACK · MATCHPROP(MA) · OVERKILL · REVERSE · SETBYLAYER | `src/commands/commands.modify.ts:47-295` | +| 맞춤 | 6 | ALIGNLEFT · ALIGNCENTERH · ALIGNRIGHT · ALIGNTOP · ALIGNCENTERV · ALIGNBOTTOM | `commands.modify.ts:297-340` | +| 글자 | 5 | MTEXT(T · MT) · TEXT(DT) · STYLE(ST · 안내만) · TEXTEDIT(TEDIT) · FIND | `src/commands/commands.annotate.ts:50-98` | +| 치수 | 16 | DIM · DIMLINEAR(DLI) · DIMALIGNED(DAL) · DIMANGULAR(DAN) · DIMARC(DAR) · DIMRADIUS(DRA) · DIMDIAMETER(DDI) · DIMORDINATE(DOR) · DIMBASELINE(DBA) · DIMCONTINUE(DCO) · QDIM · CENTERMARK · CENTERLINE · DIMSTYLE(D) · DIMUPDATE · DIMSPACE | `commands.annotate.ts:100-238` | +| 지시선 · 표 · 표식 | 13 | MLEADER(MLD) · MLEADERSTYLE(MLS) · MLEADERALIGN · TABLE(TB) · TABLESTYLE(TS) · TABLEEDIT · TABLEROW · TABLECOL · TABLEMERGE · TABLEUNMERGE · PLACETABLE · REVCLOUD · ANNOSCALE | `commands.annotate.ts:240-355` | +| 도면층 | 18 | LAYER(LA) · LAYCURSET · LAYOFF · LAYON · LAYFRZ · LAYTHW · LAYLCK · LAYULK · LAYISO · LAYUNISO · LAYERP · LAYERSTATE(LAS) · LAYERSTATERESTORE · LAYMCH · LAYCUR · LAYMRG · LAYDEL · LAYWALK | `src/commands/commands.layer.ts:25-166` | +| 특성 · 그룹 | 8 | COLOR(COL) · LINETYPE(LT) · LWEIGHT(LW) · PROPERTIES(CH · MO · PR) · QUICKPROPERTIES(QP) · TRANSPARENCY · GROUP(G) · UNGROUP | `src/commands/commands.utility.ts:40-112` | +| 측정 · 조회 · 고르기 | 13 | DIST(DI) · RADIUS · ANGLE · AREA(AA) · MEASUREGEOM(MEA) · ID · LIST(LI) · QSELECT · QUICKCALC(QC) · SELECTSIMILAR · ISOLATEOBJECTS · HIDEOBJECTS · UNISOLATEOBJECTS(UNHIDE · UNISOLATE) | `commands.utility.ts:114-223` | +| 클립보드 | 6 | COPYCLIP · CUTCLIP · PASTECLIP · COPYBASE · PASTEORIG · PASTEBLOCK | `commands.utility.ts:225-270` | +| 블록 · 삽입 | 3 | IMAGEATTACH(IAT) · BLOCK(B · 이름 = 브라우저 prompt) · INSERT(I · 블록 창 열기) | `src/commands/commands.insert.ts:13-61` | +| 보기 | 6 | SELECT(SE) · ZOOM(Z · 범위만) · ZOOMIN · ZOOMOUT · REGEN(RE) · PAN(P · 안내 글만) | `src/commands/commands.view.ts:31-87` | +| 파일 · 되돌리기 | 10 | QSAVE(SAVE) · RECOVER · EXPORT(EXP) · EXPORTDXF · EXPORTDWG · EXPORTSVG · EXPORTPNG · TOAREA · UNDO(U) · REDO | `src/commands/commands.file.ts:13-118` | + +- 없는 제도 설정 명령 — OSNAP · DSETTINGS · UNITS · SNAP · GRID · ORTHO 이름 없음 · `SE` = 우리 SELECT(오토캐드 acad.pgp 는 DSETTINGS 로 앎 · 대조 필요). + +## 8. 묶음 · 자동화 + +| 항목 | 상태 | 어떻게 | 근거 | +|---|---|---|---| +| 스크립트(.scr) · 매크로 · 동작 기록 | ○ | 없음 | (src 검색 0건) | +| 명령 기록 | ◐ | 실행 명령 200줄 메모리 · 명령행에 최근 3줄 · 저장 · 내보내기 · 다시 돌리기 없음 | `run-command.ts:10-24` · `CommandLine.tsx:43-48` | +| 마지막 명령 반복 | ● | 3장 참고 | `run-command.ts:66-72` | +| 부모 화면 → CAD 메시지 | ● (사람 입력 아님) | 도면 싣기 · 저장 요청 · 표 놓기 · 표 칸 갈기 · 작도 영역 · 가이드 보이기 | `src/integration/aislo-drawing-bridge.ts:36-49,145-217` · `ui_template/cad_host/cad_host.ts:17-39` | +| 시험용 재생 | ◐ (시험만) | 크롬 기록기 JSON 재생 도우미 — 앱 기능 아님 | `B07_DesignDetail/openwebcad/test/helpers/replay-recording.ts` | +| 상태 읽기 창구 | ◐ (검증용) | `window.__aisloCad` 읽기만 | `src/helpers/debug-hook.ts:1-63` | +| 설정 저장 | ◐ | 휠 방향 · 내 별칭만 계정 pref · 스냅 · 직교 · 극 · 그리드 · 명령행 켬은 메모리(새로 열면 기본) | `A00/input_prefs.ts:13-51` · `src/state.ts:82,179-182` · `Toolbar.tsx:23` | + +## 9. 세 곳 차이 · 보기 전용 + +### 9-1. 띄우는 곳별 + +| 자리 | 띄우는 곳 | 겉칸 | 편집 | 입력 차이 | +|---|---|---|---|---| +| B07 상세 설계 | `B07_DesignDetail/B07_DesignDetail_UI_Page.ts:117,357` | 전부(제목 · 리본 · 좌측 패널 · 상태막대 · 명령행 켬 단추 · 우하단 줌) | 됨 · 확정 도면 = readOnly | 표준 · Ctrl+S 없음 · 도각 편집 때 자리표 단추 · 가이드 끌기 | +| B07 평면도 관리 미리보기 | `B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts:67,184-190` | 캔버스만(bare) | 안 됨(readOnly + viewOnly) | 왼쪽 끌기 = 팬 · 두 번 = 맞춤 · 휠 · 고르기 · 스냅 · 커서 상자 없음 | +| B06 횡단 [상세] 아래 「표 위 CAD」 | `B06_Section/B06_Section_UI_Cross_Detail_Cad.ts:317-346` | 캔버스만(bare) — 리본 · 상태막대 · 명령행 · 좌측 패널 · 우하단 줌 없음 | 됨 · 고칠 때마다 세션 초안에 씀 | 명령 = 커서 곁 글 치기 · 손가락 단추만 · 직교 · 극 · 스냅 · 그리드 = F키만 · 스냅 추적 켜고 끄기 · ↑ 기록 못 씀 · 되돌리기 = Ctrl+Z 만 | +| M02 도면 양식 편집 | `M02_MasterTemplete/M02_MasterTemplete_Drawing.ts:243,270-291` | 리본 접힘(탭 줄만) · 우하단 ↶ ↷ | 권한 따라 readOnly · 미리보기면 viewOnly | Ctrl+S · 💾 = 부모 [저장] · 작도 영역 가이드 끌기 · 원점 십자 · 자리표 단추 | +| M02 구조물 카드 | `M02_MasterTemplete/M02_MasterTemplete_DrawingCard.ts:42-45,68-95` | 보기 = 제목 · 리본 숨김 · 고치기 = 제목 · 리본 · 좌측 패널 · 상태막대 · 빠른 막대 숨김 | 보기 = viewOnly · 고치기 = 됨 | 고치기 = 상태막대 없어 명령행 · 스냅 추적 · 그리드 단추 못 씀(F키 · 글 치기만) | +| M02 면적산출 도면 패널 | `M02_MasterTemplete/M02_MasterTemplete_AreaDrawing.ts:31-47,155-167` | 카드 고치기와 같은 숨김 | readOnly(고르기는 됨 — TOAREA 용) | 누름 · 끌기로 고르기 → 보내기 | + +- 공통 — 같은 앱 한 벌(`/b07-cad`) · 갈래는 싣기 곁값(`readOnly` · `viewOnly` · `bare` · `hostSave` · `ribbonCollapsed` · `panelsCollapsed` · `originMarker` · `guide`)뿐 — `ui_template/cad_host/cad_host.ts:83-104` · `src/integration/aislo-drawing-bridge.ts:183-197`. +- 실을 때마다 SELECT 로 돌림(앞 도면 도구 내림) — `aislo-drawing-bridge.ts:197`. +- 부모 돋보기(Z 키)는 CAD 위에서 안 켬 — `py/test_221_1_loupe_cad.py`. + +### 9-2. 보기 전용에서 막히는 입력 + +| 입력 | readOnly(확정 · 권한 없음) | viewOnly(미리보기) | 근거 | +|---|---|---|---| +| 명령(리본 · 글 · 별칭) | 보는 명령만(보기 · 조회 · 도면층 보이기 · 내보내기 · COPYCLIP · COPYBASE · TOAREA) · 나머지 알림 | 같음 | `src/commands/registry.ts:60-98` · `run-command.ts:30-34` | +| 고르기(누름 · 상자) | 됨 | 안 됨(왼쪽 = 팬) | `input-controller.ts:191-194,319-324` | +| 스냅 · 스침 강조 · 커서 상자 | 됨 | 안 됨 | `input-controller.ts:275` · `draw.ts:75-78` · `main.tsx:89` | +| 휠 · 가운데 팬 · 화살표 | 됨 | 됨 | — | +| 글자 두 번 누름 | 막힘(알림) | 전체 맞춤 | `text-inplace-edit.ts:210-218` | +| 붙여넣기 · 파일 놓기 | 막힘 | 막힘 | `dxf-clip-paste.ts:70-74` · `run-command.ts:30-34` | +| 칸 모서리 끌기 | 막힘 | 막힘(못 고름) | `box-resize-drag.ts:67` | +| 우하단 ↶ ↷ 단추 | 숨김 | 숨김 | `ViewControls.tsx:14` | +| ⚠ 그립 끌기 | **안 막힘**(명령 길을 안 거침) | 못 고름 | `select-tool.helpers.ts:55-72` · `grip-edit-tool.ts:26-30` | +| ⚠ Delete · 손가락 🗑 | **안 막힘** | **Ctrl+A → Delete 로 안 막힘** | `input-controller.ts:438-450` · `select-tool.ts:73-76,167-171` | +| ⚠ 특성 칸 · 리본 특성 · 도면층 관리(옮김 · 층 지움) | **안 막힘** | 고른 것 없음 · 층 지움은 패널 보이면 됨(추정) | `PropertiesEditor.tsx:12-17` · `RibbonWidgets.tsx:39-47` · `LayerManager.tsx:41-60` | +| Ctrl+Z · Ctrl+Y | 안 막힘(위 새는 고침만 되돌림) | 같음 | `input-controller.ts:429-434` | + +- ⚠ 줄 = 코드 읽기 · 실측 필요 · 저장까지 가는지는 부모 [저장] 길 미확인 · 읽기 전용 판정 시험 = `src/state.test.ts:113-123`. + +## 10. 시험 파일 모음 + +| 시험 | 보는 것 | +|---|---| +| `cad/keyboard-chain.test.ts` | 331-1~4(각도 고정 · 시퀀스 첫 점 · MOVE · COPY · ROTATE 값 · LINE C · U · 한 번 스냅 · 동적 입력 Tab) | +| `src/inputController/ime-sink.test.ts` | 한글 IME · Space = Enter · 빈 Enter 반복 · 우클릭 반복 안 함 | +| `src/inputController/input-wheel.test.ts` · `cad/input-prefs-cad.test.ts` · `py/test_input_prefs.py` | 휠 줌 · 방향 설정 · 핀치 · 내 별칭 | +| `cad/command-catalog.test.ts` · `py/test_app_settings.py` | 명령 표 거울 · 별칭 검사 · 설정 창 | +| `cad/ctrl-shortcut-hangul.test.ts` | Ctrl 단축키 자판 자리 | +| `cad/grips.test.ts` | 그립 도우미(누르기 흐름 아님) | +| `cad/text-inplace-edit.test.ts` | 글자 두 번 누름 입력 칸 | +| `cad/dxf-clip.test.ts` · `py/test_b07_dxf_clip.py` | DXF 붙여넣기 · 끌어 놓기 | +| `py/test_cad_touch.py` | 손가락 단추 · 핀치 · 끌기 | +| `py/test_221_1_loupe_cad.py` · `py/test_cad_host_tabs.py` | 부모 쪽 돋보기 · 탭 | +| (없음) | 고르기 · F키 토글 · 그립 누르기 흐름 · 명령행 ↑ 기록 · 읽기 전용 새는 길 | From 5bf5e0c46eebea62b55483726b8b211ebbc313aa Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 16:24:23 +0900 Subject: [PATCH 33/36] =?UTF-8?q?chore(=EC=A1=B0=EC=82=AC):=20336=20?= =?UTF-8?q?=EC=99=B8=EB=B6=80=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=A7=9E=EC=B6=A4=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EA=B2=80=ED=86=A0=20=E2=80=94=20=EC=8B=A4?= =?UTF-8?q?=EC=B8=A1=20=EC=84=B8=20=ED=8C=8C=EC=9D=BC=20=C2=B7=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=EB=84=B7=20=C2=B7=20=EC=A0=95=ED=95=A0=20=EA=B2=83?= =?UTF-8?q?=20=C2=B7=20=EB=B6=80=EB=A1=9D=20=EB=91=98?= 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_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/조사/2026-10-15_336_부록1_실측.md | 299 ++++++++ .../raw/조사/2026-10-15_336_부록2_바깥방법.md | 696 ++++++++++++++++++ ...26-10-15_336_외부일위대가_이름맞춤_검토.md | 323 ++++++++ 3 files changed, 1318 insertions(+) create mode 100644 docs/raw/조사/2026-10-15_336_부록1_실측.md create mode 100644 docs/raw/조사/2026-10-15_336_부록2_바깥방법.md create mode 100644 docs/raw/조사/2026-10-15_336_외부일위대가_이름맞춤_검토.md diff --git a/docs/raw/조사/2026-10-15_336_부록1_실측.md b/docs/raw/조사/2026-10-15_336_부록1_실측.md new file mode 100644 index 000000000..fb17791b7 --- /dev/null +++ b/docs/raw/조사/2026-10-15_336_부록1_실측.md @@ -0,0 +1,299 @@ +# C1 외부 일위대가 이름 맞춤 실측 (요구 225 · 검토) + +M02 「외부 일위대가 가져오기」 맞춤을 실제 엑셀로 재고 실패 까닭 · 개선 방법 효과를 가늠한 기록. 코드 안 고침(읽기 · 스크래치패드 흉내만). +기준 = main_laptop_1 31e6adf7d · 우리 돈 로직 1,438(산림 300 · 건설 1,138) · 기억 없음(층 기억 빈 것)으로 잼. + +## 1. 결론 요약 + +1. 잰 자료 = 북부청 임도 · 사방(지금 올라가는 꼴) + 동부청 영월 2024(STmate 꼴 · 지금은 「일위대가 시트 없음」 400 → 스크래치패드에서 따로 읽어 같은 맞춤 함수에 넣음). 로직 줄 고유 136(줄 504) 전수 정답 표시 · 정답 있음 126 · 우리에 없음 10(7.4%). +2. 이름만(330 앞) 1순위 엄격 34.1% · 느슨 60.3% · 다섯 안 57.1 / 87.3% — 이름 문제 실재(같은 뜻 다른 말 · 산림/건설 책 갈래 · 레미콘 같은 줄임말이 주 까닭). +3. 지금(330 근거 맞춤 뒤) 1순위 71.4 / 81.7% · 다섯 안 86.5 / 94.4% — 엑셀 단가산출 조항 · 직종이 이름 문제 대부분을 덮음(+37pt). 줄 무게로는 1순위 느슨 78.1% · 다섯 안 느슨 97.9%. +4. 지금 남은 1순위 실패 36 줄(고유)의 주 까닭 = 책 갈래 11(「같음」 자동 대상이 엑셀 출처와 다른 책 10 · 이름 같음 덤이 근거를 이김 1) · 같은 뜻 다른 말 11(고임돌 · 조약돌 · 소운반 · 철골설치 · 측구수로관) · 우리 로직이 더 잘게 5(철근 = 가공 + 조립) · 그 밖 5(같은 이름 접힘 · 이름 같음 덤) · 장비 줄 2 · 복합 1 · 괄호 1. +5. 이름 쪽 손질(다듬기 · n-그램 · 낱말 차례 · 단위 거름망)은 효과 없거나 해로움. 사전(임도에서 16쌍)은 사방 +7.0 · 영월 −8.9(느슨 1위) → 한 파일로 만든 사전은 다른 꼴에 안 통함. +6. 효과 큰 것 = 근거 쪽 작은 고침 — 조항 윗마디(6-2 ⊃ 6-2-2) · 같은 이름 접힘 고침 · 조항 오타 → 1순위 77.8 / 88.1% · 여기에 「같음도 근거 검사(판 지킴)」 더하면 85.7 / 88.1% · 다섯 안 94.4 / 94.4%. +7. 금액(우리 값 ÷ 엑셀 단가)은 순위 점수로 넣으면 엄격 −4.7pt(복합 줄 · 입력 기본값) · 확인 표시로는 ±5% 안 1순위 21 중 20 맞음(95%). +8. 기억 — 지금 열쇠(품명|규격|단위)로는 같은 회사 다른 파일 재사용 고유 4/48 · 둘째 열쇠(품명|단위)면 15/48(모두 맞음) · 씨앗(사용자 실무 이름 80)이 영월 1순위 엄격 44.1 → 67.6. +9. 단추 정밀 — [근거 맞음 모두 확정] 고른 55 줄(고유) 모두 맞음(엄격 54) · [같음 모두 확정] 25 중 엄격 20(다른 책 5 · 느슨은 25). +10. 「다른 외부문서도 같음」의 실제 걸림돌 = 저장소 실무 내역 xlsx 6 개를 열어 보니 5 개가 STmate 꼴(일위대가표 · 단가산출근거 · D/M/L/X 코드) → 지금은 올리기부터 400 · 이름 맞춤 앞 단계 문제. + +## 2. 지표 표 + +고유 줄 = 같은 (품명 · 규격 · 단위 · 단산 번호) 한 번 셈(확정 한 번이면 같은 열쇠 줄 모두 기억으로 들어감). 칸 = 엄격 / 느슨(%). 엄격 = 엑셀 조항 · 품과 같은 우리 로직 · 느슨 = 같은 일의 다른 책 로직 · 복합의 곁 몫까지. 정답 있는 줄만 분모 · 「우리에 없음」은 따로. +\* 영월 = STmate 꼴을 스크래치패드에서 읽은 가정(지금 화면은 400). 「지금」 · 「추천 결합」은 기억 없음 · 씨앗 켬. + +| 파일 | 고유 줄(줄) | 정답 있음 | 우리에 없음(줄) | 이름만 1위 | 이름만 5안 | 지금 1위 | 지금 5안 | 추천 결합 1위 | 추천 결합 5안 | +|---|---|---|---|---|---|---|---|---|---| +| 북부청 임도 | 52(271) | 49 | 3(6) · 5.8% | 26.5 / 57.1 | 55.1 / 89.8 | 71.4 / 85.7 | 83.7 / 98.0 | 91.8 / 91.8 | 98.0 / 98.0 | +| 북부청 사방 | 48(142) | 43 | 5(22) · 10.4% | 34.9 / 62.8 | 55.8 / 88.4 | 74.4 / 83.7 | 88.4 / 95.3 | 86.0 / 88.4 | 95.3 / 95.3 | +| 동부청 영월* | 36(91) | 34 | 2(5) · 5.6% | 44.1 / 61.8 | 61.8 / 82.4 | 67.6 / 73.5 | 88.2 / 88.2 | 76.5 / 82.4 | 88.2 / 88.2 | +| 세 파일 | 136(504) | 126 | 10(33) · 7.4% | 34.1 / 60.3 | 57.1 / 87.3 | 71.4 / 81.7 | 86.5 / 94.4 | 85.7 / 88.1 | 94.4 / 94.4 | + +- 추천 결합 = ⑥ 조항 윗마디 + ⑬ 같은 이름 접힘 고침 + 조항 오타(13-114 → 13-11-4) + ⑪ 같음도 근거 검사(판 지킴). +- 우리에 없음 10 줄 중 지금 엉뚱 후보가 보이는 줄 5(고화매트 → 식생매트 · 잔토정리 → 타설후 정리 · 철강재운반 → 덤프 자재 운반 · 철골공 → 철골 공장용접 · 영월 잔토처리 → 터널굴착 1발파당 인력). +- 줄 무게(같은 줄 되풀이 셈) — 이름만 1위 느슨 56.1 · 지금 78.1 · 추천 결합 85.6 · 다섯 안 느슨 90.0 · 97.9 · 97.9. +- 앞서 잰 「1순위 근거 맞음 123/201」은 근거 꼬리표 기준(정답 대조 아님) — 이번 고유 줄 지금 [근거 맞음] 고름 임도 26/41 · 사방 22/40 과 같은 결. +- 자재 · 별도계상 줄(북부청 임도 142 · 사방 56)은 정답 표시 밖 — 상태만: 임도 자재 같음 12 · 후보 50 · 없음 16 / 별도계상 후보 53 · 없음 11. + +## 3. 실패 까닭 갈래 표 + +1순위 엄격 실패 줄(고유)마다 주 갈래 하나 + 곁 갈래(겹쳐 셈). 칸 = 주 갈래 고유(줄) · 곁 갈래 수. 이름만 = 330 앞 · 씨앗 끔(실패 83 · 줄 345) / 지금 = 330 뒤(실패 36 · 줄 162). + +| 갈래 | 이름만 실패 | 지금 실패 | 예 | +|---|---|---|---| +| 같은 뜻 다른 말 | 25(88) · 곁 9 | 11(46) · 곁 0 | 휠라설치 → 신축이음 채움재 설치 · 구조물터파기(인력100%) → 굴착(인력/토사) · 고임돌 · 조약돌 채집 → 모래·자갈·약돌 채집(1위 궤도 자갈채집) | +| 줄임말 | 9(51) · 곁 2 | 0(0) · 곁 0 | 레미콘타설(장비사용) → 레디믹스트콘크리트 타설(1위 펌프카 타설) · 배수관부설및접합(P.V.C) → PVC관 고무링 접합 및 부설 · B/H 90%(백호 = 굴착기) | +| 낱말 차례 | 0(0) · 곁 10 | 0(0) · 곁 0 | 철근가공조립(현장) ↔ 철근 현장가공 및 조립 · 큰돌찰붙임 ↔ 큰돌붙이기 찰붙이기 · (곁 갈래만 · 주 까닭 아님) | +| 괄호 속 규격 | 4(23) · 곁 3 | 1(18) · 곁 0 | 큰돌쌓기(찰쌓기) → 1위 메쌓기(괄호 「찰쌓기」 버림) · 구조물터파기(토사) → 1위 터파기(기계) 암 · 구조물터파기(암) → 1위 육상토사 | +| 비율 표기(인력100%·9:1) | 0(0) · 곁 1 | 0(0) · 곁 0 | 구조물터파기(인력100%) · 고임돌채집및운반(9:1) · (곁 갈래만 — 비율 글은 지금도 낱말로 안 셈) | +| 단위 표기(hr·공/㎥·개소) | 0(0) · 곁 7 | 0(0) · 곁 5 | 대형브레이카 hr ↔ 시간 · 강관동바리 공/㎥ ↔ 공㎥ · 도로반사경 개소 ↔ 본(환산 「입력」) | +| 장비 · 노임 이름 줄 | 2(2) · 곁 0 | 2(2) · 곁 0 | 콘크리트 펌프차 41m [hr] → 기계 시간당 기계경비(기계 입력) · 크레인(타이어) 10톤 [시간] · 철골공 · 특별인부 [인] = 노임(우리에 없음) | +| 책 갈래(산림↔건설) | 29(122) · 곁 3 | 11(53) · 곁 0 | 합판거푸집 → 같음 산림 12-4(엑셀은 건설 6-3-1) · 강관비계 · 강관동바리 → 같음 산림(엑셀 건설 2-7-1 · 2-6-1) · 큰돌메붙임 → 1위 건설 메붙임(굴착기+집게)(엑셀 산림 13-7-1) | +| 우리 로직이 더 잘게/굵게 | 6(33) · 곁 0 | 5(27) · 곁 1 | 철근가공조립(현장) = 우리 현장가공 + 현장조립 둘 · 잔토처리(인력) = 우리 굴착(인력) 입력 「잔토처리」에 묶임 · 원형돌망태 조립설치 · 채움 = 우리 원형 돌망태 하나 | +| 엑셀 복합 호표 | 1(1) · 곁 21 | 1(1) · 곁 7 | 레미콘타설 + 굴착기 시간 · 고임돌 = 채집 + 상차 + 운반 · 도장(철재면2회) = 바탕 + 녹막이 + 유성 셋 | +| 근거 조항 비어 있음 · 엉뚱 | 0(0) · 곁 6 | 0(0) · 곁 6 | 철골설치(출처 없음) · 조약돌채집운반(9:1)(출처 없음) · 고임돌 「1-10」 = 단위중량 인용(작업 조항 아님) | +| 품셈 판 차이(번호 바뀜) | 1(6) · 곁 3 | 0(0) · 곁 9 | 철근 「건설 6-2」 = 옛 한 조항 → 지금 6-2-2 · 6-2-3 · 야면석채집 「건설 8-2 (2016 삭제)」 · 영월 「건설 3-1-3 · 9-3 · 1-9-3 · 임도품셈 5-5」 옛 번호 | +| 그 밖(오타 · 비슷한 이름 함정 · 접힘) | 6(19) · 곁 14 | 5(15) · 곁 3 | 흙깍기(오타 깍/깎) → 후보 없음 · 원형돌망태 → 1위 타원형(원형 ⊂ 타원형) · 유로폼 설치,해체 → 같은 이름 산림 · 건설 둘이 이름 후보에서 하나로 접힘 | + +- 이름만 실패의 65%(54/83)가 같은 뜻 다른 말 · 책 갈래 — 330 근거가 이 둘을 대부분 풂(책 갈래 29 → 11 · 같은 뜻 25 → 11). +- 지금 책 갈래 11 = 「같음」(이름 같음 · 씨앗) 자동 대상이 엑셀 출처와 다른 책 10(산림 로직 ↔ 엑셀 건설 조항 9 · 반대 1 = 돌망태형 옹벽) + 이름 같음 덤(+1)이 근거 점수를 이김 1(영월 합판거푸집(4회)) — 같음에도 근거 검사를 하면 10 이 풀림(⑪). +- 지금 같은 뜻 다른 말 11 = 근거로도 못 품 — 엑셀 출처가 비었거나(사방 철골설치 · 조약돌 9:1) · 단위중량 인용(고임돌 「1-10」) · 옛 판 번호(영월 9-1 · 9-3) · 우리에 그 책 조항 없음(건설 1-2-7 인력운반). + +## 4. 방법별 효과 표 + +세 파일 정답 있는 126 줄(고유) · 칸 = 1위 엄격 / 느슨 · 5안 엄격 / 느슨(%). 「이름만에」 = 330 앞 맞춤에 얹음 · 「지금에」 = 330 맞춤에 얹음. 흉내는 스크래치패드에서 함수를 바꿔 끼워 같은 줄을 다시 돌림. + +| 방법 | 1위 | 5안 | 들 일 | 위험 · 비고 | +|---|---|---|---|---| +| 기준 ① 이름만(330 앞 · 씨앗 끔) | 34.1 / 60.3 | 57.1 / 87.3 | — | — | +| 기준 ② 지금(330 뒤) | 71.4 / 81.7 | 86.5 / 94.4 | — | — | +| ① 이름 다듬기(괄호 낱말 살림 · 수 · 단위 · 규격 조각 뺌) — 이름만에 | 30.2 / 53.2 | 59.5 / 88.1 | 작음 | 괄호 속 규격이 엉뚱 후보를 끌어옴 · 느슨 1위 −7.1 | +| ① 이름 다듬기 — 지금에 | 70.6 / 81.7 | 86.5 / 95.2 | 작음 | 영월 −2.9 · 사방 터파기(토사)만 나아짐 | +| ② 같은 뜻 말 사전(임도 파일에서 16쌍) — 이름만에 | 34.1 / 65.1 | 59.5 / 87.3 | 작음(사전) · 키우기 큼 | 사방(처음 봄) 느슨 1위 62.8→69.8 · 영월(다른 꼴) 61.8→52.9 | +| ② 같은 뜻 말 사전 — 지금에 | 64.3 / 81.0 | 86.5 / 94.4 | 작음 | 레미콘 → 산림 GF000155 로 넘어감(이름 같음 덤 + 산림 덤) · 엄격 −7.1 | +| ③ 낱말 차례 무시(뜻 낱말 겹침 덤) — 이름만에 | 37.3 / 59.5 | 65.9 / 81.7 | 작음 | 다섯 안 느슨 −5.6 · 한국어 붙여 쓴 이름이라 효과 적음 | +| ④ 글자 1·2·3그램 — 이름만에 | 34.1 / 59.5 | 57.1 / 87.3 | 작음 | 변화 거의 없음 | +| ④ difflib(가장 긴 같은 조각) — 이름만에 | 35.7 / 54.0 | 73.0 / 86.5 | 작음 | 다섯 안 엄격 ↑ · 느슨 ↓ · 없음 줄 엉뚱 후보 4→10 | +| ⑤ 단위 거름망(단위 안 맞으면 뺌) — 지금에 | 63.5 / 73.8 | 78.6 / 86.5 | 작음 | 해로움 — 맞는 짝 단위가 달라(개소↔M · 개↔㎡ · 조↔㎡ · hr↔시간) 1위 엄격 −7.9 | +| ⑥ 근거 조항 윗마디(엑셀 6-2 ⊃ 우리 6-2-2) — 지금에 | 75.4 / 85.7 | 86.5 / 94.4 | 작음(_clause 몇 줄) | 낮음 — 윗마디 = 0.9 · 직종 · 낱말 거름 그대로 | +| ⑦ 직종 · 장비 구성 겹침(조항 없이도) — 지금에 | 71.4 / 82.5 | 85.7 / 94.4 | 중간 | 효과 작음 — 직종은 이미 근거 점수에 듦 | +| ⑧ 금액(우리 값 ÷ 엑셀 단가 ±15% 덤) — 지금에 | 66.7 / 77.8 | 86.5 / 94.4 | 중간 | 해로움 — 복합 줄 · 입력 기본값 탓 · 확인 표시로는 ±5% 안 1순위 21 중 20 맞음 | +| ⑪ 같음도 근거 검사(판 지킴 없이) — 지금에 | 77.0 / 80.2 | 93.7 / 94.4 | 중간 | 영월 −5.8 — 옛 판 1-9-3 이 지금 낙석방지책으로 걸림 | +| ⑪ 같음도 근거 검사(판 지킴: 옛 판 글이면 안 내림 · 직종/뜻 낱말 요구) | 78.6 / 81.7 | 93.7 / 94.4 | 중간 | 낮음 — 같음 → 후보로 바뀌어 사람 확정 한 번 더 | +| ⑫ 블록 글 낱말(우리 이름 낱말이 엑셀 산출 글에 나오면 덤) | 69.8 / 84.1 | 86.5 / 95.2 | 중간 | 섞임 — 느슨 ↑ 엄격 ↓(블록에 단위중량 · 자재 이름이 섞임) | +| ⑬ 같은 이름 접힘 고침(산림·건설 같은 이름을 하나로 접던 것) | 73.8 / 84.1 | 86.5 / 94.4 | 작음(중복 빼기 열쇠) | 다섯 안에 같은 이름 둘 → 화면에 책 표시 필요 | +| ⑭ 부문(공통 · 토목 글로 부문 가름) | 75.4 / 85.7 | 86.5 / 94.4 | 작음 | 이 표본에선 변화 0 | +| 책 맞춤 덤(엑셀 출처 책 = 우리 책 +0.2) | 73.8 / 82.5 | 86.5 / 94.4 | 작음 | 섞임 — 영월 +11.8 · 임도 −2.0(건설 삭제 조항 · 산림에만 있는 일) | +| 결합 ⑥ + ⑬ + 조항 오타 | 77.8 / 88.1 | 86.5 / 94.4 | 작음 | 낮음 | +| 결합 ⑥ + ⑬ + 조항 오타 + ⑪(판 지킴) ← 추천 | 85.7 / 88.1 | 94.4 / 94.4 | 작음~중간 | 낮음 — 같음 11 줄이 후보로 바뀜(사람 확정 한 번 더) | +| 결합 + ① 다듬기 | 85.7 / 88.9 | 94.4 / 95.2 | 중간 | 영월 기초잡석 · 큰돌 채집 둘 뒤집힘 | +| ⑨ 맞춤 기억 재사용(임도 정답 → 사방) | 지금 열쇠 4/48 · 품명\|단위 15/48(맞음 15) · 정리이름\|단위 24/48(맞음 21) | 사방 → 임도 4/52 · 18/52(18) · 23/52(22) | 중간 | 정리이름 열쇠는 괄호 낱말을 버려 엉뚱 3(구조물터파기(토사) ↔ (인력100%)) → 품명\|단위가 알맞음 | +| ⑨' 씨앗(사용자 실무 이름 80 = 기억의 한 꼴) | 영월 1위 엄격 44.1 → 67.6 · 느슨 55.9 → 73.5 | 영월 79.4 → 88.2 | — | 임도 · 사방은 씨앗 「면고르기 · 문양거푸집 → 산림」 탓에 엄격 −4.1 · −2.3 | +| 단위 별칭(hr=시간 · 공/㎥=공㎥ · 개소=개=본) | 순위 변화 거의 없음 | — | 작음 | 정답 짝 환산 「입력」 20 → 14 줄 | +| 장비 줄 길(갈래 기계 · 단위 시간 → 「기계 시간당 기계경비」 + 기계 입력) | 흉내 안 함 · 남은 실패 2 줄(펌프차 · 크레인) 풂 예상 | — | 작음 | 낮음 | +| 임베딩(한국어 문장 벡터) 재순위 | 기대 — 같은 뜻 다른 말 일부(터파기/굴착 · 레미콘/레디믹스트) | — | 중간~큼(새 패키지 · 모델 · 미리 셈) | 휠라 · 고임돌 · 야면석 같은 현장말은 모를 가능성 · 산림/건설 같은 이름은 벡터도 같아 책 가름 못 함 → 근거와 함께만 | +| 큰 언어 모델 재순위(후보 20 + 엑셀 산출 글 + 우리 호표 · 출처) | 기대 — 남은 실패(복합 · 판 차이 · 같은 뜻)의 대부분 | — | 중간(길 · 열쇠) · 비용 줄당 수 원 | 외부 전송(실무 자료) · 매번 다른 답 · 없는 키 지어냄 → 후보 목록 안에서만 고르게 · 근거 실패 줄(약 15%)에만 · 사람 확정 그대로 | + +## 5. 추천 차례 — 먼저 할 것 셋 + +1. **근거를 이름보다 앞세우는 서버 고침 넷**(들 일 작음~중간 · 위험 낮음) — `M02_Hopyo_Basis._clause` 윗마디(엑셀 6-2 → 우리 6-2-2 · 6-2-3) · `M02_Hopyo_Match.candidates` 접힘 열쇠에 로직 키 · 조항 오타(13-114) · 같음일 때도 근거 검사(엑셀 글이 옛 판 「24년 · 임도품셈 · 사방품셈」이면 안 내림 · 근거 후보가 직종 또는 뜻 낱말을 나눌 때만) → 1순위 엄격 71.4 → 85.7% · 다섯 안 엄격 86.5 → 94.4%. 덤으로 장비 줄 길(기계 갈래 → 기계 시간당 기계경비 + 기계 입력)과 단위 별칭(hr · 공/㎥ · 개소). +2. **고르기 창 손질**(중간 · 부록 A) — 후보마다 책 · 조항 · 우리 값 ÷ 엑셀 단가(±5% 표시) · 엑셀 산출 글의 직종 · 품과 우리 호표를 나란히 · 같음 줄에도 근거 후보 · [같음 모두 확정]에서 근거 어긋남 빼기 · [다른 로직 찾기]에 엑셀 조항 미리 넣기. 다섯 안 정답이 94%라 남은 일은 「사람이 빨리 고르기」. +3. **기억 넓히기**(중간) — 확정 때 둘째 열쇠(품명|단위)를 「기억 후보」로 · 확정한 엑셀 이름을 층 별칭으로 남겨 이름 후보 · 찾기에 씀(손 사전 대신 사용자 확정이 사전이 됨) → 같은 회사 다음 파일 4/48 → 15/48 · 씨앗 효과(영월 +23.5pt)와 같은 결. + +- 별도로 알아 둘 것 — 다른 외부문서 대부분(STmate 꼴)은 지금 올리기부터 400. 「다른 문서에서도 이름이 다름」을 풀려면 STmate 읽기(일위대가표 · 단가산출근거 · D/L/X 코드로 줄 ↔ 블록 · 노임 · 장비 바로 이음)가 먼저 — 코드가 있어 이름 · 블록 잇기는 북부청 꼴보다 쉬움. +- 손 사전 · 금액 순위 점수 · 단위 거름망 · n-그램은 하지 않음(위 표에서 해로움 · 효과 없음). + +## 부록 A. 화면 사용 흐름 — 사람이 고르기 어렵게 만드는 것(추천 2 의 근거) + +- 후보 단추 글 = 「이름 [단위] · 키」 뿐(View.ts:446-452 · targetText :86-91) — 이름 후보에는 책 · 조항이 안 보여 같은 이름 산림 · 건설(유로폼 설치 및 해체 GF000210 · GC000214)을 구별 못 함. +- 점수 · 까닭은 마우스 올림(title)에만(View.ts:459) · 초록 「근거 맞음」은 조항 같음 + 단위 + 직종 셋 다일 때만(basisOk :122-126) — 윗마디 조항(6-2)이면 늘 회색. +- 「같음」 줄은 서버가 후보를 안 만듦(Match.py:395-403) → 고르기 창에 「가까운 것 없음」만(View.ts:499) · 다른 책으로 바꾸려면 [다른 로직 찾기…]로 이름을 다시 쳐야 함. +- [같음 모두 확정](View.ts:276-282)은 근거를 안 봄 — 이번 표본 25 중 5 가 엑셀과 다른 책. +- 엑셀 근거는 한 줄 요약(출처 · 직종 · 행 · View.ts:464-487) — 계산식(품 · 수량 · 장비)과 우리 로직 호표를 나란히 못 봄 · [엑셀에서 보기]로 창을 하나 더 엶. +- 후보마다 우리 값(엑셀 단위로 환산) ÷ 엑셀 단가가 없음 — ±5% 안이면 95% 맞는 신호인데 안 씀. +- [다른 로직 찾기…](View.ts:488-496)는 고르기 창을 닫고 빈 찾기 창을 엶 — 찾기 칸은 원문번호(조항)로도 걸러지지만(ui_template_logic_find.ts:44-53) 엑셀 조항을 미리 넣어 주지 않음 · 안내 없음. +- 표 「대상」 칸(View.ts:424)도 키 · 책 없이 이름만 — 같은 이름 둘이면 무엇을 골랐는지 안 보임. +- 확정 열쇠 = 품명|규격|단위 정확히 같을 때만(Match.py:68-76) · M01 로직 별칭(208-1)은 로직 1,438 중 0 — 확정한 엑셀 이름이 다음 파일 · 찾기 창에 안 쓰임. + +## 부록 B. 맞춤 흐름(파일:줄) + +- 올리기 — 화면 `M02_MasterTemplete_Hopyo_Side.ts` → `Hopyo_Api.ts:219-231 uploadHopyo` → `M02_Labor_Router.py:206 POST /layers/{층}/hopyo` → `_upload :174-184` → `M02_Hopyo_Excel.read_book :211-279`(`_sheets :74-102` 「일위대가 · 일위대가총괄표 · 단가산출」 세 시트만 · 없으면 400 `NO_SHEET :32` · 호표 · 줄 · `kind_of :132-149` 갈래 · 단산N참조 줄에 `Basis.read :84-125` 블록 근거 `{출처, 직종, 행}` :257-261) → `Grid.read` → `Store.save :78`(맞춤은 저장 안 함 · 열 때마다 셈). +- 호표 열기 — `Hopyo_View.ts drawOne :261-344` → `Api readHopyoOne :180-181` → `Router :243-252 GET …/hopyo/{key}/{no}` → `Match.view :460-474` → `match_hopyo :440-453` → `match_line :367-414`. +- `match_line` 차례 — % · 빼기 · 일위N(호표) → 층 기억 `recall :348-350`(열쇠 `line_key :68-76` = 품명|규격|단위) → 이름 같음 · 씨앗 `_exact :286-297`(`_seeds :266-278` 일위대가조합.json 메모) → 하나면 「같음」 끝(:395-403 · 후보 안 만듦) → 아니면 근거 후보 `Basis.candidates :191-235`(`_clause :166-176` 조항 같음 1.0 · 앞 마디 0.5 · 건설 공통/토목 0.1 · 직종 겹침 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 :213-214) + 이름 후보 `Match.candidates :300-329`(정리 이름 `tidy :79-84` 괄호 글 버림 · 글자 두 개 묶음 dice · 정리 이름 같음 +1 · 단위 0.15 · 산림 0.05 · 자재 규격 0.5 · 뜻 낱말 거름 `near :103-105` · (이름 · 규격 · 단위) 같으면 하나로 접음 :320-325) → `Basis.merge :238-256`(로직 키로 점수 합 · 낱말 안 겹친 근거 후보 뒤로) → `Basis.pick :259-266`(이름 1순위 한 자리) → 후보 5. +- 출처 읽기 — `Basis.sources :60-71`(「…품셈」 뒤 20 글자 안 절 번호 · 앞 글에 산림 · 임도 = 산림품셈 · 그 밖 건설품셈 — 부문 · 연도 버림) · 우리 쪽 조항 `M02_Labor_Inputs.sources :68-85` · `_NUMBER :20`. +- 고르기 · 확정 — `View choose :435-505` → `guessHopyo`(Api :183-193) → `Router :263-281` → `Match.guess :417-428`(입력값 · 환산) → [확정] `saveHopyoMemory` → `Router :283-304` → `Match.confirm :495-526`(층 기억). 단추 [같음 모두 확정] `View :276-282` · [근거 맞음 모두 확정] `View :284-291`(basisOk :122-126 · basisPick :153-158). + +## 부록 C. 실제 자료 + +- `resources/knowledge/original/실무문서/2026_실무일위대가 모음/` — `2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls`(호표 68 · 줄 415 · 로직 줄 고유 52) · `2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls`(호표 27 · 줄 201 · 고유 48) — 시험 `resources/tester/test_m02_hopyo_excel.py:26-30` 이 씀. 같은 폴더 `2016_상반_…사방사업표준품셈(안).xls` · `2026_상반_…수목진료.xls` = 일위대가 시트 없음(400). +- `resources/knowledge/original/실무문서/05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx` — STmate 꼴(일위대가표 211행 · 단가산출근거 5,604행) · D 코드 줄 고유 36(줄 91). 같은 꼴 = 간선임도 기번3 · 울진 소광 · 계류보전 영덕 · 봉화 현동 내역 xlsx(시트 이름 확인) · 거창은 또 다른 꼴. +- 우리 로직 = `resources/master_data/로직_*.json`(M01 `loaded()` 읽기만) · 씨앗 = `resources/master_data/일위대가조합.json` 메모 80. +- tmp 안 실무 엑셀 없음. + +## 부록 D. 정답 표시 근거(고유 줄 136 · 전수) + +칸 「지금」 = 지금 맞춤 상태 · 1순위 키 · 표시(✓ 엄격 맞음 · △ 허용 맞음 · 5안 = 다섯 안에만 · ✗). N = 북부청 임도 · S = 사방 · Y = 영월. 「짐작」 = 블록에 출처가 없거나 우리 로직이 일부만 덮어 사람 판단이 섞임. 영월 「씨앗」 줄은 사용자 실무 이름 → 로직(일위대가조합.json)을 정답으로 봄. + +| id | 엑셀 품명 · 규격 [단위] ×줄 | 정답(엄격) · 허용 | 근거 | 지금 | +|---|---|---|---|---| +| N000 | 레미콘타설(장비사용) · 철근구조물-소량 타설 [㎥] ×13 | GC000198 · 허용 GF000155 | 건설 6-1-1 콘크리트공 3 · 보통인부 1 ÷ 일 시공량(+소량 ×2) + 굴착기 시간 — GC000198 6-1-1(타설방법 · 소량감률 · 굴착기 입력) | 후보 GC000198 ✓ | +| N001 | 합판거푸집 · 4회 [㎡] ×11 | GC000211 · 허용 GF000160 | 건설 6-3-1 형틀목공 5 · 보통인부 2 ÷ 일 시공량 = GC000211 6-3-1 · 이름 같음 GF000160 은 산림 12-4 | 같음 GF000160 △(허용) | +| N002 | 기초잡석채집,운반,다짐 · 발파암활용,덤프15톤,L=500m [㎥] ×18 | GC000110 · GF000168 · 짐작 | 채집 · 적사 · 운반 + 기초지정 건설 3-4-8 = GC000110(부분) · 산림 12-12(옛 번호) → 지금 12-25 기초잡석 GF000168 | 후보 GC000110 ✓ | +| N003 | 철근가공조립(현장) · Type-Ⅰ(소량 시공) [Ton] ×12 | GC000206 · GC000207 · 허용 GF000159 | 건설 6-2 Type 가공(철근공 0.67 · 보통 0.22) + 조립 — 우리 6-2-2 가공 · 6-2-3 조립 둘(복합) · 산림 12-3 GF000159 같은 일 · 이름 1위 GF000187 은 산림 12-18 복잡 고정 4.2인(다른 품) | 후보 GF000187 5안 | +| N004 | 야면석채집 및 운반(계곡내) · 굴삭기0.6㎥,35cm,4.5톤,L=500m [㎡] ×1 | GF000214 · 짐작 | 인력 10% 보통인부 0.17인/㎡(= 산림 13-2-4 뒷길이 35㎝) + 굴착기 채집 + 운반 — GF000214 은 인력 채집 몫(부분) · 건설 8-2 는 2016 삭제 | 후보 GF000214 ✓ | +| N005 | 고임돌 채집 및 운반 · 굴삭기0.6㎥(90%),4.5톤,L=500m [㎥] ×1 | GF000211 · 허용 GF000212 · 짐작 | 채집(기계 90 · 인력 10% 보통인부 0.3인/㎥ = 산림 13-2-1 막자갈) + 상차 + 운반 — GF000211 은 인력 채집 몫만 · 출처 1-10 은 단위중량 인용 · 1위 GC000363 은 궤도 자갈(토목 4-6-1) | 후보 GC000363 5안 | +| N006 | 야면석소운반 · L=35cm [㎡] ×3 | GF000280 · 허용 GF000279 · 짐작 | 건설 1-2-7 인력운반 공식(575㎏/㎡ ÷ 25㎏ ÷ 42회) — 우리 건설 1-2-7 없음 · 산림 10-6-2 인력운반 돌(지게) 같은 일(㎡ ↔ ㎥) | 후보 GF000214 ✗ | +| N007 | 야면석찰붙임 · L3=35cm적용,장비사용 [㎡] ×7 | GF000225 · 허용 GC000263 | 산림 13-5-2 석공 0.11 · 보통 0.04 + 굴착기 집게 = GF000225 돌붙임 찰붙임(장비) | 후보 GF000225 ✓ | +| N008 | 구조물터파기(인력100%) · 토사 [㎥] ×4 | GC000083 · 허용 GF000101 | 건설 3-2-1 특별인부 (3.6+2.7+2.2)/3 = GC000083 굴착(인력/토사) | 후보 GC000083 ✓ | +| N009 | 구조물되메우기(인력100%) [㎥] ×4 | GF000113 | 산림 9-14 인력품 보통인부 0.1인 = GF000113 되메우기(9-14-1) | 후보 GF000113 ✓ | +| N010 | 잔토처리(인력100%) [㎥] ×4 | 우리에 없음 · 부분 GC000083 | 보통인부 0.2인/㎥ = 건설 3-2-1 비고 잔토처리 — 우리 단독 로직 없음(GC000083 입력 「잔토처리」로 굴착과 묶임) | 없음 — 없음 줄 · 후보 없음 | +| N011 | 야면석채집및운반(계곡내) · 굴삭기0.6㎥,35cm,4.5톤,L=500m [㎡] ×7 | GF000214 · 짐작 | 인력 10% 보통인부 0.17인/㎡(= 산림 13-2-4 뒷길이 35㎝) + 굴착기 채집 + 운반 — GF000214 은 인력 채집 몫(부분) · 건설 8-2 는 2016 삭제 | 후보 GF000214 ✓ | +| N012 | 고임돌채집및운반 · 굴삭기0.6㎥(90%),4.5톤,L=500m [㎥] ×17 | GF000211 · 허용 GF000212 · 짐작 | 채집(기계 90 · 인력 10% 보통인부 0.3인/㎥ = 산림 13-2-1 막자갈) + 상차 + 운반 — GF000211 은 인력 채집 몫만 · 출처 1-10 은 단위중량 인용 · 1위 GC000363 은 궤도 자갈(토목 4-6-1) | 후보 GC000363 5안 | +| N013 | 레미콘타설(장비사용) · 무근구조물 [㎥] ×13 | GC000198 · 허용 GF000155 | 건설 6-1-1 콘크리트공 3 · 보통인부 1 ÷ 일 시공량(+소량 ×2) + 굴착기 시간 — GC000198 6-1-1(타설방법 · 소량감률 · 굴착기 입력) | 후보 GC000198 ✓ | +| N014 | 유로폼 · 보통,0~7m [㎡] ×13 | GC000214 · 허용 GF000210 | 건설 6-3-3 형틀목공 4 · 보통 1 ÷ 35~40 = GC000214 | 같음 GC000214 ✓ | +| N015 | 문양거푸집 [㎡] ×4 | GC000215 · 허용 GF000161 | 건설 6-3-4 형틀목공 0.07 · 보통 0.03 = GC000215 · 이름 같음 GF000161 은 산림 12-5 | 같음 GF000161 △(허용) | +| N016 | P.V.C파이프설치 · Φ50mm [M] ×11 | GF000192 | 산림 12-22 재료비 + 설치비 5% = GF000192 P.V.C 파이프 설치(개소 ↔ M) | 후보 GF000192 ✓ | +| N017 | 다웰바설치 · Φ32×1000 [EA] ×4 | GC000226 · 허용 GF000201 | 건설 6-3-10 다웰바 형틀목공 0.043 = GC000226 · 산림 12-30-1 GF000201(D32 · L1000) 같은 일 | 후보 GC000226 ✓ | +| N018 | 휠라설치 · T=15mm,1000×1500mm [㎡] ×4 | GC000227 | 건설 6-3-10 휠라(채움재) 형틀목공 0.029 = GC000227 신축이음 채움재 설치 | 후보 GC000227 ✓ | +| N019 | 철근가공조립(현장) · 연결철근,Type-Ⅰ×30%적용 [Ton] ×4 | GC000206 · GC000207 · 허용 GF000159 | 건설 6-2 Type 가공(철근공 0.67 · 보통 0.22) + 조립 — 우리 6-2-2 가공 · 6-2-3 조립 둘(복합) · 산림 12-3 GF000159 같은 일 · 이름 1위 GF000187 은 산림 12-18 복잡 고정 4.2인(다른 품) | 후보 GF000187 5안 | +| N020 | 큰돌채집운반 · 공사중 발생석 활용 50%적용 [㎡] ×4 | GF000213 | 산림 13-2-3 작업반장 0.026인/개 × 4개/㎡ × 75% × 50 · 40% + 굴착기 = GF000213 큰돌 채집(기계)(개 ↔ ㎡) | 후보 GF000213 ✓ | +| N021 | 큰돌쌓기(메쌓기) · 직경40cm이상~60cm미만 [㎡] ×2 | GF000226 · 허용 GC000260 | 산림 13-6-1 = GF000226 큰돌쌓기 메쌓기 | 후보 GF000226 ✓ | +| N022 | 뒷채움돌채집운반뒷채움(발파) · 굴삭기0.6㎥(90%),4.5톤,L=500m [㎥] ×11 | GC000106 · 허용 GF000218 · 짐작 | 채집(9:1) · 운반 + 뒷채움 및 다짐 건설 3-4-4(소형장비) = GC000106(부분) | 후보 GC000106 ✓ | +| N023 | 큰돌쌓기(찰쌓기)_배수공 · 직경40cm이상~60cm미만 [㎡] ×2 | GF000227 | 산림 13-6-2 = GF000227 큰돌쌓기 찰쌓기 | 후보 GF000227 ✓ | +| N024 | 레미콘타설(인력운반) · 무근구조물-소량 타설 [㎥] ×3 | GC000198 · 허용 GF000155 | 건설 6-1-1 콘크리트공 3 · 보통인부 1 ÷ 일 시공량(+소량 ×2) + 굴착기 시간 — GC000198 6-1-1(타설방법 · 소량감률 · 굴착기 입력) | 후보 GC000198 ✓ | +| N025 | 합판거푸집 · 6회 [㎡] ×12 | GC000211 · 허용 GF000160 | 건설 6-3-1 형틀목공 5 · 보통인부 2 ÷ 일 시공량 = GC000211 6-3-1 · 이름 같음 GF000160 은 산림 12-4 | 같음 GF000160 △(허용) | +| N026 | 도로반사경설치 · D=1000(STS) [개소] ×1 | GC000310 | 건설 1-8-3 = GC000310 도로반사경 설치(본 ↔ 개소) | 같음 GC000310 ✓ | +| N027 | 야면석메붙임 · L3 =35cm내외,장비사용 [㎡] ×1 | GF000224 · 허용 GC000262 | 산림 13-5-2 석공 0.13 · 보통 0.04 = GF000224 돌붙임 메붙임(장비) | 후보 GF000224 ✓ | +| N028 | 큰돌메붙임 · 직경40cm이상~60cm미만 [㎡] ×2 | GF000229 · 허용 GC000262 | 산림 13-7-1 = GF000229 큰돌붙이기 메붙이기 | 후보 GF000229 ✓ | +| N029 | 큰돌찰붙임 · 직경40cm이상~60cm미만 [㎡] ×4 | GF000230 · 허용 GC000263 | 산림 13-7-2 = GF000230 큰돌붙이기 찰붙이기 | 후보 GF000230 ✓ | +| N030 | 레미콘타설(장비사용) · 소형구조물 [㎥] ×2 | GC000198 · 허용 GF000155 | 건설 6-1-1 콘크리트공 3 · 보통인부 1 ÷ 일 시공량(+소량 ×2) + 굴착기 시간 — GC000198 6-1-1(타설방법 · 소량감률 · 굴착기 입력) | 후보 GC000198 ✓ | +| N031 | 야면석찰쌓기 · L3=35cm적용,장비사용 [㎡] ×2 | GF000222 · 허용 GC000261 | 산림 13-4-5 석공 0.09 · 보통 0.05 = GF000222 돌쌓기 찰쌓기(장비) | 후보 GF000222 ✓ | +| N032 | 면고르기 [㎡] ×4 | GC000111 · 허용 GF000124 | 건설 3-5-1 절토면 고르기 굴착기 1.0 = GC000111 · 씨앗 같음 GF000124 은 산림 9-19-1 | 같음 GF000124 △(허용) | +| N033 | 대형브레이카 · +굴착기(0.7㎥) [hr] ×3 | GC000271 | 기계 hr 대형브레이카 + 굴착기 0.7 = GC000271 | 후보 GC000271 ✓ | +| N034 | 와이어메시바닥깔기 · #6100×100 [㎡] ×1 | GC000665 | 건설 8-1-3 = GC000665 | 같음 GC000665 ✓ | +| N035 | 원형돌망태 설치 및 돌채움 · #8,45×45 cm [㎡] ×6 | GF000328 | 산림 13-11-1 조립 0.08 · 돌채움 0.17 = GF000328 원형 돌망태 | 후보 GF000328 ✓ | +| N036 | 조약돌채집및운반 · 굴삭기0.6㎥(90%),4.5톤,L=500m [㎥] ×5 | GF000211 · 짐작 | 채집 인력 10%(보통인부 0.3 · 0.6인/㎥ = 산림 13-2-1 막자갈 · 조약돌) + 상차 + 운반 — GF000211(부분) · 1위 GC000363 궤도 자갈 | 후보 GC000363 5안 | +| N037 | 돌망태형 옹벽설치 · 사각돌망태 [㎡] ×4 | GF000235 · 허용 GC000342 | 산림 13-114(= 13-11-4 오타) 사각돌망태 = GF000235 · 이름 같음 GC000342 은 건설 토목 2-2-3 | 같음 GC000342 △(허용) | +| N038 | 콘크리트포장(인력) · T=20cm, 종단15%미만 [㎥] ×4 | GC000297 · 허용 GF000162 | 건설 1-6-2 포장공 4 · 보통 2 ÷ 100㎥(15% 이상 ×1.2) = GC000297 콘크리트 표층 인력포설 · 산림 12-6 GF000162(종단경사 입력) 같은 일 | 후보 GC000297 ✓ | +| N039 | 비탈면 보호공 · 평떼 [㎡] ×6 | GC000143 · 허용 GF000032 | 건설 4-1-1 조경공 1 · 보통 4 ÷ 150 + 떼꽂이(산림 5-23-3) = GC000143 잔디붙임(주) | 후보 GC000143 ✓ | +| N040 | 콘크리트포장(인력) · T=20cm,종단15%이상 [㎥] ×3 | GC000297 · 허용 GF000162 | 건설 1-6-2 포장공 4 · 보통 2 ÷ 100㎥(15% 이상 ×1.2) = GC000297 콘크리트 표층 인력포설 · 산림 12-6 GF000162(종단경사 입력) 같은 일 | 후보 GC000297 ✓ | +| N041 | 고화매트 설치 [㎡] ×1 | 우리에 없음 · 부분 GF000124 · GF000126 | 고화매트120 설치(건설품셈 p.266 · 우리 없음) + 토사면 고르기(산림 9-19-1) + 크레인 · 살수 | 후보 GC000343 없음 줄 · 엉뚱 후보 보임 | +| N042 | 큰돌쌓기(찰쌓기) · 직경40cm이상~60cm미만 [㎡] ×1 | GF000227 | 산림 13-6-2 = GF000227 | 후보 GF000227 ✓ | +| N043 | 레미콘타설(장비사용) · 철근구조물 [㎥] ×7 | GC000198 · 허용 GF000155 | 건설 6-1-1 콘크리트공 3 · 보통인부 1 ÷ 일 시공량(+소량 ×2) + 굴착기 시간 — GC000198 6-1-1(타설방법 · 소량감률 · 굴착기 입력) | 후보 GC000198 ✓ | +| N044 | 강관동바리 · 암거용-1개월사용 [공/㎥] ×7 | GC000015 · 허용 GF000189 | 건설 2-6-1 형틀목공 3 · 보통 1 ÷ 50공㎥ = GC000015 강관 동바리(토목) · 이름 같음 GF000189 은 산림 12-20 | 같음 GF000189 △(허용) | +| N045 | 철근가공조립(현장) · Type-ll [Ton] ×7 | GC000206 · GC000207 · 허용 GF000159 | 건설 6-2 Type 가공(철근공 0.67 · 보통 0.22) + 조립 — 우리 6-2-2 가공 · 6-2-3 조립 둘(복합) · 산림 12-3 GF000159 같은 일 · 이름 1위 GF000187 은 산림 12-18 복잡 고정 4.2인(다른 품) | 후보 GF000187 5안 | +| N046 | 강관비계 · 10m이하,3개월사용 [㎡] ×6 | GC000022 · 허용 GF000188 | 건설 2-7-1 비계공 3 · 보통 1 ÷ 55㎡ = GC000022 강관비계(쌍줄) · 이름 같음 GF000188 은 산림 12-19(공㎥) | 같음 GF000188 △(허용) | +| N047 | 흙깍기(토사) · 굴삭기1.0㎥,보통토사 [㎥] ×1 | GC000085 · 허용 GF000089 | 건설 3-2-3 굴착기 1.0 310㎥/일 = GC000085 흙깎기(기계) 보통토사 | 후보 GC000085 ✓ | +| N048 | 잔토정리 · 굴삭기0.6㎥ [㎥] ×1 | 우리에 없음 · 부분 GC000267 · 짐작 | 굴착기 0.6 사이클 공식만(출처 없음) — 우리 「잔토정리」 로직 없음 | 후보 GC000357 없음 줄 · 엉뚱 후보 보임 | +| N049 | 잡철물 제작설치 · 규격철물 설치(일반철재) [Ton] ×1 | GC000676 | 건설(건축) 8-3-1 = GC000676 잡철물 제작 및 설치 | 후보 GC000676 ✓ | +| N050 | 도장(철재면2회) · 방청1,유성2 [㎡] ×1 | GC000705 · GC000714 · GC000712 | 건설(건축) 11-1-3 바탕 + 11-2-6 녹막이 + 11-2-4 유성 — 우리 셋(복합) | 후보 GC000705 ✓ | +| N051 | 임도안전표지판설치 · 삼각,900mm [개소] ×1 | GC000308 | 건설 1-8-1 지주 · 표지판 = GC000308 교통 안전표지판 설치 | 후보 GC000308 ✓ | +| S052 | 레미콘타설(장비사용) · 철근구조물 [㎥] ×1 | GC000198 · 허용 GF000155 | 건설 6-1-1 콘크리트공 3 · 보통인부 1 ÷ 일 시공량(+소량 ×2) + 굴착기 시간 — GC000198 6-1-1(타설방법 · 소량감률 · 굴착기 입력) | 후보 GC000198 ✓ | +| S053 | 레미콘타설(장비사용) · 무근구조물 [㎥] ×4 | GC000198 · 허용 GF000155 | 건설 6-1-1 콘크리트공 3 · 보통인부 1 ÷ 일 시공량(+소량 ×2) + 굴착기 시간 — GC000198 6-1-1(타설방법 · 소량감률 · 굴착기 입력) | 후보 GC000198 ✓ | +| S054 | 유로폼(Euro Form) · 6회, 보통 [㎡] ×2 | GC000214 · 허용 GF000210 | 건설 6-3-3 형틀목공 4 · 보통 1 ÷ 35~40 = GC000214 | 후보 GC000214 ✓ | +| S055 | 철근가공조립(현장) · Type-Ⅱ [Ton] ×1 | GC000206 · GC000207 · 허용 GF000159 | 건설 6-2 Type 가공(철근공 0.67 · 보통 0.22) + 조립 — 우리 6-2-2 가공 · 6-2-3 조립 둘(복합) · 산림 12-3 GF000159 같은 일 · 이름 1위 GF000187 은 산림 12-18 복잡 고정 4.2인(다른 품) | 후보 GF000187 5안 | +| S056 | 강관비계 · (10m이하) [㎡] ×2 | GC000022 · 허용 GF000188 | 건설 2-7-1 비계공 3 · 보통 1 ÷ 55㎡ = GC000022 강관비계(쌍줄) · 이름 같음 GF000188 은 산림 12-19(공㎥) | 같음 GF000188 △(허용) | +| S057 | 강관동바리 · (2.5m이하) [공/㎥] ×1 | GC000015 · 허용 GF000189 | 건설 2-6-1 형틀목공 3 · 보통 1 ÷ 50공㎥ = GC000015 강관 동바리(토목) · 이름 같음 GF000189 은 산림 12-20 | 같음 GF000189 △(허용) | +| S058 | 유로폼(Euro Form) · 6회, 간단 [㎡] ×1 | GC000214 · 허용 GF000210 | 건설 6-3-3 형틀목공 4 · 보통 1 ÷ 35~40 = GC000214 | 후보 GC000214 ✓ | +| S059 | 철근가공조립(현장) · Type-Ⅰ(소량) [Ton] ×3 | GC000206 · GC000207 · 허용 GF000159 | 건설 6-2 Type 가공(철근공 0.67 · 보통 0.22) + 조립 — 우리 6-2-2 가공 · 6-2-3 조립 둘(복합) · 산림 12-3 GF000159 같은 일 · 이름 1위 GF000187 은 산림 12-18 복잡 고정 4.2인(다른 품) | 후보 GF000187 5안 | +| S060 | 철강재운반(할증포함) · (구역화물) [Ton] ×1 | 우리에 없음 · 부분 GF000283 · 짐작 | 구역화물자동차 운임(왕복 150㎞ + 60㎞) — 우리 화물 운임 로직 없음(덤프 운반 GF000283 은 다른 셈) | 후보 GF000283 없음 줄 · 엉뚱 후보 보임 | +| S061 | 철골설치 [Ton] ×1 | GC000563 · 허용 GC000561 · 짐작 | 크레인 10Ton 1hr/Ton 철골세우기 기계만(출처 없음) = GC000563 철골세우기 장비 손료(ton당) | 후보 GC000571 ✗ | +| S062 | 철골가공조립(공장) · (대형) [Ton] ×1 | GC000559 · 허용 GC000560 | 건설(건축) 1-1 철골공 2.48 × 0.71(89%) + 1-1-3 용접 — GC000559 주 · GC000560 곁 | 후보 GC000560 △(허용) | +| S063 | 잡철물제작 [Ton] ×2 | GC000676 | 건설 8-3-1 = GC000676 | 후보 GC000676 ✓ | +| S064 | 합판거푸집(6회) [㎡] ×7 | GC000211 · 허용 GF000160 | 건설 6-3-1 형틀목공 5 · 보통인부 2 ÷ 일 시공량 = GC000211 6-3-1 · 이름 같음 GF000160 은 산림 12-4 | 후보 GC000211 ✓ | +| S065 | 와이어메시 바닥깔기 [㎡] ×2 | GC000665 | 건설 8-1-3 = GC000665 | 같음 GC000665 ✓ | +| S066 | 구조물터파기(토사) [㎥] ×18 | GC000089 · 허용 GC000088 | 인력 10% + 기계 90% 혼합토사 건설 3-2-4 = GC000089 터파기(기계) 혼합토사 | 후보 GC000090 5안 | +| S067 | 구조물되메우기 및 다짐 [㎥] ×6 | GC000109 | 건설 3-4-7 대형장비 290㎥/일 = GC000109 | 후보 GC000109 ✓ | +| S068 | 잔토처리 [㎥] ×18 | 우리에 없음 · 짐작 | 굴착기 0.8 사이클 기계 100%(출처 없음) — 우리 잔토처리 로직 없음 | 없음 — 없음 줄 · 후보 없음 | +| S069 | 레미콘타설(장비사용) · 무근구조물-소량타설 [㎥] ×7 | GC000198 · 허용 GF000155 | 건설 6-1-1 콘크리트공 3 · 보통인부 1 ÷ 일 시공량(+소량 ×2) + 굴착기 시간 — GC000198 6-1-1(타설방법 · 소량감률 · 굴착기 입력) | 후보 GC000198 ✓ | +| S070 | 합판거푸집(4회) [㎡] ×1 | GC000211 · 허용 GF000160 | 건설 6-3-1 형틀목공 5 · 보통인부 2 ÷ 일 시공량 = GC000211 6-3-1 · 이름 같음 GF000160 은 산림 12-4 | 후보 GC000211 ✓ | +| S071 | 기초잡석다짐 및 채집운반 · (9:1)(0.6㎥+4.5Ton) [㎥] ×1 | GC000110 · GF000168 · 짐작 | 채집 · 적사 · 운반 + 기초지정 건설 3-4-8 = GC000110(부분) · 산림 12-12(옛 번호) → 지금 12-25 기초잡석 GF000168 | 후보 GC000110 ✓ | +| S072 | 배수관부설 · (D=1000mm) [M] ×1 | GC000404 · 허용 GF000176 · GF000179 | 건설 6-7-2 파형강관 배관공(수도) · 보통 ÷ 6 = GC000404 파형강관 부설 및 접합(본 ↔ M) · 산림 12-11-3 GF000176 | 후보 GC000404 ✓ | +| S073 | 철골공 [인] ×1 | 우리에 없음 | 노임 한 줄(인 단위) — 로직 아님(인력 단가) | 후보 GC000560 없음 줄 · 엉뚱 후보 보임 | +| S074 | 특별인부 [인] ×1 | 우리에 없음 | 노임 한 줄(인 단위) — 로직 아님(인력 단가) | 없음 — 없음 줄 · 후보 없음 | +| S075 | 레미콘타설(인력운반) · 무근구조물-소량타설 [㎥] ×1 | GC000198 · 허용 GF000155 | 건설 6-1-1 콘크리트공 3 · 보통인부 1 ÷ 일 시공량(+소량 ×2) + 굴착기 시간 — GC000198 6-1-1(타설방법 · 소량감률 · 굴착기 입력) | 후보 GC000198 ✓ | +| S076 | 구조물터파기 · 인력 [㎥] ×1 | GC000083 · 허용 GF000101 | 건설 3-2-1 특별인부 = GC000083 | 후보 GC000083 ✓ | +| S077 | 구조물되메우기 · 인력 [㎥] ×1 | GF000113 · 짐작 | 보통인부 0.1인/㎥(출처 없음) = 산림 9-14-1 GF000113(임도 파일 단산12 와 같은 값) | 후보 GF000113 ✓ | +| S078 | 잔토처리 · 인력 [㎥] ×1 | 우리에 없음 · 부분 GC000083 | 보통인부 0.2인/㎥ = 건설 3-2-1 비고 잔토처리 — 우리 단독 로직 없음(GC000083 입력 「잔토처리」로 굴착과 묶임) | 없음 — 없음 줄 · 후보 없음 | +| S079 | 원형돌망태조립설치 · 산림사업품셈 [조] ×1 | GF000328 | 산림 13-11-1 조립설치 = GF000328(시공 입력 · 조 ↔ ㎡) | 후보 GF000328 ✓ | +| S080 | 원형돌망태채움 · 산림사업품셈 [조] ×1 | GF000328 | 산림 13-11-1 돌채움 = GF000328 | 후보 GF000328 ✓ | +| S081 | 조약돌채집운반 · (9:1)(0.6㎥+4.5Ton) [㎥] ×1 | GF000211 · 짐작 | 채집 인력 10%(보통인부 0.3 · 0.6인/㎥ = 산림 13-2-1 막자갈 · 조약돌) + 상차 + 운반 — GF000211(부분) · 1위 GC000363 궤도 자갈 | 후보 GF000212 5안 | +| S082 | 구조물터파기(암) [㎥] ×2 | GC000090 | 건설 3-2-4 암 = GC000090 | 후보 GC000090 ✓ | +| S083 | 배수관부설 · (D=500mm) [M] ×1 | GC000404 · 허용 GF000176 · GF000179 | 건설 6-7-2 파형강관 배관공(수도) · 보통 ÷ 6 = GC000404 파형강관 부설 및 접합(본 ↔ M) · 산림 12-11-3 GF000176 | 후보 GC000404 ✓ | +| S084 | 레미콘타설(무근펌프카)(130㎥) · 41m(80-95㎥/hr) [㎥] ×1 | GC000201 · 허용 GF000182 | 건설 6-1-4 펌프차 편성 = GC000201 | 후보 GC000201 ✓ | +| S085 | 콘크리트 펌프차 · 41m,80~95㎥/hr [hr] ×1 | GC000268 · GC000269 · GC000275 · 짐작 | 기계 hr 콘크리트펌프차 41m — 우리 「기계 시간당 기계경비」(기계 입력) | 후보 GC000201 ✗ | +| S086 | 문양거푸집 [㎡] ×1 | GC000215 · 허용 GF000161 | 건설 6-3-4 형틀목공 0.07 · 보통 0.03 = GC000215 · 이름 같음 GF000161 은 산림 12-5 | 같음 GF000161 △(허용) | +| S087 | 지수판 설치 · 산림사업품셈 [M] ×1 | GF000196 | 산림 12-27-1 = GF000196 지수판 설치 | 같음 GF000196 ✓ | +| S088 | P.V.C 파이프 설치 · 산림사업품셈 [M] ×1 | GF000192 | 산림 12-22 = GF000192(개소 ↔ M) | 후보 GF000192 ✓ | +| S089 | 떼붙임(평떼) · 산림사업품셈 [㎡] ×2 | GF000025 · 허용 GF000032 | 산림 5-12 보통인부 0.06 + 떼 소운반 = GF000025 떼붙임(재배잔디) | 후보 GF000025 ✓ | +| S090 | 큰돌메쌓기 · 40cm-60cm,(산림사업품셈) [㎡] ×1 | GF000226 · 허용 GC000260 | 산림 13-6-1 | 후보 GF000226 ✓ | +| S091 | 고임돌채집및운반 · (9:1)(0.6㎥+4.5Ton) [㎥] ×9 | GF000211 · 허용 GF000212 · 짐작 | 채집(기계 90 · 인력 10% 보통인부 0.3인/㎥ = 산림 13-2-1 막자갈) + 상차 + 운반 — GF000211 은 인력 채집 몫만 · 출처 1-10 은 단위중량 인용 · 1위 GC000363 은 궤도 자갈(토목 4-6-1) | 후보 GC000363 5안 | +| S092 | 뒷채움잡석채집운반다짐 · (9:1)(0.6㎥+4.5Ton) [㎥] ×4 | GC000106 · 허용 GF000218 · 짐작 | 채집(9:1) · 운반 + 뒷채움 및 다짐 건설 3-4-4(소형장비) = GC000106(부분) | 후보 GC000106 ✓ | +| S093 | 큰돌찰쌓기 · 40cm-60cm,(산림사업품셈) [㎡] ×10 | GF000227 · 허용 GC000261 | 산림 13-6-2 | 후보 GF000227 ✓ | +| S094 | 큰돌채집운반 · 산림사업품셈 [㎡] ×5 | GF000213 | 산림 13-2-3 작업반장 0.026인/개 × 4개/㎡ × 75% × 50 · 40% + 굴착기 = GF000213 큰돌 채집(기계)(개 ↔ ㎡) | 후보 GF000213 ✓ | +| S095 | 뒷채움잡석구입운반 [㎥] ×1 | GF000135 · GF000284 · 짐작 | 적사(굴착기 0.8) + 덤프 15t 운반 18.7㎞(출처 없음) = 산림 10-3-2 골재운반 잡석 · 10-12-1 덤프운반 | 후보 GF000135 ✓ | +| S096 | 뒷채움잡석뒷채움 [㎥] ×2 | GC000106 | 건설 3-4-4 소형장비 = GC000106 | 후보 GC000106 ✓ | +| S097 | 뒷채움잡석구입운반 · (창봉 기번3)(2차) [㎥] ×1 | GF000135 · GF000284 · 짐작 | 적사(굴착기 0.8) + 덤프 15t 운반 18.7㎞(출처 없음) = 산림 10-3-2 골재운반 잡석 · 10-12-1 덤프운반 | 후보 GF000135 ✓ | +| S098 | 큰돌메붙임 · 40cm-60cm,(산림사업품셈) [㎡] ×2 | GF000229 · 허용 GC000262 | 산림 13-7-1 | 후보 GF000229 ✓ | +| S099 | 큰돌찰붙임 · 40cm-60cm,(산림사업품셈) [㎡] ×7 | GF000230 · 허용 GC000263 | 산림 13-7-2 | 후보 GF000230 ✓ | +| Y000 | 무근콘크리트타설 · 인력운반타설 [M3] ×3 | GC000198 · 허용 GF000155 | 건설 6-1-1 인력운반 23㎥/일 · 씨앗 같음 | 같음 GC000198 ✓ | +| Y001 | 합판거푸집(6회) · 간단 [M2] ×6 | GC000211 · 허용 GF000160 | 건설 6-3-1 · 씨앗 같음 | 같음 GC000211 ✓ | +| Y002 | 구조물터파기(인력) · 경질토사 [M3] ×3 | GF000101 · 허용 GC000083 · 짐작 | 옛 건설 3-1-3 인력 터파기 보통인부 0.26 · 씨앗 = GF000101 | 같음 GF000101 ✓ | +| Y003 | 구조물되메우기 · (인력) [M3] ×4 | GF000113 | 보통인부 0.1 · 씨앗 같음 | 같음 GF000113 ✓ | +| Y004 | 잔토처리 · 인력100% [M3] ×4 | 우리에 없음 | 잔토처리 인력 0.2인 — 단독 로직 없음 | 후보 GC000346 없음 줄 · 엉뚱 후보 보임 | +| Y005 | 크레인(타이어) · 10톤 [시간] ×1 | GC000268 · GC000269 · GC000275 · 짐작 | 기계 시간 크레인(타이어) 10톤 — 기계 시간당 기계경비(기계 입력) | 후보 GF000144 ✗ | +| Y006 | 구조물터파기 · 토사(B/H 90%+인력10%) [M3] ×1 | GF000101 · 허용 GC000089 · 짐작 | 임도품셈 5-1-2(옛) 기계 90 + 인력 10 · 씨앗 = GF000101 | 같음 GF000101 ✓ | +| Y007 | 잔토처리 · 토사(B/H 100%) [M3] ×1 | 우리에 없음 | 잔토처리 기계 100%(백호 0.7) — 로직 없음 | 없음 — 없음 줄 · 후보 없음 | +| Y008 | 콘크리트타설 · 임도품셈 5-5 [M3] ×6 | GC000198 · 허용 GF000155 · GF000166 · 짐작 | 임도품셈 5-5(옛) 콘크리트공 0.15 · 보통 0.27 · 씨앗 = GC000198 | 같음 GC000198 ✓ | +| Y009 | 유로폼 설치,해체(복잡) · 수직고7.0m 이하 [M2] ×5 | GC000214 · 허용 GF000210 | 건설 6-3-3 유로폼(복잡) — GC000214 · 이름 후보에서 산림 GF000210 과 한 줄로 접혀 근거 점수만 받음 | 후보 GC000223 5안 | +| Y010 | 철근가공조립(현장) · TYPE Ⅰ [Ton] ×6 | GC000206 · GC000207 · 허용 GF000159 | 건설 6-2-3 · 6-2-2 가공 + 조립 | 후보 GF000187 5안 | +| Y011 | 기초잡석채집및운반부설 · 4.5톤(L=500) [M3] ×11 | GF000168 · 허용 GC000110 · 짐작 | 채집(9:1) · 운반 · 부설 — 산림 12-25 기초잡석(짐작) | 후보 GF000168 ✓ | +| Y012 | 임도안전표지판설치 · 삼각,D=900 [개소] ×1 | GC000308 | 옛 1-9-3 표지판 · 씨앗 같음 | 같음 GC000308 ✓ | +| Y013 | 야면석 채집 운반 · (25x25x35㎝)장비 [M2] ×4 | GF000214 · 짐작 | 기계 8 : 인력 2 채집 + 운반 — 인력 채집 몫(부분) | 후보 GF000214 ✓ | +| Y014 | 야면석찰붙임 · 뒷길이 35Cm [M2] ×3 | GC000263 · 허용 GF000225 | 건설 7-2-2 석공 0.05 + 굴착기 집게 = GC000263 | 후보 GC000263 ✓ | +| Y015 | 고임돌채집운반 · 4.5톤덤프(L=500) [M3] ×5 | GF000211 · 허용 GF000212 · 짐작 | 채집(9:1) + 덤프 — 인력 채집 몫(짐작) | 후보 GF000212 △(허용) | +| Y016 | 잡석운반및다짐 [M3] ×2 | GF000218 | 씨앗 같음(잡석운반및다짐) | 같음 GF000218 ✓ | +| Y017 | 면고르기 [M2] ×2 | GF000124 · 허용 GC000111 | 보통인부 0.009 + 굴착기 0.7 · 씨앗 같음 | 같음 GF000124 ✓ | +| Y018 | 구조물터파기 · 인력(경질토사) [M3] ×1 | GF000101 · 허용 GC000083 · 짐작 | 씨앗 같음 | 같음 GF000101 ✓ | +| Y019 | 도로반사경설치 · D=1000(STS) [개소] ×1 | GC000310 | 씨앗 같음 | 같음 GC000310 ✓ | +| Y020 | 철근콘크리트타설 · 장비사용타설 [M3] ×2 | GC000198 · 허용 GF000155 | 건설 6-1-1 장비사용 55㎥/일 + 굴삭기 — GC000198(1위 펌프차 · 토목 6-1-1 소켓관이 부문 없이 조항 같음) | 후보 GC000201 5안 | +| Y021 | 유로폼 설치,해체(보통) · 수직고7.0m 이하 [M2] ×1 | GC000214 · 허용 GF000210 | 건설 6-3-3 유로폼 — 이름 같은 산림 GF000210 과 접힘(이름 후보 중복 빼기) | 후보 GC000223 5안 | +| Y022 | 지수판 설치(PVC 용접) · 200×5t [m] ×1 | GC000224 | 씨앗 같음 | 같음 GC000224 ✓ | +| Y023 | 배수관부설및접합(P.V.C) · Ø 50 MM - VG2 일반관 [m] ×1 | GC000393 | 토목 6-4-2 · 씨앗 같음 | 같음 GC000393 ✓ | +| Y024 | 야면석소운반(L=35CM) · (L=20m) [M2] ×2 | GF000280 · 허용 GF000279 · 짐작 | 옛 건설 9-1 인력운반(지게) 575㎏/㎡ — 산림 10-6-2(짐작) | 후보 GF000214 ✗ | +| Y025 | 콘크리트소운반 · (L=20m) [M3] ×1 | GF000279 · GF000281 · 짐작 | 옛 건설 9-1 인력운반 2,300㎏/㎥ — 산림 10-6 인력운반(짐작) | 후보 GF000162 ✗ | +| Y026 | 야면석메붙임 · 뒷길이 35Cm [M2] ×1 | GF000224 · GC000262 | 건설 7-2-1 석공 0.13 = GC000262 · 씨앗 = GF000224 | 같음 GF000224 ✓ | +| Y027 | 측구수로관부설 · 500∼700kg미만 [m] ×1 | GC000251 | 공통 6-7-1 플륨관 특별 · 보통 + 트럭크레인 = GC000251 플륨관 설치(1위 PC관 = 토목 6-7-1) | 후보 GC000403 ✗ | +| Y028 | 콘크리트타설 · 장비사용타설(소형) [M3] ×1 | GC000198 · 허용 GF000155 | 6-1-1 장비사용(소형) — GC000198 · 1위 GF000166 은 산림 12-37 다른 품 | 후보 GF000166 5안 | +| Y029 | 합판거푸집(4회) · 보통 [M2] ×1 | GC000211 · 허용 GF000160 | 건설 6-3-1 4회 보통 | 후보 GF000160 △(허용) | +| Y030 | 혼합골재부설 · 영월-산불 [M3] ×1 | GF000136 · 짐작 | 골재장 → 현장 덤프 운반(옛 9-7) = 산림 10-3-3(짐작) | 후보 GF000136 ✓ | +| Y031 | 콘크리트포장(인력) · T=20㎝ [M3] ×1 | GF000162 · 허용 GC000297 | 임도품셈 7-1-1(옛) · 씨앗 같음 | 같음 GF000162 ✓ | +| Y032 | 콘크리트포장절단공 · 임도 [M] ×1 | GF000163 | 임도품셈 7-1-2(옛) · 씨앗 같음 | 같음 GF000163 ✓ | +| Y033 | 뒷채움돌채집운반부설 · 4.5톤 덤프트럭(L=500m) [M3] ×2 | GF000218 | 씨앗 같음 | 같음 GF000218 ✓ | +| Y034 | 큰돌메쌓기 · 뒷길이 0.6~0.8m [M2] ×2 | GF000226 | 사방품셈 P52 · 씨앗 같음 | 같음 GF000226 ✓ | +| Y035 | 큰돌채집운반(뒷길이 0.6~0.8m) · 장비(80%) + 인력(20%) [M2] ×2 | GF000213 | 큰돌 채집 장비 80 + 인력 20 | 후보 GF000213 ✓ | + +## 부록 E. 스크립트 · 결과 파일 + +폴더 = 스크래치패드 `req225/C1/`. 모두 읽기만(서버 · DB · storage · 저장소 안 씀 · 함수 바꿔 끼움은 같은 프로세스 안). + +- `s01_extract.py` — 두 xls 를 `M02_Hopyo_Excel.read_book` 으로 읽어 줄마다 `match_line`(기억 없음) → `out_lines.json`(616 줄 · 후보 · 점수 · 단산 블록 글) · 우리 로직 표 `out_ours.json`. +- `s02_view.py` · `s03_find.py` — 고유 로직 줄 보기(`out_items.json`) · 우리 로직 찾기. +- `labels.py` · `labels_y.py` — 정답 표시(부록 C 원천) · `cats.py` — 실패 갈래. +- `s04_eval.py` · `s04b_diff.py` — 지표 · ①②④⑥ 흉내 · 줄별 바뀜. +- `s05_more.py` · `s05b_diff.py` — 책 맞춤 · ⑤⑦⑧⑪ · 금액 진단(`out_money.json`). +- `s06_memory.py` — ⑨ 기억 재사용 · 점수 문턱. +- `s09_stmate.py` — 영월 STmate 읽기(흉내) → `out_stmate.json`. +- `s10_all.py` · `s10b_diff.py` — 세 파일 지표 · ⑫⑬⑭ · 씨앗 끔 → `out_all.json`. +- `s11_buttons.py` — 단추 정밀 · `s12_units.py` — 단위 별칭 · `s13_money_flag.py` — 금액 확인 표시 정밀. +- `s14_fails_all.py` · `s15_cats.py` — 실패 목록 · 갈래 표(`out_fails_all.json` · `out_cats.json`). +- `s16_guard.py` — 작은 고침 묶음 · ⑪ 판 지킴 → `out_guard.json` · `s18_name_all.py` — 이름 쪽 방법 세 파일 → `out_name_all.json`. +- `s17_report.py` — 이 글. +- 실행 = 저장소 뿌리 `venv/Scripts/python.exe -B <스크립트>`(스크래치패드 폴더에서 · 각 120 초 안). + +## 부록 F. 정의 · 한계 + +- 엄격 = 엑셀 블록 출처 조항 · 직종 · 품과 같은 우리 로직(복합이면 주된 몫 · 여럿이면 어느 하나). 느슨 = 엄격 + 같은 일 다른 책 · 복합의 곁 몫. 「우리에 없음」은 분모에서 빼고 따로 셈. +- 짐작 표시 31 줄(출처 없음 · 부분 덮음) — 고임돌 · 조약돌 = 산림 13-2-1 인력 채집 몫 · 소운반 = 산림 10-6 인력운반 · 철골설치 = 철골세우기 장비 손료 등은 사람 판단. +- 사전(②)은 임도 파일에서만 뽑아 사방 · 영월을 처음 보는 셈으로 잼 — 같은 파일 점수는 부풀어 있음. +- 금액(⑧)은 자동값 + 규격에서 읽은 입력값으로 우리 로직을 서버 엔진 그대로 셈(`M02_Labor_Doc.run_line`) · 엑셀 노임(2026)과 우리 노임 차이 몇 % 섞임. +- 영월은 지금 화면이 못 올리는 꼴 — 지표는 「읽기를 붙였다고 가정」 · 노임 코드 → 직종 이름이 덜 이어져 근거 직종이 거의 빔. diff --git a/docs/raw/조사/2026-10-15_336_부록2_바깥방법.md b/docs/raw/조사/2026-10-15_336_부록2_바깥방법.md new file mode 100644 index 000000000..17718a0a7 --- /dev/null +++ b/docs/raw/조사/2026-10-15_336_부록2_바깥방법.md @@ -0,0 +1,696 @@ +# C2 · 일위대가 이름 맞춤 — 다른 곳은 어떻게 푸나 (상세 검토 · 코드 고침 없음) + +조사일 2026-10-15 · 요구 225(10-15) 「외부에서 일위대가 가져오기 기능에서 이름이 달라 매칭이 안되는 문제 개선 방법」 · 저장소 읽기만 · 웹 출처 = 실제로 연 페이지만 · 확인 못 한 것 = 「확인 못 함」 · 우리 자료 실측은 다른 조사자 몫(여기 수치는 바깥 자료 · 코드 읽기 · 예시 계산). +근거 꼴 = 저장소 `경로:줄`(지식DB 는 `resources/knowledge/` 아래 경로 — `original/` · `technical_info/` 로 시작 · 코드 · 마스터 자료는 저장소 뿌리 기준) · 실무 엑셀 `시트!R행(C열)` · 웹 = URL. + +## 0. 한눈 결론 + +1. **이름 자동 맞춤으로 푼 곳은 없음.** 시중 적산 프로그램은 자원 줄(노임 · 자재 · 중기 · 경비)은 코드로, 일위대가 · 단가산출 머리는 사람이 검색해 고름 — 다산 공지 「일위대가, 단가산출은 '코드일괄변경'이 적용되지 않으므로 … 검색후 [코드변경]」(2-1). STmate 도 정확 일치 열쇠를 5 단으로 내려갈 뿐 퍼지 · 학습 없음(2-3). 조달청 의뢰 연구(2020)는 「표준 공사코드와 내역항목의 일치 … 방법론은 구축되어 있지 않은 상태」(2-2). 국내 논문(2020)은 글자 유사도 1순위 정답률 50 % 아래 → 「후보 몇 개 + 전문가 고름」 을 권함(3-3). 우리 「후보 + 사용자 고름 + 층 기억」 은 이 흐름과 같음. +2. **코드로 잇기는 보조까지.** 실무 엑셀 6 개(임도 4 · 사방 2)에 국가 표준 코드 칸 0(1-0). 실무 STC 에서도 국가 호환코드가 일위대가 머리 0/216 · 단가산출 0/371(2-3). 쓸 수 있는 「코드」 = 단가산출 글 속 품셈 조항 번호뿐 — 판 · 부문이 빠지고 서식이 제각각. 번호는 판마다 바뀜(산림 판마다 전부 · 건설 해마다 끼워 넣기로 밀림 · 4-다). 지금 근거 길(조항 대조)에 판 짐작 · 부문 넣기 · 옛 번호 바꿈표가 붙어야 힘을 냄. +3. **예시 짝은 원문이 직접 이어 줌.** 건설 3-2-1 [주]① 「깊이 1m이하의 인력에 의한 구조물 터파기 또는 흙깎기 등에 적용」(`original/원가계산/건설공사_표준품셈/본문/01_공통부문/제03장_토공사/3-02_굴착.md:30`) · 실무 출처 칸 「[건설공사표준품셈 3-2-1 인력굴착]」(`original/실무문서/2026_실무일위대가 모음/2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls` 단가산출!R4229C2 「93.구조물터파기(인력100%)(토사)[㎥]」 · R4231C2) · 조달청 세부공종코드 목록에 옛 「인력터파기」(ABB111100000)와 새 「굴착(인력/토사)」(ABB11100010S)이 같은 코드 묶음(1-4). +4. **코드 읽기로 본 벽(실측 아님).** 지금 맞춤은 괄호 글을 지우고 뜻 낱말이 하나도 안 겹치면 후보에서 뺌 → 「구조물터파기(인력100%) 토사」 ↔ 「굴착(인력/토사)」 은 이름 길에서 빠지고, 근거 길도 줄 직종이 보통인부뿐이면 같은 낱말 조건에 걸림(3-0). 같은 뜻 말 한 줄(터파기 ↔ 굴착)이면 넘음. 예시 계산에서 글자 기법은 모두 오답(이름 비슷한 기계 터파기)을 더 높게 봄(3-1). +5. **가장 값싼 길 = 이미 있는 그릇.** M01 로직 줄에 「별칭」 칸(화면 · 층 · 쉼표로 여럿)이 이미 있고 M01 찾기는 씀. 마스터 1,469 줄은 모두 비어 있고 M02 맞춤은 안 읽음(3-0). 별칭을 맞춤에 읽히고, 씨앗 메모 170 · 품셈 [주] 다리 문구 · 조달청 옛/새 이름 · 실무 짝 20 · 사용자 확정 짝으로 채우는 길. +6. **바로 쓸 만함(새 의존성 없음)**: 별칭 · 같은 뜻 말/쪼개기 표 · 표기 고르기(깎/깍 · ㎥/m3 · 굴삭기/굴착기/백호우 · 브레이카) · 괄호 글을 칸(방법 · 토질 · 규격)으로 · 칸 어긋남 규칙 · 구성 비교(직종 · 장비 · 자재 · 「보통인부뿐」) · 확정 짝 → 별칭 후보 · 정확 일치 열쇠 단계 내리기 · 글자 2~3 그램 TF-IDF(5). +7. **조건부**: 조항 번호 바꿈표(산림 14 건 씨앗 · 건설 2025 → 2026 한 단계) · 조달청 세부공종코드 CSV(33,491 행 · 무료 · 사전 재료로는 바로) · C3R XML · STC 직접 읽기 · 조달 8 자리 자재 번호 · 금액 대조 · Jaro-Winkler/자모 · 형태소 분석 · 문장 임베딩(수백 MB) · 능동 학습(5). +8. **안 맞음**: 표준시장단가 · 건설정보분류체계 · 물가지 코드 · 일위대가 머리 국가 코드 · STmate/다산 내부 코드(프로젝트 순번) · 암 이름을 같은 뜻 말로 묶기 · 큰 언어 모델(오프라인 · 외부 전송) · 신경망 분류 학습(자료 적음)(5). + +--- + +## 1. 국내 표준 코드 · 분류 — 이름 대신 코드로 이을 수 있나 + +### 1-0. 한눈 요약 + +| # | 대상 | 코드 꼴 | 실무 엑셀(임도 · 사방 6개)에 있나 | 이름 대신 코드로 잇기 | 판정 | +|---|---|---|---|---|---| +| 1 | 건설공사 표준품셈 | 부문 → 장 → 절 `N-N` → 항목 `N-N-N` · 해마다 개정 | 있음 — 단가산출 글 속 자유 서식(판 · 부문 빠짐) | 번호만으로는 못 함(판 · 부문이 있어야 유일) | 조건부 | +| 2 | 표준시장단가 | `AA310.01010` (영문 2 + 숫자 3 + 점 + 숫자 5) · 반기 | 없음(0건) | 임도 · 사방 일위대가엔 해당 없음 | 안 맞음 | +| 3 | 건설정보분류체계(공종분류) | 고시(2009 적용기준) · 자릿수 확인 못 함 | 없음 | 못 함 — 실무에서 안 씀 | 안 맞음 | +| 4 | 조달청 공사원가호환규정(C3R) · 표준공사코드 | 세부공종코드 12자(첫 글자 = 공사구분) · 33,491행 공개 CSV | 없음(0건) — STmate 산출 xlsx 숨은 칸엔 프로그램 자체 코드뿐 | 엑셀 입력엔 못 함 · XML 입력이면 열쇠 칸 있음 | 조건부(사전 재료로는 바로 쓸 만함) | +| 5 | 산림사업 표준품셈 | 산림청고시 제2025-82호(2026-01-01 시행) · 장-절 `5-12` | 책 이름만(「임도품셈」「산림사업품셈」) · 조항 번호 거의 없음 | 못 함 — 판이 통째로 바뀜 | 조건부(항목 제목 = 기준말로는 바로 쓸 만함) | +| 6 | 한국물가정보 · 물가자료 | KPI 분류 CATE_CD 4 · 8자리 보임 · 품목 코드 비공개(유료) | 없음 — 「쪽 번호」만 | 못 함 | 안 맞음 | +| 7 | 조달청 물품목록(물품분류 8자리 · 식별번호 8자리) · 나라장터 가격 | 물품식별번호 8자리 | 일부 — 조달청 가격 쓴 자재 줄에 8자리 번호 | 그 자재 줄만 가능 | 조건부 | + +핵심: 실무 엑셀 6개(임도 4 · 사방 2)에 **국가 표준 코드 칸은 하나도 없음**. 들어 있는 것은 ① 단가산출 글 속 품셈 조항 번호(서식 제각각) ② 조달청 가격 자재 줄의 8자리 식별번호 ③ STmate 가 산출한 xlsx 셀 안 숨은 프로그램 자체 코드뿐임. 따라서 「코드로 잇기」는 엑셀 입력에서는 보조 열쇠(후보 거르기)까지이고, 대신 공개 목록(조달청 세부공종코드 · 품셈 항목 제목)은 **같은 뜻 말 · 정규 표기 사전의 재료**로 쓸 만함. + +### 1-실측. 실무 엑셀에 코드가 들어 있나 + +훑은 파일(저장소 `original/실무문서/` 아래, 스크립트 = scratchpad `req225/p1_scan_codes.py`): + +| 파일 | 품셈 조항 `N-N-N` | 표준시장단가 · 조달청 세부공종코드 꼴 | 「코드」 머리칸 | 8자리 숫자 | +|---|---:|---:|---:|---:| +| `2026_실무일위대가 모음/2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls` | 92 | 0 | 0 | 14(자재단가 시트) | +| `2026_실무일위대가 모음/2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls` | 61(레미콘 규격 25-24-12 섞임) | 0 | 0 | 4(품명 괄호) | +| `2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx` (STmate 산출) | 39 | 0 | 9(안내문 · 숨은 메타) | — | +| `20250527 2025년 계류보전사업(…)(변경)/01.내역자료/2025년 계류보전사업(…)(변경).xlsx` (STmate 산출) | 17 | 0 | 7(안내문 · 숨은 메타) | — | +| `01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(…)내역+수량.xlsx` | — | 0 | — | — | +| `05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx` | — | 0 | — | — | + +관찰 예: +- 품셈 근거가 글 속에 제각각 — `단가산출!R242C2` 「[건설표준품셈(8-2-8)] 덤프트럭 적용」 · `R277C2` 「되메우기및 다짐 [건설공사표준품셈 표준품셈3-4-7]대형장비」 · `R582C2` 「건설공사표준품셈 2-2-3 콘테이너형 가설건축물」 · `R10C2` 「[건설표준품셈 준용]」(번호 없음) — 2026 북부청 임도. +- 같은 문서 안 옛 번호 · 새 번호 공존 — 2025 계류보전 `단가산출근거!R7C2` 「건설표준품셈 9-3 굴삭기 적용」 ↔ `R180C2` 「건설표준품셈 8-2-3(굴삭기) , 8-2-8(덤프트럭) 적용」. 2026판에서 8-2-3 = 「굴착기」(`original/원가계산/건설공사_표준품셈/본문/01_공통부문/제08장_건설기계/8-02-03_굴착기.md` 16줄), 9-3 = 토목부문 「지형 및 토지측량」(`…/02_토목부문/제09장_측량/9-03_지형_및_토지측량.md` 3줄) → 부문 · 판 없이 번호만 대조하면 엉뚱한 항목에 붙음. 이름도 「굴삭기」 ↔ 「굴착기」. +- 일위대가표 첫 칸에 조항 번호 — STmate 산출 xlsx `일위대가표!R7C2` 「6-1-4(모르타르)」 · `R44C2` 「7-1-2(찰쌓기)」 · `R87C1` 「임도표준품셈」(2024 간선임도). +- 산림 품셈은 책 이름만 — 2026 북부청 임도 `내역서!R81C4` 「임도품셈, 발생석활용 50%적용」(규격 칸) · 사방 `내역서!R22C4` 「산림사업품셈」. +- 호표 번호는 문서 안 차례일 뿐 — `일위대가!R5C2` 「제1호표」. +- STmate 산출 xlsx 셀 안 숨은 메타 — `중기목록표!R27C10` 「`COD|X00102 … EQC|03060201050`」 · `노무비목록표!R14C7` 「`COD|L00033 … BQC|01000000007`」(2024 간선임도). M · L · S · X · W + 5자리 = 프로그램 자체 코드(국가 코드 아님 · 필드 뜻은 `original/원가계산/STmate/STC_필드사전_핵심.md` 57 · 61 · 90 · 91줄 — EQCODE「중기 코드」, BQCODE「단가 적용월」 ⚠). + +### 1-1. 건설공사 표준품셈 (국토교통부 공고 · 한국건설기술연구원 관리) + +- 무엇: 「건설공사의 일반ㆍ보편적 공종에서 단위작업 당 투입되는 인력, 장비 등을 수치화한 자료」(뉴스서울 2026 공고 기사). +- 코드 체계: 부문(공통 · 토목 · 건축 · 기계설비 · 유지관리) → 장(부문마다 제1장부터) → 절 `N-N` → 항목 `N-N-N`. 2026판 = 5부문 · 45장(공통 8 · 토목 9 · 건축 11 · 기계설비 13 · 유지관리 4)(`original/원가계산/건설공사_표준품셈/_meta.md` 19줄). 항목 제목 끝에 개정 연도 꼬리표 — 「3-2-1 굴착(인력/토사)('08, '20, 25년 보완)」(`…/제03장_토공사/3-02_굴착.md` 18줄). +- 갱신 주기: 해마다 12월 말 다음 해 판 공고 · 1월 1일 적용(`_meta.md` 6줄). 2026판 = 「총 1459개 항목 중 349개 항목(공통 254, 토목 28, 건축 30, 설비 24, 유지관리 13)을 개정」(조선비즈 2025-12-22). 항목 수가 해마다 크게 다름 — 2019 2,317개(산업일보 2019-01-02) · 2025판 1,438개(뉴스서울 2024-12-26) · 2026판 1,459개. +- 번호가 바뀌나: 바뀜. + - 2019 개편 — 「토목·건축·기계설비 부문에 중복 분류돼 있던 토공사, 철근콘크리트공사 등의 주요공종 단일화를 통해 … 표준품셈 체계개편을 추진」(산업일보 2019-01-02) → 같은 일이 부문 · 장을 옮김. + - 2026판 토공사 절 사이에 '25년 신설 항목이 끼어 있음 — 3-2-3 흙깎기(기계) · 3-2-4 터파기(기계) · 3-4-1 흙쌓기 · 3-4-3 흙 다지기 · 3-4-6 · 3-4-7 되메우기 및 다짐(`3-02_굴착.md` 126줄 · `3-04_쌓기.md` 152줄). 2026판 본문 항목 제목 1,095개 중 「'25년 신설」 27개(곧은 · 굽은 따옴표 모두 · 6-1 에서 다시 셈). 끼어든 자리 뒤 항목은 옛 판과 번호가 다를 수 있음 — 옛 판 원문 대조는 확인 못 함. + - 실무 실측 — 위 「9-3 굴삭기」 ↔ 「8-2-3 굴착기」. + - 신구 번호 대응표: 공개처에서는 못 찾음. 저장소 `2026년_건설공사_표준품셈_개정사항.pdf` 의 현행 ↔ 개정 대조에서 2025 → 2026 한 단계 번호 바뀜은 뽑힘(4-다-2 · 6-1). +- 공식 공개처: 「국토교통부 누리집 또는 한국건설기술연구원 공사비원가관리센터 누리집에서 열람 가능」(조선비즈 2025-12-22). CODIL(codil.or.kr)은 인증서 오류로 이번에 열지 못함(저장소 `_meta.md` 5줄도 같은 문제 기록). +- 실무 엑셀에 있나: 있음 — 단가산출(근거) 글 속, 일위대가표 첫 칸. 다만 판(연도) · 부문을 거의 안 적고, 괄호 · 띄어쓰기 · 책 이름(「건설표준품셈」「건설공사표준품셈」「건설공사건설표준품셈」)이 제각각. +- 이름 대신 코드로 잇기: 번호 하나로는 못 함 — 「판 + 부문 + 번호」가 있어야 유일. 실무 글엔 판 · 부문이 빠지는 일이 흔해 확정 열쇠로는 약함. 쓸 길 = ① 문서 작성 연도로 판을 짐작해 후보를 같은 절로 좁힘 ② 번호 → 그 판의 항목 제목으로 바꿔 이름 맞춤의 기준말로 씀. +- 우리에 맞나: 지금 쓰는 「단가산출 출처 조항 대조」가 바로 이것 — 판 짐작 · 부문 짐작 · 옛 번호 대응이 붙어야 힘을 냄. 우리 이름 「굴착(인력/토사)」은 2026판 3-2-1 제목 그대로임. +- 판정: **조건부** — 후보 거르기 열쇠로는 쓸 만함, 확정 열쇠로는 판 · 부문 짐작과 옛 번호 대응표(직접 만들어야 함)가 붙어야 함. + +### 1-2. 표준시장단가 (국토교통부 공고 · 한국건설기술연구원) + +- 무엇: 「이미 수행한 공사의 시장거래가격을 토대로 산정된 표준단가(100억원 이상 공사에 활용)」(뉴스서울 2026 공고 기사). 2025 적용분 「표준시장단가 1,832개 중 414개 항목(토목 236, 건축 101, 기계설비 77)」 현장조사 반영(뉴스서울 2024-12-26). +- 코드 체계: 영문 2자 + 숫자 3자리 + 점 + 숫자 5자리 — `AA310.01010` · `AA323.10500` · `LH340.02000` · `FB002.01000` · `EE001.20000`(2024 상반기 신구대비표 PDF 사본에서 뽑음). 반기 공고. 신구대비표에 같은 코드로 규격 · 단가가 바뀐 줄 다수(뽑은 글에서 양쪽 코드가 같은 줄 257줄 — 조판 탓에 코드 자체 변경 여부는 판정 못 함). +- 품셈과 잇는 표: 공개 자료에서 확인 못 함. +- 실무 엑셀에 있나: 없음(6개 파일 0건). +- 이름 대신 코드로 잇기: 임도 · 사방 일위대가는 품셈 기반이라 해당 없음. +- 덤 관찰: 조달청 세부공종코드가 이 코드를 그대로 품음 — 「공사구분 글자 + 표준시장단가 코드(점 뺌) + 0」. 대조 7건 모두 맞음: `AA323.10500` → `AAA323105000`(건축) · `CAA323105000`(토목) 「시스템동바리」 · `AA310.02010` → `CAA310020100` 「시스템비계」 · `LH340.02000` → `CLH340020000` 「가드휀스」 · `EE001.20000` → `CEE001200000` 「철근가공조립/현장가공」 · `AC210.10000` → `AAC210100000` 「임대료/타워크레인」 · `FB002.01000` → `CFB002010000`. (호환규정 XSD 의 실적공사비코드 꼴 `[ACEMT]+…` 와도 맞음 — 4번.) +- 우리에 맞나: 임도 · 사방 공사는 보통 100억 미만이라 대상 밖(개별 공사 규모는 따로 확인). +- 판정: **안 맞음**. + +### 1-3. 건설정보분류체계(공종분류 등) + +- 무엇: 건설 정보를 분류 축별로 나눈 국가 분류체계. 2015 논문 참고문헌에 「Construction Information Classification Application Standard, Ministry of Land, Transport and Maritime Affairs, 2009.」(한국산학기술학회논문지 2015). 고시 원문 · 번호 · 공종 코드 자릿수는 확인 못 함(법제처 한글주소 오류 페이지). 시설물 · 공간 · 부위 · 공종 · 자원 축 구성은 검색 요약에만 보였고 연 페이지에선 확인 못 함. +- 실무 활용: 「건설정보 분류체계가 고시된 후 수년이 경과되었으나, 아직까지 현업에서의 활용은 극히 저조한 상태」(한국정보처리학회 2012). 「이에 대한 활용비중이 낮으며」(한국산학기술학회논문지 2014). 1998년에도 건설기술연구원 표준분류체계와 건설교통부 실적공사비 분류체계가 「서로 상이」하다고 지적(건설산업연구원 1998). +- 실무 엑셀에 있나: 없음 — 공종분류 코드 칸이 어느 파일에도 없음. +- 이름 대신 코드로 잇기: 못 함. +- 판정: **안 맞음** — 실무 문서에 코드가 안 실리고, 분류 자체가 개념 수준. + +### 1-4. 조달청 공사원가호환규정(C3R) · 표준공사코드 + +- 무엇: 「공사원가호환규정은 … XML … 문서로 작성」 · 「공사원가호환 데이터 파일의 작성자는 반드시 XML스키마를 통해 공사원가호환규정의 유효성을 검토하여 유효한 파일을 작성」 · 호환규정검증시스템 = 「설계내역 전산파일이 조달청 공사원가호환규정에 따라 적합하게 작성되었는지 검증하는 서비스」 · 「조달청 공사원가호환규정 및 코드에 따라 작성된XML파일만 등록이 가능」(서울시 「XML 안내」 수정일 2026-04-03). +- XSD 칸(저장소 사본 = V1.0.5 · 2022-11-28, `original/경쟁사 프로그램/STmate 분석/30_원자료/조달청_공사원가호환규정.xsd`): + - 판 이력 — 「표준시장단가 추가 … 1.0.3 (2015-07-14)」 · 「내역공종코드형식 max length 변경(16->20) (2017-04-19)」(4~11줄). + - 일위대가 — `일위대가코드` = 세부공종코드형식(12자 · 영숫자)(708 · 1244~1249줄) · 품명 · 규격 · 단위 · 일위대가상세(728줄). + - 일위대가상세 — `자원구분`(자재 · 노무 · 경비항목 · 일위대가 · 중기단가산출 · 기계경비 · 실적공사비 · 시장시공가격 · 손료 · 표준시장단가)(642~657줄) · `자원코드`(17자)(658 · 1279~1283줄) · `노임산출근거` → `공량품셈근거`(자유 글)(632~638 · 701줄). + - 세부공종 — `세부공종코드`(17자)(947 · 1224~1228줄) · `WBS코드1` · `WBS코드2`(1057~1058줄). + - 실적공사비코드 — 12자 · `[ACEMT]+[0-9A-Z]*`(621 · 1237~1242줄). 자재코드 · 노무코드 16자(1258~1269줄). 노무코드 주석 「`[L]{1}[A-Z0-9]+` 표준노무코드 적용시」(1109줄). + - 저장소 정리: `…/20_분석/30_조달청_호환규정_XSD.md` 28줄 「규정 코드는 반기마다 갱신」 · `30_원자료/호환규정코드_안내.txt` 1~3줄 「호환규정코드 2026년 9월 1일 조달청 발표분 반영 … https://npccs.g2b.go.kr 발표」. npccs 사이트는 이번에 본문이 비어 열람 못 함(스크립트로 그리는 화면). +- 표준공사코드: 「공사원가 산정업무의 효율성과 가격의 객관성을 확보하기 위해 '표준공사코드' 체계를 운영 중」 · 「시설자재, 노무비, 기계경비, 세부공종 등에 대해 수요기관이 공사내역 작성 시 활용할 수 있도록 표준공사코드를 제공」(공공데이터포털 15151273 메타). 파일 「조달청_공사원가_세부공종코드_20250911」 — 칸 = 공사구분 · 세부공종코드(최대 16) · 품명 · 규격 · 단위 · 33,491행 · 등록 2025-11-05 · 「수시 (1회성 데이터)」(공공데이터포털 파일 페이지). 무료 · 로그인 없이 내려받음. +- 내려받아 훑은 결과(scratchpad `req225/p1dl_datagokr/seb.csv` · CP949 · 2.3MB): + - 공사구분 6 — 건축 17,551 · 토목 6,771 · 설비 5,556 · 통신 1,794 · 전기 1,150 · 문화재수리 669. + - 코드 12자 33,475행 · 첫 글자 = 공사구분(A 건축 · C 토목 · M 설비 · T 통신 · E 전기 · R 문화재) · 고유 품명 6,071 · 품명+규격 같은 쌍이 2번 넘게 실린 것 1,296(건축 · 토목 겹침 등). + - 끝 글자 S 코드 10,043 — 품명이 품셈 항목 꼴: `ABB11100010S` 「굴착(인력/토사) / 보통토사, 깊이 1m 이하」 · `CDE10010000S` 「굴착(인력/토사) / 보통토사, H=1m 이하」 · `CRB11000035S` 「돌쌓기 / 메쌓기 / 뒷길이 35cm」 · `CDA21000000S` 「벌개제근 / 벌목」. S 의 공식 뜻은 확인 못 함 · 코드에 품셈 조항 번호가 담기지는 않음(관찰). + - 옛 이름 · 새 이름이 이웃 코드로 함께 실림 — `ABB111100000` 「인력터파기 / 보통토사,0∼1m」 ↔ `ABB11100010S` 「굴착(인력/토사) / 보통토사, 깊이 1m 이하」(앞 6자 `ABB111` 같음) · `CDA210100100` 「벌목 / 5m미만」 ↔ `CDA21000000S` 「벌개제근 / 벌목 / H=5.0m 미만」 · `CRB101035000` 「돌쌓기/메쌓기」 ↔ `CRB11000035S` 「돌쌓기 / 메쌓기」. 코드 앞부분이 같은 묶음 = 같은 뜻 말 후보(관찰 3건 — 전체 규칙인지는 확인 못 함). + - 임도 · 사방 고유 공종 없음 — 품명에 「임도」「사방」「골막이」「기슭막이」「바닥막이」「떼붙임」「씨뿌리기」 0건. 일반 토목은 있음 — 돌붙임 20 · 메쌓기 7 · 찰쌓기 7 · 파형강관 85 · 측구 53 · 벌개 6 · 굴착 72 · 터파기 211. 「구조물터파기」 0건 · 「인력터파기」 36건. + - 예: 「조달청은 정부 전기공사의 2,300여 주요 공종」 일위대가표를 코드화해 공개한 적 있음(산업일보 2012-07-27) — 일위대가 코드화 선례. +- 실무 엑셀에 있나: 없음 — 세부공종코드 · 표준시장단가 꼴 0건(6개 파일). STmate 산출 xlsx 숨은 칸 코드는 프로그램 자체 코드. +- 이름 대신 코드로 잇기: 엑셀 입력엔 못 함. C3R XML 을 받는다면 `일위대가코드` · `자원코드` · `공량품셈근거` 칸이 이름보다 단단한 열쇠 — 다만 실제 납품 XML 에 표준공사코드가 실리는지는 확인 못 함(저장소에 C3R XML 견본 없음). +- 우리에 맞나: + - 사전 재료로 맞음 — 무료 · 오프라인 CSV 한 벌 · 품명 / 규격 / 단위 정규 표기 + 옛 · 새 이름 묶음 · 품셈 꼴 이름(S 코드). 예: 엑셀 「인력터파기」류 ↔ 우리 「굴착(인력/토사)」를 같은 코드 묶음으로 잇는 근거 후보. + - 빈틈 — 산림 고유 공종이 없음 · 임도 · 사방 실무 이름(「구조물터파기(인력100%)」 등)은 그대로 실려 있지 않음. +- 판정: **조건부** — 「같은 뜻 말 · 정규 표기 사전 재료」로는 바로 쓸 만함(내려받아 서버에 두면 됨), 「코드로 잇기」는 C3R XML 입력을 받을 때만. + +### 1-5. 산림사업 표준품셈 (산림청) + +- 무엇 · 판: 산림청고시 제2025-82호 「산림사업 표준품셈」 · 제정 · 발령 2025-11-26 · 시행 2026-01-01 · 현행(법제처 행정규칙 XML). 목적 = 「「산림기술 진흥 및 관리에 관한 법률」제15조 … 에 따른 산림사업의 실시설계 시 적용할 산림사업 표준품셈을 규정」. 재검토기한 = 「매 3년이 되는 시점」. 부칙 경과조치 = 「「임도설치 및 관리 등에 관한 규정」 및 기타 관계 법령에 따라 … 계약이 체결된 경우는 종전의 규정에 따르며」 → 2025년까지 실무 엑셀의 「임도품셈」은 다른 문서(종전 규정)를 가리킴. +- 앞선 판: 「산림청, '산림사업 표준품셈' 책자 첫 발간」(데일리안 2018-05-17) — 「제1편 기술부문에 설계와 감리」 · 「제2편에는 기능부문으로 산림경영·산림보호·산림공학」 · 「산림공학분야는 사방·임도·숲길로 편성」 · 「산림청 누리집(통합자료실 전자북)에서 누구나 내려 받을 수 있다」. +- 코드 체계: 2026 고시 별표 = 14장 · 번호 꼴 장-절(예: 「5-12 떼붙임(재배잔디)」 · 「5-18 씨뿌리기(줄)」)(저장소 `original/원가계산/산림_표준품셈/_meta.md` 7 · 8 · 16줄 · `본문/` 폴더 이름). 2018 책자는 사업별 편(산림공학 → 사방 · 임도 · 숲길), 2026 고시는 공종별 장(제9장 토공 · 제13장 구조물 …) → 판 사이 번호 연속성 없음. 연도별 대응은 저장소 쪽(다른 포크)에서 확인. +- 실무 엑셀에 있나: 책 이름만 — 「임도품셈」「산림사업품셈」「임도표준품셈」, 조항 번호 거의 없음(위 실측). +- 이름 대신 코드로 잇기: 못 함. +- 우리에 맞나: 우리 일위대가 로직의 1차 근거라 「항목 제목 = 기준말」로 최우선 재료. 옛 「임도 품셈」 ↔ 2026 고시 대응이 있어야 2025년 이전 실무 엑셀과 이음. +- 판정: **조건부** — 항목 제목을 기준말 사전으로 쓰는 것은 바로 쓸 만함, 번호로 잇기는 판 대응표가 붙어야 함. + +### 1-6. 한국물가정보(KPI) · 물가자료(한국물가협회) 자재 코드 + +- 한국물가정보: 첫 화면 분류 링크에 `CATE_CD=1010` 공통자재 · `1015` 토목자재 · `1018` 조경자재 · `1020` 건축자재 …(4자리) · `CATE_CD=10100615`(8자리) 같은 계층 코드가 보임(kpi.or.kr). 데이터 연동 = 「한국물가정보 데이터제공서비스(KPI-Data Provide Service) — 물가정보 데이터를 고객사의 시스템과 연동하여 … 제공하는 서비스」 · 메뉴 이름 「DPS(DB판매)」(유료). 품목 단위 코드의 공개 여부 · 자릿수는 확인 못 함. +- 한국물가협회(물가자료): 공식 페이지를 찾지 못함 — 코드 체계 확인 못 함. +- 실무 엑셀에 있나: 없음 — 2026 북부청 임도 `자재단가!R3` 머리 = 품명 · 규격 · 단위 · 가격정보(단가 · page) · 물가자료(단가 · page) · 유통물가(단가 · page) · 물가정보(단가 · page) · 거래가격. 물가지는 쪽 번호로만 가리킴(예 `R29` 다이아몬드톱날 — 물가자료 1414쪽 · 물가정보 1080쪽). 다산 ESTX 도 같은 6출처 슬롯(`original/원가계산/다산소프트/ESTX_데이터관계.md` 62줄). +- 이름 대신 코드로 잇기: 못 함 — 쪽 번호는 월호마다 바뀌고 코드는 유료 · 비공개. +- 판정: **안 맞음**. + +### 1-7. 조달청 물품목록정보 · 나라장터 시설자재 가격 + +- 코드 체계: 물품분류번호 = 8자리 숫자 · 4단계 계층 · 세부품명번호 = 최대 10자리 · 물품식별번호 = 최대 8자리(공공데이터포털 15131726 칸 정의). 분류 바탕 = UNSPSC — 「2003년 … UNSPSC … 를 정부물품 분류체계로 도입」 · 2024-10 「21년만에 전면 개편」 · 물품분류(품명) 9,900개 · 품목 등록정보 512만개(보안뉴스 2024-10-14). +- 가격: 나라장터 가격정보현황서비스 API(`apis.data.go.kr/1230000/ao/PriceInfoService`) — 저장소 사본 27,485행 · 칸 `prdctClsfcNo`(8자리) · `prdctIdntNo`(8자리) · `krnPrdctNm` · `unit` · `prce`(`original/원가계산/자재단가/_meta.md` 11 · 17줄 · CSV 1행). 철근 · 레미콘 · 아스콘 없음(같은 파일 17줄). +- 실무 엑셀에 있나: 일부 — 2026 북부청 임도 `자재단가!R30C7` 파형강관 D=800 → 「21280926」 · `R31C7` 파형강관 이음관 → 「21280982」 · `R61C7` 임도안내판 → 「25872137」(가격정보 page 칸) · 사방 `내역서!R188C3` 「계도사인(25872124)」 · `R189C3` 「국가지점번호판(25872138)」(품명 괄호). 꼴은 물품식별번호(8자리)와 같음 — 이 번호들의 실제 식별번호 대조는 확인 못 함(저장소 2026-08-14 CSV 엔 없음). +- 이름 대신 코드로 잇기: 8자리 번호가 보이는 자재 줄만 가능. 물가지 출처 자재 · 노무 · 장비 줄엔 해당 없음. 일위대가 호표 이름 맞춤 자체엔 직접 도움 없음. +- 판정: **조건부** — 자재 줄에서 8자리 번호가 보이면 「번호 먼저, 이름은 그다음」. + +### 1-곁. 곁 메모(다른 절에 쓸 것) + +- 저장소 venv 확인(2026-10-15): `fitz`(PyMuPDF) · `pypdf` · `openpyxl` · `xlrd` 있음, `rapidfuzz` · `kiwipiepy` · `scikit-learn` 없음. +- 이름 차이 실례(사전 거리): 「굴삭기」↔「굴착기」 · 「뒷채움」(실무)↔「뒤채움」(2026판 3-4-4) · 「인력터파기」(조달청 옛 코드)↔「굴착(인력/토사)」(2026판 3-2-1 · 조달청 S 코드 · 우리 이름). + +### 1-출처. 실제로 연 페이지 + +- 산업일보 2019-01-02 「올해 표준시장단가 3.39%↑, 1일부터 적용」 — https://kidd.co.kr/news/206547 +- 조선비즈(다음) 2025-12-22 「국토부, 2026년 적용 건설공사 표준품셈·표준시장단가 공고」 — https://v.daum.net/v/20251222110205258 +- 뉴스서울 「국토교통부, 2026년 적용 건설공사 표준품셈·표준시장단가 공고」 — https://newsseoul.co.kr/news/view/1065583819330007 +- 뉴스서울 2024-12-26 「국토교통부, 2025년 적용 표준시장단가·표준품셈 공고」 — https://www.newsseoul.co.kr/news/view/1065586820132417 +- 2024 상반기 표준시장단가 신구대비표 PDF(블로그 파일 저장소 사본 · 비공식 게시처) — https://files-scs.pstatic.net/2024/01/02/W86QynSheM/3._[공고자료]_2024년도_상반기_건설공사_표준시장단가_신구대비표.pdf +- 공공데이터포털 「조달청_공사원가_세부공종코드」 메타 — https://www.data.go.kr/biz/dcat/metadata/15151273.do +- 공공데이터포털 같은 파일 페이지 — https://www.data.go.kr/data/15151273/fileData.do · 내려받기 https://www.data.go.kr/cmm/cmm/fileDownload.do?atchFileId=FILE_000000003514458&fileDetailSn=1&insertDataPrcus=N +- 서울시 「XML 안내」(C3R) — https://news.seoul.go.kr/gov/archives/202400 +- 산업일보 2012-07-27 「전기공사 설계 내역서 표준화 한다」 — https://kidd.co.kr/news/157668 +- 건설산업연구원 1998 「건설정보 분류 체계의 표준화」 — https://www.cerik.re.kr/report/issue/117 +- 한국정보처리학회 2012 「국내외 건설정보 분류체계 개발 동향에 관한 연구」 — https://koreascience.kr/article/CFKO201221868477879.page +- 한국산학기술학회논문지 2014 「건설정보 분류체계의 BIM 도입을 위한 개선방안」 — https://koreascience.kr/article/JAKO201433150757488.do +- 한국산학기술학회논문지 2015 「건설정보 분류체계의 BIM 수용을 위한 확장목록 개발」 — https://koreascience.kr/article/JAKO201525550548312.do +- 데일리안 2018-05-17 「산림청, '산림사업 표준품셈' 책자 첫 발간」 — https://m.dailian.co.kr/news/view/713952 +- 법제처 행정규칙 「산림사업 표준품셈」 XML — https://www.law.go.kr/DRF/lawService.do?OC=test&target=admrul&ID=2100000268530&type=XML +- 한국물가정보 첫 화면 — https://www.kpi.or.kr · KPI-DPS 안내 — https://www.kpi.or.kr/www/cost/api_service_01.asp?CFG_CD=dps01 +- 공공데이터포털 「조달청_디지털서비스몰 계약상품정보」(물품분류번호 · 세부품명번호 · 물품식별번호 칸 정의) — https://www.data.go.kr/data/15131726/fileData.do +- 보안뉴스 2024-10-14 「조달청, 정부 물품목록제 21년만에 전면 개편」 — http://www.boannews.com/news/articleView.html?idxno=133514 + +열지 못한 곳(출처로 안 씀): 국토교통부 정책자료 페이지(되돌림 반복) · CODIL(인증서 오류) · npccs.g2b.go.kr(본문 빔) · 법제처 한글주소 「건설정보분류체계적용기준」(오류 페이지) · 신한옥 분류체계 PDF(한글 글자층 없음). + +--- + +## 2. 국내 견적 · 적산 프로그램 — 외부 내역 · 일위대가 이름을 어떻게 잇나 + +### 2-0. 요약 + +- 시중 프로그램은 「이름 자동 맞춤」을 공개 설명에 내세우지 않음. 관찰된 관행 = **자원(노임·자재·중기·경비)은 코드로 · 일위대가·단가산출 머리는 사람 검색·선택으로** 잇기. +- 다산 EST: 조달청 표준공사코드 적용 때 노임·자재·표준(실적)단가·기계경비는 [코드일괄변경] 자동, **일위대가·단가산출은 일괄변경 불가** → 공종별 검색 뒤 [코드변경], 없으면 분류 골라 [사용자정의코드]. +- STmate: 기준자료 갱신 매칭 = **정확 일치 키 5단 내리기**(호환규정코드 → 시설코드 → 명칭·규격·단위 → 호환+명+규+단 → 명칭·단위). 퍼지 점수·학습 흔적 없음. 엑셀 재등록은 STmate가 만든 엑셀(전체계산식)만. +- 이 조사 실측(실무 STC 7건): STmate 코드(B00001·L00002 등)는 **프로젝트 안 순번** — 같은 코드가 프로젝트마다 다른 항목. 국가 호환코드(JDCODE) 채움 = 노임 86/97 · 중기 117/176 · 경비 123/166 · 자재 13/318 · **일위대가 0/216 · 단가산출 0/371**. +- 조달청 의뢰 연구(2020): C3R 검증은 코드 사용 여부·자료 구조만 봄 · 「표준 공사코드와 내역항목의 일치 및 수정에 대한 방법론은 구축되어 있지 않은 상태」 · 설계사가 과거 내역서 품명·규격·단위·코드를 복사 · 텍스트 마이닝·딥러닝 자동 맞춤은 장기 과제로만 제안. + +### 2-1. 웹 — 프로그램별 공개 설명 + +| 프로그램(회사) | 무엇(공개 설명 원문) | 어떻게 잇나 | 우리에 맞나 | 출처 | +|---|---|---|---|---| +| STmate (경영정보(주)) | 설계내역 특징 「명칭 및 코드를 간단히 검색」 · 견적내역 「조달청 등의 전산내역입찰서를 작성」 · 계약실행 「전산내역입찰서를 계약내역서로 이용」 · FAQ 「STmate에서 작성된 엑셀파일(전체계산식)을 STmate자료로 다시 등록」 · FAQ 「공사목록표에서 공사원가 호환규정 포맷(XML,조달청)으로 변환」 · 사용설명서는 정회원 전용(확인 못 함) | 코드·이름 검색(사람) · 자기 출력 엑셀 왕복 · 조달청 XML(코드 기반) | 타사 엑셀 이름 맞춤 기능 아님 — 자기 엑셀 왕복용 | W1 · W2 · W3 | +| EST+4 · Total EST+4 (다산소프트) | 「엑셀변환을 지원합니다. (단가산출까지 수식이 연결되어 변환되고, 엑셀로 변환후에는 편집없이 바로 출력이 가능합니다.)」(내보내기) · 「조달청포맷파일(JDL)로 변환을 지원합니다.」 · 품셈검색 · 엑셀 불러오기 설명 없음 | 엑셀→EST = 고객지원 「착공내역서요청」·「기술문의」 게시판에 엑셀 내역서를 붙여 요청 → 「접수(11/12)→완료(11/12)」 꼴 회신. 프로그램 기능인지 사람 대행인지 확인 못 함 | 사람 대행형 — 안 맞음 | W4 · W5 · W6 · W7 · W8 · W9 · W10 | +| 다산 EST 표준공사코드 적용(공지 2012·2022) | 「설계서에 입력된 모든 자료에 표준공사코드를 반드시 적용하여야함」 · 1개라도 누락되면 XML 변환 안 됨 · 메뉴 [도우미→조달청 표준공사코드 적용] | 노임·자재·표준(실적)단가·기계경비 = [코드일괄변경] 자동 · 자동 안 된 것은 가장 유사한 항목 골라 [사용자정의코드] · **일위대가·단가산출 = 「'코드일괄변경'이 적용되지 않으므로」 공종별로 「단산,일위 코드」에서 검색 뒤 [코드변경]** · 검색 안 되면 「공종분류」 탭 분류 선택 뒤 [사용자정의코드] | 일위대가 머리는 사람 확인이라는 우리 처지와 같은 결론 — 관행 근거로 씀 | W11 · W12 · W13 | +| SGS (나스) | 「엑셀(Excel)로 작업된 내역을 SGS내역자료에 맞게 아주 간단히 읽어 들일 수 있는 기능을 업계 최초로 내장」 · 대상 「자재, 노임, 견적.기타, 표준시장단가, 건설기계, 단가산출, 일위대가, 예산내역서, 원가계산서」 · 「선택 영역(열, 행)만 읽어오기, 전 영역(열, 행) 읽어오기」 · 「외부자료 변환기능」 · 「코드 또는 공종으로 해당자원들 … 자동입력」 · 「코드 또한 사용자정의 방식」 · 조달청 XML 읽기/쓰기 | 열·행 지정 읽기 + 코드·공종 기준 자원 입력. 이름 맞춤 방식 설명 없음(확인 못 함) | 열 지정 읽기 층만 같음 · 맞춤 참고 못 됨 | W14 · W15 | +| Easy 2.0 건설 (건설연구원) | 「엑셀불러오기 - 엑셀로 작성한 내역서를 프로그램으로 불러옵니다.」 · C3R 적합 인증 2017-10-19 · 「2026년 6월 1일 현재 재인증 준비중」 | 맞춤 방식 설명 없음(확인 못 함) | 근거 부족 | W16 · W17 | +| 에니윈2000 | 「이 일위대가는 에니윈2000 을 사용하면 자동연결되어 견적됩니다.」 · 자재+일위대가 12만여 개 · 삼사단가 | 같은 회사 자료↔같은 회사 프로그램 사이 연결(맞춤 문제 자체 없음) | 외부 문서 맞춤 아님 — 안 맞음 | W18 | +| 콘엑스 (AI 적산, 웹) | 페이지 구조화 자료 featureList 「AI 기반 일위대가 자동 검증」 · 「표준품셈 실시간 대조」 · 설명 「일위대가를 자동 검증합니다」 | 방법·근거·성능 비공개(확인 못 함) | 오프라인·근거 설명 요구에 맞는지 판단 불가 | W19 | +| RevitQ (명진소프트컨설팅, 2013 기사) | 「수량/산출 코드와 Revit 모델링 데이터 간의 코드 매핑(mapping) 작업」 · 「코드 매핑 작업이 완료된 … 파일을 사용자 라이브러리(Library)로 등록」 · 다른 프로젝트에서 재사용 | 사람이 한 매핑을 라이브러리로 저장·재사용(BIM 쪽) | 「확정 매핑 기억」 발상만 참고 | W20 | + +### 2-2. 웹 — 조달청 생태계 규칙(프로그램이 따르는 열쇠) + +| 시점 | 내용 | 출처 | +|---|---|---| +| 2010-07-01 | 조달청 계약 요청 시설공사 = 호환규정 표준코드 적용 내역서 제출 · 다산 2010-08-02 인증 | W13 | +| 2012-01-01 | 호환규정·코드 준수 XML 제출 · 코드 1개라도 누락이면 XML 변환 안 됨 · C3R 검증 2013-08 나라장터 이관 | W12 | +| 2017-07-01 | 표준 코드 의무 — 「노무비, 표준시장단가, 시장시공가격 -> 매칭율 100%」 · 건축 일위대가 850개 「매칭율 80% 이상」 | W21 | +| 2025-05 · 2025-09-24 | 「올해 5월 가격정보와 산출근거를 포함하여 보다 내실 있는 코드체계로 개편」 · C3R 인증 원가계산 프로그램사 11곳 간담회(이름 미기재) | W22 | + +한국조달연구원 「공사코드 표준화를 통한 유형별 공사비 산정방안 연구」 최종보고서(2020-11, 조달청 제출) — W23 + +| 쪽 | 내용 | +|---|---| +| p.16 | 국도설계실무요령 대공종 ↔ 표준공사코드 대분류 1:1 대응 어려움 · 「대부분 적산프로그램에 의해 산출된 과거 내역서 항목을 그대로 불러와 내역서를 작성하는 문제」 | +| p.17 | 국도31호선 양구-원통 설계내역서 C3R 검증 → 표준공사코드 사용률 27.2% · 표준시장단가·시장시공가격·노무단가는 대부분 준수, 세부공종·자재 코드는 미준수 | +| p.18~19 | 도로공사 44건 세부공종 구성: 중기단가산출 54.79% · 일위대가 4.77% · 자재 6.86% 등 = 표준코드 미사용(임의코드) | +| p.20 | 조달청 자원코드 = UNSPSC 기반 8자리 + 식별번호 8자리 = 16자리 | +| p.27~28 | 업체 임의코드는 「1번의 코드 수집을 통해 자동 변환이 가능」(코드↔코드 대응표) · 「코드가 없이 품목과 규격만 존재하는 경우에는 프로젝트 마다 인위적인 표준공사코드를 부여」 → 텍스트 마이닝 접목 필요 | +| p.29 | 미준수 3갈래 — ①표준코드 없음 ②있는데 안 씀 ③「공사코드 품명을 잘못 연결한 경우」 | +| p.30 | 설계사가 과거 내역서 품명·규격·단위·공사코드를 그대로 복사 = 준수율 저조 주원인 · C3R 은 사용 여부와 자료 구조만 검증 · 「표준 공사코드와 내역항목의 일치 및 수정에 대한 방법론은 구축되어 있지 않은 상태」 | +| p.31 · p.35 | 장기 = 텍스트 마이닝·딥러닝 알고리즘 · 「인공지능 기반 공사코드 자동 매칭 시스템」 | + +⇒ 국가 차원에서도 「이름 → 코드」 자동 맞춤은 2020년 기준 미해결 과제. 그 뒤 진척은 확인 못 함. + +### 2-3. 저장소 — STmate · 다산이 무엇으로 식별하고 무엇으로 잇나 + +경로 머리 생략: `K/` = `resources/knowledge/original/` · `ST/` = `K/경쟁사 프로그램/STmate 분석/` + +#### 2-3.1 STmate + +| 항목 | 내용 | 근거(파일:줄) | +|---|---|---| +| 식별 칸 | `CODE`(C6) + `NAME` + `SIZE` + `UNIT` · 연결 칸 `JDCODE`(C17 호환규정코드) · `EQCODE`(시설) · `BQCODE` 등 | `ST/20_분석/02_데이터셋과_파일형식.md:44-46` · `K/원가계산/STmate/STC_필드사전_핵심.md:88-91, 110` | +| 코드 종류 | 노무·자재·경비·**대가(=일위대가 `B`)**·중기·**산근(=단가산출 `D`)**·기타·실적 | `ST/20_분석/20_설정파일과_보고서_카탈로그.md:79-83` | +| 프로젝트 코드는 순번 (이 조사 실측) | 실무 STC 7건 `COSTN.DBF`: 같은 코드가 프로젝트마다 다른 이름 — B 158 중 30 · D 273 중 50 · L 37 중 23 · M 228 중 43 · S 92 중 31 · X 96 중 35 (예 `L00002` = 보통인부 / 철근공 / 형틀목공) | 측정 `scratchpad/req225/p2_codecheck.py` → `p2_codecheck.txt` | +| 국가 호환코드 채움 (이 조사 실측) | `JDCODE` 채움 = L 86/97 · X 117/176 · S 123/166 · M 13/318 · **B 0/216 · D 0/371** · W 0/17. 예 `L00002 형틀목공 → L001010101000007` · `M00012 휘발유 → 1510150620282203`(앞 8자리 15101506 = UNSPSC 휘발유, 조달연 p.20 의 16자리 구조와 맞음) · `X00002 굴삭기(무한궤도) 0.7㎥ → 00000201007000000` | 측정 `scratchpad/req225/p2_jdcheck.py` → `p2_jdcheck.txt` | +| 출력 엑셀에 찍히는 코드 | 인쇄메뉴 `PM_Prt` 의 코드·비고 칸 선택지 = 「코드번호 인쇄, 번호(호표) 인쇄, 분류번호 인쇄, 시설코드 인쇄, 호환규정코드(, 적용기준일, 수공분류코드)」 → 엑셀에 호환코드가 보이는지는 설계자 인쇄 설정에 달림 | `ST/30_원자료/ui_form_catalog.txt:5-10` | +| 기준자료 갱신 매칭 `wM_Mrg_BSC` | 「매칭 기준(5단계) 호환규정코드 / 시설코드 / 명칭·규격·단위 / 호환+명+규+단 / 명칭·단위 / 설정안함」 · 콤보 5개(`cBx_Sel1~5`)로 우선순위 지정 · 명칭·단가·조달코드는 복사처/복사원 적용 선택 → **정확 일치 키를 차례로 내림** | `ST/20_분석/22_UI_폼_분석.md:116-124` · `ST/20_분석/34_화면구조_정리.md:62` · `ST/30_원자료/ui_form_catalog.txt:140-145` | +| 호환규정코드 찾기 `wM_JDCode` | 기준 「명칭 / 명칭·규격 / 분류번호 / 시설코드 / 선택안함」 · 검색 「명칭·규격·단위 / 호환규정코드 / 군급목록번호 / 중기품셈코드 / 시설코드번호」 → **사람 검색·선택** | `ST/20_분석/34_화면구조_정리.md:61` · `ST/30_원자료/ui_form_catalog.txt:116-118` | +| 단가 목록 검색 `wM_JabPum` | 검색 「명칭,규격 / **산근내용**」 → 구성 글로도 찾음 | `ST/20_분석/34_화면구조_정리.md:56` | +| 자원 사전 | `NAME` 과 `ST_NAME`(표준명칭) 별도 칸 · `SIZE` 110자 · 자재 명칭 1,761종 : 코드 57,580건(규격이 난관) · `ST_NAME` 은 경비·중기만 채움 | `ST/20_분석/29_암호화자료의_뼈대.md:21-26, 69, 76, 79` | +| 엑셀 왕복 | `CONV_XLS_2_ST`(엑셀→STmate 역등록) · `XLS_2_BDREC` · 행을 무슨 키로 잇는지는 확인 못 함(사용설명서 정회원 전용) | `ST/20_분석/24_모듈구조_전수.md:61-69` · W3 | +| 숨김 `〓 INITIAL 〓` 시트 | 계산 설정 스냅샷(공사정보 33 · 초기값 52 · 인쇄정보 75) · 행↔코드 매핑 규칙은 미착수 | `ST/20_분석/18_누락_계산로직_점검.md:81-101` · `K/원가계산/STmate/XLSX_INITIAL_구조.md:32` | +| 조달청 XML | `Conv_Jodal_ST8` ↔ `Conv_ST8_XML` 양방향 · XSD V1.0.5 · 내역공종코드 16→20자(2017-04-19) | `ST/20_분석/28_변환_수량산출_식언어.md:72-88` · `ST/20_분석/30_조달청_호환규정_XSD.md:19-30` | +| 일위대가 조합 350개 | 「건설표준품셈」 근거가 23조합에 글로 남음(`▣ 토공의 비탈규준틀[개소]2-6-1` 꼴) · 자원 이름 잇기에 `stmate_code_name_list.csv` 를 쓰자는 제안 | `ST/20_분석/31_일위대가_레시피_사전.md:112, 141` — ⚠ 그 CSV 의 코드 칸은 프로젝트 순번(위 실측)이라 열쇠로 못 씀, 이름·규격·단위 칸만 씀 | + +#### 2-3.2 다산 EST (2026-07 주자료 ESTX) + +| 항목 | 내용 | 근거(파일:줄) | +|---|---|---| +| 2층 구조 | 머리(HeadTitle 일위대가 1,468 · CostTitle 단가산출 1,569 · MachTitle 기계경비 1,049) ↔ 구성(*Main) 의 `TitleCode` 전수 일치 | `K/원가계산/다산소프트/ESTX_데이터관계.md:5-10` | +| 구성행이 원천을 잇는 열쇠 | 구성행 `Code` + `LinkIndex`(5 자재 · 6 노임 · 103 일위대가 · 104 단가산출 · 105 기계경비) = 코드 참조 | `K/원가계산/다산소프트/ESTX_데이터관계.md:16-38` | +| 식별 | `Code`(접두 U 일위대가 · D 단가산출 · E 기계경비 · M 자재 · L 노임 + 10자리) + `Name` + `Size` + `Unite` | `K/원가계산/다산소프트/ESTX_값사전_견적내역.md:88-91, 449-452` · `ESTX_값사전_자재노무기계.md:14-17, 182-185` | +| 이름은 규격으로 갈림 | 일위대가 머리 1,468건에 `Name` 347종 · `Size` 748종 | `ESTX_값사전_견적내역.md:449-451` | +| 표준시장단가 코드 | `Code` 대표값 `AA210.90110` 꼴 | `ESTX_값사전_자재노무기계.md:251` | +| 품셈 조항·조달 코드 칸 | 확인된 필드 범위에 전용 칸 없음(비고 `Bigo` 글만) | `ESTX_값사전_견적내역.md:440-500` | +| 엑셀 관련 | `ExcelSheetIndex` 칸 있으나 뜻 확인 불가 · `ExcelMullangList` 0건 | `ESTX_값사전_견적내역.md:397` · `ESTX_테이블사전.md:41` | +| 코드의 판 사이 안정성 | 확인 못 함(비교 스냅샷에서 `Code` 다른 행 존재 — CostMain 895) | `ESTX_데이터관계.md:69` | + +### 2-4. 관찰된 패턴 + +1. **같은 원천이면 코드로 자동** — 에니윈(자사 자료) · 다산 자원 [코드일괄변경] · STmate 갱신 1순위 호환규정코드. +2. **정확 일치 키 단계 내리기** — STmate 5단(코드 → 명+규+단 → 명+단). 퍼지·학습 없음. +3. **일위대가·단가산출 머리 = 국가 코드 거의 없음 → 사람 검색·선택 + 사용자정의코드** — 다산 공지 · 실측 B·D 의 JDCODE 0 · 조달연 p.18~19. +4. **타사 엑셀은 「읽기」(열·행 지정)까지만 공개** — 이름 맞춤 방식 공개 자료 없음(나스 · Easy). 다산은 변환 요청 게시판. +5. **이름 → 코드 자동 맞춤(텍스트 마이닝·AI)** = 조달연 장기 제안 단계 · 시중 AI 제품은 방법 비공개. +6. **확정 매핑 재사용** — RevitQ 사용자 라이브러리 · 조달연 「1번의 코드 수집」 대응표. + +### 2-5. 판정 — 우리 M02 에 가져올 것 + +| 방법(출처 패턴) | 판정 | 까닭 | +|---|---|---| +| 사람 검색·선택 + 사용자정의 표시(다산·STmate 공통) | 바로 쓸 만함 | 시중 표준 관행 · 「사용자가 고르는 도구」 원칙과 같음 · 새 의존성 없음 | +| 확정 매핑 기억(외부 이름+규격+단위 → 우리 일위대가) 재사용 (RevitQ 라이브러리 · 조달연 대응표) | 바로 쓸 만함 | 같은 설계사·같은 실무 양식이 되풀이되는 임도·사방에 맞음 · 표 하나로 됨 | +| 정확 일치 키 단계 내리기(정규화 뒤 명+규+단 → 명+단) | 바로 쓸 만함 | 계산 싸고 설명 쉬움 — 1차 거름으로 · 이름 차이 자체는 못 풂 | +| 구성 내용으로 찾기(STmate 「산근내용」 검색 · 다산 구성행 코드 참조) | 바로 쓸 만함 | 지금 단가산출 탭 대조와 같은 방향 — 구성 자원(노임 직종·장비)이 머리 이름보다 안정 | +| 자원 줄을 국가 호환코드(JDCODE)로 잇기 | 조건부 | 노임·중기·경비는 STC 에 코드가 대부분 있음 · 엑셀에는 인쇄 설정에서 「호환규정코드」를 골랐을 때만 보임 — 실무 엑셀 포함 여부 실측은 다른 조사 몫 | +| STC 파일을 직접 받아 읽기 | 조건부 | 엑셀보다 열쇠가 많음(`COSTN` 의 JDCODE·NAME·SIZE·UNIT 평문 + `COSTN.SMT` 산근 전개 평문 — `ST/20_분석/02_데이터셋과_파일형식.md:50-52`) · 발주처가 STC 를 줄 때만 · STC import 는 이미 필수 요구(`K/원가계산/STmate/_meta.md:22`) | +| AI·텍스트 마이닝 자동 맞춤(조달연 장기안 · 콘엑스) | 조건부 | 공개된 방법·성능 근거 없음 · 오프라인·무거운 의존성 부담 — 후보 순위 보조로만 | +| 일위대가 머리를 국가 코드로 잇기 | 안 맞음 | 실무 STC 일위대가 0/216 · 단가산출 0/371 · 조달연도 일위대가·중기단가산출은 임의코드로 봄 | +| STmate 프로젝트 코드(B00001 등)로 잇기 | 안 맞음 | 프로젝트 안 순번 — 같은 코드가 다른 항목(실측) | +| 엑셀 → 프로그램 변환 대행(다산 게시판) | 안 맞음 | 사람 서비스 — 기능 아님 | + +### 2-6. 확인 못 함 + +- STmate `CONV_XLS_2_ST` 가 엑셀 행을 무슨 키로 잇는지 (사용설명서 정회원 전용) +- 다산 엑셀 → EST 변환이 프로그램 기능인지 사람 대행인지 +- 나스 SGS · Easy 2.0 엑셀 읽기 뒤 이름 맞춤 방식 +- 콘엑스 AI 검증 방법·성능 +- 조달청 2025-05 개편 코드에 토목 일위대가·단가산출 코드가 생겼는지 (항목 1 몫) +- 다산 내부 코드(U·D·E·M·L+10자리)의 판 사이 안정성 + +### 2-출처 + +#### 웹 (실제로 연 페이지 · W1~W3·W18·W19 는 curl 로 받음) + +- W1 http://www.stma.co.kr/new_program/?pIdx=program2 +- W2 http://www.stma.co.kr/cust/?pIdx=cust&B_Name=center&b_dir=FAQ_bbs&b_url=list&category=cust2 +- W3 http://www.stma.co.kr/cust/?pIdx=cust&B_Name=center&b_dir=bbs&b_url=list&category=cust1 +- W4 https://www.dasansoft.com/products/?mname=products01&sub=2&pageN=1 +- W5 https://www.dasansoft.com/products/?mname=products02&sub=2&pageN=2 +- W6 https://www.dasansoft.com/products/?mname=products03&sub=2&pageN=3 +- W7 https://dasansoft.com/support/?file=dd_view&grp=1000&id=2121&mname=support05&mode=view&no=1866&offset=450&page=31&pageN=3&sh_category_id=&sub=0&table=statement +- W8 https://www.dasansoft.com/support/index.html?mname=support05&file=dd_list&table=statement&oid=535&sh_keyword=&sh_kind=&sh_category_id=&sub=0&pageN=3&page=112 +- W9 https://dasansoft.com/support/?file=dd_view&grp=12587&id=21360&mname=support03&mode=view&no=18887&offset=4035&page=270&pageN=1&sub=0&table=techqna +- W10 https://dasansoft.com/support/?file=dd_view&grp=17116&id=30408&mname=support03&mode=view&no=27338&offset=855&page=58&pageN=&sh_category_id=&sub=&table=techqna +- W11 https://www.dasansoft.com/customer/?mname=customer01&file=dd_view&table=notice&mode=view&id=4335&grp=4321&sub=3&pageN=1 +- W12 https://dasansoft.com/customer/?file=dd_view&grp=4078&id=4078&mname=customer01&mode=view&no=201&offset=0&page=1&pageN=&sh_category_id=&sub=3&table=notice +- W13 https://www.dasansoft.com/customer/?mname=customer01&file=dd_view&table=notice&mode=view&id=4045&grp=4045&sub=3&pageN=1 +- W14 https://nas.co.kr/product/features +- W15 https://nas.co.kr/nas/product/sgs10.php +- W16 https://www.04info.co.kr/products/1489130729/ +- W17 https://www.04info.co.kr/customer/01/v/330 +- W18 http://www.ew2002.com/office/i_ilwi.htm +- W19 https://ddusul.com/ +- W20 https://www.cadgraphics.co.kr/newsview.php?pages=review&sub=review01&subcate=24&catecode=24&num=4777 +- W21 https://www.glotechsoft.com/noticeView?F_NUM=5936 +- W22 https://www.pps.go.kr/kor/bbs/view.do;jsessionid=glV5dbwk1zSkvZI-PCz7tW9wZBCrSqUlHorpQtAVbR_j7QyrAvaj!286171551?key=00634&bbsSn=2509240009 +- W23 https://www.kip.re.kr/src/uploads/20221121/%E2%98%85(%ED%99%95%EC%A0%95)+%EA%B3%B5%EC%82%AC%EC%BD%94%EB%93%9C+%ED%91%9C%EC%A4%80%ED%99%94%EB%A5%BC+%ED%86%B5%ED%95%9C+%EC%9C%A0%ED%98%95%EB%B3%84+%EA%B3%B5%EC%82%AC%EB%B9%84+%EC%B5%9C%EC%A2%85%EB%B3%B4%EA%B3%A0%EC%84%9C+1216.pdf (pdftotext 로 본문 추출 · 쪽 번호 = 인쇄 쪽) + +#### 저장소 (읽기만) + +- `resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/` 00 · 02 · 18 · 20 · 22 · 24 · 28 · 29 · 30 · 31 · 34 +- `resources/knowledge/original/경쟁사 프로그램/STmate 분석/30_원자료/ui_form_catalog.txt` · `stmate_code_name_list.csv` +- `resources/knowledge/original/원가계산/STmate/` `_meta.md` · `STC_필드사전_핵심.md` · `XLSX_INITIAL_구조.md` +- `resources/knowledge/original/원가계산/다산소프트/` `_meta.md` · `ESTX_분석.md` · `ESTX_테이블사전.md` · `ESTX_데이터관계.md` · `ESTX_값사전_견적내역.md` · `ESTX_값사전_자재노무기계.md` +- 실측 대상: `resources/knowledge/original/실무문서/**/*.STC` 7건 (`COSTN.DBF`) + +#### 이 조사의 측정 (스크래치패드) + +- `req225/p2_codecheck.py` → `p2_codecheck.txt` (STmate 코드의 프로젝트 사이 이름 충돌) +- `req225/p2_jdcheck.py` → `p2_jdcheck.txt` (`COSTN.JDCODE` 종류별 채움) + +--- + +## 3. 일반 맞춤 기법 — 한국어 짧은 공종 이름 기준 + +### 3-0. 바탕 — 지금 맞춤이 무엇을 하나(코드 읽기 · 실측 아님) + +- 차례 = ① 층 기억(개인 → 회사 → 마스터 · 「품명 | 규격 | 단위」 정규화 열쇠가 똑같을 때만) ② 이름 같음 · 씨앗(`일위대가조합.json` 메모 170 줄) ③ 정리 이름 같음 ④ 후보 5 = 글자 두 개 묶음 Dice + 단위 덤 0.15 + 산림 덤 0.05 + 규격 덤 0.5 · 근거 후보(단산 출처 조항 · 직종 · 단위) 합침 — `M02_MasterTemplete/M02_Hopyo_Match.py:1-17`, `M02_Hopyo_Basis.py:1-15`. +- 정리 이름이 **괄호 글을 지움** — `M02_Hopyo_Match.py:79-84`. 우리 이름 「굴착(인력/토사)」 → 「굴착」 만 남음. +- 이름 후보 거름 = 글자 두 개 묶음이 하나도 안 겹치거나 뜻 낱말(괄호 밖 한글 덩어리)이 하나도 안 겹치면 뺌 — `M02_Hopyo_Match.py:311`. +- 근거 후보 거름 = 줄 직종에서 보통인부를 빼고 겹침이 없고 뜻 낱말도 안 겹치면 뺌 — `M02_Hopyo_Basis.py:198-213`. +- 읽은 결과(실측 아님): 「구조물터파기(인력100%) 토사」 ↔ 「굴착(인력/토사)」 은 글자 묶음 · 뜻 낱말이 하나도 안 겹쳐 **이름 길로는 후보에 못 듦**. 근거 길도 같은 벽 — 실무 출처 칸이 「[건설공사표준품셈 3-2-1 인력굴착]」 으로 우리 3-2-1 과 조항이 같아도(4-나-3 #1), 줄 직종이 보통인부뿐이면 직종 겹침이 0 이 되고(`M02_Hopyo_Basis.py:198`) 뜻 낱말(「구조물터파기」 ↔ 「굴착」)도 안 겹쳐 빠지는 길(`:213`). 같은 뜻 말 한 줄(터파기 ↔ 굴착)이면 이 거름을 넘음. 실제 비율은 다른 조사자 실측 몫. +- **이미 있는 그릇**: M01 로직 줄에 「별칭」(설계자가 로직을 찾는 다른 이름 · 쉼표로 여럿 · 208-1) · 「옛이름」 칸이 있음. M01 찾기는 씀 — `M01_MasterData/M01_MasterData_Store_Find.py:1-10`(키 · 원문번호 · 이름 · 옛이름 · 별칭). M02 맞춤은 안 씀 — `M02_Hopyo_Match.py:16` 「M01 은 읽기만(로직 별칭 안 씀)」. 마스터 로직 1,469 줄 셈 = 별칭 0 · 옛이름 0 · 원문번호(품셈 절 번호) 1,469 줄 모두(`resources/master_data/로직_*.json`). 템플릿 층에는 별칭을 쓴 자리 있음(예 `storage/1/10/templates/labor/item1.json:23` 「고임도채집」). + +### 3-1. 예시 계산(쌍 6 개 · 실측 아님 · 표준 라이브러리만) + +스크립트 = 스크래치패드 `req225/tools/demo_sim.py`. 우리 이름은 `resources/master_data/로직_*.json`(로직 1,469 줄) · 정답 짝은 `resources/master_data/일위대가조합.json` 씨앗 메모 기준. + +| 엑셀 이름 | 우리 이름 | 판 | 2자 Dice(괄호 뺌 · 지금 방식 근사) | 2자 Dice(괄호 둠) | Jaro-Winkler | difflib | 자모 2자 Dice | 같은 뜻 말 + 낱말 묶음 Jaccard | +|---|---|---|---|---|---|---|---|---| +| 구조물터파기(인력100%) 토사 | 굴착(인력/토사) | 정답(건설 3장 인력 굴착) | 0.00 | 0.24 | 0.00 | 0.00 | 0.19 | 0.75 | +| 구조물터파기(인력100%) 토사 | 구조물터파기 육상토사(0~1m) | 오답(산림 9장 · 굴착기) | 0.75 | 0.50 | 0.96 | 0.89 | 0.81 | 0.75 | +| 구조물 터파기(토사) 굴삭기 0.2m3 | 터파기(기계) 보통토사 | 정답(씨앗 GC000088) | 0.22 | 0.27 | 0.55 | 0.30 | 0.29 | 0.40 | +| 레미콘타설(장비) 무근 | 레디믹스트콘크리트 타설 | 정답(씨앗 GC000198) | 0.12 | 0.11 | 0.54 | 0.44 | 0.42 | 0.50 | +| 콘크리트믹서사용 0.45 m3 | 기계비빔타설 | 정답(씨앗 GF000156) | 0.00 | 0.00 | 0.00 | 0.00 | 0.06 | 0.00 | +| 모르터바르기 1:3 | 모르타르 배합(시멘트 · 모래) | 정답(씨앗 GC000677) | 0.17 | 0.12 | 0.70 | 0.43 | 0.43 | 0.33 | + +읽는 법: +- 1 · 2 줄 = 글자 기법(Dice · Jaro-Winkler · difflib · 자모) **모두 오답 쪽을 더 높게 봄**. 이름이 비슷한 다른 일(기계 터파기)이 이김. 같은 뜻 말 사전을 써도 낱말 묶음만으로는 동점(0.75 = 0.75). 가르는 것 = 방법 칸(인력 ↔ 기계)이 어긋나면 크게 깎는 규칙, 또는 구성(보통인부만 ↔ 굴착기) 대조. +- 5 줄 = 모든 글자 기법 0. 「믹서 ↔ 기계비빔」 같은 뜻 말 줄이나 구성 대조(콘크리트믹서)만 잡음. +- 6 줄 = 「모르터 ↔ 모르타르」 같은 표기 흔들림은 자모 분해 · 같은 뜻 말이 일부 메움. +- 결론 = 글자 유사도 기법을 바꾸는 것만으로는 근본 해결 안 됨. **같은 뜻 말 + 칸 쪼개기 + 구성 대조** 가 핵심. + +### 3-2. 기법별 + +| 기법 | 무엇 · 어떻게 | 한국어 짧은 공종 이름에서 | 설치 부담 | 판정 | 출처 | +|---|---|---|---|---|---| +| 정규화(괄호 · 규격 · 단위 · 숫자) | NFKC(전각 → 반각 · ㎥ → m3 · Ⅰ → I) · 옛 표기(굴삭기 → 굴착기) · 단위 표기 통일 · 「인력100%」 「기계90%+인력10%」 → 방법 칸 · 「Ø 50 MM」 「D50」 → D=50 mm · 괄호 글은 **지우지 말고 칸으로 옮김** | 이름 흔들림의 가장 큰 몫(띄어쓰기 · 괄호 · 규격 섞임)을 값싸게 줄임. 괄호를 지우면 「인력/토사」 같은 결정적 정보가 사라짐 | 없음(표준 `unicodedata` · `re`) | 바로 쓸 만함 | Python unicodedata 문서 https://docs.python.org/3.12/library/unicodedata.html · 공종명 혼재 실태 = 정상원 · 정기창(2020) 한국건설관리학회 논문집 21(6) 125~134 https://www.koreascience.or.kr/article/JAKO202034352378495.pdf (Table 1 · 1.1절) | +| 순서 무관 열쇠(fingerprint) | 낱말을 정렬 · 중복 제거해 붙인 열쇠로 같음 판정 — 「토사 구조물터파기」 = 「구조물터파기 토사」 | 칸 쪼개기 뒤에 쓰면 「이름 같음」 단계가 넓어짐. 오탐이 가장 적은 방식 | 없음 | 바로 쓸 만함(칸 쪼개기와 함께) | OpenRefine clustering https://openrefine.org/docs/technical-reference/clustering-in-depth | +| 같은 뜻 말 사전(시소러스) | 세 종류 줄 — 같은 말(레미콘 = 레디믹스트콘크리트 · 모르터 = 모르타르) · 쪼개기(구조물터파기 → 구조물 + 터파기) · 덮는 말(보통토사 ⊂ 토사). 검색엔진 한국어 분석기도 같은 방식(사용자 사전에 「복합어 → 쪼갠 낱말」 줄) | 예시 1 · 5 줄처럼 글자가 하나도 안 겹치는 짝을 잇는 유일한 값싼 길. 사전 품이 드는 대신 근거가 사람 눈에 보임 | 없음 — 그릇이 이미 있음(M01 로직 「별칭」 칸 · 화면에서 고침 · 층별). 맞춤이 별칭을 읽게만 하면 됨 · 쪼개기 · 덮는 말 줄은 작은 표 하나 더 | 바로 쓸 만함(핵심) | Elasticsearch nori user_dictionary · decompound_mode https://www.elastic.co/docs/reference/elasticsearch/plugins/analysis-nori-tokenizer · 씨앗 = `resources/master_data/일위대가조합.json`(메모 170 줄) · `M02_Hopyo_Match.py:45-51`(MACH_WORDS 5 짝). 재료 후보 = 품셈 [주] 다리 문구(4-나-1) · 조달청 세부공종코드 옛/새 이름 묶음(1-4) · 실무 짝 20(4-나-3) · 국토교통부_건설용어사전 CSV 6,666 줄(칸 = 용어ID · 용어명 · 용어영문명 · 용어설명 · 초성값 — 동의어 칸 없음 · 해마다 갱신 · 정의문에서 손으로 뽑아야 함) https://www.data.go.kr/data/15128802/fileData.do · https://www.data.go.kr/biz/dcat/metadata/15128802.do | +| 한국어 형태소 분석 | kiwipiepy = Apache-2.0 · Windows x86-64 바퀴(cp39-abi3 → 3.12 됨 · 3.7 MB) · 모델 꾸러미 `kiwipiepy_model` 자동 설치 · 사용자 사전 `add_user_word` · 오타 `typos` · 띄어쓰기 `space()`. mecab-ko = python-mecab-ko 1.3.7(BSD-3) 에 cp312 win_amd64 바퀴(653,484 바이트) 있음 · 사전 꾸러미 `python-mecab-ko-dic` 를 함께 받음. KoNLPy 의 Mecab 은 Windows 미지원 · KoNLPy 는 JDK + JPype 필요 | 공종 이름은 짧고 전문 복합어(구조물터파기 · 깬잡석찰쌓기)라 일반 사전으로는 잘못 쪼갤 위험. 분석기가 띄어쓰기에 크게 기댐(위 논문 5.1절). 어차피 도메인 사전이 있어야 함 | 새 의존성(kiwipiepy + 모델) · 시험 못 함(설치 금지) | 조건부 — 도메인 사전으로 모자랄 때만, 고른다면 kiwipiepy | https://pypi.org/project/kiwipiepy/ · https://github.com/bab2min/kiwipiepy · https://github.com/jonghwanhyeon/python-mecab-ko · https://pypi.org/pypi/python-mecab-ko/1.3.7/json · https://konlpy.org/en/latest/install/ | +| 퍼지 맞춤(rapidfuzz · Jaro-Winkler · difflib) | rapidfuzz 3.14.6 = MIT · 의존성 없음 · Windows 바퀴 · Python ≥ 3.11 · `ratio` `partial_ratio` `token_sort_ratio` `token_set_ratio` `WRatio` · 3.0 부터 기본 전처리 없음. 표준 `difflib.SequenceMatcher`(Ratcliff/Obershelp) · `get_close_matches` 는 설치 없음 | 국내 공종명 비교 연구(타일 공사 684 개)에서 Jaro-Winkler 가 5 기법 중 가장 나음 — 그래도 1순위 정답률 33.7~49.7 %(N-gram 2 = 28.5~37.7 %). 결론 = 「완전 자동화 힘듦 · 전문가에게 몇 개 보여 주는 추천 시스템」. token_* 은 빈칸 기준이라 붙여 쓴 한국어에선 칸 쪼개기 뒤에야 쓸모. 예시 1 · 2 줄처럼 오답을 더 높게 보기도 함 | rapidfuzz 작음 · difflib 은 표준 · Jaro-Winkler 는 25 줄 | 조건부 — 보조 점수 · 근본 해결 아님 | https://pypi.org/project/RapidFuzz/ · https://docs.python.org/3.12/library/difflib.html · 정상원 · 정기창(2020) 위 PDF Table 5 · 7 · 6.2절 | +| 자모 분해 | NFD 정규화 = 한글 음절을 자모로 풂(표준 `unicodedata.normalize("NFD", …)`) → 자모 n-그램 · 편집 거리 | 「모르터 ↔ 모르타르」 「깍기 ↔ 깎기」 같은 표기 흔들림에 강함(예시 6 줄 0.17 → 0.43). 뜻이 다른 말은 못 이음 | 없음 | 조건부 — 보조 점수 | Unicode FAQ(Korean) https://www.unicode.org/faq/korean.html 「Text that is normalized as NFD will contain only Jamo」 | +| 글자 n-그램 TF-IDF | 글자 2~3 그램에 IDF 무게 → 흔한 묶음(콘크·크리·설치·운반)은 가볍게, 드문 묶음은 무겁게. 지금 Dice 는 모든 묶음을 같은 무게로 봄 | 우리 로직 1,469 개 · 엑셀 수천 줄이면 표준 라이브러리로 충분. 같은 뜻 말 문제는 못 풂 | 없음(scikit-learn 미설치 · `Counter` 로 20 줄 · numpy/scipy 는 설치돼 있음) | 바로 쓸 만함(지금 Dice 대체 · 작은 이득) | scikit-learn TfidfVectorizer `analyzer='char_wb'` 설명 https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html | +| 문장 임베딩 | multilingual-e5-small = MIT · 12 층 · 384 차원 · 한국어 포함 · 「query: / passage: 」 머리말 필수 · model.safetensors 470,641,600 바이트 · ONNX qint8 118,346,824 바이트. ko-sroberta-multitask = 768 차원 · KorSTS 코사인 스피어만 85.60 · model.safetensors 442,499,016 바이트 · sentence-transformers(PyTorch) 필요. ONNX 길 = onnxruntime 1.31.0(cp312 바퀴 14.9 MB) + 토크나이저 | 「아스팔트 부수기 ↔ 아스콘 깨기」 같은 뜻 거리를 잡을 가능성. 그러나 짧은 전문 용어(터파기 ↔ 굴착 · 리핑암)를 일반 말뭉치 모델이 아는지 미확인 — 시험 먼저. 우리 로직 이름은 한 번 미리 계산해 두면 됨(CPU 가능) | 큼(수백 MB 모델 파일 + 새 실행 꾸러미) · 오프라인은 모델 파일 복사로 가능 | 조건부 — 위 기법 뒤 남은 실패가 「뜻은 같고 글자 다름」 일 때만 · 실측으로 이득 확인 뒤 | https://huggingface.co/intfloat/multilingual-e5-small/raw/main/README.md · https://huggingface.co/api/models/intfloat/multilingual-e5-small/tree/main · https://huggingface.co/api/models/intfloat/multilingual-e5-small/tree/main/onnx · https://huggingface.co/jhgan/ko-sroberta-multitask · https://sbert.net/docs/installation.html · https://pypi.org/project/onnxruntime/ | +| 큰 언어 모델 재순위 | 후보 5~10 개를 주고 고르게 하고 까닭(조항 · 직종 · 방법)을 한 줄로 받음. Claude Haiku 5.5 = 입력 $0.10 · 출력 $0.50 / 백만 토큰(프롬프트 10 만 이하) · 캐시 적중 $0.01 · 배치 50 % 할인. 어림(가정 · 생각 토큰 별도): 줄 2,000 × (입력 400 + 출력 60 토큰) ≈ 입력 0.8 M · 출력 0.12 M → 약 $0.14 / 파일 — 돈은 걸림돌 아님 | 뜻 맞춤 · 설명은 가장 잘할 가능성. 그러나 인터넷 필요(사내 오프라인 서버에서 못 씀) · 발주처 자료가 밖으로 나감 · 같은 입력에 답이 흔들릴 수 있음. 로컬 모델(예 EXAONE 3.5 2.4B = 매개변수 2.4B · 32K 문맥 · GGUF 양자화본 있음 · 크기 확인 못 함)은 라이선스가 「EXAONE AI Model License Agreement 1.1 - NC」(비상업) | 클라우드 = 설치 작음 · 망 필요 / 로컬 = 큼 · 라이선스 걸림 | 안 맞음(지금 조건) — 온라인 · 외부 전송이 허용되면 「설명 붙임 보조」 로 조건부 | https://platform.claude.com/docs/en/about-claude/pricing.md · https://huggingface.co/LGAI-EXAONE/EXAONE-3.5-2.4B-Instruct | +| 구조 쪼개기(칸별 맞춤) | 이름을 칸으로 나눔 — 대상(구조물 · 측구 · 노체) · 작업(굴착 · 되메우기 · 쌓기 · 붙이기 · 타설) · 방법(인력 · 기계 · 비율) · 재료/토질(토사 · 혼합토사 · 리핑암 · 발파암 · 깬잡석 · 레미콘) · 규격(D · H · T · L3 · 0.7㎥) · 조건(육상/용수 · 깊이 0~1 m · 소형/간단/보통). 칸마다 같음 · 덮음 · 어긋남을 매기고 더함. 결정 칸(방법 · 토질)이 어긋나면 크게 깎음. 무게는 처음엔 손으로, 확정 짝이 쌓이면 칸별 m/u 셈(Fellegi–Sunter: 칸 무게 = log₂(m/u), 칸 무게를 더함) | 국내 연구도 공종명을 〈대상〉+〈작업〉 두 칸으로 정의함(Table 3). 예시 1 · 2 줄을 가르는 길. 화면에 「어느 칸이 맞고 어긋났나」 를 그대로 보일 수 있음(사용자가 고르는 도구에 맞음) | 없음(규칙 + 사전) | 바로 쓸 만함(핵심) | Splink Fellegi–Sunter https://moj-analytical-services.github.io/splink/topic_guides/theory/fellegi_sunter.html · 정상원 · 정기창(2020) 위 PDF Table 3 · 5.1절 · `M02_Hopyo_Match.py:57-66 · 115-180`(이름 붙은 수 D= H= … 이미 읽음) | +| 구성 비교(노무 · 재료 · 장비 줄 묶음) | 엑셀 호표 구성 줄의 직종 · 장비 · 자재 묶음 ↔ 우리 로직 호표 요소 묶음 겹침(Jaccard). 「보통인부만」 = 인력 작업의 증거 · 굴착기 있음 = 기계 작업의 증거 | 이름과 무관한 증거라 예시 1 · 2 · 5 줄을 가름. 지금 근거 길은 단산 직종을 쓰되 보통인부를 뺌(`M02_Hopyo_Basis.py:198`) — 장비 · 자재 · 「보통인부뿐」 신호는 아직 안 씀 | 없음(엑셀 · 우리 로직 둘 다 이미 읽음) | 바로 쓸 만함 | `M02_Hopyo_Basis.py:128-156`(우리 직종 = 호표 LB 요소) | +| 금액 대조 | 엑셀 호표 단가(재료비 · 노무비 · 경비)를 같은 해 단가로 셈한 우리 후보 단가와 견줌 — 비율이 가까우면 덤 · 멀면 표시. 절대 금액보다 노무비 몫(노무비 ÷ 계) 이 해 · 지역 차이에 덜 흔들림 | 동점 가르기에 쓸모. 실무 엑셀은 옛 판 값 · 할증 · 지역 단가가 섞여 단독 판정 불가 | 없음(우리 셈 이미 있음) | 조건부 — 동점 가르기 · 경고 표시만 | 씨앗 메모 「(실무 수는 옛 판 값)」 꼬리표 — `M02_Hopyo_Match.py:52` | +| 사용자 확정 학습 | ① 맞춤 기억 — 이미 있음(층 기억 개인 → 회사 → 마스터 · 열쇠 = 품명 · 규격 · 단위 똑같음). ② 확정 짝에서 낱말 차이를 뽑아 「같은 뜻 말 후보」 로 쌓고 관리자가 승인 → 사전으로 → 똑같지 않은 다음 줄에도 번짐. ③ 능동 학습 — 가장 불확실한 줄부터 사용자에게 보여 주고, 확정마다 무게 다시 셈(dedupe 방식). ④ 확정 짝으로 칸 무게 셈(m/u) | ① 은 똑같은 줄만 기억 → 이름이 조금만 달라도 못 씀. ② 가 일반화의 핵심. ③ 은 사용자 품을 줄임 | 없음 | ①② 바로 쓸 만함 · ③④ 조건부(확정 짝이 쌓인 뒤) | `M02_Hopyo_Match.py:68-77 · 333-352`(기억 열쇠 · 층 차례) · dedupe 능동 학습 https://docs.dedupe.io/en/latest/how-it-works/Matching-records.html · OpenRefine reconciliation(후보 + 점수 + 사람이 고름) https://openrefine.org/docs/manual/reconciling | + +### 3-3. 비슷한 남의 사례(문헌) + +| 사례 | 무엇 | 우리와 | 출처 | +|---|---|---|---| +| 정상원 · 정기창(2020) 「문자열 유사도 알고리즘을 이용한 공종명 인식의 자연어처리 연구」 | 나라장터 입찰 내역서의 타일 공종명 684 개를 2020 표준품셈 기준 정식 공종명에 맞춤. Hamming · N-gram(2 · 3) · Levenshtein · Jaro-Winkler 비교 → Jaro-Winkler 최고(1순위 정답률 33.7~49.7 %). 정답률이 낮아 「추천 시스템(후보 몇 개 + 전문가 고름)」 과 「전문가 작업 환경에서 DB 가 자라게」 를 권함 | 우리 「후보 + 사용자 고름 + 층 기억」 구조가 이 결론과 같은 방향. 글자 기법만으로는 한계라는 근거 | https://www.koreascience.or.kr/article/JAKO202034352378495.pdf | +| Lee · Lee · Chi · Oh(2023) 국도 내역서 공종 분류 코드 자동 부여 | 국도 BOQ 세부 공종 설명을 토큰으로 나눠 낱말 겹침으로 가장 비슷한 공종 분류 코드를 붙임 · 정확도 91.12 % 넘음(초록 기준) | 「표준 코드 표 + 낱말 겹침」 이 국내 공공 내역에서 통한 사례. 원문은 열람 못 함(ASCE 403) — 초록 수치는 검색 요약으로만 확인 | https://cm.snu.ac.kr/publication/automatic-classification-of-construction-work-codes-in-bill-of-quantities-of-national-roadway-based-on-text-analysis/ · https://api.semanticscholar.org/graph/v1/paper/DOI:10.1061/JCEMD4.COENG-12730?fields=title,abstract,year,authors,venue | +| Deza 외(2022) 영국 BOQ → ICMS 32 분류 | 24 개 사업 5 만여 줄 · 신경망 분류 · 평균 F1 90 % 넘음 | 학습 자료가 5 만 줄 규모일 때 이야기 — 우리는 자료 적음 · 안 맞음 | https://arxiv.org/abs/2211.07705 | +| Maurya 외(2020) 송장 품목 줄 맞춤 · 사용자 피드백 온라인 학습 | 담당자의 맞음/아님 · 순위 피드백으로 유사도를 계속 학습. 「739mL ↔ 0.739L」 같은 단위 · 약어 문제를 지적 | 우리 확정 학습(②~④)과 같은 발상 · 단위 정규화 필요성 근거 | https://arxiv.org/abs/2001.00288 | + +--- + +## 4. 저장소 지식DB — 맞춤 사전 재료 + +근거 표기 = `resources/knowledge/` 아래 상대경로:줄. 줄임 표: + +| 줄임 | 경로 | +|---|---| +| 건설/ | original/원가계산/건설공사_표준품셈/본문/ | +| 산림/ | original/원가계산/산림_표준품셈/본문/ | +| TI/ | technical_info/01_임도/ | +| ST/ | original/경쟁사 프로그램/STmate 분석/ | +| [북부임도] | original/실무문서/2026_실무일위대가 모음/2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls — 「시트!R행」 | +| [북부사방] | 같은 폴더 2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls | +| [사방안16] | 같은 폴더 2016_상반_산림청_사방사업표준품셈(안).xls | +| [경남1]·[경남2] | original/원가계산/자재단가/경남교육청_일위대가/일위대가_1.xlsx · 일위대가_2.xlsx | +| [레시피] | ST/30_원자료/일위대가_레시피.json (줄) | +| [개정26] | original/원가계산/건설공사_표준품셈/2026년_건설공사_표준품셈_개정사항.pdf (인쇄쪽) | + +- xls·xlsx 는 읽기 전용 파이썬(-I) 한 번으로 이름 칸(앞 8열 글자)만 훑음 — 스크립트·출력 = 스크래치패드 `req225/scan_names.py` · `req225/scan_out_u8.txt`. 단가산출 탭의 「번호.이름[단위]」 줄은 B열(C2). +- [개정26] 은 pdftotext 로 글자만 뽑음 — `req225/rev2026.txt`. md 판 없음(original/원가계산/건설공사_표준품셈/_meta.md:23). + +--- + +### 4-가. 품셈 항목 이름 목록 — 뽑을 자리 · 수 · 형식 + +| 원천 | 자리 | 수(머리말 셈) | 머리말 꼴 | +|---|---|---|---| +| 건설공사 표준품셈 2026 | 건설/ 5부문 45장 320 절 파일 (original/원가계산/건설공사_표준품셈/_meta.md:19) | 절(##) 222 · 항(###) **1,095** · 목(####) 458 — 공통 항 321·목 282 / 토목 항 235·목 76 / 건축 162 / 기계설비 242 / 유지관리 135 | `### 3-2-1 굴착(인력/토사)('08, '20, 25년 보완)` (건설/01_공통부문/제03장_토공사/3-02_굴착.md:18) — 번호 · 이름 · 개정 표지 | +| 건설 8장 기계 손료표 | 건설/01_공통부문/제08장_건설기계/8-03-0*_*.md | 기종 머리 156 | `#### (0201) 굴착기(무한궤도)` (8-03-01_[00]토공기계.md:74) — 4자리 기종 코드 + 이름 | +| 산림사업 표준품셈 2026 | 산림/ 14장 203 절 파일 (original/원가계산/산림_표준품셈/_meta.md:16) | 절(##) 189 · 항(###) 274 · 목(####) 78 — 토공 9장 22/50/16 · 철근콘크리트 12장 38/28/5 · 구조물 13장 16/34/12 | `## 9-13. 구조물터파기` · `### 9-13-1. 육상토사(0~1m)` (산림/제09장_토공/9-13_구조물터파기.md:16 · :21) — 번호 뒤 마침표 | +| 산림 별표 md | original/원가계산/산림_표준품셈/별표/별표0_산림사업 표준품셈.md | 내용 없음(안내문뿐 · 같은 파일:5) | 쓸 것 없음 | +| 산림 현행_20260101.md | original/원가계산/산림_표준품셈/현행_20260101.md | 고시 본문 3조뿐(:6-16) | 항목 이름 없음 | + +- 머리말 규칙이 정해져 있음 — 「`#` 장 · `##` 절 · `###` 항 · `####` 목, 번호와 이름은 PDF 글자 그대로」(original/원가계산/_품셈md_작성규칙.md:49) · 개정 표지 남김(:48). 두 품셈 모두 PDF 1:1 대조 「확정」(건설 _meta.md:20 · 산림 _meta.md:16). +- 기계로 뽑기 = 쉬움. 정규식 한 줄(번호 `\d+(-\d+)+\.?` + 나머지)로 번호·이름이 갈림. 손볼 것: + 1. 산림 하위 머리는 한정어만인 것이 많음 — 6자 이하 머리: `인력` 3 · `토사` 3 · `기계` 2 · `암절취` 2 · `발파암` 2 · `집토` 2 … 예 `### 9-3-1. 인력`(산림/제09장_토공/9-03_토사깍기.md:18) → 윗머리와 이어 「토사깍기 > 인력」으로 만들어야 이름이 됨. + 2. 건설 절 번호는 부문마다 1장부터 다시 시작 — 열쇠에 부문을 넣어야 함 (TI/04_수량분석정보/건설품셈_활용.md:54). + 3. 개정 표지 괄호는 이름에서 뗌 — 따옴표가 `'` · `‘` 두 꼴 (건설/01_공통부문/제04장_조경공사/4-01_잔디_및_초화류.md:36). + 4. 산림 목(####) 일부에 식이 붙음 — `#### 3. 집토: Q=3600×q×k×f×E/㎝= ㎥/시간` (산림/제09장_토공/9-08_철근콘크리트_깨기.md:43). + 5. 산림 원문 목차 번호 중복 오기 2건 — 본문 머리 번호를 씀 (TI/04_수량분석정보/건설품셈_활용.md:93-100). +- 표 안 「구분」 열(특별인부 · 보통인부 · 굴착기 0.7㎥ …)이 구성(노무·장비) 비교 재료로 함께 뽑힘 — 예 건설/01_공통부문/제03장_토공사/3-02_굴착.md:25. +- 범위 밖 한 줄: 지식DB 문서가 가리키는 `resources/data_work_item_link/…json`(TI/04_수량분석정보/건설품셈_활용.md:53) · `resources/data_work_item_master/…json` 은 지금 저장소에서 찾지 못함 — `resources/master_data/계수_*품셈_*.json` 이 뒤를 이은 것으로 보이나 지식DB 밖이라 열지 않음(확인 못 함). + +--- + +### 4-나. 용어 정의 · 같은 뜻 말 + +#### 4-나-1. 원문이 직접 잇는 말 (다리 문구) + +| 다리 | 근거 | +|---|---| +| 굴착(인력/토사) = 깊이 1m 이하 인력 「구조물 터파기 또는 흙깎기」에 적용 | 건설/01_공통부문/제03장_토공사/3-02_굴착.md:30 | +| 「잔토처리」 = 굴착 품 비고의 별도 계상 항(㎥당 보통인부 0.2인) | 같은 파일:26 | +| 인력굴착 — 깊이 20㎝ 이상 = 「터파기」, 그 밖 = 「절취」 | 산림/제09장_토공/9-01_굴착.md:22 | +| 구조물터파기 기본 = 「인력 10%+기계 90%」 → 실무 이름의 「(인력100%)」「(기계100%)」「기계 90% + 인력 10%」 표기의 뿌리 | 산림/제09장_토공/9-13_구조물터파기.md:18 | +| 한 절 안에서 장비 이름 둘 — 머리 「굴착기 0.7㎥」 · 표 「유압식백호우(무한궤도,0.7㎥)」 | 같은 파일:18 · :28 | +| 표준시방서 제목이 같은 뜻 말을 괄호로 병기 — 「땅깎기(절토)」「흙쌓기(성토)」「되메우기 및 뒤채움」「사토 및 잔토처리」(KCS 11 20 10 · 20 · 25 · 30) | original/표준시방서/건설공사 표준시방서 (건설기준 코드 KDS_KCS)/_index.md:42-43 (같은 폴더 `첨부/[압축] 개정전문/KCS 11 00 00 지반 공사/` 파일 이름) | +| 「리핑암 = 풍화암 / 발파암 = 연·경암」(KDS 분류) · 품셈 분류(보통토사~극경암)와 두 체계 | TI/03_계산정보/토량계산.md:90 · :108 | +| 「암절취 (리핑암 — 암파쇄·집토)」 | TI/05_원가정보/원가_출력변수_사전.md:66 · TI/04_수량분석정보/토공_수량.md:19 | +| 「사토장 관리」(품셈 공종명) ≠ 「유용토운반작업장」(시행규칙 시설명) — 용어 다름 | TI/04_수량분석정보/토공_수량.md:54 | +| 「지장목 제거(벌개제근)·뿌리다듬기·제근」을 한 공종으로 묶음 | TI/05_원가정보/원가_출력변수_사전.md:55 | +| 이름이 비슷하다는 이유만으로 자동 연결 금지 — 산림 총칙 7호(유사 공종은 산림 품셈 우선) | TI/04_수량분석정보/건설품셈_활용.md:50 · 산림/제01장_적용기준/1-01_일반사항.md:34 | +| 이름이 같아도 다른 것 — 「표면 마무리」 산림 12-2 ㎡당 vs 건설 6-1-3 100㎡당(100배) · 「토량환산」 같은 이름 토질 행이 다른 계수 · 「돌쌓기」 표 축 다름 | TI/04_수량분석정보/건설품셈_활용.md:89 · :87 · :85 | + +#### 4-나-2. 원천별 표기 갈래 (낱말 수 · 부분 문자열 셈) + +원천 경로 — 건설 품셈 = 건설/ · 산림 품셈 = 산림/ · 임도교본 = original/임도기술교본 · 사방교본 = original/사방기술교본 · KCS 지반 = original/표준시방서/건설공사 표준시방서 (건설기준 코드 KDS_KCS)/첨부/[압축] 개정전문/KCS 11 00 00 지반 공사. 「깎기」는 땅깎기·흙깎기를 포함 · 「굴착」은 굴착기를 뺀 셈. + +| 낱말 | 건설 품셈 | 산림 품셈 | 임도교본 | 사방교본 | KCS 지반 | +|---|---:|---:|---:|---:|---:| +| 굴착(기 뺌) | 146 | 17 | 16 | 22 | 383 | +| 터파기 | 74 | 22 | 131 | 15 | 187 | +| 절토 | 20 | 22 | 279 | 5 | 12 | +| 절취 | 7 | 49 | 48 | 10 | 10 | +| 땅깎기 | 0 | 1 | 43 | 44 | 100 | +| 흙깎기 | 4 | 1 | 20 | 9 | 3 | +| 깍기(맞춤법 어긋남) | 0 | 4 | 21 | 2 | 2 | +| 되메우기 / 되메움 | 42 / 0 | 14 / 0 | 57 / 1 | 23 / 0 | 173 / 6 | +| 뒤채움 / 뒷채움 | 15 / 0 | 34 / 15 | 41 / 44 | 3 / 8 | 173 / 18 | +| 잔토처리 / 잔토정리 | 16 / 0 | 0 / 1 | 12 / 0 | 1 / 0 | 9 / 0 | +| 사토 | 4 | 7 | 74 | 2 | 28 | +| 굴착기 / 굴삭기 / 백호우·백호 | 260 / 7 / 0 | 148 / 15 / 39 | 5 / 8 / 3 | 3 / 4 / 0 | 9 / 0 / 1 | + +- 원천마다 주 낱말이 다름 — 건설·KCS = 굴착·터파기 · 교본 = 절토·땅깎기 · 산림 = 절취·깍기. 같은 원천 안에서도 뒤채움/뒷채움 · 굴착기/굴삭기/백호우가 섞임. + +#### 4-나-3. 같은 일 · 다른 이름 짝 (20) + +판정 칸 = 「같은 일」(출처 인용 번호나 원문 다리로 이어짐) · 「같은 갈래」(같은 일인지 사용자 확인 필요). + +| # | 이름들 | 근거 | 판정 | +|---|---|---|---| +| 1 | 실무 「구조물터파기(인력100%)(토사)」 — 출처 칸 「[건설공사표준품셈 3-2-1 인력굴착]」 ↔ 우리 「굴착(인력/토사)」 = 건설 3-2-1 | [북부임도] 단가산출!R4229 · R4231 · 건설/01_공통부문/제03장_토공사/3-02_굴착.md:18 | 같은 일 — 겹치는 낱말은 인력·토사뿐, 출처 번호로 이어짐 | +| 2 | 「인력굴착(토사) / 보통토사」 + 비고 「H=1m 이하(구조물터파기, 흙깍기)」 | [경남1] 공종별내역서!R845 · 일위대가목록!R1405 | 같은 일 — 어순 뒤집힘 · 비고가 다리 | +| 3 | 사방 「구조물터파기(인력)」 ↔ 임도 「구조물터파기(인력100%)」 — 한 발주처 두 문서 | [북부사방] 단가산출!R316 · [북부임도] 일위대가!R22 | 같은 일 | +| 4 | 「구조물터파기(암)」 [2025년 표준품셈3-2-4] ↔ 「구조물터파기(암절취)」 [건설공사표준품셈3-2-4] ↔ 건설 「터파기(기계)」 3-2-4 ↔ 산림 「구조물터파기 > 육상 암절취(0~1m)」 9-13-7 | [북부사방] 단가산출!R336 · R338 · [북부임도] 단가산출!R4039 · R4041 · 3-02_굴착.md:126 · 산림/제09장_토공/9-13_구조물터파기.md:92 | 같은 이름이 건설·산림 두 출처로 갈림 | +| 5 | 「흙깍기(토사)」 — 출처 「흙깎기-보통토사 [건설공사 표준품셈3-2-3]」 ↔ 「토사절취(하상절취)」 — 출처 「토사절취-혼합토사 [2025년 표준품셈3-2-3]」 ↔ 건설 「흙깎기(기계)」 3-2-3 ↔ 산림 「토사깍기 > 기계」 9-3-2 | [북부임도] 단가산출!R2346 · R2348 · [북부사방] 단가산출!R2757 · R2759 · 3-02_굴착.md:54 · 산림/제09장_토공/9-03_토사깍기.md:33 | 같은 일(토질 축만 다름) | +| 6 | 「흙깎기(연암)(굴삭기1.0㎥+대형브레이커)」 — 산출 첫 줄 「암절취 : 굴착기(무한궤도)(1.0+브레이카)」 ↔ 산림 「암절취」 9-4 ↔ TI 「암절취(리핑암)」 | [북부임도] 단가산출!R2370 · R2376 · 산림/제09장_토공/9-04_암절취.md:16 · TI/04_수량분석정보/토공_수량.md:19 | 같은 갈래 — 암 분류 경계가 체계마다 다름 | +| 7 | 「잔토정리(굴삭기0.6㎥)」 ↔ 「잔토처리(기계100%) - 굴삭기 0.8㎥」 ↔ 「구조물잔토처리 굴착기0.6m3」 | [북부임도] 단가산출!R2573 · [북부사방] 단가산출!R1663 · R1665 · [레시피]:342 | 같은 일 | +| 8 | 「구조물되메우기및다짐(대형장비,굴삭기0.6㎥)」 — 「[건설공사표준품셈 표준품셈3-4-7]대형장비」 ↔ 「구조물되메우기 및 다짐」 ↔ 「되메우기 및 다짐」 ↔ 건설 3-4-7 ↔ 산림 9-14 | [북부임도] 단가산출!R2294 · R277 · [북부사방] 단가산출!R263 · [경남2] 일위대가목록!R1721 · 건설/01_공통부문/제03장_토공사/3-04_쌓기.md:152 · 산림/제09장_토공/9-14_되메우기_및_다짐.md:16 | 같은 일 | +| 9 | 「양질사토운반」 ↔ 「사토운반(L=0.3㎞)」 ↔ 「사토처리」 · 「사토운반 L=409m」 | [북부임도] 내역서!R36 · [북부사방] 내역서!R12 · [레시피]:3160 · :3096 | 같은 갈래 — 거리 규격이 이름에 섞임 | +| 10 | 「레미콘타설(장비사용)」 — 출처 「건설표준품셈, 6-1-1 콘크리트 타설」 ↔ 건설 「레디믹스트콘크리트 타설」 6-1-1 ↔ 「레디믹스트콘크리트 장비사용 타설」 ↔ 「레미콘타설(장비) 무근」 · 「레미콘타설 무근(장비)」 ↔ 산림 12-1-1 | [북부임도] 일위대가!R7 · 단가산출!R1101 · 건설/01_공통부문/제06장_철근콘크리트공사/6-01_콘크리트.md:23 · [경남1] 일위대가목록!R407 · [레시피]:2448 · :2374 · 산림/제12장_철근콘크리트/12-01_콘크리트타설.md:18 | 같은 일 — 줄임말 · 괄호 자리 바뀜 · 출처 칸 이름도 본문 머리와 다름 | +| 11 | 「레미콘타설(인력운반)(무근구조물-소량타설)」 — 「2024년 품셈개정, 6-1-1콘크리트 타설」 ↔ 「레디믹스트콘크리트 인력운반 타설」 | [북부사방] 단가산출!R903 · R905 · [경남1] 일위대가목록!R405 | 같은 일 | +| 12 | 「합판거푸집(4회)」 — 「건설공사표준품셈, 6-3-1 합판거푸집」 ↔ 건설 「합판거푸집 설치 및 해체」 6-3-1 ↔ 「합판거푸집 설치 및 해체 보통 4회, 수직고 7m까지」 ↔ 산림 「합판거푸집」 12-4 | [북부임도] 단가산출!R1173 · R1175 · 건설/01_공통부문/제06장_철근콘크리트공사/6-03_거푸집.md:18 · [경남1] 일위대가!R1876 · 산림/제12장_철근콘크리트/12-04_합판거푸집.md:16 | 같은 일 — 사용 횟수·높이가 이름에 섞임 | +| 13 | 「문양거푸집」 [건설표준품셈6-3-4] ↔ 건설 「문양거푸집(판넬) 설치 및 해체」 6-3-4 ↔ 「문양거푸집 (판넬)」 ↔ 「문양 거푸집」 | [북부임도] 단가산출!R893 · R895 · 6-03_거푸집.md:208 · [경남1] 일위대가목록!R1425 · [레시피]:5787 | 같은 일 — 띄어쓰기 | +| 14 | 「큰돌메쌓기(40cm-60cm,(산림사업품셈))」 — 산출 「큰돌쌓기(메쌓기) (2026년 산림사업품셈 13-6-1)」 ↔ 「큰돌쌓기(메쌓기)(직경40cm이상~60cm미만)」 ↔ 산림 「큰돌쌓기 > 메쌓기」 13-6-1 | [북부사방] 단가산출!R2531 · R2533 · [북부임도] 단가산출!R1605 · 산림/제13장_구조물/13-06_큰돌쌓기.md:18 | 같은 일 — 어순 · 규격 표기 다름 | +| 15 | 「큰돌찰쌓기」 ↔ 「큰돌쌓기(찰쌓기)」 [산림사업표준품셈 13-6-2] ↔ 구조물 이름이 앞에 붙은 「큰돌흙막이(찰쌓기)」 · 「큰돌기슭막이(찰쌓기)」 | [북부사방] 단가산출!R2619 · R2621 · 일위대가!R129 · [북부임도] 단가산출!R1561 · R1563 · 일위대가!R73 · 13-06_큰돌쌓기.md:44 | 같은 공법 — 흙막이·기슭막이 호표는 공법 위 묶음이라 1:1 아님 | +| 16 | 「돌쌓기 / 메쌓기 / 깬돌」 ↔ 「깬잡석메쌓기」 · 「메쌓기(켜쌓기)」 ↔ 건설 「메쌓기」 7-1-1 ↔ 산림 「돌쌓기 > 메쌓기(인력)」 13-4-1 | [경남2] 일위대가목록!R3900 · [레시피]:6873 · :6699 · 건설/01_공통부문/제07장_돌공사/7-01_돌쌓기.md:18 · 산림/제13장_구조물/13-04_돌쌓기.md:18 | 같은 갈래 — 건설·산림 표 축 다름(TI/04_수량분석정보/건설품셈_활용.md:85) | +| 17 | 「떼붙임(평떼)(산림사업품셈)」 — 「떼붙임 [2026년 산림사업표준품셈(5-12)]」 ↔ 「비탈면보호공(평떼)」 — 「[건설표준품셈(4-1-1)]」 ↔ 건설 「잔디붙임」 4-1-1 ↔ 산림 「떼붙임(재배잔디)」 5-12 · 「평떼 > 평떼 붙임」 5-22-2 | [북부사방] 단가산출!R830 · R836 · [북부임도] 단가산출!R2899 · R2901 · 건설/01_공통부문/제04장_조경공사/4-01_잔디_및_초화류.md:18 · 산림/제05장_식재_및_녹화/5-12_떼붙임(재배잔디).md:16 · 5-22_평떼.md:22 | 같은 일 이름 — 출처가 산림·건설 두 갈래 | +| 18 | 「떼붙임(줄떼)」 ↔ 「떼심기(줄떼(평탄지용))」 ↔ 산림 「떼심기」 5-13 · 「줄떼 > 떼붙임」 5-23-2 | [사방안16] 단가산출!R6983 · R7060 · 산림/제05장_식재_및_녹화/5-13_떼심기.md:16 · 5-23_줄떼.md:22 | 같은 갈래 — 붙임·심기는 다른 품일 수 있음 | +| 19 | 산림 「씨앗뿜어붙이기 > 뿜어붙이기-기계/일반」 5-24-1 ↔ 건설 「초류종자 살포(기계살포)」 4-1-3 ↔ 건설 목 「식생기반제 뿜어붙이기」(3-7-4 안) ↔ 「식생기반제 뿜어붙이기(20m 미만)」 | 산림/제05장_식재_및_녹화/5-24_씨앗뿜어붙이기.md:18 · 4-01_잔디_및_초화류.md:53 · 건설/01_공통부문/제03장_토공사/3-07_비탈면_보호공.md:119 · [경남2] 일위대가목록!R1761 | 같은 갈래 — 재료·두께가 달라 같은 일 아닐 수 있음 | +| 20 | 「(☞ 터파기(기계))」 → 「기계터파기/보통토사/5M이하(육상)」 ↔ 건설 「터파기(기계)」 3-2-4 | [경남2] 일위대가목록!R1630 · R1631 · 3-02_굴착.md:126 | 같은 일 — 어순 뒤집힘 · 육상/용수 축 덧붙음 | + +#### 4-나-4. 철자 · 띄어쓰기 · 장비 · 직종 · 시설 이름 변이 + +- 깎기 ↔ 깍기 — 한 문서 안에서도 섞임: [북부임도] 단가산출!R2346 「흙깍기(토사)」 vs R2348 「흙깎기-보통토사」 · [경남2] 일위대가목록!R1595 「(☞ 흙깍기(기계))」 vs R1596 「흙깎기/보통토사」 · 산림 절 이름 자체가 「토사깍기」(산림/제09장_토공/9-03_토사깍기.md:16). +- 띄어쓰기 — 「구조물 되메우기」 ↔ 「구조물되메우기」([레시피]:5845 · :5817) · 「구조물 터파기(토사)」 ↔ 「구조물터파기(토사)」(:5839 · :5811). +- 오타·줄임 — 「브렐이커」([북부임도] 단가산출!R4039) ↔ 「브레이커」(R2370) ↔ 「브레이카」(R2376) ↔ 「브레카」([레시피]:3788). +- 단위 표기 — 「㎥」 ↔ 「m3」 ↔ 「M3」 ([레시피]:246 「굴삭기 0.7M3」 · :190 「0.2m3」). +- 장비 — 굴착기 ↔ 굴삭기 ↔ 유압식백호우 ↔ B/H ↔ 백호 (산림/제09장_토공/9-13_구조물터파기.md:18 · :28 · [레시피]:370 「대형브레카+B/H0.7㎥」 · 나-2 표) · 불도저 ↔ 불도우저 ↔ 도자 ([북부임도] 단가산출!R2374 · original/실무문서/_종합비교/03_토공_경사시공.md:37). +- 직종 — 목도 → 인력운반공 · 건설기계운전기사 → 건설기계운전사 · 운전사(운반차) → 화물차운전사 (original/원가계산/노임단가_건설업_대한건설협회/2. 임금적용요령.md:71-73) · 특수비계공 → 비계공 · 갱부 → 특별인부 (original/원가계산/노임단가_직종대응_근거.md:65 · :67) · STmate 직종 사전 106종에 건설기계운전기사·건설기계운전사·중기운전사가 함께 있음 (ST/20_분석/28_변환_수량산출_식언어.md:90-100). +- 공종 이름 — 실무 「지장목제거」 ↔ 「벌개제근」 ↔ 「제근」 (original/실무문서/05. 동부청 영월/_분석보고서.md:18 · TI/05_원가정보/원가_출력변수_사전.md:55 · 건설/01_공통부문/제03장_토공사/3-09_벌개제근.md:16 · 산림/제09장_토공/9-21_제근.md:16) · 실무 「토사절취」(울진 2공구) ↔ 「절토」(3공구) ↔ 품셈 「토사깍기」 (original/실무문서/2024년 간선임도(기번3-울진.대흥)(2공구)/_분석보고서.md:18 · (3공구)/_분석보고서.md:17). +- 시설 이름 — 「관보호공」 ↔ 실무 「동물이동통로 돌집수정」 (original/실무문서/_종합비교/04_임도구조물_원단위.md:76 · original/실무문서/2024년 간선임도(기번3-울진.대흥)(공통)/_분석보고서.md:68). +- 암 이름 — 「연암」(울진 1·3공구) ↔ 「브레카암」(2공구) ↔ 「암절취」(산림) ↔ 「리핑암」(TI) (original/실무문서/_종합비교/03_토공_경사시공.md:17-19 · TI/03_계산정보/토량계산.md:90). **같은 뜻 말로 묶지 말 것** — KDS 는 연암을 발파암 쪽에 둠(토량계산.md:90), 실무는 연암을 브레이커 암으로 씀([북부임도] 단가산출!R2370). + +--- + +### 4-다. 해마다 조항 번호가 바뀐 이력 + +#### 4-다-1. 산림(임도·사방) 품셈 — 판마다 번호 전부 바뀜 + +- 판 흐름: 2017 임도표준품셈 → 2018 통합 산림사업 표준품셈 → 2024 개정판 → 2026 산림청고시 제2025-82호(제정 · 2026-01-01 시행) (ST/20_분석/33_임도품셈_판_대응표.md:11 · original/원가계산/산림_표준품셈/현행_20260101.md:3). 2026 고시 전에는 임도 품이 「임도설치 및 관리 등에 관한 규정」 등 여러 지침 안에 있었음(현행_20260101.md:21 부칙 경과조치). +- 대응표 = STmate 레시피가 인용한 14건만: 확정 13 · 미확인 1 (ST/20_분석/33_임도품셈_판_대응표.md:13-17 · ST/30_원자료/임도품셈_판_대응표.csv). + +| 공종 | 2017 | 2018 | 2024 | 2026 현행 | 근거 | +|---|---|---|---|---|---| +| 층따기 | 3-7 | 4.25 | 8-19 | 9-18 | 임도품셈_판_대응표.csv:13 | +| 제근 | 2-3 | 4.20.2 | 8-22 | 9-21 | csv:11 | +| 토사깎기(굴삭기) | 3-3-1 | 4.3.2 | 8-2 | 9-3-2 「기계」 | csv:14 | +| 임도성토면 다짐 | 3-6 | 4.24 | 8-18 | 9-17-1 「비탈면 다짐」(이름도 바뀜) | csv:7 | +| 구조물터파기 육상 암 0~1m | 5-1-2 사 | 4.27.2 | 8-12 사 | 9-13-7 | csv:3 | +| 측구터파기 암절취 | 5-1-1 나 | 4.27.1 나 | 8-11 나 | 9-12-2 | csv:12 | +| 쇄석·혼합석 부설 | 7-2(선행) | 4.39 | 10-4 | 11-4 | csv:15 | +| 선떼붙이기공 | 선행 사방품셈 4-2-2 | 4.16.2 | 4-16 | 5-16 / 5-16-1 단끊기 / 5-16-2 선떼붙이기 (일대다) | csv:5 | +| 암절취 | 3-3-2 | 4.3.3 | 8-3 | 9-4 / 9-4-1 암파쇄 / 9-4-2 집토 (일대다) | csv:10 | +| 노면다짐 | 7-3 | 4.40 | 목차에 없음 | 미확인(연결 금지) | csv:4 | + +- 번호 꼴도 바뀜 — 2018 은 점 표기(4.3.2), 2024·2026 은 줄표(8-2 · 9-3-2). +- 사용 경계 — 일대다는 대표 하위 절로 자동 치환하지 않음 · 절 대응 확정 ≠ 값 복사 허용 (33_임도품셈_판_대응표.md:26-30) · 「번호만 같은 다른 절을 자동 연결하지 않는다」(ST/20_분석/32_특수산식과_현행품셈_대조.md:51). +- 실무 인용의 실태 — STmate 레시피 350 중 「근거 미기재」 107 · 「근거문구 있으나 현행 위치 미확인(과거 번호 개편·복수 절)」 69 · 「명칭으로 후보 절만」 161 · 「현행 절 확인·수동 대조」 11 · 「원문 직접 일치」 2 (32_특수산식과_현행품셈_대조.md:30-37). 같은 문서 :50 은 「근거문구 보유 82개」라 적어 표와 셈 기준이 다름 — 확인 필요. +- 2017·2018·2024 판 원문 PDF 는 저장소 안에서 찾지 못함(33_임도품셈_판_대응표.md:34-36 이 이름만 적음) — 확인 못 함. 2016 사방사업표준품셈(안) xls 는 있음([사방안16]). + +#### 4-다-2. 건설 품셈 — 해마다(12월 말) 새 판 · 끼워 넣기로 뒤 번호가 밀림 + +- 갱신 주기 = 연간, 매년 12월 말 익년도판 (original/원가계산/건설공사_표준품셈/_meta.md:6). +- [개정26] 「현행 ↔ 개정(안)」 대조표에서 번호가 바뀐 줄(같은 줄에 두 번호가 찍힌 것만 셈 — 최소치): + +| 현행(2025) | 2026 | 인쇄쪽 | 2026 본문 확인 | +|---|---|---|---| +| 공통 2-7-7 비계용 브라켓 설치 및 해체 | 2-7-9 (2-7-3 · 2-7-5 신설로 밀림) | 16 | 건설/01_공통부문/제02장_가설공사/2-07_비계.md:190 | +| 공통 2-7-2 시스템비계 설치 및 해체 | 2-7-2 「…(인력)」 + 새 2-7-3 「시스템비계 설치 및 해체」 — **옛 이름이 다른 항이 됨** | 14 · 15 | 2-07_비계.md:39 · :62 | +| 공통 5-1-3 H-Beam 철거 | 5-1-4 H-Beam 해체 (번호·이름) | 32 | 건설/01_공통부문/제05장_기초공사/5-01_흙막이_및_물막이.md:81 | +| 공통 5-1-4 흙막이판 설치・철거 | 5-1-6 흙막이판 설치・해체 | 33 | 같은 파일:125 | +| 공통 5-1-5 어스앵커 공법 | 5-1-7 | 34 | 같은 파일:149 | +| 건축 10-4-1 알루미늄 프레임 설치 | 10-1-8 커튼월 프레임 설치 | 124 | 건설/03_건축부문/제10장_창호_및_유리공사/10-01_창호.md:144 | +| 건축 10-4-2 외벽 패널 설치 | 8-2-6 금속패널 설치 (장 이동) | 125 | 건설/03_건축부문/제08장_금속공사/8-02_시설물.md:125 | +| 건축 10-4-3 코킹 | 8-2-7 금속패널 코킹 | 125 | 같은 파일:146 | +| 유지관리 2-1-12 차선도색 | 2-1-15 | 149 | 건설/05_유지관리부문/제02장_토목/2-01_도로포장공사.md:467 | + +- [개정26] 구분 칸 어림 — 보완 119 · 신설 31 · 적정검토 23 줄 (pdftotext 줄머리 셈). +- 2026 본문 항(###) 1,095 중 개정 표지 있는 항 837 · 「'24년 신설」 18 · 「'25년 신설」 27 · 「'26년 신설」 26 (머리말 셈). +- 토공 2025 개편 — 3-2-3 흙깎기(기계) · 3-2-4 터파기(기계) · 3-4-1 흙쌓기 · 3-4-3 흙 다지기 · 3-4-6/3-4-7 되메우기 및 다짐 전부 「'25년 신설」 (3-02_굴착.md:54 · :126 · 3-04_쌓기.md:18 · :71 · :132 · :152). 2024 이전 토공 번호는 옛 판이 저장소에 없어 확인 못 함. +- 실무 출처 칸의 판 표기도 제각각 — 「[2025년 표준품셈3-2-4]」([북부사방] 단가산출!R338) · 「2024년 품셈개정, 6-1-1」(R905) · 「[2026년 건설표준품셈6-3-4]」(R1121) · 「[건설표준품셈6-3-4]」(연도 없음 · [북부임도] 단가산출!R895) · 「[임도표준품셈의 발파암 기준 적용]」(옛 품셈 이름 · R1522) · 「< 공통품셈 8-」(다산 · original/원가계산/다산소프트/ESTX_값사전_견적내역.md:37). +- 삭제 표지 — 경남교육청 이름 뒤 「23-1/4 삭제」 · 「24-1/4삭제」 ([경남1] 일위대가!R1681 · R1956). +- 노임 직종 코드도 판마다 바뀜 — 145→117(2010) → 123(2018) → 127(2020-09) → 132(2025-01) · 코드 3001~3018 은 같은 번호로 뜻이 바뀜(문화재목공 → 한식목공) (TI/05_원가정보/원가_기초데이터_원천.md:148 · :154). +- 조달청 호환규정 — 내역공종코드 16 → 20자(2017-04-19) · 규정 코드 반기마다 갱신 (ST/20_분석/30_조달청_호환규정_XSD.md:30 · :28). + +#### 4-다-3. 대응표를 맞춤 열쇠로 쓸 수 있나 + +- 산림 판 대응표 — 14건뿐(STmate 인용분) → 「옛 번호 → 현행 번호」 바꿈표의 씨앗 수준. 출처 칸이 옛 번호를 인용한 실무 엑셀에만 쓸모. 전 항목으로 넓히려면 2017·2018·2024 판 원문이 필요(저장소에 없음). +- 건설 개정사항 — [개정26] 에서 「현행 ↔ 개정」 줄을 뽑으면 한 해 치 바꿈표가 됨. 2026 판만 있어 2025 → 2026 한 단계만 가능. 해마다 받아 쌓아야 함. +- 열쇠 꼴 — 「품셈 갈래(건설·산림) + 부문 + 판 연도 + 번호」 넷이 있어야 이어짐 (TI/04_수량분석정보/건설품셈_활용.md:54 · :131). 실무 출처 칸은 판 연도·부문을 자주 빼먹음(위 실무 표기 예). + +--- + +### 4-라. technical_info 등 — 이름 · 조항 대응 · 같은 뜻 말을 다룬 문서 + +| 문서 | 담긴 것 | 쓰임 | +|---|---|---| +| TI/04_수량분석정보/건설품셈_활용.md | 두 품셈 연결 원칙(:50 이름만으로 자동 연결 금지) · 부문별 번호 재시작(:54) · 잇지 못하는 갈래 표(:83-89) · 산림 목차 번호 오기(:93-100) · 품셈 자료 요건 = 공종 코드 + 출처(산림/건설 + 장·절 + 판 연도)(:131) | 맞춤 규칙 근거 | +| TI/04_수량분석정보/토공_수량.md | 토공 공종 ↔ 산림 절 대응(:16-31) · 구조물터파기 3축 18구분(:33-41) · 용어 차이 해석(:54) | 토공 이름 축 사전 — 단 근거 줄(L…)이 지운 옛 md 를 가리킴(:7 · technical_info/_index.md:8) | +| TI/05_원가정보/원가_출력변수_사전.md | 우리 공종 79 ↔ 산림 절 (B 절 :47~) | 사전의 「우리 쪽」 이름 목록 | +| TI/05_원가정보/산림_건설_미명시7건_대응매핑.md | 산림이 「건설품셈 준용」만 적은 7건의 건설 절 대응 (:12-) | 두 품셈 사이 다리 | +| TI/05_원가정보/연결고리_건설공종_6유형_분류.md | 연결 64건 · 고유 건설 절 42 (:7 · :13-20) | 참고 — 「공통 3-6-1 터파기」(:20 · :68)가 2026 본문 3-6-1 「성토면 고르기」(건설/01_공통부문/제03장_토공사/3-06_성토부대공.md:18)와 어긋남 · 판정 안 함 · 사용자 확인 필요 | +| TI/05_원가정보/원가_기초데이터_원천.md:144-157 | 직종·자재·기계 판 교체 때 「번호가 같아도 뜻이 바뀜」 경고 | 코드 열쇠의 한계 근거 | +| TI/03_계산정보/토량계산.md:89-90 · :108 | 토질·암 분류 두 체계(품셈 vs KDS) | 암 이름은 같은 뜻 말 아님 근거 | +| original/원가계산/노임단가_직종대응_근거.md | 품셈 옛 직종명 → 현행 직종 대응표(:50-69 등) | 직종 사전 그대로 | +| original/원가계산/노임단가_건설업_대한건설협회/2. 임금적용요령.md:66-76 · :78 · :86 · :93 | 2010 직종 명칭변경표 · 2018 · 2020 · 2025 신설직종 | 직종 사전 | +| original/원가계산/노임단가_제조업_중소기업중앙회/직종코드_변천_대조표.md | 제조 직종 코드 변천(151줄) | 이번에 자세히 안 읽음 | +| ST/20_분석/31_일위대가_레시피_사전.md + [레시피] | 실무 6건 일위대가·단가산출·중기 조합 350(이름 · 규격 · 단위 · Q식 · 품셈근거 · 구성) (:48-56 · :118-134) | 「같은 일 · 다른 이름 · 같은 구성」 학습·시험 자료 | +| ST/30_원자료/stmate_code_name_list.csv | 자원 코드 894 ↔ 이름·규격·단위 | 자원 이름 사전 후보 | +| ST/20_분석/22_UI_폼_분석.md:116-124 | STmate 기준자료 갱신 매칭 키 5단계 — 호환규정코드 / 시설코드 / 명칭·규격·단위 / 호환+명+규+단 / 명칭·단위 (퍼지 없음) | 경쟁사도 「코드 → 이름·규격·단위 일치」 순으로 이음 | +| ST/20_분석/30_조달청_호환규정_XSD.md + ST/30_원자료/조달청_공사원가호환규정.xsd | 세부공종명·규격·단위 · 노임산출근거 안 「공량품셈근거」 칸(xsd:637) · 실적공사비코드 `[ACEMT]+[0-9A-Z]*`(xsd:1241) · 일위대가·자재·노무 코드는 영숫자 12~16자 형식만 정함(xsd:1244-1271) | XML 납품본이면 품셈 근거 칸이 있음 · 값 목록(국가 공통 코드 여부)은 확인 못 함 | +| original/원가계산/다산소프트/ESTX_분석.md:26-27 | 다산 ESTX 안 단가산출 제목 1,569 · 일위대가 제목 1,468 | 이름 목록 md 없음 · 원본 .estx 를 풀어야 함(이번에 안 엶 · 확인 못 함) | +| technical_info/02_사방댐/05_원가정보/01_원가_특이사항.md | 사방 원가 특이사항(47줄) — 이름·조항 대응 없음 | 해당 없음 | +| 같은 뜻 말 전용 문서 | 없음 — 「동의어·같은 뜻·별칭·유의어·alias」 검색 결과는 사방 대상지 「별칭」 한 줄뿐(technical_info/02_사방댐/01_대상지_타당성평가/03_대상지조사_시공대상지.md:19) | — | + +--- + +### 4-마. 판정 — 맞춤 사전 재료로 + +| 재료 | 판정 | 한 줄 이유 | +|---|---|---| +| 건설·산림 품셈 본문 머리말(항 이름 1,095 + 산림 274 등) | 바로 쓸 만함 | 규칙 있는 md 머리말 · 정규식으로 뽑힘 · 윗머리 잇기·부문 붙이기만 | +| 건설 8장 기종 코드 머리 156 | 바로 쓸 만함 | 장비 줄 구성 비교의 열쇠(코드 + 이름) | +| 직종 대응표(직종대응_근거 · 2010 명칭변경표) | 바로 쓸 만함 | 원문 근거 있는 옛 이름 → 새 이름 짝 | +| 장비 이름 변이(굴착기·굴삭기·백호우·B/H · 브레이커·브레이카 · 불도저·도자) | 바로 쓸 만함 | 짧은 손사전 · 원문·실무 근거 있음 | +| 철자·띄어쓰기·단위 표기 고르기(깎/깍 · 뒤/뒷채움 · ㎥/m3/M3 · 띄어쓰기) | 바로 쓸 만함 | 규칙 몇 줄 | +| 품셈 [주]·비고 속 다리 문구(3-2-1 주① 등) · KCS 제목 병기 | 조건부 | 손으로 골라 담아야 함 · 수십 건 규모 | +| 실무 출처 칸 인용 문자열(「[건설공사표준품셈 3-2-1 인력굴착]」 꼴) | 조건부 | 번호는 강한 열쇠이나 판 연도·부문 표기가 제각각 · 옛 번호·옛 품셈 이름 섞임 · STmate 레시피 350 중 107 은 근거 없음 | +| 실무 이름 짝(북부청 2건 · 경남 2건 · STmate 레시피 350) | 조건부 | 같은 일·다른 이름 학습·시험 자료로 좋음 · 사용자 확정 뒤에만 사전에 넣음 | +| 산림 판 대응표(14건) | 조건부 | 씨앗 수준 · 전 항목 아님 · 옛 판 원문 없음 | +| 건설 개정사항 PDF 현행↔개정 줄 | 조건부 | md 없음 · 2026 판만 · 해마다 받아 쌓아야 바꿈표가 됨 | +| 조달청 호환규정 XSD 코드·품셈근거 칸 | 조건부 | XML 납품본에만 해당 · 이번 실무 엑셀 5건에선 공종 코드 칸을 못 봄(경남만 자체 10자리 공종 코드가 이름 칸 앞에 붙음 — [경남1] 공종별집계표!R50-R60) · 엑셀 열 전체는 안 봄(확인 못 함) | +| 다산 ESTX 일위대가·단가산출 제목 | 조건부 · 확인 못 함 | 이름 목록이 md 로 풀려 있지 않음 | +| 토질·암 분류 이름(연암·리핑암·브레카암·발파암) | 안 맞음 | 체계마다 경계가 달라 같은 뜻 말로 묶으면 틀림 — 「축」으로 따로 다룸 | +| 같은 뜻 말 전용 문서 | 안 맞음 | 지식DB 에 없음 | + +덧붙임(범위 밖 한 줄): 저장소 venv 에 xlrd · openpyxl · pandas 있음 · rapidfuzz · kiwipiepy · scikit-learn 없음(import 확인만). Git Bash 에 pdftotext 있음. + +--- + +## 5. 판정 표 — 우리 조건 기준 + +조건 = 오프라인 사내 서버 · Python 3.12 · FastAPI · 무거운 새 의존성 싫음 · 사용자가 결과를 고르는 도구 · 자동 확정 없음. +판정 = 바로 쓸 만함 / 조건부 / 안 맞음 · 품 = 작음 · 중간 · 큼(어림) · 근거 절 = 이 문서 절 번호. + +| # | 갈래 | 방법 | 판정 | 까닭(한 줄) | 품 · 새 의존성 | 근거 절 | +|---|---|---|---|---|---|---| +| 1 | 사전 | M01 로직 「별칭」 을 맞춤이 읽게 함 + 채움 | 바로 쓸 만함 | 그릇 · 화면 · 층이 이미 있음 · 마스터 1,469 줄 모두 빔 · M02 맞춤은 안 읽음 | 작음 · 없음 | 3-0 | +| 2 | 사전 | 같은 뜻 말 · 쪼개기(구조물터파기 → 구조물 + 터파기) · 덮는 말(보통토사 ⊂ 토사) 표 — 재료 = 품셈 [주] 다리 문구 · 조달청 옛/새 이름 · 실무 짝 20 · 씨앗 메모 170 | 바로 쓸 만함 | 글자가 하나도 안 겹치는 짝을 잇는 유일한 값싼 길 · 근거가 눈에 보임 | 작음 · 없음 | 3-2 · 4-나 · 1-4 | +| 3 | 정규화 | 표기 고르기 — 깎/깍 · 뒤/뒷채움 · ㎥/m3/M3 · 굴삭기/굴착기/백호우/B/H · 브레이커/브레이카/브렐이커 · 불도저/불도우저/도자 · 띄어쓰기 | 바로 쓸 만함 | 한 문서 안에서도 섞임 · 규칙 몇 줄 | 작음 · 없음 | 4-나-4 · 3-2 | +| 4 | 정규화 | 괄호 글을 지우지 않고 칸(방법 · 토질 · 규격 · 비율)으로 옮김 | 바로 쓸 만함 | 지금은 괄호를 지워 「인력/토사」 를 잃음 | 작음 · 없음 | 3-0 · 3-2 | +| 5 | 칸 맞춤 | 칸별 같음 · 어긋남 매김 · 방법 · 토질이 어긋나면 크게 깎음 · 화면에 칸별 까닭 | 바로 쓸 만함 | 이름 비슷한 다른 일(기계 터파기)을 가름 · 국내 연구도 〈대상〉+〈작업〉 구조 | 중간 · 없음 | 3-1 · 3-2 | +| 6 | 증거 | 구성 비교 — 직종 · 장비 · 자재 묶음 · 「보통인부뿐」 신호 · 직종 옛/새 이름 표 · 건설 8장 기종 코드(156) | 바로 쓸 만함 | 이름과 무관한 증거 · 시중 STmate 「산근내용」 검색과 같은 방향 · 엑셀 · 우리 로직 둘 다 이미 읽음 | 중간 · 없음 | 2-3 · 3-2 · 4-라 | +| 7 | 학습 | 확정 짝 → 별칭 후보 쌓기 → 관리자 승인 → 사전 | 바로 쓸 만함 | 지금 기억은 품명 · 규격 · 단위가 똑같을 때만 · 일반화가 빠짐 | 작음 · 없음 | 3-2 | +| 8 | 단계 | 정확 일치 열쇠 단계 내리기 — 정규화 · 칸 쪼개기 뒤 순서 무관 열쇠 → 명+규+단 → 명+단 | 바로 쓸 만함 | STmate 갱신 매칭과 같은 방식 · 오탐 적음 · 설명 쉬움 | 작음 · 없음 | 2-3 · 3-2 | +| 9 | 점수 | 글자 2~3 그램 TF-IDF(흔한 묶음 가볍게) | 바로 쓸 만함(작은 이득) | 지금 Dice 대체 · 표준 라이브러리로 됨 · 같은 뜻 말 문제는 못 풂 | 작음 · 없음 | 3-2 | +| 10 | 사람 | 후보 + 사람 고름 + 층 기억(개인 → 회사 → 마스터) | 이미 있음 — 유지 | 시중 프로그램 · 조달연 · 국내 논문 모두 이 길 | — | 2 · 3-3 | +| 11 | 코드 | 단가산출 출처 품셈 조항 번호(지금 근거 길) + 판 짐작 + 부문 넣기 + 옛 번호 바꿈표 | 조건부 | 번호는 강한 열쇠 · 실무 글은 판 · 부문을 자주 뺌 · 판마다 번호 바뀜 · 바꿈표 재료는 산림 14 건 · 건설 2025 → 2026 한 단계뿐 | 중간 · 없음 | 1-1 · 1-5 · 4-다 | +| 12 | 코드 | 조달청 세부공종코드 CSV(33,491 행 · 무료 · 오프라인)를 사전 재료로 | 조건부(사전 재료로는 바로 쓸 만함) | 옛/새 이름 묶음 · 품셈 꼴 이름(S 코드) · 임도 · 사방 고유 공종 없음 | 작음 · 없음(CSV 한 벌) | 1-4 | +| 13 | 코드 | C3R XML 입력(일위대가코드 · 자원코드 · 공량품셈근거 칸) | 조건부 | XML 을 받을 때만 · 실제 납품 XML 에 표준공사코드가 실리는지 확인 못 함 | 중간 · 없음 | 1-4 | +| 14 | 코드 | 자원 줄 국가 호환코드(JDCODE) · 조달 8 자리 물품식별번호 | 조건부 | STC 엔 노임 · 중기 · 경비 대부분 있음 · 엑셀엔 인쇄 설정에 달림 · 조달 가격 자재 줄만 번호 보임 | 작음 · 없음 | 1-7 · 2-3 | +| 15 | 코드 | STC 파일 직접 읽기 | 조건부 | 엑셀보다 열쇠가 많음 · 발주처가 STC 를 줄 때만 | 중간 · 없음 | 2-5 | +| 16 | 증거 | 금액 대조(노무비 몫 · 같은 해 단가) | 조건부 | 옛 판 값 · 할증 · 지역 단가가 섞임 — 동점 가르기 · 경고만 | 중간 · 없음 | 3-2 | +| 17 | 점수 | Jaro-Winkler · rapidfuzz · 자모 분해 | 조건부 | 보조 점수 · 국내 연구 1순위 정답률 50 % 아래 · 예시에서 오답을 더 높게 봄 | 작음 · rapidfuzz 만 새 꾸러미(작음) | 3-1 · 3-2 | +| 18 | 점수 | 형태소 분석(kiwipiepy · mecab-ko) | 조건부 | 둘 다 Windows · 3.12 바퀴 있음 · 짧은 전문 복합어는 도메인 사전이 먼저 | 중간 · 새 꾸러미 + 모델 | 3-2 | +| 19 | 학습 | 불확실한 줄 먼저 보이기 · 칸 무게 학습(m/u) | 조건부 | 확정 짝이 쌓인 뒤 | 중간 · 없음 | 3-2 | +| 20 | 사전 | 국토교통부_건설용어사전 CSV(6,666 용어) | 조건부 | 동의어 칸 없음 · 정의문에서 손으로 뽑아야 함 | 중간 · 없음 | 3-2 | +| 21 | 점수 | 문장 임베딩(multilingual-e5-small · ko-sroberta) | 조건부(보류) | 수백 MB · 전문 용어 이해 미확인 · 1~9 뒤 남은 실패를 보고 시험 | 큼 · 새 실행 꾸러미 + 모델 | 3-2 | +| 22 | 코드 | 표준시장단가 코드 | 안 맞음 | 100억 원 이상 공사용 · 실무 엑셀 0 건 | — | 1-2 | +| 23 | 코드 | 건설정보분류체계(공종분류) | 안 맞음 | 실무 활용 「극히 저조」 · 실무 엑셀 0 건 | — | 1-3 | +| 24 | 코드 | 물가지(한국물가정보 · 물가자료) 자재 코드 | 안 맞음 | 엑셀엔 쪽 번호뿐 · 품목 코드는 유료 · 비공개 | — | 1-6 | +| 25 | 코드 | 일위대가 · 단가산출 머리를 국가 코드로 잇기 | 안 맞음 | 실무 STC 일위대가 0/216 · 단가산출 0/371 | — | 2-3 | +| 26 | 코드 | STmate · 다산 내부 코드 | 안 맞음 | 프로젝트 안 순번 — 같은 코드가 프로젝트마다 다른 항목 | — | 2-3 | +| 27 | 사전 | 암 이름(연암 · 리핑암 · 브레카암 · 발파암)을 같은 뜻 말로 묶기 | 안 맞음 | 체계마다 경계가 다름 — 따로 「축」 으로 다룸 | — | 4-나-4 | +| 28 | 점수 | 큰 언어 모델 재순위 | 안 맞음(지금 조건) | 인터넷 · 자료 외부 전송 필요 · 로컬 모델은 큼 · 라이선스 걸림 · 돈은 싸서 걸림돌 아님 | — | 3-2 | +| 29 | 점수 | 신경망 분류 학습(BERT 미세조정 등) | 안 맞음 | 5 만 줄 규모 학습 자료가 전제 · 우리는 적음 | — | 3-3 | + +### 5-1. 묶어 보면 — 차례 제안(정할 것은 사용자) + +| 단계 | 묶음 | 기대 | +|---|---|---| +| 1 | 1 · 2 · 3 · 4 · 7 (별칭 · 같은 뜻 말 · 표기 고르기 · 괄호 칸 · 확정 짝 → 별칭) | 「글자가 안 겹쳐 후보에도 못 듦」 을 없앰 — 새 의존성 없음 | +| 2 | 5 · 6 · 8 (칸 어긋남 · 구성 비교 · 열쇠 단계) | 「이름 비슷한 다른 일이 이김」 을 가름 · 화면에 칸별 까닭 | +| 3 | 9 · 11 · 12 (TF-IDF · 조항 바꿈표 · 조달청 CSV) | 순위 다듬기 · 옛 판 엑셀 대응 | +| 4 | 실측 뒤 남은 실패를 보고 16 ~ 21 중 고름 | 무거운 것은 효과가 보일 때만 | + +--- + +## 6. 조사끼리 어긋난 셈 · 확인 못 한 것 + +### 6-1. 어긋난 셈(이 정리에서 다시 봄) + +| 무엇 | 1 절 | 4 절 | 다시 본 결과 | +|---|---|---|---| +| 건설 2026 본문 항(###) 중 「'25년 신설」 수 | 처음 셈 22 | 27 | **27** — 곧은 따옴표 22 + 굽은 따옴표(‘) 5(4-1-2 · 4-4-5 · 6-1-7 · 2-1-12 · 2-1-14). 처음 셈은 굽은 따옴표를 빠뜨림 — 1 절 본문은 27 로 고쳐 둠. 처음 셈의 「'25 꼬리표 135」 는 꼬리표 꼴(「25년 보완」 따옴표 없음 등)이 섞여 다시 세지 않음 — 뺌 | +| `2026년_건설공사_표준품셈_개정사항.pdf` 읽힘 | 처음엔 「글자층이 깨져 읽지 못함」 | pdftotext 로 본문 뽑아 번호 바뀜 9 건 대조 | **본문은 읽힘** — 뽑은 글(스크래치패드 `req225/rev2026.txt`)에서 「2-7-7 비계용 브라켓 … → 2-7-9」 「5-1-2 H-Beam 설치 → 5-1-2 H-Beam 설치(띠장)」 줄이 그대로 보임 · 표지 글자만 겹침. 1 절 본문은 「공개처에서는 못 찾음 · 2025 → 2026 한 단계는 이 PDF 에서 뽑힘(4-다-2)」 으로 고쳐 둠 | +| STmate 레시피 근거 셈 | — | 「근거 미기재 107」 vs 같은 문서 「근거문구 보유 82」 | 셈 기준이 다름 — 확인 필요(4-다-1) | +| 「공통 3-6-1 터파기」(TI 연결고리 문서) | — | 2026 본문 3-6-1 = 「성토면 고르기」 | 판정 안 함 · 사용자 확인 필요(4-라) | + +### 6-2. 확인 못 함(모음) + +- STmate `CONV_XLS_2_ST` 가 엑셀 행을 무슨 열쇠로 잇는지(사용설명서 정회원 전용) · 다산 엑셀 → EST 변환이 기능인지 사람 대행인지 · 나스 SGS · Easy 2.0 엑셀 읽기 뒤 이름 맞춤 방식 · 콘엑스 AI 검증 방법 · 성능(2-6). +- 조달청 2025-05 개편 코드에 토목 일위대가 · 단가산출 코드가 생겼는지(npccs.g2b.go.kr 본문 빔) · 실제 납품 C3R XML 에 표준공사코드가 실리는지(저장소에 XML 견본 없음)(1-4). +- 건설 품셈 2025 이전 판 번호 대응(옛 판 원문 · 공개 대응표 못 찾음) · 산림 2017 · 2018 · 2024 판 원문(저장소에 없음)(1-1 · 4-다). +- 건설정보분류체계 고시 원문 · 공종 코드 자릿수(법제처 오류 페이지)(1-3) · 물가지 품목 코드 체계(유료)(1-6) · 실무 엑셀 8 자리 번호의 실제 물품식별번호 대조(1-7). +- Lee 외(2023) 국도 내역서 공종 코드 논문 원문(ASCE 403 — 초록 수치 91.12 % 는 검색 요약으로만)(3-3). +- 형태소 분석기 · 임베딩 모델이 「구조물터파기」 「깬잡석찰쌓기」 같은 이름을 어떻게 다루는지(설치 금지로 시험 못 함) · EXAONE GGUF 크기(3-2). +- 다산 내부 코드의 판 사이 안정성 · ESTX 일위대가 · 단가산출 제목 목록(.estx 안 엶)(2-3 · 4-라). + +## 부록 · 이 조사의 스크립트 · 받은 자료(스크래치패드 `req225/` · 저장소 밖) + +| 파일 | 무엇 | +|---|---| +| `tools/demo_sim.py` | 3-1 예시 계산(표준 라이브러리만) | +| `paper_kjcem2020.txt` | 정상원 · 정기창(2020) 논문 글자(3-3) | +| `p1_scan_codes.py` · `p1_seb_stats.py` · `p1dl_datagokr/seb.csv` | 실무 엑셀 코드 훑기 · 조달청 세부공종코드 33,491 행(1 절) | +| `p2_codecheck.py` → `.txt` · `p2_jdcheck.py` → `.txt` | STmate 코드 충돌 · JDCODE 채움 실측(2 절) | +| `scan_names.py` → `scan_out_u8.txt` · `rev2026.txt` | 실무 엑셀 이름 훑기 · 2026 개정사항 PDF 글자(4 절) | diff --git a/docs/raw/조사/2026-10-15_336_외부일위대가_이름맞춤_검토.md b/docs/raw/조사/2026-10-15_336_외부일위대가_이름맞춤_검토.md new file mode 100644 index 000000000..c218a20b9 --- /dev/null +++ b/docs/raw/조사/2026-10-15_336_외부일위대가_이름맞춤_검토.md @@ -0,0 +1,323 @@ +# 외부 일위대가 이름 맞춤 — 상세 검토 (요구 225-4 · PLAN 336) + +M02 「외부 일위대가 가져오기」에서 이름이 달라 못 맞추는 문제의 개선 방법 검토. 코드 고침 없음 — 어떤 개선을 코드로 만들지 고르는 판단 자료. +재료 = C1 실측(우리 엑셀 3 파일 · 정답 표시 136 줄) · C2 바깥 조사(시중 프로그램 · 표준 코드 · 기법 · 지식DB) · 324 · 329-330 조사 · C3 덧잼(C1 함수로 몇 가지 더 잼). +C1 = 같은 폴더 `2026-10-15_336_부록1_실측.md` · C2 = `2026-10-15_336_부록2_바깥방법.md` · 실험 스크립트(C1 · C3 · `req225/` 경로)는 브레인 스크래치패드 임시 — 수치는 이 문서 · 부록에 남김. +기준 = main_laptop_1 04021d1f7(맞춤 코드는 C1 기준 31e6adf7d 와 같음 — 그 뒤 바뀐 것은 메뉴 화면 두 파일뿐) · 수치는 모두 「기억 없음」(확정한 것 없는 첫 올리기) 상태. + +## 0. 결론 요약 + +1. 이름이 달라 못 맞추던 문제는 330(엑셀 단가산출의 품셈 조항 · 직종으로 맞춤)으로 이미 크게 줄었음 — 세 파일 126 줄 기준 1순위 정답 41% → 71% · 후보 다섯 안 정답 60% → 87%(엄격 기준). +2. 남은 1순위 실패 36 줄 = 산림 ↔ 건설 책 갈림 11 · 현장말(고임돌 · 소운반 · 철골설치) 11 · 우리 로직이 더 잘게 5 · 그 밖 9 — 줄임말 · 띄어쓰기 · 낱말 차례처럼 글자 꼴만 다른 실패는 0. +3. 이름 글자를 더 다듬는 방법(같은 뜻 사전 · 글자 비교 바꾸기 · 괄호 살리기 · 단위 거르기)은 우리 자료로 재 보니 효과 0 이거나 오히려 떨어짐. +4. 가장 싸고 큰 개선 = 근거 쪽 서버 고침 넷 → 1순위 71% → 86% · 다섯 안 87% → 94% · 「같음」으로 엑셀과 다른 책이 들어가던 10 줄 → 0. +5. 손으로 만든 같은 뜻 사전은 안 맞음 — 회사(발주처)마다 같은 일에 쓰는 책이 달라 한 파일로 만든 사전이 다른 꼴 문서에서 −9 점 · 지금 맞춤에 얹으면 −7 점. +6. 사전 대신 「사용자 확정이 회사 층에 쌓이는 맞춤 기억」을 넓힘 — 열쇠를 품명 · 단위로 넓히면 같은 발주처 다음 파일에서 바로 1순위로 뜨는 엑셀 줄 5% → 33%(모두 맞음) · 다른 발주처 기억은 62~71% 만 맞아 「후보 꼬리표」로만 · 원본 로직 · M01 별칭은 안 건드림. +7. 다섯 안 정답이 94% 라 그다음은 「사람이 빨리 · 바르게 고르기」 — 후보마다 책 · 조항 · 직종 · 금액 비(±5% 안이면 95% 맞음)를 보이는 고르기 창 손질. +8. 「다른 문서에서도」의 실제 걸림돌 = 지식DB 실무 내역 xlsx 9 개 중 6 개(STmate 꼴)가 올리기부터 막힘 · 2 개는 문서 보안(DRM) 잠김 — 이름 맞춤 앞 단계. +9. 큰 언어 모델 · 임베딩은 오프라인 사내 서버 · 자료 외부 유출 금지 조건에 안 맞음 — 보류. +10. 추천 차례 = ① 근거 고침 → ② 고르기 창 → ③ 기억 넓히기 · ④ STmate 읽기는 「다른 문서」를 언제 쓸지에 따라 ①과 나란히. + +--- + +## 1. 지금 실력 + +말 풀이 — 1순위 = 고르기 창 맨 위 후보(또는 「같음」 대상) · 다섯 안 = 후보 다섯 안에 정답 있음 · 엄격 = 엑셀 단가산출이 적은 품셈 조항 · 품과 같은 우리 로직 · 느슨 = 같은 일의 다른 책(산림 ↔ 건설) 로직이나 복합 줄의 곁 몫까지 맞음 · 고유 줄 = 품명 · 규격 · 단위 · 단산 번호가 같은 줄은 한 번 셈(한 번 확정하면 같은 줄 모두 들어감) · 같음 = 이름이 똑같거나 씨앗과 같아 사람이 안 골라도 들어가는 줄 · 씨앗 = 일위대가조합.json 메모(실무 이름 80 → 로직 · 읽기만). + +### 1-1. 330 앞 대 지금 (칸 = 엄격 / 느슨 %) + +| 파일 | 고유 줄(엑셀 줄) | 정답 있음 | 우리에 없음 | 330 앞 1순위 | 330 앞 다섯 안 | 지금 1순위 | 지금 다섯 안 | +|---|---|---|---|---|---|---|---| +| 북부청 임도 | 52(271) | 49 | 3 · 5.8% | 28.6 / 59.2 | 53.1 / 89.8 | 71.4 / 85.7 | 83.7 / 98.0 | +| 북부청 사방 | 48(142) | 43 | 5 · 10.4% | 34.9 / 62.8 | 53.5 / 88.4 | 74.4 / 83.7 | 88.4 / 95.3 | +| 동부청 영월* | 36(91) | 34 | 2 · 5.6% | 67.6 / 79.4 | 76.5 / 88.2 | 67.6 / 73.5 | 88.2 / 88.2 | +| **세 파일** | 136(504) | 126 | 10 · 7.4% | **41.3 / 65.9** | **59.5 / 88.9** | **71.4 / 81.7** | **86.5 / 94.4** | +| 엑셀 줄 무게(되풀이 셈 · 느슨) | 504 | 471 | 33 · 6.5% | 59.2 | 90.7 | 78.1 | 97.9 | + +- \* 영월 = STmate 꼴 — 지금 화면은 올리기부터 400. C1 이 스크래치패드에서 따로 읽어 같은 맞춤 함수에 넣은 가정 값. +- 330 앞 = 씨앗 켬(실제 프로그램 상태). C1 결론 표는 씨앗 끈 값(34.1)을 「이름만」으로 둬 영월의 330 효과가 부풀어 보임 — 실제 영월의 330 효과 = 1순위 엄격 0 · 다섯 안 +11.7(3-2 절). +- 읽는 법 — 북부청 두 파일은 330 으로 1순위가 두 배 넘게 오름. 영월은 근거 직종을 거의 못 읽어(STmate 흉내 한계 · 5 절) 330 효과가 다섯 안에만 남. + +### 1-2. 씨앗 몫 — 처음 보는 파일로 치면 (1순위 엄격 / 느슨 %) + +| 파일 | 330 앞 씨앗 끔 | 330 앞 씨앗 켬 | 지금 씨앗 끔 | 지금 씨앗 켬 | 묶음 ① 뒤 씨앗 끔 | 묶음 ① 뒤 씨앗 켬 | +|---|---|---|---|---|---|---| +| 북부청 임도 | 26.5 / 57.1 | 28.6 / 59.2 | 75.5 / 85.7 | 71.4 / 85.7 | 91.8 / 91.8 | 91.8 / 91.8 | +| 북부청 사방 | 34.9 / 62.8 | 34.9 / 62.8 | 76.7 / 83.7 | 74.4 / 83.7 | 86.0 / 88.4 | 86.0 / 88.4 | +| 동부청 영월 | 44.1 / 61.8 | 67.6 / 79.4 | 44.1 / 55.9 | 67.6 / 73.5 | 55.9 / 67.6 | 76.5 / 82.4 | +| 세 파일 | 34.1 / 60.3 | 41.3 / 65.9 | 67.5 / 77.0 | 71.4 / 81.7 | 80.2 / 84.1 | 85.7 / 88.1 | + +- 씨앗(일위대가조합.json 조합 37 · 실무 이름 80)을 만든 실무 호표에 영월 호표 20 개가 들어 있음(봉화 29 · 울진대흥 19 · 영월 20 · 울진소광 11 · 거창 7 · 계류보전 1) → 영월의 씨앗 효과(+20~24)는 「이미 본 파일」 효과. 처음 보는 STmate 파일 실력 = 영월 씨앗 끔 값. +- 지금은 씨앗이 북부청 두 파일에 해로움(−4.1 · −2.3) — 씨앗이 문양거푸집 2 줄 · 면고르기 1 줄을 산림 쪽으로 「같음」 처리하는데 북부청 엑셀은 건설 조항. 묶음 ①(「같음」도 근거 검사)을 넣으면 이 해가 0 이 됨. + +### 1-3. 「같음」 줄 — 사람이 안 골라도 들어가는 줄 + +- 지금 「같음」 33 줄(고유) 중 10 줄이 엑셀 출처와 다른 책(모두 같은 일 · 느슨으론 맞음) — 합판거푸집 · 문양거푸집 · 강관비계 · 강관동바리 · 면고르기 · 돌망태형 옹벽. +- 환산이 정해진 25 줄은 [사전조합 만들기] 때 확정 없이 들어감 — 그중 5 줄이 다른 책. 나머지 다른 책 5 줄은 환산을 넣으면 들어감. +- 묶음 ① 뒤 「같음」 22 줄 · 다른 책 0 — 11 줄(다른 책 10 + 두 책 모두 정답인 야면석메붙임 1)이 「후보」로 바뀌어 사람이 고름 · 그 11 줄 모두 「엑셀 출처 책 로직 1순위 · 같은 이름 다른 책 2순위」로 보임. + +### 1-4. 단추 정밀 + +- [근거 맞음 모두 확정] — 후보 줄 98 중 55 줄을 한 번에 · 55 줄 모두 느슨 맞음(엄격 54) — 믿을 만한 단추. +- [같음 모두 확정] — 25 줄 중 엄격 20(다른 책 5) — 근거를 안 봄. + +### 1-5. 우리에 없음 10 줄(고유 · 엑셀 33 줄) + +- 잔토처리 · 잔토정리 6 — 우리 로직엔 단독으로 없음(「굴착(인력/토사)」의 잔토처리 입력으로만 있음). +- 노임 줄 2 — 철골공 · 특별인부 [인] — 로직이 아닌 노임 단가 · 사전조합엔 노임 줄 그릇이 없음. +- 고화매트 설치 1 · 철강재 화물 운임 1. +- 이 중 5 줄은 엉뚱한 후보가 1순위로 보임(고화매트 → 식생매트 · 잔토정리 → 타설 후 정리 · 철골공 → 철골 공장용접 등). + +--- + +## 2. 남은 실패 까닭 + +1순위 엄격 실패 줄(고유) · 칸 = 고유 줄(엑셀 줄). 「묶음 ① 뒤」 = 3 절 1~4 고침을 얹은 흉내. + +| 갈래 | 지금 실패 | 묶음 ① 뒤 | 쉬운 예 | 풀 길 | +|---|---|---|---|---| +| 책 갈래(산림 ↔ 건설) | 11(53) | 1(1) | 합판거푸집(4회) — 엑셀은 건설 6-3-1 인데 이름이 같은 산림 12-4 가 「같음」 | 「같음」도 근거 검사(4) | +| 같은 뜻 다른 말 · 현장말 | 11(46) | 11(46) | 고임돌채집및운반 → 1위 「자갈채집 및 운반」(철도 궤도 자갈) · 정답 「모래·자갈·약돌 채집(인력)」 | 근거로도 못 품(출처 없음 · 단위중량 인용 · 옛 판) → 한 번 확정 뒤 기억(③) | +| 우리 로직이 더 잘게 | 5(27) | 0 | 철근가공조립(현장) = 우리 「철근 현장가공」 + 「철근 현장조립」 둘 · 엑셀 출처는 윗 조항 「6-2」 | 조항 윗마디(1) | +| 그 밖(접힘 · 이름 함정) | 5(15) | 2(7) | 유로폼 설치,해체 — 산림 · 건설 같은 이름 둘이 후보에서 하나로 접혀 건설 것이 사라짐 · 「철근가공조립(현장)」이 괄호를 지우면 우리 「철근가공조립(복잡)」과 이름이 같아짐 | 접힘 고침(2) · 남은 2 = 영월 — 근거 직종을 못 읽어 「괄호 지운 이름 같음」 덤이 이김 → 괄호 다른 덤 빼기(2-1)로 1 더 풂 | +| 괄호 속 규격 | 1(18) | 1(18) | 구조물터파기(토사) → 1위 「터파기(기계) 암」(괄호 「토사」를 지워 토질을 못 봄) | 토질 칸 어긋남 깎기(7) — 느슨까지 | +| 장비 · 노임 이름 줄 | 2(2) | 2(2) | 콘크리트 펌프차 41m [hr] → 1위 「콘크리트 펌프차 타설」(㎥) · 가야 할 곳 = 「기계 시간당 기계경비」 | 장비 시간 줄 길(5) | +| 엑셀 복합 호표 | 1(1) | 1(1) | 철골가공조립(공장) = 우리 「공장가공조립」 + 「공장용접」 | 사람이 고름(느슨 1위 맞음) | +| **합계** | **36(162)** | **18(75)** | | | + +- 다섯 안에도 정답이 없는 줄(엄격) — 지금 17 → 묶음 ① 뒤 7(야면석소운반 2 · 콘크리트소운반 · 철골설치 · 측구수로관부설 · 펌프차 · 크레인) · 장비 길(5)로 2 더 풀면 5. 이 5 줄은 [다른 로직 찾기]로 사람이 찾아야 함. +- 묶음 ① 뒤 남은 현장말 11 줄 중 6 줄이 고임돌 · 조약돌 채집(엑셀은 「기계 90% + 인력 10%」를 한 줄로 · 우리 정답은 인력 채집 몫) — 같은 품명이 세 파일에 되풀이되므로 기억(③)이 가장 잘 맞는 갈래. +- 330 앞(씨앗 끔) 실패 83 줄의 65% 가 같은 뜻 다른 말 · 책 갈래였고 330 이 이 둘을 대부분 풂(책 갈래 29 → 11 · 같은 뜻 25 → 11 · 줄임말 9 → 0). + +--- + +## 3. 방법 표 + +### 3-1. 방법마다 (기대 효과 기준 = 지금 1순위 71.4 / 81.7 · 다섯 안 86.5 / 94.4 · 세 파일 고유 126 줄) + +판정 = 먼저 / 다음 / 보류 / 안 맞음 · 묶음 번호(①~④)는 4 절. + +**가. 근거 쪽(서버)** + +| # | 방법 | 무엇이 좋아지나 | 기대 효과 | 들 일 | 위험 | 판정 | +|---|---|---|---|---|---|---| +| 1 | 조항 윗마디 | 엑셀이 윗 조항(「6-2」)만 적어도 우리 「6-2-2 · 6-2-3」을 같은 조항으로 봄 — 철근 가공조립 | 75.4 / 85.7 (+4.0 / +4.0) | 작음 | 낮음 | 먼저 ① | +| 2 | 같은 이름 접힘 고침 | 산림 · 건설에 이름이 똑같은 로직 둘이 후보에서 하나로 접혀 사라지던 것 → 둘 다 남음 — 유로폼 · 레미콘 | 73.8 / 84.1 (+2.4 / +2.4) · 영월 +8.9 | 작음 | 같은 이름 둘이 나란히 → 책 표시 필요(27) | 먼저 ① | +| 2-1 | 괄호 다른 「같은 이름」 덤 빼기 | 괄호를 지운 이름만 같고 괄호 글이 다르면(「철근가공조립(현장)」 ↔ 「철근가공조립(복잡)」) 이름 같음 덤(+1)을 안 줌 | 묶음 ① 위 86.5 / 88.9 (+0.8 / +0.8) · 영월 철근 1 줄 풂 · 해 0 | 작음 | 낮음 | 먼저 ① 덤 | +| 3 | 조항 오타 바로잡기 | 「13-114」 → 「13-11-4」 꼴 — 돌망태형 옹벽 | 1~4 묶음 안에서만 셈 | 작음 | 낮음 | 먼저 ① | +| 4 | 「같음」도 근거 검사(판 지킴) | 이름이 같거나 씨앗으로 「같음」이 된 줄도 엑셀 출처 조항이 다른 책이면 「후보」로 내려 사람이 고름 · 엑셀 글이 옛 판(「24년」 · 「임도품셈」 · 「사방품셈」)이면 그대로 | 78.6 / 81.7 (+7.2 / 0) · 다섯 안 93.7 · 같음 다른 책 10 → 0 | 중간 | 같음 11 줄이 후보로 → 확정 한 번 더 | 먼저 ① | +| 1~4 | 묶음 | | **85.7 / 88.1 (+14.3 / +6.4) · 다섯 안 94.4 / 94.4** · 씨앗의 해 사라짐 | 작음~중간 | 낮음 | 먼저 ① | +| 5 | 장비 시간 줄 길 | [hr] · [시간] 장비 줄 → 「기계 시간당 기계경비」 + 기종 입력 — 펌프차 · 크레인 | 2 줄 풂 어림(+1.6) · 흉내 안 함 | 작음 | 낮음 | 먼저 ① 덤 | +| 6 | 노임 줄 표시 | [인] 노임 줄(철골공 · 특별인부)을 「노임 · 그릇 없음」으로 → 엉뚱 후보 안 보임 | 엉뚱 후보 5 → 4 어림 | 작음 | 낮음 | 먼저 ① 덤 | +| 7 | 토질 칸 어긋남 깎기(토사 ↔ 암) | 이름 괄호 「토사」와 후보 「암」이 어긋나면 뒤로 — 구조물터파기(토사) | 엄격 0 · 느슨 +0.8(한 줄 · 엑셀 18 번 되풀이) | 작음 | 낮음 | 다음 ① 덤 | +| 8 | 방법 칸 어긋남 깎기(인력 ↔ 기계) | C2 제안 | −2.4 / −1.6 — 「굴삭기 + 인력 10%」 섞인 채집 줄 3 뒤집힘 | 작음 | 높음 | 안 맞음 | +| 9 | 부문 가름(공통 · 토목 글) | 같은 번호 다른 부문 가름 | 변화 0 | 작음 | — | 보류 | +| 10 | 블록 글 낱말 덤 | 우리 이름 낱말이 엑셀 산출 글에 나오면 덤 | 69.8 / 84.1 (−1.6 / +2.4) | 중간 | 단위중량 · 자재 이름 섞임 | 보류 | +| 11 | 직종 · 장비 구성 점수 | 직종 · 장비 묶음 겹침을 점수로 | 71.4 / 82.5 (0 / +0.8) | 중간 | 직종은 이미 근거 점수에 듦 | 보류(나란히 보기 28 로만) | +| 12 | 책 맞춤 덤 | 엑셀 출처 책 = 우리 책이면 덤 | 73.8 / 82.5 · 영월 +11.8 · 임도 −2.0 | 작음 | 섞임 · 4 가 더 곱게 함 | 보류 | +| 13 | 옛 조항 번호 바꿈표 | 옛 판 번호를 지금 번호로 | 잼 못 함 · 재료 = 산림 14 건 · 건설 한 해 치뿐 | 중간 | 번호만 같은 다른 절에 붙음 | 보류 | + +**나. 이름 쪽** + +| # | 방법 | 무엇이 좋아지나 | 기대 효과 | 들 일 | 위험 | 판정 | +|---|---|---|---|---|---|---| +| 14 | 손 사전(같은 뜻 말 16 쌍) 순위 덤 | 레미콘 = 레디믹스트콘크리트 · 터파기 = 굴착 등 | 지금 위 64.3 / 81.0 (−7.1 / −0.7) · 묶음 ① 위 −0.8 / −1.6 · 다른 꼴(영월) 330 앞 위 −8.8 / −8.9 | 작음(사전) · 키우기 큼 | 회사마다 책이 달라 틀린 쪽을 1위로 | 안 맞음 | +| 15 | 표기 고르기 — 후보 넓히기에만 | 깎/깍 · 뒷/뒤채움 · ㎥/M3 · 브레이카/브레이커 — 근거 없는 줄에서 글자 꼴이 달라 후보에도 못 드는 것 | 잼 못 함 · 근거 있는 줄은 이미 근거가 덮음(흙깍기 맞음) | 작음 | 낮음(덤 안 줄 때) | 보류 | +| 16 | 이름 다듬기(괄호 낱말 살림 · 수 · 단위 뺌) | 괄호 속 「인력/토사」 살림 | 지금 위 70.6 / 81.7 (−0.8 / 0) · 330 앞 위 −3.9 / −7.1 | 작음 | 괄호 속 규격이 엉뚱 후보를 끌어옴 | 안 맞음 | +| 17 | 낱말 차례 무시 | 「큰돌찰붙임 ↔ 큰돌붙이기 찰붙이기」 | 330 앞 위 1순위 +3.2 · 다섯 안 느슨 −5.6 | 작음 | 엉뚱 후보 | 안 맞음 | +| 18 | 글자 비교 바꾸기 | 1·2·3 글자 묶음 · difflib · Jaro-Winkler · TF-IDF | 글자 묶음 변화 0 · difflib 다섯 안 엄격 +15.9 이나 우리에 없음 줄 엉뚱 후보 4 → 10 · Jaro-Winkler · TF-IDF 잼 못 함 | 작음 | 엉뚱 후보 늘어남 | 안 맞음 | +| 19 | 단위 거름망(단위 다르면 뺌) | | 63.5 / 73.8 (−7.9 / −7.9) | 작음 | 맞는 짝 단위가 다름(개소 ↔ M · 조 ↔ ㎡) | 안 맞음 | +| 20 | 단위 별칭(hr = 시간 · 공/㎥ = 공㎥ · 개소 = 개 = 본) | 환산 칸 손 덜기 | 순위 변화 0 · 환산 「입력」 20 → 14 줄 | 작음 | 낮음 | 다음 ① 덤 | +| 21 | 형태소 분석(kiwipiepy) | 붙여 쓴 이름 쪼개기 | 잼 못 함(설치 안 함) | 중간 · 새 꾸러미 | 짧은 전문 복합어 잘못 쪼갬 | 보류 | + +**다. 기억 · 사전 (사용자 확정 · 마스터 밖)** + +| # | 방법 | 무엇이 좋아지나 | 기대 효과 | 들 일 | 위험 | 판정 | +|---|---|---|---|---|---|---| +| 22 | 기억 둘째 열쇠(품명 · 단위) → 「기억 후보」 | 규격만 다른 같은 품명이 다음 파일에서 바로 1순위 | 같은 발주처: 고유 4/48 → 15/48 · 엑셀 줄 5% → 33%(모두 맞음) · 다른 발주처: 북부청 → 영월 13 줄 중 엄격 8 · 느슨 12 / 영월 → 북부청 17 줄 중 엄격 12 · 느슨 17 | 중간 | 다른 회사 기억 = 다른 책 → 후보로만 · 근거 검사 | 다음 ③ | +| 23 | 기억 셋째 열쇠(정리 이름 · 단위) | 더 넓게 | 24/48 중 21 맞음 · 3 틀림(괄호 「토사」 · 「인력100%」를 지워 섞임) | 작음 | 틀린 짝이 1순위 | 안 맞음 | +| 24 | 확정한 엑셀 이름 → 층 기억 별칭 | 현장말(고임돌 · 소운반 · 철골설치)을 한 번 확정하면 다음 파일 이름 후보 · 찾기 창에 걸림 | 잼 못 함 · 같은 꼴인 씨앗 = 같은 출처 파일(영월) +20.6 · 다른 발주처엔 4 와 함께면 0 | 중간 | 회사마다 다름 → 회사 층에 | 다음 ③ | +| 25 | M01 로직 별칭을 맞춤이 읽기만 | 사용자가 M01 화면에서 채운 별칭을 이름 후보로 | 지금 별칭 1,438 중 0 → 효과 0(채운 만큼) | 작음 | 낮음 | 보류 | +| 26 | 손 사전을 마스터에(별칭 칸 자동 채움) | C2 제안 | 14 와 같음 · 원본 로직을 코드로 고치게 됨 | — | 높음 | 안 맞음 | + +**라. 고르기 창 (사람이 빨리 · 바르게)** + +| # | 방법 | 무엇이 좋아지나 | 기대 효과 | 들 일 | 위험 | 판정 | +|---|---|---|---|---|---|---| +| 27 | 후보마다 책 · 조항 · 직종 늘 보이기 | 같은 이름 산림 · 건설 구별 · 마우스 안 올려도 까닭 보임 | 순위 그대로 · 다섯 안 94% 를 빨리 고름 | 중간 | 낮음 | 먼저 ② | +| 28 | 엑셀 산출 글 ↔ 우리 호표 나란히 | 직종 · 품 · 장비를 눈으로 맞춤 | 잼 못 함 | 중간 | 낮음 | 먼저 ② | +| 29 | 금액 비 초록 표시(우리 값 ÷ 엑셀 단가) | 맞는 짝을 한눈에 | ±5% 안 21 줄 중 20 맞음 · 덮는 줄 16% · ±15% 밖 69 줄 중 51 도 맞음 → 빨강 표시 없음 | 중간(서버가 후보 값 셈) | 낮음(초록만) | 다음 ② | +| 30 | 금액 순위 점수 | | 66.7 / 77.8 (−4.7 / −3.9) | 중간 | 복합 줄 · 입력 기본값 | 안 맞음 | +| 31 | [같음 모두 확정]에서 근거 어긋남 빼기 · 같음 줄에도 근거 후보 | 4 의 화면 쪽 | 25 중 다른 책 5 가 빠짐 | 작음 | 낮음 | 먼저 ② | +| 32 | [다른 로직 찾기]에 엑셀 조항 · 이름 미리 넣기 | 다섯 안 밖 줄 찾기 손 덜기 | 잼 못 함 | 작음 | 낮음 | 먼저 ② | +| 33 | 낮은 점수 후보 숨김(문턱) | 엉뚱 후보 숨김 | 문턱 0.6 → 없음 줄 3 숨김 · 맞는 1순위 3 잃음 | 작음 | 맞는 것도 숨음 | 안 맞음 | + +**마. 무거운 길 · 코드** + +| # | 방법 | 무엇이 좋아지나 | 기대 효과 | 들 일 | 위험 | 판정 | +|---|---|---|---|---|---|---| +| 34 | 문장 임베딩 재순위 | 뜻 거리 | 잼 못 함 · 수백 MB 모델 · 산림/건설 같은 이름은 벡터도 같아 못 가름 | 큼 | 현장말 모를 수 있음 | 보류 | +| 35 | 큰 언어 모델(클라우드) 재순위 | 남은 현장말 · 복합 줄 | 잼 못 함 · 사내 오프라인 서버에서 못 씀 | 중간 | 발주처 자료 밖으로 · 답 흔들림 | 안 맞음(지금 조건) | +| 36 | 로컬 큰 언어 모델 | 같음 | 한국어 모델(EXAONE) 비상업 라이선스 · 설치 큼 | 큼 | 라이선스 | 안 맞음 | +| 37 | 국가 표준 코드로 잇기(조달청 세부공종 · 표준시장단가) | | 실무 엑셀 코드 칸 0 · STmate 자료 일위대가 국가 코드 0/216 | — | — | 안 맞음 | +| 38 | 조달청 세부공종코드 CSV 를 사전 재료로 | 옛/새 이름 묶음 | 잼 못 함 · 임도 · 사방 고유 공종 0 · 사전 자체가 근거 위에서 이득 없음 | 작음 | — | 보류 | + +**바. 읽기 (다른 꼴)** + +| # | 방법 | 무엇이 좋아지나 | 기대 효과 | 들 일 | 위험 | 판정 | +|---|---|---|---|---|---|---| +| 39 | STmate 꼴 읽기 | 막히던 STmate 내역 6 개가 올라감 | 영월 흉내(처음 보는 셈) 지금 44.1 / 55.9 → 묶음 ① 위 55.9 / 67.6 · 다섯 안 82.4 · 노무 목록표로 직종을 이으면 더 오를 여지(잼 못 함) | 중간 | 판마다 시트 꼴 조금씩 다름 | 먼저 ④(별도 축) | +| 40 | 거창 꼴(줄 = 노임 · 자재 직접) | 호표 머리 이름을 로직 하나에 맞춤 | 잼 못 함 · 지금도 올라감(호표 14 · 검산 맞음) | 중간~큼 | — | 보류 | +| 41 | 문서 보안(DRM) 파일 안내 | 「엑셀을 읽지 못함」 대신 「문서 보안 걸린 파일 — 풀린 사본을 올림」 | — | 작음 | 낮음 | 다음 ④ | + +### 3-2. C2(바깥 조사)와 C1 · C3(우리 실측)이 어긋나는 곳 — 실측 우선 + +| 주제 | C2 말 | 우리 실측 | 정리 — 어떻게 쓰면 되나 | +|---|---|---|---| +| 같은 뜻 말 사전 | 「바로 쓸 만함(핵심)」 · 재료 = 품셈 다리 문구 · 조달청 옛/새 이름 · 실무 짝 20 · 씨앗 · M01 별칭 채움 | 임도 파일로 만든 16 쌍 — 330 앞 위 사방 +7.0 · 영월 −8.9(느슨) · 지금 위 −7.1(엄격) · 묶음 ① 위 −0.8. 해의 대부분 = 「레미콘 = 레디믹스트콘크리트」 한 짝(9 줄) — 이름이 같아진 산림 · 건설 둘 중 산림이 1위로(같은 이름 접힘과 겹침). 판단이 든 짝(터파기 → 굴착 등 10 쌍)은 지금 위 이득 0 | 순위에 덤 주는 손 사전은 두지 않음. 「어느 로직을 쓰나」는 회사마다 다름(북부청 구조물터파기(인력) = 건설 3-2-1 · 영월 · 씨앗 = 산림 9-13-1) → 사용자 확정이 회사 층 기억에 쌓이는 맞춤 사전(③). 표기 고르기는 근거 없는 줄의 후보 넓히기에만(15) | +| M01 로직 별칭 칸 | 「가장 값싼 길 — 그릇 있음 · 채움」 | 별칭 1,438 중 0 · 원본 로직은 코드로 안 고침 | 3-3 두 갈래 — 추천은 M02 맞춤 사전(마스터 밖) | +| 괄호 글을 칸으로(토질 · 방법) | 「바로 쓸 만함」 · 「인력/토사」를 잃음 | 괄호 낱말 살림 = 지금 위 −0.8 · 330 앞 위 −3.9 / −7.1. 토질 칸만 = 0 / +0.8 · 방법 칸까지 = −2.4(9:1 섞인 채집 줄 3 뒤집힘). C2 예시 「구조물터파기(인력100%) ↔ 굴착(인력/토사)」는 330 근거로 이미 풀림(맞음 2 줄) | 토질 칸만 덤(7) · 방법 칸은 안 씀(8) | +| 구성 비교(직종 · 장비 · 보통인부뿐) | 「바로 쓸 만함」 | 0 / +0.8 · 직종은 이미 근거 점수에 듦 | 점수는 안 둠 · 고르기 창 나란히 보기(28)로 | +| 글자 비교(TF-IDF · Jaro-Winkler) | 「바로 쓸 만함(작은 이득)」 · 「조건부(보조)」 | 글자 묶음 바꾸기 0 · difflib 은 엉뚱 후보 4 → 10 | 하지 않음 | +| 품셈 조항 번호 | 「조건부 — 판 짐작 · 부문 · 옛 번호 바꿈표가 붙어야」 | 근거 쪽 작은 고침이 가장 큰 이득(+14.3) · 부문 가름 0 · 옛 판은 「같음 안 내림」(판 지킴)으로 충분 | 근거 작은 고침 넷을 먼저(①) · 바꿈표 보류 | +| 금액 대조 | 「조건부 — 동점 가르기 · 경고」 | 순위 점수 −4.7 · ±5% 안 21 줄 중 20 맞음 · 멀어도 69 줄 중 51 맞음 | 「가까우면 초록」만 · 경고(빨강)는 안 둠 | +| 열쇠 단계 내리기 | 「바로 쓸 만함」(STmate 5 단) | 품명 · 단위 = 같은 발주처 15/15 · 18/18 맞음 · 다른 발주처 엄격 62~71% · 정리 이름 · 단위 = 3 틀림 | 품명 · 단위까지만 · 「기억 후보」로(22) | +| 큰 언어 모델 | 「안 맞음(지금 조건)」 | C1 「기대 큼 · 근거 실패 줄(약 15%)에만」 | 오프라인 · 유출 금지 기준 보류 — 외부 전송 허용은 사용자가 정함 | +| 씨앗 | 사전 재료(메모 170) | 영월 +23.5 이나 북부청 −4.1 · −2.3 · 영월은 씨앗 출처 파일 | 마스터 층 기억의 한 꼴로 그대로 · 4 와 함께면 해 없음 | +| C1 지표의 「이름만」 | — | C1 은 씨앗 끈 값(34.1)을 330 앞으로 둠 → 영월 330 효과 +23.5 로 보임 | 이 검토서는 씨앗 켬(41.3)을 330 앞으로 · 영월 330 효과 = 1순위 0 · 다섯 안 +11.7 | + +### 3-3. 「별칭」 두 갈래 — 꼭 가를 것 + +| | (가) M01 로직 별칭 칸 | (나) M02 맞춤 사전(= 층 기억 넓히기) | +|---|---|---| +| 자리 | 마스터 정본 로직 줄 안의 칸 | M02 층 기억 파일(층마다 · 마스터 로직 밖) | +| 누가 채우나 | 사용자가 M01 화면 로직 목록 「별칭」 칸에 쳐서 [저장] — 코드가 자동으로 채우지 않음 | 사용자가 M02 고르기 창에서 [확정]할 때 쌓임 | +| 지금 | 1,438 중 0 · M02 맞춤은 안 읽음 | 있음 — 품명 · 규격 · 단위가 똑같을 때만 씀 | +| 회사별 | 정본 하나(모든 회사 같음) | 개인 → 회사 → 마스터 차례로 봄 — 회사마다 갈림 | +| 원본 지킴 | 사용자 화면 작업이라 원칙에 맞음 · 코드 자동 채움은 금지 | 원본 로직 · 일위대가조합.json 안 건드림 | +| 판정 | 보류(사용자가 채우기 시작하면 「읽기만」 붙임 · 25) | 추천(③ · 22 · 24) | + +--- + +## 4. 묶음 추천 · 차례 + +| 묶음 | 1순위 엄격 / 느슨 | 다섯 안 엄격 | 들 일 | 차례 | +|---|---|---|---|---| +| 지금 | 71.4 / 81.7 | 86.5 | — | — | +| ① 근거 쪽 서버 고침 | 85.7 / 88.1 (덤 2-1 · 5 더해 어림 88.1 / 90.5) | 94.4 (어림 96.0) | 작음~중간 | 1 | +| ② 고르기 창 손질 | 순위 그대로 | 그대로 | 중간 | 2(①과 같은 때가 좋음) | +| ③ 기억 넓히기(회사별 맞춤 사전) | 같은 발주처 다음 파일 바로 1순위 엑셀 줄 5% → 33% | — | 중간 | 3 | +| ④ STmate 꼴 읽기 | 영월(처음 보는 셈) ① 위 55.9 / 67.6 | 82.4 | 중간 | ①과 나란히 또는 4 | + +### ① 근거 쪽 서버 고침 — 방법 1 · 2 · 3 · 4 (+ 덤 2-1 · 5 · 6 · 7 · 20) + +- 화면에서 달라지는 것: 호표 줄 표의 1순위가 엑셀 단가산출 출처 조항과 같은 우리 로직으로 · 「같음」이던 11 줄(엑셀과 다른 책 10 포함)이 「후보」로 바뀌어 엑셀 책 로직 1순위 · 같은 이름 다른 책 2순위(확정 한 번 더) · 철근 가공조립이 「철근 현장가공 · 현장조립」 쪽으로 · 펌프차 · 크레인 시간 줄이 「기계 시간당 기계경비」로 · 철골공 · 특별인부 줄은 「노임 · 그릇 없음」. +- 수치: 1순위 71.4 / 81.7 → 85.7 / 88.1(덤 2-1 · 5 더해 어림 88.1 / 90.5) · 다섯 안 86.5 → 94.4(어림 96.0) · 파일별 1순위 엄격 임도 71.4 → 91.8 · 사방 74.4 → 86.0 · 영월 67.6 → 76.5 · 엑셀 줄 무게 1순위 느슨 78.1 → 85.6 · 「같음」 다른 책 10 → 0 · 씨앗을 꺼도 북부청 값 그대로(씨앗의 해 사라짐). +- 들 일: 작음~중간 — 서버 맞춤 두 파일의 몇 함수 · 시험 기대값 고침 · 화면은 안 바뀜. 다만 2 로 같은 이름 둘이 다섯 안에 함께 보이므로 ②의 책 표시와 같이 내는 것이 좋음. +- 차례: 1 — 가장 싸고 가장 큼 · STmate 를 읽게 되면 그 파일에도 같은 함수로 효과. + +### ② 고르기 창 손질 — 방법 27 · 28 · 31 · 32 (+ 29) + +- 화면에서 달라지는 것: 후보 단추에 「건설 공통 6-3-1 · 형틀목공 · 단위 맞음」 같은 줄이 늘 보임(지금은 「이름 [단위] · 키」 뿐 · 까닭은 마우스 올림에만) · 엑셀 산출 글(직종 · 품 · 장비)과 후보 우리 호표를 위아래로 · 금액 비 ±5% 안이면 초록 · [같음 모두 확정]이 근거 어긋남 줄을 빼고 셈 · [다른 로직 찾기…]가 엑셀 조항 · 이름을 미리 넣고 엶(지금은 빈 찾기 창). +- 수치: 순위는 그대로 — 남은 일은 다섯 안 94% 정답을 빨리 고르기 · [근거 맞음 모두 확정]은 지금도 55 줄 모두 맞음 · 금액 초록 = 덮는 줄 16% · 맞음 95%. +- 들 일: 중간 — 고르기 창이 든 화면 파일이 614 줄(한도 700)이라 고르기 창을 새 파일로 나눔 · 서버가 후보마다 책 · 조항 · 직종 · 금액 비를 보냄(금액은 우리 로직을 엑셀 단위로 셈). +- 차례: 2. + +### ③ 기억 넓히기 = 회사별 맞춤 사전 — 방법 22 · 24 + +- 화면에서 달라지는 것: 다음 파일을 올리면 전에 확정한 품명과 같은 줄이 규격이 달라도 「기억 후보」 꼬리표로 1순위(자동 확정 아님 · 사람이 [확정]) · 확정한 엑셀 이름이 [다른 로직 찾기] 찾기 칸에서도 걸림 · 다른 회사 기억이 엑셀 근거와 어긋나면 꼬리표에 「근거 다름」. +- 수치: 같은 발주처 다음 파일 바로 1순위 — 임도 → 사방 고유 4/48 → 15/48 · 엑셀 줄 5% → 33% · 사방 → 임도 엑셀 줄 10% → 44% · 모두 맞음. 다른 발주처(북부청 ↔ 영월) 기억은 엄격 62~71% → 후보로만. 묶음 ① 뒤 남은 현장말 11 줄(고임돌 · 조약돌 6 · 소운반 3 · 철골설치 · 측구수로관)은 한 번 확정하면 같은 품명 다음 파일부터 바로. +- 저장 자리: M02 층 기억 파일(개인 → 회사 → 마스터 차례 이미 있음) · 원본 로직 · 일위대가조합.json · M01 별칭 안 건드림. +- 들 일: 중간 — 기억 꼴에 둘째 열쇠 · 엑셀 이름 덧칸 · 「기억 후보」 꼬리표 · 시험. +- 차례: 3 — 쓸수록 좋아지는 길 · ①의 근거 검사와 함께여야 다른 회사 기억이 틀린 책을 끌어오지 않음. + +### ④ STmate 꼴 읽기 — 방법 39 · 41 + +- 화면에서 달라지는 것: 지금 「일위대가 시트 없음」으로 막히는 STmate 내역 xlsx 가 올라가 같은 호표 표 · 고르기 창으로 보임 · 문서 보안 잠긴 파일은 까닭 글. +- 수치: 지식DB 실무 내역 xlsx 9 개 중 올라가는 것 1 → 7 · 영월 흉내(처음 보는 셈) 묶음 ① 위 1순위 55.9 · 다섯 안 82.4 · 같은 회사 기억이 쌓이면 영월 씨앗처럼 76.5 근처(이미 본 셈). +- 들 일: 중간 — 서버 새 읽기 파일 하나(일위대가표 · 단가산출근거 · 노무 목록표 · 줄 코드 D · L · M · X · B 이음) · 6 파일 시험 · 엑셀 보기 창은 값만(서식 없음). +- 차례: 「다른 문서」를 곧 쓸 거면 ①과 나란히(서로 다른 파일이라 안 겹침) · 아니면 4. + +--- + +## 5. 걸림돌 — STmate 꼴 · 문서 보안 + +지식DB 실무 문서의 엑셀 꼴(시트 이름 · 파일 머리로 가름): + +| 꼴 | 파일 | 지금 | +|---|---|---| +| 북부청 꼴(일위대가 + 단가산출 · 「단산N참조」) | 북부청 임도 · 사방 xls 2 | 올라감 · 근거 맞춤 됨 | +| STmate 꼴(일위대가표 · 단가산출근거 · 줄 코드) | 봉화 현동 · 동부청 영월 · 울진 간선 기번3(2) · 영덕 계류보전 · 울진 소광 — 6(사업 5) | 400 「일위대가 시트 없음」 | +| 거창 꼴(일위대가 시트 · 줄 = 노임 · 자재 직접) | 거창 1 | 올라감(호표 14 · 줄 68 · 검산 맞음) · 줄이 「레미콘 · 콘크리트공 · 보통인부 · 공구손료 %」 꼴이라 줄 맞춤으로는 로직을 못 찾음 | +| 문서 보안(DRM) 잠김 | 장수 · 진안 「내역+수량」 2 | 400 「엑셀 파일을 읽지 못함」 — 파일 머리가 「DRMONE」 · 어떤 길로도 못 읽음 | +| 일위대가 시트 없음(단가산출만) | 2016 사방품셈(안) · 수목진료 xls 2 | 400 | + +- C1 은 「6 개 중 5 개」로 셈(사업 단위) — 파일로는 STmate 6 · 장수 · 진안 2 개(보안 잠김)는 C1 셈에 빠져 있었음. +- 판단: 「다른 문서에서도 이름이 다름」을 풀려면 STmate 읽기가 이름 맞춤보다 앞 — 지금은 맞춤까지 가지도 못함. 다만 ①은 읽기와 상관없이 북부청 꼴에서 바로 효과이고 STmate 에도 같은 맞춤 함수가 쓰이므로, ①을 먼저(작음) 하거나 ④와 나란히 함. +- STmate 가 오히려 쉬운 점: 줄마다 코드(D 단가산출 · L 노임 · M 자재 · X 기계 · B 다른 호표 · S 별도계상)가 있어 줄 ↔ 단가산출 블록 · 노임 · 장비를 이름 없이 바로 이음 · 같은 D 코드 = 같은 줄(한 번 확정이면 모두) · 노임 · 장비 줄을 코드로 바로 가름(L → 「노임」 표시 · X → 장비 시간 길). +- 어려운 점: 단가산출근거 블록 안 노임 줄은 코드(「L00002L」)와 식만 있고 직종 이름이 없음 → 노무 목록표 시트로 이어야 근거 직종이 채워짐(C1 흉내는 이를 안 해 영월 근거 직종이 거의 빔 — 영월 330 효과가 작은 까닭) · 2024 문서들은 옛 판 번호를 인용(「건설표준품셈 3-1-3」 · 「임도품셈 5-5」) → 근거 힘이 약함 · 판마다 덧시트가 조금씩 다름(일위대가 단가산출 금액 집계표 · 일식경비 목록표 등). +- 들 일: 중간. STmate 자료 파일(STC)을 직접 읽는 길은 발주처가 STC 를 줄 때만 — 보류. +- 문서 보안 파일: 사용자가 보안을 푼 사본으로 저장해 올려야 함 · 화면 글만 고침(작음). + +--- + +## 6. 사용자가 정할 것 + +1. **같은 일 다른 책(산림 ↔ 건설)** — 엑셀 출처 책(남의 문서 그대로)을 1순위로 할지 · 산림 품셈 총칙 1-1 제7호(「타 부문과 유사한 공종의 품은 본 품셈을 우선하여 적용」)대로 산림을 1순위로 할지. 예: 북부청은 합판거푸집을 건설 6-3-1 로 셈(산림 12-4 있음). 갈리는 것 = 방법 4 의 판정 · 정답 기준 · 금액. 기본안 = 엑셀 출처 책 1순위 · 같은 이름 다른 책은 둘째 후보로 늘 보임. +2. **「같음」 자동 들어감** — 지금 환산이 정해진 「같음」은 확정 없이 사전조합에 들어감(① 뒤 22 줄 · 다른 책 0). 그대로 둘지 · [같음 모두 확정] 한 번을 거치게 할지. +3. **금액 비 표시 기준** — ±5%(맞음 95% · 덮는 줄 16%) / ±15%(맞음 89% · 덮는 줄 29%). 「멀다」 빨강 표시는 두지 않음(멀어도 74% 맞음). +4. **회사 공유 사전** — 확정이 쌓이는 층: 개인만 / 회사 층(지금은 회사 관리자만 회사 층에 씀) / 개인 확정을 관리자가 회사로 올리는 단추. 마스터 층 기억과 씨앗(일위대가조합.json 메모)을 「모든 회사 공통」으로 계속 둘지. +5. **둘째 열쇠 기억** — 「바로 들어감」 / 「1순위 후보 + 꼬리표」. 기본안 = 후보로만(같은 발주처 100% · 다른 발주처 62~71%). +6. **잔토처리 · 잔토정리** — 「우리에 없음」 10 줄 중 6 줄. M01 에 단독 로직을 둘지(사용자 마스터 작업) · 「굴착(인력/토사)」의 잔토처리 입력으로 맞출지. +7. **노임 줄([인])** — 사전조합에 노임 줄 그릇이 없음. 「노임 · 그릇 없음」 표시만(작음) / 노임 줄 꼴 새로(큼 · 일위대가 문서 꼴이 바뀜). +8. **섞인 줄(기계 90% + 인력 10% 채집 · 터파기)** — 우리 인력 로직 하나(인력 몫)를 정답으로 볼지 · 기계 몫 로직까지 둘로 쪼개 넣을지(정답 기준 · 사전조합 꼴에 걸림). +9. **차례** — STmate 읽기를 ①과 나란히(다른 문서 곧 씀) / ① → ② → ③ 뒤로. +10. **외부 AI** — 발주처 자료를 클라우드 언어 모델로 보내도 되는지(지금 판단 = 안 됨 · 보류). +11. **문서 보안 파일** — 풀린 사본으로 올리라는 안내 글로 충분한지. + +--- + +## 7. 부록 + +### A. 정답 표시 기준 + +- 자료 = 북부청 임도(xls · 호표 68 · 줄 415 · 로직 줄 고유 52) · 북부청 사방(xls · 호표 27 · 줄 201 · 고유 48) · 동부청 영월(STmate xlsx · 고유 36 · 지금 화면은 400 → 스크래치패드에서 읽어 같은 맞춤 함수에 넣음). +- 대상 = 단가산출 · 기계 · 기타 갈래 줄(로직 줄). 자재 · 별도계상 · % 줄은 정답 표시 밖. +- 정답 = 엑셀 단가산출 블록의 출처 조항 · 직종 · 품을 우리 로직 호표와 대조해 붙임(C1 부록 D · 136 줄 전수). 엄격 = 같은 조항 · 품 · 느슨 = 같은 일 다른 책 · 복합의 곁 몫. 짐작 31 줄(출처 없음 · 일부만 덮음)은 사람 판단 섞임. 영월 씨앗 줄은 씨앗 짝을 정답으로 봄(영월 씨앗 효과가 큰 까닭 하나). +- 「우리에 없음」은 분모에서 빼고 따로 셈. 기억 없음 · 씨앗 켬이 기본. +- 한계: 표본 3 파일 · 그중 둘이 같은 발주처 · 영월은 읽기 흉내(직종 이음 빠짐) · 금액은 엑셀 노임(2026)과 우리 노임 차이가 몇 % 섞임. + +### B. 실험 경로 (스크래치패드 `req225/` · 저장소 안 고침) + +- C1 — `C1/` 스크립트 s01~s18 · 결과 `out_*.json`(C1 부록 E). 이번에 다시 돌려 수치 확인: `s06_memory.py`(기억 재사용) · `s11_buttons.py`(단추 정밀) · `s13_money_flag.py`(금액 표시). +- C3 덧잼 — `C3/s20_c3_extra.py`(C1 함수를 불러 같은 프로세스 안에서 바꿔 끼움): + - `a` 사전이 바꾼 줄 · 표기 사전(6 쌍) / 판단 사전(10 쌍) 나눠 얹기 + - `b` 묶음 ① 씨앗 끔 · 묶음 ① + 사전 · 「같음」 줄 셈 + - `c` 묶음 ① 뒤 남은 실패(C1 갈래 표시) + - `d` 칸 어긋남 깎기 · `d soil` = 토질 칸만 + - `e` 다른 발주처 기억 재사용(열쇠 꼴별) + - `f` 괄호 다른 「같은 이름」 덤 빼기(씨앗 켬 · 끔) +- 실행 = 저장소 `venv/Scripts/python.exe -B s20_c3_extra.py `(C3 폴더에서 · 각 20 초 안). +- 시트 꼴 확인 = 읽기 전용 한 번(시트 이름 · 파일 머리 바이트 · 거창은 지금 읽기 함수로 읽어 봄). + +### C. 바깥 출처 대표 (C2 에서) + +- 다산 EST 공지 — 일위대가 · 단가산출은 「'코드일괄변경'이 적용되지 않으므로」 검색 뒤 [코드변경](사람이 고름) — https://www.dasansoft.com/customer/?mname=customer01&file=dd_view&table=notice&mode=view&id=4335&grp=4321&sub=3&pageN=1 +- 한국조달연구원(2020) 공사코드 표준화 연구 — 「표준 공사코드와 내역항목의 일치 및 수정에 대한 방법론은 구축되어 있지 않은 상태」 — https://www.kip.re.kr/src/uploads/20221121/%E2%98%85(%ED%99%95%EC%A0%95)+%EA%B3%B5%EC%82%AC%EC%BD%94%EB%93%9C+%ED%91%9C%EC%A4%80%ED%99%94%EB%A5%BC+%ED%86%B5%ED%95%9C+%EC%9C%A0%ED%98%95%EB%B3%84+%EA%B3%B5%EC%82%AC%EB%B9%84+%EC%B5%9C%EC%A2%85%EB%B3%B4%EA%B3%A0%EC%84%9C+1216.pdf +- 정상원 · 정기창(2020) 공종명 인식 — 글자 유사도 1순위 정답률 33.7~49.7% → 「후보 몇 개 + 전문가 고름」 권함 — https://www.koreascience.or.kr/article/JAKO202034352378495.pdf +- STmate 기준자료 갱신 매칭 = 정확 일치 열쇠 5 단 · 퍼지 없음 — 저장소 `resources/knowledge/original/경쟁사 프로그램/STmate 분석/20_분석/22_UI_폼_분석.md:116-124` +- OpenRefine 맞추기(후보 + 점수 + 사람이 고름) — https://openrefine.org/docs/manual/reconciling +- 산림 품셈 총칙 1-1 제7호 — `resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준/1-01_일반사항.md` · 이름만으로 자동 연결 금지 — `resources/knowledge/technical_info/01_임도/04_수량분석정보/건설품셈_활용.md:50` + +### D. 코드 자리 (다음 코딩 창용) + +| 고칠 것 | 자리 | +|---|---| +| 조항 윗마디(1) | `M02_MasterTemplete/M02_Hopyo_Basis.py` `_clause` :166-176 | +| 조항 오타(3) | `M02_Hopyo_Basis.py` `sources` :60-71 | +| 같은 이름 접힘(2) · 괄호 다른 덤(2-1) | `M02_MasterTemplete/M02_Hopyo_Match.py` `candidates` :320-325(접힘 열쇠 = 이름 · 규격 · 단위) · 이름 같음 덤 :314 · 정리 이름 `tidy` :79-84 | +| 「같음」 근거 검사(4) · 장비 줄(5) · 노임 줄(6) | `M02_Hopyo_Match.py` `match_line` :395-403 · 갈래 = `M02_Hopyo_Excel.py` `kind_of` | +| 기억 열쇠(22 · 24) | `M02_Hopyo_Match.py` `line_key` :68-76 · `recall` :348-350 · `confirm` :495-526 · 파일 `M02_Hopyo_Store.py` `memory` · `remember`(`실무자료/맞춤.json`) | +| 고르기 창(27~32) | `M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts` `choose` :435-505(파일 614 줄) | +| 엑셀 읽기(39) | `M02_Hopyo_Excel.py` `_sheets` :74-102 | +| 시험 | `resources/tester/test_m02_hopyo_match.py` · `test_m02_hopyo_basis.py` · `test_m02_hopyo_excel.py` | From b7e72786309690620dd91820fc709b0ce2f43edb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 17:33:08 +0900 Subject: [PATCH 34/36] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20226=20?= =?UTF-8?q?=E2=80=94=20CAD=20=EC=9E=85=EB=A0=A5=20=EB=B3=B4=EA=B0=95=20?= =?UTF-8?q?=EB=B0=94=EA=B9=A5=20=EC=A1=B0=EC=82=AC=20=EB=84=93=ED=9E=98(?= =?UTF-8?q?=EC=BD=94=EB=8D=B1=EC=8A=A4=20=C2=B7=20=EC=95=88=ED=8B=B0?= =?UTF-8?q?=EA=B7=B8=EB=9E=98=EB=B9=84=ED=8B=B0=20=C2=B7=20=EB=B8=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B8)=20=C2=B7=20=EC=99=B8=EB=B6=80=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=20=EB=A1=9C=EC=A7=81=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EB=A7=9E=EC=B6=A4=20=EC=9E=AC=EC=A1=B0=EC=82=AC?= 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_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1010_2/사용자요구_1010_2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/raw/요구_1010_2/사용자요구_1010_2.md b/docs/raw/요구_1010_2/사용자요구_1010_2.md index 4af9ac3fb..d0ab3dd40 100644 --- a/docs/raw/요구_1010_2/사용자요구_1010_2.md +++ b/docs/raw/요구_1010_2/사용자요구_1010_2.md @@ -280,3 +280,5 @@ 3.오토캐드의 프로그램이 추가 보강 입력방법이 없는지 확인. 4. 외부에서 일위대가 가져오기 기능에서 이름이 달라 매칭이 안되는 문제를 개선 방법이 있는지 확인. 5. 3번과 4번은 코드 요청이 아니라 상세검토 요청임. 코드 작업은 검토 결과 보고 판단할 예정 +226. (원문 · 10-15) 3번 캐드항목에 대해 코덱스와 안티그래비티에게도 인터넷을 찾아보라고 하고 너도 추가로 더 찾아볼것. +4번 산림과 건설이 유일한 품셈정보이나 사용자들이 본인들의 편의대로 변경해서임. 우리 서버의 단가산출 로직이 정상이지만 우리서버를 바꾸더라도 같은 문제가 발생됨. 나는 우리서버측 단가산출 로직을 변경할 생각은 없음. 일위대가는 어차피 단가산출 로직의 조합이거나 일위대가일 수 밖에 없음. 그리고 중요한건 값이아니라 어떤 로직의 조합인지와 로직이 중요. 경우에 따라서는 한번 한 것들은 기억하는것도 좋음. 다시한번 정밀검토하여 정밀도를 높일 수 있는 방안 확보할 것. 3번/4번 재조사 From 42e4aa70ca78545fce5f2721bd248c995849373a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 18:38:06 +0900 Subject: [PATCH 35/36] =?UTF-8?q?chore(=EC=A1=B0=EC=82=AC):=20335=20CAD=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EB=B3=B4=EA=B0=95=20=EC=9E=AC=EC=A1=B0?= =?UTF-8?q?=EC=82=AC=20=E2=80=94=20=EC=BD=94=EB=8D=B1=EC=8A=A4=20=C2=B7=20?= =?UTF-8?q?=EC=95=88=ED=8B=B0=EA=B7=B8=EB=9E=98=EB=B9=84=ED=8B=B0=20=C2=B7?= =?UTF-8?q?=20=EB=B8=8C=EB=A0=88=EC=9D=B8=20=EB=B0=94=EA=B9=A5=20=EC=A1=B0?= =?UTF-8?q?=EC=82=AC=2053=20=EC=A4=84=20=C2=B7=20=EC=83=88=20=ED=9D=A0=20H?= =?UTF-8?q?13=20=C2=B7=20H14=20=C2=B7=20=EB=AC=B6=EC=9D=8C=20=E2=91=A7~?= =?UTF-8?q?=E2=91=A9=20=C2=B7=20=EC=A0=95=ED=95=A0=20=EA=B2=83=2015~23?= 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_01C6BNBFXFndbqYxu6sZuknK --- .../조사/2026-10-15_335_부록3_바깥조사원문.md | 519 ++++++++++++++++++ .../raw/조사/2026-10-15_335_재조사_CAD입력.md | 257 +++++++++ 2 files changed, 776 insertions(+) create mode 100644 docs/raw/조사/2026-10-15_335_부록3_바깥조사원문.md create mode 100644 docs/raw/조사/2026-10-15_335_재조사_CAD입력.md diff --git a/docs/raw/조사/2026-10-15_335_부록3_바깥조사원문.md b/docs/raw/조사/2026-10-15_335_부록3_바깥조사원문.md new file mode 100644 index 000000000..61f27adb5 --- /dev/null +++ b/docs/raw/조사/2026-10-15_335_부록3_바깥조사원문.md @@ -0,0 +1,519 @@ +# 부록3 — 바깥 조사 원문 (요구 226-3 · PLAN 335-3) + +- 본문 = `2026-10-15_335_재조사_CAD입력.md` · 아래 = 재료 여섯 원문(내용 손대지 않음 · 차례 = 코덱스 1차 → 코덱스 2차 → 안티그래비티 → 코덱스 3차 출처 대조 → B4 토목측량 → B5 웹 · 터치 · AI). +- 안티그래비티 출처는 코덱스 3차 대조 판정을 따름(본문 5-1). + + +--- + +## 원문 1 — `codex_1.md` · 코덱스 1차 — 해외 CAD(BricsCAD · ZWCAD · Onshape · Fusion · SOLIDWORKS · DraftSight · Rhino · SketchUp) + +# 코덱스 1차 (GPT-6-Luna medium · ctx_a0f7d41d525f) + +쓸모 상: BricsCAD Quad는 객체 위에 문맥 명령을 띄워 도구 선택·클릭을 줄이며, 우클릭/Tab으로 실행 또는 겹친 객체 순환을 지원함. BricsCAD Manipulator는 선택 객체를 축·평면·회전 핸들로 바로 이동/복사/회전하고 거리·각도를 입력하는 편집 방식임. Rhino SmartTrack은 임시 기준선·점으로 기존 형상 사이의 정렬/위치 추론을 제공함. Onshape 스케치 중 치수 입력은 형상을 그리면서 값을 바로 구속하고 Alt+방향키로 복수 치수 칸을 이동함. SketchUp 추론 잠금과 Fusion 우클릭 방사형 메뉴는 기존 입력과 대체로 같음. + +| 입력 방법 | 프로그램 | 입력 방식 | 임도 도면 손질 쓸모 | 출처 | +|---|---|---|---|---| +| Quad 문맥 커서 메뉴 | BricsCAD | 객체에 커서 올려 최근/객체별 명령 노출, 우클릭 실행, Tab으로 겹친 객체 순환 | 상 — 객체 종류별 편집 명령을 메뉴 탐색 없이 호출 | [공식 도움말](https://help.bricsys.com/document/_guides--BCAD_getting_started--GD_quadcursormenu/V23/EN_US?id=165079146598) | +| Manipulator 핸들 조작 | BricsCAD | 선택 후 축·평면·회전 핸들 드래그, Ctrl로 복사, 거리·각도 수치 입력 | 상 — 구조물 상세도 요소 이동·복사·회전과 수치 조정에 직접적 | [공식 도움말](https://help.bricsys.com/en-us/document/bricscad/modifying-entities/the-manipulator?version=V26) | +| 적응형 스냅 눈금자 | BricsCAD | 확대 수준에 따라 스냅 간격 자동 변화, 눈금자에서 정밀 간격 선택 | 중 — 상세도 확대/축소 때 스냅 밀도를 자동 맞춤; 기존 그리드 스냅의 자동 조절형 | [공식 도움말](https://help.bricsys.com/en-us/document/bricscad/modifying-entities/the-manipulator?version=V26) | +| Smart Mouse 제스처 | ZWCAD | 마우스 제스처에 명령/옵션을 지정하고 추가 제스처 등록 | 중 — 반복 편집 명령을 짧은 마우스 동작으로 실행; SolidWorks·DraftSight에도 같은 방식 | [제품 기술자료](https://confluence.zwcad.com/pages/viewpage.action?pageId=110934086) | +| Smart Voice 음성 메모 | ZWCAD | 점·영역·객체·발언자 위치에 음성 메모를 붙이고 아이콘 눌러 재생 | 하 — 입력 명령이 아니라 도면 검토사항/현장 설명 기록에 활용 | [제품 기술자료](https://confluence.zwcad.com/pages/viewpage.action?pageId=321161799) | +| 스케치 중 치수 구속 입력 | Onshape | 스케치 요소 작성 중 숫자/식을 입력, Alt+방향키로 여러 치수 입력칸 이동; 음수로 방향 전환 가능한 치수 있음 | 상 — 종단·횡단의 정형 단면이나 구조물 치수 스케치에 즉시 수치 지정 | [공식 도움말](https://cad.onshape.com/help/Content/Sketch/sketch_basics.htm) | +| 치수값 직접 편집/구동 전환 | Onshape | 치수 더블클릭 후 값 입력; 우클릭으로 구동/참조 치수 전환 | 중 — 제약 조건을 유지하는 수치 수정 가능; 웹 CAD 치수 편집에 참고 | [공식 도움말](https://cad.onshape.com/help/Content/Sketch/dimension.htm) | +| 스케치 S 단축 툴바 | Onshape | 활성 스케치에서 S를 눌러 사용자 지정 툴바 호출 | 중 — 선·호·치수 등 자주 쓰는 도구를 커서 근처에서 선택 | [공식 도움말](https://cad.onshape.com/help/Content/Sketch/sketch_basics.htm) | +| 치수 입력으로 사각형 생성 | Fusion | 첫 점 후 길이·폭 입력하고 다음 점 클릭; Alt+방향키는 기존 조사 동작과 같음 | 중 — 구조물 상세도 박스·단면 요소를 정확한 크기로 작성 | [공식 도움말](https://help.autodesk.com/cloudhelp/ENU/Fusion-Sketch/files/SKT-CREATE-RECTANGLES.htm) | +| 우클릭 방사형 Marking 메뉴 | Fusion | 캔버스 우클릭 후 방향으로 드래그해 부채꼴 명령 선택 | 중 — 이동·삭제·스케치 등 반복 명령을 포인터 근처에서 호출 | [공식 도움말](https://help.autodesk.com/cloudhelp/ENU/Fusion-GetStarted/files/GUID-6514ABC1-CB75-4F0B-AB0E-316FAD36BA93.htm) | +| 마우스 제스처 가이드 사용자화 | SOLIDWORKS | 우측 드래그 후 2·3·4·8·12방향 명령/매크로 가이드에서 선택 | 중 — 도면·스케치의 반복 명령 호출 단축; ZWCAD Smart Mouse와 같은 방식 | [공식 도움말](https://help.solidworks.com/2025/english/SolidWorks/sldworks/c_mouse_sestures.htm) | +| 마우스 제스처 | DraftSight | 우측 드래그 방향으로 명령 호출, 4/8방향 및 명령 재지정 | 중 — ZWCAD·SOLIDWORKS와 같은 제스처 입력 | [공식 도움말](https://help.solidworks.com/2023/english/DraftSight/additional/mg_Customizing_Mouse_Gestures.htm?id=1cec871ec38740e89f6da03d9c13cb83) | +| SmartTrack 임시 기준선·점 | Rhino | 점/객체에 커서를 두어 임시 추적점·선 생성, 교차 위치를 추론해 점 지정 | 상 — 평면·종단·횡단에서 기존 선형/구조물 기준 정렬점 지정에 유용 | [공식 도움말](https://docs.mcneel.com/rhino/8/help/en-us/user_interface/rhino_window.htm) | +| Gumball 직접 조작 및 Alt 복사 | Rhino | 객체 선택 후 이동·회전·축척 핸들 드래그; 드래그 중 Alt로 복사 | 중 — 상세도 요소의 직관적 변형; BricsCAD Manipulator와 같은 계열 | [공식 도움말](https://docs.mcneel.com/rhino/8/help/en-us/user_interface/keyboard%20modifiers.htm) | +| 기존 방식과 같음: 추론 잠금/축 잠금 및 치수 숫자 입력 | SketchUp | 이동 중 Shift 또는 방향키로 축 잠금, 숫자·좌표 입력 | 이미 조사한 항목과 같음 — 신규로 세지 않음 | [공식 도움말](https://help.sketchup.com/en/sketchup/moving-entities-around) | + +확인 범위는 사용자가 열거한 제품 중심의 공식 문서 검색이며, 각 기능이 웹 CAD에 구현 가능하거나 해당 라이선스에서 사용 가능하다는 뜻은 아님. ZWCAD Smart Voice는 음성 인식 명령이 아니라 도면에 붙이는 녹음 메모로 확인됨; Figma류 라벨 드래그 수치 조절, Shapr3D 펜/터치, GstarCAD·nanoCAD·QCAD·LibreCAD의 신규 입력 기능은 이번 자료에서 공식 근거를 확인하지 못해 제외함. + + +--- + +## 원문 2 — `codex_2.md` · 코덱스 2차 — 해외 CAD(Vectorworks · Archicad · ARES · GstarCAD · nanoCAD · QCAD · LibreCAD · FreeCAD · Shapr3D) + +# 코덱스 2차 (GPT-6-Luna medium · ctx_a9087afe50e2) + +쓸모 상: Vectorworks Snap Loupe는 Z 키로 커서 주변을 임시 확대해 복잡한 교차점에서 스냅 위치를 고르는 방식으로, 상세도 손질에 상급 후보임. Archicad Magic Wand는 기존 선·폴리라인의 연결 경계나 닫힌 영역을 따라 새 다각형을 생성하는 방식으로, 해치·영역 윤곽 작성에 상급 후보임. ARES A3는 자연어 한 문장으로 객체 선택과 이동·회전·해치 등 수정을 함께 요청하고, Kudo Professional은 음성 입력도 지원함. Archicad Tracker의 좌표값 증감 입력은 편집값 조정에, ARES QuickModify 반복 변환은 다수 복사·배치에 중간 후보임. Vectorworks Data Bar, GstarCAD·nanoCAD 동적 입력, QCAD 좌표 필터, ARES SmartSelect 등은 이미 조사한 입력·필터 개념과 같거나 가까운 항목임. + +| 입력 방법 | 판정 | 프로그램 | 어떻게 입력하나 | 임도 도면 손질 쓸모 | 출처 | +|---|---|---|---|---|---| +| Snap Loupe 커서 확대 스냅 | 신규 | Vectorworks | 스냅할 곳에 커서를 두고 Z, 필요하면 다시 Z를 눌러 임시 확대 후 목표 스냅점 클릭 | 상 — 촘촘한 상세도 교차·끝점에서 오선택 감소 | [공식 도움말](https://app-help.vectorworks.net/2027/eng/VW2027_Guide/Start/Streamline_drawing_interactions.htm) | +| Magic Wand 경계 추적 생성 | 신규 | Archicad | 새 요소 도구를 고른 뒤 Space를 누른 상태에서 기존 선/경계 또는 닫힌 영역 클릭 | 상 — 기존 선형으로 해치/폴리곤 영역이나 경계 형상 빠르게 생성 | [공식 도움말](https://help.graphisoft.com/AC/28/INT/_AC28_Help/030_Interaction/030_Interaction-53.htm) | +| A3 자연어 선택·수정 | 신규 | ARES Commander / Kudo | 자연어 한 프롬프트로 도면 요소를 선택하고 이동·회전·반전·삭제·해치·신축 등 수행; Commander 음성 미지원, Kudo Professional 음성 입력 지원 | 상 — 명령어를 외우지 않고 반복 객체 편집·해치 적용; 결과 확인 절차 필요 | [공식 제품 안내](https://www.graebert.com/cad-software/ares-commander/new-features/) | +| Smart Options Display 커서 문맥 도구 | 같음: Quad/방사형 메뉴 | Vectorworks | 설정한 키/동작으로 커서 옆에 즐겨 쓰는 도구·모드·스냅 옵션 표시 | 중 — 툴바 왕복 감소; BricsCAD Quad·Fusion Marking 메뉴와 유사 | [공식 도움말](https://app-help.vectorworks.net/2027/eng/VW2027_Guide/Start/Streamline_drawing_interactions.htm) | +| Floating Data Bar 숫자 입력 | 같음: 동적 입력 | Vectorworks | 커서 옆 Data Bar에 거리·각도 등 표시; Tab 또는 숫자 입력으로 칸 활성화·고정 | 중 — 위치 고정형 숫자 입력이며 기존 DYN의 커서 숫자칸과 동일 계열 | [공식 설정 도움말](https://app-help.vectorworks.net/2025/eng/VW2025_Guide/Basic1/Data_bar_options.htm) | +| Tracker 좌표값 증감 | 보강점 | Archicad | Tracker에서 X3+ / Y-4-처럼 입력해 현재 좌표값에 증감; /로 XY와 거리·각도 전환 | 중 — 단면·상세 편집 중 기준 위치에서 일정량 이동 시 편리; 좌표 입력 자체는 기존과 같음 | [공식 도움말](https://help.graphisoft.com/AC/26/INT/_AC26_Help/030_Interaction/030_Interaction-41.htm) | +| QuickModify 반복 변환 | 보강점 | ARES Commander | 기준점과 객체 선택 후 Copy/Move/Rotate/Scale을 한 명령 안에서 반복하고 Exit | 중 — 동일 부재·기호를 연속 배치하는 반복 편집에 유용; 조작 자체는 Manipulator와 유사 | [공식 도움말](https://onlinehelp.graebert.com/EN/ARES_Content/25/additional/hlpid_edit_quickmodify.htm) | +| SmartSelect 속성 필터 | 같음: 빠른 선택/거르개 | ARES Commander | 객체 종류·속성·연산자·값을 지정해 도면 전체/현재 선택에서 대상 집합 생성 | 중 — 레이어·유형별 일괄 수정에 적합하나 기존 Quick Select/필터와 같은 기능 | [공식 도움말](https://onlinehelp.graebert.com/EN/ARES_Content/25/html/hlpid_query_qselect.htm) | +| 입력 방식별 펜·터치 동시 사용 | 낮음 | Shapr3D | 호환 기기에서 펜·터치 제스처 사용, 키보드 핫키 또는 명령 검색과 병용 | 하 — 태블릿 외근/현장 검토에는 유용 가능; 정밀 2D 도면 입력 직접성 낮음 | [공식 도움말](https://support.shapr3d.com/hc/en-us/articles/7873906073884-Keyboard-shortcuts-gestures-and-hotkeys) | +| 치수 라벨 드래그로 숫자 변경 | 확인 못 함 | Figma 류 | 요청된 동작의 공식 도움말/제품 문서 근거를 이번 조사에서 확인 못 함 | 판정 보류 — CAD 치수에 같은 조작을 적용할 근거 미확인 | 공식 근거 미확인 | +| 동적 입력 분리 포인터/치수칸 | 같음: 동적 입력·Tab 이동 | GstarCAD 2024+ | 커서 옆 분리 필드에 좌표·거리·각도 표시, Tab으로 칸 이동·값 편집; 프롬프트 옵션 목록 표시 | 중 — 이미 조사한 동적 입력과 같고 분리형 UI와 옵션 목록이 보강점 | [공식 도움말](https://www.gstarcad.com/help/GstarCAD_2024_zh-CN/detail/GstarCAD2024NEW/Dynamicdimensioninput.html) | +| Dynamic Input 커서 프롬프트 | 같음: 동적 입력 | nanoCAD | 커서 옆에 명령 프롬프트·좌표·치수 필드를 띄우고 Tab·화살표로 항목 선택 | 중 — 기존 동적 입력과 같음 | [공식 도움말](https://nanocad.com/learning/online-help/nanocad-platform/dynamic-input/) | +| QCAD 좌표 추출 스냅 | 같음: 점 거르개(.X/.Y) | QCAD | X/Y from Points에서 한 점의 X와 다른 점의 Y를 순서대로 잠금 | 중 — 기존 점 거르개와 같음 | [공식 설명서](https://www.qcad.org/doc/qcad/latest/reference/en/qcad_reference_manual_en.html) | +| LibreCAD 특수 좌표 입력 | 확인 범위에서 신규 없음 | LibreCAD | 공식 설명서의 명령줄 절대·상대·극좌표 입력은 기존 조사 항목과 같음 | 신규 판정 없음 | [공식 설명서](https://docs.librecad.org/en/latest/ref/fundamentals.html) | +| FreeCAD Sketcher / SolveSpace 구속 치수 | 같음: Onshape 치수 구속 | FreeCAD / SolveSpace | 스케치 구속·치수 라벨 편집으로 기하를 구동하거나 참조값으로 표시 | 신규 판정 없음 — Onshape의 구동/참조 치수와 같은 계열 | [FreeCAD 도움말](https://reqrefusion.github.io/FreeCAD-Documentation-html/wiki/Sketcher_Dimension.html), [SolveSpace 설명서](https://solvespace.readthedocs.io/en/latest/constraints/) | + +progeCAD·TurboCAD·CorelCAD의 독립적인 새 입력 방식과 Figma 숫자 라벨 스크러빙은 이번 검색에서 신뢰할 공식 근거를 찾지 못해 확인 못 함으로 남김. 파일 변경·코드 작업 없음. + + +--- + +## 원문 3 — `agy_1.md` · 안티그래비티 — 국내 실무 리습 · 국산 CAD · 국내 측량(28 가지) + +# 안티그래비티 (agy -p · effort high) + +### 「쓸모 상」 핵심 입력 방법 요약 (웹 CAD 임도 도면 손질 관점) + +- **측점(Station) 및 편측 오프셋(Offset) 직교 입력**: 임도 중심선을 따라 20m 정측점 및 플러스 측점을 일괄 타각(`DSTA`)하고, 선형 기준 이격거리(`STA 3+15, L 4.0`)로 집수정·배수관·소단·옹벽 등 구조물 위치를 직접 스냅하는 방식은 평면도 손질의 핵심 기반임. +- **토공 사면 경사비(1:n) 구속 및 소단(Berm) 단차 전개**: 산지 지형 특성상 절토·성토 비탈면 작도가 빈번하므로, 사면 경사비(1:1.5, 1:1.2 등) 각도 구속 스냅과 5m 높이별 소단 자동 전개(`DSLO`), 법면 빗금 기호(`TALUS`) 일괄 생성이 필수적임. +- **종단 계획 구배율(%) 직접 입력 및 표고(EL) 자동 계산**: 산지관리법상 임도 최대 종단경사 기준(13~18% 이내) 준수를 위한 구배율(%) 직결 고정 입력과, 기준 BM 대비 Y축 변위를 자동 환산하는 표고 주기(`EL`) 입력이 종단도 손질에 직결됨. +- **한글 자판 상태 단축키 자동 보정 (IME 가상 매핑)**: 한글 주석·측점 작성 후 영문 전환 없이 `ㅣ(Line)`, `ㅡ(Move)`, `채(Copy)`를 쳐도 즉시 명령이 실행되는 한영 자동 보정은 브라우저 웹 CAD 환경에서 실무자의 조작 피로도를 획기적으로 낮춤. +- **도면 텍스트 사칙연산(`CTC`) 및 연속 연장 합산(`TLEN`)**: 선형 및 종단 계획 변경 시 도면 내 지반고·계획고 수치를 일괄 가감승제하고, 측구·난간·암거의 총 연장 수량을 다중 선택으로 도면에 자동 기입하는 기능이 수량 검토 작업 속도를 결정함. +- **현장 측량 데이터(XYZ/PNT) 및 엑셀 표 양방향 연동**: GNSS/광파기 야장(점번호, X, Y, Z) 원클릭 도면 전개와 토적표·수량표의 캐드-엑셀 간 상호 복사(`CTE`/`ETC`)가 웹 CAD의 현장 실무 호환성을 좌우함. + +--- + +### 국내 실무 CAD 입력 방법 조사 결과 표 + +| 번호 | 이름 | 어디서 (프로그램·리습) | 어떻게 입력하나 (입력 시퀀스) | 임도 도면 손질 쓸모 (상·중·하 + 까닭) | 출처 URL | +| :---: | :--- | :--- | :--- | :--- | :--- | +| 1 | **선형 측점 일괄 표기 (DSTA)** | 드림플러스 / ZDream | 폴리선 선택 → 시작 측점(예: 0+00) 및 간격(예: 20m) 입력 → 엔터 시 직각 틱 마크 및 NO. 텍스트 일괄 생성 | **상** — 임도 중심선에 20m 간격 정측점 및 주요 변화점 일괄 배치 필수임 | [dreamcad.net](https://dreamcad.net) | +| 2 | **측점·오프셋 조회 주기 (STT/GST)** | 드림플러스 / 토목 리습 | 중심선 클릭 → 도면상 임의 지점 클릭 → 측점 및 좌우 이격거리(`STA 2+14.5, L 3.2m`) 지시선 텍스트 자동 삽입 | **상** — 배수관, 암거, 집수정 등 부속 구조물 위치를 평면도에 즉시 주기함 | [dreamcad.net](https://dreamcad.net) | +| 3 | **계획 사면 자동 작도 (DSLO)** | 드림플러스 / ZDream | 노견 기준선 클릭 → 경사비(예: 1:1.5) 및 소단 조건 입력 → 자연지반선 클릭 시 법면선 자동 교차 작도 | **상** — 임도 횡단면 절토·성토 사면 및 5m 소단 작도의 핵심 기능임 | [dreamcad.net](https://dreamcad.net) | +| 4 | **법면 빗금 기호 작도 (TALUS)** | 토목 LISP (`talus.lsp`) | `TALUS` 입력 → 사면 어깨선(Top)과 발선(Toe) 연속 클릭 → 빗금 간격 입력 시 긴선·짧은선 기호 자동 생성 | **상** — 임도 평면도 상에서 절·성토 비탈면 범위를 나타내는 표준 제도 기호임 | [gocad.co.kr](https://www.gocad.co.kr) | +| 5 | **경사비(1:n) 및 구배 표기 (SLP)** | 토목 LISP (`slope.lsp`) | `SLP` 입력 → 경사선 위 두 점 클릭 → 경사비(1:1.5) 또는 구배(%) 선택 시 직각삼각형 기호와 수치 자동 기입 | **상** — 횡단 비탈면 경사 및 종단 노면 종단구배 표기에 직접 활용됨 | [gocad.co.kr](https://www.gocad.co.kr) | +| 6 | **표고 기호 및 높이차 계산 (EL)** | 토목/건축 LISP (`el.lsp`) | `EL` 입력 → 기준점 클릭 및 기준 표고 입력 → 대상점 클릭 시 Y축 변위를 가산하여 역삼각 기호와 `EL=수치` 자동 삽입 | **상** — 종·횡단면도에서 임도 계획고·지반고 손질 시 필수 계산 입력임 | [gocad.co.kr](https://www.gocad.co.kr) | +| 7 | **다중 선분 총 연장 합산 (TLEN)** | 범용 LISP (`tlen.lsp`) | `TLEN` 입력 → 선·호·폴리선 다중 선택 → 엔터 시 총 연장 출력 및 도면 클릭 지점에 합산 텍스트 기입 | **상** — 측구수로관, 가드레일, 낙석방지망 등 임도 시설물 총 연장 산출에 직결됨 | [lee-mac.com](https://www.lee-mac.com/totallength.html) | +| 8 | **면적 텍스트 자동 기입 (AR/DLA)** | 토목 LISP (`ar.lsp`) | `AR` 입력 → 닫힌 구역 내부점 클릭 → m² 단위 및 자릿수 지정 시 클릭 위치에 산출 면적 텍스트 자동 작성 | **상** — 횡단면 토공(절토·성토) 단면적 산출 및 용지 편입 면적 확인에 필수임 | [dreamcad.net](https://dreamcad.net) | +| 9 | **숫자·문자 자동 증분 넘버링 (TCOUNT)** | AutoCAD Express / 국산 CAD | `TCOUNT` 입력 → 텍스트 다중 선택 → 정렬 축/선택순 지정 → 시작값·증분값(예: 1, 1) 입력 시 일괄 치환 | **중** — 단면 번호, 측점 번호, 집수정 일련번호 순차 부여 작업에 유용함 | [help.autodesk.com](https://help.autodesk.com) | +| 10 | **도면 텍스트 숫자 사칙연산 (CTC)** | 드림플러스 / ZDream | `CTC` 입력 → 연산자(+ - * /) 및 상수 입력 → 도면 내 숫자 텍스트들을 드래그 선택 시 표기 수치 일괄 연산 갱신 | **상** — 계획고 변경이나 표고차 일괄 가감 시 텍스트 재입력 없이 즉시 갱신함 | [dreamcad.net](https://dreamcad.net) | +| 11 | **캐드 표 ↔ 엑셀 양방향 변환 (ETC/CTE)** | 드림플러스 / ZDream | `ETC` 입력 후 캐드 표(선·문자) 드래그 시 엑셀 시트 자동 생성 (반대로 엑셀 복사 후 `CTE` 입력 시 캐드 표 생성) | **상** — 토적표, 수량집계표, 측점좌표표를 엑셀과 연동하여 손질할 때 필수적임 | [dreamcad.net](https://dreamcad.net) | +| 12 | **숫자 키 원터치 레이어 토글 (11/22/33)** | 국내 실무 단축 LISP | 객체 선택 후 `11`(선택 레이어만 격리), `22`(전체 켜기), `33`(선택 레이어 끄기) 입력 | **상** — 등고선, 도로중심선, 측점문자 등 복잡한 토목 도면층을 초고속으로 제어함 | [gocad.co.kr](https://www.gocad.co.kr) | +| 13 | **TM 국가좌표 지시선 주기 (XY)** | 드림플러스 / 토목 LISP | `XY` 입력 → 대상 위치 클릭 → 지시선 끝점 클릭 시 `X(N): ..., Y(E): ...` 형식으로 국가 TM 좌표 자동 표기 | **중** — 임도 교각점(IP), 시·종점, 주요 구조물 설치 위치의 현장 측량 좌표 표기에 사용됨 | [dreamcad.net](https://dreamcad.net) | +| 14 | **지형도 교차 종단 지반고 추출 (GRP)** | 드림플러스 / ZDream | 수치지형도 3D 등고선 선택 → 평면 중심선 선택 → 종단 원점 클릭 시 종단면도 지반선 및 밴드 일괄 자동 작도 | **상** — 수치지형도로부터 임도 종단면도를 초기 생성하고 손질하는 핵심 도구임 | [dreamcad.net](https://dreamcad.net) | +| 15 | **평면 선형 기반 횡단 지반선 추출 (CFM)** | 드림플러스 / ZDream | 횡단 폭(좌우 15m) 및 측점 지정 → 3D 지형도 선택 시 측점별 횡단 지반선 및 그리드 일괄 작도 | **상** — 임도 횡단면도 토공 단면을 추출하고 구조물을 얹는 기본 바탕임 | [dreamcad.net](https://dreamcad.net) | +| 16 | **한글 자판 단축키 자동 보정 (IME 변환)** | 캐디안 / 드림플러스 (HCMD) | 한글 입력 상태에서 `ㅣ`(L), `ㅡ`(M), `ㅊ`(C), `채`(CO) 입력 시 영문 전환 없이 해당 명령 즉시 실행 | **상** — 한글 측점·주석 작성 후 명령 입력 시 한/영 전환 오입력을 완전히 방지함 | [cadian.com](https://www.cadian.com) | +| 17 | **한글 자연어 명령어 직접 입력** | 캐디안 (CADian) | 명령줄에 `선`, `원`, `복사`, `이동`, `지우기` 등 한국어 단어 입력 후 엔터 시 해당 캐드 명령 실행 | **중** — 단축키에 미숙한 산림 토목 현장 담당자 및 초심자에게 직관성을 제공함 | [cadian.com](https://www.cadian.com) | +| 18 | **마우스 제스처 명령 입력 (SmartMouse)** | ZWCAD (지더블유캐드) | 우클릭 드래그로 화면에 궤적(←: Undo, →: Redo, ㄴ: Erase, 대각선: Zoom Extents 등)을 그리면 명령 실행 | **중** — 뷰 이동, 명령 취소 등 빈번한 단순 보조 입력을 마우스 한 손으로 신속 처리함 | [zwsoft.co.kr](https://www.zwsoft.co.kr) | +| 19 | **도면 음성 메모 입력 (SmartVoice)** | ZWCAD (지더블유캐드) | 도면 객체 클릭 후 마이크 녹음 → 도면에 확성기 심볼 생성 → 심볼 클릭 시 현장 수정 지시 음성 재생 | **중** — 웹 환경에서 설계 검토자 간 구조물 수정 위치 및 지시 사항 전달에 유용함 | [zwsoft.co.kr](https://www.zwsoft.co.kr) | +| 20 | **증분 복사 입력 (Incremental Copy)** | 지스타캐드 / ZWCAD | 숫자가 포함된 문자 복사 시 증분 모드 적용 → 마우스 클릭 시마다 숫자 부분이 1씩 자동 증가하여 부착 | **상** — 측점 번호(NO.0, NO.1..), 배수관 일련번호를 연속 복사 배치할 때 극도로 효율적임 | [gstarcad.co.kr](https://www.gstarcad.co.kr) | +| 21 | **비대칭 축척(H/V) 종단 그리드 생성** | 지스타캐드 Magic / 드림플러스 | 수평 축척(1:1000)과 수직 축척(1:100)을 분리 입력하여 왜곡 비율이 적용된 종단 그리드 및 도각 자동 삽입 | **상** — 고저차를 과장 표현해야 하는 국내 토목·임도 종단면도의 표준 규격 작도에 필수임 | [gstarcad.co.kr](https://www.gstarcad.co.kr) | +| 22 | **측점 + 편측 오프셋 좌표 스냅 입력** | 국내 도로설계 S/W (INFRACAD 등) | 명령줄에 `STA 3+15.5, L 4.0` 입력 → 기준 선형의 측점 3+15.5 위치에서 좌측 4.0m 수직 지점에 좌표 스냅 | **상** — 임도 중심선 기준으로 측구, 소단, 비탈면 경계점을 도면에 직접 작도할 때 유용함 | 확인 못 함 | +| 23 | **방위각(도분초) + 거리 입력** | 토목 측량 CAD 패키지 | 방위각 `125.3020`(125°30'20")과 거리 `45.2m` 입력 → 기준점에서 북 기준 방위각 방향으로 직선 분절 작도 | **상** — 산림 경계, 필지 경계선, 노선 교각점(IP) 측량 야장 데이터를 직접 전개할 때 쓰임 | [gocad.co.kr](https://www.gocad.co.kr) | +| 24 | **사면 경사비 구속 입력 (Slope Lock)** | 토목 횡단 설계 도구 | 경사선 작도 중 `1:1.5` 또는 `1:1.2` 입력 → 마우스 커서 이동 각도가 해당 사면 경사선 상으로 고정 구속됨 | **상** — 수식 계산 없이 커서 드래그만으로 지반선 교점까지 절·성토 비탈면선을 원클릭 작도함 | 확인 못 함 | +| 25 | **종단 계획 구배율(%) 직접 입력** | 도로·임도 종단 설계 패키지 | 종단 변곡점에서 `i = 5.5%` 입력 → 수평거리 대비 높이 각도가 자동 고정되어 다음 계획선까지 연속 작도 | **상** — 산지관리법 임도 최대 종단경사(13~18%) 규정 준수 여부를 즉각 반영하여 작도함 | 확인 못 함 | +| 26 | **종단 곡선 제원(VCL / R) 입력** | 도로·임도 종단 설계 패키지 | 종단 변곡점에서 곡선장 `VCL=40` 또는 반경 `R=300` 입력 → 두 경사선 사이에 접선 포물선 호 자동 생성 | **상** — 종단 계획선에서 볼록·오목 종단곡선을 오토캐드 원호 명령 없이 정밀 작도함 | 확인 못 함 | +| 27 | **측량 좌표(CSV/TXT) 일괄 전개 (PNT)** | 드림플러스 / ZDream (CIM) | `점번호, N, E, Z, 코드` 포맷 파일 드래그 앤 드롭 → 점 심볼, 점번호, 표고 텍스트를 레이어별 일괄 전개 | **상** — GNSS/토털스테이션 현장 측량 데이터를 웹 CAD 캔버스에 즉시 로드하는 기본 입력임 | [dreamcad.net](https://dreamcad.net) | +| 28 | **소단(Berm) 계단식 단차 자동 전개** | 토목 사면 모듈 | 비탈면 높이 5m 도달 시 소단폭 1.0m 및 배수 역경사(4%) 입력 → 지반선 접점까지 계단식 단차 연속 생성 | **상** — 깊은 절토 사면에서 암반 안정성 확보를 위한 계단식 소단 상세도 손질에 필수적임 | [dreamcad.net](https://dreamcad.net) | + + +--- + +## 원문 4 — `codex_3.md` · 코덱스 3차 — 안티그래비티 28 가지 출처 대조 + +# 코덱스 3차 — 안티그래비티 결과 출처 대조 (ctx_c76434261edc) + +확인 2 · 일부 13 · 못 찾음 13 + +| 번호 | 판정 | 대조 결과 · 바른 내용 | 바른 출처 URL | +|---:|---|---|---| +| 1 | 확인 | DSTA의 선형 측점 표기와 간격 지정, 선형 직각 표기 확인. 세부 설정은 프로그램 창 도움말 기준임 | [드림플러스 DSTA 안내](https://dreamcad.net/bbs/board.php?bo_table=qna&wr_id=16553) | +| 2 | 일부 | STT는 선택 측점 표기, GST는 측점 조회로 확인. L/R 오프셋 지시선 자동 삽입 순서는 확인 못 함 | [STT 안내](https://www.dreamcad.net/bbs/board.php?bo_table=qna&wr_id=22368), [DSTA/GST 안내](https://www.dreamcad.net/bbs/board.php?bo_table=qna&wr_id=12839) | +| 3 | 못 찾음 | DSLO 및 제시한 사면·소단 입력 절차를 출처에서 확인 못 함 | 드림플러스 일반 홈페이지뿐, 기능 페이지 미제시 | +| 4 | 못 찾음 | 고캐드 홈 주소만으로 TALUS LISP와 입력 순서 확인 불가 | 기능을 설명하는 출처 못 찾음 | +| 5 | 못 찾음 | SLP 명령과 선택 순서를 설명하는 출처 못 찾음 | 기능을 설명하는 출처 못 찾음 | +| 6 | 일부 | 제시된 일반 EL 명령의 동작은 확인 못 함. 드림플러스에는 FE 상대 EL, GEL 두 점 사이 EL 등 서로 다른 명령이 있음 | [FE/GEL 안내](https://dreamcad.net/bbs/board.php?bo_table=qna&wr_id=13168) | +| 7 | 일부 | Lee Mac TLEN은 선·호·폴리선 등을 선택해 총 연장을 계산하지만 결과는 명령줄 출력임. 도면 클릭 위치에 합산 문자를 쓰는 기능은 아님 | [Lee Mac Total Length](https://lee-mac.com/totallengthandarea.html) | +| 8 | 못 찾음 | AR/DLA 및 내부점 클릭 후 면적 문자 작성 순서 확인 못 함 | 드림플러스 일반 홈페이지뿐, 기능 페이지 미제시 | +| 9 | 일부 | AutoCAD Express TCOUNT의 객체 선택, X/Y/선택순 정렬, 시작·증분 및 덮어쓰기/접두/접미 순서 확인. 국산 CAD 호환은 이 출처로 확인 못 함 | [Autodesk TCOUNT 한국어 도움말](https://help.autodesk.com/cloudhelp/2025/KOR/AutoCAD-Core/files/GUID-4B92219C-DA94-4D71-B308-D3818D3C6B8F.htm) | +| 10 | 일부 | CTC 문자 사칙연산은 확인 못 함. 드림플러스 공지에는 CALC의 문자 기능으로 사칙연산하는 내용 확인; 명령명 수정 필요 | [드림플러스 업데이트 공지](https://www.dreamcad.net/bbs/board.php?bo_table=notice&wr_id=80) | +| 11 | 일부 | ETC는 엑셀 표→캐드, CTE는 캐드 표→엑셀임. 제시 표의 방향이 뒤바뀜. 업데이트 기능은 양쪽 확인, CAD 선·문자 선택 등 제시 순서 일부 확인 | [드림플러스 공식 Q&A](https://dreamcad.net/bbs/board.php?bo_table=qna&wr_id=30570) | +| 12 | 일부 | 고캐드 예시는 11/22/33에 레이어 1/2/3의 켜기·현재 설정·다른 층 끄기를 사용자 LISP로 정의한 사례임. 선택 객체 레이어 격리/전체 켜기/끄기라는 보편 단축키는 아님 | [고캐드 LISP 코드 사례](https://www.gocad.co.kr/%EC%9E%90%EB%A3%8C%EC%8B%A4/?act=procFileDownload&file_srl=2095880&mod=document&module=file&pageid=453&q=%EC%9E%90%EB%A3%8C%EC%8B%A4%2F&sid=3bd9beccd8c7e82101307b2ffc710d66&uid=4770) | +| 13 | 일부 | DreamPlus XY 좌표 쓰기와 좌표축 설정 확인. X(N)/Y(E) 형식 및 클릭 후 지시선 종점 순서는 출처에서 확인 못 함 | [XY 사용 안내](https://dreamcad.net/bbs/board.php?bo_table=qna&wr_id=15273) | +| 14 | 일부 | GRP는 Z값 지형의 선형을 선택해 종단을 추출함. 원점 클릭·밴드 자동 작성 등 제시한 상세 시퀀스는 해당 도움말에서 확인 못 함 | [드림플러스 GRP 설명](https://www.dreamcad.net/bbs/board.php?bo_table=product&wr_id=3) | +| 15 | 일부 | CFM은 Z값 지형에서 횡단 추출. 측점 간격 추출, 기존 횡단선 선택, Excel 측점 목록, 좌우 편측 폭 등 모드 확인; 단순히 측점·폭 입력 후 3D 지형을 고르는 순서로 한정 불가 | [드림플러스 CFM 설명](https://www.dreamcad.net/bbs/board.php?bo_table=product&wr_id=4) | +| 16 | 일부 | HCMD는 한글 상태에서 명령을 입력해 ‘알 수 없는 명령’이 되면 영문으로 바꿔 재실행하는 드림플러스 기능임. 자모/한글 글쇠를 영문 키에 가상 매핑하는 기능은 아님; CADian 근거도 못 찾음 | [HCMD 공식 답변](https://dreamcad.net/bbs/board.php?bo_table=qna&page=197&wr_id=17336) | +| 17 | 못 찾음 | CADian 한글 자연어 명령어 입력 근거를 공식 문서에서 확인 못 함 | CADian 제품 소개는 해당 기능 설명 없음 | +| 18 | 일부 | SmartMouse 우클릭 드래그와 방향/패턴 제스처, 명령 사용자 지정 확인. ← Undo/→ Redo/ㄴ Erase/대각 Zoom Extents 고정 배정은 확인 못 함 | [ZWCAD 공식 안내](https://www.zwsoft.com/user-guide/zwcad-smart-features) | +| 19 | 확인 | SmartVoice는 점·객체·영역에 녹음 메모 연결, 녹음 버튼 누르고 있는 동안 녹음, 놓으면 재생 버튼 표시. ‘확성기 심볼’은 정확한 UI 설명이 아님 | [ZWCAD 공식 안내](https://www.zwsoft.com/user-guide/zwcad-smart-features) | +| 20 | 일부 | GstarCAD Architecture 설명서에 문자 숫자/문자 부분 선택→기준점→배치점마다 증분 복사 확인. GstarCAD Architecture 근거이며 ZWCAD 일반 기능이라는 근거는 없음 | [GstarCAD Architecture 안내 PDF](https://cdn-sg-gw.gstarcad.net/gstarsoft_pdf/GstarCAD-Architecture-2021-User-Guide.pdf) | +| 21 | 못 찾음 | 제시된 GstarCAD 도메인에서 비대칭 축척 종단 그리드·도각 자동 삽입을 설명하는 근거 못 찾음 | 기능 출처 미제시 | +| 22 | 못 찾음 | INFRACAD 등 제품명·명령 도움말·직접 출처가 없어 측점+편측 오프셋 입력 확인 못 함 | 출처 URL 미제시 | +| 23 | 못 찾음 | 고캐드 홈 주소에서 방위각·거리 입력 명령과 순서 확인 못 함 | 기능을 설명하는 출처 못 찾음 | +| 24 | 못 찾음 | Slope Lock 명령이나 입력 절차 근거 없음. 1:n 등 도메인 숫자 — 지식DB 확인 몫 | 출처 URL 미제시 | +| 25 | 못 찾음 | 구배율 고정 입력·계획선 연속 작도 근거 없음. 임도 최대 경사 숫자 — 지식DB 확인 몫 | 출처 URL 미제시 | +| 26 | 못 찾음 | VCL/R 입력으로 포물선 종단곡선 자동 작성하는 근거 없음. 곡선 제원 숫자 — 지식DB 확인 몫 | 출처 URL 미제시 | +| 27 | 일부 | CIM은 Excel 좌표를 CAD 점·선 또는 3D 폴리선 등으로 가져올 수 있음. 파일 드래그앤드롭, 점번호·코드 포함 5열 포맷, 레이어별 전개 순서는 출처에서 확인 못 함 | [드림플러스 CIM 사용 예](https://dreamcad.net/bbs/board.php?bo_table=qna&wr_id=14564) | +| 28 | 못 찾음 | 소단폭·배수 역경사 등을 입력해 자동 전개하는 사면 모듈 출처 확인 못 함 | DreamPlus 일반 홈페이지뿐, 기능 페이지 미제시 | + +도메인 숫자 13~18% 등은 판정하지 않았으며, 해당 항목에 지식DB 확인 몫으로만 표시함. + + +--- + +## 원문 5 — `B4_토목측량입력.md` · B4 — 토목 · 도로 · 측량 전용 입력 + +# B4 — 토목 · 도로 · 측량 전용 CAD 입력 조사 (요구 226-3) + +- 기준 = `main_laptop_1` b7e727863 · 인터넷 공식 문서 먼저(제3자 · 포럼은 표기) · 우리 코드는 grep · 짧은 읽기만 · 코드 고침 없음. +- 겹침 뺌 = 335(오토캐드 71 · 우리 현황) · 코덱스 1 · 2차(BricsCAD · Rhino · Onshape 등) — 같은 것은 「같음 + 번호」 한 마디만. +- 기호 — 우리: 있음 · 일부 · 없음 · 쓸모: 상 · 중 · 하(임도 도면 손질 기준 · 짐작 포함) · [R..] = 맨 끝 출처. + +## 0. 결론 + +1. 토목 CAD 입력의 바탕 = 도면 좌표 ↔ 실값(측점 · 오프셋 · 표고 · 경사 %)을 아는 「뷰 · 노선 객체」 — 사용자는 종이 mm 가 아니라 실값을 침(Civil 3D 투명 명령 · Civil AccuDraw · TBC COGO 칸 모두 같은 틀). +2. 우리 CAD = 종이 mm 도형 + 제목 글(「2+0.0」 · 「STA.0+050.000」)만 받음 → 아래 「상」 거의 전부 지금은 불가 · 엔진이 이미 계산하는 장 · 블록별 변환(축척 · 원점 · 기준 표고 · 시작 측점 · 노선 표지)을 실음 메시지에 덧붙이면 길이 열림(3장). +3. 상 · 없음(평면) — 측점 + 오프셋 점(P1 `'SO` · P2 Civil AccuDraw · P3 TBC 선 따라 거리 + 오프셋 = 노선 객체 없이 폴리선만으로 됨) · 측점 · 오프셋 조회(P6) · 측점 · 오프셋 글씨(T1) · 측점 꼴 `No.n+d` 입력(P7 · 20 m 번호 꼴 = Trimble 「Station index」). +4. 상 · 없음(종단) — 측점 · 표고 점(L1 `'PSE` · 우리 종단 가로 1/1000 · 세로 1/200 = 세로 5 배 → 손 계산 없앰) · 경사 % + 측점 · 길이 선(L2 `'PGS` · `'PGL`) · 측점 · 표고 / 두 점 경사 글씨(T3). +5. 상 · 없음(횡단) — 오프셋 · 표고 표 입력 + 앞 점에서 경사 · 비율 → 표고(C1 Carlson) · 커서 자리 실값 읽기(C3) · 오프셋 · 표고 / 두 점 경사 글씨(T2) · 실거리 단위 붙여 치기 「3m」 → 축척대로 30 mm(C7 TBC). +6. 조사 중 찾은 빈틈 — 우리 치수 글 = 종이 거리 그대로 → 횡단 1/100 에서 3 m 를 재면 「30」 · 평면 1/1200 은 「2.5」(T4 · 오토캐드 DIMLFAC 류 없음) — 축척 정보 하나로 C7 과 함께 풂. +7. 정할 것 — 오프셋 부호: 우리 엔진 = +좌 / −우 · Carlson · GEOPAK · TBC = 좌 − · 우 + → 입력 칸 부호는 사용자 협의로. +8. MicroStation AccuDraw 는 대부분 기존 조사와 같음(동적 입력 · 직교 · FROM · 각도 잠금) — 새것 = 나침반을 고른 요소 방향으로 돌림(M4) · 잠긴 값에 Enter 또 = 그만큼 더함(P2) · 가 스냅 뒤 확정(M5) — 모두 중. +9. 설계 객체 표 편집(PVI 표 · 끌기 고정 꼴 · 정렬 표)은 설계 정본 몫(B05 · B06) — CAD 손질엔 하(L6). +10. 국내 임도 전용 설계 프로그램 = 확인 못 함 · 국내 유틸 KTOOL(엑셀 ↔ 캐드 종단) 하나 확인 — 우리 B05 → B07 자동과 같은 일(K1). + +## 1. 표 + +### 1-1. 평면 · 측점 + +| # | 이름 | 프로그램 | 어떻게 입력하나 | 쓸모 | 우리 | 쓰일 곳 | 출처 | +|---|---|---|---|---|---|---|---| +| P1 | 측점 · 오프셋 점 `'SO` | Civil 3D | 점 묻는 단계에서 `'SO` → 노선 고름 → 측점 → 오프셋(치거나 찍기) → 점 · 측점 · 오프셋 칸 자물쇠 = 같은 값 되풀이 · 노선 바꿔 이어 감 · Esc 끝 · 측점은 `+` 없이 숫자(포럼) | 상 — 구조물 · 시설 기호를 「No.12+5 좌 3 m」 자리에 바로 | 없음 | B07 평면 | [R1][R2][R3] | +| P2 | 측점 · 오프셋 / Δ측점 · 오프셋 칸 | OpenRoads(Civil AccuDraw) | `O` → 기준 요소 고름 → 커서 따라 측점 · 오프셋 칸이 움직임 → 칸에 치고 Enter = 잠금 · 잠긴 칸에 Enter 또 = 그 값만큼 더함 · End = 풂 · Shift+End = 모두 풂 · Tab 칸 넘김 · ←→ 칸 선택지 · 칸 꼴 = 거리 · 방향 / XY / DX DY / 측점 · 오프셋 / Δ측점 · 오프셋(고쳐 더할 수 있음) | 상 — 한 칸 잠그고 다른 칸만 바꿔 되풀이 · 20 m 간격 되풀이 | 없음 | B07 평면 | [R5][R6][R8] | +| P3 | 선 따라 거리 + 오프셋(Offset Line Snap) | Trimble Business Center | 좌표 칸에서 그림 우클릭 → Offset → 선 · 호 고름 → 측점 → 오프셋 · 측점 0 = 고른 자리에 가까운 끝 · + = 오른쪽 · 기운 선이면 Z 보간 | 상 — 노선 객체 없이 그냥 폴리선에 됨 = 우리 평면(노선 = 폴리선 한 줄)에 바로 맞음 · 축척만 있으면 됨 | 없음 | B07 평면 · 구조물 | [R9] | +| P4 | Chainage Offset 점(+ 끝 너머 Extended) | 12d Model | CAD → Point → Chainage Offset → 선 고름 → 측점 → 오프셋 · 다른 도구 고르기 전까지 이어 찍음 · Extended = 선 끝 너머 연장 | 중 — P3 과 같음 · 끝 너머 연장만 덧 | 없음 | B07 평면 | [R12] | +| P5 | 곡선 좌표 추적(Curvilinear · 체인) | GEOPAK | 좌표 꼴 = Curvilinear + 체인 고름 → 커서 = 측점 · 오프셋으로 읽힘 · 반올림 값 쳐서 자물쇠 · 왼쪽 = − | 중 — P2 와 같은 계열(옛판) | 없음 | B07 평면 | [R13] | +| P6 | 측점 · 오프셋 조회 | 12d(Measure String from point) · Carlson(Station-Offset from Coords) · TBC(Station from Line Snap · Station at Point Snap) | 점 찍음 → 노선 기준 측점 · 수직 오프셋 보고 · TBC 는 측점 칸을 그림 찍기로 채움(종단에서도) | 상 — 손질한 기호 · 글 자리를 측점으로 대조 | 일부 — `ID` = 종이 mm 좌표만 | B07 평면 · 종단 | [R12][R16][R10] | +| P7 | 측점 꼴 「번호 + 나머지」 | Trimble Access · Civil 3D | 표시 꼴 = 1000.0 · 10+00.0 · 1+000.0 · Station index(간격 20 → 42.0 m = 2+02.0) · Civil 3D 입력은 `+` 없이 숫자 | 상 — 우리 도면 글 = `No.n+d`(20 m) · 제목 「2+0.0」 · 정보 「STA.0+050.000」 → 입력도 `No.12+5` · `12+5` · `245` 다 받아야 | 없음 — 입력 없음 · 글만 `B07_DesignDetail_Engine_Cad.py:329-330` | B07 평면 · 종단 · 횡단 | [R17][R3] | +| P8 | 점 · 측점으로 이동 `'ZTP` | Civil 3D | 점 이름 · 번호 · 번호 범위 침 → 그리로 확대 | 중 — 우리 판 = 측점 쳐서 긴 평면 · 종단의 그 자리로 | 없음 — ZOOM = 범위만(335 D11) | B07 평면 · 종단 | [R1] | +| P9 | 간격 + 오프셋 점 배치(Locate Template Points) | Carlson | 중심선 따라 찍은 점 · 점 번호 · 측점 + 오프셋 침 · 측점 간격 + 오프셋 되풀이 · 자리 오프셋 · 표고 오프셋 따로 | 중 — 측점 눈금 · 표지 되풀이 | 일부 — MEASURE(길이분할 · 선 위 간격 점) 있음 · 오프셋 · 실거리 m 없음 | B07 평면 | [R19] | +| P10 | 임시 기준선 두 점 → 측점 · 오프셋 점 여러 개 | TBC(Create Points at Station Offsets) | 시작 · 끝 점으로 임시 기준선 → 점마다 측점 · 오프셋 고침 · 파란 화살표 = 쓰는 X · Y 축 | 중 — 비스듬한 구조물 축 기준 띄우기(사용자 좌표계 계열) | 없음 — UCS 없음 | 구조물 · B07 평면 | [R14] | +| P11 | 측점 추적선(Station Tracker) | Civil 3D | 평면 커서 → 종단 · 횡단 창에 같은 측점 임시선 | 하 — 우리 CAD 는 도면 한 장씩 · 페이지 몫 | 없음 | B07 페이지 | [R20] | + +### 1-2. 종단 + +| # | 이름 | 프로그램 | 어떻게 입력하나 | 쓸모 | 우리 | 쓰일 곳 | 출처 | +|---|---|---|---|---|---|---|---| +| L1 | 측점 · 표고 점 `'PSE` (+ `'STAE` · `'SSE` · `'SPE`) | Civil 3D | 점 묻는 단계에서 `'PSE` → 프로파일 뷰 고름 → 측점 → 표고(치거나 찍기) · 뷰가 가로 · 세로 축척 · 과장을 앎 · `'STAE` = 측점을 평면 노선에서 찍기 · `'SSE` = 표고를 지형면에서 · `'SPE` = 측량점에서 | 상 — 우리 종단 = 가로 1/1000 · 세로 1/200(세로 5 배) → 종이 mm 손 계산 없앰 | 없음 | B07 종단 | [R1][R21][R22] | +| L2 | 경사 + 측점 `'PGS` · 경사 + 길이 `'PGL` | Civil 3D | 앞 점 하나 있어야 · `'PGS` → 뷰 → 경사(치거나 두 점 찍기) → 측점(치거나 찍기) → 다음 경사 · 측점 되풀이 · Esc 끝 | 상 — 종단 경사(%)로 계획선 · 보조선 긋기(5 배 과장 안 따져도) | 없음 — 각 = 종이 각(십진 도) | B07 종단 | [R1][R23] | +| L3 | Profile Toolset 띠 | Civil 3D | 띠 칸 = 측점 · 표고 · 경사 · 길이 · 첫 점 = 측점 + 표고 · 다음 = 측점 + 표고나 경사 / 길이 + 경사 / 표고 + 경사 · 점마다 칸 비움 · 평면 · 종단 두 창 | 중 — L1 · L2 를 한 묶음 칸으로(우리 커서 곁 칸에 맞춤) | 없음 | B07 종단 | [R4] | +| L4 | 종단 Civil AccuDraw | OpenRoads | 종단 도구에 따라 칸 = 측점 · Z · ΔZ · 종단 오프셋 · 경사 · 측점 칸은 단축키 안 받음(포럼) | 중 — L3 과 같은 계열 | 없음 | B07 종단 | [R7] | +| L5 | 고저 편집 표(Elevation Editor) | Civil 3D(피처 라인 · 그레이딩) | 표 줄 = 측점 · 표고 · 앞 경사 · 뒤 경사 · 앞 경사 고침 = 다음 점 표고 바뀜 · 뒤 경사 고침 = 이 점 표고 바뀜 · 여러 줄 골라 경사 한 번 · 처음 · 끝 고정 평탄화 · 경사 꺾임만 보기 | 중 — 종단 계획선 · 횡단 지반선 폴리선을 실값 표로 손질 | 없음 — 특성 칸 길이 = 읽기만 | B07 종단 · 횡단 | [R24] | +| L6 | PVI 표 + 끌기 고정 꼴 | Carlson(Road Profile Editor) · Civil 3D(Profile Grid View · Insert PVIs-Tabular) | 표(측점 · 표고 · 종곡선 길이 · K)와 그림이 맞물림 · PVI 끌기 = Free / Hold Slope In / Hold Slope Out / Hold Station / Hold Elevation · 끄는 동안 종곡선 길이 · K 고정 · 해상도 스냅(측점 · 경사 반올림) · 세로 과장 목록 | 하 — 종단 정본 = B05 · CAD 손질은 설계에 안 돌아감 | 없음(B05 몫) | B05(참고) | [R25][R26] | + +### 1-3. 횡단 + +| # | 이름 | 프로그램 | 어떻게 입력하나 | 쓸모 | 우리 | 쓰일 곳 | 출처 | +|---|---|---|---|---|---|---|---| +| C1 | 오프셋 · 표고 표 입력 | Carlson(Input-Edit Section File) | 측점마다 표 = 오프셋 · 표고 · 설명 · 왼쪽 = − · 앞 점에서 경사나 비율 치면 표고 계산 · 표고 칸 식(`1926.18-1.25`) · Check Offset = 오프셋 치면 표고 · 정렬 · 줄 더함 · 뺌 · F2/F3 앞뒤 측점 | 상 — 횡단 지반선 · 계획선을 m 값으로 · 1:1.5 비탈 끝 표고 자동 | 없음 — 그립 = 종이 mm | B07 횡단 · B06 상세(참고) | [R27] | +| C2 | 끌기 고정 꼴(Hold Slope · Offset · Elevation) | Carlson | 점 끌 때 Free / Hold Slope(비탈 따라) / Hold Offset(표고만) / Hold Elevation(오프셋만) | 중 — 비탈 기울기 그대로 끝만 늘림 · 높이만 맞춤(연장 · 극 추적으로도 됨) | 일부 — 그립 끌기 있음 · 축 고정은 335 D3(Shift 직교) 제안뿐 · 비탈 따라 고정 없음 | B07 횡단 · 구조물 | [R27] | +| C3 | 커서 자리 실값 읽기 | Carlson(오프셋 · 표고 · 경사) · GEOPAK(평면 측점 · 오프셋 · 종단 측점 · 표고) · OpenRoads(`O` 뒤 커서 따라 측점 · 오프셋) | 커서만 움직이면 실값 표시 | 상 — 모든 손질에 바로 도움 | 없음 — 커서 곁 = 종이 mm 절대 좌표만(335 부록2) | B07 횡단 · 종단 · 평면 | [R27][R13][R8] | +| C4 | 비탈 꼴 바꿈(% · 비율 · 각) | Civil 3D · Trimble Access | Civil 3D 비탈 칸 = 숫자 3 → 3:1 · `25%` → 4:1 로 바꿔 보임(제3자) · Trimble 표시 꼴 = 각 · % · Rise:Run · Run:Rise | 중 — 335 D2(`<1:1.5`)와 같음 · 덧 = 한 칸이 % · 1:n · n:1 · 각 다 받기 + 꼴 설정 | 없음(D2 제안) | B07 횡단 · 종단 | [R30][R17] | +| C5 | 실거리 단위 붙여 치기 · 칸 식 · 점 ID 표고 | TBC(COGO 칸) | `3m` = 프로젝트 단위로 바꿈 · 칸 안 `+ - * / ^` · 표고 칸에 `"12"` = 점 12 의 표고 · 좌표 칸엔 따옴표 없이 점 ID | 상 — 우리 도면 = 종이 mm → `3m` 치면 축척대로(횡단 30 mm · 평면 2.5 mm) | 없음 — 단위 붙은 숫자 없음(335 부록2) · 식은 335 1-4 | B07 횡단 · 평면 · 구조물 | [R11] | + +### 1-4. 글씨 · 치수 + +| # | 이름 | 프로그램 | 어떻게 입력하나 | 쓸모 | 우리 | 쓰일 곳 | 출처 | +|---|---|---|---|---|---|---|---| +| T1 | 측점 · 오프셋 글씨 | Civil 3D(Station Offset · Fixed Point 라벨 · AddAlignOffLbl · AddAlignOffXYLbl) | 노선 고름 → 점 찍음 → 노선 이름 · 측점 · 오프셋 · 좌표 글 · 좌우 따라 글 돌림은 스타일 식 | 상 — 「No.5+12.0 좌 3.0」 글을 계산 없이 | 없음 | B07 평면 | [R18] | +| T2 | 횡단 글씨 — 오프셋 · 표고 / 두 점 경사 | Civil 3D(Section View Labels) | 섹션 뷰 고름 → Offset Elevation = 점 찍으면 오프셋 · 표고 글 · Grade = 두 점 → 경사 글(꼴 = 스타일) · 낱개 글은 측점 바뀌어도 안 따라감 | 상 — 「EL.123.45」 · 「1:1.5」 글을 계산 없이 | 없음 | B07 횡단 | [R28] | +| T3 | 종단 글씨 — 측점 · 표고 / 깊이 · 경사 | Civil 3D(Profile View Labels) | 프로파일 뷰 → Station Elevation = 점 → 측점 · 표고 글 · Depth = 두 점 → 깊이 · 경사 글 · 글은 뷰에 붙음(선이 바뀌어도 안 따라감) | 상 — 종단 손질의 표고 · 경사 글 | 없음 | B07 종단 | [R29] | +| T4 | 치수 측정 축척(DIMLFAC) | AutoCAD(치수 스타일 Primary Units) | 측정값 × 계수 = 치수 글(각도 · 공차 빼고) | 상 — 우리 치수 글 = 종이 거리 그대로 → 횡단 1/100 에서 3 m = 「30」 · 평면 1/1200 = 「2.5」 · 종단 세로 1/200 = 「15」 | 없음 — `entities/MeasurementEntity.ts:304,419` 반올림만 · `commands/dim-settings.ts:13-33` 소수 자리 · 주석 축척뿐 | B07 횡단 · 평면 · 종단 · 구조물 | [R31] | +| T5 | 값 글자 눌러 고치기(text manipulators) | OpenRoads · ConceptStation | 고른 노선의 반지름 · 길이 · 경사 글자를 눌러 새 값 + Enter → 도형이 규칙대로 따라 바뀜 | 하 — 우리 도면 = 선 · 글 따로(규칙 객체 없음) · 큼 · 코덱스 Onshape 치수 구동과 같은 계열 | 없음 | B07 평면 · 종단 | [R35] | + +### 1-5. 측량 좌표 · 각 · 값 집기 + +| # | 이름 | 프로그램 | 어떻게 입력하나 | 쓸모 | 우리 | 쓰일 곳 | 출처 | +|---|---|---|---|---|---|---|---| +| S1 | 북 · 동 좌표 `'NE` · 격자 `'GN` · 위경도 `'LL` | Civil 3D | 북(Northing) · 동(Easting) 값으로 점 · 격자는 축척 계수 반영 | 중 — 국내 측량 X = 북 · Y = 동(CAD X · Y 와 거꾸로) → 「북 · 동」 칸 이름이 뒤바뀜 막음 · 기준점 · 구조물 TM 자리 | 없음 — 평면 = 장 최소점 뺀 종이 mm | B07 평면 | [R1] | +| S2 | 방위 · 방위각 · 각 · 편각 + 거리 `'BD` `'ZD` `'AD` `'DD` | Civil 3D | 앞 점에서 방위(N45°E 꼴) · 방위각(북 0 시계) · 기준선에서 각 · 앞 선 편각 + 거리 | 중 — 편각 = 노선 IP 교각 손버릇 · 방위 꼴 자체는 331 · 335 부록1 과 같음 | 일부 — 극좌표 = 동 0° 반시계 십진 도만 | B07 평면 | [R1] | +| S3 | 사이드샷 `'SS` · 점 번호 · 이름 · 점 `'PN` `'PA` `'PO` · 거르개 `.p` `.g` `.n` | Civil 3D | 측량 점 번호 · 이름 쳐서 그 좌표 · 기준점 + 후시 방향 → 각 · 거리 | 하 — 우리 도면엔 측량 점 객체 없음 | 없음 | — | [R1] | +| S4 | 객체 길이 · 반지름 집기 `AmL` `AmR` | Civil 3D | 길이 · 반지름 묻는 단계에서 객체 고르면 그 값 | 중 — 같은 길이 · 반지름 되풀이 | 없음 | 구조물 · B07 평면 | [R1] | +| S5 | 곡선 계산기 `'CCALC` | Civil 3D | 곡선 요소 둘 → 나머지(반지름 · 길이 · 교각 · 접선장 등) | 하 — IP 표 대조 정도 | 없음 | B07 평면(IP 표) | [R1] | + +### 1-6. MicroStation AccuDraw · 그 밖 + +| # | 이름 | 프로그램 | 어떻게 입력하나 | 쓸모 | 우리 | 쓰일 곳 | 출처 | +|---|---|---|---|---|---|---|---| +| M1 | 나침반 · 칸 잠금 | MicroStation AccuDraw | 앞 점에 나침반 · 숫자 치면 커서 쪽 칸으로 · `M` 직교 ↔ 극 · `X` `Y` `D` `A` 칸 잠금 · `P` · `M` 점 키인 창 | 중 — 같음(동적 입력 · 직접 거리 · 각도 잠금 = 331 · 335) | 일부 | — | [R32] | +| M2 | Enter 스마트 잠금 | MicroStation AccuDraw | 축 가까우면 그 축 0 잠금 · 극이면 0 · 90 · −90 · 180 잠금 · 아니면 앞 거리 잠금 | 중 — 축 잠금은 335 D3 와 같음 · 「앞 거리 잠금」 덧 | 없음 | 구조물 · B07 횡단 | [R32] | +| M3 | 원점 옮김 `O` | MicroStation AccuDraw | 명령 중 커서 자리로 나침반 원점 | 하 — 같음(335 1-8 FROM) | 없음 | — | [R32] | +| M4 | 나침반 돌림 `RE` · `RQ` · `RA` | MicroStation AccuDraw | `RE` = 고른 요소 방향 · `RQ` = 잠시 · `RA` = 영구(ACS) · `RX` `RY` `RZ` = 90° · `B` = 기본으로 | 중 — 노선 · 비탈 방향 기준 앞 · 옆 거리 · 사용자 좌표계 계열 · 「요소 하나 찍어 맞춤」이 덧 | 없음 — UCS 없음 | B07 평면 · 구조물 | [R32] | +| M5 | 가 스냅(Tentative) | MicroStation | 가 스냅 단추 → 강조 + 좌표 → 데이터 점 = 확정 · 다시 누르면 다음 후보 · Reset = 놓음 | 중 — 겹친 곳 확인 뒤 확정 · 펜 · 터치 정밀(B5 몫과 겹침) | 일부 — 335 2-4(Tab 후보 돌기) 제안과 같은 계열 | B07 횡단 · 구조물 | [R33] | +| M6 | 색인 잠금 `LI` | OpenRoads(Civil AccuDraw) | 켜면 방향 = 나침반 눈금 · 거리 = 앞 거리 · Civil AccuDraw 에선 Enter 가 스마트 잠금 아님 | 하 — 같음(극 추적) | 일부 | — | [R6] | +| K1 | KTOOL 「도로 종단면도 그리기」 | 국내 유틸(카페 배포) | 엑셀 A = 누가거리 · B = 지반고 · 「종단계획 엑셀로」 → 계획선 폴리선 · 시점 계획고 · 종단 시점 찍음 → 절점 누가거리 · 계획고가 엑셀로 · 종곡선 길이 손 입력 · 축척 · 측점 꼴 · 기준 GL · 자리수 → 「종단 그리기」 → 삽입점 | 중 — 표 ↔ 도면 오가기 = 우리 B05 → B07 이 이미 자동 · 「도면 → 표 역추출」만 덧 | 일부 | B07 종단 | [R34] | +| K2 | 국내 임도 전용 설계 프로그램 | — | 확인 못 함 | — | — | — | — | + +## 2. 쓰일 곳별 묶음(상 · 없음만) + +| 쓰일 곳 | 상 · 없음 항목 | 꼭 필요한 문맥 | +|---|---|---| +| B07 평면 | P1 · P2 · P3 · P6 · P7 · T1 · C5 · T4 | 축척(1/1200 · 고를 수 있음) · 노선 폴리선 표지 · 그 장 시작 측점 | +| B07 종단 | L1 · L2 · T3 · P7 · T4 · C3 | 가로 · 세로 축척 둘 · 기준선 표고 · 그 장 시작 측점 | +| B07 횡단 | C1 · C3 · T2 · C5 · T4 | 축척 · 블록마다 도로 중심 · 기준 표고 | +| 구조물 · B06 상세 · M02 양식 | C5 · T4(축척 있는 구조물 도면만) | 축척 하나 · B06 상세 · M02 = 표 · 양식이라 노선 문맥 없음 → 나머지 하 | + +## 3. 측점 문맥 판정 + +판정 한 줄 — 지금은 「측점 + 오프셋」 입력 불가: CAD 는 종이 mm 도형과 제목 글만 받고 축척 · 원점 · 기준 표고 · 노선 표지가 없음 · 엔진이 이미 장 · 블록마다 변환을 계산하므로 이를 실음 메시지에 실으면 가능(크기 중간 · 어림). + +근거: + +- B07 도면 = 서버가 B05 · B06 산출물(노선)로 만듦 — `B07_DesignDetail/B07_DesignDetail_Engine_Cad.py:1-14`(좌표 규약: 종단 x = 측점 m · 횡단 x = 오프셋 m(+좌/−우) · y = 표고 m). +- 도면 좌표 = 종이 mm · 축척 `config/config_system.py:155-194` — 횡단 1/100(1 m = 10 mm) · 종단 가로 1/1000 · 세로 1/200(1 m = 1 · 5 mm) · 평면 1/1200(1 m = 0.833 mm · 고를 수 있음 500~6000). +- 횡단 블록 = 종이 (오프셋 × 10 + ox, (표고 − dy) × 10 + oy) · 블록마다 ox(도로 중심) · dy(그 단면 높이 가운데) · oy(쌓은 자리) 다름 — `B07_DesignDetail_Engine_Cad.py:488-560`. +- 종단 = x = 측점 × 1.0 · y = (표고 − 기준선) × 5 · 기준선 = 최저 표고 아래 5 m 단위 내림 — `B07_DesignDetail_Engine_Cad_Long.py:53-55,597-610`. +- 평면 = (사업지 좌표 − 장 콘텐츠 최소점) × 0.833 · 노선 = 폴리선 한 줄 · 측점 눈금 + `No.n+d` 글 — `B07_DesignDetail_Engine_Cad_Plan.py:15,45,334-373` · `B07_DesignDetail_Engine_Cad.py:329-330`. +- CAD 가 받는 문맥 = `DesignMeta`(kind · drawingId · title · info = 글 · confirmed · quantityTable · hasPrev/Next) — `openwebcad/src/App.types.ts:42-57` · 실음 메시지에 축척 · 원점 칸 없음 — `openwebcad/src/integration/aislo-drawing-bridge.ts:51-77`. +- `openwebcad/src` grep — station · chainage 0 건 · 측점 = 위 타입 주석 · 수량 패널뿐 · 노선 0 건 · UCS 0 건 · 엔티티 id = uuid5(결정적) → 측점 뜻을 못 읽음. +- 있는 것 — `ID`(점 좌표 · 종이 mm) `commands/commands.utility.ts:159-164` · MEASURE(길이분할) · DIVIDE(등분) `commands/commands.draw.ts:157-170` · DIMORDINATE. + +열리는 차례(어림): + +1. 축척만(장마다 mm/m · 종단은 가로 · 세로 둘) → C5 `3m` · T4 치수 축척 · P3 선 따라 거리(상대) — 작음~중간. +2. + 블록 · 장 원점 · 기준 표고 → L1 · L2 · C1 · C3 · T2 · T3 — 중간. +3. + 노선 표지(평면 노선 폴리선 · 그 장 시작 측점) · 측점 꼴 해석(`No.n+d` · `n+d` · m) → P1 · P2 · P6 · T1 — 중간. +- 내보내기(DXF · DWG)는 종이 mm 그대로 — 문맥은 화면 입력 · 표시에만 씀(정본 안 바뀜). + +정할 것: + +- 오프셋 부호 — 엔진 = +좌/−우(`B05_Profile/B05_Profile_Engine_Sections_Core.py:562` 「positive: left」) · Carlson · GEOPAK = 왼쪽 − · TBC = 오른쪽 + → 입력 칸 부호를 사용자 협의로. +- 종이 좌우 — 엔진 식 그대로면 +좌(왼쪽 오프셋)가 종이 오른쪽에 그려짐(추정 · 화면 실측 필요). +- 측점 꼴 — `No.n+d`(20 m) · `n+d` · `STA.0+050.000` 셋이 섞여 보임 → 입력은 셋 다 받고 표시는 한 꼴로. + +## 4. 확인 못 함 + +- 국내 임도 전용 · 국내 도로 설계 상용 프로그램(국내 쓰는 CARD/1 등)의 입력 방법 — 검색 한계(미국 지역 검색)로 확인 못 함 · 국내 리습 · 국산 CAD 는 안티그래비티 몫. +- Civil 3D 측점 입력 꼴 `1+50` 받는지 — 공식 문서 없음 · 포럼은 `+` 없이 숫자. +- Civil 3D 섹션 뷰 전용 투명 명령(오프셋 · 표고 쳐서 점) — 2024 표에 없음 → 없는 것으로 봄(라벨만 확인). +- Civil 3D `'NE` 의 묻는 차례(북 먼저) — 이름 차례로만 봄 · 화면 확인 못 함. +- OpenRoads 횡단용 Civil AccuDraw 칸(오프셋 · 표고) — 공식 본문 확인 못 함(종단 칸은 포럼). +- 12d 「pick ops」 · 높이 스냅 — 공식 문서 확인 못 함 · 12d 위키 본문 403 → 검색 요약 근거. +- Civil 3D 비탈 칸 꼴(3 = 3:1 · 25% → 4:1) — 제3자 글 근거. +- 쓸모 = 도면 종류별 짐작 · B07 에서 실제 무엇을 자주 고치는지 자료 없음(335 와 같음). + +## 5. 출처 + +- [R1] Civil 3D Transparent Command Reference — https://help.autodesk.com/cloudhelp/2024/ENG/Civil3D-UserGuide/files/GUID-21B960A1-8F33-4BBA-BA05-8A1DB5CBD4C7.htm +- [R2] Civil 3D Station Offset 투명 명령 — https://help.autodesk.com/cloudhelp/2023/ENU/Civil3D-UserGuide/files/GUID-0B7110B8-9941-44FE-A916-B4DA701E2EBA.htm +- [R3] Autodesk 포럼 Point to alignment with station — https://forums.autodesk.com/t5/civil-3d-forum/point-to-alignment-with-station/m-p/12927097 +- [R4] Civil 3D Profile Toolset — https://help.autodesk.com/cloudhelp/2023/ENU/Civil3D-UserGuide/files/GUID-9A3AE605-6EA6-4F1E-B429-198E97CC1CCD.htm +- [R5] OpenRoads Toolbox Civil AccuDraw(칸 꼴) — https://docs.bentley.com/LiveContent/web/OpenRoads%20Designer-v2024.0.2/Help/en/topics/405971/toolbox_civil_accudraw.html +- [R6] Civil AccuDraw Shortcut Key-ins — https://docs.bentley.com/LiveContent/web/OpenRail%20Designer-v2025/Help/en/topics/405971/shortcuts.html +- [R7] Bentley 포럼 Civil AccuDraw 종단 칸 — https://communities.bentley.com/products/road___site_design/f/geopak-inroads-mx-openroads-forum/203565/ir-ss4-10-civil-accudraw-shortcuts-non-functioning-in-profile-views +- [R8] NDDOT OpenRoads Fundamentals for Reviewers — https://www.dot.nd.gov/manuals/design/caddmanual/OpenRoads%20Fundamentals%20for%20Reviewers.pdf · CDOT 교육 — https://www.codot.gov/business/designsupport/cadd/ord/openroads-designer-10-10-for-roadway-designers-self-paced-training/creating-editing-centerline-geometry/creati-1.pdf +- [R9] TBC Offset Line Snap — https://help.fieldsystems.trimble.com/tbc/1255.htm +- [R10] TBC Station from Line Snap — https://help.fieldsystems.trimble.com/tbc/3842.htm · Station at Point Snap — https://help.fieldsystems.trimble.com/tbc/1418.htm +- [R11] TBC COGO Expressions, Units, and Entry Formats — https://help.fieldsystems.trimble.com/tbc/1847.htm +- [R12] 12d 위키 Chainage Offset — https://12dwiki.com.au/tag/chainage-and-offset/ · Measure chainage and offset — https://12dwiki.com.au/measure-chainage-and-offset +- [R13] TDOT GEOPAK Road Manual 2017(Tracking · Graphical COGO · 검색 결과 주소) — https://www.tdot.tn.gov/PublicDocuments/%5CDesignDivision%5Cassistant_engineer_design%5Cdesign%5CTraining%5CGeopak%20Road%20Manual%202017%5CProposed%20Roadway%20Tracking.pdf +- [R14] TBC Create Points at Station Offsets — https://help.fieldsystems.trimble.com/tbc/27540.htm +- [R16] Carlson Station Offset from Coords — https://files.carlsonsw.com/mirror/manuals/Carlson_2022/source/CGSurvey/CGCogo/Station_Offset/Station-Offset_From_Coords/Station-Offset_From_Coords.html +- [R17] Trimble Access Job units(측점 · 경사 꼴) — https://help.fieldsystems.trimble.com/trimble-access/2025.10/en/job-units.htm +- [R18] Civil 3D Alignment Command Reference — https://help.autodesk.com/cloudhelp/2027/ENU/Civil3D-UserGuide/files/GUID-7A01C84A-0476-4F7F-9C71-7D8208020D47.htm · VDCI(제3자) — https://vdci.edu/learn/civil-3d/working-with-alignment-labels-in-civil-3d-station-offset-fixed-point-and-station-offset +- [R19] Carlson Locate Template Points — https://files.carlsonsw.com/mirror/manuals/Carlson_2022/source/Site_Road_Design/Roads/Locate_Template_Points/Locate_Template_Points.htm +- [R20] Civil 3D Station Tracker — https://help.autodesk.com/cloudhelp/2023/ENU/Civil3D-UserGuide/files/GUID-DB691511-62AD-4FD0-BE0A-37EEE1C023BA.htm +- [R21] Civil 3D `'STAE` — https://help.autodesk.com/cloudhelp/2023/ENU/Civil3D-UserGuide/files/GUID-86AD3A0B-F3D2-47CA-8B67-A4EC4C320C5E.htm +- [R22] NRCS HowTo Profile Creation C3D(`'PSE` 흐름) — https://www.nrcs.usda.gov/sites/default/files/2022-10/HowTo-%20Profile%20Creation%20C3D.pdf +- [R23] Civil 3D `'PGS` — https://help.autodesk.com/cloudhelp/2023/ENU/Civil3D-UserGuide/files/GUID-DCEE5557-CEFF-432F-9A10-78859AC9AE6D.htm +- [R24] Civil 3D Grading Elevation Editor — https://help.autodesk.com/cloudhelp/2023/ENU/Civil3D-UserGuide/files/GUID-CD59A8C3-6A59-4655-9CDB-75ECA6C0D31B.htm +- [R25] Carlson Road Profile Editor — https://files.carlsonsw.com/mirror/manuals/Carlson_2022/source/Site_Road_Design/Profiles/Input-Edit_Road_Profile/InputEditRoadProfile.html +- [R26] Civil 3D To Work With Profile PVIs — https://help.autodesk.com/cloudhelp/2023/ENU/Civil3D-UserGuide/files/GUID-E0F43873-38D5-4C1B-85E2-E32D09B9F879.htm +- [R27] Carlson Input-Edit Section File — https://files.carlsonsw.com/mirror/manuals/Carlson_2022/source/Site_Road_Design/Sections/Input-Edit_Section_File/InputEdit_Section_File.htm +- [R28] Civil 3D To Add Section View Labels — https://help.autodesk.com/cloudhelp/2023/ENU/Civil3D-UserGuide/files/GUID-FB83A5B0-7D18-4250-B11D-8540DB4F4161.htm +- [R29] Civil 3D To Work With Adding Profile View Labels — https://help.autodesk.com/cloudhelp/2023/ENU/Civil3D-UserGuide/files/GUID-E2C1DAA4-B3DC-4996-84D4-688D3E47F3F2.htm +- [R30] IMAGINiT(제3자) Slope vs Grade · 서브어셈블리 값 꼴 — https://resources.imaginit.com/civil-3d/civil-3d-slope-vs-grade-format · https://resources.imaginit.com/civil-3d/civil3d-tips-and-tricks-switching-format-for-subassembly-values +- [R31] AutoCAD Primary Units Tab(DIMLFAC) — https://help.autodesk.com/cloudhelp/2023/ENG/AutoCAD-Core/files/GUID-2D1177AB-3130-4522-A631-AA28493529DC.htm +- [R32] MicroStation Complete List of AccuDraw Shortcut Key-ins — https://docs.bentley.com/LiveContent/web/MicroStation%20Help-v26/en/GUID-F954C772-A9B3-79A5-8441-AEC7EA77DCB0.html · AccuDraw's Shortcut Key-ins — https://docs.bentley.com/LiveContent/web/MicroStation%20Help-v27/en/GUID-7855BF35-47CA-7489-8D1E-AA12CFF8DC2D.html +- [R33] MicroStation 가 스냅 — https://prd-aws-docs.bentley.com/LiveContent/web/MicroStation%20Help-v28/en/GUID-67C2437B-8695-A8DC-5277-981A506A28B2.html · Bentley 포럼 — https://communities.bentley.com/products/microstation/f/microstation-forum/143160/how-do-i-prevent-sticking-of-tentative-snap +- [R34] KTOOL 도로 종단면도 그리기(다음 카페) — https://m.cafe.daum.net/gildosa/EBSk/297 +- [R35] OpenRoads ConceptStation Editing Roads — https://docs.bentley.com/LiveContent/web/OpenRoads%20ConceptStation%20Help-v16/en/GUID-D1A928C9-FC4F-452E-A9AF-9C651E5E7FD5.html · CDOT Horizontal Geometry — https://www.codot.gov/business/designsupport/cadd/ord/edit-horiz-geometry + + +--- + +## 원문 6 — `B5_웹터치AI근거.md` · B5 — 웹 · 터치 · 펜 · AI · 효율 근거 + +# B5 웹 · 터치 · 펜 · AI 입력 + 효율 근거 (요구 226-3 · 10-15) + +- 범위 = 웹 2D 그리기 도구 · 터치 · 펜 · 태블릿 CAD · AI · 자연어 입력 · 효율 근거(논문). 코드 고침 없음 · 웹 조사 + 우리 코드 읽기(실측 없음). +- 우리 상태 기준 = `docs/raw/조사/2026-10-15_335_부록2_우리CAD현황.md` · 「(추정)」 = 코드 읽기 판단 · 화면 실측 필요. +- 겹쳐 뺀 것 — 335(오토캐드 71) · 부록1 · 코덱스 1 · 2차에 이미 있는 것: 올가미 · 식 입력 · 극 추적 각 고르기 · 우클릭 메뉴(AutoCAD Web 의 우클릭 스냅 메뉴 · 머리 설정 극 추적 각도 같은 것) · Ctrl 끌기 복제 · 꼴 맞추기(Excalidraw Ctrl+Alt+C/V = MATCHPROP) · 도구 잠금 Q(= 명령 되풀이) · Quad · 표식 메뉴 · 마우스 몸짓 · Snap Loupe · ARES A3 설명. Space+끌기 팬(Figma · Excalidraw)은 우리 Space = Enter 라 안 맞아 뺌. +- 출처 = 표 안 [W..] · [E..] → 맨 아래 목록. + +## 0. 결론 + +1. 「누구든 · 어느 기기든 같은 기능」에서 가장 큰 빈칸 = 자판 없는 기기에서 숫자 · 명령을 넣는 길. 손가락 단추 6(Esc · Enter · 지움 · ↶ · ↷ · 스냅)뿐이라 태블릿 · 휴대폰은 거리 · 좌표 · 반지름 · 글 높이를 못 넣음(커서 곁 칸 = 캔버스 그림이라 못 누름) · B06 상세 · M02 카드는 리본 · 명령행도 없어 손가락 기기에선 명령까지 못 부름(추정 · 코드 읽기). +2. 그 빈칸을 메우는 셋 — ① 화면 숫자판(값 눌러 고치기 · AutoCAD 모바일 · DWG FastView) ② 명령 찾기 창(Figma Ctrl+K · FigJam 최근 셋 · Excalidraw Ctrl+/ — 단추로도 열림) ③ 고른 것 위 동작 막대(Miro). 셋 다 마우스 · 손가락 · 펜에서 같은 판이라 기기별 개발이 아님. +3. 웹 도구에서 가져올 작은 손버릇 — 고른 것 맞춤 보기(Shift+2) · 끌다가 가장자리 자동 이동(tldraw) · 여러 개 고른 값 「섞임」 + 식 한꺼번에(Figma `Mixed+100`) · 같은 거리 되풀이 복제(Alt 끌기 → Ctrl+D) · 누르는 동안 스냅 끔(draw.io Alt). +4. 펜 — 펜을 한 번 쓰면 손가락은 화면 이동만(펜 모드 · tldraw · Excalidraw · Morpholio) · iPad 는 펜이 닿는 동안 손가락 이벤트를 아예 안 보냄 → 「펜 = 그리기 · 손가락 = 다루기」가 근거 있는 기본값. 옆 단추 = 우클릭(Enter) · 지우개 끝 = 지움은 웹 표준만으로 됨. +5. 손가락 정밀 — 우리 「길게 눌러 손가락 위 70px 십자선」 = 오프셋 커서 꼴. 논문상 작은 표적 오류를 81% → 4~14% 로 줄이는 갈래라 방향은 맞음. 다음 걸음 = 가린 곳을 보여 주는 확대 창(callout) · 아래 70px 띠는 못 닿음(추정). +6. AI — 상용 CAD AI(Autodesk Assistant · Markup Assist · Smart Blocks 감지 · ARES A3 · Onshape AI Advisor)는 모두 클라우드 → 발주처 자료 유출 금지 조건에서 그대로 못 씀. 사내에서 되는 길 = ⓐ 규칙 기반 한글 명령 찾기(모델 없음) ⓑ 내 명령 순서를 세어 다음 명령 후보(NX · Solid Edge 꼴 · 모델 없음) ⓒ 사내 서버 작은 모델(0.5~8B · Qwen3 Apache 2.0 · HyperCLOVA X SEED 상업 무료) + 명령 목록 도구 부르기(시험 필요). 브라우저 안 AI(크롬 내장)는 안드로이드 · iOS 가 빠져 원칙 어긋남 · 음성은 크롬 기본이 서버 인식이라 불가. +7. 효율 근거 — 명령은 소수만 씀(9할이 90 개 미만 · 가장 많은 무리 31~40 개 · 최다 ERASE · ERASE→MOVE→COPY) · 숙련자도 빠른 길로 잘 안 넘어감(CAD 효율 전략이면 약 40% 단축 · 단축키 드묾) · 생각 한 번(1.35초) = 키 예닐곱 번 · 끌기 지연 25ms 넘으면 성능 떨어짐. +8. 근거로 본 차례 — ⓪ 335 흠 막기 → ① 기기 무관 셋(숫자판 · 명령 찾기 · 동작 막대) → ② 335 고르기 · 극 추적 묶음 → ③ 펜 모드 · 확대 창 · 두 손가락 되돌리기 → ④ 고른 것 맞춤 · 가장자리 이동 · 섞인 값 식 · 같은 거리 복제 → ⑤ 한글 명령 찾기 · 다음 명령 후보 → ⑥ 사내 작은 모델(시험). + +## 1. 웹 2D 그리기 도구 입력 + +| # | 이름 | 어디 | 어떻게 입력하나 | 임도 쓸모 | 우리 | 출처 | +|---|---|---|---|---|---|---| +| 1-1 | 명령 찾기 창(팔레트) | Figma Actions(Ctrl+K · 툴바 단추) · FigJam 빠른 실행(Ctrl+/ · 열면 최근 셋 · ↑↓ + Enter 되풀이 · 「Create new …」) · Excalidraw(Ctrl+/) | 단추나 키로 열고 낱말 침 → 명령 목록 → 누름 | 상 — 명령행 없는 B06 상세 · M02 카드 · 태블릿에서도 153 명령 모두 닿음 · 최근 셋 = 손가락 되풀이 | 일부 — B07 명령행 후보 8(기본 숨김) · 커서 곁 후보 6 · 앞글자만 · B06 · M02 없음 | [W1][W2][W3] | +| 1-2 | 고른 것 위 동작 막대 | Miro(고르면 물건 위 가로 막대 · Shift 누르면 숨김 · … 더 보기) · AutoCAD 모바일(고른 뒤에만 편집 도구 · 부록1) | 고르면 곁에 옮김 · 복사 · 지움 · 특성 단추 | 상 — 리본 없는 곳 · 손가락에서 고치기 명령 바로 · 손가락 그립 대신 「옮김」 | 없음 — 리본 · 리본 특성만 | [W4] | +| 1-3 | 여러 개 고른 값 「섞임」 + 식 | Figma(위치 · 크기 · 회전 칸 · `+10` · `(x/2)+6` · 섞인 칸에 `Mixed+100` = 고른 것마다 제 값에 더함) | 섞인 칸에 식 → 고른 것마다 적용 | 상 — 측점 · 표기 글 높이 일괄 · 여러 글 자리 일괄 | 없음 — 특성 칸 = 색 · 선 · 층만 · 여러 개면 첫 객체 값 · 335 6-7(빠른 속성 숫자 칸)에 「섞임 + 상대식」만 덧 | [W5] | +| 1-4 | 같은 거리 되풀이 복제 | Figma(Alt 끌어 복제 → Ctrl+D 마다 같은 거리로 하나 더) · Excalidraw(Ctrl+D · Alt 끌기) · draw.io(Ctrl 끌기 · Ctrl+D) | 한 번 옮겨 복제한 거리 기억 → 키 한 번에 하나 | 중 — 배수관 · 표지 · 표기 같은 간격 · ARRAY 보다 가벼움 | 일부 — COPY 여러 번 · ARRAY | [W6][W7][W3][W8] | +| 1-5 | 미세 이동 두 걸음 + 걸음 값 설정 | Figma(화살표 1 · Shift 10 · 환경설정에서 바꿈) · draw.io(Shift+화살표 = 격자 한 칸) | 고른 것 있으면 화살표 = 옮김 | 중 — 글 · 기호 자리 | 없음 — 화살표 = 화면 이동 · 335 6-2 에 「큰 걸음 · 값 설정」만 덧 | [W5][W8] | +| 1-6 | 누르는 동안 스냅 끔 | draw.io(Alt 누른 채 옮기기 · 크기 = 격자 · 가이드 무시) | 키를 누르는 동안만 | 중 — 촘촘한 횡단 상세에서 엉뚱한 스냅 피함 | 일부 — F3 켬끔 · 한 번 NON · 손가락 ⌖ | [W8] | +| 1-7 | 고른 것 맞춤 보기 | Excalidraw(Shift+1 전체 · Shift+2 고른 것) | 키 하나 · 손가락은 단추 | 중 — 긴 노선 평면에서 고른 구조물로 바로 | 일부 — 전체(Z · ⛶)만 | [W3] | +| 1-8 | 끌다가 가장자리 자동 이동 | tldraw(가장자리 8px 안에 0.2초 머물면 화면이 따라 감 · 0.2초에 걸쳐 빨라짐 · 손가락은 띠 12px 더 넓음 · 옮기기 · 상자 고르기 · 크기 바꾸기에만) | 끌기 중 화면 끝으로 | 중 — 작은 화면 · 긴 노선 · 그립 끌기 · 상자 고르기 | 없음 | [W9] | +| 1-9 | 거리 빨간 선(명령 없이) | Figma(하나 고르고 Alt 누른 채 다른 물건 위 = 가로 · 세로 거리) | 키 누르고 스침 | 중 — 표기와 선 띄움 확인 · 손가락은 스침이 없어 기기 무관 아님 | 없음 — DIST 명령 | [W10] | +| 1-10 | 값 칸 끌어 고치기(스크럽) | Figma(칸 이름 끌기 · 속도 2 · 1 · ½ · ¼) | 칸 이름 좌우로 끔 | 하 — CAD 값은 정확한 숫자가 기본 | 없음 | [W5] | +| 1-11 | 손가락이면 고름 크기 키움 | tldraw(`isCoarsePointer` — 예 48px 대 마우스 32px) | 기기 자동 판별 | 중 | 있음 — 접촉 크기 · 최소 24px · 단추 44px · `any-pointer: coarse` | [W11] | +| 1-12 | 검토 표시층(마크업) | Autodesk Viewer · Docs(화살표 · 말풍선 · 구름 · 손그림 · 글 · 길이 · 각 · 넓이 · 축척 맞춤) | 도면은 그대로 · 위에 표시만 | 중 — 확정 도면에 검토 의견(정본 안 건드림) · 저장 자리 따로 필요 | 없음 — 확정 = 보기 전용 · REVCLOUD 는 편집 명령 | [W12] | +| 1-13 | 웹 앱 · 모바일 앱 나눔(참고) | AutoCAD Web 강의 자료(AU · 강의자 권함) — 큰 iPad = 웹 앱 · 휴대폰 = 모바일 앱 | — | 참고 — 우리는 한 벌(사용자 확정) → 휴대폰 너비 칸 배치를 따로 봐야 함 | — | [W13] | + +## 2. 터치 · 펜 · 태블릿 입력(기기 구분 없이 같은 기능 중심) + +| # | 이름 | 어디 | 어떻게 입력하나 | 임도 쓸모 | 우리 | 출처 | +|---|---|---|---|---|---|---| +| 2-1 | 화면 숫자판 · 값 눌러 고치기 | AutoCAD 모바일(두 점 뒤 길이 · 각 숫자를 누르면 아래 숫자판 · 펜으론 숫자판이 안 떴다는 사용자 글) · DWG FastView(좌표 값 바꿔 점 옮김 · 절대 · 상대 · 극) · 웹 표준 `inputmode="decimal"`(진짜 입력 칸이면 기기 숫자 자판) | 값을 누름 → 숫자판 → Enter | 상 — 자판 없는 기기에서 거리 · 좌표 · 반지름 · 글 높이를 넣는 길 · 원칙의 핵심 빈칸 | 없음 — 손가락 단추 6 뿐(`input-touch.ts:126-133`) · B07 은 아래 명령행 칸(기본 숨김)을 켜면 기기 자판(추정) · B06 상세 · M02 카드 없음 | [W14][W15][W16][W17] | +| 2-2 | 펜 모드(펜이면 손가락은 이동만) | tldraw(`isPenMode` — Apple Pencil 같은 펜을 쓰면 켜짐 · 켜진 동안 손가락 무시) · Excalidraw(펜 첫 감지 때 켜짐 · 손가락 대부분 막음 · 두 손가락 팬만 · 핀치 확대 끔 · 펜 이벤트를 화면 주사율로 줄임) · Morpholio(Pencil Only = 손바닥 거부 · 한 손가락 팬) · Concepts(손가락 동작 따로 정함) | 펜 첫 사용 때 자동 · 끄기 단추 | 상 — 태블릿 + 펜에서 손바닥 톡이 점 · 고름으로 들어가는 것 막음 | 없음 — 펜 = 마우스 길 · 손가락 톡 = 늘 누름 | [W11][W18][W19][W20][W21] | +| 2-3 | 가린 곳 확대 창(callout) | Shift 기법(논문 · 손가락 22mm 위 원 26mm · 작은 표적만 바로 · 큰 표적은 약 1.2초 뒤) · DWG FastView 돋보기 · CAD Pockets 돋보기 · AutoCAD 모바일 돋보기(부록1) | 누르면 손가락 위에 확대 창 → 끌어 맞추고 뗌 | 중~상 — 횡단 상세 끝점 · 교차가 촘촘 | 일부 — 길게(0.45초) = 위 70px 십자선(오프셋 커서) · 확대 창 없음 · 아래 70px 띠 못 닿음(추정) · 부모 돋보기(Z)는 CAD 를 비킴 | [W22][W15][W23] | +| 2-4 | 두 손가락 톡 = 되돌리기 · 세 손가락 = 다시 | Procreate(두 손가락 누른 채 = 연속 되돌리기) · Concepts(기본값 · 동작 바꿀 수 있음) · Morpholio | 화면 아무 데나 톡 | 중 — 단추까지 손 안 옮김 · 핀치와 가르기 = 움직임 없음 + 짧게 | 일부 — 단추 ↶ ↷ 만 · 몸짓 없음 | [W24][W20][W21] | +| 2-5 | 몸짓마다 단추 대안 | Procreate Single Touch Gestures Companion(여러 손가락 몸짓을 한 손가락으로) | 몸짓 = 덤 · 같은 일을 단추로 | 상(원칙) — 기기 무관 · 한 손 · 장갑 낀 현장 | 일부 — 손가락 단추 6 | [W24] | +| 2-6 | 길게 누름 메뉴 | iOS Safari 는 길게 눌러도 `contextmenu` 를 안 보냄 → 자체 타이머 필요 | — | 중 — 우리 길게 누름 = 정밀 고르기라 겹침 → 메뉴는 동작 막대(1-2)로 | 없음 | [W25] | +| 2-7 | 펜 옆 단추 · 지우개 끝 | 웹 표준 Pointer Events(옆 단추 = button 2 · 지우개 = button 5 · buttons 32) | 옆 단추 누른 채 톡 · 펜 뒤집어 문지름 | 중 — 펜만 들고 확정 · 지움 | 일부(추정) — 펜이 마우스 길이라 옆 단추 = 우클릭 = Enter 일 것 · 지우개 처리 없음 | [W26] | +| 2-8 | 펜 띄워 미리보기(hover) | Safari 가 Apple Pencil 띄움을 hover 이벤트로 보냄(WebKit 답변) · 윈도우 펜 | 화면 닿기 전 스냅 표시 | 중 | 있음(추정) — 펜 = 마우스 길 → 스냅 표시 | [W27] | +| 2-9 | 그리다 멈추면 반듯한 도형 | Procreate QuickShape(끝에서 머물면 선 · 도형 · 다른 손가락 톡 = 반듯이) · Miro 스마트 그리기 | 손그림 끝에서 머묾 | 하 — CAD 는 명령으로 정확히 · Miro 오변환 불만 | 없음 | [W24][W28] | +| 2-10 | 문질러 지우기 | Miro(스마트 도형 절반 넘게 문지르면 지움) | 문지름 | 하~중 — 오지움 위험 | 없음 — 🗑 단추 = Delete | [W28] | + +- 펜 + 손가락 동시 몸짓은 iPad 에서 못 씀(펜이 닿는 동안 손가락 이벤트가 안 옴 [W18]) → 「손가락 = 이동 · 펜 = 그리기」 나눔만 현실적 · 근거 = Pen + Touch 논문 「펜은 쓰고 손가락은 다룸」[W29]. +- iOS 는 사용자 누름 안에서 준 초점에만 자판을 띄움 [W17] → 숫자판은 앱 안 단추판이 기기 차이가 적음(기기 자판은 기기마다 꼴 · 높이가 다름). + +## 3. AI · 자연어 입력 + +| # | 이름 | 어디 | 무엇을 넣으면 무엇이 되나 | 임도 쓸모 | 우리 | 사내 · 유출 금지 판정 | 출처 | +|---|---|---|---|---|---|---|---| +| 3-1 | Autodesk Assistant | AutoCAD 2026(물음 답) → 2027(표준 파일 대조 · 자연어로 고르기 · 도면 조회 = 층 목록 · 블록 쓰임 · 프롬프트 모음 · 대화 기록 · MCP) → 2027.1(고른 것 알아봄 · Count · Query 넓힘 · 맞춤 팁) | 채팅 글(음성 = 업체 글) → 답 · 고름 · 보고 | 중 — 「층 위반 찾기」 · 「덮어쓴 치수 찾기」 같은 검사 | 없음 | ✕ 클라우드 · Autodesk 계정 개인정보 설정 · 일부 기술 미리보기 → 그대로 불가. 「자연어로 고르기 · 조회」는 규칙 거르개(층 · 종류 · 글 내용 · 덮어쓴 치수)로 대신 가능 | [W30][W31][W32] | +| 3-2 | Markup Import · Assist | AutoCAD 2023~2026 | PDF · 그림의 붉은 표시 → 글 · 지시선 · 구름 | 하 | 없음 | ✕ 클라우드 처리(도움말 명시) · LT · 웹 · 모바일 없음 | [W33] | +| 3-3 | Smart Blocks 감지 · 바꾸기 | AutoCAD 2025~2026.1.1 | 반복 모양 → 블록 후보 | 하 | 없음 | ✕ 도면 기하를 Autodesk 기계학습 서비스로 보냄 | [W34] | +| 3-4 | BricsCAD Assist | V26.2 | 채팅 → 도움말 · 방법 안내(명령 실행 안 함) | 하 | 없음 | △ 클라우드 여부 확인 못 함 · 같은 일 = 사내 도움말 글 찾기(모델 없이) | [W35] | +| 3-5 | ARES A3 | ARES 2024~2027 | 자연어 → 고르기 · 만들기 · 고치기(회전 · 대칭 · 축척 · 해치 · 신축 · 지움 · 블록) · Kudo 음성 | 중 | 없음 | ✕ OpenAI 기술 · 클라우드 · 하루 대화 수 제한(코덱스 2차와 겹침 · 판정만) | [W36] | +| 3-6 | Onshape AI Advisor | 2025 | 물음 → 자체 도움말 · 강좌 · 블로그 근거 답 + 번호 출처 | 하 | 없음 | ✕ 클라우드 · 기업판 기본 꺼짐 · 정부판 없음 · 「근거 단 도움말」 생각만 사내 글 찾기로 | [W37] | +| 3-7 | 다음 명령 예측 | Siemens NX(2019 · 하는 일 보고 다음 명령을 작은 판에) · Solid Edge 2021(커서 곁) | 입력 없음 — 쓰는 순서 학습 | 중 — 임도 손질은 순서가 잦음(간격띄우기 → 자르기 등 · 짐작) | 일부 — 빈 Enter 마지막 명령 · 자주 쓴 순 없음(335 D9) | ○ 내 명령 기록에서 「앞 → 다음」 짝 세기만으로 가능 · 모델 없음 · 계정 pref | [W38][W39] | +| 3-8 | 크롬 내장 AI | Gemini Nano · Prompt API | 브라우저 안 작은 모델 | — | 없음 | ✕ 윈도우 · 맥 · 리눅스 · 크롬북 Plus 만(안드로이드 · iOS 안 됨) · 디스크 22GB · GPU 4GB 넘게 또는 RAM 16GB → 기기 무관 원칙 어긋남 | [W40] | +| 3-9 | 음성 명령 | 브라우저 Web Speech API | 말 → 글 → 명령 | 하 | 없음 | ✕ 크롬 기본 = 서버 인식(유출) · 기기 안 처리(`processLocally`)는 실험 · 엣지 Canary · Dev 만 한국어 로컬 | [W41][W42] | +| 3-10 | 사내 서버 작은 모델 + 도구 부르기 | Ollama 도구 부르기(Qwen3 · Llama 3.1 등) · Qwen3 0.6~8B(Apache 2.0 · 119 언어에 한국어) · HyperCLOVA X SEED 0.5 · 1.5 · 3B(상업 무료 · 한국어 강조) · FunctionGemma 270M(도구 부르기 전용 · 미세조정 전제) | 「고른 글 높이 2.5 로」 → 모델이 명령 이름 + 값(JSON) → 화면이 미리보기 → 사람이 [확인] | 중 — 명령 이름 모르는 초보 · 일괄 고치기 | 없음 | ○ 사내 서버 안에서 끝남 → 유출 없음 · 단 한국어 정확도 · 서버 GPU · 라이선스 원문 · 잘못 고침 막는 확인 단계 시험 필요 | [W43][W44][W45][W46] | +| 3-11 | 규칙 기반 한글 명령 찾기 | 우리 덧 — AutoCAD 동의어 · 자동 고침(부록1 3장)과 같은 계열 | 「간격띄우기」 · 초성 「ㄱㄱㄸ」 · 한글 자판 그대로 친 「ㅐㄹㄹㄴㄷㅅ」 → OFFSET | 상 — 한글 화면 · 초보 · 명령 찾기 창(1-1)과 한 벌 | 없음 — 내 별칭 영문 · 숫자만 · 335 D13(명령행 한글 자판) | ○ 모델 · 네트워크 없음 · 표 한 벌 | (판단) | + +## 4. 효율 근거 → 우리 차례에 주는 뜻 + +1. 명령은 소수만 씀 — AutoCAD CIP 1.6만 명 · 6개월 · 4천만 건: 9할이 90 개 미만 · 4천 명 표본에서 가장 많은 무리 31~40 개 · 명령 사이 95% 가 1분 안 [E1] · 가장 많이 쓴 명령 ERASE · 가장 많은 순서 ERASE→MOVE→COPY(Autodesk Research CIP · 2008 블로그 인용) [E2] · CIP 누적 6천만 건 [E3]. + → 우리 153 명령 중 지움 · 옮김 · 복사 · 간격띄우기 · 자르기 · 글 고치기 같은 30~40 개를 마우스 · 손가락 · 펜 어디서나 두 번 누름 안에 닿게(동작 막대 1-2 · 명령 찾기 1-1)가 먼저 · 나머지는 찾기 창이면 충분 · 「최근 셋」(FigJam)은 ERASE→MOVE→COPY 같은 짧은 되풀이에 맞음. +2. 숙련자도 빠른 길로 잘 안 넘어감 — CAD 숙련자가 묶어 고르기 · 되풀이 대신 손 제도 같은 순서를 씀 · 효율 전략이면 한 사용자 모형에서 실행 시간 약 40% 단축(둘째 도형 42%) [E4] · Word 숙련자 251 명 대부분 단축키 드묾 · 단축키가 가장 빠름 [E5] · 키를 누르는 동안 단축키를 단추 위에 겹쳐 보이면 더 쓰고 더 빨라짐(ExposeHK) [E6] · 메뉴에 소리 알림 · 메뉴 막기로 단축키 학습 빨라짐(2차 요약) [E19] · 표식 메뉴는 메뉴보다 약 3.5 배(현장 2 명) [E7] · 빠른 길은 있어도 안 쓰이는 게 흔함(개관) [E8]. + → 기능을 더하기보다 「있는 빠른 길을 보이게」가 싸고 큼 — 명령 찾기 창에 별칭 같이 보임 · 리본 풍선은 지금 기본 별칭 첫째만(`ribbon/Ribbon.tsx:34`) → 내 별칭도 · 335 고르기 묶음(상자로 더하기 · 앞 선택 P = 묶어 고르기 전략)이 앞 차례인 근거. +3. 키 수준 모형(KLM) 표준값 — 키 0.2초(숙련 0.08 ~ 낯선 자판 1.2) · 가리키기 1.1초 · 손 옮김 0.4초 · 생각 1.35초 [E9]. + → 「뭘 누르지?」 생각 한 번 = 키 예닐곱 번 → 단계마다 쓸 값 · 선택지를 커서 곁에 보이는 안내(335 9-2 · D8)가 키 줄이기보다 큼 · 손가락 기기는 숫자 하나마다 단추판을 가리켜야 → 숫자판은 숫자 · 소수점 · 지움 · Enter · `@` · `<` 를 한 판에(판 오가기 = 가리키기 추가). +4. 손가락 정밀 — 맨손가락 작은 표적(2.6mm) 오류 최대 81% · 오프셋 · Shift 4~14% · 오프셋 커서는 손가락으로 될 큰 표적에서 1.57 배 느림 · 필요할 때만 확대 창(Shift)이 오프셋보다 28~32% 빠름 [E10] · 손가락 표적 9.2~9.6mm [E11] · 웹 접근성 최소 24×24 CSS px [E12] · 받침(안정) 있으면 손가락도 4px 표적까지 마우스와 차이 없음 [E13]. + → 우리 「길게 눌러 오프셋」은 맞는 갈래 · 큰 표적 톡 = 바로(지금대로) · 다음 걸음 = 확대 창(2-3) · 손가락 단추 44px · 고름 최소 24px 은 기준 안. +5. 지연 — 끌기 지연 25ms 넘으면 성능 떨어짐 · 손가락 화면에서 알아채는 차이 끌기 11ms · 톡 69ms [E14] · 75ms 부터 움직임 시간 16%↑ · 225ms 64%↑ · 오류 214%↑ [E15] · 화면 이동 · 확대 지연은 앱을 못 쓰게 하나 새 결과 1초는 견딤 · 500ms 더하면 탐색 줄어듦 [E16] · 웹 INP 좋음 200ms 이하(RAIL 100ms) [E17] · 「두 번 톡」을 가르려 기다리면 한 번 톡이 150~500ms 늦어짐(iOS 250 · 윈도우 500 · iOS WebKit 350ms) [E14] · 펜 잉크 지연은 50ms 안팎에서 알아챔(비잉크 2~7ms) [E18]. + → 미리보기 · 스냅 · 팬 · 그립 끌기는 브라우저 안에서 한 화면 틀(16ms) 안(지금 원칙 그대로) · 서버 계산은 [저장] · [확정] 때만 · 손가락 「두 번 톡」 몸짓은 넣지 않음(점 찍기 톡이 늦어짐) · 두 손가락 톡(2-4)은 두 번째 손가락 판별만이라 기다림 짧음 · 펜 그리기 매끄럽게(`getCoalescedEvents` · `getPredictedEvents` · 크롬 `desynchronized`) [E20]는 점 찍는 CAD 엔 낮은 차례. +6. 펜 + 손가락 나눔 — 펜은 쓰고 손가락은 다룸 [W29] · iPad 는 펜이 닿으면 손가락 이벤트를 안 보냄 [W18] · Excalidraw 는 손바닥 오작동 때문에 펜 모드에서 핀치를 껐음 [W19]. + → 펜 모드(2-2)는 근거 있는 기본값 · 켜는 조건 = 펜 첫 사용(자동) · 끄는 단추는 손가락 단추줄에. +7. 웹 CAD 전용 지연 · KLM 측정 · 터치 KLM(TLM) 값 = 못 찾음 → 위 일반 HCI 근거로 대신. + +근거로 본 보강 차례(335 차례에 끼움): + +- ⓪ 335 흠 12(Ctrl+C 글자 쌓임 · 확정 도면 새는 고침 · 그립 밀림) — 335 그대로. +- ① 기기 무관 셋 — 화면 숫자판 · 명령 찾기 창 · 동작 막대(B07 · B06 · M02 세 곳 · 마우스에도 같은 판) — 사용자 확정 원칙 · 위 1 · 2 · 3. +- ② 335 고르기 · 극 추적 · Shift 직교 — 위 2(묶어 고르기 전략 = 약 40%). +- ③ 펜 모드 · 확대 창 · 두 손가락 되돌리기 · 몸짓마다 단추 — 위 4 · 6. +- ④ 고른 것 맞춤 · 가장자리 이동 · 섞인 값 식 · 같은 거리 복제 · Alt 스냅 끔 · 큰 걸음 미세 이동 — 위 1(고치기 명령 비중). +- ⑤ 한글 명령 찾기 · 다음 명령 후보 · 리본 풍선에 내 별칭 — 위 2 · 3. +- ⑥ 사내 작은 모델 + 도구 부르기 — 시험만 · 3-10. + +## 5. 확인 못 함 + +- AutoCAD Web 터치 도움말 · Autodesk 블로그 · Autodesk Assistant 소개 쪽 · 터치 몸짓 글 = 403 · 503 → 웹 앱 터치는 2019 강의 자료 · 검색 요약만 · Assistant 의 자료 처리 · 오프라인 여부 원문 못 봄(계정 개인정보 설정 언급만). +- Figma Ctrl+D 되풀이 = 공식 글은 「Alt 끌어 복제 뒤 Ctrl+D」 꼴만 확인 · 회전 섞이면 어긋난다는 건 사용자 글 · Figma 거리 빨간 선 · 액션 메뉴의 최근 항목은 글에 없음(FigJam 만 최근 셋). +- Excalidraw 지우개 끌기 동작 · 화살표 미세 이동 값 = 공식 근거 못 찾음 · Miro 스마트 그리기 쪽은 403 → 검색 요약. +- Morpholio 기능 = 사용자 올린 안내서(2차) · DWG FastView · CAD Pockets = 앱 장터 · 목록 글. +- BricsCAD Assist · Onshape AI Advisor 클라우드 여부 · 자료 처리 = 문서에 없음. +- 사내 모델 — 한국어 명령 해석 정확도 · FunctionGemma 라이선스 · 한국어 · EXAONE 4.0 상업 조건 · HyperCLOVA X SEED 「제한적 예외」 내용 = 원문 · 시험 필요. +- 우리 쪽 추정 — 펜 옆 단추 = Enter · 펜 띄움 스냅 표시 · 아래 70px 띠 · B07 명령행 칸 기기 자판 · iPad 글(TEXT) 단계 한글 받개 초점(`ime-sink.ts:93-101` 이 누름 안에서 불리는지) = 화면 실측 필요. +- 숫자 크기 — 표식 메뉴 3.5 배 = 현장 2 명 · CAD 40% = 한 사용자 모형 · ERASE 최다 = 2008 블로그의 2차 인용 · 펜 잉크 50ms = 검색 요약 → 방향만 참고. +- 웹 CAD 전용 지연 연구 · CAD 전용 KLM 측정 · TLM 연산자 값 = 못 찾음. + +## 출처 + +- [W1] Figma 액션 메뉴 — https://help.figma.com/hc/en-us/articles/23570416033943-Use-the-actions-menu-in-Figma-Design +- [W2] FigJam 빠른 실행(화면 낭독기 글 안) — https://help.figma.com/hc/en-us/articles/14477051168791-Use-FigJam-with-a-screen-reader +- [W3] Excalidraw 단축키(도움말 창 원본) — https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/HelpDialog.tsx · 시작 안내 — https://plus.excalidraw.com/how-to-start +- [W4] Miro 도구 막대 · 고른 것 메뉴 — https://help.miro.com/hc/en-us/articles/360017730553-Toolbars +- [W5] Figma 위치 · 크기(미세 이동 · 식 · Mixed · 스크럽) — https://help.figma.com/hc/en-us/articles/360039956914-Adjust-alignment-dimensions-rotation-and-position +- [W6] Figma 복사 · 붙여넣기(Alt 끌기 + Ctrl+D) — https://help.figma.com/hc/en-us/articles/4409078832791-Copy-and-paste-objects +- [W7] Figma 스마트 선택(간격 손잡이) — https://help.figma.com/hc/en-us/articles/360040450233-Arrange-layers-with-Smart-selection +- [W8] draw.io 수정키 · 맞춤 도구 — https://www.drawio.com/docs/reference/shortcuts/modifier-shortcuts-in-diagrams/ · https://www.drawio.com/docs/manual/editor/alignment-tools/ +- [W9] tldraw 가장자리 이동 — https://tldraw.dev/sdk-features/edge-scrolling +- [W10] Figma 거리 재기 — https://help.figma.com/hc/en-us/articles/360039956974-Measure-distances-between-layers +- [W11] tldraw 인스턴스 상태(isPenMode · isCoarsePointer · 도구 잠금) — https://tldraw.dev/sdk-features/instance-state +- [W12] Autodesk Viewer 도구(마크업 · 재기) — https://knowledge.autodesk.com/search-result/caas/CloudHelp/cloudhelp/ENU/ADSKVIEWER-Help/files/ADSKVIEWER-Help-AutodeskViewerTools-html-html.html +- [W13] AU 강의 자료 CS317864(AutoCAD Web · 태블릿) — https://static.au-uw2-prd.autodesk.com/Class_Handout_CS317864_Shaun_Bryant.pdf +- [W14] AU 강의 자료 AS323988(모바일 숫자판 · 돋보기) — https://static.au-uw2-stg.autodesk.com/Class_Handout_AS323988_Jim_LaPier.pdf · 펜 숫자판 글 — https://forums.autodesk.com/t5/autocad-on-mobile-forum/length-and-keypad-issue/m-p/9427781 +- [W15] DWG FastView(앱 장터) — https://apps.apple.com/us/app/-/id456933691 +- [W16] MDN inputmode — https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/inputmode +- [W17] WebKit 195884(누름 안 초점만 자판) — https://bugs.webkit.org/show_bug.cgi?id=195884 +- [W18] tldraw 블로그 A touchy subject — https://tldraw.dev/blog/a-touchy-subject +- [W19] Excalidraw 3년(펜 모드) — https://plus.excalidraw.com/blog/year-three +- [W20] Concepts 설명서 — https://concepts.app/en/manual +- [W21] Morpholio Trace 안내서(2차) — https://www.scribd.com/document/562575563/TracePro-HowTo +- [W22] Vogel · Baudisch, Shift(CHI 2007) — https://projects.csail.mit.edu/hci-reading/papers/chi2007-vogel.pdf +- [W23] CAD Pockets(목록) — https://www.cesdb.com/cad-pockets.html +- [W24] Procreate 몸짓 — https://help.procreate.com/procreate/handbook/interface-gestures/gestures +- [W25] WebKit 213953(길게 눌러 contextmenu 없음) — https://bugs.webkit.org/show_bug.cgi?id=213953 +- [W26] MDN Pointer events(펜 단추 값) — https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events +- [W27] WebKit 296943(Pencil hover) — https://bugs.webkit.org/show_bug.cgi?id=296943 +- [W28] Miro 스마트 그리기 — https://help.miro.com/hc/articles/360017572014 +- [W29] Hinckley 외, Pen + Touch = New Tools(UIST 2010) — https://www.microsoft.com/en-us/research/publication/pen-touch-new-tools/ +- [W30] AutoCAD 2027 새 기능 — Autodesk Assistant — https://help.autodesk.com/cloudhelp/2027/ENU/AutoCAD-WhatsNew/files/GUID-99E2D8F7-D4D9-4B3A-9E57-7B73D6BB12AD.htm +- [W31] Autodesk Assistant 소개(403 · 검색 요약) — https://www.autodesk.com/solutions/autodesk-ai/autodesk-assistant +- [W32] AutoCAD 2027.1 블로그(403 · 검색 요약) — https://www.autodesk.com/blogs/autocad/autocad-2027-1/ +- [W33] Markup Import · Assist 개요 · 자료 처리 — https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-Platform/files/GUID-0CD6B2CE-EAF0-478B-B39B-F4821F9DC91D.htm · https://help.autodesk.com/cloudhelp/2026/ENU/AutoCAD-Platform/files/GUID-46693B8E-FDC8-41F2-8E11-C96B718D815D.htm +- [W34] Smart Blocks 물체 감지 — https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-WhatsNew/files/GUID-25BD5FB7-119A-42A8-B1C1-62BB812A3F4F.htm +- [W35] BricsCAD V26 — https://bricscad.octave.com/bricscad/v26 +- [W36] ARES A3 — https://www.graebert.com/blog/general-news/introducing-ares-ai-assist-a3-your-personal-cad-assistant/ · ARES 2027 — https://architosh.com/2026/04/graebert-releases-ares-2027-ai-push-and-forma-integration/ +- [W37] Onshape AI Advisor — https://cad.onshape.com/help/Content/ai_advisor.htm +- [W38] Siemens NX 명령 예측 — https://develop3d.com/develop3d-blog/siemens-adds-machine-learning-prediction-productivity-nx/ +- [W39] Solid Edge 2021 적응 화면 — https://www.engineering.com/solid-edge-2021-adds-subdivision-modeling-an-ai-ui-and-more/ +- [W40] 크롬 내장 AI 시작 — https://developer.chrome.com/docs/ai/get-started +- [W41] MDN Web Speech · processLocally — https://developer.mozilla.org/docs/Web/API/Web_Speech_API/Using_the_Web_Speech_API · https://developer.mozilla.org/docs/Web/API/SpeechRecognition/processLocally +- [W42] 엣지 로컬 음성 인식 — https://learn.microsoft.com/en-us/microsoft-edge/web-platform/speech-recognition-api +- [W43] Ollama 도구 부르기 — https://ollama.com/blog/tool-support · https://ollama.com/blog/streaming-tool +- [W44] Qwen3 — https://qwenlm.github.io/blog/qwen3/ +- [W45] HyperCLOVA X SEED — https://clova.ai/en/tech-blog/sowing-the-seeds-in-the-ai-ecosystem-introducing-hyperclova-x-seed-a-commercial-open-source-ai +- [W46] FunctionGemma — https://ollama.com/library/functiongemma +- [E1] Li 외, 명령 추천(TOCHI 2011 · CIP 통계) — https://www.tovigrossman.com/papers/2011%20TOCHI%20cc.pdf +- [E2] ERASE 최다 명령(2008 · Autodesk Research 인용) — https://budweiser.cadstudio.cz/2008/09/erase-most-frequent-autocad-command.html +- [E3] Autodesk Research 명령 쓰임 그림 — https://www.research.autodesk.com/projects/command-usage-arc-diagrams/ +- [E4] Bhavnani · John, From Sufficient to Efficient Usage(CHI 97) — https://chi1997.acm.org/proceedings/paper/skb.html +- [E5] Lane 외, Hidden Costs of GUIs(2005 · 서지) — https://dblp.dagstuhl.de/pid/65/2289.html +- [E6] Malacria 외, ExposeHK(CHI 2013) — https://hal.archives-ouvertes.fr/hal-01894253 +- [E7] Kurtenbach · Buxton, 표식 메뉴(CHI 94) — https://www.research.autodesk.com/publications/user-learning-and-performance-with-marking-menus/ +- [E8] Cockburn 외, 초보 → 숙련 넘어가기(2014) — https://doi.org/10.1145/2659796 +- [E9] KLM 연산자 값 — https://syntagm.co.uk/design/klmcalc.shtml · https://cs.auckland.ac.nz/courses/compsci345s1c/lectures/2006/Lecture%2019%20Cognitive%20models%202.pdf +- [E10] = [W22] +- [E11] Parhi 외, 엄지 표적 크기(MobileHCI 2006) — https://dx.doi.org/10.1145/1152215.1152260 +- [E12] WCAG 2.5.8 표적 크기 최소 — https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html +- [E13] Sears · Shneiderman, 정밀 터치 대 마우스(1991) — https://www.cs.umd.edu/users/ben/papers/Sears1991High.pdf +- [E14] Nishida 외, 한 번 톡 지연(2024 · Jota 2013 · Deber 2015 인용) — https://arxiv.org/pdf/2408.02525 +- [E15] MacKenzie · Ware, 지연과 성능(INTERCHI 93) — https://www.yorku.ca/mack/CHI93b.html +- [E16] Liu · Heer, 지연과 탐색(InfoVis 2014) — https://idl.uw.edu/papers/latency · 후속 요약 — https://arxiv.org/pdf/2507.19690 +- [E17] web.dev INP — https://web.dev/articles/inp +- [E18] Annett 외, 잉크 지연(GI 2014 · 검색 요약) — https://webdocs.cs.ualberta.ca/~wfb/publications/C-2014-GI-Latency.pdf +- [E19] Grossman 외, 단축키 학습(CHI 2007) — https://www.dgp.toronto.edu/~ravin/papers/chi2007_learninghotkeys.pdf +- [E20] MDN getPredictedEvents · 크롬 desynchronized — https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent/getPredictedEvents · https://developer.chrome.com/blog/desynchronized + diff --git a/docs/raw/조사/2026-10-15_335_재조사_CAD입력.md b/docs/raw/조사/2026-10-15_335_재조사_CAD입력.md new file mode 100644 index 000000000..adb47a706 --- /dev/null +++ b/docs/raw/조사/2026-10-15_335_재조사_CAD입력.md @@ -0,0 +1,257 @@ +# CAD 입력 보강 — 재조사 (요구 226-3 · PLAN 335-3) + +재료 = 코덱스 1 · 2차(해외 CAD 16 종) · 안티그래비티(국내 리습 · 국산 CAD · 측량 28 가지) + 코덱스 3차 출처 대조 · B4 토목측량 입력 · B5 웹 · 터치 · 펜 · AI · 효율 근거 · 앞 검토 335(본문 · 부록1 · 부록2) · 코드 읽기(`main_laptop_1` b7e727863 · 실행 · 화면 실측 없음) · 원문 = 같은 폴더 `2026-10-15_335_부록3_바깥조사원문.md`. + +- 기호 — 쓸모 상 · 중 · 하(임도 도면 손질 기준 · 짐작 포함) · 우리 있음 · 일부 · 없음 · 크기 작(반나절) · 중(하루~이틀) · 큼(사흘 넘음 · 335 와 같은 자) · 근거 공식(공식 문서 · 표준 · 논문) · 대조(코덱스 3차가 출처 확인 · 「일부」는 바른 내용으로 고쳐 씀) · 약함(출처 못 찾음 · 제3자 · 앱 장터 · 카페). +- 줄 이름 — 335 번호(1-4 · D2 · H1 등) · B4 번호(P · L · C · T · S · M · K) · B5 번호(1-1 · 2-1 · 3-1 · E1 · W29) · 코덱스 X1(1차) · X2(2차) · 안티그래비티 A1~A28. +- 경로 줄임 — `src/` = `B07_DesignDetail/openwebcad/src/` · 별표2 = `resources/knowledge/original/법률/산림자원의 조성 및 관리에 관한 법률 시행규칙/별표/별표2_산림관리기반시설의 설계 및 시설기준(제5조제1항 관련).md` · 그 밖 페이지 파일은 저장소 뿌리부터. + +## 0. 결론 요약 + +1. 바깥 조사 셋에서 앞 71 가지에 없던 입력 방법 53 줄을 새로 추림(겹침은 한 줄로 합침) — 쓸모 상 20 · 중 29 · 하 3 · 뺌 1 · 우리 없음 38 · 일부 15. +2. 가장 큰 새 갈래 = 「실제 값으로 넣기」 — 우리 CAD 는 종이 위 mm 만 앎 → 「No.12+5 좌 3 m」 · 「표고 123.45」 · 「경사 8 %」를 칠 수 없고 1/100 · 1/200 · 1/1200 을 손으로 바꿔 쳐야 함. +3. 새 흠 둘(코드로 확인) — H13 사람이 넣는 치수 · 거리 · 면적 숫자가 종이 길이로 나옴(횡단 3 m → 「30」 · 평면 3 m → 「2.5」 · 면적 100 배) · 그 숫자를 고칠 칸도 없음 / H14 B07 횡단도는 도로 왼쪽을 종이 오른쪽에 그림 → B06 화면과 좌우가 거울(어느 쪽이 맞는지 사용자 확인). +4. 좋은 소식 — 서버가 횡단 칸마다 축척 · 원점 · 기준 높이를 이미 만들어 페이지에 보냄 → CAD 안으로만 넘기면 횡단부터 실제 값 입력이 작게 열림. +5. 둘째 갈래 = 「어느 기기든 같은 입력」 — 태블릿 · 휴대폰엔 숫자 · 명령을 넣을 길이 없음 → 화면 숫자판 · 명령 찾기 창 · 고른 것 곁 단추 막대로 마우스 · 손가락 · 펜 모두 같은 판. +6. 셋째 갈래 = 「임도 표기 도구」 — 측점 · 표고 · 경사 글 · 길이 합 · 숫자 한꺼번에 셈 · 번호 매김 · 번호 늘려 복사(국내 리습에 흔함 · 출처는 반쯤만 확인). +7. 안티그래비티 28 가지 = 출처 확인 2 · 일부 14 · 못 찾음 12 → 못 찾은 것은 「근거 약함」으로만 둠 · 재료 속 도메인 숫자(종단 13~18 % · 소단 5 m 등)는 버리고 지식DB 값으로 바꿈. +8. AI — 상용 CAD AI 는 모두 바깥 서버라 자료 유출 금지 조건에서 못 씀 · 사내에서 되는 길 = 한글 명령 찾기(모델 없음) · 다음 명령 후보 · 사내 작은 모델(시험만). +9. 묶음 셋 더함 — ⑧ 실제 값 입력 · ⑨ 기기 무관 입력 · ⑩ 임도 표기 도구 · 새 차례 = ⓪ 흠(+H13) → ⑨ 앞몫 → ③ 고르기 → ⑧ 횡단 앞몫 → ② 스냅 → 나머지(앞 다섯 어림 21~24 일). +10. 정할 것 아홉 더함(15~23) — 먼저 = 횡단 좌우(17) · 오프셋 부호(15) · 측점 꼴(16) · 치수 실값 범위(18). + +## 1. 새로 찾은 것 표 + +- 335 의 71 줄 · D1~D15 와 같은 것은 줄로 안 셈(1-6 묶음 줄) · 일부만 겹치면 「같음 계열: …」 + 덧만 적음 · 재료끼리 겹침 = 한 줄에 출처를 모두 적음. +- 크기 = 문맥(⑧ 축척 · 원점 · 노선)을 넘긴 뒤 기준 · 코드 읽기 어림. + +### 1-1. 실제 값 입력 — 측점 · 오프셋 · 표고 · 경사 · 단위 (⑧) + +| # | 이름 | 어디 | 어떻게 입력하나 | 쓸모 | 우리 | 크기 | 묶음 | 근거 | +|---|---|---|---|---|---|---|---|---| +| N01 | 측점 + 오프셋으로 점 | Civil 3D `'SO` · OpenRoads Civil AccuDraw · TBC Offset Line Snap · 12d · GEOPAK(B4 P1~P5) · 같음: A22(INFRACAD 「STA 3+15.5, L 4.0」) | 점 묻는 단계에서 노선(폴리선) 고름 → 측점 → 오프셋(치거나 찍기) · 칸 잠금 = 같은 값 되풀이 · 잠긴 칸에 Enter 또 = 그만큼 더함 · TBC 는 노선 객체 없이 폴리선만으로 됨 | 상 — 구조물 · 기호를 「No.12+5 좌 3 m」 자리에 바로 | 없음 | 중 | ⑧ 평면 | 공식 · A22 약함 | +| N02 | 측점 · 오프셋 조회 | 12d · Carlson · TBC(B4 P6) · 드림플러스 GST(A2) | 점 찍음 → 노선 기준 측점 · 수직 오프셋 보고 | 상 — 손질한 기호 · 글 자리를 측점으로 대조 | 일부 — `ID` = 종이 mm | 작 | ⑧ | 공식 · 대조 | +| N03 | 측점 꼴 「번호 + 나머지」로 치기 | Trimble Access Station index · Civil 3D(`+` 없이 숫자)(B4 P7) | `No.12+5` · `12+5` · `245`(m) 다 받음 · 표시는 한 꼴 | 상 — 지식DB 꼴 「No.11+13」(`resources/knowledge/original/산림과임업기술(임도)/2. 임도/5. 설계.md:157`) · 측점 간격 20 m(별표2 `:25`) | 없음 — 글만(평면 `No.n+d` · 제목 「2+0.0」 · 정보 「STA.0+050.000」) | 작 | ⑧ | 공식 | +| N04 | 측점으로 화면 이동 | Civil 3D `'ZTP`(B4 P8) | 측점 쳐서 그 자리로 확대 | 중 — 긴 평면 · 종단 | 없음 — ZOOM = 범위만 | 작 | ⑧ | 공식 | +| N05 | 간격 + 오프셋 되풀이 점 · 두 점 임시 기준선 | Carlson Locate Template Points · TBC Create Points at Station Offsets(B4 P9 · P10) | 측점 간격 + 오프셋 되풀이 · 두 점으로 임시 기준선 → 측점 · 오프셋 | 중 — 표지 되풀이 · 비스듬한 구조물 축 | 일부 — MEASURE · DIVIDE = 선 위 간격 점만 | 중 | ⑧ 뒤 | 공식 | +| N06 | 측점 · 표고로 점(종단) | Civil 3D `'PSE` · `'STAE`(B4 L1) · Profile Toolset 띠(L3) · OpenRoads 종단 칸(L4) | 뷰 고름 → 측점 → 표고(치거나 찍기) · 뷰가 가로 · 세로 축척을 앎 | 상 — 종단 세로 5 배(가로 1/1000 · 세로 1/200) 손 환산 없앰 | 없음 | 중 | ⑧ 종단 | 공식 | +| N07 | 경사 % + 측점 · 길이로 선(종단) | Civil 3D `'PGS` · `'PGL`(B4 L2) · 같음: A25(구배율 입력) | 앞 점 → 경사 → 측점(또는 길이) 되풀이 · Esc 끝 | 상 — 계획선 · 보조선을 경사 % 로(기준 = 5-2) | 없음 — 각 = 종이 각 | 중 | ⑧ 종단 | 공식 · A25 약함 | +| N08 | 오프셋 · 표고로 점 · 표 입력(횡단) | Carlson Input-Edit Section File(B4 C1) | 오프셋 · 표고 침(왼쪽 부호 = 정할 것 15) · 앞 점에서 경사 · 비율 치면 표고 계산 · 칸 식 | 상 — 지반선 · 계획선 · 비탈 끝을 m 값으로 | 없음 — 그립 = 종이 mm | 중 | ⑧ 횡단 | 공식 | +| N09 | 고저 편집 표(점마다 측점 · 표고 · 앞뒤 경사) | Civil 3D Elevation Editor(B4 L5) | 표 줄 고침 → 선이 따라 바뀜 · 여러 줄 경사 한 번 | 중 | 없음 — 특성 칸 길이 = 읽기만 | 큼 | ⑧ 뒤 | 공식 | +| N10 | 커서 자리 실값 읽기 | Carlson · GEOPAK · OpenRoads(B4 C3) | 커서만 움직이면 오프셋 · 표고 / 측점 · 표고 / 측점 · 오프셋 | 상 — 모든 손질에 바로 | 없음 — 커서 곁 = 종이 mm 좌표 | 작 | ⑧ | 공식 | +| N11 | 실거리 단위 붙여 치기 `3m` | TBC COGO 칸(B4 C5) | `3m` → 축척대로(횡단 30 mm · 평면 2.5 mm) · 칸 안 사칙 · 점 표고 집기 | 상 | 없음 — 단위 붙은 숫자 없음 · 식 = 335 1-4 | 작 | ⑧ | 공식 | +| N12 | 북 · 동 좌표 · 방위각 · 편각 + 거리 | Civil 3D `'NE` `'BD` `'ZD` `'DD`(B4 S1 · S2) · 같음: A23(방위각 도분초) | 북 · 동 값으로 점(국내 X = 북 · Y = 동 → CAD X · Y 와 거꾸로) · 편각 = IP 교각 손버릇 | 중 — 기준점 · 구조물 TM 자리 · 교각 부호 = 지식DB 좌측각 (+)(`…/2. 임도/4. 노선측량.md:322`) | 일부 — 극좌표 = 동 0° 반시계 십진 도만 · 평면 원점 없음 | 중 | ⑧ 평면 뒤 | 공식 · A23 약함 | +| N13 | 치수 측정 축척(DIMLFAC) | AutoCAD 치수 스타일(B4 T4) | 측정값 × 계수 = 치수 글 | 상 — H13 을 바로 풂 | 없음(H13) | 중 | ⓪ · ⑧ | 공식 | + +### 1-2. 스냅 · 값 · 끌기 손버릇 덧 (① ② ④ ⑤) + +| # | 이름 | 어디 | 어떻게 입력하나 | 쓸모 | 우리 | 크기 | 묶음 | 근거 | +|---|---|---|---|---|---|---|---|---| +| N14 | 비탈 꼴 바꿔 받기(% · 1:n · n:1 · 각) | Civil 3D 비탈 칸(제3자) · Trimble Access 꼴(B4 C4) · 같음 계열: 335 D2 `<1:1.5` · A24(Slope Lock) | 한 칸이 `25%` · `1:1.5` · `1.5:1` · 각 모두 받음 · 표시 꼴 설정 | 중 — 비탈 기준 = 5-2 | 없음 — D2 제안뿐 | 작 | ②(D2 와 한 함수 · ⑧ 공용) | 공식 일부 · 제3자 | +| N15 | 끌기 고정 꼴(비탈 따라 · 높이만 · 오프셋만) | Carlson(B4 C2) | 점 끌 때 Hold Slope · Elevation · Offset | 중 — 비탈 기울기 그대로 끝만 늘림 | 일부 — 그립 끌기 · 축 고정 = 335 D3 제안 | 중 | ④ | 공식 | +| N16 | 객체 길이 · 반지름 집기 | Civil 3D `AmL` · `AmR`(B4 S4) | 길이 묻는 단계에서 객체 고르면 그 값 | 중 — 같은 길이 · 반지름 되풀이 | 없음 | 작 | ① | 공식 | +| N17 | 칸 값 증감 `X3+` | Archicad Tracker(X2) · 같음 계열: Figma `+10`(B5 1-3) · 335 1-4 식 | 지금 좌표 · 값에 더하고 뺌 · `/` 로 XY ↔ 거리 · 각 | 중 | 없음 | 작 | ① | 공식 | +| N18 | 나침반을 고른 요소 방향으로 · Enter 스마트 잠금 · 가 스냅 | MicroStation AccuDraw `RE` · Enter · Tentative(B4 M2 · M4 · M5) | 요소 하나 찍어 축 맞춤 · 축 가까우면 0 잠금 · 가 스냅 뒤 확정 | 중 — 노선 · 비탈 방향 기준 앞 · 옆 거리 | 없음 — UCS 없음 · 가 스냅 = 335 2-4 계열 | 중 | ② 뒤 | 공식 | +| N19 | 확대 따라 바뀌는 스냅 눈금 | BricsCAD 적응형 스냅 눈금자(X1) | 확대 수준 따라 격자 · 스냅 간격 자동 · 눈금자에서 고름 | 중 | 없음 — 그리드 = 화면 무늬(H10) | 중 | ②(정할 것 10) | 공식 | +| N20 | 손잡이 위젯(축 · 회전 손잡이) | BricsCAD Manipulator · Rhino Gumball(X1) · 같음: 부록1 9장(낮음 판정) | 고르면 축 · 회전 손잡이 · 끌며 거리 · 각 숫자 · Ctrl / Alt = 복사 | 중 | 없음 | 큼 | ④ 맨 뒤 | 공식 | +| N21 | 같은 거리 되풀이 복제 · 한 명령 안 반복 변환 | Figma Alt 끌기 → Ctrl+D · draw.io(B5 1-4) · ARES QuickModify(X2) · 같음 계열: 335 5-4 | 한 번 옮겨 복제한 거리 기억 → 키 한 번에 하나 더 | 중 — 배수관 · 표지 같은 간격 | 일부 — COPY 여러 번 · ARRAY | 작 | ⑤ | 공식 | +| N22 | 미세 이동 두 걸음 · 걸음 값 | Figma · draw.io(B5 1-5) · 같음 계열: 335 6-2 | 고른 것 있으면 화살표 1 · Shift 10 | 중 — 글 · 기호 자리 | 없음 — 화살표 = 화면 이동 | 작 | ⑤ | 공식 | +| N23 | 누르는 동안 스냅 끔 | draw.io Alt(B5 1-6) | 키 누르는 동안만 | 중 — 촘촘한 횡단 상세 | 일부 — F3 · 한 번 NON | 작 | ② | 공식 | +| N24 | 고른 것 맞춤 보기 | Excalidraw Shift+2(B5 1-7) | 키 · 단추 하나 | 중 — 긴 평면에서 고른 구조물로 | 일부 — 전체만(Z · ⛶) | 작 | ⑤ | 공식 | +| N25 | 여러 개 고른 값 「섞임」 + 식 | Figma `Mixed+100`(B5 1-3) · 같음 계열: 335 6-7 | 섞인 칸에 식 → 고른 것마다 제 값에 적용 | 상 — 글 높이 · 자리 일괄 | 없음 — 특성 칸 = 색 · 선 · 층 · 여러 개면 첫 값 | 중 | ⑤(6-7 덧) | 공식 | +| N26 | 명령 없이 거리 표시 | Figma Alt 스침(B5 1-9) | 고른 뒤 Alt + 다른 것 위 = 가로 · 세로 거리 | 중 — 손가락은 못 씀 | 없음 — DIST 명령 | 작 | ⑤ 뒤 | 공식 | + +### 1-3. 어느 기기든 같은 입력 — 숫자판 · 찾기 · 막대 · 펜 · 터치 (⑨) + +| # | 이름 | 어디 | 어떻게 입력하나 | 쓸모 | 우리 | 크기 | 묶음 | 근거 | +|---|---|---|---|---|---|---|---|---| +| N27 | 화면 숫자판 · 값 눌러 고치기 | AutoCAD 모바일 · DWG FastView · 웹 `inputmode`(B5 2-1) · 같음: 부록1 6장 숫자판(낮음 → 기기 무관 원칙으로 올림) | 값 누름 → 숫자판 → Enter | 상 — 자판 없는 기기에서 거리 · 좌표 · 반지름 · 글 높이 | 없음 — 손가락 단추 6(`src/inputController/input-touch.ts:126-133`) | 중 | ⑨ | 공식 · 앱 장터 | +| N28 | 명령 찾기 창 | Figma Ctrl+K · FigJam(최근 셋) · Excalidraw Ctrl+/(B5 1-1) | 단추 · 키 → 낱말 → 목록 → 누름 · 별칭 같이 보임 | 상 — 명령행 없는 B06 상세 · M02 카드 · 태블릿에서도 153 명령 | 일부 — 명령행 후보 8 · 커서 곁 6 · 앞글자만 · B06 · M02 없음 | 중 | ⑨ | 공식 | +| N29 | 고른 것 곁 단추 막대 | Miro · AutoCAD 모바일(B5 1-2) · 같음 계열: BricsCAD Quad(335 9-1) · Onshape `S` 툴바 · Vectorworks Smart Options · Fusion 표식 메뉴(X1 · X2) | 고르면 곁에 옮김 · 복사 · 지움 · 특성 단추 | 상 — 리본 없는 곳 · 손가락 | 없음 | 중 | ⑨(⑤ D15 와 한 틀) | 공식 | +| N30 | 펜 모드(펜이면 손가락 = 화면 이동만) | tldraw · Excalidraw · Morpholio · Concepts(B5 2-2) · Shapr3D(X2) | 펜 첫 사용 때 자동 · 끄기 단추 | 상 — 손바닥 톡이 점 · 고름으로 들어가는 것 막음 | 없음 — 펜 = 마우스 길 · 손가락 톡 = 늘 누름 | 작 | ⑨ | 공식 | +| N31 | 확대 창(손가락 가린 곳 · 마우스도) | Shift 기법 논문 · DWG FastView · AutoCAD 모바일 돋보기(B5 2-3) · Vectorworks Snap Loupe `Z`(X2) · 같음: 부록1 6장 돋보기(낮음 → 올림) | 손가락 길게 → 손가락 위 확대 창 / 마우스 `Z` → 커서 둘레 잠깐 확대 → 스냅점 누름 | 상 — 횡단 상세 끝점 · 교차 촘촘 | 일부 — 길게 = 위 70px 십자선 · 확대 창 없음 | 중 | ⑨ | 공식 · 앱 장터 | +| N32 | 두 손가락 톡 = 되돌리기 · 세 손가락 = 다시 · 몸짓마다 단추 | Procreate · Concepts · Morpholio(B5 2-4 · 2-5) | 화면 아무 데나 톡 · 같은 일을 단추로도 | 중 | 일부 — 단추 ↶ ↷ | 작 | ⑨ | 공식 | +| N33 | 펜 옆 단추 · 지우개 끝 | 웹 Pointer Events(B5 2-7) | 옆 단추 누른 채 톡 = 확정 · 뒤집어 문지름 = 지움 | 중 | 일부(추정) — 옆 단추 = 우클릭 = Enter · 지우개 처리 없음 | 작 | ⑨ | 공식 | +| N34 | 끌다가 가장자리 자동 이동 | tldraw(B5 1-8) | 끌기 중 화면 끝에 머물면 화면이 따라 감 | 중 — 작은 화면 · 긴 노선 | 없음 | 중 | ⑨ | 공식 | +| N35 | 한글로 명령 찾기(이름 · 초성 · 한글 자판 그대로) | 우리 덧(B5 3-11) · 드림플러스 HCMD(A16 — 모르는 명령이면 영문 자판으로 바꿔 다시) · 같음 계열: 335 3-2 · D13 · H12 · A17(CADian · 못 찾음) | 「간격띄우기」 · 「ㄱㄱㄸ」 · 「ㅐㄹㄹㄴㄷㅅ」 → OFFSET | 상 — 한글 화면 · 초보 · N28 과 한 벌 | 없음 — 명령 단계 = 영문 · 내 별칭 영문 · 숫자 | 작 | ⑨(⑥ 3-2 · D13 당김) | 대조 · 판단 | + +### 1-4. 임도 표기 · 재기 도구 (⑩) + +- 서버가 이미 자동으로 그리는 것(평면 측점 눈금 · 번호 · 비탈 눈금 · 횡단 수량 표 · 종단 측점 표)은 손질 때만 씀. + +| # | 이름 | 어디 | 어떻게 입력하나 | 쓸모 | 우리 | 크기 | 묶음 | 근거 | +|---|---|---|---|---|---|---|---|---| +| N36 | 측점 · 오프셋 글 | Civil 3D Station Offset 라벨(B4 T1) · 드림플러스 STT 선택 측점 표기(A2 · 좌우 오프셋 지시선 순서는 못 찾음) | 노선 고름 → 점 → 「No.5+12.0 좌 3.0」 | 상 | 없음 | 중 | ⑩(⑧ 평면 뒤) | 공식 · 대조 | +| N37 | 횡단 글 — 오프셋 · 표고 / 두 점 경사 | Civil 3D Section View Labels(B4 T2) · 같음: A5(SLP · 못 찾음) | 점 → 「EL 123.45」 · 두 점 → 「1:1.5」 | 상 | 없음 | 중 | ⑩(⑧ 횡단 뒤) | 공식 · A5 약함 | +| N38 | 종단 글 — 측점 · 표고 / 깊이 · 경사 | Civil 3D Profile View Labels(B4 T3) · 드림플러스 FE(기준 대비 상대 EL) · GEL(두 점 사이 EL)(A6 · 재료의 `EL` 한 명령은 못 찾음) | 점 → 측점 · 표고 글 · 두 점 → 경사 글 | 상 | 없음 | 중 | ⑩(⑧ 종단 뒤) | 공식 · 대조 | +| N39 | 측점 눈금 · 번호 일괄 표기 | 드림플러스 DSTA(A1) | 폴리선 → 시작 측점 · 간격 → 직각 눈금 + 번호 글 | 중 — 노선 손질 뒤 다시 매길 때만 | 일부 — 서버가 평면에 자동 · CAD 도구 없음 · MEASURE = 점만 | 중 | ⑩(⑧ 평면 뒤) | 대조(확인) | +| N40 | 골라 길이 합 | Lee Mac TLEN(A7 — 결과 = 명령줄 · 도면 글 아님) | 선 · 호 · 폴리선 여럿 고름 → 총 연장 | 중 — 측구 · 난간 연장 대조 | 없음 — LIST = 낱개 길이 · 종이 단위(`src/tools/utility/inquiry-tools.ts:114`) | 작 | ⑩ | 대조 | +| N41 | 닫힌 영역 안 한 번 누름 → 경계 · 해치 · 면적 | Archicad Magic Wand(X2 · Space + 누름) · A8(면적 글 · 못 찾음) | 영역 안 누름 → 경계 다각형 → 해치 · 면적 글 | 상 — 횡단 절 · 성토 면 해치 · 면적 | 없음 — HATCH · BOUNDARY = 경계 객체를 골라야(`src/tools/draw/fill-tools.ts:36,81`) · AREA = 알림만 · 종이 단위 | 중 | ⑩ | 공식 · A8 약함 | +| N42 | 숫자 글 한꺼번에 셈 | 드림플러스 CALC 문자 사칙연산(A10 — 재료의 `CTC` 이름은 틀림) | 숫자 글들 고름 → `+0.5` → 글 숫자 일괄 갱신 | 중 — 계획고 · 표고 일괄 가감 | 없음 | 작 | ⑩ | 대조 | +| N43 | 번호 일괄 매김 | AutoCAD Express TCOUNT(A9 · 국산 CAD 호환은 못 찾음) | 글 여럿 고름 → 정렬(X · Y · 고른 순) → 시작 · 증분 → 덮어쓰기 · 앞 · 뒤 | 중 — 집수정 · 단면 번호 | 없음 | 중 | ⑩ | 공식 | +| N44 | 번호 늘려 복사(증분 복사) | GstarCAD Architecture(A20 · ZWCAD 일반 기능 근거 없음) | 숫자 든 글 복사 → 놓을 때마다 숫자 +1 | 중 — 배수관 · 측점 번호 | 없음 — COPY = 글 그대로 | 중 | ⑩ | 대조 | +| N45 | 표 ↔ 엑셀 | 드림플러스 ETC(엑셀 → 캐드) · CTE(캐드 → 엑셀)(A11 · 재료는 방향이 거꾸로) · KTOOL 엑셀 ↔ 종단(B4 K1 · 카페) | 엑셀 칸 복사 → 캐드 표 · 캐드 표 고름 → 엑셀 | 중 — 수량 · 좌표 표 손질 | 없음 — 붙여넣기 = DXF 글 · 앱 클립보드만(`src/integration/dxf-clip-paste.ts:79-84`) | 중 + 중 | ⑩ | 대조 · 약함 | +| N46 | 좌표 글(TM 북 · 동) | 드림플러스 XY(A13 · `X(N) Y(E)` 꼴은 못 찾음) | 점 → 지시선 끝 → 좌표 글 | 중 — IP · 시종점 · 구조물 좌표 | 일부 — DIMORDINATE = 종이 좌표(H13) | 작 | ⑩(⑧ 평면 뒤) | 대조 | +| N47 | 숫자 키 층 켬끔(11 · 22 · 33) | 국내 사용자 LISP(A12 — 보편 단축키 아님) | `11` = 층 하나만 · `22` = 모두 켬 · `33` = 끔 | 하 — B07 도면 층이 적음 | 일부 — LAYISO · LAYON · LAYOFF 명령 있음 · 내 별칭에 숫자 이름 가능(`A00_Common/app_settings_cad.ts:48`) | 작 | ⑥(안내만) | 대조 | + +### 1-5. AI · 그 밖 (⑥ ⑦ · 뺌) + +| # | 이름 | 어디 | 어떻게 입력하나 | 쓸모 | 우리 | 크기 | 묶음 | 근거 | +|---|---|---|---|---|---|---|---|---| +| N48 | 다음 명령 후보 | Siemens NX · Solid Edge(B5 3-7) | 내 명령 「앞 → 다음」 짝을 세어 커서 곁에 | 중 — 간격띄우기 → 자르기 같은 순서 | 일부 — 빈 Enter = 직전 명령 | 작~중 | ⑥(D9 와 같이) | 제3자 기사 | +| N49 | 자연어 → 명령(사내 작은 모델 + 도구 부르기) | Ollama 도구 부르기 · Qwen3 · HyperCLOVA X SEED · FunctionGemma(B5 3-10) | 「고른 글 높이 2.5 로」 → 명령 이름 + 값 → 미리보기 → [확인] | 중 | 없음 | 큼(시험) | ⑦ 시험만 | 공식(모델 문서) · 한국어 정확도 미확인 | +| N50 | 좌표 표 → 점 · 선 | 드림플러스 CIM(A27 — 끌어 놓기 · 5 열 꼴은 못 찾음) | 엑셀 좌표 붙임 → 점 · 선 · 3D 폴리선 | 하 — B07 도면은 서버 자동 | 없음 | 중 | ⑦ | 대조 | +| N51 | 검토 표시층(마크업) | Autodesk Viewer · Docs(B5 1-12) | 도면은 그대로 · 위에 화살표 · 구름 · 글 · 재기 | 중 — 확정 도면에 의견(정본 안 건드림) | 없음 — 확정 = 보기 전용 · REVCLOUD 는 편집 | 큼 | ⑦(저장 자리 정해야) | 공식 | +| N52 | 클라우드 AI · 음성 · 브라우저 안 AI | Autodesk Assistant · Markup Assist · Smart Blocks 감지 · BricsCAD Assist · ARES A3(X2) · Onshape AI Advisor · 크롬 내장 AI · Web Speech(B5 3-1~3-6 · 3-8 · 3-9) | 채팅 · 음성 → 명령 | — | 없음 | — | 뺌 — 도면이 바깥 서버로 감(유출 금지) · 안드로이드 · iOS 빠짐(기기 무관 어긋남) | 공식 | +| N53 | 음성 메모 | ZWCAD Smart Voice(A19 · X1) · GstarCAD | 점 · 객체에 녹음 붙임 | 하 | 없음 | 중 | 뺌 | 대조(확인) · 공식 | + +### 1-6. 같음 · 이미 함 · 낮음 (줄로 안 셈) + +- 같음(335 에 있음) — SketchUp 축 잠금(X1) · Vectorworks Data Bar · GstarCAD · nanoCAD 동적 입력(X2 = 331-4 Tab 칸) · Onshape · Fusion 그리면서 치수 칸(음수 = 반대 방향은 우리도 됨 `src/helpers/get-point-from-event.ts:42`) · Fusion 사각형 치수 · QCAD X/Y(1-7) · ARES SmartSelect(4-11) · Rhino SmartTrack(1-10 · 2-7) · ZWCAD · SOLIDWORKS · DraftSight 몸짓 · A18(9-3) · MicroStation M1 · M3 · M6 · LibreCAD · FreeCAD · SolveSpace(새것 없음). +- 서버 · 설계 쪽이 이미 함 — A14 GRP 종단 지반선 · A15 CFM 횡단 지반선(B05 단면 뽑기) · A21 가로 · 세로 다른 축척 종단 틀(`B07_DesignDetail/B07_DesignDetail_Engine_Cad_Long.py:53-55`) · A3 DSLO 비탈 · A28 소단(B06 설계) · A26 종단곡선(B05) · A4 TALUS 법면 기호(평면 비탈 눈금 = `B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py:1` 자동 · 횡단 띠 해치 = 335 8-7) · B4 K1 표 → 종단(B05 → B07 자동). +- 낮음 · 설계 정본 몫 — PVI 표 · 끌기 고정(B4 L6 · B05 몫) · 값 글자 눌러 규칙대로 바꿈(B4 T5 · Onshape 치수 구동 X1 — 규칙 객체 없음 · 큼) · 측점 추적선(P11) · 사이드샷 · 점 번호(S3) · 곡선 계산기(S5) · 값 칸 끌기(B5 1-10) · 길게 누름 메뉴(2-6 → N29 로) · 펜 띄움 미리보기(2-8 · 있음 추정) · 그리다 멈추면 반듯한 도형 · 문질러 지움(2-9 · 2-10) · 손가락 고름 크기(1-11 · 있음). + +## 2. 새 흠 · 확인 거리 + +### 2-1. 흠(H13~) + +| # | 무엇이 틀림(화면 말) | 근거 | 어디서 | 고침(한 줄) | 크기 | 급함 | 판정 | +|---|---|---|---|---|---|---|---| +| H13 ⚠ | 사람이 CAD 로 넣는 치수 · 재기 숫자 = 종이 길이 — 횡단(1/100) 3 m → 「30」 · 면적 2 m² → 「200」 · 평면(1/1200) 3 m → 「2.5」 · 종단 세로(1/200) 높이 1 m → 「5」 · 표준도(1/50) 1 m → 「20」(측구 확대도 1/10 → 「100」) · 종단 각도 치수 = 세로 5 배 과장 각 · 치수 글 덮어쓰기 칸이 없어 숫자를 고칠 길도 없음 · 서버가 만든 수치는 글(Text)이라 맞음 | 선형 치수 = 두 점 종이 거리 반올림 `src/entities/MeasurementEntity.ts:304,419` · 반지름 · 지름 · 호 길이 `src/tools/annotate/annotation.helpers.ts:43-48` · 좌표 치수 = 종이 X · Y `src/tools/annotate/dimension-radial-tools.ts:146-163` · DIST · AREA · LIST `src/tools/utility/inquiry-tools.ts:36,88,114` · 치수 설정 = 글 높이 · 화살표 · 자리수 · 주석 축척(크기만) `src/commands/dim-settings.ts:11-36` · 치수 객체 칸 = 세 점 · 색 · 선 · 층(글 칸 없음) · 서버 도면 형 = Line · PolyLine · Circle · Hatch · Table · Text · Image(치수 객체 없음) · 축척 상수 `config/config_system.py:155-194` | B07 횡단 · 종단 · 평면 · 표준도 · 유토 · 유역 · DXF · DWG 내보내기(그 숫자 그대로 · 추정) | 장 · 칸마다 측정 계수(m ÷ 종이 mm) 받아 치수 · 재기 값에 곱함 · 종단은 가로 · 세로 따로(기운 치수 · 각도는 막거나 경고) · 한 장 두 축척은 칸별(정할 것 18) | 중 | 중~높음 | 코드로 확인됨(값 만드는 줄에 축척 곱 없음) · 화면 숫자는 실측 안 함 | +| H14 ⚠ | 횡단 좌우 — B07 횡단도는 도로 왼쪽(+ 오프셋)을 종이 오른쪽에 그림 · B06 횡단 화면은 왼쪽을 화면 왼쪽에 그림 → 같은 측점이 두 화면에서 좌우 거울 · 진행 방향을 보고 그리는 관례라면 B07 쪽이 뒤집힌 셈 | + = 왼쪽(법선 = 접선 반시계 90°) `B05_Profile/B05_Profile_Engine_Sections_Core.py:406,562` · B07 종이 x = 오프셋 × 10 + ox(부호 그대로) `B07_DesignDetail/B07_DesignDetail_Engine_Cad.py:13,557-558` · B07 겹침 그림 같은 식 `B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts:603` · B06 화면 x = 왼쪽 끝 + (최대 오프셋 − 오프셋) `B06_Section/B06_Section_UI_Cross_Axes.ts:60,73` · 지식DB 좌우 방향 글 없음(「좌측하단에서 상단방향」 = 장 안 배치 차례 · 별표2 `:69`) · 시험이 좌우를 고정하지 않음 | B07 횡단 · 횡단 장 · 겹침 구조물 · 내보내기 · ⑧ 오프셋 입력 | 관례 확인 뒤 — B07 을 B06 처럼 뒤집거나(엔진 · 겹침 · 테두리 · 표 자리 · 시험) 지금대로 두고 까닭을 적음 | 중(뒤집을 때 · 추정) | 높음 후보(관례 확인 먼저) | 식은 코드로 확인됨 · 화면 좌우 · 관례는 화면 확인 + 사용자 확인 필요 | +| H15 | 손가락 정밀 고르기(길게 누름)는 늘 손가락 위 70px 자리를 누름 → 캔버스 아래 70px 띠는 정밀 모드로 못 짚음(추정) · 위 70px 안에서 길게 누르면 십자선이 캔버스 밖 | `src/inputController/input-touch.ts:18,71,95,111` | 손가락 기기 세 곳 | 가장자리에선 띄우는 방향을 바꿈 · 또는 확대 창(N31)으로 갈음 | 작 | 낮음 | 식은 코드로 확인됨 · 못 짚는지는 화면 확인 필요 | + +### 2-2. 확인 거리(흠 아님 · ⑧ 바닥) + +- K1 — 서버가 횡단 칸마다 변환값(`cross_placements` = 측점 · ox · oy · dy · mm_per_m · 테두리)을 이미 냄 `B07_DesignDetail/B07_DesignDetail_Engine_Cad.py:619-638` · 페이지만 씀 `B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts:28-30` · CAD 엔 안 감(`src/App.types.ts:41-57` 축척 · 원점 칸 없음 · `src` 의 station · chainage · 측점 = 주석 · 수량 패널뿐) → 횡단은 넘기기만 하면 됨(작). +- K2 — 종단 · 평면은 변환값을 따로 안 냄 — 축척 = 상수(종단 가로 1/1000 · 세로 1/200 · 평면 1/1200 · 고를 수 있음 500~6000 `config/config_system.py:155-191`) · 종단 기준선 표고 · 평면 원점(장 콘텐츠 최소점) · 그 장 시작 측점 · 노선 폴리선 표지는 서버가 덧내야 함(B4 3장) → 중. +- K3 — 한 장 두 축척 — 표준 횡단도 본 그림 1/50 + 측구 확대도 1/10 `B07_DesignDetail/B07_DesignDetail_Engine_Cad_Standard.py:10,43-44` · 종단 가로 · 세로 → 장 하나에 계수 하나로는 H13 을 못 풂. +- K4 — 계산 자리(CLAUDE 5장) — 변환값 = 서버가 냄 · 입력 바꿈 · 표시는 브라우저가 즉시(왕복 없음) · 도면 좌표 · 내보내기는 종이 mm 그대로(정본 안 바뀜). + +## 3. 묶음 갱신 · 차례 + +### 3-1. 묶음 표(덧 · 새) + +| 묶음 | 무엇이 생기나(예) | 담는 줄 | 기대 효과 | 크기(어림) | 앞몫 | +|---|---|---|---|---|---| +| ⓪ 흠 막기(덧) | 앞 H1~H12 + 치수 · 재기가 m 로 · 횡단 좌우 판정 뒤 손봄 | H13 · H14 · H15 · N13 | 틀린 숫자가 도면 · 내보내기에 안 남음 | 앞 3~6 일 + H13 1.5 일(+ H14 뒤집으면 1.5 일) | H13 = 축척 값 넘기기와 한 몸 | +| ① 좌표 · 값(덧) | 객체 길이 집기 · 칸 증감 `X3+` | N16 · N17 | 같은 길이 되풀이 | +1 일 | — | +| ② 스냅 · 추적(덧) | 비탈 꼴 네 가지 받기 · 누르는 동안 스냅 끔 · 요소 방향 나침반 · 확대 따라 눈금 | N14 · N18 · N19 · N23 | 횡단 비탈을 키보드로 | +4 일 | N14 · N23 → 1 일(D2 와 같이) | +| ④ 그립(덧) | 비탈 따라 끌기 · 손잡이 위젯 | N15 · N20 | 비탈 끝만 늘림 | +5.5 일 | — | +| ⑤ 편집 손버릇(덧) | 같은 거리 복제 · 큰 걸음 미세 이동 · 고른 것 맞춤 · 섞인 값 식 · 거리 표시 | N21 · N22 · N24 · N25 · N26 | 같은 간격 · 일괄 고침 | +3.5 일 | N21 · N24 → 1 일 | +| ⑥ 명령줄(덧) | 다음 명령 후보 · 숫자 별칭 안내 | N47 · N48 | 순서 되풀이 | +1~2 일 | — | +| ⑦ 자동화 · 그 밖(덧) | 좌표 표 → 점 · 검토 표시층 · 사내 모델 시험 | N49 · N50 · N51 | — | +9.5 일(시험 따로) | 사내 모델 = 정할 것 21 | +| ⑧ 실제 값 입력(새) | 축척 · 원점 · 노선 정보 받기(K1 · K2) → 「No.12+5 좌 3」 · 「오프셋 −2.5 · 표고 123.4」 · 「경사 8 % · 측점 No.15」 · 비탈 비율(② D2 · N14 공용) · `3m` · 커서 곁 실값 · 측점으로 이동 | N01~N12 | 1/100 · 1/200 · 1/1200 손 환산 · 계산기 없앰 | 어림 14 일(+ 고저 편집 표 4 일) | 횡단 = 원점 · 기준 높이 넘김 · N10 · N11 · N08 → 3 일 | +| ⑨ 기기 무관 입력(새) | 숫자판 · 명령 찾기 창(한글) · 고른 것 곁 막대 · 펜 모드 · 확대 창 · 두 손가락 되돌리기 · 펜 단추 · 가장자리 이동 | N27~N35 | 태블릿 · 휴대폰 · 펜에서도 같은 일 · 마우스도 같은 판 | 어림 9.5 일 | N27 · N28 · N35 · N29 → 5 일 | +| ⑩ 임도 표기 도구(새) | 측점 · 표고 · 경사 글 · 측점 눈금 · 길이 합 · 안쪽 눌러 면적 · 숫자 일괄 셈 · 번호 매김 · 번호 늘려 복사 · 표 ↔ 엑셀 · 좌표 글 | N36~N46 | 표기 · 수량 손질을 계산 · 다시 치기 없이 | 어림 15 일 | 문맥 없이 되는 N40 · N42 · N43 · N44 → 4 일 | + +- 크기 셈 = 작 0.5 · 중 1.5 · 큼 4 일(335 와 같음) · ⑧ 에 문맥 넘기기(횡단 0.5 · 종단 · 평면 서버 덧 1.5) 포함. + +### 3-2. 먼저 할 관계(덧) + +- ⓪ H13 → ⑧(축척 값 넘기기를 같이 깖) · ⑩ 글 도구(숫자가 m 여야). +- 정할 것 17(횡단 좌우) · 15(오프셋 부호) → ⑧ 횡단 · ⑩ N37 — 그림 좌우가 바뀌면 입력 부호 · 커서 실값 · 글이 다 따라 바뀜. +- ⑧ 횡단(변환값 있음) → ⑧ 종단(서버가 가로 · 세로 축척 · 기준선 표고 · 시작 측점 덧) → ⑧ 평면(노선 표지 · 시작 측점 · 측점 꼴 해석 N03). +- ⑧ → ⑩ N36~N39 · N46. +- ⑨ 숫자판(N27) → ⑧ 실값 칸 · ④ 그립 숫자(5-9) · ① 값 — 손가락 기기에서 숫자를 넣는 바닥. +- ⑨ 명령 찾기 창(N28 · N35) = ⑥ 3-2 · D9 · D13 과 한 찾기 함수 → ⑥ 앞몫 일부를 ⑨ 로 당김. +- ⑨ 막대(N29) ↔ ⑤ 우클릭 메뉴(D15) = 한 메뉴 틀(정할 것 3 · 23). +- ⓪ H2(보기 전용 문) → ⑨ 막대 · ⑩ 일괄 셈 · 번호 매김 · 번호 늘려 복사(새 고치기 길 모두 문을 지남). +- ② D2(1:n 각) = N14(비탈 꼴) = ⑧ N08(앞 점에서 비율 → 표고) — 비율 해석 함수 하나. +- ⑨ 펜 모드(N30) → 확대 창(N31) · 펜 단추(N33). + +### 3-3. 새 차례 추천(B5 효율 근거로) + +| 차례 | 묶음 · 몫 | 크기(어림) | 까닭(근거) | +|---|---|---|---| +| 1 | ⓪ 흠 막기 + H13(+ H14 판정) | 4.5~7.5 일 | 도면을 몰래 바꾸거나 틀린 숫자를 남기는 것부터 | +| 2 | ⑨ 앞몫 — 숫자판 · 명령 찾기 창(한글) · 고른 것 곁 막대 | 5 일 | 쓰는 명령은 소수(가장 많은 무리 31~40 개 · 최다 ERASE→MOVE→COPY [B5 E1 · E2]) → 어느 기기에서나 두 번 누름 안에 · 있는 빠른 길을 보이게 [E5 · E6] · 기기 무관 원칙 | +| 3 | ③ 고르기 앞몫(335) | 4 일 | 묶어 고르기 · 되풀이 전략이면 실행 시간 약 40 % 줄음 [E4] | +| 4 | ⑧ 횡단 앞몫 — 원점 · 기준 높이 넘김 · 커서 실값 · `3m` · 오프셋 · 표고 점 | 3 일 | 「뭘 치지?」 생각 한 번(1.35 초) = 키 예닐곱 번 [E9] → 1/100 손 환산이 손질마다 그 생각 · 서버 변환값이 있어 작음 · 정할 것 15 · 16 · 17 먼저 | +| 5 | ② 스냅 앞몫(335) + N14 · N23 | 4 일 | 횡단 비탈을 키보드로 · ⑧ 비율 해석과 한 함수 | +| 6 | ⑨ 펜 모드 · 확대 창 · 두 손가락 되돌리기 · 펜 단추 | 3 일 | 작은 표적 손가락 오류 최대 81 % → 확대 · 오프셋 4~14 % [E10] · 펜은 쓰고 손가락은 다룸 [W29] | +| 7 | ④ · ① · ⑤ 앞몫(335) | 10~11 일 | 335 차례 그대로 | +| 8 | ⑩ 앞몫(길이 합 · 숫자 일괄 셈 · 번호 매김 · 번호 늘려 복사) | 4 일 | 문맥 없이 됨 · 수량 · 표기 손질 | +| 9 | ⑧ 종단 · 평면 → ⑩ 글 도구(측점 · 표고 · 경사 · 좌표 글) | 어림 15 일 | 서버 덧(K2) 뒤 | +| 10 | ⑥ · ⑦ · 나머지 몫 | — | 앞몫을 써 본 뒤 · 사내 모델은 시험만(정할 것 21) | + +- 앞 다섯 합 = 어림 21~24 일 · 미리보기 · 스냅 · 끌기는 브라우저 안 한 화면 틀(16ms) 안 · 서버 계산은 [저장] · [확정] 때만 [E14 · E15]. +- 335 차례와 다른 점 — ⑨ 를 ③ 앞으로(기기 무관 원칙 · 효율 근거) · ⑧ 횡단 앞몫을 ② 앞에 끼움 · ⑩ 은 ⑤ 뒤. + +## 4. 정할 것 갱신(335 의 1~14 뒤) + +| # | 무엇 | 지금 | 바깥 · 근거 | 갈래 | 걸리는 묶음 | +|---|---|---|---|---|---| +| 15 | 오프셋 부호(입력 칸) | 엔진 = +좌 / −우(`B05_Profile/B05_Profile_Engine_Sections_Core.py:562`) | Carlson · GEOPAK = 왼쪽 − · TBC = 오른쪽 + · 지식DB 교각 = 좌측각 (+)(`…/2. 임도/4. 노선측량.md:322`) | (가) 엔진 그대로 +좌 (나) 바깥식 좌 − (다) 부호 대신 「좌 3」 · 「우 3」 · L · R 글자 | ⑧ · ⑩ | +| 16 | 측점 입력 · 표시 꼴 | 평면 글 `No.n+d`(20 m) · 제목 「2+0.0」 · 정보 「STA.0+050.000」 셋 섞임 | 지식DB 「No.11+13」 · 「STA NO. 3」(`…/5. 설계.md:157,244`) · 측점 간격 20 m(별표2 `:25`) · Trimble Station index · Civil 3D `+` 없이 숫자 | 입력 = `No.12+5` · `12+5` · `245`(m) 다 받을지 · `2+015` 를 km 꼴(2 015 m)로 볼지 번호 꼴(No.2 + 15 m = 55 m)로 볼지 · 표시 한 꼴 | ⑧ · ⑩ | +| 17 | 횡단 좌우 방향 | B07 = 왼쪽(+)을 종이 오른쪽 · B06 = 화면 왼쪽(H14) | 관례 = 사용자 확인(지식DB 글 없음) | (가) B07 을 B06 처럼 뒤집음 (나) 지금 그대로 + 까닭을 도면 · 문서에 적음 | ⓪ H14 · ⑧ | +| 18 | 치수 · 재기 실값 범위 | 종이 값 그대로(H13) | AutoCAD DIMLFAC = 치수 스타일마다 계수 하나 | 새 치수만 / DIST · AREA · LIST · ID 도 · 종단 가로 · 세로 따로(기운 치수 · 각도는 막음 · 종이 값 표시) · 한 장 두 축척(표준도 1/50 + 1/10)은 칸별 · 치수 글 덮어쓰기 칸(335 6-6 · 6-7)도 같이 넣을지 | ⓪ H13 · ⑧ | +| 19 | 펜 모드 기본 | 펜 = 마우스 길 · 손가락 톡 = 늘 누름 | tldraw · Excalidraw = 펜 첫 사용 때 자동 켬 | (가) 자동 켬 + 끄기 단추 (나) ☰ 설정에서만 (다) 안 넣음 | ⑨ | +| 20 | 한글 명령 찾기 범위(정할 것 11 덧) | 명령 단계 = 영문 글자 · 명령행 한글 못 찾음(추정) | 드림플러스 HCMD = 모르는 명령이면 영문 자판으로 바꿔 다시 | 한글 이름 · 초성 · 자판 그대로 중 어디까지 · 찾기 창만 vs 캔버스 · 명령행도 · 자판 바꿔 바로 실행 vs 후보만 보임 · 내 별칭 한글 허용 | ⑨ · ⑥ | +| 21 | 사내 작은 AI 모델 시험 | 없음 | 상용 CAD AI = 바깥 서버(못 씀) | (가) 시험 안 함 (나) 사내 서버에 작은 모델 시험(한국어 정확도 · GPU · 라이선스 원문 · [확인] 단계) | ⑦ | +| 22 | 화면 숫자판 꼴 · 띄울 때 | 없음 · 손가락 단추 6 | AutoCAD 모바일 = 값 누르면 숫자판 · 웹 `inputmode` = 기기 자판(기기마다 꼴 · 높이 다름) | (가) 앱 안 단추판 (나) 기기 자판 (다) 둘 다 · 손가락 기기만 / 늘 / 단추로 · 판의 키(숫자 · 소수점 · `-` · `@` · `<` · `:` · `m` · 지움 · Enter) | ⑨ | +| 23 | 고른 것 곁 막대 ↔ 우클릭 메뉴(정할 것 3 덧) | 둘 다 없음 | Miro 막대 · BricsCAD Quad · Fusion 표식 메뉴 | 한 틀 · 같은 명령 목록(고른 채 = 막대 · 우클릭 = 메뉴) · 막대에 둘 명령(지움 · 옮김 · 복사 · 간격띄우기 · 자르기 · 특성 등 — 근거 E1 · E2) | ⑨ · ⑤ | + +- 먼저 정할 것(새) = 17 · 15 · 16 · 18(⓪ H13 · ⑧ 앞몫) → 22 · 23 · 19 · 20(⑨ 앞몫) → 21(시험 여부). + +## 5. 근거 약함 · 확인 못 함 · 출처 + +### 5-1. 근거 약함 — 안티그래비티 「못 찾음」 12(일반 아이디어로만) + +| A | 이름(재료) | 판정 · 어디로 | +|---|---|---| +| A3 | DSLO 사면 자동 작도 | 기능 쪽 못 찾음 · 비탈은 B06 설계가 그림 → 손질은 N08 · N14 | +| A4 | TALUS 법면 빗금 | 기능 쪽 못 찾음 · 평면 비탈 눈금은 서버 자동 · 횡단 띠 해치 = 335 8-7 | +| A5 | SLP 경사 기호 | 못 찾음 → 공식 근거 있는 N37 · N38 로 | +| A8 | AR · DLA 면적 글 | 못 찾음 → N41(Archicad 공식)로 | +| A17 | CADian 한글 자연어 명령 | 못 찾음 → N35(규칙 기반 한글 찾기)로 | +| A21 | 가로 · 세로 다른 축척 종단 틀 | 못 찾음 · 우리 서버 자동 | +| A22 | INFRACAD 측점 + 오프셋 | 제품 · 출처 없음 → N01(Civil 3D · OpenRoads · TBC 공식) | +| A23 | 방위각 + 거리 | 못 찾음 → N12(Civil 3D 공식) | +| A24 | Slope Lock | 못 찾음 → N14 · 335 D2 | +| A25 | 구배율 입력 | 못 찾음 → N07(Civil 3D 공식) | +| A26 | VCL · R 종단곡선 | 못 찾음 · 종단곡선은 B05 설계 몫 | +| A28 | 소단 자동 전개 | 못 찾음 · 소단은 B06 설계 몫 | + +- 코덱스 3차 머리 줄 = 「확인 2 · 일부 13 · 못 찾음 13」 · 표 줄을 세면 확인 2(A1 · A19) · 일부 14 · 못 찾음 12 → 줄별 판정을 따름. +- 「일부」를 바른 내용으로 고친 것 — A6(일반 EL 명령 없음 · FE · GEL 있음) · A7(결과 = 명령줄) · A10(CTC → CALC 문자 기능) · A11(ETC = 엑셀 → 캐드 · CTE = 캐드 → 엑셀) · A12(사용자 LISP 사례) · A13(`X(N) Y(E)` 꼴 못 찾음) · A16(HCMD = 모르는 명령이면 영문으로 다시 · 자모 가상 매핑 아님 · CADian 근거 없음) · A18(몸짓마다 명령 고정 배정 못 찾음) · A20(GstarCAD Architecture 근거) · A2 · A9 · A14 · A15 · A27(세부 순서 못 찾음). + +### 5-2. 재료 속 도메인 숫자 → 지식DB 로 바꿈 + +| 재료 숫자 | 지식DB | 인용 | +|---|---|---| +| 「산지관리법상 임도 최대 종단경사 13~18 %」(안티그래비티) | 법이 다름 · 간선임도 종단기울기(순기울기) = 설계속도 40 · 30 · 20 km/h 에 일반지형 7 · 8 · 9 % 이하 · 특수지형 10 · 12 · 14 % 이하 · 노면포장 때만 18 % 범위 조정 · 작업임도 = 최대 20 % 범위 | 별표2 `:168-177` · `:665` | +| 「5 m 높이마다 소단 · 소단폭 1.0 m · 배수 역경사 4 %」 | 붕괴 우려 지역 = 사면길이 2~3 m 마다 폭 50~100 cm 소단 · 토사 절토는 높이에 따라 소단 · 성토사면 길이 5 m 이내(넘으면 옹벽 · 석축) · 역경사 4 % = 지식DB 확인 몫 | 별표2 `:274` · `:258` · `:267` | +| 「1:1.5 · 1:1.2」 비탈 | 절토 경암 1:0.3~0.8 · 연암 1:0.5~1.2 · 토사 1:0.8~1.5 · 성토 1:1.2~2.0 | 별표2 `:258` · `:265` | +| 「VCL=40 · R=300」 | 종단곡선 반경 · 길이 = 40 km/h 450 · 40 m 이상 · 30 km/h 250 · 30 · 20 km/h 100 · 20 · 비포장 대수차 5 % 이하면 안 씀 · 포물선 가능 | 별표2 `:187-197` | +| 「20 m 정측점」 | 측점 간격 20 m · 필요 지점 보조말뚝 | 별표2 `:25` | +| 그 밖(「횡단 폭 좌우 15 m」 등) | 입력 기능 예시일 뿐 · 쓰지 않음 | — | + +- 이 숫자들은 근거 · 후보일 뿐 — 극 추적 비율 목록 등 기본값은 정할 것 4 대로 사용자 협의. + +### 5-3. 확인 못 함 + +- 화면 실측 안 함 — H13 화면 숫자 · H14 화면 좌우 · H15 아래 띠 · B5 의 우리 쪽 추정(펜 옆 단추 = Enter · 펜 띄움 스냅 표시 · B07 명령행 칸 기기 자판 · iPad 한글 받개 초점). +- B07 손질에서 실제로 무엇을 자주 고치는지 자료 없음 → 쓸모 = 도면 종류별 짐작(335 와 같음). +- 국내 임도 전용 · 국내 도로 설계 상용 프로그램 입력 = 못 찾음(B4) · 국산 CAD 국내 문서는 대부분 홈페이지 주소뿐(안티그래비티). +- 코덱스 — progeCAD · TurboCAD · CorelCAD 근거 못 찾음 · Figma 값 칸 끌기는 코덱스 「확인 못 함」 · B5 가 Figma 도움말로 확인(B5 판정 따름). +- B4 · B5 의 「확인 못 함」 그대로(부록 원문 5 · 6) · B5 숫자 크기(표식 메뉴 3.5 배 = 2 명 · CAD 40 % = 한 사용자 모형)는 방향만. +- H13 의 내보내기 숫자 · H14 를 뒤집을 때 크기 · 모든 크기 = 코드 읽기 어림(시험 포함 · 화면 확인 별도). + +### 5-4. 출처 + +- 원문 · 전체 출처 목록 = `2026-10-15_335_부록3_바깥조사원문.md`(B4 [R1~R35] · B5 [W1~W46] [E1~E20] · 코덱스 표 링크 · 코덱스 3차 바른 URL). +- 이 글에서 B4 · B5 밖에서 쓴 출처: + - Vectorworks Snap Loupe · Smart Options — https://app-help.vectorworks.net/2027/eng/VW2027_Guide/Start/Streamline_drawing_interactions.htm + - Archicad Magic Wand — https://help.graphisoft.com/AC/28/INT/_AC28_Help/030_Interaction/030_Interaction-53.htm · Tracker 증감 — https://help.graphisoft.com/AC/26/INT/_AC26_Help/030_Interaction/030_Interaction-41.htm + - ARES QuickModify — https://onlinehelp.graebert.com/EN/ARES_Content/25/additional/hlpid_edit_quickmodify.htm + - BricsCAD Manipulator · 적응형 스냅 — https://help.bricsys.com/en-us/document/bricscad/modifying-entities/the-manipulator?version=V26 + - Rhino Gumball — https://docs.mcneel.com/rhino/8/help/en-us/user_interface/keyboard%20modifiers.htm + - ZWCAD Smart Voice — https://www.zwsoft.com/user-guide/zwcad-smart-features + - 그 밖 코덱스 줄(Quad · Onshape · Shapr3D · ARES A3 · SmartTrack 등) = 부록 원문 1 · 2 표 링크 + - 드림플러스 DSTA — https://dreamcad.net/bbs/board.php?bo_table=qna&wr_id=16553 · STT — https://www.dreamcad.net/bbs/board.php?bo_table=qna&wr_id=22368 · DSTA · GST — https://www.dreamcad.net/bbs/board.php?bo_table=qna&wr_id=12839 · FE · GEL — https://dreamcad.net/bbs/board.php?bo_table=qna&wr_id=13168 · CALC 문자 — https://www.dreamcad.net/bbs/board.php?bo_table=notice&wr_id=80 · ETC · CTE — https://dreamcad.net/bbs/board.php?bo_table=qna&wr_id=30570 · XY — https://dreamcad.net/bbs/board.php?bo_table=qna&wr_id=15273 · CIM — https://dreamcad.net/bbs/board.php?bo_table=qna&wr_id=14564 · HCMD — https://dreamcad.net/bbs/board.php?bo_table=qna&page=197&wr_id=17336 + - Lee Mac Total Length — https://lee-mac.com/totallengthandarea.html + - AutoCAD TCOUNT(한국어) — https://help.autodesk.com/cloudhelp/2025/KOR/AutoCAD-Core/files/GUID-4B92219C-DA94-4D71-B308-D3818D3C6B8F.htm + - GstarCAD Architecture 증분 복사 — https://cdn-sg-gw.gstarcad.net/gstarsoft_pdf/GstarCAD-Architecture-2021-User-Guide.pdf + - 고캐드 11 · 22 · 33 LISP 사례 = 코덱스 3차 12 줄 링크(부록 원문 4) +- 지식DB — 별표2(5-2 · `:25,69,168-177,187-197,258,265,267,274,665`) · `resources/knowledge/original/산림과임업기술(임도)/2. 임도/5. 설계.md:157,238,244` · `…/2. 임도/4. 노선측량.md:322`. +- 코드(읽기만) — 2 장 표 · K1~K3 의 줄. From 546db1ab1108a0128cf7c2e2661b486264a12af0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 15 Oct 2026 19:22:57 +0900 Subject: [PATCH 36/36] =?UTF-8?q?chore(=EC=A1=B0=EC=82=AC):=20336=20?= =?UTF-8?q?=EC=99=B8=EB=B6=80=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4=20=EC=9E=AC=EA=B2=80=ED=86=A0=20=E2=80=94=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A1=B0=ED=95=A9=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=C2=B7=20=EC=9B=90=EB=B3=B8=20=EB=A1=9C=EC=A7=81=20=EC=95=88=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=A8=20=C2=B7=201=EC=88=9C=EC=9C=84=2071=20?= =?UTF-8?q?=E2=86=92=2093%=20=C2=B7=20=EC=A1=B0=ED=95=A9=20=EB=A7=9E?= =?UTF-8?q?=EC=9D=8C=2010=20=E2=86=92=2076%=20=C2=B7=20=EB=AC=B6=EC=9D=8C?= =?UTF-8?q?=20=EC=97=AC=EC=84=AF=20=C2=B7=20=EC=A0=95=ED=95=A0=20=EA=B2=83?= =?UTF-8?q?=20=EB=8B=A4=EC=8B=9C=20=EC=A0=95=EB=A6=AC?= 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_01C6BNBFXFndbqYxu6sZuknK --- .../조사/2026-10-15_336_부록3_재실측원문.md | 806 ++++++++++++++++++ .../2026-10-15_336_재검토_로직조합맞춤.md | 313 +++++++ 2 files changed, 1119 insertions(+) create mode 100644 docs/raw/조사/2026-10-15_336_부록3_재실측원문.md create mode 100644 docs/raw/조사/2026-10-15_336_재검토_로직조합맞춤.md diff --git a/docs/raw/조사/2026-10-15_336_부록3_재실측원문.md b/docs/raw/조사/2026-10-15_336_부록3_재실측원문.md new file mode 100644 index 000000000..27c29da7e --- /dev/null +++ b/docs/raw/조사/2026-10-15_336_부록3_재실측원문.md @@ -0,0 +1,806 @@ +# 부록3 — 재실측 원문 (요구 226-4 · PLAN 336-3) + +본문 = 같은 폴더 `2026-10-15_336_재검토_로직조합맞춤.md` · 아래 세 글은 C4 · C5 · C6 결과 글을 손대지 않고 차례대로 이어 붙임(머리만 닮). + + +--- + + +**부록 A. C4 — 로직 지문 맞춤 실측 원문** (원문 `C4_로직지문.md`) + +# C4 로직 지문 맞춤 실측 (요구 226-4) + +바깥 단가산출 블록의 「로직 속」(직종 · 품 · 장비 · 자재 · 할증 · 단위 · 조건 · 조항)을 뽑아 우리 돈 로직 1,438 의 같은 꼴 지문과 견준 맞춤 흉내. 금액(원 · 단가 · 금액 비)은 순위 · 판정에 안 씀. 저장소 안 고침(읽기 · 스크래치패드 흉내만). +기준 = main_laptop_1 b7e727863 · 정답 · 셈 = 225 C1 · C3 와 같음(북부청 임도 · 사방 · 동부청 영월 · 고유 136 줄 · 정답 있음 126 · 기억 없음 · 씨앗 켬이 기본). 지금 · 묶음 ① 수치는 C3 스크립트를 다시 돌려 같음 확인(71.4 / 81.7 · 85.7 / 88.1). + +## 0. 결론 + +1. **로직 속만으로 맞춤이 됨** — 지문만(조항 · 이름은 덤) 1순위 엄격 81.7% · 다섯 안 93.7%(지금 71.4 · 86.5). 북부청 두 파일(92 줄)만 보면 지문만 91.3% 로 묶음 ①(89.1%)보다 높음. 영월은 씨앗(사용자가 전에 확정한 실무 이름) 줄이 많아 55.9%(묶음 ① 씨앗 끔과 같음). +2. **추천 = 「같음은 그대로 · 나머지는 지문」** — 묶음 ① 이 「같음」으로 낸 22 줄(씨앗 · 이름 같고 조항 검사 통과)은 그대로 두고, 나머지는 지문 점수에 묶음 ① 차례 덤을 더해 줄 세움 → 1순위 88.9 / 90.5 · 다섯 안 96.8(묶음 ① 85.7 / 88.1 · 94.4). 처음 보는 파일(씨앗 끔) 85.7 / 87.3 · 96.8(묶음 ① 씨앗 끔 80.2 / 84.1 · 92.9). 미리 정한 단순 섞기(β=2)만 해도 87.3 / 89.7 · 96.0. +3. **「딱 맞음」 신호 — 금액 없이 확실함 표시** — 1위 로직의 직종 묶음이 같고 품이 모두 3% 안 · 2위와 점수 차 큼 = 고유 57 줄(엑셀 줄 220 / 504 = 44%) · 정답 있는 56 줄 모두 맞음(100%). C3 금액 ±5% 표시(21 줄 중 20)보다 넓고 정확 → 초록 표시 · [모두 확정] 후보. +4. **로직 조합이 보임** — 블록을 절(1. 현장가공 · 2. 조립)과 작은 절(가. 굴착기 · 나. 인력 10%)로 갈라 따로 맞추면 복합 정답 8 줄 중 7 이 조합 그대로(철근가공조립 = 현장가공 + 현장조립 6/6 · 도장 = 바탕 + 녹막이 + 유성) · 섞음 줄의 인력 몫이 따로 붙음(고임돌 9:1 → 모래·자갈·약돌 채집). 새 지표 「조합 맞음」 지금 76.2 → 묶음 ① 86.5 → 추천 88.9. +5. **사용자 말 맞음 — 작성자가 편의대로 바꿈** — 정답 있는 126 줄 중 이름 다름 116(92%) · 이름만 다르고 조항 · 품 같음 40 · 묶음(우리 로직에 없는 장비 · 일 더함) 40 · 계수(품) 바꿈 32 · 섞음(기계 % + 인력 %) 18 · 다른 책 조항 18 · 단위 바꿈 12 · 출처 없음 11 · 옛 판 번호 5 · 할증 · 횟수 붙임 4 · 직종 바꿈 3 · 쪼갬 2. 직종 묶음은 거의 안 바꿈(3 줄) → 직종 · 품 꼴이 이름 · 조항보다 단단한 지문. +6. **책 갈래 11 줄은 작성자 잘못이 아님** — 품 숫자가 모두 엑셀이 인용한 책과 같음(건설 7 · 산림 1 · 두 책 같은 품 2 · 장비만 건설 1). 틀린 쪽은 우리 「이름 같음」 자동 대상. 반대로 「조항은 건설 · 숫자는 산림」은 9 줄(고임돌 · 조약돌 4 · 야면석 채집 3 · 되메우기 1 · 야면석메붙임 1) → 조항은 한 신호로만. +7. **「로직 X · 계수 바뀜」 판정이 됨** — 지문 1위가 맞은 103 줄 중 품 같음 56 · 조합 같음 4 · 계수 바뀜 23 · 섞음 · 할증 붙임 3 · 직종 다름 3 · 단위 달라 크기 못 견줌 8 · 그 밖 6. 26 줄은 「로직은 맞고 다른 점만」 글로 보임 — 예 「GC000083 굴착(인력/토사) · 계수 바뀜 특별인부 0.370 → 0.354」. +8. **덤 — 입력값 추천** — 품이 같게 나오는 입력 조합 = 엑셀이 쓴 조건. 품 같은 65 줄 중 지금 규격 읽기로 같은 품 48 · 지문 변형으로만 맞는 17(합판 「유형=보통」은 규격엔 없고 블록 글 숫자에만). +9. **남는 것** — 지문 · 묶음 ① 둘 다 틀린 11 줄(고임돌 · 조약돌 6 · 소운반 3 · 측구수로관 · 철골 공장) = 기억(C6) 몫 · 보통인부 하나뿐 줄은 품 숫자로 못 가름 · 「우리에 없음」 줄에도 늘 후보가 뜸(문턱 무익). +10. **원본 로직 안 고침** — 우리 쪽은 M01 엔진으로 읽어 셈만. 고칠 곳 = M02 맞춤(새 `M02_Hopyo_Finger.py` + `Basis.read` · `Match.match_line` · `guess` 몇 줄). 우리 지문 미리 셈 ≈ 2 분 → 서버 뒤에서 · 마스터가 바뀔 때만. + +--- + +## 1. 지문 만드는 법 + +### 1-1. 바깥(엑셀) 지문 — 고유 136 줄 + +| 칸 | 뽑는 법 | 있는 줄 | +|---|---|---| +| 직종 · 품 | 「직종 : 단가 × 식 = 금액」에서 단가를 떼고 남은 식을 셈(273,540 × 3인 ÷ 55㎥ × 2 → 0.109 인/㎥) · 변수(P · Q) 든 식은 바꿔 쓴 식(= 215,907×4×0.026…)으로 · 금액 ÷ 단가 역산은 0 줄 | 118 | +| 밑품(율 뺀 품) | 「× 10%」 · 「0.75(품할증)」 · 「× 2회」 · 이름 · 절 머리의 9:1 · (90%)와 같은 맨 인자를 율로 가름 — 0.3인/㎥ × 10% → 밑품 0.3 | 율 든 직종 · 장비 | +| 장비 | 절 머리 · 줄의 장비 낱말(굴착기 · 덤프 · 크레인 · 브레이커 · 펌프차 · 집게 …) + 규격 수(0.6㎥ · 4.5톤) + 시간(「노무비 : 단가 ÷ Q × 90%」에서 단가 뗀 식) | 72 | +| 자재 | 「이름 단가 × 수량」 · 「: 별도계상」 | 50 | +| 율 · 계수 | (노무비의 2%) 꼴 56 · q · K · f · E · Cm · L · V · T 42 | — | +| 조건 낱말 | 토질 · 메/찰 · 원형/사각 · 무근/철근 · 난이 · 규모 · 방법 · N회 · N개월 · Type | 98 | +| 조항 | C1 `Basis.sources` 그대로 · 옛 책 이름(임도품셈 · 사방품셈) · 2020 이하 글은 「옛판」 | 119 | +| 절 | 1. · 가. · 1) · ① 마다 직종 · 장비를 갈라 둠 | 둘 이상 72 | + +- 영월(STmate) = 코드 칸(L · X · M · S) + QTY 칸이 곧 품 · 시간 · 수량 → 노무비 · 중기 · 재료비 · 경비 목록표로 이름을 이음. 직종 든 줄이 C1 흉내 5 → 33(36 중). +- 못 뽑는 줄 = 노임 줄 2(철골공 · 특별인부 [인]). +- 쉬운 예 — 합판거푸집(4회) N001: 형틀목공 5인 ÷ 45 = 0.111 · 보통인부 0.044 · 합판 1.03㎡ × 38% · 각재 0.038㎥ × 38% · 소모자재(주자재비의 9%) · 공구손료(노무비의 1%) · 조건 「4회 · 보통」 · 조항 건설 6-3-1. + +### 1-2. 우리 로직 지문 — 1,438 + +| 칸 | 만드는 법 | +|---|---| +| 정적 | 호표 인력(LB) → 직종 · 「로직(기계경비 …)」 부름 → 장비 갈래 · 규격(EQ) · 재료 이름 · 덧줄 %(식 「* N / 100」) · 조항(`Basis.ours`) · 이름 · 단위 · 이름과 고르기 글의 조건 낱말 | +| 동적 | M01 엔진(`mf.run` — `Labor_Doc.run_line` 과 같은 길)으로 입력 조합마다 단위당 품(인) · 장비 시간 · 재료 수량(결과단위 「원/100㎡」면 ÷100) | +| 입력 조합 | 자동값 + 고르기 입력(지역 · 조종원직종 · 보정작업 · 기종 목록 뺌) — 곱이 64 이하면 모두 · 넘으면 하나씩 바꿈 | +| 섞음 | 인력비율 · 기계비율 중간값 든 로직 23(산림 9 장 터파기 · 되메우기 …)은 「섞기 전 품」 변형 덧붙임 | +| 기계경비 로직 | 고를 수 있는 기종의 갈래 · 규격 = 지문(조종원 줄 뺌) | + +| 덮는 범위 | 수 | +|---|---| +| 엔진으로 셈 | 1,407 / 1,438 (97.8%) | +| 품 든 지문 | 1,328 — 품 모름 110(기본값으로 못 돎 31 · 품 입력이 비어 0 인 79, 예 떼붙임 「품」 칸) | +| 변형(입력 조합) | 엔진 6,767(맞춤용 6,773 = 섞기 전 · 정적 더함) · 변형 2 개 이상인 로직 775 | +| 인력 · 장비 · 재료 든 로직 | 1,347 · 512 · 275 | +| 셈 시간 | 지문 한 번 ≈ 110 초(노트북) · 맞춤 136 줄 ≈ 7 초 | + +- 쉬운 예 — GC000211 합판거푸집(건설 6-3-1) 「유형=보통」 = 형틀목공 0.112 · 보통인부 0.045(엑셀과 1% 차) · GF000160 합판거푸집(산림 12-4)은 가장 닮은 조합도 0.104 · 0.057 → 숫자로 건설. +- 곁 발견 — GC000211 자동값 「높이감률 = 1」이라 시공량이 1% 깎여 나옴(엑셀 0.111 ↔ 우리 0.112). 맞춤엔 지장 없음(3% 안) · 원본 확인은 사용자 몫. + +### 1-3. 닮음 셈 — 금액 안 씀 · 무게는 미리 정함 + +점수 = 3.0 인력 + 2.0 장비 + 1.0 자재 + 0.3 율 + 0.5 단위 + 0.6 조건 + 1.5 조항 + 0.6 이름 (로직마다 가장 닮은 변형으로) + +- 인력 = 0.35 직종 겹침(보통인부 반 무게) + 0.25 꼴(코사인 · 품 · 밑품 중 큰 쪽) + 0.40 크기(직종마다 3% 안 = 1 · 멀수록 0) — 단위가 다르면 크기 덤 없음 · 단위 별칭(개소 = 개 = 본 · hr = 시간 · 공/㎥ = 공㎥). +- 장비 = 갈래 겹침 0.6 + 규격 0.2 + 시간 0.2 · 우리에만 있는 갈래는 깎음. +- 자재 = 엑셀 자재 낱말이 우리 재료에 있는 비율 · 율 = 같은 % 하나라도. +- 조건 = 엑셀 조건 낱말이 우리 이름 · 고르기 글에 있는 비율 − 이름 축 어긋남(토질 · 메찰 · 형만 — C3 #8 대로 인력/기계는 안 깎음). +- 조항 = C1 윗마디 셈(같음 1 · 윗마디 0.9 · 앞 마디 0.5) · 옛판이면 반 · 이름 = 정리 이름 글자 두 개 묶음 닮음(약한 덤). +- 엑셀엔 없는데 우리에만 인력 · 장비 → −0.6 · −0.4. +- 딱 맞음 = 1위의 직종 묶음 같고 품 모두 3% 안(보통인부 하나뿐 줄 뺌) 또는 장비 갈래 · 시간 맞음 · 2위와 0.3 넘는 차. +- 섞기 = 지문 점수 + β × 묶음 ① 차례 덤(1 · 0.6 · 0.45 · 0.35 · 0.25) · 조합 = 절 · 작은 절마다 직종 · 장비만 떼어 같은 셈 → 절 1위 묶음. + +--- + +## 2. 수치 표 + +### 2-1. 지표 — 세 파일 고유 126 줄(칸 = 엄격 / 느슨 %) + +| 방법 | 1순위 | 다섯 안 | 임도 1위 | 사방 1위 | 영월 1위 | 엑셀 줄 무게 1위 느슨 | 우리에 없음 줄 후보 | +|---|---|---|---|---|---|---|---| +| 지금 | 71.4 / 81.7 | 86.5 / 94.4 | 71.4 | 74.4 | 67.6 | 78.1 | 5 | +| 지금(씨앗 끔) | 67.5 / 77.0 | 86.5 / 93.7 | 75.5 | 76.7 | 44.1 | 75.8 | 5 | +| 묶음 ① | 85.7 / 88.1 | 94.4 / 94.4 | 91.8 | 86.0 | 76.5 | 85.6 | 5 | +| 묶음 ①(씨앗 끔) | 80.2 / 84.1 | 92.9 / 93.7 | 91.8 | 86.0 | 55.9 | 83.9 | 5 | +| **지문만** | **81.7 / 82.5** | **93.7 / 93.7** | 89.8 | **93.0** | 55.9 | 83.0 | 10 | +| 지문 − 조항 | 65.9 / 77.8 | 88.1 / 89.7 | 79.6 | 65.1 | 47.1 | 77.9 | 10 | +| 지문 − 이름 | 74.6 / 75.4 | 86.5 / 87.3 | 85.7 | 81.4 | 50.0 | 75.4 | 10 | +| 속만(조항 · 이름 다 뺌) | 53.2 / 65.1 | 80.2 / 82.5 | 67.3 | 48.8 | 38.2 | 65.8 | 10 | +| 지문 − 품 크기(갈래만) | 78.6 / 79.4 | 90.5 / 90.5 | 89.8 | 88.4 | 50.0 | 79.4 | 10 | +| 지문 + 묶음 ①(β=1) | 88.1 / 89.7 | 96.8 / 96.8 | 91.8 | 86.0 | 85.3 | 89.4 | 10 | +| 지문 + 묶음 ①(β=2 · 미리 정함) | 87.3 / 89.7 | 96.0 / 96.0 | 91.8 | 86.0 | 82.4 | 89.4 | 10 | +| 같음은 묶음 ① · 나머지 지문만 | 88.1 / 88.9 | 93.7 / 93.7 | 89.8 | 93.0 | 79.4 | 87.3 | 10 | +| **추천: 같음은 묶음 ① · 나머지 지문 + 묶음 ①(β=1)** | **88.9 / 90.5** | **96.8 / 96.8** | 91.8 | 86.0 | **88.2** | **90.7** | 10 | +| 추천(씨앗 끔) | 85.7 / 87.3 | 96.8 / 96.8 | 91.8 | 86.0 | 76.5 | 88.5 | 10 | +| 지문 + 묶음 ①(씨앗 끔 β=2) | 84.9 / 87.3 | 96.0 / 96.0 | 91.8 | 86.0 | 73.5 | 88.5 | 10 | + +- 북부청 두 파일(92 줄) 1순위 엄격 — 지금 72.8 · 묶음 ① 89.1 · 지문만 91.3 · 추천 89.1. 사방(처음 보는 꼴 많음)은 지문만이 가장 좋음(93.0 · 다섯 안 100). +- 신호 몫 — 조항을 빼면 −15.8 · 이름을 빼면 −7.1 · 품 크기를 빼면 −3.1 · 속만(조항 · 이름 다 뺌)으로도 1순위 53% · 다섯 안 80%. +- 지문만과 묶음 ① 둘 중 하나라도 맞음 115 / 126(91.3%) · 둘 다 틀림 11 → 추천이 둘의 장점을 대부분 모음(112 / 126). +- 주의 — β 와 「같음은 그대로」 규칙은 표본을 보고 고름(한 줄 = 0.8pt) · 미리 정한 β=2 가 보수 값. + +### 2-2. 지금 실패 36 줄 — 갈래별 1순위 엄격으로 풀린 줄 + +| 갈래(C3) | 지금 실패 | 묶음 ① | 지문만 | 추천 | +|---|---|---|---|---| +| 책 갈래(산림 ↔ 건설) | 11 | 10 | 11 | 11 | +| 같은 뜻 다른 말 · 현장말 | 11 | 0 | 1(철골설치) | 1 | +| 우리 로직이 더 잘게 | 5 | 5 | 5 | 5 | +| 그 밖(접힘 · 이름 함정) | 5 | 3 | 5 | 5 | +| 장비 · 노임 이름 줄 | 2 | 0 | 2(펌프차 · 크레인 → 기계경비) | 1 | +| 괄호 속 규격 | 1 | 0 | 1(터파기 토사 9:1) | 0 | +| 엑셀 복합 호표 | 1 | 0 | 0 | 0 | +| **합(36 중)** | — | **18** | **25** | **23** | +| 지금 맞던 줄이 새로 틀림 | — | 0 | 12(영월 11 — 씨앗 8 · 짐작 정답 2 · 큰돌채집 1 · 도장 1) | 1(영월 큰돌채집) | + +- 현장말 중 고임돌 · 조약돌 6 줄 — 추천에서 1 줄(영월 Y015) 풂 · 나머지 5 줄은 정답(인력 몫 GF000211)이 2~4 위 · 1위는 숫자가 더 닮은 9:1 터파기 로직(4 절). + +### 2-3. 로직 조합 — 절마다 맞춤 + +| 엑셀 줄 | 절 1위 묶음(지문) | 정답 조합 | 판정 | +|---|---|---|---| +| 철근가공조립(현장) ×6 | 현장가공 GC000206(0.67 · 0.22) + 현장조립 GC000207(3.53 · 1.18) | 같음 | 6/6 맞음 | +| 도장(철재면 2회) | 바탕 GC000705 + 녹막이 GC000714 + 유성 GC000712 | 같음 | 맞음(줄 1위는 「걸레받이용 페인트칠」) | +| 철골가공조립(공장) | 엉뚱(GC000904) + 공장용접 GC000560 | GC000559 + GC000560 | 틀림 — 엑셀 「철골공」 ↔ 우리 「철골 가공조립공」 | +| 비탈면 보호공(평떼) | 건설 잔디붙임 GC000143 + 산림 줄떼 떼꽂이 GF000035 | (정답표 GC000143 주) | 두 책 로직을 섞어 묶은 꼴 | +| 고임돌채집및운반(사방 9:1) | 굴착기 채집 → 터파기(기계) GC000088 · 인력 10% → GF000211 · 적사 → GC000088 · 운반 → 기계경비 | 정답 GF000211 = 인력 몫 | 몫 맞음 | +| 야면석 채집 운반(영월 8:2) | 인력 20% → GF000214(뒷길이 35 · 0.17) + 굴착기 · 덤프 | 정답 GF000214 | 몫 맞음 | + +- 「조합 맞음」 지표(복합 8 줄 = 절 1위 묶음 ⊇ 정답 조합 · 나머지 = 1순위 엄격) — 지금 76.2 · 묶음 ① 86.5 · 지문만 82.5 · 추천 88.9. +- 절 둘 이상인 정답 줄 71 중 정답 로직이 절 1위 묶음 안에 있음 59. +- 장비만 있는 절(상차 · 운반)은 굴착기 · 덤프 든 우리 로직이 많아(512) 엉뚱하게 붙기 쉬움 — 「운반 = 덤프운반 GF000284」 정도만 믿을 만함(C5 몫). + +### 2-4. 딱 맞음 + +| | 줄 | +|---|---| +| 딱 맞음 고유 | 57 (엑셀 줄 220 / 504 = 44%) | +| 정답 있음 → 1위 맞음 | 56 / 56 | +| 우리에 없음 줄 | 1 — 영월 잔토처리(B/H 100%) = 우리 토사깍기(기계 · 굴착기 0.7)와 숫자 같음(닫힌 세계로 보면 그 로직) | +| 그 57 중 지금 1위 맞음 | 43 · 묶음 ① 55 | +| 뺀 꼴 | 보통인부 하나뿐 줄(우연 같음 — 콘크리트소운반 → 모르타르 배합) | + +--- + +## 3. 「편의대로 바꿈」 꼴 — 사용자 말 확인 + +### 3-1. 꼴 표 — 정답 있는 126 줄(한 줄에 여럿 · 정답 로직의 가장 닮은 변형과 견줌) + +| 꼴 | 줄 | 뜻 · 예 | +|---|---|---| +| 이름 다름 | 116 | 거의 전부 — 「레미콘타설(장비사용)」 ↔ 「레디믹스트콘크리트 타설」 | +| 이름만 다름 | 40 | 조항 같음 · 품 3% 안 · 섞음 · 묶음 없음 — 합판거푸집 4회 = GC000211 「유형=보통」 | +| 인용 조항만 다름 | 3 (+ 출처 없음 · 품 같음 6) | 품 같은데 조항이 다른 책 · 번호 — 영월 되메우기 「건설 3-1-3」 = 산림 9-14 섞기 전 0.1 | +| 계수(품) 바꿈 | 32 | 같은 직종 · 품이 3% 넘게 다름 — 세 토질 평균 · 옛 판 값 · 두 작업을 한 품으로 | +| 묶음 | 40 | 우리 정답 로직에 없는 장비 · 일을 더함(채집 + 상차 + 운반) · 복합 8 | +| 쪼갬 | 2 | 우리 한 로직을 두 줄로 — 원형돌망태 조립설치(S079) + 채움(S080) = GF000328(기계 시공) · 단위도 ㎡ → 조(0.45㎡) | +| 섞음(기계 % + 인력 %) | 18 | 9:1 · 8:2 · (인력100%) — 고임돌 인력 0.3 × 10% | +| 할증 · 횟수 · 보정 붙임 | 4 | 품할증 0.75 · 보정계수 1.36 · × 2회 | +| 다른 책 조항 | 18 | 고임돌 「건설 1-10」(단위중량 인용) · 야면석 「건설 8-2」(2016 삭제) | +| 옛 판 · 없는 번호 | 5 | 「임도품셈 5-5」 · 「사방품셈 P52」 | +| 출처 없음 | 11 | 철골설치 · 사방 9:1 채집 | +| 단위 바꿈 | 12 | 개소 ↔ 본 · ㎡ ↔ 개 · 조 · ㎡ ↔ ㎥ | +| 직종 바꿈 | 3 | 다웰바(철근공 더함) · 펌프카(편성 다름) · 터파기 9:1(특별인부 더함) | + +- 정답 로직 대 엑셀 품 판정(126) — 같음 58 · 조합 같음 5 · 섞음 · 할증 붙임(밑품 같음) 9 · 계수 바뀜 29 · 조합 계수 바뀜 3 · 단위 달라 못 견줌 12 · 직종 다름 3 · 장비 · 기계경비 4 · 그 밖 3. +- 읽는 법 — 이름은 거의 다 바꾸고(92%) 조항도 넷에 하나꼴로 다른데, 직종 묶음을 바꾼 줄은 3 뿐 · 품을 그대로 둔 줄이 절반 넘음(같음 · 밑품 같음 72) → 「어떤 로직인가」는 직종 · 품 꼴이 가장 잘 말함. + +### 3-2. 책 갈래 11 줄 — 품 숫자는 어느 책 + +| 줄 | 엑셀 인용 | 엑셀 품 | 건설 로직 | 산림 로직 | 숫자 | +|---|---|---|---|---|---| +| 합판거푸집 N001 · N025 · Y029 | 건설 6-3-1 | 형틀 0.111 · 보통 0.044 | GC000211 0.112 · 0.045 | GF000160 0.104 · 0.057(가장 닮은 조합) | 건설 | +| 문양거푸집 N015 · S086 | 건설 6-3-4 | 0.07 · 0.03 | GC000215 같음 | GF000161 같음 | 두 책 같은 품 | +| 강관동바리 N044 · S057 | 건설 2-6-1 | 형틀 0.06 · 보통 0.02 | GC000015 같음 | GF000189 0.07 · 0.05(㎥) | 건설 | +| 강관비계 N046 · S056 | 건설 2-7-1 | 비계 0.055 · 보통 0.018 | GC000022 같음 | GF000188 비계 0.1(공㎥) | 건설 | +| 돌망태형 옹벽 N037 | 산림 13-114(= 13-11-4) | 석공 0.072 · 특별 0.053 · 보통 0.013 | GC000342 0.19 · 0.134 · 0.117 | GF000235 같음(단위 ㎡ ↔ ㎥ 표기만 다름) | 산림 | +| 면고르기 N032 | 건설 3-5-1 | 굴착기만 | GC000111 장비만(시간 다름) | GF000124 보통인부 + 장비 | 구성 건설 | + +- 11 줄 모두 숫자 = 엑셀 인용 책 → 작성자는 책을 바르게 적음 · 틀린 쪽은 우리 「이름 같음」 자동 대상(묶음 ① 같음 근거 검사 · 지문 둘 다 11 줄 풂). +- 정답 · 허용에 두 책이 다 있는 64 줄로 넓히면 — 숫자 = 인용 책 31 · 두 책 같은 품 8 · 어느 책과도 3% 넘게 다름 23 · 인용 건설 · 숫자 산림 1(영월 야면석메붙임 「건설 7-2-1」 · 품 = 산림 13-5-2) · 출처 없음 · 숫자 산림 1. +- 「조항은 건설 · 숫자는 산림」 모두 9 줄 = 고임돌 · 조약돌 4(「건설 1-10」 단위중량 인용 · 밑품 0.3 · 0.6 = 산림 13-2-1 막자갈 · 조약돌) · 야면석 채집 3(「건설 8-2」 2016 삭제 · 밑품 0.17 = 산림 13-2-4) · 되메우기 1 · 야면석메붙임 1. + +### 3-3. 「로직 X · 계수 바뀜」 판정 — 되는가 · 몇 줄 + +- 지문 1위가 맞은 103 줄 — 품 같음 56 · 조합 같음 4 · 계수 바뀜 21 + 조합 2 · 섞음 · 할증 붙임 3 · 직종 다름 3 · 단위 달라 크기 못 견줌 8 · 기계경비 · 자재 · 품 모름 6. +- 「로직은 맞추고 다른 점만」 글로 보일 줄 = 26(계수 23 · 섞음 3) · 직종 다름 3 은 「직종 다름(철근공 더함)」 글로 · 단위 다른 8 은 꼴(비율)만 견줌. + +| 엑셀 줄 | 판정 글 | 까닭(엑셀 블록) | +|---|---|---| +| 구조물터파기(인력100%) 토사 | GC000083 굴착(인력/토사) · 계수 바뀜 특별인부 0.370 → 0.354 | 보통 · 경질 · 자갈섞인 토사 평균 | +| 임도안전표지판설치 | GC000308 교통 안전표지판 설치 · 계수 바뀜 특별인부 0.167 → 0.258 · 보통인부 0.083 → 0.129 | 지주 · 표지판 두 작업을 한 품으로 | +| 야면석찰붙임(영월) | GC000263 찰붙임(굴착기+집게) · 계수 바뀜 석공 0.050 → 0.081 · 보통인부 0.020 → 0.027 | 옛 판 값 | +| 구조물되메우기(인력100%) | GF000113 되메우기 · 섞음 비율 바뀜(인력 10% → 100%) · 품 같음 0.1 | 산림 9-14 섞음 비율 무시 | +| 고임돌채집및운반(9:1) | GF000211 모래·자갈·약돌 채집 · 섞음 10%(밑품 0.3 = 막자갈) + 굴착기 · 덤프 묶음 | 채집 + 상차 + 운반 한 줄 | +| 철근가공조립(현장) TypeⅠ | GC000206 + GC000207 · 조합 같음 | 건설 6-2 윗 조항 하나로 | + +- 엑셀 조건값 — 품 같은 65 줄 중 지금 규격 읽기(`spec_inputs`)로 같은 품 48 · 지문 변형으로만 같은 품 17 → 지문 1위 변형을 입력값 추천으로 쓰면 17 줄 입력이 맞아짐(예 합판 「사용횟수 4회」만 읽고 「유형=보통」은 놓침). + +--- + +## 4. 남는 실패 · 한계 + +| 남는 1순위 실패 | 지문만 23 | 추천 14 | 풀 길 | +|---|---|---|---| +| 고임돌 · 조약돌(9:1 · 정답 = 인력 몫) | 6 | 5(정답 2~4 위) | 숫자로는 「인력 10% + 굴착기 90%」 꼴의 터파기 로직(GF000103)이 더 닮음 → 절 조합(2-3)으로 보이거나 기억(C6) | +| 소운반(옛 9-1 인력운반 식) | 3 | 3 | 단위 ㎡ ↔ ㎥ · 식이 다름 → 기억 | +| 측구수로관 · 철골 공장 · 철골설치 | 2 | 3 | 직종 이름 다름(철골공 ↔ 철골 가공조립공) · 플륨관 이름 | +| 펌프차 · 터파기(토사) | 0 | 2 | 지문은 맞히나 섞기에서 잃음 — 「딱 맞음」이 아닌 줄 | +| 영월 씨앗 · 짐작 정답 줄(사용자 확정 · 짐작이 숫자와 다름) | 10 | 0 | 「같음은 그대로」 · 섞기로 풂 — 예 콘크리트타설 Y008: 숫자는 산림 GF000166(0.15 · 0.27 정확) · 씨앗은 건설 GC000198 | +| 영월 큰돌채집운반(직종 · 단위 바꿈 — 엑셀 보통인부 ↔ 우리 작업반장 · ㎡ ↔ 개) | 1 | 1 | 기억 | +| 도장(복합) | 1 | 0 | 조합으로는 맞음 | + +- 보통인부 하나뿐 줄(되메우기 · 잔토처리 · 떼붙임 …) — 같은 품 로직이 수십 → 품 숫자로 못 가름 · 이름 · 조항 · 기억이 가름(딱 맞음에서도 뺌). +- 우리에 없음 10 줄 — 지문은 늘 가장 닮은 것을 내어 후보 뜸 5 → 10 · 1위 점수 문턱 2.5 = 없음 4 숨김 · 맞은 4 잃음(C3 #33 과 같은 결 · 무익). 다만 영월 잔토처리(B/H 100%)는 숫자가 토사깍기(기계)와 같아 「닫힌 세계」로는 그 로직(C5 와 겹침). +- 품 모름 로직 110 — 직종 이름 · 조항 · 이름으로만 셈(예 GF000192 P.V.C 파이프 · GF000025 떼붙임 「품」 빈 칸). +- 같은 품 로직 둘 — 영월 포장절단 GF000262 · GF000163 처럼 우리 쪽에 숫자가 같은 로직이 있으면 숫자로 못 가름. +- 표본 3 파일(둘이 같은 발주처) · 정답표 일부 짐작(31) · 영월 씨앗 줄은 사용자 선택이 정답 · 무게 · β · 규칙은 표본을 보고 다듬음 → 다른 발주처 파일로 다시 잴 것. +- 북부청 꼴 식 풀기는 글 꼴(「직종 : 단가 × 식 = 금액」)에 기댐 — 거창 꼴(줄 = 노임 · 자재)은 줄 자체가 지문이라 더 쉬움(안 잼). + +--- + +## 5. 코드로 옮길 때 자리 + +| 할 일 | 자리 | 비고 · 흉내 | +|---|---|---| +| 엑셀 지문 뽑기(직종 품 · 밑품 · 장비 · 자재 · 율 · 계수 · 조건 · 절) | 새 `M02_MasterTemplete/M02_Hopyo_Finger.py` `excel(...)` — `M02_Hopyo_Basis.read`(:84-125)가 블록을 돌 때 함께 불러 블록에 「지문」 칸 | Basis 266 줄 → 새 파일(700 줄 지킴) · `s1_excel_fp.north` | +| STmate 지문 | C3 ④ STmate 읽기 안(코드 · QTY · 목록표) | `s1_excel_fp.yeongwol` · C6 와 겹침 | +| 우리 지문 미리 셈 | `M02_Hopyo_Finger.ours()` — `m01.loaded()` + `m01.cm.mf.run`(입력 조합) · 마스터 자국이 바뀔 때만 · 처음 ≈ 2 분 → `M01_MasterData_Store_Cache.warm` 처럼 뒤에서 · 메모리(원하면 tmp 캐시) | `s2_ours_fp.py` | +| 닮음 셈 · 딱 맞음 · 판정 글 | `M02_Hopyo_Finger.score · sure · verdict` | `s3_match.score_pair · sure` · `s4_changes.compare` | +| 후보에 합치기 | `M02_Hopyo_Match.match_line`(:404-414) — 근거 · 이름 후보(`hopyo_basis.merge` 뒤)에 지문 점수를 더해 다시 줄 세움 · 지문 상위도 후보에 넣음 · 같음(:395-403)은 그대로(추천 규칙 · 묶음 ① ⑪ 과 함께) | 금액 안 씀 | +| 입력값 추천 | `M02_Hopyo_Match.guess`(:417-428) — 지문 1위 변형 입력을 `spec_inputs` 빈 칸에 | 17 줄 | +| 조합(절마다) | `M02_Hopyo_Finger.sections` | C5 와 겹침 — 한 벌로 | +| 화면 | 고르기 창 후보 글에 「딱 맞음」 · 「계수 바뀜 …」 · 절 조합 | C3 ② 고르기 창 손질과 같이 | +| 시험 | `resources/tester/test_m02_hopyo_finger.py` — 식 풀기 · 섞음 가름 · N001 → GC000211 · 철근 조합 · 딱 맞음 | | + +- 원본 로직 안 고침 확인 — 우리 쪽은 `m01.loaded()` 읽기 · 엔진 셈(`run_line` 과 같은 읽기 길)만 · `resources/master_data` · `일위대가조합.json` 쓰기 없음 · 이번 흉내도 저장소 파일 안 고침. +- 겹침 표시 — C5(조합 풀이 · 닫힌 세계): 절마다 조합(`s4_changes.combination`) · 영월 잔토처리 「없음 → 토사깍기」. C6(기억 · 시험 파일): STmate 읽기 · 딱 맞음 = 자동 확정 · 기억 후보 재료. 함수는 각자 만들었으므로 코딩 때 한 벌로 모음. + +## 부록. 스크립트 · 자료 (스크래치패드 `req226/C4/` · 각 120 초 안 · 저장소 `venv` 파이썬) + +- `fp_common.py` — 식 풀기(`parse_rhs`) · 장비 갈래 · 조건 낱말 · 닮음 셈. +- `s1_excel_fp.py` → `excel_fp.json`(엑셀 지문 136) · `s2_ours_fp.py <시작> <끝>` · `merge` → `ours_fp.json`(우리 지문 1,438 · 세 번에 나눠 ≈ 110 초). +- `s3_match.py` → `out_match.json`(지표 · 줄마다 1~5 위 · 채널 · 변형 · 딱 맞음 · 추천) · `s4_changes.py` → `out_changes.json`(조합 · 바꿈 꼴 · 판정 · 책 갈래 · 두 책). +- `s5_summary.py` 표 뽑기 · `s6_specinputs.py` 규격 읽기 대 지문 변형 · `p0*.py` 들여다보기. +- 정답 · 셈 · 지금 · 묶음 ① = C1 · C3 함수(`labels` · `labels_y` · `cats` · `s10_all` · `s20_c3_extra`)를 같은 프로세스에서 불러 씀. + +--- + + +**부록 B. C5 — 조합 풀이 · 닫힌 세계 실측 원문** (원문 `C5_조합풀이.md`) + +# 요구 226-4 C5 — 조합 풀이 · 닫힌 세계 실측 + +바깥 일위대가(호표)를 「우리 로직 · 우리 일위대가 · 단가 줄의 조합」으로 푸는 길을 실측함. 코드 · 저장소 고침 없음 — 실험만. +기준 = main_laptop_1 b7e727863 · 정답 · 셈 = 앞 조사(225-4 C1 · C3)와 같음(고유 줄 · 엄격/느슨 · 1순위 · 다섯 안 · 씨앗 켬 · 기억 없음). +「묶음 ①」 = C3 추천 결합(조항 윗마디 · 같은 이름 접힘 고침 · 조항 오타 · 같음도 근거 검사) 흉내 · 「(가)」 = 이 조사의 호표 묶음 조건 · 「조합 풀이」 = 블록 몫 벡터를 우리 로직 벡터로 풂. +스크립트 · 중간 자료 = 스크래치패드 `req226/C5/`(7 절). + +## 0. 결론 + +1. 바깥 호표 나무 — 8 파일 · 호표 335 · 줄 1,388 을 세 꼴(북부청 · 거창 · STmate)로 다 세움. 직접 줄 호표(노임 · 장비 줄이 호표에 바로) 59 · 호표 참조 줄 90 · 참조 깊이 최대 3. +2. (가) 호표 묶음 조건만으로는 줄 1순위가 작게 오름 — 묶음 ① 위 +1.6(85.7 → 87.3 · 엄격). 「한 호표 안 같은 책」은 오히려 −1.6(북부청은 한 호표 안에서 콘크리트 · 거푸집 = 건설 · 되메우기 · 돌쌓기 = 산림을 섞음) · 「다른 발주처 조합과 통째 같음」은 −0.8(그 발주처 책을 끌고 옴 · 근거 지킴을 달면 0). +3. 조합 풀이(몫 벡터 · 금액 안 씀)를 더하면 줄 1순위 87.3 → 91.3(엄격) · 93.7(느슨) · 엑셀 줄 무게 느슨 88.5 → 95.5. 쉬운 예 — 「고임돌 채집 및 운반(굴삭기 90% · 인력 10%)」이 「자갈채집 및 운반(궤도)」 대신 인력 10% 몫 벡터(보통인부 0.03 = 0.3 × 10%)로 「모래·자갈·약돌 채집(인력)」을 찾음. +4. 새 지표 「조합 맞음」(호표 통째 · 덮을 수 있는 83 호표 · 엄격) — 지금 9.6% → 묶음 ① 27.7% → +(가) 26.5% → + 조합 풀이 72.3%. 칸(호표 안 로직) 맞음 59.1 → 77.7 → 91.2%. 줄 1순위 몇 점 차이가 호표 통째에선 세 배 가까이 — 철근 가공 + 조립 · 섞인 채집 줄이 여러 호표에 되풀이되기 때문. +5. (나) 직접 줄 호표는 노임 · 장비 벡터가 이름보다 훨씬 맞음 — 판정 34 호표(STmate 인용 29 · 거창 사람 판정 5 · 짐작) 엄격 이름만 9 · 이름 + 인용 11 · 벡터 + 인용 32(느슨 27 · 29 · 33). 이름은 「같은 일」은 찾되 책을 틀림 — 직종 · 장비(굴착기 + 집게) · 품이 책 · 갈래를 가름. +6. 4 번 물음 — 우리 로직이 더 잘게(철근 가공 + 조립) 고유 6 중 4 풂(엑셀 33 줄 중 23) · 엑셀 복합 4 중 1 풂(도장 셋 풂 · 철골 가공조립 + 용접은 직종 이름 어긋남으로 못 풂) · 섞인 줄(기계 90% + 인력 10%) 11 중 인력 몫 11 · 기계 몫까지 9 · 줄 1순위 바로잡음 5. +7. 다른 꼴(거창 · STmate) 24 호표를 37 조합의 「실무 호표 — 봉화 B00005」 출처 표시(사람 판정)와 견주면 조합 풀이가 통째 14 → 17 · 조합 로직 찾음 79 → 90%(씨앗 켬 = 이미 본 파일). 씨앗 끔(처음 보는 셈)은 3 → 5 · 44 → 57% — 처음 보는 파일은 기억(C6) · 읽기가 먼저. +8. 원본 로직 · 일위대가조합.json · M01 은 읽기만 — 우리 로직 벡터는 M01 엔진(`mf.run`)을 부르기만 해서 냄. 옮길 곳 = M02 맞춤 쪽 새 파일 셋(몫 벡터 · 우리 벡터 캐시 · 조합 풀이) + 읽기 작은 고침 둘 + 사전조합 만들기 「한 줄 = 담은로직 여럿」(정할 것 8). +9. 한계 — 규칙 문턱을 같은 정답 표본을 보며 정함(과적합 위험 · 새 파일 재측정 필요) · 직종 이름 어긋남 · 숫자 입력(몫 비율 · 칠 횟수) 갈래 안 돌림 · 장비만 든 몫은 「기계 시간 로직」 규칙으로만 · 우리에 없음 줄에 엉뚱 로직. + +--- + +## 1. 나무 세우기 + +호표마다 줄 → 갈래. 단산 = 단가산출(근거) 블록 참조 · 호표 = 다른 호표 참조 · 노임 · 장비 = 직접 줄 · 근거글 = STmate 인용 줄(「건설표준품셈 7-2-2(찰붙임)」). + +| 파일 | 꼴 | 호표 | 줄 | 단산 | 호표 참조 | 노임 | 장비 | 자재 | % | 별도계상 | 근거글 | 직접 줄 호표 | 참조만 호표 | 단산 블록 | 몫 벡터 단산 줄 | 깊이 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 북부청 임도 | 북부청 xls | 68 | 415 | 268 | 1 | 0 | 3 | 78 | 1 | 64 | 0 | 3 | 64 | 95 | 257/268 | 2 | +| 북부청 사방 | 북부청 xls | 27 | 201 | 139 | 2 | 2 | 1 | 38 | 1 | 18 | 0 | 2 | 25 | 67 | 135/139 | 2 | +| 거창 | 거창 xlsx | 14 | 68 | 21 | 12 | 10 | 3 | 13 | 6 | 3 | 0 | 5 | 9 | 61 | 21/21 | 3 | +| 봉화 | STmate | 45 | 216 | 103 | 22 | 30 | 7 | 33 | 7 | 7 | 7 | 15 | 30 | 65 | 67/103 | 3 | +| 동부청 영월 | STmate | 23 | 138 | 90 | 3 | 3 | 1 | 23 | 0 | 18 | 0 | 2 | 21 | 69 | 55/90 | 3 | +| 울진 대흥 | STmate | 34 | 184 | 69 | 28 | 29 | 11 | 15 | 3 | 16 | 13 | 16 | 18 | 47 | 52/69 | 2 | +| 영덕 계류 | STmate | 9 | 37 | 2 | 3 | 14 | 5 | 4 | 3 | 0 | 6 | 7 | 2 | 15 | 1/2 | 2 | +| 울진 소광 | STmate | 25 | 129 | 43 | 19 | 17 | 5 | 15 | 9 | 9 | 12 | 9 | 16 | 40 | 38/43 | 3 | +| **합** | | **335** | **1,388** | **735** | **90** | **105** | **36** | **219** | **30** | **135** | **38** | **59** | **185** | **459** | **626/735** | | + +- 직접 줄 호표 = 노임 · 장비 줄이 하나라도 있음 · 참조만 = 단산 · 호표 참조 · 자재 · % · 별도계상뿐. +- 깊이 3 쉬운 예 — 거창 제5호표 BOX(암거) → 일위1 레미콘타설(철근) · 일위3 합판거푸집 → 일위4 합판거푸집 1회재료비(자재만). 일위1 · 3 은 노임 줄이 바로 적힌 직접 줄 호표. + +꼴마다 읽기: + +| 꼴 | 읽은 길 | 줄 갈래 | 블록 몫 벡터 | +|---|---|---|---| +| 북부청 xls | 지금 읽기 함수(`M02_Hopyo_Excel.read_book`) 그대로 | 지금 `kind_of` + 단위 「인」 · 우리 직종 이름 = 노임(사방 철골공 · 특별인부 2) | B 열 블록 글 → 맨 윗 번호 절(1. 2.) · 가. 나. 몫 · 품 = 식에서 첫 수(노임 · 장비 단가)를 1 로 두고 나머지만 셈(「273,540 × 3인 ÷ 55㎥ × 2」 → 0.109 · 「노무비 : 59,020 ÷ 23.1 × 90%」 → 장비 0.039 시간) · 식 514 풂 · 못 푼 32 는 노임 줄만 금액 ÷ 노임으로 품 되찾음(순위 · 판정엔 금액 안 씀) | +| 거창 xlsx | 지금 읽기로 호표 14 · 줄 68 읽힘 | 노임 줄이 지금은 「기타」로 로직 맞춤에 들어감(엉뚱 후보) | 단가산출 글이 한 행 수십 칸에 흩어짐 → 지금 `Basis.read`(B 열만)는 출처 0/21 · 직종 0/21 · 행 칸을 이어 붙이면 출처 17/21 · 직종 21/21 · 몫 벡터 21/21 | +| STmate xlsx 5 | 흉내 읽기(C1 `s09_stmate` 넓힘) — 일위대가표 + 단가산출근거 | 줄 코드 = 14 열 「D00096_1」(봉화 · 대흥은 12 열이 비고 · 「대가 N」 「산근 N」 글) · B 호표 참조 · D 단산 · L 노임 · M 자재(「별산」 · 「별도계상」 = 별도계상) · X 장비 · S · W 별도계상 · PR % · 빈 코드 = 근거글 · T · C = 계 줄(뺌) | 단산 블록 머리 = 6 열 「BDCOD\|D00002」 · 자원 줄 = 0 열 「L00002L」 또는 6 열 식 「&L00007L&」 + 7 열 QTY + 23 열 이름 → 노임 · 장비 수가 식 없이 바로 나옴 | + +--- + +## 2. 닫힌 세계 풀이 — 줄 + +### 2-1. (가) 호표 묶음 조건 — 지금 후보 다섯(지금 함수 그대로)에서 다시 고름 + +칸 = 1순위 엄격 / 느슨 · 다섯 안 엄격 / 느슨(고유 126 줄). 조건이 다섯 안에서만 다시 고르므로 다섯 안 값은 그대로. + +| 조건 | 무엇 | 지금 위 | 묶음 ① 위 | 쉬운 예 | +|---|---|---|---|---| +| 바탕 | | 71.4 / 81.7 · 86.5 / 94.4 | 85.7 / 88.1 · 94.4 / 94.4 | | +| 같은 책(줄 근거 먼저 · 없으면 호표 다수) | 1순위 책이 다르면 다섯 안 같은 일 다른 책으로 | 80.2 / 82.5 (+8.8) | 84.9 / 87.3 (−0.8) | 지금 위 오름은 묶음 ①의 「같음도 근거 검사」와 같은 몫 | +| 같은 책(호표 다수만 · 근거 없는 줄) | 한 호표 안 줄끼리 같은 책 | 69.8 / 80.2 (−1.6) | 84.1 / 86.5 (−1.6) | 구조물되메우기(근거 없음 · 정답 산림 9-14) → 호표 다수 건설 「되메우기 및 다짐(소형장비)」로 틀어짐 | +| 같은 조건(토질) | 줄 · 호표 이름 「토사 ↔ 암」 어긋난 후보 뒤로 | 71.4 / 82.5 (0 / +0.8) | 85.7 / 88.9 (0 / +0.8) | 구조물터파기(토사) → 「터파기(기계) 암」 대신 보통토사 | +| 같은 이름 다수(한 문서) | 정리 이름 같은 줄(규격만 다름)은 같은 로직 | 73.0 / 82.5 (+1.6) | 87.3 / 88.9 (+1.6) | 영월 콘크리트타설 · 합판거푸집(4회) 2 줄이 문서 다수 답으로 | +| 우리 조합 통째 같음(37) | 호표 로직 줄 수 = 조합 로직 수 · 줄마다 다섯 안에 조합 로직 하나 | 70.6 / 81.0 (−0.8) | 84.9 / 88.1 (−0.8) | 북부청 임도반사경 = 「도로반사경 설치」 조합(영월 출처) → 구조물터파기(인력100%)가 건설 3-2-1 → 산림 9-13-1 로 틀어짐 | +| 우리 조합 안에 다 듦 | 호표 줄(2 이상)이 모두 한 조합 안 | 70.6 / 82.5 | 82.5 / 87.3 (−3.2) | 큰돌찰쌓기 → 메쌓기로 틀어짐 | +| 통째 + 근거 지킴 | 1순위가 줄 근거 조항과 맞으면 안 바꿈 | 71.4 / 81.7 (0) | 85.7 / 88.1 (0) | | +| 안에 다 듦 + 근거 지킴 | 〃 | 73.8 / 84.1 (+2.4) | 85.7 / 88.1 (0) | 철근가공조립(현장) → 「철근 현장가공」(묶음 ①은 조항 윗마디로 이미 풂) | +| **(가) 좋은 것**(토질 · 이름 다수 · 안에 다 듦 근거 지킴) | | **75.4 / 85.7 (+4.0)** | **87.3 / 89.7 (+1.6)** | | +| 씨앗 끔 | | 67.5 / 77.0 → 69.0 / 79.4 | 80.2 / 84.1 → 81.0 / 84.9 | | + +- 「아래에서 위로」 — 정답 세 파일의 호표 참조 줄은 6 뿐이고 로직 줄이 아님 → 줄 지표엔 안 듦 · 조합 지표(3 절)에서 참조 호표 로직을 위 호표에 더함. 거창 · STmate 넷(영월 뺌)은 참조 줄 84 라 조합 지표에서 큰 몫(3-2). +- 「같은 일 몫」 — 정답 세 파일엔 몫이 줄로 나뉜 호표가 없음(북부청은 기계 90% + 인력 10% 를 한 블록에 씀) → 줄 안 몫 풀이(2-2)로 셈. +- 뜻 — 북부청 호표는 일마다 책을 골라 섞음(콘크리트 · 거푸집 · 철근 = 건설 · 되메우기 · 돌쌓기 · 채집 = 산림). 호표 단위 「같은 책」은 맞지 않는 가정 · 다른 발주처가 만든 조합을 통째로 옮기면 그 발주처의 책 선택이 따라옴(C3 「다른 발주처 기억 62~71%」와 같은 뜻). + +### 2-2. 조합 풀이 — 블록 몫 벡터를 우리 로직 벡터로 + +방법: +- 우리 로직 단위당 벡터 = M01 엔진 `mf.run(whole, 로직, 입력)` 줄의 단위 「인」(직종 · 품) · 「시간」(장비 · 시간) — 자동값 한 벌 + 고르기 입력 갈래(지역 뺌 · 곱 48 넘으면 하나씩 바꿈) · 돈 로직 1,438 · 엔진 13,854 번(85 초씩 다섯 번) · 캐시 `ourvec_var.json`. +- 맞음 = 차원(직종 · 장비 종류)마다 작은/큰 가중 평균(보통인부 0.5 · 장비는 있음 0.5 + 시간 0.5 · 크기 다름 × 0.8 · 굴착기 ↔ 굴착기 + 집게 × 0.8) · 몫 x ∈ {0.1 … 0.9}(10% · 90% 나눔) × 0.95 · 몫 표시(「인 력 (10%)」)와 같은 x 면 덤. +- 순위 = 맞음 + 앞선 믿음(지금 합친 후보 순위 0.3 · 줄 근거 조항 같음(윗마디 포함) 0.3 · 같은 책 0.1) · 후보 풀 = 지금 합친 후보 15 + 드문 직종 · 장비 색인(40 개 이하) + 절 제목 이름 후보 8. +- 고름 = 1순위 + 1순위가 못 덮는 절 · 몫의 로직(절 맞음 ≥ 0.75 · 몫 ≥ 0.6) · 장비만 든 절 · 몫 = 「기계 시간당 기계경비」(닫힌 세계 규칙 · 기종 입력) · 섞인 줄(몫 표시 + 노임만 든 몫 + 장비만 든 몫)에서 1순위가 줄 벡터를 35% 못 덮으면 1순위를 인력 몫 로직으로. + +| 단계 | 1순위 엄격 / 느슨 | 다섯 안 | 엑셀 줄 무게 1순위 느슨 | 파일별 1순위 엄격(임도 · 사방 · 영월) | +|---|---|---|---|---| +| 지금 | 71.4 / 81.7 | 86.5 / 94.4 | 78.1 | 71.4 · 74.4 · 67.6 | +| 묶음 ① | 85.7 / 88.1 | 94.4 / 94.4 | 85.6 | 91.8 · 86.0 · 76.5 | +| + (가) | 87.3 / 89.7 | 94.4 / 94.4 | 88.5 | 91.8 · 86.0 · 82.4 | +| **+ 조합 풀이(섞인 줄 1순위 바꿈)** | **91.3 / 93.7** | 94.4 / 94.4 | **95.5** | **98.0 · 90.7 · 82.4** | +| 씨앗 끔: (가) → + 조합 풀이 | 81.0 / 84.9 → 84.9 / 88.9 | 92.9 / 93.7 | | | + +- 바로잡은 5 줄(엑셀 33 줄) = 고임돌 채집 및 운반 셋 · 조약돌 채집 둘 — 「자갈채집 및 운반(궤도)」 · 「막돌 채집」 → 「모래·자갈·약돌 채집(인력)」(x = 0.1 · 맞음 0.95). +- 뺌 규칙을 모든 줄에 걸면 「구조물되메우기(인력100%)」가 틀어짐(엑셀이 인력 몫을 100% 로 바꿈 · 우리 자동값은 10%) → 섞인 줄에만 씀. + +### 2-3. 4 번 물음 — 조합 풀이로 몇 개 푸나 + +| 갈래 | 고유(엑셀 줄) | 풂 | 쉬운 예 | 못 푼 까닭 | +|---|---|---|---|---| +| 우리 로직이 더 잘게 | 6(33) | 4(23) — {철근 현장가공, 철근 현장조립} 둘 다 | 「철근가공조립(현장)」 블록 1. 현장가공(철근공 0.67 · 보통 0.22 = 6-2-2 Type-Ⅰ 맞음 0.99) + 2. 조립 → 6-2-3 | 임도 1 = 현장가공 몫 수가 달라 「공장가공」으로 · 영월 1 = 블록 몫 벡터 못 읽음 | +| 엑셀 복합 호표 · 복합 줄 | 4(4) | 1 — 도장(바탕 + 녹막이 + 유성 셋) | 「도장(철재면2회)」 = 11-1-3 + 11-2-6 + 11-2-4 | 철골가공조립(공장) = 엑셀 「철골공」 ↔ 우리 「철골 가공조립공」 직종 이름 어긋남(용접 몫만 맞음) · 잡석 구입 운반 2 = 운반 블록 하나를 골재운반이 다 덮음(덤프운반 몫 안 나뉨) | +| 섞인 줄(기계 90% + 인력 10%) | 11(75) | 인력 몫 11 · 기계 몫(기계 시간 로직)까지 9 · 1순위 바로잡음 5 | 고임돌 · 조약돌 · 야면석 채집 · 뒷채움 · 기초잡석 · 구조물터파기(토사) | 영월 섞인 줄(구조물터파기 B/H 90% · 야면석 · 고임돌 채집운반)은 STmate 블록에서 인력 · 기계 몫이 한 절로 읽혀 판별 못 함 | + +- 홑 정답 줄 105 중 조합 풀이가 로직을 둘 넘게 고른 줄 5 — 엉뚱 덧 2(다웰바 + 쉬즈관 · 되메우기 + 훈증더미) · 기계 시간 몫 덧 3(기초잡석 운반 · 잡석운반 · 포장절단). + +--- + +## 3. 새 지표 「조합 맞음」(호표 단위) + +### 3-1. 정답 세 파일(북부청 둘 · 영월) — 115 호표 · 덮을 수 있음 83 + +정답 = 앞 정답(labels · labels_y)을 호표로 모음 — 줄마다 칸 하나(정답 여럿 = 그중 하나) · 철근 가공 + 조립 · 도장 셋 · 잡석 운반 둘 = 칸 여럿(모두) · 정답 없는 줄(잔토처리 등) = 「없음」 칸(채울 수 없음 → 덮을 수 없는 호표 32) · 호표 참조 = 참조 호표 칸을 더함(아래에서 위로). +통째 = 칸이 다 차고 남는 로직 없음 · 칸 = 찬 칸 비율 · 고름 = 고른 로직 중 맞은 비율. T1 = 앞 정답 그대로(섞인 줄 = 인력 몫 하나) · T2 = 정할 것 8 다른 안(섞인 줄에 기계 몫 칸 · 철골 = 가공조립 + 용접 두 칸). + +| 우리 | T1 통째 엄격 / 느슨(덮을 수 있음 83) | T1 칸 | T1 통째(전체 115) | T1 파일별 통째(임도 · 사방 · 영월) | T2 통째 | T2 칸 | +|---|---|---|---|---|---|---| +| 지금(줄 1순위 모음) | 9.6 / 30.1 | 59.1 / 73.2 | 7.0 | 4/60 · 0/6 · 4/17 | 9.6 / 26.5 | 52.7 / 65.9 | +| 묶음 ① | 27.7 / 31.3 | 77.7 / 79.5 | 20.0 | 19/60 · 0/6 · 4/17 | 24.1 / 27.7 | 70.3 / 72.1 | +| + (가) | 26.5 / 28.9 | 77.6 / 79.0 | 19.1 | 19/60 · 0/6 · 3/17 | 22.9 / 25.3 | 70.1 / 71.6 | +| + 조합 풀이 · 기계 몫 넣음 · 뺌 | 21.7 / 24.1 | 91.2 / 92.6 | 15.7 | 15/60 · 2/6 · 1/17 | **51.8 / 54.2** | **91.6 / 93.0** | +| **+ 조합 풀이 · 기계 몫 안 넣음 · 뺌** | **72.3 / 74.7** | **91.2 / 92.6** | **52.2** | **53/60 · 4/6 · 3/17** | 42.2 / 44.6 | 80.5 / 82.0 | +| 씨앗 끔: (가) → + 조합 풀이(T1) | 24.1 → 69.9 | 72.9 → 86.5 | | | | | + +- 나눠 셈(T1 · 통째 · 83 호표) — (가) 22 → 덧(철근 조립 · 도장 몫 등)만 38 → 섞인 줄 1순위 바꿈만 40 → 둘 다 60. +- 줄 1순위 +4 점이 호표 통째 +46 점 — 같은 엑셀 줄이 여러 호표에 되풀이(고임돌채집및운반 1 줄이 임도 호표 17 개 · 철근가공조립 1 줄이 11 개). +- 남는 실패 호표(T1 · 23) — 영월 기초잡석채집 · 철근(블록 벡터 없음) · 고임돌채집운반(영월) · 야면석 · 콘크리트 소운반(현장말 · 우리에 없음) · 잡석 구입 운반 · 크레인 · 측구수로관. + +### 3-2. 다른 꼴(거창 · STmate) — 37 조합 출처 표시를 사람 판정(짐작)으로 + +일위대가조합.json 37 조합 비고 「실무 호표 — 봉화 B00005 · 울진대흥 B01224」를 호표 정답으로 씀. 출처 하나뿐인 조합 24 호표 = 통째 견줌 · 출처 여럿 63 = 고른 것 맞음만(조합 = 여러 호표 합). 「사람이 뺀 줄」(비고의 「우리 로직에 없음」 · 「이름이 달라 뺌」) 줄은 우리 쪽에서도 뺌. +우리 — 지금 = 줄마다 지금 1순위(노임 · 장비 줄도 지금처럼 로직 줄로) / 조합 풀이 = 단산 줄 묶음 ① + 조합 풀이 · 직접 줄 = 벡터 풀이(4 절) · 호표 참조 = 아래에서 위로. + +| | 씨앗 켬(이미 본 파일) 통째 · 찾음 · 고름 | 씨앗 끔(처음 보는 셈) 통째 · 찾음 · 고름 | 출처 여럿 63 고름(켬 · 끔) | +|---|---|---|---| +| 지금 | 14/24 · 79.3% · 81.7% | 3/24 · 44.2% · 46.4% | 59.2% · 38.2% | +| **조합 풀이** | **17/24 · 90.2% · 92.8%** | **5/24 · 56.5% · 58.0%** | 59.2% · 45.0% | + +- 쉬운 예 — 거창 제5호표 BOX(암거): 지금 = {굴착(인력/토사) · 철근 현장가공 · 유로폼 · 콘크리트 포장(인력시공) · 강관비계}(노임 줄 「콘크리트공 · 보통인부」를 로직 이름으로 맞춤) · 조합 풀이 = {레디믹스트콘크리트 타설 · 합판거푸집 · 유로폼 · 철근 현장가공 · 철근 현장조립 · 강관 동바리 · 강관비계(쌍줄)} → 조합 UA000014 일곱 중 여섯 같음(합판거푸집 책만 다름) · 씨앗 꺼도 같음. 날개벽(제7 · 8호표)은 통째 같음. +- 어긋남 다수 = 같은 일 다른 책 — 울진소광 파형강관 · 문양거푸집 · 돌조공: 엑셀 인용은 건설(「건설품셈 토목 6-7-2」 · 「※ 건설품셈 6-3-4」 · 「건설품셈 7-1-1」) · 우리 풀이도 건설 · 조합(사람)은 산림 → 책 정책(앞 검토 정할 것 1)에 따라 판정이 갈림. +- 씨앗 켬 · 끔 차이(통째 14 → 3)가 조합 풀이 효과(+2~3)보다 큼 — 다른 꼴에서는 기억(이미 확정한 조합)이 가장 큰 몫. + +--- + +## 4. 직접 줄 풀이 (나) + +직접 줄 호표 59(북부청 5 · 거창 5 · STmate 49). E = 호표의 노임 · 장비 줄 벡터(참조 줄 뺌). 견줌: +- 이름만 = 호표 머리(이름 · 규격 · 단위)를 지금 맞춤 함수에 줄로 넣음(앞 검토 방법 40 흉내). +- 이름 + 인용 = STmate 근거글(「건설표준품셈 7-2-2(찰붙임)」)을 근거 출처로 함께. +- 벡터 풀이 = 2-2 엔진(통째 하나 맞음 ≥ 0.85 · 아니면 몫) · 맞음이 낮으면 음 아닌 최소제곱 짝(둘) 참고. +- 판정 정답 = 인용 정답(인용 조항이 같고 직종이 겹치는 우리 로직 · 29 · 짐작 — 옛 판 번호 섞임) + 거창 사람 판정 5(레미콘타설 셋 = 6-1-1 · 합판거푸집 = 6-3-1 · 강관동바리 = 2-6-1 · 조합 UA000014 와 맞음). + +| 길 | 엄격(34) | 느슨(같은 일 다른 책 · 34) | +|---|---|---| +| 이름만 | 9 | 27 | +| 이름 + 인용 | 11 | 29 | +| 벡터(인용 없이) | 15 | 31 | +| **벡터 + 인용** | **32** | **33** | + +- 쉬운 예 — 영덕 「돌붙임 메붙임 L3=35cm」(석공 0.13 · 보통인부 0.04 · 굴삭기 + 집게 0.25 시간 · 인용 건설 7-2-1): 이름만 = 「돌붙임(인력)」 · 벡터 + 인용 = 「메붙임(굴착기 + 부착용 집게)」(맞음 0.91). 거창 제2호표 레미콘타설(무근): 이름만 = 「펌프카 타설」 · 벡터 = 「레디믹스트콘크리트 타설」(콘크리트공 0.06 · 보통 0.02 · 굴착기 0.09). +- 정답 없는 25 호표 사람 판정(짐작) — 맞음 16(기계 시간 로직 넷 · 앵커볼트 · 찰쌓기 · 메쌓기 · 포장 인력포설 · 잡철물 · 와이어메시 · 파형강관 · 관목 굴취 · 식재 · 모르타르 · 우리에 없음 둘을 빈 답) · 틀림 6(녹막이 1회 + 조합 2회 · 유성페인트 3회 둘 = 곱 · 합 못 풂 · 표면마무리 못 찾음 · 글자새김 → 찰쌓기(인력) · 규준틀 → 마루틀 = 우리에 없음인데 엉뚱) · 모름 3. +- 음 아닌 최소제곱 짝은 이름 후보 풀에서 엉뚱한 짝(철부도장 → 강관 접합부 도장 + 강교 도장 · 규준틀 → 마루틀 + 건축물보양 × 11.5) — 풀을 직종 · 장비 색인과 인용으로 좁히지 않으면 못 씀. 집합 덮기(절 · 몫마다 하나 · 맞음 문턱)가 낫음. + +--- + +## 5. 남는 실패 · 한계 + +| 갈래 | 예 | 풀 길(제안 · 코드 안 고침) | +|---|---|---| +| 현장말 · 우리에 없음 | 야면석소운반 · 콘크리트소운반 · 잔토처리 · 글자새김 · 녹생토 | 조합 풀이로 못 품 → 「우리에 없음」 빈 답 + 기억(C6) · 맞음 문턱 아래 거름 | +| 직종 이름 어긋남 | 엑셀 「철골공」 ↔ 우리 「철골 가공조립공」 · 거창 철근 블록 「철근공」만 읽힘 | LB 옛이름 쪽 짝 표(마스터 밖 맞춤 사전) — 원본 LB 안 고침 | +| 숫자 입력 갈래 안 돌림 | 「인력 100%」로 바꾼 되메우기 · 칠 3회 · 소량 철근(3.53 인) | 고르기 갈래만 돌림 → 몫 비율 · 횟수 · 운반거리 같은 숫자 입력은 몫 x 에 2 · 3 · 10 배까지 넣어 봄(위험 셈 필요) | +| 장비만 든 몫 | 상차 · 적사 · 운반 · 펌프차 시간 | 일 로직(덤프운반 등)인지 기계 시간 로직인지 벡터로 못 가름 → 지금은 기계 시간 로직 규칙 · 덤프운반(적재 + 운반) 같은 굵은 로직은 절 이어 붙여 셈 필요 | +| 같은 일 다른 책 | 산림 · 건설 벡터가 거의 같음(돌붙임 · 합판거푸집) | 벡터만으론 책 가름 약함(인용 없이 엄격 15/34) → 인용 · 줄 근거 · 책 정책 | +| 영월(STmate) 블록 | 몫 벡터 55/90 · 인력 · 기계 몫이 한 절 | STmate 절 표시(「1. 인 력 (10 %)」) 위치 맞춤 · 직종은 노무비목록표로 | +| 과적합 | 문턱(0.6 · 0.75 · 0.85 · 35%) · 뺌 규칙을 정답 표본을 보며 정함 | 새 파일(다른 발주처)로 다시 잼 — 다른 꼴 24 호표에선 씨앗 끔 통째 +2 · 찾음 +12 점으로 작게 오름 | +| 정답 판정 | 인용 정답 = 작성자 인용(옛 판 번호) · 조합 정답 = 사람이 뺀 줄 · 책 선택 | 「짐작」 표시 · 사용자 판정 표본 늘림 | +| 실행 시간 | 우리 벡터 캐시 13,854 번 엔진(약 7 분) | 마스터 판(sha) 열쇠 디스크 캐시 · 또는 후보 로직만 그때그때 | + +--- + +## 6. 코드로 옮길 때 자리 — 원본 로직 안 고침 확인 + +| 할 것 | 자리 | 원본 로직 · 마스터 | +|---|---|---| +| 노임 줄 갈래(단위 「인」 + 우리 직종 이름) · 장비 줄은 지금대로 | `M02_MasterTemplete/M02_Hopyo_Excel.py` `kind_of` :132-149 | 안 고침 | +| 거창 꼴 단가산출 블록 = 행 칸 이어 붙여 읽음 | `M02_Hopyo_Basis.py` `read` :84-125(지금 B 열 한 칸) | 안 고침 | +| STmate 읽기(일위대가표 · 단가산출근거 · 줄 코드 14 열 · 자원 줄) | 새 파일(예: `M02_Hopyo_Stmate.py`) · `M02_Hopyo_Excel._sheets` :74-102 시트 이름 | 안 고침 | +| 블록 → 절 · 몫 · 품 벡터(식 풀이) | 새 파일(예: `M02_Hopyo_Parts.py` ← c01 `sections` · `qty_of` · `sections_stm`) | 안 고침 | +| 우리 로직 단위당 벡터 + 갈래 캐시 | 새 파일(예: `M02_Hopyo_Vec.py` ← c02 · c03) — `M01_MasterData_Store` 의 `mf.run` · `mau.values` 부르기만 | 엔진 읽기만 | +| 조합 풀이(1순위 + 못 덮는 몫 · 섞인 줄 1순위 바꿈 · 직접 줄 호표 풀이) | 새 파일(예: `M02_Hopyo_Combine.py` ← c05) · `M02_Hopyo_Match.match_line` :367-414 결과에 「조합」 칸 · `match_hopyo` :440-453 에 직접 줄 풀이 · `view` :460-474 에 같은 이름 다수(문서 한 번) | 안 고침 | +| (가) 토질 · 같은 이름 다수 · 조합 안 근거 지킴 | `M02_Hopyo_Match.view` · `match_hopyo`(문서 · 호표를 함께 보는 곳) — Match 534 줄이라 새 파일로 | 일위대가조합.json 읽기만 | +| 사전조합 만들기 — 한 줄 = 담은로직 여럿(철근 가공 + 조립 같은 수량 · 섞인 줄 인력 몫 × 0.1 · 기계 몫 = 기계 시간 로직 × 시간) | `M02_Hopyo_Build.py` `Builder.build` :110-175(지금 한 줄 = 로직 하나 · 같은 로직 둘째 감쌈) | 정할 것 8 · 사전조합 꼴 | +| 조합 기억(호표 · 줄 열쇠 → 로직 모음 · 몫) | `M02_Hopyo_Match.confirm` :495-526 · `M02_Hopyo_Store.remember` | C6 와 겹침 | +| 시험 | `resources/tester/test_m02_hopyo_match.py` · `_basis` · `_excel` + 새 시험(몫 벡터 · 조합 풀이 거울) | | + +- 원본 확인 — 실험은 `resources/master_data`(로직 · 계수 · 인력 · 일위대가조합.json)와 M01 를 읽기만 함(`m01.loaded()` · `mf.run` 부르기 · 파일 쓰기 없음) · 37 조합은 통째 비교 · 출처 정답으로만 씀 · M02 마스터 사전조합은 견본 1(SC-000001)뿐이라 「우리 일위대가」 = 37 조합. +- 계산 자리(CLAUDE.md 5 장) — 조합 풀이는 서버 단독(③) · 브라우저는 안 셈. + +--- + +## 7. 방법 · 실행 경로 · 겹침 + +- 스크립트(`req226/C5/` · 저장소 `venv/Scripts/python.exe -B` · 차례) — `c01_trees.py`(나무 · 몫 벡터 → `trees.json`) → `c02_ourvec.py`(자동값 벡터 → `ourvec_auto.json`) → `c02b_varcache.py 0 1438 85` 다섯 번(갈래 벡터 → `ourvec_var.json`) → `c04_closed.py`((가) → `closed.json`) → `c06_lines.py`(정답 줄 조합 · 4 번 → `lines_solved.json`) → `c08_drop.py`(섞인 줄 1순위 바꿈 → `final.json`) → `c07_hopyo.py`(조합 맞음 → `hopyo_metric.json`) → `c09_direct.py`((나) → `direct.json`) → `c10_others.py [끔]`(다른 꼴 · 37 조합 → `others.json` · `others_off.json`). 엔진 부품 = `c03_vec.py`(맞음) · `c05_solve.py`(조합 풀이). 진단 = `t_ablate`(덧 · 뺌 나눠 셈) · `t_off`(씨앗 끔) · `t_rows`(엑셀 줄 무게) · `t_stats`(읽기 셈) · `t_fail`(실패 호표) · `t_fallback`(식 풀이 셈). +- 앞 조사 부품을 그대로 부름 — C1 `s04_eval` · `s10_all`(정답 · 셈 · run) · C3 `s16_guard.typo_guard`(묶음 ①) · `clause_prefix` · `candidates_nofold`. +- 겹침 — C4(로직 속 지문): 몫 벡터(직종 · 품 · 장비) · 우리 로직 벡터 · 맞음 점수가 같은 재료 → 여기서는 조합(여럿 · 몫 · 호표)으로만 씀 · 줄 하나 지문 순위는 C4 결과와 맞대어 하나로. C6(기억 · 시험 파일 넓히기): STmate · 거창 읽기 흉내(c01) · 씨앗 켬/끔 차이(= 이미 본 조합 기억 효과) · 조합 기억 칸이 겹침. + +--- + + +**부록 C. C6 — 기억 · 시험 파일 넓히기 실측 원문** (원문 `C6_기억실측.md`) + +# C6 — 기억 · 시험 파일 넓히기 실측 (요구 226-4) + +조사 · 실험만 — 저장소 안 고침(끝에 `git status` 깨끗 · HEAD b7e727863). 스크립트 · 중간 자료 = 스크래치패드 `req226/C6/`. +셈은 336 과 같음(고유 줄 · 엄격 / 느슨 · 1순위 · 다섯 안 · 씨앗 켬 · 끔 · 기억 없음) + 새 지표 「로직 조합 맞음」. + +## 0. 결론 + +1. 시험 자료를 3 파일 → 8 파일로 넓힘 — STmate 꼴 5 사업(봉화 · 영월 · 울진대흥 · 영덕계류 · 울진소광) + 거창. 고유 로직 줄 136 → 415(호표에 쓰인 줄 231) · 엑셀 로직 줄 504 → 783. +2. STmate 는 한 읽기 함수로 두 판(25 열 · 45~47 열)을 다 읽음 — 열쇠 칸(12열 자원 코드 · 7열 품 · 23열 이름)이 같음. L 코드 번호는 파일마다 다름(봉화 L00004 = 석공 · 영월 L00002 = 보통인부) → 직종 이름은 파일마다 23열 · 노무비목록표에서 이음(두 곳 어긋남 0 · 23열이 빈 1 줄은 목록표로 채움). +3. 자동 정답(엄격 — 인용 조항 같음 + 직종 · 품 ±3% 가 우리 로직 하나와만 맞음) = 58 줄. 사람 정답과 겹친 38 줄 모두 같음(100%) · 손 확인 20 줄 모두 맞음. 다만 줄 수가 적고(단가산출 줄의 15%) 레미콘 · 유로폼 두 로직이 절반 — 「쉬운 줄」 정답. +4. 지금 맞춤 실력(336 같은 정답 126 줄) = 1순위 71.4 · 다섯 안 86.5 그대로 재현. 영월 직종을 노무 목록으로 이으면 영월 1순위 67.6 → 73.5(씨앗 끔 44.1 → 52.9) — 336 이 「잼 못 함」으로 둔 몫. +5. 처음 보는 STmate · 거창 줄(씨앗 짝 43 줄 · 씨앗 끔) = 1순위 41.9 · 다섯 안 65.1 — 북부청(75%)보다 크게 낮음. 거창은 단가산출 글이 여러 칸에 흩어져 지금 읽기로는 근거 0 → 1순위 0~11%. +6. 기억 열쇠 넷 중 넓게 닿는 것 = (b) 품명 · 단위(앞 파일 모두 기억 시 줄 33%) · 틀림 없는 것 = (c) 로직 속 지문(단위 · 직종:품 · 장비 · 조항 — 서로 다른 줄 49 중 49 맞음 · 다른 발주처 30/30). (a) 지금 열쇠는 8% 만 닿음 · (d) 호표 조합 지문은 2% — 쓸모 없음. +7. 해로움은 「다른 발주처 + 엑셀 인용 조항과 기억 답 조항이 다름(근거 다름)」에 몰림 — (b) 9/20 만 맞음 · (c2) 6/15. 같은 발주처는 열쇠 a · b · c 모두 100%. 해로움 예 = 산림 ↔ 건설 책 갈림(구조물터파기 · 면고르기 · 콘크리트포장 · 야면석붙임 · 지수판). +8. 「경우에 따라서는」 = 근거 판정으로 가름: (a · c) 근거 맞음/인용 없음 → 바로 씀 · (b · c2) → 1순위 후보 · 근거 다름 → 2순위 후보. 앞 파일 모두 기억 흉내에서 1순위 72.1 → 78.6 · 해로움 0 · 바로 들어간 32 줄 모두 맞음(발주처 표시까지 쓰면 80.0 · 39 줄 모두 맞음). 지금 코드(a 만 바로) = 72.1 그대로 · 「a · b 모두 바로」 = 76.4 이나 해로움 6. +9. 새 지표 「로직 조합 맞음」(호표 하나의 로직 줄이 모두 1순위 맞음) — 사람 정답 세 파일 호표 113 중 15(13%) → 기억 가름 33(29%). 자주 되풀이되는 줄 하나가 틀리면 호표 여럿이 함께 틀림 → 기억 효과가 줄 지표보다 큼. +10. 설계 안 = 지금 `맞춤.json`(열쇠 a 하나 · 덮어쓰기)에 둘째 · 셋째 열쇠(b · c) · 출처 파일 · 엑셀 조항 · 로직 판을 덧붙이고, recall 을 「열쇠 차례 a → c → b → c2 + 근거 판정」으로 — 원본 로직 · 일위대가조합.json · M01 은 안 건드림. + +--- + +## 1. 넓힌 자료 + +### 1-1. 파일 표 + +| 파일(발주처 · 해 · 꼴) | 호표 | 줄 | 로직 줄(엑셀) | 고유 로직 줄(호표 쓰임/모두*) | 단가산출 블록(모두/호표 쓰임) | 출처 있음 · 직종 있음(고유) | 호표 = 로직** | 자동 정답 엄격/준엄격 | 씨앗 짝 줄 | +|---|---|---|---|---|---|---|---|---|---| +| 북부청 임도(북부 · 2026 · 엑셀꼴) | 68 | 415 | 271 | 52 / 52 | 단산 블록(C1) | 50 · 47 | — | 19 / 31 | — | +| 북부청 사방(북부 · 2026 · 엑셀꼴) | 27 | 201 | 142 | 48 / 48 | 〃 | 36 · 38 | — | 14 / 25 | — | +| 동부청 영월(동부 · 2024 · STmate 47열) | 23 | 138 | 91 | 36 / 70 | 69 / 35 | 64 · 47 | 2 | 6 / 19 | (사람 정답) | +| 봉화 현동(남부 · 2024 · STmate 25열) | 45 | 203 | 110 | 33 / 67 | 65 / 31 | 44 · 31 | 15 | 7 / 10 | 19 | +| 울진대흥 기번3(남부 · 2024 · STmate 45열) | 34 | 171 | 80 | 18 / 50 | 46 / 14 | 31 · 10 | 16 | 3 / 4 | 9 | +| 영덕 계류보전(남부 · 2025 · STmate 45열) | 9 | 30 | 7 | 3 / 16 | 15 / 2 | 13 · 2 | 7 | 1 / 1 | 0 | +| 울진소광 기번8(남부 · 2025 · STmate 25열) | 25 | 108 | 48 | 20 / 43 | 40 / 17 | 20 · 17 | 9 | 7 / 9 | 6 | +| 거창(남부 추정 · 2025 · 엑셀꼴 흩어진 칸) | 14 | 68 | 34 | 21 / 69 | 61 / 13 | 10 · 36(지금 읽기 0 · 0) | 5 | 1 / 3 | 9 | +| **합** | 245 | 1,334 | 783 | 231 / 415 | | | 54 | 58 / 102 | 43 | + +- \* 모두 = 호표에 안 쓰이고 내역서가 바로 부르는 단가산출 블록까지(단가산출근거목록표로 이름 · 규격 · 단위). 기억은 호표 고르기 창 확정에서만 쌓이므로 기억 흉내는 「호표 쓰임」만 씀. +- \*\* 호표 = 로직 = 노임 줄(L 코드 · 「인」)을 호표에 바로 적은 호표(봉화 「깬잡석찰쌓기」 = 석공 0.08 + 보통인부 0.04 + 굴착기 0.3hr). 줄 맞춤으로는 못 맞추고 호표 머리를 로직 하나에 맞춰야 함(336 방법 40) — C5(조합 풀이)와 겹침. +- 줄 갈래(STmate 코드): D 단가산출 · X 기계 · L 노임 · M 자재 · B 다른 호표 · S 별도계상 · W 일식. 로직 줄 = D · X(엑셀꼴은 단가산출 · 기계 · 기타). + +### 1-2. 못 읽은 파일 · 따로 셈 + +| 파일 | 까닭 | +|---|---| +| 장수 · 진안 「내역+수량」 xlsx 2 | 문서 보안 잠김(파일 머리 「DRMONE」) — 어떤 길로도 못 읽음 | +| 봉화 오솔길 xlsx 1 | 문서 보안 잠김 · 내역 아님(야장 입력) | +| 2016 사방품셈(안) · 수목진료 xls 2 | 일위대가 시트 없음(336) | +| 울진대흥 「신설사업(착공)」 xlsx | 읽힘 — 일위대가 줄 126 가지가 「기번3(설계)」와 모두 같음 → 한 파일로 셈(블록 글은 판 꼴만 다름) | +| 울진대흥 1 · 2 · 3 공구 폴더 | 수량산출만(내역 없음) | + +### 1-3. 읽기에서 알게 된 것 + +- STmate 는 줄 코드로 줄 ↔ 블록 ↔ 노임 · 장비가 이름 없이 이어짐 — 같은 D 코드 = 같은 줄. 블록 안 자원 줄 7열 = 단위당 품(「0.26 * 165,545」의 0.26 · 「3 / (63/2)」의 0.0952)이라 품을 글 풀이 없이 바로 얻음. +- 블록의 30%(영월) ~ 87%(영덕계류)는 기계만(직종 없음 — 봉화 46% · 울진대흥 74% · 울진소광 55%) — 토공 · 채집 · 운반 블록. 직종 근거가 없어 조항만으로 맞춤. +- 거창 단가산출 시트는 글 한 줄이 열 수십 칸에 한 덩이씩 흩어짐 → 지금 읽기(B열만)로는 블록 61 모두 빈 글 · 출처 · 직종 0. 칸을 이어 붙이면 직종 36 줄 · 출처 10 줄(거창은 조항을 거의 안 적음 — 51 줄 출처 없음). + +--- + +## 2. 자동 정답(엄격) + +### 2-1. 규칙 + +- 대상 = 단가산출 줄(블록 있음). 블록 인용 조항(지금 `hb.sources`) 중 하나가 우리 돈 로직 조항과 같은 책 · 같은 번호 → 후보. +- 후보 로직을 입력 조합마다 엔진으로 돌림(고르기 모두 · 범위 두 끝 · 지역 · 기계 고르기는 견본값 · 600 조합 안) → 「인」 줄 직종 묶음 · 품. +- 엑셀 블록 직종 묶음과 같고 직종마다 품이 ±3% 안인 조합이 있는 로직이 **하나뿐이면** 자동 정답 · 아니면 「정답 없음」. +- 준엄격(덧) = 품 값은 안 보고 직종 묶음만 같은 로직이 하나. +- 쉬운 예: 봉화 「레미콘타설(장비) 무근,소형」 블록 = 「건설 6-1-1 · 콘크리트공 3 / (63/2) · 보통인부 1 / (63/2)」 → 우리 GC000198 을 「장비사용 · 무근 · 소량감률 50%」로 돌린 품 0.0952 · 0.0317 과 같음 → 자동 정답 GC000198. + +### 2-2. 파일별 수 · 정답 없음 까닭(단가산출 줄 · 고유 모두) + +| 파일 | 단가산출 줄 | 자동 엄격 | 준엄격 | 출처 없음 | 직종 · 품 없음 | 조항 같은 로직 없음 | 조항 같으나 품 다름 | 둘 이상 | +|---|---|---|---|---|---|---|---|---| +| 북부청 임도 | 51 | 19 | 31 | 1 | 4 | 8 | 19 | 0 | +| 북부청 사방 | 45 | 14 | 25 | 9 | 2 | 2 | 18 | 0 | +| 영월 | 69 | 6 | 19 | 5 | 21 | 11 | 26 | 0 | +| 봉화 | 65 | 7 | 10 | 21 | 21 | 10 | 6 | 0 | +| 울진대흥 | 46 | 3 | 4 | 15 | 24 | 1 | 3 | 0 | +| 영덕계류 | 15 | 1 | 1 | 2 | 12 | 0 | 0 | 0 | +| 울진소광 | 40 | 7 | 9 | 20 | 6 | 4 | 3 | 0 | +| 거창 | 61 | 1 | 3 | 51 | 0 | 2 | 7 | 0 | +| **합** | 392 | **58** | **102** | 124 | 90 | 38 | 82 | 0 | + +- 「조항 같으나 품 다름」 82 = 2024 문서의 옛 판 품 · 소량 할증 · 9:1 섞음 · 채집 + 운반 묶음 — 준엄격이 이 중 44 를 더 받음. + +### 2-3. 맞대 봄 + +| 맞대 본 것 | 엄격 | 준엄격 | +|---|---|---| +| 사람 정답(C1 · 북부청 + 영월)과 겹친 줄 | 38 중 38 같음(100%) | 70 중 68 같음 — 다른 2 = 영월 「기초잡석 · 뒷채움돌 채집운반부설」(사람 정답도 짐작) | +| 씨앗 짝과 겹친 줄 | 18 중 16 같음 — 다른 2 = 「문양거푸집」(씨앗은 산림 12-5 · 엑셀 인용 · 사람 정답은 건설 6-3-4 = 자동과 같음) | 28 중 23 | +| 손 확인 표본(사람 정답 없는 새 줄 20 · 블록 글 · 로직 품 대조) | **20 중 20 맞음** | — | + +- 손 확인 20 줄 = 레미콘 6-1-1(6) · 유로폼 6-3-3(7) · 합판거푸집 6-3-1 · 파형강관 6-7-2 · 뒤채움 3-4-4 · 막자갈채우기 = 뒤채움 대형장비 3-4-5 · 포장 인력포설 1-6-2 · 평떼 = 잔디붙임 4-1-1 · 수축줄눈 = 포장줄눈 절단 1-6-6. 「막자갈채우기 → 뒤채움 및 다짐(대형장비)」처럼 이름은 다르나 로직이 같은 줄도 맞힘. +- 한계: 엄격 58 줄이 로직 24 가지뿐 · GC000198(17) · GC000214(12)가 절반. 조항 · 품이 깨끗한 「쉬운 줄」 정답이라 이 줄의 맞춤 실력은 위 끝(3 절). + +--- + +## 3. 지금 실력(넓힌 자료 · 기억 없음) + +읽기 갈래(STmate · 거창만 바꿈 — STmate 는 지금 400 이라 읽는다고 친 값): 이름만 = 근거 없음 · 출처만 = 336 영월 흉내(직종 빠짐) · 출처직종 = 노무 목록으로 직종 이음(거창은 흩어진 칸 붙임). + +### 3-1. 같은 정답 · 같은 셈(336 의 126 줄) — 칸 = 1순위 엄격 / 느슨 · 다섯 안 엄격 / 느슨 + +| 줄 | 읽기 | 씨앗 켬 | 씨앗 끔 | +|---|---|---|---| +| 세 파일 126 | 출처만(336 그대로) | **71.4 / 81.7 · 86.5 / 94.4** | 67.5 / 77.0 · 86.5 / 93.7 | +| 세 파일 126 | 출처직종 | 73.0 / 82.5 · 86.5 / 94.4 | 69.8 / 77.8 · 86.5 / 93.7 | +| 영월 34 | 출처만 → 출처직종 | 67.6 → 73.5 · 88.2 그대로 | 44.1 → 52.9 · 79.4 그대로 | + +- 336 수치를 그대로 재현함(같은 정답 · 같은 셈 확인). 직종을 이으면 영월 1순위 +5.9(씨앗 끔 +8.8) — 근거 점수 「직종 겹침 0.5」가 살아남. + +### 3-2. 새 줄 + +| 정답 셋 | 줄 | 이름만 켬 / 끔 | 출처만 켬 / 끔 | 출처직종 켬 / 끔 | 다섯 안(출처직종 켬) | +|---|---|---|---|---|---| +| 자동 엄격(사람 정답 없는 새 줄) | 20 | 60.0 / 35.0 | 75.0 / 75.0 | **90.0 / 90.0** | 90.0 | +| 준엄격 덧 | 32 | 46.9 / 28.1 | 62.5 / 59.4 | 71.9 / 71.9 | 71.9 | +| 씨앗 짝(씨앗 끔만 — 처음 보는 셈) | 43 | — / 32.6 | — / 41.9 | — / 41.9 | 65.1(끔) | + +- 자동 엄격에서 틀린 2 줄 = 「같음」이 다른 책을 들임(파형강관 → 산림 GF000176 · 합판거푸집 3회 → 산림 GF000160 · 엑셀은 건설) — 336 묶음 ① 방법 4 대상. 근거가 깨끗한 줄은 근거만 읽히면 거의 다 맞음. +- 씨앗 짝 43 줄(봉화 19 · 울진대흥 9 · 울진소광 6 · 거창 9) = 실무자가 이름을 바꾼 어려운 줄 — 씨앗 끔 1순위 봉화 42.1 · 울진대흥 66.7 · 울진소광 50.0 · 거창 11.1(근거 없음). 영월 씨앗 끔(52.9)과 비슷 → 「처음 보는 STmate 파일 실력 ≈ 40~55%」. + +### 3-3. 새 지표 — 로직 조합 맞음(호표의 판정 줄이 모두 1순위 맞음) + +| 바탕 | 사람 정답 세 파일 호표(113) | 넓힌 8 파일 호표(154) | +|---|---|---| +| 출처직종 · 씨앗 켬 · 기억 없음 | 15(13%) — 임도 5/65 · 사방 1/26 · 영월 9/22 | 55(36%) | +| 〃 + 기억(근거 가름 · 4-6) | 33(29%) — 임도 17 · 사방 5 · 영월 11 | 73(47%) | +| 〃 + 기억(근거 가름 + 발주처) | 35(31%) | 75(49%) | + +- 북부청 임도는 줄 1순위 71% 인데 호표 조합은 8% — 「레미콘타설(장비) 소량」 · 「합판거푸집 4회」처럼 호표 10~13 곳에 되풀이되는 줄 하나가 틀리면 그 호표가 모두 틀림. 기억이 그런 줄을 한 번에 고쳐 조합 지표가 두 배 넘게 오름. +- STmate 호표는 자동 정답(쉬운 줄)만 판정이라 높게 나옴 — 사람 정답 세 파일 칸이 바른 비교. + +--- + +## 4. 기억 흉내 — 열쇠별 수치 · 해로움 + +### 4-1. 흉내 방법 + +- 「앞 파일에서 사람이 확정했다」 = 앞 파일 줄 정답(사람 · 자동 엄격) + 씨앗 짝(씨앗을 만든 파일 줄 = 사람이 고른 것)을 기억에 넣음. 판정은 사람 · 자동 엄격 정답만(씨앗은 판정에 안 씀). +- 바탕 = 지금 맞춤 · 출처직종 · **씨앗 끔**(씨앗을 기억 쪽으로 옮겨 셈 — 씨앗 출처 파일 오염 없음). 기억이 닿으면 그 답을 1순위로. +- 열쇠: **a** 품명|규격|단위(지금 `line_key`) · **b** 품명|단위 · **c** 로직 속 지문 = 단위 | 직종:품(유효 3자리) | 장비(굴착기 · 덤프트럭 …) | 조항 — 금액 안 씀(엑셀꼴 품은 「금액 ÷ 단가」로 되찾기만) · **c2** = 단위 | 직종:품만 · **d** 호표 조합 지문 = 그 호표 로직 줄 지문 묶음 + 자리. c · c2 는 C4(로직 속 지문)와 겹침 · d 는 C5(조합 풀이)와 겹침. +- 쉬운 예(c): 봉화 「레미콘타설(장비) 철근」과 영월 「철근콘크리트타설 장비사용타설」은 이름이 다르나 지문이 같음(m3 | 콘크리트공 0.0545 · 보통인부 0.0182 | 굴착기 | 건설 6-1-1) → 봉화에서 확정한 GC000198 이 영월에서 1순위(지금은 「콘크리트 펌프차 타설」이 1위). + +### 4-2. 장면별(호표 쓰임 줄 · 칸 = 기억 닿음 / 그중 맞음 · 1순위 전 → 뒤 · 해로움) + +| 장면 | 판정 줄 | a | b | c | c2 | d | +|---|---|---|---|---|---|---| +| 북부청 임도 → 사방(같은 발주처 · 해) | 43 | 4/4 · 77→77 · 0 | 15/15 · 77→**88** · 0 | 15/15 · 77→81 · 0 | 20/20 · 77→88 · 0 | 0 | +| 북부청 사방 → 임도 | 49 | 4/4 · 76→76 · 0 | 18/18 · 76→**90** · 0 | 16/16 · 76→84 · 0 | 23/23 · 76→94 · 0 | 0 | +| 울진대흥 ↔ 봉화(남부 · 2024) | 10 | 4/4 | 4/4 | 7/7 | 7/7 | 2/2 | +| 남부 2024 → 남부 2025(해 바뀜) | 7 | 2/2 | 3/3 | 4/4 | 4/4 | 1/1 | +| 북부청 → 영월(다른 발주처) | 34 | 2/1 · 53→53 | 13/8 · 53→**44** · **4** | 4/4 · 53→59 · 0 | 11/8 · 53→56 · 2 | 0 | +| 영월 → 북부청(다른 발주처) | 92 | 2/1 · 76→75 · 1 | 17/11 · 76→75 · **6** | 8/8 · 76→78 · 0 | 17/11 · 76→76 · 2 | 0 | +| 남부 2024 · 2025 → 북부청(다른 발주처 · 해) | 92 | 2/2 | 9/8 · 76→83 · 0 | 13/13 · 76→78 · 0 | 14/13 · 76→77 · 1 | 0 | + +- 남부 STmate 파일끼리는 판정 줄(자동 정답)이 적어 맞음만 봄 — 모두 맞음. + +### 4-3. 차례 누적(울진대흥 → 봉화 → 영월 → 울진소광 → 영덕계류 → 거창 → 북부청 임도 → 사방 · 앞 파일 모두 기억) + +| 열쇠 | 닿음(앞 줄 모두 확정이라 치면 · 고유 213) | 엑셀 줄 무게 | 판정 140 줄 중 닿음 · 맞음 | 1순위 전 → 뒤 | 다섯 안 전 → 뒤 | 도움 · 해로움 · 충돌 | +|---|---|---|---|---|---|---| +| a(지금) | 17(8%) | 9% | 12 · 11 | 72.1 → 72.1 | 87.1 → 87.9 | 1 · 1 · 0 | +| b | 71(33%) | 46%(a→b) | 42 · 35 | 72.1 → 76.4 | 87.1 → 91.4 | 12 · 6 · 1 | +| c | 43(20%) | 26%(a→c) | 33 · 33 | 72.1 → 76.4 | 87.1 → 88.6 | 6 · 0 · 0 | +| c2 | 54(25%) | — | 48 · 42 | 72.1 → 77.1 | 87.1 → 90.7 | 10 · 3 · 4 | +| d | 4(2%) | — | 2 · 2 | 그대로 | 그대로 | 0 | +| a → c → b → c2 사슬 | 96(45%) | 61% | 4-6 표 | | | | + +- 충돌 때 「최근」 대 「다수」 — 차이 없음(충돌 열쇠가 b 1 · c2 4 뿐). +- 열쇠 a 가 거의 안 닿는 까닭 = 규격 칸이 파일마다 다름(「굴삭기 0.2m3」 ↔ 「굴착기0.6m3」 · 「4회」 ↔ 「(4회)」). + +### 4-4. 조건별 맞음(짝 모두 + 차례 누적 · 서로 다른 판정 줄 · 답으로 셈) + +| 조건 | a | b | c | c2 | +|---|---|---|---|---| +| 모두 | 19/21(90%) | 60/73(82%) | **49/49(100%)** | 68/78(87%) | +| 같은 발주처 | 14/14 | 42/42 | 42/42 | 54/54 | +| 다른 발주처 | 5/7 | 28/41(68%) | 30/30 | 39/49(80%) | +| 근거 맞음(기억 답 조항 = 엑셀 인용 조항) | 18/18 | 46/47(98%) | 45/45 | 53/53 | +| 인용 없음 | 0/1 | 3/4 | — | 2/3 | +| 근거 다름 | 1/2 | 11/22(50%) | 4/4 | 13/22(59%) | +| 다른 발주처 · 근거 다름 | 1/2 | **9/20(45%)** | — | **6/15(40%)** | +| 다른 발주처 · 근거 맞음 | 4/4 | 17/18(94%) | 30/30 | 32/32 | +| 사람 정답으로만 판정 | 12/14 | 50/63 | 36/36 | 55/65 | +| 같은 열쇠 다른 답(충돌) | 0 | 1 열쇠 · 0/1 | 0 | 8 번 중 1 맞음(12%) | + +- c 의 100% 는 사람 정답 판정(36/36)에서도 같음 — 자동 정답 판정 줄은 지문과 정답이 같은 재료(조항 · 품)라 맴돎이 있어 사람 판정 칸을 근거로 봄. +- 한 파일 안 같은 열쇠 정답이 갈린 묶음: b 17 중 0 · c 6 중 1(영월 「기초잡석 · 뒷채움돌 채집운반부설」 — 블록 내용은 같고 사람 정답이 짐작으로 갈림). + +### 4-5. 해로움 예(다른 발주처 · 근거 다름) + +| 기억(앞 파일) | 다음 파일 줄 | 기억 답 → 정답 | +|---|---|---| +| 영월 「구조물터파기 토사(B/H 90%+인력10%)」(임도품셈 5-1-2 · 산림) | 북부청 사방 「구조물터파기 인력」(건설 3-2-1) | 산림 GF000101 → 건설 GC000083(열쇠 b) | +| 영월 「면고르기」(인용 없음 · 산림) | 북부청 임도 「면고르기」(건설 3-5-1) | 산림 GF000124 → 건설 GC000111(**열쇠 a — 지금 코드도 바로 넣음**) | +| 영월 「콘크리트포장(인력) T=20㎝」(산림 7-1-1) | 북부청 임도 「콘크리트포장(인력) T=20cm」(건설 1-6-2) | 산림 GF000162 → 건설 GC000297(b) | +| 북부청 사방 「지수판 설치」(산림 12-27-1) | 영월 「지수판 설치(PVC 용접)」(건설 6-3-9) | 직종 · 품이 같아 c2 가 섞음 → c(조항 포함)는 가름 | + +- 모두 「같은 일 다른 책」 — 발주처마다 쓰는 책이 다름. 엑셀이 인용한 조항과 기억 답의 조항을 맞대면(근거 판정) 모두 걸러짐. + +### 4-6. 가름 규칙 흉내(차례 누적 · 판정 140 줄 · 바탕 씨앗 끔 1순위 72.1 · 다섯 안 87.1) + +| 규칙 | 1순위 | 다섯 안 | 바로 들어감(맞음) | 1순위 후보(맞음) | 2순위 후보(맞음) | 도움 · 해로움 | +|---|---|---|---|---|---|---| +| 지금(a 만 · 바로) | 72.1 | 87.9 | 12(11) | — | — | 1 · 1 | +| a · b 바로(336 방법 22 를 바로 씀으로) | 76.4 | 91.4 | 42(35) | — | — | 12 · 6 | +| 모든 열쇠 바로 | 77.1 | 92.1 | 63(51) | — | — | 15 · 8 | +| **근거 가름** | **78.6** | 92.1 | 32(32) | 12(11) | 19(8) | 9 · **0** | +| 근거 가름 + 발주처 표시 | 80.0 | 92.1 | 39(39) | 7(6) | 17(6) | 11 · 0 | + +- 근거 가름 = 열쇠 차례 a → c → b → c2 · 첫 닿음만 씀: 근거 다름 → 2순위 후보 · a · c 이면서 근거 맞음/인용 없음 → 바로 씀 · b · c2 이면서 근거 맞음/인용 없음 → 1순위 후보(확정 한 번). + 발주처 = 같은 발주처 기억이면 a · b · c 를 근거와 상관없이 바로(같은 발주처 100% 라서). + +### 4-7. 호표 = 로직 54 개(노임을 바로 적은 호표) — 다른 파일과 닿음 + +| 열쇠 | 닿음 | +|---|---| +| 호표 이름 · 단위 | 54 중 11(20%) | +| 호표 지문(단위 · 노임 직종:수량 · 장비) | 54 중 23(43%) | + +- 정답이 없어 맞음은 못 잼. 이름을 바꿔도 지문은 같아 두 배 닿음 — 호표 머리 맞춤(336 방법 40 · C5)을 만들면 같은 지문 기억이 그대로 쓰임. + +--- + +## 5. 기억 설계 안 + +### 5-1. 한눈에 + +| 지금(`M02_Hopyo_Match` · `M02_Hopyo_Store`) | 안 | +|---|---| +| 열쇠 a 하나(품명 · 규격 · 단위) | 열쇠 a · b · c(+ c2) — 한 번 확정에 셋을 함께 담음 | +| 닿으면 바로 씀(상태 「기억」 · 사전조합에 바로 들어감) | 근거 판정으로 바로 씀 / 1순위 후보 / 2순위 후보 가름(5-3) | +| 같은 열쇠 다른 답 = 덮어씀(마지막만) | 답마다 출처 파일 · 횟수 · 엑셀 조항을 남기고 근거로 고름(5-5) | +| 출처 · 판 안 남김 | 출처 파일 · 확정 때 · 사람 · 엑셀 인용 조항 · 로직 원문번호 · 판 · 입력 이름 | +| 층 차례 = 같은 층 → 개인 → 회사 → 마스터(있음) | 그대로 + 층 올림 기준(5-4) | + +### 5-2. 한 기억에 담을 것(`맞춤.json` 「줄」 값 덧칸) + +`{로직|자재, 입력값, 환산, 확정}`(지금) + `열쇠2`(b) · `지문`(c) · `지문2`(c2) · `파일`(실무자료 이름) · `발주처`(올릴 때 고르는 칸 — 없으면 빈 값) · `엑셀조항`([[책, 번호]]) · `판`(로직 원문번호 · 마스터 판 · 입력 이름 목록) · `답들`(같은 열쇠에 확정된 다른 답 · 횟수). 읽을 때 b · c 찾기 표를 한 번 만듦(파일은 지금처럼 a 열쇠 한 벌). + +### 5-3. 경우에 따라 — 바로 씀 / 후보로만 (수치로 가름) + +| 경우 | 맞음(4-4) | 처리 | 화면 | +|---|---|---|---| +| a 또는 c 닿음 + 근거 맞음 · 인용 없음 | a 18/18 · c 45/45 | **바로 씀**(지금 「기억」 그대로 · 들어감) | 「기억 · 같은 줄」 / 「기억 · 같은 로직」 꼬리표 | +| b 또는 c2 닿음 + 근거 맞음 | b 46/47 · c2 53/53 | **1순위 후보**(확정 한 번) | 후보 단추 「기억 · 같은 품명 — 봉화 파일」 | +| b · c2 + 인용 없음 | 3/4 · 2/3 | 1순위 후보 | 「기억 · 근거 없음」 | +| 같은 발주처 기억(발주처 칸이 같을 때) | a · b · c 98/98 | 바로 씀(근거 다름이어도) | 「기억 · 같은 발주처」 | +| 어느 열쇠든 근거 다름 · 다른 발주처 | b 9/20 · c2 6/15 | **2순위 후보**(1순위는 지금 맞춤) | 「기억 · 근거 다름(산림 ↔ 건설)」 | +| 같은 열쇠 답 둘 넘음(충돌) | c2 1/8 | 바로 안 씀 · 답마다 후보 + 횟수 | 「기억 둘 — A 3번 · B 1번」 | +| d 호표 조합 | 닿음 2% | 안 만듦 | — | + +- 근거 판정 = 엑셀 줄 인용 조항(`근거.출처`)과 기억 답 로직 조항(`hb.ours()`)을 같은 책 · 같은 번호로 맞대 봄 — 330 근거 코드에 이미 있는 재료. +- 흉내 결과: 1순위 72.1 → 78.6 · 해로움 0(4-6). 지금 코드도 열쇠 a 를 다른 발주처 파일에 바로 넣어 틀린 예가 있음(면고르기) → a 에도 근거 판정을 붙임. + +### 5-4. 층 + +- 개인 층 = 내가 확정한 것(기본 자리 · 지금 그대로). 회사 층 = 회사 관리자 확정 + 개인 기억 올림. +- 올림 기준(안): 같은 답이 서로 다른 파일 둘 넘게 확정되고 충돌 없음 → 「회사로 올림」 단추에 표시(관리자가 누름) — 두 파일 넘게 같은 답 b 9/10 · c2 19/19. 자동 올림은 하지 않음(회사마다 쓰는 책이 다름). +- 마스터 층 = 씨앗(일위대가조합.json 메모 · 읽기만) + 마스터 관리자 확정. 이름 열쇠(a · b) 마스터 기억은 다른 발주처에서 68% 라 늘 근거 판정을 거침(씨앗의 북부청 해 −4.1 · −2.3 도 같은 까닭 — 336 묶음 ① 4 와 같은 고침). c 열쇠는 다른 발주처 30/30 → 마스터에 둬도 됨. +- 층이 갈려 답이 다르면 아래 층(개인)이 이김 · 「회사 기억과 다름」 꼬리표. + +### 5-5. 같은 열쇠 다른 답 + +- 덮어쓰지 않고 `답들`에 쌓음(출처 파일 · 횟수 · 엑셀 조항). +- 부를 때: 답들 중 근거 맞는 답이 하나면 그 답(5-3 처리) · 둘 넘거나 없으면 바로 안 씀 → 후보로 모두 보임. 예: 「구조물터파기|㎥」 = 북부청 파일 GC000083(건설 3-2-1) · 영월 파일 GF000101(산림) → 건설 3-2-1 을 인용한 줄엔 GC000083 · 임도품셈을 인용한 줄엔 GF000101. + +### 5-6. 「기억에서 옴」 표시 + +- 줄 상태 칸: 지금 「기억」 하나 → 「기억 · 같은 줄 / 같은 품명 / 같은 로직」 + 출처 파일 이름(마우스 올리면 확정 때 · 사람 · 층). +- 후보 단추: 기억에서 온 후보에 「기억」 표 + 근거 판정 글(「근거 맞음 · 건설 6-1-1」 / 「근거 다름 — 엑셀 산림 13-5-2」). +- [같음 모두 확정] · [근거 맞음 모두 확정] 에 「기억 후보 모두 확정」을 따로 두지 않음 — 바로 씀 줄은 이미 들어가고 나머지는 사람이 봄. + +### 5-7. 잊기 + +- 줄마다 [기억 지움](지금 confirm 에 null — 있음) + 층별 기억 목록(파일 · 로직 · 날짜로 거름 · 여럿 지움) — 작은 새 창. +- 바로 쓴 기억을 사람이 다른 답으로 고치면: 덮어쓰지 않고 `답들`에 「고쳐짐」 횟수 → 그 열쇠는 다음부터 바로 안 씀(후보로만). +- 실무자료 파일을 지워도 기억은 남김(지금 그대로) · 출처에 「지운 파일」. +- 시간으로 저절로 잊기는 안 둠(자료에 근거 없음). + +### 5-8. 우리 로직 판이 바뀔 때 + +- 확정 때 로직 원문번호 · 마스터 판(건설 「2026」 · 산림 「2026-01-01」) · 입력 이름 목록을 함께 담음. +- 부를 때 비교: 로직 키 없어짐 → 기억 버림 + 「기억한 로직 없어짐」 알림 · 원문번호 · 판 · 입력 이름이 바뀜 → 바로 씀을 1순위 후보로 내림 + 「로직 판 바뀜 — 입력값 다시」 · 없어진 입력은 입력값에서 뺌. +- c 지문은 바깥 엑셀 내용이라 판이 바뀌어도 열쇠는 그대로 · 답만 다시 봄. 원본 로직 · 일위대가조합.json 은 손대지 않음. + +### 5-9. 들 일 · 차례 + +| 차례 | 할 것 | 크기 | +|---|---|---| +| 1 | recall 에 근거 판정 · 열쇠 b(품명 · 단위) · 1순위/2순위 후보 갈래 · 충돌 쌓기 — 맞춤 서버 두 파일 + 시험 | 작음~중간 | +| 2 | 블록 품 뽑기(엑셀꼴 = 「금액 ÷ 단가」 · STmate = 7열) + 열쇠 c · c2 | 중간 — STmate 읽기(336 묶음 ④)와 같이 | +| 3 | 화면 꼬리표 · 기억 목록(잊기) · 올림 단추 · 발주처 칸 | 중간 | + +- 336 묶음 ①(「같음」도 근거 검사)과 같은 재료(조항 판정)를 씀 → 같이 하면 씨앗 · 기억 · 같음 세 길이 한 판정 함수를 거침. + +--- + +## 6. 한계 + +- 판정 정답이 사람 정답(북부청 · 영월 126 줄)에 몰림 — 새 STmate 파일은 자동 엄격 20 줄 · 씨앗 짝 43 줄뿐이라 새 파일 수치는 n 이 작음(표에 n 적음). +- 자동 정답은 「조항 · 품이 깨끗한 쉬운 줄」만 · 로직 24 가지 · 레미콘 · 유로폼이 절반 — 어려운 줄(이름 바뀜 · 옛 판 · 섞인 채집)은 정답 없음으로 남음. +- 씨앗을 「앞 파일 사람 확정」으로 씀 — 씨앗은 브레인이 만든 짝이고 「옛 판 값」 짝이 섞임. +- 발주처: 봉화 · 거창은 관할로 남부청 짐작 · 같은 발주처라도 설계사가 다를 수 있음 · 해 안의 파일 차례는 짐작. +- c 지문은 품이 유효 3 자리까지 같아야 함 → 품셈 개정(2024 → 2026)으로 품이 바뀐 줄은 해가 바뀌면 안 닿음(같은 발주처 · 다른 해 c 닿음 7%). +- 엑셀꼴에서 품이 안 잡힌 줄(기계만 블록 · 「금액 ÷ 단가」 꼴 아닌 글 포함): 북부청 임도 6 · 사방 11 · 거창 33 → 이 줄은 열쇠 c · c2 가 없음. +- 기억이 1순위로 들어간 뒤 사람이 고르는 손은 안 잼(고르기 창 일). + +## 7. 코드 자리 · 확인 · 실험 경로 + +| 할 것 | 자리 | +|---|---| +| 열쇠 a(지금) · b · c 함수 | `M02_MasterTemplete/M02_Hopyo_Match.py` `line_key` :68-76 | +| 기억 부르기 · 가름 | 같은 파일 `recall` :348-350 · `match_line` :379-394(상태 「기억」 · 들어감) | +| 확정 저장 · 덧칸 | 같은 파일 `confirm` :495-526 · `M02_Hopyo_Store.py` `memory` · `remember` :123-140(`실무자료/맞춤.json`) | +| 근거 판정 재료 | `M02_Hopyo_Basis.py` `ours` :158 · `_clause` :166-176 · 블록 품은 `read` :84-125 직종 줄 고리에 덧 | +| 층 차례 | `M02_Hopyo_Match.py` `chain` :333-341 · 확정 길 = `M02_Labor_Router.py` `hopyo_memory` :283-292 | +| 화면 꼬리표 | `M02_MasterTemplete_Hopyo_View.ts` `STATE` :45-53 · `M02_MasterTemplete_Hopyo_Api.ts` :93 | +| STmate 읽기 · 거창 흩어진 칸 | `M02_Hopyo_Excel.py` `_sheets` :74-102 · `M02_Hopyo_Basis.py` `read`(B열만 읽음) | +| 시험 | `resources/tester/test_m02_hopyo_match.py` · `test_m02_hopyo_basis.py` | + +- 원본 로직 안 고침 확인: 우리 로직 품은 엔진 `mf.run`(같은 프로세스 · 읽기)으로 셈 · 씨앗 끔은 `hm._seeds` 를 같은 프로세스에서 바꿔 끼움 · `resources/master_data` · `일위대가조합.json` · 실무문서는 읽기만 · 끝에 저장소 `git status` 변경 0. +- 겹침: c · c2 지문 · 우리 로직 품 벡터(`s04_auto.vectors`) = C4(로직 속 지문)와 겹침 · d 호표 조합 · 호표 = 로직 54 = C5(조합 풀이)와 겹침 — 함수는 따로 만듦. +- 실험 경로(스크래치패드 `req226/C6/` · 저장소 `venv/Scripts/python.exe -B`): + +| 스크립트 | 하는 일 · 결과 | +|---|---| +| `s02_read.py` | STmate 6 · 거창 읽기 → `data_<파일>.json` | +| `s03_items.py` | 고유 줄 · 블록 속(출처 · 직종 · 품 · 장비) → `items.json` · `hopyo_units.json` | +| `s04_auto.py [loose]` | 자동 정답 → `auto.json` · `auto_loose.json` · 로직 품 벡터 `vec_cache.json` | +| `s05_handcheck.py` | 손 확인 표본 20 줄 보기 | +| `s06_match.py` · `s07_score.py` | 지금 맞춤 6 갈래 → `match.json` · 실력 표 `score.json` | +| `s08_memory.py [모두] [준엄격]` | 기억 흉내(장면 · 누적 · 조건) → `memory.json` | +| `s09_examples.py` · `s13_distinct.py` | 도움 · 해로움 예 · 서로 다른 줄 셈 | +| `s10_policy.py` · `s11_extra.py` · `s12_combo.py` | 가름 규칙 · 엑셀 줄 무게 · 로직 조합 맞음 | diff --git a/docs/raw/조사/2026-10-15_336_재검토_로직조합맞춤.md b/docs/raw/조사/2026-10-15_336_재검토_로직조합맞춤.md new file mode 100644 index 000000000..8fe5cc94a --- /dev/null +++ b/docs/raw/조사/2026-10-15_336_재검토_로직조합맞춤.md @@ -0,0 +1,313 @@ +# 외부 일위대가 맞춤 — 재검토 · 로직 조합 기준 (요구 226-4 · PLAN 336-3) + +재료 = C4 로직 지문 · C5 조합 풀이 · C6 기억 실측(스크래치패드 `req226/` · 원문 = 같은 폴더 `2026-10-15_336_부록3_재실측원문.md`) + 336 검토(`docs/raw/조사/2026-10-15_336_외부일위대가_이름맞춤_검토.md`) + C7 덧잼(세 실험 함수를 한 프로세스에 불러 겹쳐 잼 · `req226/C7/`) · 기준 main_laptop_1 42e4aa70c(맞춤 · M01 · master_data 는 C4~C6 기준 b7e727863 과 같음) · 저장소 고침 없음 · 원본 로직 · 일위대가조합.json 읽기만. + +## 0. 결론 요약 + +1. 사용자 말이 맞음 — 실무자는 이름 · 인용 조항은 편의대로 거의 다 바꾸지만 직종 묶음 · 품(로직 속)은 거의 그대로 둠 → 이름 · 책이 아니라 「어떤 로직(의 조합)인가」로 맞춰야 함. +2. 원본 로직은 그대로 두고 맞춤 쪽만 묶음을 쌓으면 — 1순위 71.4% → 92.9% · 다섯 안 86.5% → 96.8% · 호표 통째 조합 맞음 9.6% → 75.9%. +3. 몫이 큰 것 — 근거 고침(1순위 85.7%) · 조합 풀이(조합 맞음 30.1% → 75.9%) · 로직 지문(다섯 안 96.8% · 처음 보는 셈 1순위 80.2% → 85.7%). +4. 지문과 조합 풀이를 그냥 겹치면 조합 맞음이 49.4% 로 깎임 — 「섞인 줄(기계 90% + 인력 10%)은 몫마다 먼저 풂」 규칙을 넣어야 둘이 더해짐. +5. 기억은 맞춤이 약할 때 큰 도움(지금 위 1순위 71.4% → 80.2%) · 다 쌓은 뒤엔 같은 줄을 이미 맞혀 1순위 그대로 — 대신 사람 확인 없이 바로 들어가는 줄이 생김(틀림 없음). +6. 「딱 맞음」(직종 · 품이 그대로 같음) 표시는 가린 파일에서도 거의 다 맞음 → 금액 비 대신 초록 표시 · 한꺼번에 확정 후보. +7. 넘침 확인 — 임도 하나로만 손잡이를 정해도 사방 1순위 90.7% 그대로 · 영월 88.2% → 85.3% · 새 파일 같거나 오름 → 부풀린 값 아님 · 다만 무게를 한 파일로 정하면 크게 흔들림. +8. 처음 보는 STmate · 거창 파일은 아직 약함 — 1순위 41.9% → 쌓아서 65.1~72.1% · 읽기 넓힘이 있어야 지문 · 기억이 돎. +9. 추천 차례 — ① 근거 고침 → ② 로직 지문 → ③ 조합 풀이 → ④ 조건부 기억 · ⑤ 읽기 넓힘은 새 문서를 곧 쓰면 ①과 나란히 · ⑥ 고르기 창은 ②부터 함께. +10. 정할 것 — 앞 11 중 넷은 이번 말로 풀림 · 새로 넷(사전조합 「한 줄 = 담은로직 여럿」 · 딱 맞음 한꺼번에 확정 · 우리에 없음 줄 · 발주처 칸). + +--- + +## 1. 사용자 말 확인 — 실무자가 편의대로 바꾼 꼴 + +### 1-1. 꼴 표 — 정답 있는 126 줄(한 줄에 여럿 · 정답 로직의 가장 닮은 입력 조합과 견줌 · C4) + +| 꼴 | 줄 | 쉬운 예 | +|---|---|---| +| 이름 다름 | 116 (92%) | 「레미콘타설(장비사용)」 ↔ 우리 「레디믹스트콘크리트 타설」 | +| 이름만 다름(조항 · 품 같음) | 40 | 「합판거푸집(4회)」 = GC000211 「유형=보통」 그대로 | +| 묶음(우리 로직에 없는 장비 · 일을 더함) | 40 | 채집 + 상차 + 운반을 한 줄로 | +| 계수(품) 바꿈 | 32 | 토질 셋 평균 · 옛 판 값 · 두 작업을 한 품으로 | +| 섞음(기계 % + 인력 %) | 18 | 고임돌 9 : 1 — 인력 0.3 × 10% | +| 다른 책 조항 인용 | 18 | 고임돌 「건설 1-10」(단위중량 표를 인용) | +| 단위 바꿈 | 12 | 개소 ↔ 본 · ㎡ ↔ 개 | +| 출처 없음 | 11 | 철골설치 · 사방 9 : 1 채집 | +| 옛 판 · 없는 번호 | 5 | 「임도품셈 5-5」 · 「사방품셈 P52」 | +| 할증 · 횟수 붙임 | 4 | 품할증 0.75 · × 2회 | +| 직종 바꿈 | 3 | 다웰바(철근공 더함) | +| 쪼갬 | 2 | 원형돌망태 조립 + 채움 두 줄 = 우리 로직 하나 | + +- 품 판정 — 품 그대로 72 줄(같음 58 · 조합 같음 5 · 섞음 · 할증만 붙고 밑품 같음 9) · 계수 바뀜 32 · 단위 달라 못 견줌 12 · 그 밖 10. +- 읽는 법 — 이름은 거의 다 · 조항은 넷에 하나꼴로 바꾸지만 직종 묶음을 바꾼 줄은 3 뿐 · 품은 절반 넘게 그대로 → 「어떤 로직인가」는 직종 · 품 꼴이 가장 잘 말함. 우리 서버 이름을 바꿔도 다음 문서 작성자가 또 바꾸므로 같은 문제(사용자 말 그대로). + +### 1-2. 같은 일 다른 책 — 숫자는 인용한 책 + +- 지금 실패 중 책 갈래 11 줄 — 품 숫자가 모두 엑셀이 인용한 책과 같음(건설 7 · 산림 1 · 두 책 같은 품 2 · 장비만 건설 1) → 작성자는 책을 바르게 씀 · 틀린 쪽은 우리 「이름 같음」 자동 연결. +- 반대로 「조항은 건설 · 숫자는 산림」 9 줄(고임돌 · 조약돌 4 · 야면석 채집 3 · 되메우기 · 야면석메붙임) → 조항은 한 신호일 뿐 · 숫자(로직 속)가 정답 책을 가름. +- 쉬운 예 — 합판거푸집 N001(건설 6-3-1 인용 · 형틀목공 0.111 · 보통인부 0.044) = 건설 GC000211(0.112 · 0.045) · 산림 GF000160 은 가장 닮은 조합도 0.104 · 0.057. + +### 1-3. 닫힌 세계 — 바깥 줄은 셋 중 하나 + +- 8 파일 · 호표 335 · 줄 1,388 을 모두 갈래로 나눔(C5) — 단가산출 735 · 다른 호표 참조 90 · 노임 105 · 장비 36 · 자재 219 · % 30 · 별도계상 135 · 근거글 38 → 「우리 로직(조합) · 우리 일위대가 · 단가 · % 줄」 밖으로 안 나감. +- 정답 세 파일 「우리에 없음」 = 136 중 10 — 잔토처리 · 잔토정리 6(우리 굴착 로직의 입력으로만 있음) · 노임 줄 2 · 고화매트 · 철강재 운임. + +### 1-4. 「로직 X · 계수 바뀜」으로 보임 + +지문 1위가 맞은 103 줄 — 품 같음 56 · 조합 같음 4 · 계수 바뀜 23 · 섞음 · 할증 3 · 직종 다름 3 · 단위 달라 8 · 그 밖 6. + +| 엑셀 줄 | 보일 글 | 까닭(엑셀 블록) | +|---|---|---| +| 구조물터파기(인력100%) 토사 | GC000083 굴착(인력/토사) · 계수 바뀜 특별인부 0.370 → 0.354 | 토사 셋 평균 | +| 임도안전표지판설치 | GC000308 교통 안전표지판 설치 · 특별인부 0.167 → 0.258 | 지주 · 표지판 두 작업을 한 품 | +| 구조물되메우기(인력100%) | GF000113 되메우기 · 섞음 비율 10% → 100% · 품 같음 0.1 | 산림 9-14 섞음 비율 무시 | +| 철근가공조립(현장) | GC000206 현장가공 + GC000207 현장조립 · 조합 같음 | 건설 6-2 윗 조항 하나로 | + +--- + +## 2. 누적 실력 · 가림 시험 + +같은 정답 · 같은 셈 = 336 · C4~C6 와 같음(고유 줄 · 정답 있는 126 줄 · 엄격 / 느슨 · 1순위 · 다섯 안). 조합 맞음 = C5 T1(정답 세 파일 호표 중 덮을 수 있는 83 · 호표 안 로직 칸이 다 차고 남는 로직 없음 · 엄격) · 칸 = 찬 칸 비율. + +### 2-1. 누적 표 — 씨앗 켬(지금 셈) + +| 단계 | 1순위 엄격 / 느슨 | 다섯 안 엄격 / 느슨 | 1순위 임도 · 사방 · 영월 | 조합 맞음(83) | 칸 | +|---|---|---|---|---|---| +| 지금 | 71.4 / 81.7 | 86.5 / 94.4 | 71.4 · 74.4 · 67.6 | 9.6 (8) | 59.1 | +| ① 근거 고침 | 85.7 / 88.1 | 94.4 / 94.4 | 91.8 · 86.0 · 76.5 | 27.7 (23) | 77.7 | +| ② + 로직 지문 | 88.9 / 90.5 | 96.8 / 96.8 | 91.8 · 86.0 · 88.2 | 30.1 (25) | 81.5 | +| ③ + 조합 풀이(섞인 줄 몫 먼저) | **92.9 / 94.4** | **96.8 / 96.8** | 98.0 · 90.7 · 88.2 | **75.9 (63)** | 95.1 | +| ④ + 조건부 기억(근거 가름) | 92.9 / 94.4 | 96.8 / 96.8 | 98.0 · 90.7 · 88.2 | 75.9 (63) | 95.1 | +| ⑤ + 읽기 넓힘(영월 직종 이음) | 92.1 / 93.7 | 96.8 / 96.8 | 98.0 · 90.7 · 85.3 | 75.9 (63) | 95.1 | +| (참고) C5 길만(① + (가) + 조합 풀이) | 91.3 / 93.7 | 94.4 / 94.4 | 98.0 · 90.7 · 82.4 | 72.3 (60) | 91.2 | +| (참고) ② 위에 조합 풀이를 그냥 겹침 | 89.7 / 91.3 | 96.8 / 96.8 | 91.8 · 88.4 · 88.2 | 49.4 (41) | 85.5 | + +- ① = 336 묶음 ①(조항 윗마디 · 같은 이름 접힘 고침 · 조항 오타 · 「같음」도 근거 검사) — 원본 로직이 아니라 **맞춤 코드**(M02 맞춤 서버 파일) 고침. +- ② = C4 추천(「같음」은 ① 그대로 · 나머지 = 지문 점수 + ① 차례 덤 β=1). ③ = 섞인 줄 11 은 C5 길(① + (가) 1순위 → 몫 풀이 → 인력 몫으로 바꿈) · 나머지 줄은 ② 1순위 + 못 덮는 절 · 몫의 덧 로직. +- ④ = 앞 파일(C6 차례 — 울진대흥 → 봉화 → 영월 → 울진소광 → 영덕 → 거창 → 북부청 임도 → 사방)을 사람이 다 확정했다고 침 · 열쇠 a → c → b → c2 · 근거 맞음 a · c 바로 / b · c2 1순위 후보 / 근거 다름 2순위. +- ⑤ = 126 줄에선 영월 근거 직종만 바뀜 · ②의 영월 지문이 이미 STmate 직종을 읽어 씀 → 맨 위에선 −1 줄(Y012 임도안전표지판 — 옛 판 번호 「1-9-3」이 지금 판의 낙석방지책 로직을 끌어옴). ⑤의 값어치는 새 파일(2-4). +- 엑셀 줄 무게(되풀이 셈 · 1순위 느슨) — 78.1 → 85.6 → 90.7 → 97.7 → 97.7 → 97.5. + +### 2-2. 씨앗 끔(처음 보는 셈 · 씨앗 = 마스터 층 기억으로 옮김) + +| 단계 | 1순위 엄격 / 느슨 | 다섯 안 엄격 | 1순위 영월 | 조합 맞음(83) | +|---|---|---|---|---| +| 지금 | 67.5 / 77.0 | 86.5 | 44.1 | 9.6 (8) | +| ① 근거 고침 | 80.2 / 84.1 | 92.9 | 55.9 | 24.1 (20) | +| ② + 로직 지문 | 85.7 / 87.3 | 96.8 | 76.5 | 26.5 (22) | +| ③ + 조합 풀이 | **89.7 / 91.3** | **96.8** | 76.5 | **72.3 (60)** | +| ④ + 조건부 기억 · ⑤ + 읽기 넓힘 | 89.7 / 91.3 | 96.8 | 76.5 | 72.3 (60) | +| (참고) C5 길만 | 84.9 / 88.9 | 92.9 | 58.8 | 69.9 (58) | +| (참고) ② 위에 조합 풀이를 그냥 겹침 | 86.5 / 88.1 | 96.8 | 76.5 | 45.8 (38) | + +- 임도 · 사방은 씨앗 켬과 같음(98.0 · 90.7) — 씨앗 몫은 영월(씨앗을 만든 파일)뿐. + +### 2-3. 겹침 — 그냥 더해지지 않음 + +**지문 × 조합 풀이** +- 지문 1순위에 조합 풀이를 그냥 얹으면 조합 맞음 49.4%(C5 길만 72.3% 보다 낮음). +- 까닭 = 섞인 줄 「고임돌 · 조약돌 채집 및 운반(굴삭기 90% + 인력 10%)」 — 지문이 줄 통째 숫자가 닮은 「구조물터파기(9 : 1)」를 1위로 냄 · 그 로직이 줄 벡터를 다 덮어 「인력 몫으로 바꿈」이 안 걸림 · 고임돌 줄 하나가 임도 호표 17 곳 · 사방 9 곳 · 조약돌 줄이 5 곳에 되풀이 → 호표 통째가 무더기로 틀림. +- 고침 = 섞인 줄(몫 표시 + 노임만 든 몫 + 장비만 든 몫 · 글 꼴로 가름 · 정답 안 봄)은 몫마다 먼저 풂 · 나머지는 지문 → 지문이 푼 영월 3 줄(크레인 · 기초잡석 · 고임돌)과 조합이 푼 고임돌 · 조약돌 5 줄이 함께 맞음. + +**기억 × 나머지** — 기억을 얹는 자리마다(1순위 엄격 · 도움 / 해로움 줄) + +| 얹는 자리 | 씨앗 켬 | 씨앗 끔 | +|---|---|---| +| 지금 위 | 71.4 → 80.2 (11 / 0) | 67.5 → 76.2 (11 / 0) | +| ① 위 | 85.7 → 87.3 (2 / 0) | 80.2 → 82.5 (3 / 0) | +| ② 위 | 88.9 → 89.7 (1 / 0) | 85.7 → 86.5 (1 / 0) | +| ③ 위(맨 위) | 92.9 → 92.9 (0 / 0) | 89.7 → 89.7 (0 / 0) | + +- 기억이 고치는 줄 = ① · ② · ③이 고치는 줄과 같음 → 맨 위에선 1순위 0. +- 맨 위 기억의 몫 = 사람 손 — 바로 씀 24 줄 모두 맞음 · 그중 20 줄(엑셀 93 줄)은 「후보」라 사람이 골라야 했던 줄(씨앗 끔 22 줄 · 112 줄) · 1순위 후보 12 중 11 은 쌓은 맞춤 1위와 같음(확인 신호) · 해로움 0. + +### 2-4. 새 파일 — STmate 5 · 거창(씨앗 끔 · 처음 보는 셈) + +| 단계 | 자동 정답 20(조항 · 품 깨끗한 쉬운 줄) 1순위 · 다섯 안 | 씨앗 짝 43(실무자가 이름 바꾼 줄) 1순위 · 다섯 안 | +|---|---|---| +| 지금(336 영월 흉내 읽기) | 75.0 · 75.0 | 41.9 · 65.1 | +| ① 근거 고침 | 80.0 · 80.0 | 53.5 · 69.8 | +| ⑤ + 읽기 넓힘(STmate 직종 이음 · 거창 행 칸 이음) | 95.0 · 95.0 | 55.8 · 72.1 | +| ② + 로직 지문(「같음」 그대로) | 95.0 · 95.0 | 58.1 · 76.7 | +| ② + 로직 지문(「딱 맞음」이 「같음」을 이김) | 100.0 · 100.0 | 62.8 · 81.4 | +| ④ + 조건부 기억(근거 가름) | 100.0 · 100.0 | **65.1 · 83.7** | +| ④ + 같은 발주처 기억은 바로 | 100.0 · 100.0 | **72.1 · 88.4** | + +- 새 파일은 읽기가 앞 — 지문(직종 · 품)이 STmate 노무 목록 · 7열 품 · 거창 흩어진 칸에서 나오므로 ⑤ 없이는 ②가 못 돎. +- 「딱 맞음이 같음을 이김」 = 「같음」 줄이라도 지문이 딱 맞음(2-6)이고 다른 로직을 가리키면 지문 → 126 줄은 바뀐 줄 0 · 새 파일만 오름. +- ③ 조합 풀이는 못 잼(이 파일 블록 몫 벡터가 줄 id 로 안 이어짐) — C5 다른 꼴 24 호표 씨앗 끔 통째 3 → 5 · 찾음 44.2 → 56.5%. +- 지문 = C6 items 로 만든 가벼운 지문(자재 · 장비 시간 · 섞음 밑품 없음) — 126 줄에서 C4 지문 88.9 대 가벼움 88.1(영월 −1 줄) → 거의 같은 셈. +- 자동 정답은 지문과 같은 재료(조항 · 품)로 만든 정답이라 지문 쪽에 맴돎 · 씨앗 짝이 바른 가림 무리. + +### 2-5. 가림 시험 — 문턱 · 무게를 북부청 임도로만 정함 + +손잡이 = C4 무게 다섯(인력 2 · 3 · 4 · 장비 1 · 2 · 3 · 조항 0.75 · 1.5 · 3 · 이름 0.3 · 0.6 · 1.2 · 조건 0.3 · 0.6 · 1.2) × β(0 · 0.5 · 1 · 2 · 3) × 「같음 그대로」(예 · 아니오) × C5 뺌 문턱(0.2 · 0.35 · 0.5) × C6 기억 규칙 5 = 36,450 설정 · 임도 49 줄 1순위 엄격(동점이면 지문 다섯 안)으로만 고름 · 1순위 엄격. + +| 설정 | 임도(맞춘 곳 · 49) | 사방(가림 · 43) | 영월(가림 · 34) | 새 파일 자동(가림 · 20) | 새 파일 씨앗 짝(가림 · 43) | +|---|---|---|---|---|---| +| 지금 설정(세 파일 보고 고름 · β=1) | 98.0 | 90.7 | 88.2 | 95.0 | 62.8 | +| 미리 정한 값(β=2) | 98.0 | 90.7 | 85.3 | 95.0 | 65.1 | +| 임도만 보고 고름 — 가운데(범위) | 98.0 | 90.7 (86.0~95.3) | 85.3 (55.9~88.2) | 95.0 (95.0~100) | 67.4 (58.1~81.4) | +| **떨어지는 폭(가운데 − 지금 설정)** | — | **0** | **−2.9 (한 줄)** | **0** | **+4.6** | + +- 임도가 거의 꽉 참(49 중 48) → 최고 동점 7,368 설정 · 임도 하나로는 무게를 못 정함 · 범위 끝(영월 55.9)처럼 한 파일로 정한 무게는 다른 파일에서 크게 흔들림. +- 손잡이마다(나머지는 지금 설정 · 임도 / 사방 / 영월 / 자동 / 씨앗 짝): + - β — 0: 95.9 / 95.3 / 79.4 / 95.0 / 60.5 · 1: 98.0 / 90.7 / 88.2 / 95.0 / 62.8 · 2: 98.0 / 90.7 / 85.3 / 95.0 / 65.1 → β=1 은 영월 꼭대기(표본을 보고 고른 값). + - 「같음 그대로」 — 아니오: 98.0 / 90.7 / 85.3 / 100 / 69.8 → 영월 +2.9 는 씨앗 덕(영월 = 씨앗 만든 파일) · 새 파일엔 해 → 「딱 맞음이 같음을 이김」으로 바꾸면 126 줄 그대로 · 새 파일 자동 100 · 씨앗 짝 62.8. + - C5 뺌 문턱 — 셋 모두 같음(튼튼). + - C6 기억 규칙 — 임도만 보고도 「근거 가름」(또는 + 발주처)이 뽑힘 · 다른 규칙은 맨 위에서 해로움(임도 — 지금 코드 「a 바로」 95.9 · 「a · b 바로」 87.8 · 「모든 열쇠 바로」 81.6) → 지금 코드도 다른 발주처 기억 1 줄(면고르기 · 산림 ↔ 건설)을 틀리게 넣음. +- 「섞인 줄 몫 먼저」 규칙은 결과를 보고 넣음 — 임도 줄(고임돌 · 조약돌 N005 · N012 · N036)만 보고 넣었다고 쳐도 사방(가림)에서 S081 · S091 풂 · 해 0. +- 판정 — 넘침 폭은 작음(사방 0 · 영월 −1 줄 · 새 파일 0~+5) · 무게는 C4 미리 정한 값 그대로 두고 β · 「같음」 규칙은 여러 파일로 정함. + +### 2-6. 「딱 맞음」 — 가림에서도 + +딱 맞음 = 1위 로직의 직종 묶음이 같고 품이 모두 3% 안(보통인부 하나뿐 줄 뺌) 또는 장비 갈래 · 시간 맞음 · 2위와 점수 차 큼(C4). + +| 무리 | 딱 맞음 · 맞음 | +|---|---| +| 북부청 임도 | 27 · 27 | +| 북부청 사방 | 19 · 19 | +| 동부청 영월 | 10 · 10 (+ 우리에 없음 1 = 잔토처리 B/H 100% · 숫자로는 토사깍기(기계)) | +| 새 파일 씨앗 짝(가벼운 지문 · 가림) | 15 · 14 — 틀린 1 = 구조물되메우기(지문 건설 ↔ 씨앗 산림 · 같은 일 다른 책) | +| 새 파일 자동 정답 | 18 · 18(맴돎 무리) | + +- 126 줄 = 고유 57 · 엑셀 줄 220 / 504(44%) → [딱 맞음 모두 확정] 한 번이면 엑셀 줄 절반 가까이. + +### 2-7. 어떻게 냈나 + +- C1 정답 · 셈(`s10_all`) · C3 묶음 ①(`combo`) · C4 지문(`s3_match.rank` · `fuse`) · C5 (가)(`c04_closed`) · 조합 풀이(`c05_solve.combine` · `c08` 뺌 규칙) · 조합 맞음(`c07_hopyo.judge`) · C6 기억(`s08_memory.build` · `s10_policy.first_hit` · `tier`)을 한 프로세스에서 불러 단계마다 앞 결과를 다음 단계에 넣음 · 단계 앞 값(71.4 · 85.7 · 88.9 · C5 91.3 · 72.3 · C6 75.0 / 90.0 / 41.9)을 그대로 재현해 맞음 확인. +- 가림 시험 = C4 점수를 채널 · 입력 조합별로 미리 셈(무게만 바꿔 끼움 · 지금 무게로 C4 순위와 136 줄 모두 같음 확인). +- 명령마다 120 초 안(가장 긴 것 36 초) · 어림 없음 — 모든 수치는 실측. + +--- + +## 3. 묶음 · 차례 + +사용자 뜻 = 원본 로직 안 고침 · 금액이 아니라 로직 조합 · 한 번 한 것 기억. 모든 묶음은 M02 맞춤 쪽(읽기 · 찾기 · 고르기 · 기억)만 고침 · 우리 로직은 M01 엔진으로 읽어 셈만. + +### 3-1. 묶음 표 + +기대 효과 = 앞 묶음 위에 얹은 실측(2 절) · 「126」 = 씨앗 켬 1순위 · 다섯 안 · 조합 맞음 · 「새 파일」 = 씨앗 짝 1순위. + +| 묶음 | 무엇 | 기대 효과 | 일 크기 | 먼저 할 것 | 차례 | +|---|---|---|---|---|---| +| ① 근거 고침 | 조항 윗마디 · 같은 이름 접힘 고침 · 조항 오타 · 「같음」도 근거 검사 + 덤(장비 시간 줄 · 노임 줄 표시 — 흉내 밖 · 펌프차 · 크레인 2 줄 어림) | 126: 71.4 → 85.7 · 86.5 → 94.4 · 9.6 → 27.7 / 새 파일: 41.9 → 53.5 | 작음~중간 | — | 1 | +| ② 로직 지문 | 엑셀 블록 직종 · 품 · 장비 · 자재 · 할증 · 조건 · 조항 대 우리 로직 1,438 엔진 지문 · 딱 맞음 · 판정 글 | 126: → 88.9 · 96.8 · 30.1 / 새 파일: → 62.8(⑤ 뒤) | 중간~큼 | ①(① 차례가 덤) · 새 파일은 ⑤ | 2 | +| ③ 조합 풀이 | 블록을 절 · 몫으로 갈라 우리 로직 조합으로 풂 · 섞인 줄 몫 먼저 · 1순위가 못 덮는 절은 덧 로직 · 직접 줄 호표 = 호표 통째 로직 하나 | 126: → 92.9 · 96.8 · 75.9 / 새 파일: 못 잼 | 중간 | ②(우리 벡터 한 벌) · 담기는 정할 것 N1 | 3 | +| ④ 조건부 기억 | 열쇠 a(품명 · 규격 · 단위) · b(품명 · 단위) · c(로직 지문 = 단위 · 직종 품 · 장비 · 조항) · 근거 판정으로 바로 / 1순위 후보 / 2순위 · 같은 열쇠 다른 답 쌓기 | 126: 1순위 그대로 · 사람 확인 20 줄 덜음 · 해 0 / 새 파일: → 65.1(같은 발주처 72.1) | 작음~중간 | 근거 판정 = ① · c 열쇠 = ② | 근거 판정 · b 는 ①과 함께 · c 는 ② 뒤 | +| ⑤ 읽기 넓힘 | STmate 꼴 읽기(줄 코드 · 노무 목록으로 직종 · 7열 품) · 거창 단가산출 행 칸 이어 붙임 | 126: ±1 줄 / 새 파일: STmate 6 파일이 올라감(지금 400) · 자동 80 → 95 · 씨앗 짝 53.5 → 55.8 · ② ④의 바탕 | 중간 | — | 새 문서를 곧 쓰면 ①과 나란히 · 아니면 5 | +| ⑥ 고르기 창 | 딱 맞음 초록 + [딱 맞음 모두 확정] · 「로직 X · 계수 바뀜」 · 「기억에서 옴」 · 조합(절마다 로직) 보임 · 금액 비는 순위 · 판정 · 표시에서 뺌 | 순위 그대로 — 다섯 안 96.8% 를 사람이 빨리 · 바르게 고름 | 중간 | ②③④가 낸 칸 | ②부터 함께 | + +### 3-2. 묶음마다 + +**① 근거 고침 — 맞춤 코드 고침(원본 로직 아님)** +- 화면 — 1순위가 엑셀 인용 조항 · 직종과 같은 우리 로직으로 · 이름만 같은 다른 책이 「같음」으로 들어가던 10 줄이 「후보」로 · 철근 가공조립이 「현장가공 · 현장조립」 쪽으로. +- 자리 — `M02_Hopyo_Basis._clause`(:166-176 윗마디) · `sources`(:60-71 오타) · `M02_Hopyo_Match.candidates`(:300-331 접힘 열쇠) · `match_line`(:395-403 같음 근거 검사) · `M02_Hopyo_Excel.kind_of`(:132-150 노임 · 장비 시간 줄). +- 원본 — 우리 로직 · 계수 · 일위대가조합.json · M01 쓰기 없음. + +**② 로직 지문** +- 화면 — 후보 차례가 「숫자가 닮은 로직」 쪽으로 · 딱 맞음 줄 초록 · 맞은 로직에 「계수 바뀜 특별인부 0.370 → 0.354」 같은 글 · 입력값 추천(엑셀 조건 — 합판 「유형=보통」 등 17 줄). +- 새 파일 — `M02_Hopyo_Finger.py`(엑셀 블록 → 지문: 식 풀기 · 밑품 · 장비 · 자재 · 율 · 조건 · 절 · 몫 — C4 `s1_excel_fp` · `fp_common` + C5 `c01` 절 · 몫 한 벌) · `M02_Hopyo_Ours.py`(우리 로직 지문 · 단위당 벡터 — 입력 조합마다 엔진 셈 · 마스터 판 열쇠 캐시 · 처음 ≈ 2 분 → 뒤에서 데움) · `M02_Hopyo_Fit.py`(닮음 점수 · 딱 맞음 · 판정 글 · ① 차례 덤 · 「딱 맞음이 같음을 이김」). +- 고리 — `Basis.read`(:84-125 블록 읽을 때 지문 칸) · `Match.match_line`(:404-414 후보 다시 줄 세움) · `Match.guess`(:417-429 입력값). +- 원본 — `M01_MasterData_Store.loaded`(:65) · `M01_MasterData_Store_Flow.run`(:170 — `M02_Labor_Doc.run_line` :518 과 같은 읽기 길) 부르기만 · 데움 본보기 `M01_MasterData_Store_Cache.warm`(:257). + +**③ 조합 풀이** +- 화면 — 호표 줄마다 「1. 현장가공 → 철근 현장가공 · 2. 조립 → 철근 현장조립」 · 「인력 10% → 모래·자갈·약돌 채집(인력) · 굴삭기 90% → 기계 시간당 기계경비」처럼 절 · 몫마다 로직이 보임. +- 자리 — `M02_Hopyo_Fit.py` 에 조합 풀이(C5 `c05` combine · cover · is_mixed · 길면 `M02_Hopyo_Combine.py` 로 나눔) · 우리 벡터는 ②의 `Ours` 한 벌 · `Match.match_line` 결과에 「조합」 칸 · `Match.match_hopyo`(:440-455 직접 줄 호표 — 벡터 + 인용 32 / 34) · `M02_Hopyo_Build.Builder.build`(:110-175 — 정할 것 N1 정한 뒤). +- 원본 — 37 조합 · 일위대가조합.json 은 견줌에만(읽기). + +**④ 조건부 기억(열쇠 = 로직 지문 · 품명 · 단위)** +- 화면 — 줄 상태 「기억 · 같은 로직 — 봉화 파일」 · 후보 단추 「기억 · 근거 다름(산림 ↔ 건설)」 · 같은 열쇠 다른 답은 답마다 후보 + 횟수. +- 자리 — `Match.line_key`(:68-76 열쇠 b · c · c2) · `Match.recall`(:348-352 근거 판정 갈래) · `Match.confirm`(:495-527) + `M02_Hopyo_Store.remember`(:130-140 덧칸: 열쇠2 · 지문 · 파일 · 발주처 · 엑셀 조항 · 로직 판 · 답들) · 층 차례 `Match.chain`(:333) 그대로 · 확정 길 `M02_Labor_Router.hopyo_memory`(:284). +- 원본 — 기억은 `실무자료/맞춤.json` 층 파일 · 마스터 로직 밖. + +**⑤ 읽기 넓힘(STmate · 거창)** +- 화면 — 지금 「일위대가 시트 없음」으로 막히는 STmate 내역이 올라가 같은 호표 표 · 고르기 창으로 · 거창 단가산출 근거가 보임. +- 자리 — 새 `M02_Hopyo_Stmate.py`(일위대가표 · 단가산출근거 · 노무비목록표 · 줄 코드 D · L · M · X · B · 7열 품) · `Excel._sheets`(:74-102 STmate 꼴이면 부름) · `Basis.read`(:84-125 거창 행 칸 이어 붙임). + +**⑥ 고르기 창** +- 화면 — 후보 단추에 책 · 조항 · 직종 · 딱 맞음 초록 · 판정 글 · 조합 · 기억 출처가 늘 보임 · [딱 맞음 모두 확정] · 금액 비 표시 없음. +- 자리 — 새 `M02_MasterTemplete_Hopyo_Choose.ts`(`View.ts` 614 줄 · `choose` :435-505 를 옮김 · 700 줄 지킴) · `STATE`(:45-53 꼬리표) · `M02_MasterTemplete_Hopyo_Api.ts`(:93 `HopyoMatch` 칸). +- 계산 자리 — 맞춤 · 지문 · 조합은 서버 단독(CLAUDE.md 5장 ③) · 화면은 보이기만. + +### 3-3. 추천 차례 + +1. ① 근거 고침 + ④의 근거 판정 · b 열쇠(지금 코드 a 열쇠의 해 막기) — 작고 가장 큼. +2. ② 로직 지문 + ⑥ 딱 맞음 · 판정 글 보이기. +3. ③ 조합 풀이 + ⑥ 조합 보이기(사전조합 담기는 N1 정한 뒤). +4. ④ 나머지(c 열쇠 · 출처 · 로직 판 · 잊기 · 층 올림) + ⑥ 기억 꼬리표. +5. ⑤ 읽기 넓힘 — 새 문서(STmate · 거창)를 곧 쓰면 1 과 나란히(파일이 안 겹침). + +--- + +## 4. 정할 것 — 336 정할 것 11 다시 정리 + +### 4-1. 풀림(이번 말로) + +| 앞 번호 | 물음 | 풀린 답 | +|---|---|---| +| 1 | 같은 일 다른 책(산림 ↔ 건설) | 엑셀이 쓴 숫자(품)와 같은 책 로직이 정답 · 두 책 숫자가 같으면 인용 책 · 인용과 숫자가 어긋나면 숫자 | +| 3 | 금액 비 표시 기준 | 뺌 — 순위 · 판정 · 표시에 금액 안 씀 · 대신 「딱 맞음」 | +| 5 | 둘째 열쇠 기억 바로 / 후보 | 「경우에 따라」 = 근거 판정 — a · c 근거 맞음 바로 · b · c2 1순위 후보 · 근거 다름 2순위(임도만 보고도 같은 규칙이 뽑힘) | +| 8 | 섞인 줄(9 : 1) 인력 몫 하나 / 둘 | 방향 = 로직의 조합으로 담음(인력 몫 로직 × 비율 + 기계 시간 로직) · 담는 꼴은 N1 | + +### 4-2. 남음 + +- 2 「같음」 자동 들어감 — 「같음」이라도 딱 맞음이 다른 로직이면 후보로 내림(126 줄 해 0) · 나머지 「같음」은 그대로 자동 / 한 번 누름 — N2 와 함께 정함. +- 4 회사 공유 층 — 개인 → 회사(관리자 올림 단추 · 두 파일 넘게 같은 답이면 표시) → 마스터(씨앗 · c 열쇠만) 안대로 할지. +- 7 노임 줄 — 노임을 바로 적은 호표는 호표 통째를 로직 하나로(③) · 따로 남는 노임 줄만 「노임 단가」 표시 · 노임 줄 꼴을 새로 만들지. +- 9 차례 — 3-3 안 · ⑤를 ①과 나란히 할지. +- 10 외부 AI — 그대로 보류. 11 문서 보안 파일 — 안내 글 그대로. +- 6 잔토처리 → N3 로 합침. + +### 4-3. 새 + +- **N1 사전조합 「한 줄 = 담은로직 여럿」** — 철근 가공 + 조립(같은 수량) · 섞인 줄 = 인력 몫 로직 × 0.1 + 기계 시간 로직 × 시간 · 지금 사전조합은 한 줄 = 로직 하나 → M02 만들기(`Build`) 꼴을 바꿀지(일위대가조합.json · 원본 로직 안 고침). +- **N2 「딱 맞음」 한꺼번에 확정** — [딱 맞음 모두 확정] 한 번 / 확정 없이 자동 · 가림에서도 거의 다 맞음(2-6). +- **N3 「우리에 없음」 줄** — 원본 안 고침 → 사용자가 M01 로직 화면 [복사해서 만들기](자체 로직 GX · 정본 안 건드림)로 만들지 · 「없음」으로 둘지 · 숫자가 같은 로직이 있으면 「숫자 같음」 꼬리표로만 보일지(잔토처리 6 · 고화매트 · 철강재 운임 · 노임 줄 2 · 영월 잔토처리 B/H 100% = 숫자로는 토사깍기(기계)). +- **N4 발주처 칸** — 올릴 때 발주처를 고르면 같은 발주처 기억은 근거와 상관없이 바로(같은 발주처 맞음 100% · 새 파일 씨앗 짝 65.1 → 72.1) · 올리기 화면에 칸을 둘지. + +--- + +## 5. 한계 · 남는 실패 + +### 5-1. 맨 위 남는 1순위 실패 — 씨앗 켬 9 · 씨앗 끔 13 + +| 줄 | 다섯 안 | 까닭 | 풀 길 | +|---|---|---|---| +| 야면석소운반 ×2 · 콘크리트소운반 | 밖 | 현장말 · 옛 9-1 인력운반 식 · 단위 다름 | [다른 로직 찾기] 한 번 → 기억(같은 품명 다음 파일) | +| 철골설치 · 철골가공조립(공장) | 2위 | 엑셀 「철골공」 ↔ 우리 「철골 가공조립공」 | M02 맞춤 쪽 직종 이름 짝(LB 안 고침) | +| 구조물터파기(토사) — 엑셀 18 줄 | 2위(느슨 맞음) | 보통 ↔ 혼합토사 세분 | 토질 세분 조건 · 기억 | +| 콘크리트 펌프차 | 3위 | 장비 시간 줄 | ① 덤(장비 시간 줄 → 기계 시간당 기계경비 · 이번 흉내 밖) | +| 측구수로관부설 | 5위 | 플륨관 이름 | 기억 | +| 큰돌채집운반(영월) | 밖 | 직종 · 단위 바꿈(보통인부 ↔ 작업반장 · ㎡ ↔ 개) — 지문이 깸(①은 맞음) | 기억 | +| (씨앗 끔에서 더) 콘크리트타설 · 임도안전표지판 · 잡석운반및다짐 · 콘크리트포장절단 | 2~5위 | 씨앗(사람 확정) 몫 · 옛 판 번호 | 씨앗 = 마스터 층 기억 | + +### 5-2. 한계 + +- 지문 × 조합 겹침 규칙(섞인 줄 몫 먼저)은 결과를 보고 넣음 — 사방에서 맞음 2 · 해 0 이나 새 파일 섞인 줄로는 못 잼. +- 표본 — 사람 정답 126 줄(둘이 같은 발주처) · 새 파일 51 줄(자동 정답은 지문과 같은 재료 · 씨앗 짝은 사람 짝 · 일부 짐작) · 임도가 거의 꽉 차 무게를 한 파일로 못 정함. +- 안 돌린 손잡이 — C5 조합 풀이 안 문턱(0.6 · 0.75 · 0.85) · 몫 비율 후보 · C6 열쇠 차례 · 딱 맞음 문턱(3% · 점수 차 0.3). +- 새 파일 = 가벼운 지문 · 조합 풀이 못 잼 · 기억 흉내 = 앞 파일을 사람이 정답대로 다 확정했다고 침(파일 차례 · 봉화 · 거창 발주처 짐작) · 씨앗 짝 정답과 기억이 같은 씨앗 출처 → 새 파일 기억 효과는 위 끝. +- 「우리에 없음」 10 줄에도 지문은 늘 후보를 냄(10 / 10) · 점수 문턱으로 숨기기는 무익(C3 · C4) → N3. +- 사람이 고르는 손(고르기 창 빠르기)은 안 잼 · 장수 · 진안 2 파일은 문서 보안 잠김. + +--- + +## 6. 코드 자리 + +| 할 것 | 자리 | 묶음 | 원본 | +|---|---|---|---| +| 조항 윗마디 · 오타 | `M02_MasterTemplete/M02_Hopyo_Basis.py` `_clause` :166-176 · `sources` :60-71 | ① | 안 고침 | +| 같은 이름 접힘 · 「같음」 근거 검사 | `M02_Hopyo_Match.py` `candidates` :300-331 · `match_line` :395-403 | ① | 안 고침 | +| 노임 · 장비 시간 줄 | `M02_Hopyo_Excel.py` `kind_of` :132-150 | ① | 안 고침 | +| 엑셀 블록 → 지문 · 절 · 몫 | 새 `M02_Hopyo_Finger.py` ← `Basis.read` :84-125 에서 부름 | ②③ | 안 고침 | +| 우리 로직 지문 · 벡터 캐시(뒤에서 데움) | 새 `M02_Hopyo_Ours.py` — `M01_MasterData_Store.loaded` :65 · `M01_MasterData_Store_Flow.run` :170 부르기만 | ②③ | 읽기만 | +| 닮음 · 딱 맞음 · 판정 글 · 조합 풀이 | 새 `M02_Hopyo_Fit.py`(길면 `M02_Hopyo_Combine.py` 로 나눔) | ②③ | — | +| 후보 다시 줄 세움 · 입력값 · 직접 줄 호표 | `M02_Hopyo_Match.py` `match_line` :404-414 · `guess` :417-429 · `match_hopyo` :440-455 | ②③ | — | +| 기억 열쇠 · 근거 판정 · 덧칸 | `M02_Hopyo_Match.py` `line_key` :68-76 · `recall` :348-352 · `confirm` :495-527 · `M02_Hopyo_Store.py` `remember` :130-140 | ④ | 마스터 밖 | +| STmate · 거창 읽기 | 새 `M02_Hopyo_Stmate.py` · `Excel._sheets` :74-102 · `Basis.read` :84-125 | ⑤ | 안 고침 | +| 사전조합 「한 줄 = 담은로직 여럿」 | `M02_Hopyo_Build.py` `Builder.build` :110-175 | ③(N1) | 일위대가조합.json 안 고침 | +| 고르기 창 | 새 `M02_MasterTemplete_Hopyo_Choose.ts` ← `View.ts` `choose` :435-505 · `STATE` :45-53 · `Hopyo_Api.ts` :93 | ⑥ | — | +| 시험 | `resources/tester/test_m02_hopyo_finger.py`(식 풀기 · N001 → GC000211 · 딱 맞음) · `_fit`(철근 조합 · 고임돌 몫 · 섞인 줄 몫 먼저) · `_match`(기억 근거 판정) · `_stmate` | 모두 | — | + +- 원본 로직 안 고침 확인 — 이번 실측도 `resources/master_data` · `일위대가조합.json` · M01 을 읽기만(엔진 부르기 · 같은 프로세스 바꿔 끼움) · 저장소 쓰기 없음. +- C7 실험(스크래치패드 `req226/C7/` · 저장소 `venv` 파이썬 `-B`) — `k0_lib.py`(C1 · C3 · C4 · C5 · C6 함수 묶음 · 단계) · `k1_stack.py 켬|끔`(누적 표 → `stack_*.json`) · `k2_detail.py`(기억 자리별 · 남는 실패) · `k3_vec.py`(지문 채널 미리 셈 → `vec_*.npz`) · `k4_wide.py`(새 파일 정답 · ① → `wide.json`) · `k5_hold.py 준비|그물`(가림 시험 → `hold_*.json`) · `k6_sum.py`(가림 정리) · `k7_wide_stack.py`(새 파일 누적 · 가벼움 눈금 · 딱 맞음) · `k8_sure_same.py`(딱 맞음이 같음을 이김).