diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 2d436080b..63fc84c16 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -31,6 +31,7 @@ import type { DesignView } from "./design_dock"; import { saveSharedDesign, type DesignPageShared } from "./design_shared"; import { guardDesignLeave } from "./design_leave"; import { usePipeIds } from "./design_pipe_ids"; +import { setRouteShiftOpener } from "../../B06_Section/B06_Section_UI_Cross_Center"; import "./design_page.css"; export type { DesignPageShared } from "./design_shared"; @@ -140,6 +141,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage { ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready, }; guardDesignLeave(projectId, page, () => saveSharedDesign(shared, false)); // 떠남 확인(346-4) + setRouteShiftOpener(() => shared.ensureView("profile"), showToast); // 중심점 좌우 = 편집기 숨긴 채(356-3) /** 한 벌이 보기를 따라간다 — 좌측 칸 · 단추 글자 · 종단도 판 · 진행 단계 · 제목. */ const applyView = (view: DesignView): void => { diff --git a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts index febb8b805..32c90eea2 100644 --- a/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts +++ b/B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts @@ -10,6 +10,7 @@ import * as THREE from "three"; import { contourAssetUrl, fetchCachedJson } from "../A00_Common/b_asset_cache"; +import { contourMaterials, isMajorContour } from "@ui/ui_template_contour3d"; /** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다(분리 전 상수 그대로). */ const MAX_CONTOUR_LABELS = 40; @@ -93,7 +94,7 @@ export async function loadContourLinesInto( const points = transform(c.coordinates); if (points.length < 2) return; - const isMajor = c.level % (interval * 5) === 0; + const isMajor = isMajorContour(c.level); const bucket = isMajor ? majorPoints : minorPoints; for (let i = 0; i < points.length - 1; i++) { bucket.push(points[i], points[i + 1]); @@ -156,18 +157,15 @@ export async function loadContourLinesInto( ctx.setLabelsDirty(true); } - // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다. + // 합쳐 둔 점들을 주곡선·보조곡선 각각 한 덩어리로 올린다 — 주곡선 = 5 m 고정 · 1 m 는 + // 옅게(평면 그림과 같은 색 토큰 · 353-3). + const materials = contourMaterials(); [ - { points: minorPoints, color: 0xf59e0b }, - { points: majorPoints, color: 0xd97706 }, - ].forEach(({ points, color }) => { + { points: minorPoints, material: materials.minor }, + { points: majorPoints, material: materials.major }, + ].forEach(({ points, material }) => { if (points.length === 0) return; const geometry = new THREE.BufferGeometry().setFromPoints(points); - const material = new THREE.LineBasicMaterial({ - color, - transparent: true, - opacity: 0.8, - }); ctx.contourGroup.add(new THREE.LineSegments(geometry, material)); }); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 3947302b3..3d05ffc66 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -28,6 +28,7 @@ import { loadRouteEditBackground, type RouteEditContours, } from "./B05_Profile_UI_RouteEdit_Contour"; +import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { analysisTerrain, @@ -90,6 +91,7 @@ import { } from "./B05_Profile_UI_RouteEdit_History"; import type { DesignPageHost } from "../A00_Common/design/design_page"; import * as draftLink from "./B05_Profile_UI_RouteEdit_Draft"; +import { bindRouteShift } from "./B05_Profile_UI_RouteEdit_Shift"; import "./B05_Profile_UI_Style_RouteEdit.css"; /** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다 @@ -190,6 +192,12 @@ export async function mountRouteEditor( let otherSheets: PreparedLayer[] = []; /** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */ let pickedContour = -1; + /** 등고선 자물쇠(353-2) — 잠긴 동안 빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜. */ + const contourLock = createContourLock( + canvas, + () => contours && { layer: contours.layer, view, stepM: contourStepM(), rotation }, + (index) => ((pickedContour = index), draw()), + ); /** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */ const crossPreview = createCrossPreview({ projectId, @@ -515,7 +523,7 @@ export async function mountRouteEditor( picked = index; }, setPickedContour: (index) => { - pickedContour = index; + if (contourLock.canPick()) pickedContour = index; }, toScreen, toMetric, @@ -621,6 +629,7 @@ export async function mountRouteEditor( history = createRouteEditHistory(snapshotNow()); history = draftLink.watch(history, options.draft, () => stationSnap); draftLink.restore(options.draft, restoreSnapshot); + bindRouteShift(host, projectId, snapshotNow, applyEdit, () => [plannedLine, curveInfo]); // 356-3 historyControls.sync(); syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); @@ -646,7 +655,7 @@ export async function mountRouteEditor( const useBackground = (background: Awaited>): void => { otherSheets = background.otherSheets; contours = background.contours; - pickedContour = -1; + pickedContour = contourLock.reattach(contours?.layer ?? null); // 1m 선이 있는 자료(LAS)일 때만 단추를 연다 — 도엽에는 1m 선이 없다. minorContourButton.disabled = !contours?.minor; minorContourButton.title = contours?.minor diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 875da3db4..211b0658a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -80,7 +80,7 @@ export function createRouteEditChrome( aria-pressed="false" title="곡선(호) 안 측점 하나를 곡선 중점(MC)으로 옮깁니다 — [저장] 때 저장">측점 스냅 + title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 파랑)을 평면에 겹칩니다">절/성토선 diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts b/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts new file mode 100644 index 000000000..e8e52b173 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_ContourLock.ts @@ -0,0 +1,151 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_ContourLock.ts + * 평면 등고선 자물쇠(353-2) — 등고선 위를 지나거나 고른 가닥 위에 뜨는 작은 단추. + * + * 누르면 그 높이를 고정(빈 곳 · 다른 가닥을 눌러도 고른 것이 안 바뀜) · 다시 누르면 풂. + * 높이값으로 쥐어 지형을 바꿔도 같은 높이를 다시 짚음 · 페이지 동안만(저장 안 함). + * 규칙은 `logic/B05_Profile_ContourLock` · 호버 판정은 집기와 같은 `hitPreparedLayer`. + * ========================================================================== */ + +import { pickPx } from "../A00_Common/touch_bridge"; +import { + hitPreparedLayer, + type PreparedLayer, + type ViewState, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import { + canPickContour, + featureOfLevel, + lockButtonState, + toggleLock, +} from "./logic/B05_Profile_ContourLock"; +import "./B05_Profile_UI_Style_RouteEdit_ContourLock.css"; + +/** 집기(`_Pointer` CONTOUR_HIT_PX)와 같은 거리(px). */ +const HOVER_HIT_PX = 6; +/** 커서가 선을 떠난 뒤 단추가 남는 시간(ms) — 단추로 옮겨 갈 틈. */ +const HIDE_DELAY_MS = 700; +/** 단추는 커서 오른쪽 위로 이만큼(px) — 선과 커서를 안 가림. */ +const OFFSET_PX = 12; + +const LOCK_SVG = ``; + +/** 호버 판정에 쓰는 지금 화면 — 등고선이 없으면 null. */ +export interface ContourLockScene { + layer: PreparedLayer; + view: ViewState; + /** 지금 화면 등고선 간격 — 그리지 않은 줄은 호버도 안 됨. */ + stepM: number; + rotation: { unrotate: (px: number, py: number) => [number, number] }; +} + +export interface ContourLock { + /** 고른 가닥을 바꿔도 되는지(잠긴 동안 false). */ + canPick: () => boolean; + /** 지형을 바꿔 새 가닥들이 왔다 — 잠근 높이의 가닥 번호(없으면 -1). */ + reattach: (layer: PreparedLayer | null) => number; +} + +/** 자물쇠 단추를 캔버스 칸 위에 세움 · `pick` = 잠글 때 그 가닥을 고른 것으로(다시 그리기까지). */ +export function createContourLock( + canvas: HTMLCanvasElement, + scene: () => ContourLockScene | null, + pick: (index: number) => void, +): ContourLock { + const wrap = canvas.parentElement ?? document.body; + const button = document.createElement("button"); + button.type = "button"; + button.className = "ui-btn ui-btn--glass b05-contourlock"; + button.dataset.act = "contour-lock"; + button.hidden = true; + button.innerHTML = LOCK_SVG; + wrap.append(button); + + let lockedLevel: number | null = null; + /** 단추가 가리키는 가닥 — 누르면 이 가닥을 잠금. */ + let target = -1; + let hideTimer = 0; + let frame = 0; + let last: PointerEvent | null = null; + + const levelOf = (index: number): number | null => + index >= 0 ? (scene()?.layer.features[index]?.labelValue ?? null) : null; + + const paint = (): void => { + const pressed = lockedLevel !== null; + button.setAttribute("aria-pressed", String(pressed)); + const label = pressed + ? `등고선 ${lockedLevel}m 고정 풀기 — 풀면 다른 등고선을 고를 수 있음` + : `등고선 ${levelOf(target) ?? ""}m 고정 — 빈 곳 · 다른 등고선을 눌러도 안 바뀜`; + button.setAttribute("aria-label", label); + button.title = label; + }; + + const hideLater = (): void => { + if (hideTimer) return; + hideTimer = window.setTimeout(() => { + hideTimer = 0; + button.hidden = true; + }, HIDE_DELAY_MS); + }; + const keep = (): void => { + window.clearTimeout(hideTimer); + hideTimer = 0; + }; + + const hover = (event: PointerEvent): void => { + const now = scene(); + // 단추를 누른 채(노드 · 손잡이 끌기 · 화면 이동) 지나가면 안 봄 + if (!now || event.buttons !== 0) return hideLater(); + const rect = canvas.getBoundingClientRect(); + const [px, py] = now.rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); + const hitPx = pickPx(HOVER_HIT_PX); + const index = hitPreparedLayer(now.layer, now.view, px, py, hitPx, now.stepM); + const state = lockButtonState(levelOf(index), lockedLevel); + if (!state.visible) return hideLater(); + keep(); + target = index; + const origin = wrap.getBoundingClientRect(); + button.style.left = `${Math.round(event.clientX - origin.left + OFFSET_PX)}px`; + button.style.top = `${Math.round(event.clientY - origin.top - OFFSET_PX)}px`; + button.hidden = false; + paint(); + }; + + // 한 프레임에 한 번만 잼 — 도엽 등고선은 수천 가닥. + canvas.addEventListener("pointermove", (event) => { + last = event; + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + if (last) hover(last); + }); + }); + // 고른 직후(누름을 뗀 자리)에도 띄움 — 터치는 호버가 없음. + canvas.addEventListener("pointerup", hover); + canvas.addEventListener("pointerleave", hideLater); + button.addEventListener("pointerenter", keep); + button.addEventListener("pointerleave", hideLater); + button.addEventListener("pointerdown", (event) => event.stopPropagation()); + button.addEventListener("click", (event) => { + event.stopPropagation(); + lockedLevel = toggleLock(lockedLevel, levelOf(target)); + if (lockedLevel !== null) pick(target); + paint(); + }); + + return { + canPick: () => canPickContour(lockedLevel), + reattach: (layer) => { + target = -1; + button.hidden = true; + return featureOfLevel( + layer?.features.map((feature) => feature.labelValue) ?? [], + lockedLevel, + ); + }, + }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Shift.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Shift.ts new file mode 100644 index 000000000..ff19d2ce2 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Shift.ts @@ -0,0 +1,99 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Shift.ts + * 횡단 중심점 좌우 → 노선 편집(356-3) — B06 카드가 부르는 host(`setRouteShiftHost`)를 편집기가 등록. + * + * 한 번 = 노드 옮김 · 끼움 한 번(고름은 `logic/B05_Profile_RouteShift`) → 편집기 한 걸음(applyEdit) → + * 계산 묶음이 뒤 측점을 조용히 다시 셈 → 새 종단이 오면 그 측점 계획고를 옮기기 전 값으로(바뀐 구간 첫 + * 계획선 맞춤에 휩쓸리지 않게 · 계획고 편집). 둘을 페이지 되돌리기 한 걸음으로(사진첩 pause · resume). + * ========================================================================== */ + +import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline"; +import { pagePipeline, pipelineGrade } from "../A00_Common/design/design_pipeline_page"; +import { setRouteShiftHost } from "../B06_Section/B06_Section_UI_Cross_Center"; +import { pageHistory } from "./B05_Profile_UI_Profile_History"; +import type { RouteEditHost } from "./B05_Profile_UI_RouteEdit"; +import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History"; +import type { EditedCurve } from "./logic/B05_Profile_UI_RouteEdit_Curve"; +import { spliceNode } from "./logic/B05_Profile_UI_RouteEdit_Edits"; +import { + emptyEdits, + planElevationAt, + type AlignmentEdits, +} from "./logic/B05_Profile_UI_Profile_Alignment"; +import { pinStationHeight, planRouteShift } from "./logic/B05_Profile_RouteShift"; + +type Vertex = [number, number]; +/** 새 종단을 기다리는 한계(ms) — 넘으면 높이 보정 없이 한 걸음을 닫음. */ +const PROFILE_WAIT_MS = 10000; + +/** 다음 조작의 종단 단계(가장 늦은 차례)가 끝날 때 true · 한계를 넘으면 false. */ +function nextProfile(pipe: DesignPipeline): Promise { + return new Promise((resolve) => { + const done = (ok: boolean): void => { + clearTimeout(timer); + stop(); + resolve(ok); + }; + const timer = setTimeout(() => done(false), PROFILE_WAIT_MS); + const stop = pipe.subscribe((event) => { + if ( + event.kind === "stage" && + event.stage === "profile" && + event.generation === pipe.generation + ) + done(true); + }, "중심점 좌우"); + }); +} + +/** + * 편집기가 자료를 읽은 뒤 부름 — `columns` = 지금 편집값(산 배열 · 끼움 · 옮김이 그 자리를 고침) · + * `apply` = 편집기 한 걸음 · `drawn` = [그린 계획노선, 곡선표]. 편집기가 떠나면 host 를 뗌. + */ +export function bindRouteShift( + host: RouteEditHost, + projectId: string, + columns: () => RouteEditSnapshot, + apply: (message: string) => void, + drawn: () => [Vertex[], EditedCurve[]], +): void { + setRouteShiftHost(async (chainageM, deltaM) => { + const pipe = pagePipeline(projectId); + if (pipe && !pipe.output("profile")) await nextProfile(pipe); // 막 세운 묶음(B06 바로 열기) — 첫 셈 + const now = columns(); + const [line, curves] = drawn(); + const plan = planRouteShift({ + planned: now.planned, + line: line.length ? line : now.planned, + curves, + locked: now.apexLock, + chainageM, + deltaM, + }); + if (!plan) return "노선을 아직 못 읽었습니다."; + if (plan.kind === "locked") return "고정한 노드라 옮기지 않았습니다 — 고정을 먼저 푸세요."; + const profile = pipe?.output("profile"); + const zBefore = profile ? planElevationAt(profile, chainageM) : null; + const album = pageHistory(); + album?.pause(); + try { + const routed = pipe ? nextProfile(pipe) : null; + if (plan.kind === "move") now.planned[plan.index] = plan.to; + else spliceNode(now, plan.index, plan.at); + const side = deltaM > 0 ? "좌" : "우"; + apply(`횡단 중심점 — 측점 노선을 ${side}로 ${Math.abs(deltaM).toFixed(1)}m 옮겼습니다.`); + if (!pipe || zBefore === null || !(await routed)) return null; + const base = pipe.output("planline"); + const edits = (pipe.input("alignment") as AlignmentEdits | null) ?? emptyEdits(); + const pinned = base && pinStationHeight(base, edits, chainageM, zBefore); + if (!pinned) return null; + const graded = nextProfile(pipe); + pipelineGrade(projectId, pinned); + await graded; + return null; + } finally { + album?.resume(); + } + }); + host.onDispose(() => setRouteShiftHost(null)); +} diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit_ContourLock.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit_ContourLock.css new file mode 100644 index 000000000..6a2f493b0 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit_ContourLock.css @@ -0,0 +1,28 @@ +/* 평면 등고선 자물쇠(353-2) — 커서 옆에 뜨는 작은 단추 · 자리는 `_ContourLock.ts` 가 잡음. */ +.b05-contourlock { + position: absolute; + z-index: 3; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border-radius: 999px; +} + +.b05-contourlock[hidden] { + display: none; +} + +/* 풀림 = 고리가 들림 · 잠김 = 고른 등고선 색(보라)으로 채움 */ +.b05-contourlock[aria-pressed="false"] .b05-contourlock__shackle { + transform: translate(2.5px, -1px) rotate(18deg); + transform-origin: 11px 7px; +} + +.b05-contourlock[aria-pressed="true"] { + border-color: var(--map-flow-arrow); + background-color: var(--map-flow-arrow); + color: #fff; +} diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 619242cc5..00eb84fa7 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -1,6 +1,7 @@ import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { createTerrainCompass } from "@ui/ui_template_compass"; +import { contourMaterials, isMajorContour } from "@ui/ui_template_contour3d"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; @@ -339,21 +340,20 @@ export function createRouteViewer(): RouteViewer { ([x, y, z]) => new THREE.Vector3(x - cx, z - cz + 0.15, -(y - cy)), ); if (points.length < 2) return; - const bucket = contour.level % (interval * 5) === 0 ? majorPoints : minorPoints; + const bucket = isMajorContour(contour.level) ? majorPoints : minorPoints; for (let index = 0; index < points.length - 1; index += 1) { bucket.push(points[index], points[index + 1]); } }); + // 주곡선 = 5 m 고정 · 1 m 는 옅게(평면 그림과 같은 색 토큰 · 353-3) + const materials = contourMaterials(); [ - { points: minorPoints, color: 0xf59e0b }, - { points: majorPoints, color: 0xd97706 }, - ].forEach(({ points, color }) => { + { points: minorPoints, material: materials.minor }, + { points: majorPoints, material: materials.major }, + ].forEach(({ points, material }) => { if (points.length === 0) return; contours.add( - new THREE.LineSegments( - new THREE.BufferGeometry().setFromPoints(points), - new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.75 }), - ), + new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(points), material), ); }); } diff --git a/B05_Profile/logic/B05_Profile_ContourLock.ts b/B05_Profile/logic/B05_Profile_ContourLock.ts new file mode 100644 index 000000000..85fffd545 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_ContourLock.ts @@ -0,0 +1,39 @@ +/* ============================================================================= + * B05_Profile/logic/B05_Profile_ContourLock.ts + * 평면 등고선 고정(353-2) — 고른 높이 하나를 쥐고, 잠긴 동안 다른 지정을 막는 규칙. + * + * 높이값으로 쥔다(가닥 번호는 지형을 바꾸면 달라짐) · 페이지 동안만(저장 안 함). + * 순수 계산 — 화면 · three · fetch · sessionStorage 없음. 단추는 `B05_Profile_UI_RouteEdit_ContourLock`. + * ========================================================================== */ + +/** 가닥마다 높이(모르면 null). */ +export type ContourLevels = ReadonlyArray; + +/** 자물쇠 단추 — 보일지 · 눌린 모양인지. */ +export interface LockButtonState { + visible: boolean; + pressed: boolean; +} + +/** 그 높이의 첫 가닥 번호 — 없으면 -1(지형을 바꾼 뒤 잠근 높이 다시 짚기). */ +export function featureOfLevel(levels: ContourLevels, level: number | null): number { + if (level === null) return -1; + return levels.findIndex((value) => value === level); +} + +/** 커서 아래 가닥 높이 · 잠근 높이 → 단추 — 안 잠김 = 높이 있는 가닥 위에서 · 잠김 = 그 높이 위에서만. */ +export function lockButtonState( + hoverLevel: number | null, + lockedLevel: number | null, +): LockButtonState { + if (lockedLevel !== null) return { visible: hoverLevel === lockedLevel, pressed: true }; + return { visible: hoverLevel !== null, pressed: false }; +} + +/** 단추 누름 — 잠김이면 풂(null) · 아니면 그 가닥 높이로 잠금. */ +export function toggleLock(lockedLevel: number | null, targetLevel: number | null): number | null { + return lockedLevel !== null ? null : targetLevel; +} + +/** 잠긴 동안은 고른 가닥이 안 바뀜(다른 가닥 · 빈 곳 눌러도). */ +export const canPickContour = (lockedLevel: number | null): boolean => lockedLevel === null; diff --git a/B05_Profile/logic/B05_Profile_PlanFootprint.ts b/B05_Profile/logic/B05_Profile_PlanFootprint.ts index 62ca60b39..93d9c06c9 100644 --- a/B05_Profile/logic/B05_Profile_PlanFootprint.ts +++ b/B05_Profile/logic/B05_Profile_PlanFootprint.ts @@ -3,7 +3,8 @@ * 평면 발자국 — 측점 횡단 설계 결과를 평면 선으로(도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금) (346-2) * * ⚠⚠ 짝: `B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py` - * (`route_frame` · `side_is_cut` · `slope_end` · `slope_pieces`) — 한쪽만 고치면 + * (`route_frame` · `route_normal` · `side_is_cut` · `slope_end` · `slope_pieces` 와 그 아래 + * 단조 3차 보간 · 촘촘히 · 접힘 버림 · 353-1) — 한쪽만 고치면 * 종단 · 횡단 편집 중 평면과 B07 계획평면도가 갈린다. * `footprintRecord` = `B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄. * 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py` @@ -105,18 +106,6 @@ export function routeFrame(route: Array<[number, number, number]>, chainage: num return [x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length]; } -function interp(records: Array>, chainage: number, key: string): number { - const chainages = records.map((record) => record.chainage_m); - const index = bisectLeft(chainages, chainage); - if (index <= 0) return records[0][key]; - if (index >= records.length) return records[records.length - 1][key]; - const before = records[index - 1]; - const after = records[index]; - const span = after.chainage_m - before.chainage_m; - const ratio = span > 0 ? (chainage - before.chainage_m) / span : 0.0; - return before[key] + (after[key] - before[key]) * ratio; -} - function groundOf(samples: FootprintPoint[]): Array<[number, number]> { return samples .filter( @@ -216,13 +205,187 @@ function splitRuns(points: Line, flags: boolean[]): [Line[], Line[]] { return [runs.true.filter((run) => run.length >= 2), runs.false.filter((run) => run.length >= 2)]; } +/** 절/성토선 매끄럽게(353-1) — 네 선 중 하나라도 사이 가운데 점에서 이보다 꺾이면 반으로 나눔(°). 짝 같은 이름. */ +export const SPLIT_TURN_DEG = 2.0; +/** 나눔 깊이 상한(2 m → 12.5 cm). */ +export const SPLIT_DEPTH = 4; +/** 사이 점 법선을 재는 앞뒤 거리(m) — 측점 횡단 틀(`stationFrameAt`)과 같은 값. */ +export const NORMAL_PROBE_M = 1.0; + +type Frame = [number, number, number, number]; + +/** 노선 위 그 자리와 좌측 법선 — 법선은 앞뒤 ±1 m 두 점을 잇는 방향. 짝: `route_normal`. */ +export function routeNormal(route: Array<[number, number, number]>, chainage: number): Frame { + const [x, y, nx, ny] = routeFrame(route, chainage); + const first = route[0][0]; + const last = route[route.length - 1][0]; + let before = Math.max(first, chainage - NORMAL_PROBE_M); + let after = Math.min(last, chainage + NORMAL_PROBE_M); + if (after - before <= 1e-9) { + before = Math.max(first, chainage - 1e-3); + after = Math.min(last, chainage + 1e-3); + } + const [x0, y0] = routeFrame(route, before); + const [x1, y1] = routeFrame(route, after); + const length = Math.hypot(x1 - x0, y1 - y0); + if (length <= 1e-9) return [x, y, nx, ny]; + return [x, y, -(y1 - y0) / length, (x1 - x0) / length]; +} + +function bisectRight(values: number[], target: number): number { + let low = 0; + let high = values.length; + while (low < high) { + const middle = (low + high) >> 1; + if (target < values[middle]) high = middle; + else low = middle + 1; + } + return low; +} + +type Knots = [number[], number[], number[]]; + +/** 단조 3차(Fritsch–Carlson) 꺾임점 기울기 — 이웃 값 사이를 넘지 않음. 짝: `_pchip_slopes`. */ +function pchipSlopes(xs: number[], ys: number[]): number[] { + const n = xs.length; + if (n < 2) return xs.map(() => 0); + const h = xs.slice(1).map((x, i) => x - xs[i]); + const delta = h.map((span, i) => (ys[i + 1] - ys[i]) / span); + const slopes = [delta[0], ...new Array(n - 2).fill(0), delta[n - 2]]; + for (let k = 1; k < n - 1; k += 1) { + if (delta[k - 1] * delta[k] > 0) { + const w1 = 2 * h[k] + h[k - 1]; + const w2 = h[k] + 2 * h[k - 1]; + slopes[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k]); + } + } + return slopes; +} + +/** 꺾임점 값을 그대로 지나는 단조 3차 보간 — 범위 밖은 끝값. 짝: `_pchip`. */ +function pchip([xs, ys, slopes]: Knots, x: number): number { + if (x <= xs[0]) return ys[0]; + if (x >= xs[xs.length - 1]) return ys[ys.length - 1]; + const i = bisectRight(xs, x) - 1; + const h = xs[i + 1] - xs[i]; + const t = (x - xs[i]) / h; + return ( + (1 + 2 * t) * (1 - t) ** 2 * ys[i] + + t * (1 - t) ** 2 * h * slopes[i] + + t * t * (3 - 2 * t) * ys[i + 1] + + t * t * (t - 1) * h * slopes[i + 1] + ); +} + +const knots = (xs: number[], ys: number[]): Knots => [xs, ys, pchipSlopes(xs, ys)]; + +interface SideCurves { + chainages: number[]; + flags: boolean[]; + switches: number[]; + edge: Knots; + width: Knots; +} + +/** 한 쪽 도로 끝 · 비탈 폭 보간 + 깎기↔쌓기 바뀜 자리(폭 0 = 비탈 끝이 도로 끝으로 모임). 짝: `_side_curves`. */ +function sideCurves(records: FootprintRecord[], side: Side): SideCurves { + const chainages = records.map((record) => record.chainage_m); + const edges = records.map((record) => record[`${side}_edge_m`]); + const widths = records.map((record) => record[`${side}_end_m`] - record[`${side}_edge_m`]); + const flags = records.map((record) => Boolean(record[`${side}_cut`])); + const switches: number[] = []; + const widthX = [chainages[0]]; + const widthY = [widths[0]]; + for (let i = 1; i < records.length; i += 1) { + const c0 = chainages[i - 1]; + const c1 = chainages[i]; + const w0 = Math.abs(widths[i - 1]); + const w1 = Math.abs(widths[i]); + const at = c0 + (c1 - c0) * (w0 + w1 > 1e-9 ? w0 / (w0 + w1) : 0.5); + switches.push(at); + if (flags[i - 1] !== flags[i] && c0 + 1e-9 < at && at < c1 - 1e-9) { + widthX.push(at); + widthY.push(0.0); + } + widthX.push(c1); + widthY.push(widths[i]); + } + return { + chainages, + flags, + switches, + edge: knots(chainages, edges), + width: knots(widthX, widthY), + }; +} + +/** 그 자리 깎기 여부 — 바뀜 자리부터 뒤 측점 것(바뀌는 점은 앞뒤 조각이 나눠 가짐). 짝: `_cut_at`. */ +function cutAt(curves: SideCurves, chainage: number): boolean { + const { flags } = curves; + const i = bisectRight(curves.chainages, chainage); + if (i === 0) return flags[0]; + if (i === flags.length) return flags[flags.length - 1]; + if (flags[i - 1] === flags[i]) return flags[i]; + return chainage < curves.switches[i - 1] ? flags[i - 1] : flags[i]; +} + +/** 진행을 거스르는 점(안쪽 비탈 끝이 곡선 중심을 넘어 접힘)을 버린 남는 점 번호. 짝: `_forward_only`. */ +function forwardOnly(points: Line, frames: Frame[]): number[] { + const kept = points.length ? [0] : []; + for (let i = 1; i < points.length; i += 1) { + const last = points[kept[kept.length - 1]]; + const [, , nx, ny] = frames[i]; + if ((points[i][0] - last[0]) * ny - (points[i][1] - last[1]) * nx > 1e-6) kept.push(i); + } + return kept; +} + +function turnDeg(a: number[], m: number[], b: number[]): number { + const u = [m[0] - a[0], m[1] - a[1]]; + const v = [b[0] - m[0], b[1] - m[1]]; + return Math.abs( + (Math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]) * 180) / Math.PI, + ); +} + +/** 곡선 · 폭이 가파르게 바뀌는 자리 — 가운데 점에서 많이 꺾이는 사이를 반씩 나눔. 짝: `_densify`. */ +function densify( + chainages: number[], + frameOf: (chainage: number) => Frame, + place: (chainage: number, frame: Frame) => Line, +): [number[], Frame[]] { + const split = (c0: number, p0: Line, c1: number, p1: Line, depth: number): number[] => { + if (depth >= SPLIT_DEPTH) return []; + const middle = (c0 + c1) / 2.0; + const pm = place(middle, frameOf(middle)); + // 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음 + const bends = pm.some((m, k) => { + const turn = turnDeg(p0[k], m, p1[k]); + return SPLIT_TURN_DEG < turn && turn < 90.0; + }); + if (!bends) return []; + return [ + ...split(c0, p0, middle, pm, depth + 1), + middle, + ...split(middle, pm, c1, p1, depth + 1), + ]; + }; + const points = chainages.map((chainage) => place(chainage, frameOf(chainage))); + const out = chainages.slice(0, 1); + for (let i = 1; i < chainages.length; i += 1) { + out.push(...split(chainages[i - 1], points[i - 1], chainages[i], points[i], 0)); + out.push(chainages[i]); + } + return [out, out.map(frameOf)]; +} + interface Sampled { lines: Record<(typeof KEYS)[number], Line>; cut: Record; ticks: Line[]; } -/** 측점 줄 → 측점 · 눈금 간격마다 네 선 · 쪽마다 깎기 여부 · 눈금. `slope_pieces` 의 몸통. */ +/** 측점 줄 → 네 선 · 쪽마다 깎기 여부 · 눈금. `slope_pieces` 의 몸통. */ function sampleLines( route: Array<[number, number, number]>, input: FootprintRecord[], @@ -230,49 +393,94 @@ function sampleLines( endM: number, stepM: number, ): Sampled | null { - const records = input + const sorted = input .map((record) => ({ ...record, - left_cut: Number(Boolean(record.left_cut)), - right_cut: Number(Boolean(record.right_cut)), + left_cut: Boolean(record.left_cut), + right_cut: Boolean(record.right_cut), })) - .sort((a, b) => a.chainage_m - b.chainage_m) as unknown as Array>; - if (route.length < 2 || !records.length) return null; - const byChainage = new Map>(); + .sort((a, b) => a.chainage_m - b.chainage_m); + if (route.length < 2 || !sorted.length) return null; + // 같은 누가거리 측점은 뒤 것 하나만 + const records: FootprintRecord[] = []; + for (const record of sorted) { + const last = records[records.length - 1]; + if (last && record.chainage_m - last.chainage_m <= 1e-9) records[records.length - 1] = record; + else records.push(record); + } + const curves = { left: sideCurves(records, "left"), right: sideCurves(records, "right") }; + const byChainage = new Map(); for (const record of records) byChainage.set(roundTo(record.chainage_m, 3), record); - const samples = new Set(); + const base = new Set(); for (const record of records) { if (startM <= record.chainage_m && record.chainage_m <= endM) - samples.add(roundTo(record.chainage_m, 3)); + base.add(roundTo(record.chainage_m, 3)); } - for (let step = startM; step <= endM; step += stepM) samples.add(roundTo(step, 3)); - const lines = Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as Sampled["lines"]; - const cut: Record = { left: [], right: [] }; + for (let step = startM; step <= endM; step += stepM) base.add(roundTo(step, 3)); + const all = new Set(base); + for (const side of SIDES) { + const { flags, switches } = curves[side]; + switches.forEach((at, i) => { + if (flags[i] !== flags[i + 1] && startM <= at && at <= endM) all.add(at); + }); + } + const frameOf = (chainage: number): Frame => { + const record = byChainage.get(roundTo(chainage, 3)); + const { x, y, nx, ny } = record ?? {}; + if (x !== undefined && y !== undefined && nx !== undefined && ny !== undefined) + return [x, y, nx, ny]; + return routeNormal(route, chainage); + }; + const valuesAt = (chainage: number): Record => { + const values: Record = {}; + for (const side of SIDES) { + const edge = pchip(curves[side].edge, chainage); + values[`${side}_edge_m`] = edge; + values[`${side}_end_m`] = edge + pchip(curves[side].width, chainage); + } + return values; + }; + const place = (chainage: number, [x, y, nx, ny]: Frame): Line => { + const values = valuesAt(chainage); + return KEYS.map((key) => [x + nx * values[key], y + ny * values[key]]); + }; + const [chainages, frames] = densify( + [...all].sort((a, b) => a - b), + frameOf, + place, + ); + const offsets = chainages.map(valuesAt); + const raw = Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as Sampled["lines"]; + frames.forEach(([x, y, nx, ny], i) => { + for (const key of KEYS) + raw[key].push([roundTo(x + nx * offsets[i][key], 4), roundTo(y + ny * offsets[i][key], 4)]); + }); + const kept = Object.fromEntries( + KEYS.map((key) => [key, forwardOnly(raw[key], frames)]), + ) as unknown as Record<(typeof KEYS)[number], number[]>; + const lines = Object.fromEntries( + KEYS.map((key) => [key, kept[key].map((i) => raw[key][i])]), + ) as unknown as Sampled["lines"]; + const cut: Record = { + left: kept.left_end_m.map((i) => cutAt(curves.left, chainages[i])), + right: kept.right_end_m.map((i) => cutAt(curves.right, chainages[i])), + }; + // 눈금은 도로 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아(납품 + // 도면 관례) · 접혀 버린 자리는 뺌. const ticks: Line[] = []; - [...samples] + const keptSets = { left: new Set(kept.left_end_m), right: new Set(kept.right_end_m) }; + const position = new Map(chainages.map((chainage, i) => [chainage, i])); + [...base] .sort((a, b) => a - b) .forEach((chainage, index) => { - const record = byChainage.get(chainage); - const [x, y, nx, ny] = - record && ["x", "y", "nx", "ny"].every((k) => record[k] !== undefined) - ? [record.x, record.y, record.nx, record.ny] - : routeFrame(route, chainage); - const offsets = Object.fromEntries(KEYS.map((key) => [key, interp(records, chainage, key)])); - const points = Object.fromEntries( - KEYS.map((key) => [ - key, - [roundTo(x + nx * offsets[key], 4), roundTo(y + ny * offsets[key], 4)], - ]), - ); - for (const key of KEYS) lines[key].push(points[key]); - for (const side of SIDES) cut[side].push(interp(records, chainage, `${side}_cut`) > 0.5); - // 눈금은 도로 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아(납품 도면 관례). + const i = position.get(chainage) ?? 0; + const [x, y, nx, ny] = frames[i]; for (const side of SIDES) { - const edge = offsets[`${side}_edge_m`]; - const end = offsets[`${side}_end_m`]; - if (Math.abs(end - edge) < 0.05) continue; + const edge = offsets[i][`${side}_edge_m`]; + const end = offsets[i][`${side}_end_m`]; + if (Math.abs(end - edge) < 0.05 || !keptSets[side].has(i)) continue; const reach = index % 2 === 0 ? end : edge + (end - edge) / 2.0; - ticks.push([points[`${side}_edge_m`], [x + nx * reach, y + ny * reach]]); + ticks.push([raw[`${side}_edge_m`][i], [x + nx * reach, y + ny * reach]]); } }); return { lines, cut, ticks }; diff --git a/B05_Profile/logic/B05_Profile_RouteShift.ts b/B05_Profile/logic/B05_Profile_RouteShift.ts new file mode 100644 index 000000000..1b60f8505 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_RouteShift.ts @@ -0,0 +1,97 @@ +/* ============================================================================= + * B05_Profile_RouteShift.ts + * 횡단 중심점 좌우 이동(356-3) — 측점 자리 노선을 좌측 축으로 Δ m 옮길 노드 편집을 고르고 · 그 측점 + * 계획고를 옮기기 전 값으로 되돌릴 계획고 편집을 냄. + * + * · P = 측점 중심 · L = 좌측 단위 벡터(`stationFrameAt` · +offset = 좌) + * · P 에서 0.5 m 안 노드 = 그 노드 + Δ·L(고정 노드면 안 옮김) + * · 곡선(BC ~ EC) 안 측점 = 그 교각점 + Δ·L(고정이면 안 옮김) + * · 둘 다 아니면 P + Δ·L 을 그 자리 노드 토막에 끼움 + * · 높이 = 옮기기 전 계획고 − 옮긴 뒤 계획고 만큼 그 측점 계획고 편집(`adjustStation`) + * + * 브라우저가 고르는 편집값일 뿐 — 서버는 [저장] 때 노드 · 계획고 편집을 받아 정본으로 다시 셈(짝 없음). + * ========================================================================== */ + +import { + adjustStation, + buildAlignment, + planElevationAt, + type AlignmentBase, + type AlignmentEdits, +} from "./B05_Profile_UI_Profile_Alignment"; +import { curveSpans, stationFrameAt, type CurveEnds } from "./B05_Profile_UI_RouteEdit_Frame"; + +type Vertex = [number, number]; + +/** 측점 중심에서 이 안의 노드는 끼우지 않고 옮김(m). */ +export const NODE_SNAP_M = 0.5; +/** 이보다 작은 높이 차는 보정 안 함(m). */ +export const PIN_TOLERANCE_M = 5e-4; + +export type RouteShiftPlan = + | { kind: "move"; index: number; to: Vertex } + | { kind: "insert"; index: number; at: Vertex } + | { kind: "locked"; index: number }; + +export interface RouteShiftInput { + /** 노드(꺾임점). */ + planned: ReadonlyArray; + /** 그려진 계획노선(원호 펴진 폴리라인) — 측점 누가거리의 기준. */ + line: ReadonlyArray; + /** 곡선표 — `node_first` = 그 곡선 교각점 노드 자리. */ + curves: ReadonlyArray; + /** 노드마다 고정. */ + locked: ReadonlyArray; + chainageM: number; + /** 좌(+) · 우(−) 이동(m). */ + deltaM: number; +} + +/** 점에서 가장 가까운 노드 토막 차례(앞 노드 자리). */ +function nearestSegment(nodes: ReadonlyArray, point: Vertex): number { + let best = 0; + let bestGap = Infinity; + for (let i = 0; i < nodes.length - 1; i += 1) { + const [ax, ay] = nodes[i]; + const [vx, vy] = [nodes[i + 1][0] - ax, nodes[i + 1][1] - ay]; + const length2 = vx * vx + vy * vy || 1; + const t = Math.min(1, Math.max(0, ((point[0] - ax) * vx + (point[1] - ay) * vy) / length2)); + const gap = Math.hypot(ax + vx * t - point[0], ay + vy * t - point[1]); + if (gap < bestGap) [best, bestGap] = [i, gap]; + } + return best; +} + +/** 노드 편집 고름 — 노선이 없으면 null. */ +export function planRouteShift(input: RouteShiftInput): RouteShiftPlan | null { + const { planned, line, curves, locked, chainageM, deltaM } = input; + const frame = planned.length >= 2 ? stationFrameAt(line, chainageM) : null; + if (!frame) return null; + const { origin, left } = frame; + const shifted = ([x, y]: Vertex): Vertex => [x + left[0] * deltaM, y + left[1] * deltaM]; + const move = (index: number): RouteShiftPlan => + locked[index] + ? { kind: "locked", index } + : { kind: "move", index, to: shifted(planned[index]) }; + const near = planned.findIndex( + ([x, y]) => Math.hypot(x - origin[0], y - origin[1]) <= NODE_SNAP_M, + ); + if (near >= 0) return move(near); + const curve = curves.find((one) => { + const [span] = curveSpans(line, [one]); + return span && chainageM > span[0] && chainageM < span[1]; + }); + if (curve && planned[curve.node_first]) return move(curve.node_first); + return { kind: "insert", index: nearestSegment(planned, origin) + 1, at: shifted(origin) }; +} + +/** 그 측점 계획고를 `zBefore` 로 되돌린 계획고 편집 — 차가 없으면 null. */ +export function pinStationHeight( + base: AlignmentBase, + edits: AlignmentEdits, + chainageM: number, + zBefore: number, +): AlignmentEdits | null { + const drift = zBefore - planElevationAt(buildAlignment(base, edits), chainageM); + return Math.abs(drift) < PIN_TOLERANCE_M ? null : adjustStation(base, edits, chainageM, drift); +} diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts index c835a47e8..eaee72c90 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts @@ -13,6 +13,8 @@ * - 편집 델타는 항상 **자동 선형(base_pvi) 기준**이라 키를 지우면 정확히 원복된다. * ========================================================================== */ +import { pivotAxes } from "./B05_Profile_UI_Profile_Pivot"; + export interface AlignmentPolicy { station_interval_m: number; curve_radius_ratio: number; @@ -552,6 +554,7 @@ const FEEDBACK_TOLERANCE_M = 1e-4; * 변화점이 새로 생기면 종단곡선의 중앙종거만큼 계획고가 함께 내려가(또는 올라가) * 버튼 1클릭이 정확히 0.1m가 되지 않는다. **화면에 보이는 계획고**가 정확히 delta만큼 * 움직이도록 중앙종거 변화분을 몇 번 되먹여 보정한다. + * 누른 점이 꺾임이면 이웃 꺾임(`pivotAxes`)을 축으로 사이 측점이 새 직선 위로 따라 돈다(355-1). */ export function adjustStation( base: AlignmentBase, @@ -562,17 +565,24 @@ export function adjustStation( const key = chainageKey(chainageM); const current = buildAlignment(base, edits); const target = planElevationAt(current, chainageM) + delta; - const baseElevation = interpolate( - base.basePvi.map((node) => node.chainage_m), - base.basePvi.map((node) => node.elevation_m), - chainageM, - ); + const baseS = base.basePvi.map((node) => node.chainage_m); + const baseZ = base.basePvi.map((node) => node.elevation_m); + const baseElevation = interpolate(baseS, baseZ, chainageM); let offset = (edits.station_offsets[key] ?? planElevationAt(current, chainageM) - baseElevation) + delta; - const withOffset = (value: number): AlignmentEdits => ({ - ...edits, - station_offsets: { ...edits.station_offsets, [key]: Number(value.toFixed(6)) }, - }); + const axes = pivotAxes(current, chainageM); + const withOffset = (value: number): AlignmentEdits => { + const offsets = { ...edits.station_offsets, [key]: Number(value.toFixed(6)) }; + const radii = { ...edits.curve_radii }; + axes?.inner.forEach((chainage) => { + const [axisM, axisZ] = chainage < chainageM ? [axes.fromM, axes.fromZ] : [axes.toM, axes.toZ]; + const z = + axisZ + ((baseElevation + value - axisZ) * (chainage - axisM)) / (chainageM - axisM); + offsets[chainageKey(chainage)] = Number((z - interpolate(baseS, baseZ, chainage)).toFixed(6)); + delete radii[chainageKey(chainage)]; + }); + return { ...edits, station_offsets: offsets, curve_radii: radii }; + }; let next = withOffset(offset); for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) { const error = target - planElevationAt(buildAlignment(base, next), chainageM); diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts new file mode 100644 index 000000000..ccc84efc4 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Pivot.ts @@ -0,0 +1,51 @@ +/* ============================================================================= + * B05_Profile_UI_Profile_Pivot.ts + * 종단 ▲▼ 축 찾기(355-1 · 요구 235-15) — 누른 점이 꺾임(R 있는 점)이면 좌우로 같은 + * 기울기 마디를 건너 다음 꺾임(없으면 시점 · 종점)을 축으로 삼는다. 사이 측점은 + * 새 직선(축 → 누른 점 → 축) 위로 따라 돈다(`adjustStation`). + * + * 처음 계획선은 마디마다 꺾여 축 = 누른 점 ±1 → 사이 측점 없음(옛 동작 그대로). + * `B05_Profile_UI_Profile_Alignment` 를 타입으로만 읽는다(순환 import 없음). + * ========================================================================== */ + +import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment"; + +/** 같은 직선으로 볼 기울기 차이 — `B05_Profile_UI_Profile_Straighten` 과 같은 값. */ +const COLLINEAR_EPSILON = 1e-6; +const SAME_STATION_M = 1e-6; + +export interface PivotAxes { + fromM: number; + fromZ: number; + toM: number; + toZ: number; + /** 두 축 사이(끝 · 누른 점 제외) 변화점 chainage. */ + inner: number[]; +} + +/** 누른 점이 꺾임이면 두 축과 사이 변화점 — 직선 안쪽 · 시종점 · 변화점 아님이면 null. */ +export function pivotAxes(alignment: ProfileAlignment, chainageM: number): PivotAxes | null { + const { pvi, segments } = alignment; + const index = pvi.findIndex((node) => Math.abs(node.chainage_m - chainageM) < SAME_STATION_M); + if (index <= 0 || index >= pvi.length - 1) return null; + const grade = (at: number): number => { + const segment = segments[at]; + return segment && segment.length_m > 1e-12 ? segment.height_m / segment.length_m : 0; + }; + const same = (a: number, b: number): boolean => Math.abs(grade(a) - grade(b)) < COLLINEAR_EPSILON; + if (same(index - 1, index)) return null; + let from = index - 1; + while (from > 0 && same(from - 1, index - 1)) from -= 1; + let to = index + 1; + while (to < pvi.length - 1 && same(to, index)) to += 1; + return { + fromM: pvi[from].chainage_m, + fromZ: pvi[from].elevation_m, + toM: pvi[to].chainage_m, + toZ: pvi[to].elevation_m, + inner: pvi + .slice(from + 1, to) + .map((node) => node.chainage_m) + .filter((chainage) => Math.abs(chainage - chainageM) >= SAME_STATION_M), + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Center.ts b/B06_Section/B06_Section_UI_Cross_Center.ts new file mode 100644 index 000000000..e97f7a88c --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Center.ts @@ -0,0 +1,151 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Center.ts + * 횡단 카드 중심점 고름(356-3) — 보라 선 고름(`_Cross_Design_Pick`)의 끝 조각(키 `center`). + * + * · 판정 원 = 중심 십자 자리 · 노면 판정보다 위(`stackPickTargets`) · 다른 고름과 같은 칸(하나만) + * · 고르면 ▲▼ = 종단 계획고(지금 길 · 종단도 듣개) · ◀▶ · 가로 끌기 = 그 측점 노선을 좌우로 + * · 끄는 동안 · 키를 잇는 동안은 십자만 옮겨 보이고 · 놓을 때(키는 멈춘 뒤) 한 번 보냄 + * · 좌우 = 노선 편집기가 등록한 host(`setRouteShiftHost` · B05 `_RouteEdit_Shift`) — B06 을 바로 열어 + * 편집기가 없으면 종단 보기를 숨긴 채 세움(`setRouteShiftOpener` · 페이지가 닮 · 알림도 페이지 것) + * ========================================================================== */ + +const SVG_NS = "http://www.w3.org/2000/svg"; +/** 판정 원 클래스 — 고르기 차례가 보라 선 위로 올림. */ +export const CENTER_HIT_CLASS = "b06-chart__center-hit"; +/** 판정 원 반지름(px). */ +const HIT_RADIUS_PX = 8; +/** 좌우 걸음(m). */ +export const CENTER_STEP_M = 0.1; +/** 키를 멈추고 이만큼 지나면 보냄(ms) — 잇단 누름 = 한 번. */ +const KEY_SETTLE_MS = 450; +/** 편집기를 기다리는 한계(ms) — 종단 보기 · 노선 자료 읽기. */ +const HOST_WAIT_MS = 20000; + +/** 측점 · 좌(+)우(−) m → 못 옮긴 까닭(null = 옮김). */ +export type RouteShiftHost = (chainageM: number, deltaM: number) => Promise; + +let host: RouteShiftHost | null = null; +const waiters = new Set<(ready: RouteShiftHost | null) => void>(); +let opener: (() => Promise) | null = null; +type Notice = (text: string, kind: "info" | "error") => void; +let notice: Notice = () => undefined; + +/** 노선 편집기가 단다(떠나면 null). */ +export function setRouteShiftHost(next: RouteShiftHost | null): void { + host = next; + if (!next) return; + waiters.forEach((resolve) => resolve(next)); + waiters.clear(); +} + +/** 편집기를 세우는 길 · 알림 — 페이지가 단다(종단 보기 숨긴 채 세움 · 토스트). */ +export function setRouteShiftOpener(next: (() => Promise) | null, toast?: Notice): void { + opener = next; + if (toast) notice = toast; +} + +async function readyHost(): Promise { + if (host) return host; + const waiting = new Promise((resolve) => { + waiters.add(resolve); + setTimeout(() => (waiters.delete(resolve), resolve(null)), HOST_WAIT_MS); + }); + await opener?.().catch(() => undefined); + return host ?? waiting; +} + +/** 그 측점 노선을 좌우로 — 편집기가 없으면 세워서. 옮겼으면 true. */ +export async function shiftRouteAt(chainageM: number, deltaM: number): Promise { + const ready = await readyHost(); + if (!ready) { + notice("노선 편집기를 못 열어 좌우로 옮기지 못했습니다.", "error"); + return false; + } + const problem = await ready(chainageM, deltaM); + if (problem) notice(problem, "info"); + return !problem; +} + +/** 판정 원(중심 십자 자리). */ +export function centerHit(cx: number, cy: number): SVGCircleElement { + const hit = document.createElementNS(SVG_NS, "circle"); + hit.setAttribute("cx", String(cx)); + hit.setAttribute("cy", String(cy)); + hit.setAttribute("r", String(HIT_RADIUS_PX)); + hit.setAttribute("class", CENTER_HIT_CLASS); + return hit; +} + +const snapStep = (valueM: number): number => + Math.round(Math.round(valueM / CENTER_STEP_M) * CENTER_STEP_M * 10) / 10; + +export interface CenterShift { + /** 화면 ◀(−1) · ▶(+1) 한 걸음 — 멈추면 모아 한 번 보냄. */ + step: (screenDir: -1 | 1) => void; +} + +/** + * 좌우 이동 배선 — `kx` = 화면 x / offset(m) 기울기 · `active` = 중심점을 골랐나 · `preview` = 미리 볼 + * 이동(m · 0 = 지움). 끌기는 고른 뒤에만(안 고른 원을 끌면 카드 팬 그대로). + */ +export function bindCenterShift(input: { + hit: SVGElement; + layer: SVGGraphicsElement; + chainageM: number; + kx: number; + active: () => boolean; + preview: (deltaM: number) => void; +}): CenterShift { + const { hit, layer, chainageM, kx, active } = input; + const preview = (deltaM: number): void => { + const shift = deltaM ? `${deltaM * kx}px 0px` : ""; + for (const el of [hit, layer.querySelector("g.b06-chart__center")]) + el?.style.setProperty("translate", shift); + input.preview(deltaM); + }; + const send = (deltaM: number): void => { + if (!deltaM) return preview(0); + void shiftRouteAt(chainageM, deltaM).then((moved) => moved || preview(0)); + }; + hit.addEventListener("pointerdown", (event) => { + const ctm = layer.getScreenCTM(); + if (event.button !== 0 || !active() || !ctm) return; + event.preventDefault(); + event.stopPropagation(); // 카드 팬으로 안 번짐 + const startX = event.clientX; + let deltaM = 0; + const move = (next: PointerEvent): void => { + deltaM = snapStep((next.clientX - startX) / (ctm.a * kx)); + preview(deltaM); + }; + const up = (): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + window.removeEventListener("pointercancel", up); + send(deltaM); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + window.addEventListener("pointercancel", up); + }); + let pending = 0; + let timer = 0; + return { + step: (screenDir) => { + pending = snapStep(pending + screenDir * Math.sign(kx) * CENTER_STEP_M); + preview(pending); + clearTimeout(timer); + timer = window.setTimeout(() => { + const deltaM = pending; + pending = 0; + send(deltaM); + }, KEY_SETTLE_MS); + }, + }; +} + +/** 조정창 읽기 글 — 미리 볼 이동(m · 0 = 안내). */ +export function centerPreviewText(deltaM: number): string { + if (!deltaM) return "▲▼ = 종단 계획고 · ◀▶ · 끌기 = 이 측점 노선 좌우"; + return `노선 ${deltaM > 0 ? "좌" : "우"} ${Math.abs(deltaM).toFixed(1)}m — 놓으면 반영`; +} diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index 308d96a8c..217f576d1 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -26,6 +26,7 @@ import { dpadButton, makeRow, } from "./B06_Section_UI_Cross_Panel_Base"; +import { bindCenterShift, centerHit, centerPreviewText } from "./B06_Section_UI_Cross_Center"; const SVG_NS = "http://www.w3.org/2000/svg"; const DESIGN_CLASS = "b06-chart__design-cross"; @@ -111,8 +112,9 @@ export const pieceZone = (design: ZoneDesign, piece: DesignPiece) => * 절토 · 성토측(구조물 트림 선 포함) = 그 쪽 바깥 가로(x · 노견째 · 구조물 한 벌). */ export const pieceMoveAxis = (design: ZoneDesign, piece: DesignPiece): MoveAxis => isBermPiece(design, piece) ? "depth" : pieceZone(design, piece).zone === "road" ? "y" : "x"; -/** x = 사면 가로 · y = 노면 높이(종단 계획고) · depth = 소단 높이(그 쪽 깊이 · 194-1). */ -export type MoveAxis = "x" | "y" | "depth"; +/** x = 사면 가로 · y = 노면 높이(종단 계획고) · depth = 소단 높이(그 쪽 깊이 · 194-1) · + * center = 중심점(▲▼ 종단 계획고 · ◀▶ 노선 좌우 · 356-3). */ +export type MoveAxis = "x" | "y" | "depth" | "center"; /** 그 쪽 소단 깊이(노면 끝 아래 m) — 옮긴 값 → 구간 값 → 기본 1.0(194-1). */ export const bermDepthOf = (design: ZoneDesign, side: CrossSide): number => @@ -345,7 +347,7 @@ function tellBermHost(chainageM: number | null): void { export function designLineMoves( section: Pick, onDesignChange?: (chainageM: number, change: CrossDesignChange) => void, -): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl } { +): { stationId: string; shift?: ShoulderShift; berm?: BermDepthControl; center?: number } { const design = section.design; if (!onDesignChange || !design) return { stationId: section.station_id }; const send = (change: Partial) => @@ -355,6 +357,7 @@ export function designLineMoves( }); return { stationId: section.station_id, + center: section.chainage_m, // 중심점 좌우 = 이 측점 노선(356-3) shift: { valueOf: (side) => design[`shoulder_extend_${side}_m`] ?? 0, set: (side, valueM) => send({ shoulder_extend: { side, valueM } }), @@ -395,6 +398,8 @@ export function attachDesignLinePick(input: { berm?: BermDepthControl; /** 있으면 고름을 조각 키로 기억(194-2 · `pickedLineAt`). */ stationId?: string; + /** 있으면 중심점 고름(끝 조각 · 키 `center`) — 그 측점 누가거리(356-3). */ + center?: number; }): DesignLinePick { const { layer, design, x, toDisplayY } = input; const x0 = x(0); @@ -417,25 +422,38 @@ export function attachDesignLinePick(input: { ) .map((el) => pointsOf(el).map(toM)); const pieces = visiblePieces(mergeStraightPieces(runs), occluders); - if (input.stationId) keysByStation.set(input.stationId, pieceKeys(design, pieces)); + const edges = design.road_edges; + const centerZ = + input.center === undefined || !edges || edges.left.offset_m === edges.right.offset_m + ? null + : edges.right.elevation_m - + ((edges.left.elevation_m - edges.right.elevation_m) * edges.right.offset_m) / + (edges.left.offset_m - edges.right.offset_m); + const keys = [...pieceKeys(design, pieces), ...(centerZ === null ? [] : ["center"])]; + if (input.stationId) keysByStation.set(input.stationId, keys); // 판정 선은 구조물 판정면 아래에 꽂음 — 카드가 끝에 `stackPickTargets` 로 맨 위에 다시 세움(295-1). const firstStructureHit = kids.find((el) => el.classList.contains("b06-chart__culvert-revet-hit"), ); - const hits = pieces.map((piece, index) => { + const hits: SVGElement[] = pieces.map((piece) => { const hit = document.createElementNS(SVG_NS, "polyline"); hit.setAttribute( "points", [piece.from, piece.to].map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "), ); hit.setAttribute("class", HIT_CLASS); + return hit; + }); + // 중심점(356-3) = 끝 조각 · 판정 원은 고르기 차례가 보라 선 위로(`stackPickTargets`) + const center = centerZ === null ? null : centerHit(x0, toDisplayY(centerZ)); + if (center) hits.push(center); + hits.forEach((hit, index) => { hit.addEventListener("click", (event) => { event.stopPropagation(); // 카드 고름 · 면적 띠로 번지지 않음 input.onPick(index); }); layer.insertBefore(hit, firstStructureHit ?? null); - return hit; }); const label = document.createElement("span"); @@ -455,7 +473,19 @@ export function attachDesignLinePick(input: { readout.className = "b06-structure-panel__value"; const move = makeRow("이동"); move.controls.classList.add("b06-structure-panel__buttons"); + const centerShift = + center && input.center !== undefined + ? bindCenterShift({ + hit: center, + layer: layer as SVGGraphicsElement, + chainageM: input.center, + kx, + active: () => axis === "center", + preview: (deltaM) => (readout.textContent = centerPreviewText(deltaM)), + }) + : null; const step = (dir: -1 | 1) => () => { + if (axis === "center") return centerShift?.step(dir); if (side && shift) shift.set(side, shiftedExtend(shift.valueOf(side), side, dir, kx)); }; move.controls.append( @@ -521,36 +551,48 @@ export function attachDesignLinePick(input: { label, panel: shell.root, setActive: (index) => { + const onCenter = !!centerShift && index === pieces.length; const piece = index === null ? undefined : pieces[index]; - hits.forEach((hit, i) => hit.classList.toggle("is-active", !!piece && i === index)); - label.hidden = !piece; - active = piece ? index : null; + hits.forEach((hit, i) => + hit.classList.toggle("is-active", (!!piece || onCenter) && i === index), + ); + label.hidden = !piece && !onCenter; + active = piece || onCenter ? index : null; side = piece ? pieceZone(design, piece).side : null; - axis = piece ? pieceMoveAxis(design, piece) : "x"; + axis = onCenter ? "center" : piece ? pieceMoveAxis(design, piece) : "x"; const extend = side && shift && axis === "x" ? shift.valueOf(side) : 0; - label.textContent = piece - ? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "") - : ""; - const open = !!(piece && (axis === "depth" ? berm : shift)); + label.textContent = onCenter + ? "중심점" + : piece + ? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "") + : ""; + const open = onCenter || !!(piece && (axis === "depth" ? berm : shift)); shell.root.classList.toggle("is-hidden", !open); // 이동 셋 분리(194-2) — 사면 = 이동 줄(◀▶) · 소단 = 높이 줄(▲▼) · 노면 = 둘 다 숨김(▲▼ 종단) - move.row.classList.toggle("is-hidden", axis !== "x"); - move.controls.classList.toggle("is-hidden", axis !== "x"); + // 중심점 = 이동 줄 ◀▶(노선 좌우 · ↺ 없음) · ▲▼ 는 종단도로(356-3) + const sideways = axis === "x" || axis === "center"; + move.row.classList.toggle("is-hidden", !sideways); + move.controls.classList.toggle("is-hidden", !sideways); + move.controls.children[1]?.classList.toggle("is-hidden", axis === "center"); lift.row.classList.toggle("is-hidden", axis !== "depth"); lift.controls.classList.toggle("is-hidden", axis !== "depth"); const sideName = side === "left" ? "(좌)" : "(우)"; shell.title.textContent = - axis === "y" - ? "노면 높이" - : axis === "depth" - ? `소단 높이 ${sideName}` - : `사면 이동 ${sideName}`; + axis === "center" + ? "중심점" + : axis === "y" + ? "노면 높이" + : axis === "depth" + ? `소단 높이 ${sideName}` + : `사면 이동 ${sideName}`; readout.textContent = - axis === "y" - ? "방향키 ▲▼ = 종단 계획고(종단도 함께)" - : axis === "depth" && side - ? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m` - : `이동 ${extend.toFixed(1)}m`; + axis === "center" + ? centerPreviewText(0) + : axis === "y" + ? "방향키 ▲▼ = 종단 계획고(종단도 함께)" + : axis === "depth" && side + ? `도로 아래 ${bermDepthOf(design, side).toFixed(1)}m` + : `이동 ${extend.toFixed(1)}m`; berm?.focus(axis === "depth" && !!piece); // 좌측 소단 상자 켬 · 끔(194-2) arrows.detach(); document.removeEventListener("keydown", blockOffAxis); diff --git a/B06_Section/B06_Section_UI_Cross_Pick_Order.ts b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts index 63febf573..b688e670f 100644 --- a/B06_Section/B06_Section_UI_Cross_Pick_Order.ts +++ b/B06_Section/B06_Section_UI_Cross_Pick_Order.ts @@ -9,6 +9,7 @@ * ========================================================================== */ import { thickenDown } from "./B06_Section_UI_Cross_Shape_Pick"; +import { CENTER_HIT_CLASS } from "./B06_Section_UI_Cross_Center"; /** 면 판정 — 구조물(벽 · 관 · 박스 · 도수로 띠 등) · 물넘이포장 · 맹암거 형상. */ const SURFACE_HIT_CLASSES = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"]; @@ -69,7 +70,8 @@ export function stackPickTargets(layer: SVGElement): void { .sort((a, b) => b.area - a.area) .map(({ el }) => el); const lines = kids.filter((el) => el.classList.contains(LINE_HIT_CLASS)); - layer.append(...surfaces, ...edges, ...lines); + const centers = kids.filter((el) => el.classList.contains(CENTER_HIT_CLASS)); // 중심점 = 맨 위(356-3) + layer.append(...surfaces, ...edges, ...lines, ...centers); let down: { x: number; y: number } | null = null; layer.addEventListener( "pointerdown", diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 0fc23a43b..b3fff596b 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -175,6 +175,24 @@ stroke-width: 7; } +/* 중심점 고름(356-3) — 십자 자리 투명 원 · 고르면 보라 테두리 · 끌면 좌우. 십자는 누름을 원으로 넘김. */ +.b06-chart__center { + pointer-events: none; +} + +.b06-chart__center-hit { + fill: transparent; + stroke: transparent; + stroke-width: 2; + cursor: pointer; +} + +.b06-chart__center-hit.is-active { + fill: color-mix(in srgb, var(--color-royal-amethyst) 18%, transparent); + stroke: var(--color-royal-amethyst); + cursor: ew-resize; +} + /* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */ .b06-chart__carriageway-tick { stroke: var(--color-royal-amethyst); diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py index d5c327e7f..ddf0b615a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py @@ -2,13 +2,14 @@ `_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m. -⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · side_is_cut · slope_end · -slope_pieces) — 종단 · 횡단 편집 중 평면 발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림. +⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · route_normal · side_is_cut · +slope_end · slope_pieces 와 그 아래 보간 · 촘촘히 · 접힘 버림) — 종단 · 횡단 편집 중 평면 +발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림. 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py` """ import math -from bisect import bisect_left +from bisect import bisect_left, bisect_right from typing import Any from config.config_system import DRAWING_SCALE_PLAN @@ -39,19 +40,6 @@ def route_frame( return (x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length) -def _interp(records: list[dict[str, float]], chainage: float, key: str) -> float: - chainages = [record["chainage_m"] for record in records] - index = bisect_left(chainages, chainage) - if index <= 0: - return records[0][key] - if index >= len(records): - return records[-1][key] - before, after = records[index - 1], records[index] - span = after["chainage_m"] - before["chainage_m"] - ratio = (chainage - before["chainage_m"]) / span if span > 0 else 0.0 - return before[key] + (after[key] - before[key]) * ratio - - def _ground(samples: list[dict[str, Any]]) -> list[tuple[float, float]]: return sorted( (float(item["offset_m"]), float(item["elevation_m"])) @@ -120,6 +108,149 @@ def _split_runs( ) +#: 절/성토선 매끄럽게(353-1) — 네 선 중 하나라도 사이 가운데 점에서 이보다 꺾이면 반으로 나눔(°) +SPLIT_TURN_DEG = 2.0 +#: 나눔 깊이 상한(2 m → 12.5 cm) +SPLIT_DEPTH = 4 +#: 사이 점 법선을 재는 앞뒤 거리(m) — 측점 횡단 틀(`_tangent_at`)과 같은 값 +NORMAL_PROBE_M = 1.0 + + +def route_normal( + route: list[tuple[float, float, float]], chainage: float +) -> tuple[float, float, float, float]: + """노선 위 그 자리와 좌측 법선 — 법선은 앞뒤 ±1 m 두 점을 잇는 방향(측점 횡단 틀과 같은 식).""" + x, y, nx, ny = route_frame(route, chainage) + first, last = route[0][0], route[-1][0] + before, after = max(first, chainage - NORMAL_PROBE_M), min(last, chainage + NORMAL_PROBE_M) + if after - before <= 1e-9: + before, after = max(first, chainage - 1e-3), min(last, chainage + 1e-3) + x0, y0, _nx, _ny = route_frame(route, before) + x1, y1, _nx, _ny = route_frame(route, after) + length = math.hypot(x1 - x0, y1 - y0) + if length <= 1e-9: + return x, y, nx, ny + return x, y, -(y1 - y0) / length, (x1 - x0) / length + + +def _pchip_slopes(xs: list[float], ys: list[float]) -> list[float]: + """단조 3차(Fritsch–Carlson) 꺾임점 기울기 — 이웃 값 사이를 넘지 않는다.""" + n = len(xs) + if n < 2: + return [0.0] * n + h = [xs[i + 1] - xs[i] for i in range(n - 1)] + delta = [(ys[i + 1] - ys[i]) / h[i] for i in range(n - 1)] + slopes = [delta[0]] + [0.0] * (n - 2) + [delta[-1]] + for k in range(1, n - 1): + if delta[k - 1] * delta[k] > 0: + w1, w2 = 2 * h[k] + h[k - 1], h[k] + 2 * h[k - 1] + slopes[k] = (w1 + w2) / (w1 / delta[k - 1] + w2 / delta[k]) + return slopes + + +def _pchip(knots: tuple[list[float], list[float], list[float]], x: float) -> float: + """꺾임점 값을 그대로 지나는 단조 3차 보간 — 범위 밖은 끝값.""" + xs, ys, slopes = knots + if x <= xs[0]: + return ys[0] + if x >= xs[-1]: + return ys[-1] + i = bisect_right(xs, x) - 1 + h = xs[i + 1] - xs[i] + t = (x - xs[i]) / h + return ( + (1 + 2 * t) * (1 - t) ** 2 * ys[i] + + t * (1 - t) ** 2 * h * slopes[i] + + t * t * (3 - 2 * t) * ys[i + 1] + + t * t * (t - 1) * h * slopes[i + 1] + ) + + +def _knots(xs: list[float], ys: list[float]) -> tuple[list[float], list[float], list[float]]: + return xs, ys, _pchip_slopes(xs, ys) + + +def _side_curves(records: list[dict[str, Any]], side: str) -> dict[str, Any]: + """한 쪽 도로 끝 · 비탈 폭 보간 + 깎기↔쌓기 바뀜 자리(폭 0 = 비탈 끝이 도로 끝으로 모임).""" + chainages = [record["chainage_m"] for record in records] + edges = [record[f"{side}_edge_m"] for record in records] + widths = [record[f"{side}_end_m"] - record[f"{side}_edge_m"] for record in records] + flags = [bool(record[f"{side}_cut"]) for record in records] + switches: list[float] = [] + width_x, width_y = [chainages[0]], [widths[0]] + for i in range(1, len(records)): + c0, c1 = chainages[i - 1], chainages[i] + w0, w1 = abs(widths[i - 1]), abs(widths[i]) + switch = c0 + (c1 - c0) * (w0 / (w0 + w1) if w0 + w1 > 1e-9 else 0.5) + switches.append(switch) + if flags[i - 1] != flags[i] and c0 + 1e-9 < switch < c1 - 1e-9: + width_x.append(switch) + width_y.append(0.0) + width_x.append(c1) + width_y.append(widths[i]) + return { + "chainages": chainages, + "flags": flags, + "switches": switches, + "edge": _knots(chainages, edges), + "width": _knots(width_x, width_y), + } + + +def _cut_at(curves: dict[str, Any], chainage: float) -> bool: + """그 자리 깎기 여부 — 바뀜 자리부터 뒤 측점 것(바뀌는 점은 앞뒤 조각이 나눠 가짐).""" + flags = curves["flags"] + i = bisect_right(curves["chainages"], chainage) + if i == 0: + return flags[0] + if i == len(flags): + return flags[-1] + if flags[i - 1] == flags[i]: + return flags[i] + return flags[i - 1] if chainage < curves["switches"][i - 1] else flags[i] + + +def _forward_only(points: list[list[float]], frames: list[tuple[float, ...]]) -> list[int]: + """진행을 거스르는 점(안쪽 비탈 끝이 곡선 중심을 넘어 접힘)을 버린 남는 점 번호.""" + kept = [0] if points else [] + for i in range(1, len(points)): + last = points[kept[-1]] + _x, _y, nx, ny = frames[i] + if (points[i][0] - last[0]) * ny - (points[i][1] - last[1]) * nx > 1e-6: + kept.append(i) + return kept + + +def _turn_deg(a: list[float], m: list[float], b: list[float]) -> float: + u, v = (m[0] - a[0], m[1] - a[1]), (b[0] - m[0], b[1] - m[1]) + return abs(math.degrees(math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]))) + + +def _densify( + chainages: list[float], frame_of: Any, place: Any +) -> tuple[list[float], list[tuple[float, float, float, float]]]: + """곡선 · 폭이 가파르게 바뀌는 자리 — 가운데 점에서 많이 꺾이는 사이를 반씩 나눔.""" + + def split(c0: float, p0: list, c1: float, p1: list, depth: int) -> list[float]: + if depth >= SPLIT_DEPTH: + return [] + middle = (c0 + c1) / 2.0 + pm = place(middle, frame_of(middle)) + # 되돌아가는 꺾임(90° 넘음)은 접힘 — 버릴 점이라 나누지 않음 + if not any(SPLIT_TURN_DEG < _turn_deg(a, m, b) < 90.0 for a, m, b in zip(p0, pm, p1)): + return [] + return ( + split(c0, p0, middle, pm, depth + 1) + [middle] + split(middle, pm, c1, p1, depth + 1) + ) + + points = [place(chainage, frame_of(chainage)) for chainage in chainages] + out = chainages[:1] + for i in range(1, len(chainages)): + out += split(chainages[i - 1], points[i - 1], chainages[i], points[i], 0) + out.append(chainages[i]) + return out, [frame_of(chainage) for chainage in out] + + def slope_pieces( route: list[tuple[float, float, float]], records: list[dict[str, Any]], @@ -131,60 +262,94 @@ def slope_pieces( `records` = [{chainage_m, left_edge_m, left_end_m, right_edge_m, right_end_m, left_cut?, right_cut?, (x, y, nx, ny)?}] — 좌(+) · 우(-) 오프셋. 측점 자리는 횡단 파일의 - 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 노선을 - 따라 보간한다. 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. + 중심 · 좌축을 그대로 써서 측점에서 외곽선 폭이 횡단 설계 값과 같다. 측점 사이는 단조 3차 + 보간(측점 값 그대로 지남) · 법선은 측점과 같은 ±1 m 식 · 곡선은 촘촘히 · 진행을 거스르는 + 점은 버림 · 깎기↔쌓기 바뀜 자리에서 비탈 끝이 도로 끝으로 모인다(353-1). + 비탈 끝 선은 그 쪽이 절토인지 성토인지로 나눠 두 조각에 싣는다. """ - records = sorted( + sorted_records = sorted( ( - { - **record, - **{f"{s}_cut": float(bool(record.get(f"{s}_cut"))) for s in ("left", "right")}, - } + {**record, **{f"{s}_cut": bool(record.get(f"{s}_cut")) for s in ("left", "right")}} for record in records ), key=lambda record: record["chainage_m"], ) - if len(route) < 2 or not records: + if len(route) < 2 or not sorted_records: return {"lines": []}, {"lines": []}, {"lines": []}, {"ticks": []} + # 같은 누가거리 측점은 뒤 것 하나만 + records = [] + for record in sorted_records: + if records and record["chainage_m"] - records[-1]["chainage_m"] <= 1e-9: + records[-1] = record + else: + records.append(record) + sides = ("left", "right") keys = ("left_edge_m", "left_end_m", "right_edge_m", "right_end_m") + curves = {side: _side_curves(records, side) for side in sides} by_chainage = {round(record["chainage_m"], 3): record for record in records} - samples = { + base = { round(record["chainage_m"], 3) for record in records if start_m <= record["chainage_m"] <= end_m } step = start_m while step <= end_m: - samples.add(round(step, 3)) + base.add(round(step, 3)) step += step_m - lines: dict[str, list[list[float]]] = {key: [] for key in keys} - cut: dict[str, list[bool]] = {"left": [], "right": []} - ticks: list[list[list[float]]] = [] - for index, chainage in enumerate(sorted(samples)): - record = by_chainage.get(chainage) + switches = { + at + for side in sides + for i, at in enumerate(curves[side]["switches"]) + if curves[side]["flags"][i] != curves[side]["flags"][i + 1] and start_m <= at <= end_m + } + + def frame_of(chainage: float) -> tuple[float, float, float, float]: + record = by_chainage.get(round(chainage, 3)) if record and all(k in record for k in ("x", "y", "nx", "ny")): - x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"] - else: - x, y, nx, ny = route_frame(route, chainage) - offsets = {key: _interp(records, chainage, key) for key in keys} - points = { - key: [round(x + nx * value, 4), round(y + ny * value, 4)] - for key, value in offsets.items() - } + return record["x"], record["y"], record["nx"], record["ny"] + return route_normal(route, chainage) + + def values_at(chainage: float) -> dict[str, float]: + values: dict[str, float] = {} + for side in sides: + edge = _pchip(curves[side]["edge"], chainage) + values[f"{side}_edge_m"] = edge + values[f"{side}_end_m"] = edge + _pchip(curves[side]["width"], chainage) + return values + + def place(chainage: float, frame: tuple[float, float, float, float]) -> list[list[float]]: + x, y, nx, ny = frame + values = values_at(chainage) + return [[x + nx * values[key], y + ny * values[key]] for key in keys] + + chainages, frames = _densify(sorted(base | switches), frame_of, place) + offsets = [values_at(chainage) for chainage in chainages] + raw: dict[str, list[list[float]]] = {key: [] for key in keys} + for (x, y, nx, ny), values in zip(frames, offsets): for key in keys: - lines[key].append(points[key]) - for side in cut: - cut[side].append(_interp(records, chainage, f"{side}_cut") > 0.5) - # 눈금은 노견 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아 선다(납품 도면 관례). - for side in ("left", "right"): - edge, end = offsets[f"{side}_edge_m"], offsets[f"{side}_end_m"] - if abs(end - edge) < 0.05: + raw[key].append([round(x + nx * values[key], 4), round(y + ny * values[key], 4)]) + kept = {key: _forward_only(raw[key], frames) for key in keys} + lines = {key: [raw[key][i] for i in kept[key]] for key in keys} + cut = { + side: [_cut_at(curves[side], chainages[i]) for i in kept[f"{side}_end_m"]] for side in sides + } + # 눈금은 노견 끝에서 비탈 끝으로 — 측점 · 눈금 간격 자리만 · 긴 것 · 짧은 것이 번갈아 + # 선다(납품 도면 관례) · 접혀 버린 자리는 뺌. + ticks: list[list[list[float]]] = [] + kept_sets = {key: set(kept[key]) for key in keys} + position = {chainage: i for i, chainage in enumerate(chainages)} + for index, chainage in enumerate(sorted(base)): + i = position[chainage] + x, y, nx, ny = frames[i] + for side in sides: + edge, end = offsets[i][f"{side}_edge_m"], offsets[i][f"{side}_end_m"] + if abs(end - edge) < 0.05 or i not in kept_sets[f"{side}_end_m"]: continue reach = end if index % 2 == 0 else edge + (end - edge) / 2.0 - ticks.append([points[f"{side}_edge_m"], [x + nx * reach, y + ny * reach]]) + ticks.append([raw[f"{side}_edge_m"][i], [x + nx * reach, y + ny * reach]]) cut_lines: list[list[list[float]]] = [] fill_lines: list[list[list[float]]] = [] - for side in cut: + for side in sides: cut_runs, fill_runs = _split_runs(lines[f"{side}_end_m"], cut[side]) cut_lines += cut_runs fill_lines += fill_runs diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index 60a842428..651edd4b3 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -11,6 +11,8 @@ 장부 짝이 규격 다름(품명|단위 느슨 열쇠)이면 ⑤ 아님 — 꼬리표 「별칭 · 규격 다름」 · 짝은 후보 끝 덤만. 자재 · 별도계상 = 이름 · 규격 같음 하나면 맞음(체크) · 그 밖 후보. +로직 줄 후보 묶음 표시(358-1) = 이름후보(옛 324 다섯 `Match.name_cands`) · 후보마다 뜻겹침 · 출처같음 · +책 — 차례 · 무게 · 상태는 그대로. 입력값 출처(칸마다) = 별칭 > 블록(옵션 찾기 값이 규격 읽기와 다름) > 규격(`spec_inputs`) > 자동값. 체크 = 처음 제안일 뿐 — 들어감은 [확인하고 사전조합 만들기](다 창)만. 저장 없음 · 셈은 서버 단독. 장부 = `M02_Hopyo_Ledger`(다 창) · 확인된 사전조합 = `M02_Hopyo_Build.confirmed`(다 창). @@ -214,15 +216,35 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A if alias and alias.get("갈래") in SKIP: return {**out, "상태": "뺌", "뺌": alias["갈래"], "별칭": _alias_info(alias, False)} if alias and alias.get("갈래") == "짝" and alias.get("짝"): - return {**out, **_aliased(line, ctx, alias, entry)} - if kind in hm.MATERIAL_KINDS: + got = _aliased(line, ctx, alias, entry) + elif kind in hm.MATERIAL_KINDS: got = _material(line, ctx, full) elif entry and entry["판정"]["글"] not in (fit.NO_NUMBER, "우리 로직 준비 중"): got = _numeric(line, ctx, entry) if full else _brief(entry) else: why = "셈 중" if not ctx.calc["끝"] else fit.NO_NUMBER got = _names_only(line, ctx, why, full) - return {**out, **(_loose(got, loose, line, ctx) if loose and full else got)} + got = _loose(got, loose, line, ctx) if loose and full else got + return {**out, **(_grouped(got, line, ctx) if full and kind not in hm.MATERIAL_KINDS else got)} + + +def _grouped(got: dict, line: dict, ctx: Ctx) -> dict: + """묶음 표시(358-1) — 후보 · 이름후보(옛 324 다섯)마다 뜻겹침 · 출처같음(조항 ≥ 0.9) · 책 · + 차례 · 무게 · 상태 그대로(아무것도 숨기지 않음 · 묶음은 화면 몫).""" + mine = hm.words(line.get("이름")) + theirs = hm.fix_typo(((line.get("근거") or {}).get("출처")) or []) + table = hopyo_basis.ours() + + def mark(c: dict) -> dict: + one, our = ctx.names.get(c.get("로직")), table.get(c.get("로직")) + if one is None: + return c + same = bool(theirs and our) and hm.clause(theirs, our)[0] >= 0.9 + book = (our or {}).get("책", "") + return {**c, "뜻겹침": hm.near(mine, one["words"]), "출처같음": same, "책": book} + + named = [mark(c) for c in hm.name_cands(line, ctx.got)] + return {**got, "후보": [mark(c) for c in got.get("후보") or []], "이름후보": named} def _loose(got: dict, alias: dict, line: dict, ctx: Ctx) -> dict: diff --git a/M02_MasterTemplete/M02_Hopyo_Match.py b/M02_MasterTemplete/M02_Hopyo_Match.py index 3bc65074d..5f77d3b4e 100644 --- a/M02_MasterTemplete/M02_Hopyo_Match.py +++ b/M02_MasterTemplete/M02_Hopyo_Match.py @@ -292,10 +292,14 @@ 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 | None = 5 + line: dict[str, Any], + pool: str, + got: dict[str, Any] | None = None, + limit: int | None = 5, + fold: bool = False, ) -> list[dict[str, Any]]: """③ 정리 이름 같음 먼저 · ④ 글자 두 개 묶음 점수 + 단위 맞음 + 산림품셈 덤 — 위 `limit` - (None = 모두).""" + (None = 모두) · `fold` = 로직도 (이름, 규격, 단위)로 접음(옛 324 꼴).""" me = tidy(line.get("이름")) grams, unit = _grams(me), line.get("단위") text = mm.plain_spec(f"{line.get('이름')}{line.get('규격')}") @@ -313,7 +317,8 @@ def candidates( scored.sort(key=lambda x: -x[0]) out = [] for score, one in scored: - label = one.get("로직") or (one["이름"], one.get("규격"), one["단위"]) # 로직은 접지 않음 + label = (one["이름"], one.get("규격"), one["단위"]) + label = label if fold else one.get("로직") or label # 로직은 접지 않음(fold 아니면) if label in seen: continue seen.add(label) @@ -323,6 +328,19 @@ def candidates( return out +def name_cands(line: dict[str, Any], got: dict[str, Any] | None = None) -> list[dict[str, Any]]: + """이름 닮은 로직 다섯(358-1 · 옛 324 데스크탑 판 그대로) — 이름 같음 · 씨앗(점수 9) 먼저 + + 이름 점수 후보(로직도 이름 · 규격 · 단위로 접음) · 숫자 판정 차례와 따로(표시 묶음만).""" + got = got or targets() + same = [_public(x) for x in _exact(line, "로직", got)] + rest = [ + c + for c in candidates(line, "로직", got, 5, fold=True) + if {k: v for k, v in c.items() if k != "점수"} not in same + ] + return ([{**x, "점수": 9} for x in same] + rest)[:5] + + # ── 인용 조항 · 이름 · 근거 차례(338 · 설계 2-4 B 차례) ───────────────────── _OLD_TEXT = re.compile(r"(20(1\d|2[0-4])|1\d년|2[0-4]년|임도품셈|임도표준품셈|사방품셈)") _TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 13-114 → 13-11-4 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 612aee372..dc28f057a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -55,34 +55,49 @@ overflow-wrap: break-word; } -/* 확인 창 줄 표(338 · 339) — 체크 · 갈래 · 수량 · 단위 · 상태 = 줄바꿈 없음(폭 = 글 폭) · 단추 = 세로로 쌓아 좁게 · - 품명 · 규격 · 우리 로직 · 옵션 · 품 비교가 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */ +/* 확인 창 줄 표(338 · 339 · 359-3) — 일위대가 상세 꼴(m02-labrow) · 체크 칸 · 수량 · 단위 = 줄바꿈 없음 · + 단추 = 세로로 쌓아 좁게 · 이름 · 별칭 · 품 비교가 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */ .m02-hopyo__table.m02-hopyo__lines :is(th, td) { padding: var(--spacing-8); } -.m02-hopyo__table.m02-hopyo__lines - :is(th, td):is(:nth-child(1), :nth-child(2), :nth-child(5), :nth-child(6), :nth-child(7)) { +.m02-hopyo__table.m02-hopyo__lines td:first-child, +.m02-hopyo__lines :is(.m02-labrow__qty, .m02-labrow__unit) { white-space: nowrap; } -/* 품명 · 규격 — 낱말 두셋은 한 줄에 */ -.m02-hopyo__table.m02-hopyo__lines td:nth-child(3), +/* 이름 칸 대상 줄 — 키 · 이름 · 배 칸 · 몫 한 줄(상세 이름 칸의 한 줄 말줄임을 풂) */ +.m02-hopyo__lines .m02-labrow__name > .m02-hopyo__part { + display: flex; + max-width: none; + overflow: visible; +} + +.m02-hopyo__lines .m02-labrow__name > .m02-hopyo__state, +.m02-hopyo__lines .m02-labrow__kind > .m02-hopyo__state { + display: inline-block; + margin-left: var(--spacing-4); +} + +/* 품 비교 — 항목마다 한 줄(글의 줄바꿈 그대로) */ +.m02-hopyo__table.m02-hopyo__lines td.m02-hopyo__compare { + min-width: 9em; + white-space: pre-line; +} + +.m02-hopyo__subhead { + margin: var(--spacing-8) 0 0; + font-size: var(--text-body-sm); +} + .m02-hopyo-ledger__table td:nth-child(1) { min-width: 7em; } -.m02-hopyo__table.m02-hopyo__lines td:nth-child(4), .m02-hopyo-ledger__table td:nth-child(2) { min-width: 6em; } -/* 옵션 · 품 비교 — 항목마다 한 줄(글의 줄바꿈 그대로) */ -.m02-hopyo__table.m02-hopyo__lines td:is(:nth-child(9), :nth-child(10)) { - min-width: 9em; - white-space: pre-line; -} - /* 단추 칸 — 단추 · 넣음/뺌을 세로로 쌓아 칸 폭 = 가장 긴 단추 */ .m02-hopyo__table.m02-hopyo__lines td.m02-hopyo__acts { width: 1%; @@ -212,6 +227,17 @@ gap: var(--spacing-4); } +/* 고르기 창 후보 묶음(358-1) — 뜻 낱말 안 겹치는 「후보」 = 회색 · 숫자만 닮음 = 접힘 */ +.m02-hopyo__cand.is-weak { + color: var(--color-text-muted); +} + +.m02-hopyo__group > summary { + cursor: pointer; + color: var(--color-text-secondary); + margin-bottom: var(--spacing-4); +} + /* 별칭 장부(338) — 위 층 줄 = 회색 읽기만 · 고친 줄 = 왼쪽 띠 */ .m02-hopyo-ledger__row.is-upper { color: var(--color-text-muted); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 818aec359..2b1d01c28 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -92,6 +92,10 @@ export interface HopyoTarget { /** 후보 판정(338) — 맞음 · 다른 점 · 후보 */ 판정?: "맞음" | "다른 점" | "후보"; 글?: string; + /** 묶음 표시(358-1 · 로직 후보만) — 품명 뜻 낱말 겹침 · 엑셀 출처 조항 같음(≥ 0.9) · 책(산림품셈 · 건설품셈) */ + 뜻겹침?: boolean; + 출처같음?: boolean; + 책?: string; } /** 맞춤 대상 하나(338 · 로직 여럿 가능) — 배 = 환산 × 몫 · 우리 수량 = 엑셀 수량 × 배 */ @@ -124,6 +128,8 @@ export interface HopyoMatch { 체크: boolean; 대상: HopyoPart[]; 후보?: HopyoTarget[]; + /** 이름 닮은 로직 다섯(358-1 · 옛 324 판 그대로 · 숫자 판정 차례와 따로 · 로직 줄만) */ + 이름후보?: HopyoTarget[]; 글?: string; 꼬리표?: string[]; 품?: { 엑셀: HopyoQty; 우리: HopyoQty; 같음: Record }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index 05f10c7c9..d6e2fa8a1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -1,15 +1,15 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Check.ts - * 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 · - * 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [확인하고 사전조합 만들기]로 서버에 보냄. - * · 자리 = 실무자료 본 칸(호표 누름) · [로직 추가] 「실무 호표」 모달 · 엑셀 보기 창 펼침 [확인 창 열기] + * 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1 · 359-3) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 · + * 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [사전조합 생성]으로 서버에 보냄. + * · 자리 = 엑셀 보기 창 호표 [수정] · [로직 추가] 「실무 호표」 모달 * · 머리 = 호표 · 「제안 n / 줄 m」 · 숫자 판정 셈 중이면 3 초마다 다시 읽음(끝나면 멈춤 · 만진 줄은 그대로) - * · 줄 = [✓] · 갈래 · 품명 · 규격 · 수량 · 단위 · 상태(맞음 · 별칭 · 다른 점 · 후보) · 우리 로직(여럿 · 몫 · 배) · - * 옵션(칸=값 · 출처 · 못 가름) · 품 비교(엑셀 → 우리 ✓ / ≠) · [고르기] [원문 보기] [엑셀에서 보기] · 뺌 까닭 + * · 줄 표 = 구조물 조합 일위대가 상세 꼴(로직 표 · 자재 표 · 품 비교 · 단추 — `M02_MasterTemplete_Hopyo_CheckRows`) + * · 금액 = 줄마다 서버 calc 미리 셈(`logic/M02_MasterTemplete_Hopyo_Draft` 문서 · 저장 안 함 · 줄마다 최신 응답만) * · 대상 · 옵션을 바꾸면 서버 `guess`(저장 안 함 · 줄마다 최신 응답만)가 우리 품을 다시 셈 * · 캐시 = sessionStorage `m02-hopyo-check:{층}:{주인}:{키}:{번}`(조작 즉시 · 만들기 성공하면 지움) · 자동 저장 없음 - * · 화면 잠금은 [확인하고 사전조합 만들기] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만. - * 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) — 화면은 보이기 · 체크 · 고름만. + * · 화면 잠금은 [사전조합 생성] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만. + * 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build` · `/labor/{열}/calc`) — 화면은 보이기 · 체크 · 고름만. * ========================================================================== */ import { @@ -36,22 +36,23 @@ import { type HopyoPart, type HopyoScope, } from "./M02_MasterTemplete_Hopyo_Api"; +import { builtWord, openHopyoChoose, targetText } from "./M02_MasterTemplete_Hopyo_Choose"; import { - builtWord, - inputsText, - openHopyoChoose, - sourceText, - targetText, -} from "./M02_MasterTemplete_Hopyo_Choose"; + checkTables, + type LinePrice, + type RowsContext, +} from "./M02_MasterTemplete_Hopyo_CheckRows"; import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet"; -import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; +import { calcLabor, fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; +import { draftDoc } from "./logic/M02_MasterTemplete_Hopyo_Draft"; import "./M02_MasterTemplete_Hopyo.css"; export const POLL_MS = 3000; /** 사전조합을 만들었음(document 알림) — 좌측 「일위대가 사전조합」 목록 다시 */ export const HOPYO_BUILT = "m02-hopyo-built"; -const OUT = ["우리에 없음", "안 씀"] as const; +/** 미리 셈 문서 열 이름(서버 calc 는 보낸 문서로 셈 · 저장 안 함) */ +const DRAFT_COLUMN = "hopyo-draft"; /** 확인 창 캐시 — 사용자가 만진 것만(서버 제안 위에 덮어 보임) */ export interface CheckDraft { @@ -181,7 +182,7 @@ export function newest(): (key: number) => () => boolean { }; } -/** [맞음 · 별칭 모두 체크] 대상 줄 */ +/** [맞음 · 별칭 일괄 체크] 대상 줄 */ export const sureLines = (lines: HopyoLine[], d: CheckDraft): HopyoLine[] => lines.filter((l) => ["맞음", "별칭"].includes(l.맞춤.상태) && checkable(l, d)); @@ -204,13 +205,11 @@ export interface CheckOptions { target?: HopyoScope; /** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */ canWrite: boolean; - /** [← 호표 목록](본 칸) */ - onBack?: () => void; /** 만들었음 — [로직 추가] 참조 줄 · 모달 닫기 */ onBuilt?: (got: HopyoBuilt, hopyo: HopyoOne["호표"]) => void; } -/** 확인 창 몸 — 본 칸 · 모달이 같이 씀 */ +/** 확인 창 몸 — 모달(`openHopyoCheck`)에 담김 */ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { const root = el("div", { className: "m02-hopyo m02-hopyo-check", @@ -224,6 +223,9 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { let seq = 0; let timer = 0; const ask = newest(); // 줄마다 최신 미리 셈만 + const askPrice = newest(); // 줄마다 최신 금액 미리 셈만 + const priced = new Map(); + let pending = false; const keep = (): void => saveDraft(k, d); async function load(quiet = false): Promise { @@ -313,161 +315,76 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { }); } - function partCell(line: HopyoLine): HTMLElement { - const parts = partsOf(line, d); - return el("td", { - className: "m02-hopyo__parts", - children: parts.map((p, at) => { - const times = el("input", { - className: "ui-input ui-input--sm m02-hopyo__times", - attrs: { type: "number", step: "any", min: "0", value: p.배 == null ? "" : String(p.배) }, - }) as HTMLInputElement; - times.title = "배 — 우리 수량 = 엑셀 수량 × 배"; - times.disabled = !opt.canWrite; - times.addEventListener("change", () => { - const v = times.value === "" ? null : Number(times.value); - d.대상[line.차례] = parts.map((x, i) => (i === at ? { ...x, 배: v } : x)); - keep(); - draw(); - }); - const share = p.몫 != null ? ` · 몫 ${p.몫}` : ""; - return el("div", { - className: "m02-hopyo__part", - children: [ - el("span", { - text: `${targetText(p)} ${p.로직 ?? p.자재 ?? ""}${share}`.trim(), - attrs: { title: p.어디 ?? "" }, - }), - times, - ...(p.로직 - ? [ - createButton({ - label: "옵션", - variant: "ghost", - size: "sm", - onClick: () => editOptions(line, at), - }), - ] - : []), - ], - }); - }), + /** 줄마다 금액 미리 셈(359-3) — 서버 calc(저장 안 함 · 줄마다 최신 응답만 · 문서가 같으면 다시 안 셈) */ + function price(line: HopyoLine): LinePrice | null { + const doc = draftDoc(line, partsOf(line, d)); + if (!doc) return null; + const sig = JSON.stringify(doc); + const have = priced.get(line.차례); + if (have?.sig === sig) return have.got; + const slot = { sig, got: {} as LinePrice }; + priced.set(line.차례, slot); + const mine = askPrice(line.차례); + calcLabor(target.layer, DRAFT_COLUMN, doc, {}, target.owner).then( + (calc) => mine() && ((slot.got = { calc }), soon()), + (error: unknown) => mine() && ((slot.got = { fail: why(error) }), soon()), + ); + return slot.got; + } + /** 응답 여럿 = 한 번 그림 */ + function soon(): void { + if (pending) return; + pending = true; + requestAnimationFrame(() => { + pending = false; + if (root.isConnected) draw(); }); } - function lineRow(line: HopyoLine): HTMLElement { - const m = line.맞춤; - const parts = partsOf(line, d); - const box = el("input", { attrs: { type: "checkbox", "data-check": String(line.차례) } }); - const input = box as HTMLInputElement; - input.checked = isChecked(line, d) && checkable(line, d); - input.disabled = !opt.canWrite || !checkable(line, d); - input.addEventListener("change", () => { - d.체크[line.차례] = input.checked; + const ctx = (): RowsContext => ({ + canWrite: opt.canWrite, + d, + price, + check: (line, on) => { + d.체크[line.차례] = on; keep(); draw(); - }); - const out = el("select", { - className: "ui-input ui-input--sm", - attrs: { title: "뺌 — 사전조합에 안 넣고 장부에 남김" }, - children: [ - el("option", { text: "넣음", attrs: { value: "" } }), - ...OUT.map((x) => el("option", { text: `뺌 — ${x}`, attrs: { value: x } })), - ], - }) as HTMLSelectElement; - out.value = d.뺌[line.차례] ?? ""; - out.disabled = !opt.canWrite || m.상태 === "%"; - out.addEventListener("change", () => { - if (out.value) d.뺌[line.차례] = out.value; + }, + out: (line, value) => { + if (value) d.뺌[line.차례] = value; else delete d.뺌[line.차례]; - d.체크[line.차례] = !!out.value || d.체크[line.차례]; + d.체크[line.차례] = !!value || d.체크[line.차례]; keep(); draw(); - }); - const sheet = (행: [number, number]): void => - openSheet({ - scope: opt.scope, - key: opt.key, - 파일: got?.파일 ?? "", - canWrite: opt.canWrite, - target, - 단산행: 행, - }); - const acts = [ - ...(opt.canWrite && m.상태 !== "%" - ? [ - createButton({ - label: "고르기", - variant: "ghost", - size: "sm", - onClick: () => - openHopyoChoose({ - line, - openSheet: sheet, - done: (ts) => - setParts( - line, - ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })), - ), - }), - }), - ] - : []), - createButton({ - label: "원문 보기", - variant: "ghost", - size: "sm", - onClick: () => void showSource(line), + }, + times: (line, at, value) => { + d.대상[line.차례] = partsOf(line, d).map((x, i) => (i === at ? { ...x, 배: value } : x)); + keep(); + draw(); + }, + choose: (line) => + openHopyoChoose({ + line, + openSheet: sheetAt, + done: (ts) => + setParts( + line, + ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })), + ), }), - ...(line.근거 - ? [ - createButton({ - label: "엑셀에서 보기", - variant: "ghost", - size: "sm", - onClick: () => sheet(line.근거!.행), - }), - ] - : []), - out, - ]; - const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n"); - const opts = parts - .filter((p) => p.로직) - .flatMap((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처).split(/, (?=[^,=]+=)| · /)) - .filter(Boolean); - if (m.갈리는칸?.length) opts.push(`⚠ 못 가름: ${m.갈리는칸.join(", ")}`); - const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : ""; - return el("tr", { - className: `m02-hopyo__line is-${input.checked ? "in" : "out"}`, - attrs: { "data-line": String(line.차례), "data-state": m.상태 }, - children: [ - el("td", { children: [box] }), - td(line.갈래), - td(line.이름), - td(line.규격), - td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"), - td(line.단위), - el("td", { - attrs: { title: tags }, - children: [ - el("span", { - className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`, - text: `${m.상태}${alias}`, - }), - ], - }), - partCell(line), - td(opts.join("\n")), // 옵션 · 품 비교 = 항목마다 한 줄 - td( - compareText(d.품[line.차례] ?? m.품) - .split(" · ") - .join("\n"), - ), - el("td", { className: "m02-hopyo__acts", children: acts }), - ], + source: (line) => void showSource(line), + sheet: sheetAt, + options: editOptions, + }); + const sheetAt = (행: [number, number]): void => + openSheet({ + scope: opt.scope, + key: opt.key, + 파일: got?.파일 ?? "", + canWrite: opt.canWrite, + target, + 단산행: 행, }); - } async function make(h: HopyoOne["호표"]): Promise { const whole = !!h.통째 && (d.통째 ?? h.통째.체크); @@ -483,8 +400,8 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { .map(([a, b]) => `${a} ${b}`) .join(" · ")})` : "") + - " — 확인하고 사전조합을 만듦"; - if (!(await showConfirmDialog(ask, "확인하고 만들기"))) return; + " — 사전조합 생성"; + if (!(await showConfirmDialog(ask, "생성"))) return; const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no }; showLoadingOverlay(); try { @@ -567,7 +484,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { const tools: HTMLElement[] = []; if (opt.canWrite) { const sure = sureLines(h.줄, d); - const all = createButton({ label: `맞음 · 별칭 모두 체크 (${sure.length})`, size: "sm" }); + const all = createButton({ label: `맞음 · 별칭 일괄 체크 (${sure.length})`, size: "sm" }); all.disabled = !sure.length; all.addEventListener("click", () => { for (const l of sure) d.체크[l.차례] = true; @@ -575,19 +492,15 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { draw(); }); const whole = !!h.통째 && (d.통째 ?? h.통째.체크); - const go = createButton({ label: "확인하고 사전조합 만들기", variant: "filled", size: "sm" }); + const go = createButton({ label: "사전조합 생성", variant: "filled", size: "sm" }); go.disabled = !whole && checked < 1; go.setAttribute("data-hopyo-make", ""); go.addEventListener("click", () => void make(h)); tools.push(all, go); } - const back = opt.onBack - ? [createButton({ label: "← 호표 목록", variant: "ghost", size: "sm", onClick: opt.onBack })] - : []; const head = el("div", { className: "m02-hopyo__bar", children: [ - ...back, el("strong", { text: `제${h.번}호표 ${h.이름}` }), note(h.규격), note(h.단위), @@ -598,7 +511,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { : []), ...(h.만든 ? [note(`만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}`)] : []), createButton({ - label: "엑셀에서 보기", + label: "엑셀 보기", size: "sm", onClick: () => openSheet({ @@ -638,38 +551,10 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { ]; })() : []; - const heads = [ - "", - "갈래", - "품명", - "규격", - "수량", - "단위", - "상태", - "우리 로직 · 배", - "옵션", - "품 비교", - "", - ]; root.replaceChildren( head, ...whole, - h.줄.length - ? el("div", { - className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", - children: [ - el("table", { - className: "ui-general-block__table m02-hopyo__table m02-hopyo__lines", - children: [ - el("thead", { - children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], - }), - el("tbody", { children: h.줄.map(lineRow) }), - ], - }), - ], - }) - : note("구성 줄 없음"), + ...(h.줄.length ? checkTables(h.줄, ctx()) : [note("구성 줄 없음")]), ...(built ? [builtBlock(built)] : []), ); } @@ -678,7 +563,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { return root; } -/** 확인 창 모달 — [로직 추가] 「실무 호표」 · 엑셀 보기 창 [확인 창 열기] · 만들면 닫음 */ +/** 확인 창 모달 — [로직 추가] 「실무 호표」 · 엑셀 보기 창 호표 [수정] · 만들면 닫음 */ export function openHopyoCheck(opt: CheckOptions & { title?: string }): void { openModal({ title: opt.title ?? `확인 — 제${opt.no}호표`, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts new file mode 100644 index 000000000..e14bbd11d --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts @@ -0,0 +1,343 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_CheckRows.ts + * 확인 창 줄 표(PLAN 359-3) — 구조물 조합 일위대가 상세 꼴(`Labor_Rows` logicTable · matTable)에 품 비교 · 단추만 더함. + * · 로직 표 = [✓] · 차례 · 구분(엑셀 갈래 + 상태) · 이름(우리 로직 · 배 · 옵션 글) · 별칭(엑셀 품명 = 만들 때 별칭) · + * 수량산출(위 엑셀 / 아래 우리 = 엑셀 × 배) · 결과 단위 · 노무비 · 재료비 · 경비 · 합계 · 품 비교 · 단추 + * · 자재 표 = [✓] · 차례 · 코드 · 이름 · 규격 · 수량산출 · 단위 · 세 칸(자재구분 · 별도계상 · 운반비 · 글만) · + * 가격 다섯 · 엑셀 / 우리 금액 · 단추 + * · 금액 칸 = 위 작게 엑셀 · 아래 우리(서버 calc 미리 셈 · 저장 안 함) · 셈 중 「…」 + * · 단추 = [고르기] · [원문 보기] · [엑셀 보기] · [옵션](로직 대상마다) · 드롭다운 추가 · 로직없음 · 사용안함 + * (화면 글만 · 저장 값 = 빈 값 · 「우리에 없음」 · 「안 씀」 그대로) + * 줄 표 = `tableOf`(대상이 모두 자재 · 대상 없는 자재 · 별도계상 줄 = 자재 표). + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import type { HopyoLine, HopyoPart } from "./M02_MasterTemplete_Hopyo_Api"; +import { + checkable, + compareText, + isChecked, + partsOf, + type CheckDraft, +} from "./M02_MasterTemplete_Hopyo_Check"; +import { inputsText, sourceText } from "./M02_MasterTemplete_Hopyo_Choose"; +import type { LaborCalc } from "./M02_MasterTemplete_Labor_Api"; +import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks"; +import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { tableOf } from "./logic/M02_MasterTemplete_Hopyo_Draft"; + +/** 드롭다운 — 저장 값(서버 뺌 까닭) → 화면 글 */ +export const OUT_TEXT: [string, string][] = [ + ["", "추가"], + ["우리에 없음", "로직없음"], + ["안 씀", "사용안함"], +]; +const COSTS = ["노무비", "재료비", "경비", "계"] as const; +const COST_HEAD = { + 노무비: "Prev_Labor", + 재료비: "Prev_Material", + 경비: "Prev_Expense", + 계: "Prev_Sum", +} as const; + +/** 줄 하나 미리 셈 — 셈 중 = 둘 다 없음 */ +export interface LinePrice { + calc?: LaborCalc; + fail?: string; +} + +export interface RowsContext { + canWrite: boolean; + d: CheckDraft; + price: (line: HopyoLine) => LinePrice | null; + check: (line: HopyoLine, on: boolean) => void; + out: (line: HopyoLine, value: string) => void; + times: (line: HopyoLine, at: number, value: number | null) => void; + choose: (line: HopyoLine) => void; + source: (line: HopyoLine) => void; + sheet: (행: [number, number]) => void; + options: (line: HopyoLine, at: number) => void; +} + +const num = (v: number): string => + Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); +const small = (text: string, cls = "m02-labrow__note"): HTMLElement => + el("small", { className: cls, text, attrs: { title: text } }); + +/** 금액 칸 — 위 엑셀(작게) · 아래 우리 */ +function pairCell( + excel: number | null | undefined, + ours: number | null | undefined, + wait: boolean, + label: string, +): HTMLElement { + const td = moneyCell(excel, ours, wait, label); + td.querySelector(".m02-labrow__base")?.setAttribute("title", "엑셀"); + td.querySelector(".m02-labrow__applied")?.setAttribute("title", "우리(미리 셈)"); + return td; +} + +/** 수량산출 — 위 엑셀 수량 · 아래 우리(엑셀 × 배 · 대상마다) */ +function qtyCell(line: HopyoLine, parts: HopyoPart[]): HTMLElement { + const ours = parts + .map((p) => (p.배 === null || line.수량 === null ? "—" : num(line.수량 * p.배))) + .join(" · "); + return el("td", { + className: "m02-labrow__qty", + attrs: { "data-label": tc("Labor_QtyCol") }, + children: [ + el("small", { + className: "m02-labrow__base", + text: line.수량 === null ? "—" : `${num(line.수량)} ${line.단위}`.trim(), + attrs: { title: "엑셀" }, + }), + el("span", { className: "m02-labrow__applied", text: ours || "—", attrs: { title: "우리" } }), + ], + }); +} + +const stateChip = (line: HopyoLine): HTMLElement => { + const m = line.맞춤; + const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : ""; + const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n"); + return el("span", { + className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`, + text: `${m.상태}${alias}`, + attrs: { title: tags }, + }); +}; + +/** 대상마다 한 줄 — 키 · 이름 · × 배 칸 · 몫 */ +function partLines(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLElement[] { + return parts.map((p, at) => { + const times = el("input", { + className: "ui-input ui-input--sm m02-hopyo__times", + attrs: { type: "number", step: "any", min: "0", value: p.배 == null ? "" : String(p.배) }, + }) as HTMLInputElement; + times.title = "배 — 우리 수량 = 엑셀 수량 × 배"; + times.disabled = !ctx.canWrite; + times.addEventListener("change", () => + ctx.times(line, at, times.value === "" ? null : Number(times.value)), + ); + const key = p.로직 ?? p.자재 ?? (p.호표 !== undefined ? `제${p.호표}호표` : ""); + return el("div", { + className: "m02-hopyo__part", + attrs: { title: p.어디 ?? "" }, + children: [ + el("span", { className: "m02-labrow__key", text: key }), + el("strong", { text: p.이름 ?? "" }), + times, + ...(p.몫 != null ? [small(`몫 ${p.몫}`)] : []), + ], + }); + }); +} + +/** 이름 칸 덧글 — 옵션(칸=값 · 출처) · 못 가름 · 셈 못 함 까닭 */ +function nameNotes(line: HopyoLine, parts: HopyoPart[], price: LinePrice | null): HTMLElement[] { + const opts = parts + .filter((p) => p.로직) + .map((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처)) + .filter(Boolean); + if (line.맞춤.갈리는칸?.length) opts.push(`⚠ 못 가름: ${line.맞춤.갈리는칸.join(", ")}`); + const why = [ + price?.fail, + ...(price?.calc?.줄 ?? []).map((l) => l.까닭), + ...(price?.calc?.자재 ?? []).map((m) => m.까닭), + ].filter(Boolean) as string[]; + return [ + ...opts.map((t) => small(t, "m02-labrow__note m02-hopyo__opts")), + ...why.map((t) => small(`셈 못 함: ${t}`, "m02-labrow__empty")), + ...(parts.some((p) => p.호표 !== undefined) ? [small("하위 호표 = 만든 뒤 셈")] : []), + ]; +} + +function checkCell(line: HopyoLine, ctx: RowsContext): HTMLElement { + const box = el("input", { + attrs: { type: "checkbox", "data-check": String(line.차례) }, + }) as HTMLInputElement; + box.checked = isChecked(line, ctx.d) && checkable(line, ctx.d); + box.disabled = !ctx.canWrite || !checkable(line, ctx.d); + box.addEventListener("change", () => ctx.check(line, box.checked)); + return el("td", { children: [box] }); +} + +function actsCell(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLElement { + const m = line.맞춤; + const ghost = (label: string, run: () => void): HTMLElement => + createButton({ label, variant: "ghost", size: "sm", onClick: run }); + const out = el("select", { + className: "ui-input ui-input--sm", + attrs: { title: "로직없음 · 사용안함 — 사전조합에 안 넣고 장부에 남김", "data-out": "" }, + children: OUT_TEXT.map(([value, text]) => el("option", { text, attrs: { value } })), + }) as HTMLSelectElement; + out.value = ctx.d.뺌[line.차례] ?? ""; + out.disabled = !ctx.canWrite || m.상태 === "%"; + out.addEventListener("change", () => ctx.out(line, out.value)); + const options = parts.flatMap((p, at) => { + if (!p.로직) return []; + const b = ghost("옵션", () => ctx.options(line, at)); + b.title = `${p.로직} 옵션`; + return [b]; + }); + return el("td", { + className: "m02-hopyo__acts", + children: [ + ...(ctx.canWrite && m.상태 !== "%" ? [ghost("고르기", () => ctx.choose(line))] : []), + ghost("원문 보기", () => ctx.source(line)), + ...(line.근거 ? [ghost("엑셀 보기", () => ctx.sheet(line.근거!.행))] : []), + ...options, + out, + ], + }); +} + +const rowOf = (line: HopyoLine, ctx: RowsContext, cells: HTMLElement[]): HTMLElement => { + const on = isChecked(line, ctx.d) && checkable(line, ctx.d); + return el("tr", { + className: `m02-labrow__row m02-hopyo__line is-${on ? "in" : "out"}`, + attrs: { "data-line": String(line.차례), "data-state": line.맞춤.상태 }, + children: [checkCell(line, ctx), ...cells, actsCell(line, partsOf(line, ctx.d), ctx)], + }); +}; + +function logicRow(line: HopyoLine, ctx: RowsContext): HTMLElement { + const parts = partsOf(line, ctx.d); + const price = ctx.price(line); + const wait = !!price && !price.calc && !price.fail; + const ours = price?.calc?.반영합; + const unit = price?.calc?.줄[0]?.분모 ?? parts[0]?.단위 ?? line.단위; + const compare = compareText(ctx.d.품[line.차례] ?? line.맞춤.품); + return rowOf(line, ctx, [ + el("td", { className: "m02-labrow__no", text: String(line.차례 + 1) }), + el("td", { className: "m02-labrow__kind", children: [line.갈래, stateChip(line)] }), + el("td", { + className: "m02-labrow__name m02-hopyo__parts", + children: [ + ...(parts.length ? partLines(line, parts, ctx) : [small("—", "m02-labrow__muted")]), + ...nameNotes(line, parts, price), + ], + }), + el("td", { + className: "m02-labrow__alias", + attrs: { title: `${line.이름} ${line.규격}`.trim() }, + children: [line.이름, ...(line.규격 ? [small(line.규격)] : [])], + }), + qtyCell(line, parts), + el("td", { className: "m02-labrow__unit", text: unit ?? "" }), + ...COSTS.map((c) => + pairCell(line.금액[c], price?.calc ? (ours?.[c] ?? null) : null, wait, tc(COST_HEAD[c])), + ), + el("td", { className: "m02-hopyo__compare", text: compare.split(" · ").join("\n") }), + ]); +} + +function matRow(line: HopyoLine, ctx: RowsContext): HTMLElement { + const parts = partsOf(line, ctx.d); + const price = ctx.price(line); + const wait = !!price && !price.calc && !price.fail; + const mats = price?.calc?.자재 ?? []; + const first = mats[0]; + const ours = mats.length ? mats.reduce((s, m) => s + (m.반영 ?? 0), 0) : null; + const show = (v: number | null | undefined): string => + wait ? "…" : v === null || v === undefined ? "—" : v.toLocaleString("ko-KR"); + const p = parts[0]; + return rowOf(line, ctx, [ + el("td", { className: "m02-labrow__no", text: String(line.차례 + 1) }), + el("td", { + className: "m02-labrow__code", + text: parts.map((x) => x.자재 ?? "").join(" · ") || "—", + }), + el("td", { + className: "m02-labrow__name m02-hopyo__parts", + children: [ + stateChip(line), + ...(parts.length ? partLines(line, parts, ctx) : []), + small(`엑셀 ${line.이름}`.trim()), + ...nameNotes(line, parts, price), + ], + }), + el("td", { + className: "m02-labrow__spec", + text: p?.규격 ?? line.규격, + attrs: { title: p?.규격 ?? line.규격 }, + }), + qtyCell(line, parts), + el("td", { className: "m02-labrow__unit", text: p?.단위 ?? line.단위 }), + ...markCells({ 별도계상: line.갈래 === "별도계상" ? "별도계상" : "직접계산" }), + ...PRICE_SLOTS.map((slot) => + el("td", { + className: `m02-labrow__num m02-labrow__price${first?.기준칸 === slot ? " is-base-price" : ""}`, + text: show(first?.가격?.[slot]), + attrs: { "data-label": slot, "data-price-slot": slot }, + }), + ), + pairCell(line.금액.계, price?.calc ? ours : null, wait, tc("Labor_AppliedSum")), + ]); +} + +const grid = (heads: string[], rows: HTMLElement[], kind: string): HTMLElement => + el("div", { + className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", + children: [ + el("table", { + className: "ui-general-block__table m02-labrow m02-hopyo__table m02-hopyo__lines", + attrs: { "data-hopyo-table": kind }, + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: rows }), + ], + }), + ], + }); + +/** 줄 표 둘 — 로직 표 · 자재 표(줄 없는 표는 안 그림) */ +export function checkTables(lines: HopyoLine[], ctx: RowsContext): HTMLElement[] { + const logic = lines.filter((l) => tableOf(l, partsOf(l, ctx.d)) === "logic"); + const mat = lines.filter((l) => !logic.includes(l)); + const out: HTMLElement[] = []; + if (logic.length) + out.push( + grid( + [ + "", + tc("Col_Order"), + tc("Sub"), + tc("Col_Name"), + tc("Col_Alias"), + tc("Labor_QtyCol"), + tc("Col_Unit"), + ...COSTS.map((c) => tc(COST_HEAD[c])), + "품 비교", + "", + ], + logic.map((l) => logicRow(l, ctx)), + "logic", + ), + ); + if (mat.length) + out.push( + el("h4", { className: "m02-hopyo__subhead", text: "자재" }), + grid( + [ + "", + tc("Col_Order"), + tc("Labor_Code"), + tc("Col_Name"), + tc("Labor_Spec"), + tc("Labor_QtyCol"), + tc("Labor_Unit"), + ...markHeads(), + ...PRICE_SLOTS, + `엑셀 / 우리 ${tc("Labor_AppliedSum")}`, + "", + ], + mat.map((l) => matRow(l, ctx)), + "mat", + ), + ); + return out; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts index 26046f7fc..cf8022f09 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts @@ -1,10 +1,11 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Choose.ts - * 일위대가 실무자료 고르기 창(PLAN 338-3 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미. - * · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀에서 보기](단가산출 그 행) - * · 후보 다섯 = 이름 · 키 · 판정 꼬리표(맞음 / 다른 점 / 후보) · 글 · 근거 꼬리표(근거 맞음 초록) · 금액 비 없음 - * · 고름 = [고름](한 로직) · 또는 체크 + 몫 칸 → [고른 n개 함께](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움 - * · [다른 로직 찾기] = `openLogicPicker` 에 찾기 글 미리 넣음(엑셀 인용 조항 번호 → 없으면 품명 첫 뜻 낱말) + * 일위대가 실무자료 고르기 창(PLAN 338-3 · 358 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미. + * · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀 보기](단가산출 그 행) + * · 후보 묶음(358-1 · 숨김 없음) = 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 324 다섯) / 숫자만 닮음(접힘 · + * 뜻 낱말 안 겹치는 「후보」는 회색 끝) · 같은 이름이면 책(산림 · 건설) · 판정 꼬리표 · 근거 꼬리표(근거 맞음 초록) + * · 고름 = [후보](한 로직) · 또는 체크 + 몫 칸 → [일괄 적용(n)](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움 + * · [다른 로직 검색] = `openLogicPicker` 에 품명 첫 뜻 낱말 · [다른 자재 검색] = 자재 줄 · 재료비 있는 줄(358-2) * 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감. * ========================================================================== */ @@ -15,6 +16,7 @@ import type { HopyoBasisHit, HopyoLine, HopyoLineBasis, + HopyoMatch, HopyoQty, HopyoTarget, } from "./M02_MasterTemplete_Hopyo_Api"; @@ -95,13 +97,71 @@ export const qtyText = (q: HopyoQty | undefined): string => ].join(" · ") : ""; -/** [다른 로직 찾기] 미리 넣을 글 — 엑셀 인용 조항 번호 → 없으면 품명 괄호 밖 첫 뜻 낱말 */ -export function searchHint(line: Pick): string { - const clause = line.근거?.출처?.find((x) => x[1])?.[1]; - if (clause) return clause; +/** [다른 로직 검색] 미리 넣을 글 — 품명 괄호 밖 첫 뜻 낱말(358-1 · 조항 번호는 모든 낱말 맞춤이라 0건 위험) */ +export function searchHint(line: Pick): string { return /[가-힣A-Za-z]{2,}/.exec(line.이름.replace(/\([^()]*\)/g, " "))?.[0] ?? ""; } +/** 후보 묶음 하나 — 제목 · 접힘(숫자만 닮음) · 후보 */ +export interface CandGroup { + 제목: string; + 접힘: boolean; + 후보: HopyoTarget[]; +} + +/** 약한 후보 — 판정 「후보」 + 품명 뜻 낱말 안 겹침(회색 · 묶음 끝) */ +export const weakCand = (c: HopyoTarget): boolean => c.판정 === "후보" && c.뜻겹침 === false; + +/** 후보 묶음(358-1 · 숨김 없음 · 차례는 서버 그대로) — 1위 + 판정 / 출처 같음 / 이름 닮은 로직(옛 판 다섯) / + * 숫자만 닮음(접힘 · 약한 후보 뒤로) · 한 후보는 한 묶음에만 · 자재 줄 = 1위 / 그 밖 후보 */ +export function candGroups( + m: Pick, + material = false, +): CandGroup[] { + const all = m.후보 ?? []; + const seen = new Set(); + const take = (list: HopyoTarget[]): HopyoTarget[] => + list.filter((c) => { + const id = c.로직 ?? c.자재 ?? c.이름 ?? ""; + if (seen.has(id)) return false; + seen.add(id); + return true; + }); + const top = take(all.slice(0, 1)); + if (material) + return [ + { 제목: "1위", 접힘: false, 후보: top }, + { 제목: "그 밖 후보", 접힘: false, 후보: take(all.slice(1)) }, + ].filter((g) => g.후보.length); + const named = m.이름후보 ?? []; + const source = take([...all.slice(1), ...named].filter((c) => c.출처같음)); + const name = take(named); + const rest = take(all.slice(1)); + rest.sort((a, b) => Number(weakCand(a)) - Number(weakCand(b))); + return [ + { 제목: "1위", 접힘: false, 후보: top }, + { 제목: "출처 같음", 접힘: false, 후보: source }, + { 제목: "이름 닮은 로직", 접힘: false, 후보: name }, + { 제목: "숫자만 닮음", 접힘: true, 후보: rest }, + ].filter((g) => g.후보.length); +} + +/** 같은 이름 후보 — 책(산림 · 건설)을 붙여 가를 이름 */ +export function sameNames(groups: CandGroup[]): Set { + const count = new Map(); + for (const c of groups.flatMap((g) => g.후보)) + count.set(c.이름 ?? "", (count.get(c.이름 ?? "") ?? 0) + 1); + return new Set([...count].filter(([name, n]) => name && n > 1).map(([name]) => name)); +} + +/** 후보 단추 글 — 「이름 [단위] · 키」 · 같은 이름이면 책 두 글자(산림 · 건설) */ +export const candText = (c: HopyoTarget, twins: Set): string => + `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}${twins.has(c.이름 ?? "") && c.책 ? ` · ${c.책.slice(0, 2)}` : ""}`; + +/** 자재 고름 — 자재 · 별도계상 줄 · 재료비 있는 단가산출 줄(358-2 · P.V.C파이프설치 등) */ +export const canPickMaterial = (line: Pick): boolean => + MATERIAL.has(line.갈래) || !!line.단가?.재료비; + /** 고른 후보 → 만들기 대상(배 1 · 몫 있으면 배 = 몫) */ export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): BuildTarget[] => picked.map(({ c, 몫 }) => ({ @@ -113,12 +173,12 @@ export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): export interface ChooseOptions { line: HopyoLine; - /** [엑셀에서 보기] — 단가산출 그 행 */ + /** [엑셀 보기] — 단가산출 그 행 */ openSheet?: (행: [number, number]) => void; done: (targets: BuildTarget[]) => void; } -/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 다섯(하나 또는 여럿 · 몫) · 다른 로직 · 자재 찾기 */ +/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 묶음(하나 또는 여럿 · 몫) · 다른 로직 · 자재 검색 */ export function openHopyoChoose(opt: ChooseOptions): void { const { line } = opt; const material = MATERIAL.has(line.갈래); @@ -132,9 +192,9 @@ export function openHopyoChoose(opt: ChooseOptions): void { opt.done(targets); }; const picked = new Map(); - const together = createButton({ label: "고른 0개 함께", size: "sm", disabled: true }); + const together = createButton({ label: "일괄 적용(0)", size: "sm", disabled: true }); const paint = (): void => { - together.querySelector(".ui-btn__label")!.textContent = `고른 ${picked.size}개 함께`; + together.querySelector(".ui-btn__label")!.textContent = `일괄 적용(${picked.size})`; together.disabled = picked.size < 2; }; together.addEventListener("click", () => @@ -144,7 +204,9 @@ export function openHopyoChoose(opt: ChooseOptions): void { ), ), ); - const list = (line.맞춤.후보 ?? []).map((c) => { + const groups = candGroups(line.맞춤, material); + const twins = sameNames(groups); + const row = (c: HopyoTarget): HTMLElement => { const share = el("input", { className: "ui-input ui-input--sm m02-hopyo__times", attrs: { type: "number", step: "0.05", min: "0", max: "1", placeholder: "몫" }, @@ -157,12 +219,12 @@ export function openHopyoChoose(opt: ChooseOptions): void { }); const tag = basisTag(c.근거); return el("div", { - className: "m02-hopyo__cand", + className: `m02-hopyo__cand${weakCand(c) ? " is-weak" : ""}`, attrs: { "data-cand": c.로직 ?? c.자재 ?? "" }, children: [ box, createButton({ - label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`, + label: candText(c, twins), variant: "ghost", size: "sm", onClick: () => finish(pickedTargets([{ c, 몫: null }])), @@ -176,6 +238,7 @@ export function openHopyoChoose(opt: ChooseOptions): void { ] : []), ...(c.글 ? [note(c.글)] : []), + ...(weakCand(c) ? [note("이름 뜻 안 겹침")] : []), ...(tag && c.근거 ? [ el("span", { @@ -188,6 +251,18 @@ export function openHopyoChoose(opt: ChooseOptions): void { share, ], }); + }; + const sections = groups.map((g) => { + const list = el("div", { className: "m02-hopyo__cands", children: g.후보.map(row) }); + const title = `${g.제목} (${g.후보.length})`; + if (!g.접힘) + return el("div", { attrs: { "data-group": g.제목 }, children: [note(title), list] }); + const fold = el("details", { + className: "m02-hopyo__group", + attrs: { "data-group": g.제목 }, + children: [el("summary", { text: `${title} — 이름 · 출처 안 닮음 · 숫자 지문만` }), list], + }); + return fold; }); const excel = qtyText(line.맞춤.품?.엑셀); const basis = line.근거; @@ -199,7 +274,7 @@ export function openHopyoChoose(opt: ChooseOptions): void { ...(basis && opt.openSheet ? [ createButton({ - label: "엑셀에서 보기", + label: "엑셀 보기", variant: "ghost", size: "sm", onClick: () => opt.openSheet?.(basis.행), @@ -208,23 +283,37 @@ export function openHopyoChoose(opt: ChooseOptions): void { : []), ], }); - const more = createButton({ - label: material ? "다른 자재 찾기…" : "다른 로직 찾기…", + const findMaterial = createButton({ + label: "다른 자재 검색…", size: "sm", onClick: () => { close(); - if (material) pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }])); - else - openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, { - query: searchHint(line), - }); + pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }])); }, }); + const findLogic = createButton({ + label: "다른 로직 검색…", + size: "sm", + onClick: () => { + close(); + openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, { + query: searchHint(line), + }); + }, + }); + const more = [ + ...(material ? [] : [findLogic]), + ...(canPickMaterial(line) ? [findMaterial] : []), + ]; body.append( ...(head.childElementCount ? [head] : []), - note(list.length ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [함께]" : "가까운 것 없음"), - el("div", { className: "m02-hopyo__cands", children: list }), - el("div", { className: "m02-hopyo__bar", children: [together, more] }), + note( + groups.length + ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [일괄 적용]" + : "가까운 것 없음 — 아래 검색", + ), + ...sections, + el("div", { className: "m02-hopyo__bar", children: [together, ...more] }), ); }, }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index d4902b1ca..892e71415 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -62,9 +62,9 @@ export interface HopyoSheetOptions { canWrite: boolean; /** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */ target?: HopyoScope; - /** 처음 고를 호표 번(호표 화면 [엑셀에서 보기]) */ + /** 처음 고를 호표 번(확인 창 머리 [엑셀 보기]) */ 호표?: number; - /** 처음 보일 단가산출 행(330-3 근거 [엑셀에서 보기]) */ + /** 처음 보일 단가산출 행(330-3 근거 [엑셀 보기]) */ 단산행?: number | [number, number]; /** 호표 하나를 만들었음 — [로직 추가] 참조 줄 */ onImport?: (got: HopyoBuilt, hopyo: HopyoBrief) => void; diff --git a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Draft.ts b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Draft.ts new file mode 100644 index 000000000..cb2051f00 --- /dev/null +++ b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Draft.ts @@ -0,0 +1,56 @@ +/* ============================================================================= + * M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Draft.ts + * 확인 창 금액 미리 보기 꼴(PLAN 359-3) — 엑셀 줄 하나 + 지금 대상 → 일위대가 문서 한 벌(서버 `/labor/{열}/calc` 에 + * 보냄 · 저장 안 함 · 금액은 서버가 셈) · 줄이 들어갈 표(로직 표 · 자재 표). + * 정본 = 서버 `M02_Hopyo_Build.Builder.build` 의 `put`([사전조합 생성] 때 다시 셈) — 같은 규칙: + * 우리 수량 = 엑셀 수량 × 배 · 로직 = {로직, 입력값, 수량 {손}} · 자재 = {코드, 수량 {손}, 별도계상 = + * 갈래 별도계상이면 「별도계상」 아니면 「직접계산」} · 같은 자재 = 수량 더함. + * 미리 보기만 다른 것: 호표 대상(하위 사전조합 — 만든 뒤 셈) · 배 없는 대상은 뺌 · + * 같은 로직 둘째는 뺌(Build = 한 줄 사전조합으로 감쌈 · 한 문서에 같은 로직 둘 = 서버 400). + * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). + * ========================================================================== */ + +import type { HopyoLine, HopyoPart } from "../M02_MasterTemplete_Hopyo_Api"; +import type { LaborDoc, LaborMat, LaborRow } from "../M02_MasterTemplete_Labor_Api"; + +export type CheckTable = "logic" | "mat"; + +/** 엑셀 갈래 중 자재 표로 가는 것(대상 없을 때) */ +const MAT_KINDS = ["자재", "별도계상"]; + +/** 줄이 들어갈 표 — 대상이 모두 자재면 자재 표 · 대상 없으면 갈래(자재 · 별도계상) · 그 밖 로직 표 */ +export function tableOf(line: Pick, parts: HopyoPart[]): CheckTable { + if (parts.length) return parts.every((p) => !!p.자재) ? "mat" : "logic"; + return MAT_KINDS.includes(line.갈래) ? "mat" : "logic"; +} + +/** 엑셀 줄 + 대상 → 미리 셈 문서 · 셀 것이 없으면 null(호표 대상뿐 · 배 없음 · 대상 없음) */ +export function draftDoc( + line: Pick, + parts: HopyoPart[], +): LaborDoc | null { + const rows: LaborRow[] = []; + const mats = new Map(); + const apart = line.갈래 === "별도계상" ? "별도계상" : "직접계산"; + for (const p of parts) { + if (p.배 === null || p.호표 !== undefined) continue; + const qty = (line.수량 ?? 0) * p.배; + if (p.자재) { + const have = mats.get(p.자재)?.수량; + const hand = (have ? have.손 : 0) + qty; + mats.set(p.자재, { + 코드: p.자재, + 이름: null, + 규격: null, + 단위: null, + 수량: { 손: hand }, + 별도계상: apart, + }); + } else if (p.로직 && !rows.some((r) => r.로직 === p.로직)) { + const values = (p.입력값 ?? {}) as LaborRow["입력값"]; + rows.push({ 로직: p.로직, 별칭: null, 메모: null, 입력값: values, 수량: { 손: qty } }); + } + } + if (!rows.length && !mats.size) return null; + return { 종류: "일위대가", 열: "", 단위: null, 담은로직: rows, 자재: [...mats.values()] }; +} diff --git a/resources/tester/helper_355_1_pivot_tilt.cjs b/resources/tester/helper_355_1_pivot_tilt.cjs new file mode 100644 index 000000000..9afd9741f --- /dev/null +++ b/resources/tester/helper_355_1_pivot_tilt.cjs @@ -0,0 +1,150 @@ +/* 355-1 종단 ▲▼ 이웃 꺾임 축 돌림 단위검증 헬퍼 — `B05_Profile_UI_Profile_Alignment.ts` · + * `_Straighten.ts` 를 그 자리에서 트랜스파일해 세 경우를 Node 로 돌린다. + * ① 처음 계획선 ▲ = 옛 값 ② 직선화 뒤 꺾임 ▲ = 사이 측점 한 직선 · 축 표고 그대로 · +0.1 + * ③ 직선 안쪽 ▲ = 옛 값. 옛 값 = 355-1 앞 adjustStation(아래 oldAdjust) 그대로. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const LOGIC = path.join(ROOT, "B05_Profile", "logic"); +const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts")); +const straight = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Straighten.ts")); + +// 전체 측점 폴리라인(20m 간격 · 0~200m) — 서버와 같이 꺾임마다 기본 L 라운드. +const stations = Array.from({ length: 11 }, (_, i) => i * 20); +const ground = stations.map((s, i) => 100 + 0.3 * i + (i % 2 ? 0.4 : 0)); +const base = { + policy: { + station_interval_m: 20, + curve_radius_ratio: 1, + curve_tangent_max_ratio: 0.5, + curve_skip_legal_exception: false, + default_curve_length_m: 15, + default_curve_radius_m: 300, + balance_tolerance_percent: 10, + edit_step_m: 0.1, + grade_violation_policy: "warn", + max_grade_pct: 14, + curve_skip_delta_pct: 0, + paved: true, + }, + onlyExplicitCurves: false, + basePvi: stations.map((s, i) => ({ chainage_m: s, elevation_m: ground[i] })), + chainage: stations, + ground, + stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })), +}; + +/** 355-1 앞 adjustStation — 누른 측점 하나만 옮김. */ +function oldAdjust(edits, chainageM, delta) { + const key = align.chainageKey(chainageM); + const current = align.buildAlignment(base, edits); + const target = align.planElevationAt(current, chainageM) + delta; + const baseElevation = straight.baseElevationAt(base, chainageM); + let offset = + (edits.station_offsets[key] ?? align.planElevationAt(current, chainageM) - baseElevation) + + delta; + const withOffset = (value) => ({ + ...edits, + station_offsets: { ...edits.station_offsets, [key]: Number(value.toFixed(6)) }, + }); + let next = withOffset(offset); + for (let pass = 0; pass < 4; pass += 1) { + const error = target - align.planElevationAt(align.buildAlignment(base, next), chainageM); + if (Math.abs(error) < 1e-4) break; + offset += error; + next = withOffset(offset); + } + return next; +} + +const sorted = (record) => + Object.fromEntries( + Object.keys(record ?? {}) + .sort() + .map((key) => [key, record[key]]), + ); +const pviZ = (built, s) => built.pvi.find((n) => Math.abs(n.chainage_m - s) < 1e-6).elevation_m; +const out = {}; + +// ① 처음 계획선 — 마디마다 꺾임 → 축 = 누른 점 ±1 · 옛 값과 같음(▲ · ▼). +{ + const empty = align.emptyEdits(); + const up = align.adjustStation(base, empty, 100, 0.1); + const down = align.adjustStation(base, empty, 60, -0.1); + out.initial = { + sameUp: + JSON.stringify(sorted(up.station_offsets)) === + JSON.stringify(sorted(oldAdjust(empty, 100, 0.1).station_offsets)), + sameDown: + JSON.stringify(sorted(down.station_offsets)) === + JSON.stringify(sorted(oldAdjust(empty, 60, -0.1).station_offsets)), + upKeys: Object.keys(up.station_offsets), + radiiKeys: Object.keys(up.curve_radii ?? {}), + }; +} + +// ② 40~100 · 100~160 직선화 → 100 = 꺾임 · 두 축 40 · 160 · ▲ 100. +{ + let edits = straight.straightenBetween(base, align.emptyEdits(), 40, 100); + edits = straight.straightenBetween(base, edits, 100, 160); + const before = align.buildAlignment(base, edits); + const next = align.adjustStation(base, edits, 100, 0.1); + const after = align.buildAlignment(base, next); + const p = pviZ(after, 100); + const line = (s, aM, aZ) => aZ + ((p - aZ) * (s - aM)) / (100 - aM); + const z40 = pviZ(before, 40); + const z160 = pviZ(before, 160); + out.kink = { + planRise: align.planElevationAt(after, 100) - align.planElevationAt(before, 100), + pivotRise: p - pviZ(before, 100), + axisMove: [pviZ(after, 40) - z40, pviZ(after, 160) - z160], + outsideMove: [20, 180].map((s) => pviZ(after, s) - pviZ(before, s)), + innerResidual: [60, 80] + .map((s) => pviZ(after, s) - line(s, 40, z40)) + .concat([120, 140].map((s) => pviZ(after, s) - line(s, 160, z160))), + innerMoved: [60, 80, 120, 140].map((s) => pviZ(after, s) - pviZ(before, s)), + curvesAt: after.curves.map((c) => c.chainage_m).sort((a, b) => a - b), + oldWouldBreak: (() => { + const old = align.buildAlignment(base, oldAdjust(edits, 100, 0.1)); + return [80, 120].map((s) => pviZ(old, s) - pviZ(before, s)); + })(), + }; +} + +// ③ 직선 안쪽(80 · 40~100 직선 위) — 꺾임 아님 → 옛 값. +{ + let edits = straight.straightenBetween(base, align.emptyEdits(), 40, 100); + edits = straight.straightenBetween(base, edits, 100, 160); + const next = align.adjustStation(base, edits, 80, 0.1); + out.inside = { + same: + JSON.stringify(sorted(next.station_offsets)) === + JSON.stringify(sorted(oldAdjust(edits, 80, 0.1).station_offsets)), + planRise: + align.planElevationAt(align.buildAlignment(base, next), 80) - + align.planElevationAt(align.buildAlignment(base, edits), 80), + }; +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_356_center.cjs b/resources/tester/helper_356_center.cjs new file mode 100644 index 000000000..4c36e2ef2 --- /dev/null +++ b/resources/tester/helper_356_center.cjs @@ -0,0 +1,227 @@ +/* PLAN 356-3 단위검증 헬퍼 — 횡단 중심점 좌우 = 그 측점 노선 좌우. + * 진짜 TS(`logic/B05_Profile_RouteShift` · `B05_Profile_UI_RouteEdit_Shift` · `B06_Section_UI_Cross_Center` · + * 페이지 사진첩 `B05_Profile_UI_Profile_History`)를 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. + * 계산 묶음은 흉내(노선이 바뀌면 바뀐 구간 첫 계획선을 새로 맞춤 = 그 측점 계획고가 바뀜) · 짝 = `test_356_b06_card_center.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +const session = new Map(); +globalThis.sessionStorage = { + get length() { + return session.size; + }, + key: (i) => [...session.keys()][i] ?? null, + getItem: (k) => session.get(k) ?? null, + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; + +const toasts = []; +let pipe = null; +const graded = []; +const STUBS = { + "../A00_Common/design/design_pipeline_page": { + pagePipeline: () => pipe, + pipelineGrade: (_id, edits) => (graded.push(edits), pipe.grade(edits)), + }, +}; + +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 near = (request) => + (CATALOG.isCatalogRequest(request) ? CATALOG : null) ?? + STUBS[request] ?? + (request.startsWith("@util/") + ? loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)) + : request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request)); + new Function("exports", "module", "require", js)(box.exports, box, near); + return box.exports; +} + +const B05 = path.join(ROOT, "B05_Profile"); +const shift = loadTs(path.join(B05, "logic", "B05_Profile_RouteShift.ts")); +const align = loadTs(path.join(B05, "logic", "B05_Profile_UI_Profile_Alignment.ts")); +const curve = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Curve.ts")); +const grade = loadTs(path.join(B05, "logic", "B05_Profile_Pipeline_Grade.ts")); +const history = loadTs(path.join(B05, "B05_Profile_UI_Profile_History.ts")); +const editShift = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_Shift.ts")); +const center = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Center.ts")); + +const out = {}; + +// ── 노드 고름 — 직선 끼움 · 0.5 m 안 노드 옮김 · 고정 · 곡선 안 = 교각점 ── +const straight = [ + [0, 0], + [100, 0], +]; +const plan = (planned, line, curves, locked, chainageM, deltaM) => + shift.planRouteShift({ planned, line, curves, locked, chainageM, deltaM }); +out.insert = plan(straight, straight, [], [true, true], 40, 1); +const kinked = [ + [0, 0], + [40.3, 0], + [100, 0], +]; +out.near = plan(kinked, kinked, [], [true, false, true], 40, -0.5); +out.nearLocked = plan(kinked, kinked, [], [true, true, true], 40, -0.5); +const bent = [ + [0, 0], + [60, 0], + [60, 60], +]; +const built = curve.buildEditedPolyline(bent, [true, true, true], [null, 30, null], 30); +const bc = built.curves[0].start; +const tangentIn = Math.hypot(bc[0], bc[1]); // 시점 ~ BC(첫 토막 위) +out.curveHit = plan(bent, built.vertices, built.curves, [true, false, true], tangentIn + 5, 1); +out.curveApex = bent[1]; + +// ── 뒤 측점 다시 셈 — 끼운 뒤 바뀐 범위 = 끼운 노드 앞 노드부터 끝까지 ── +const after = [straight[0], out.insert.at, straight[1]]; +out.range = grade.routeRange(straight, after); + +// ── 높이 그대로 · 되돌리기 한 걸음 · B06 바로 열기 — 편집기 host + 흉내 묶음 ── +const policy = { + station_interval_m: 20, + curve_radius_ratio: 1, + curve_tangent_max_ratio: 0.5, + curve_skip_legal_exception: false, + default_curve_length_m: 20, + default_curve_radius_m: 300, + balance_tolerance_percent: 10, + edit_step_m: 0.1, + grade_violation_policy: "warn", + max_grade_pct: 14, + curve_skip_delta_pct: 0, + paved: true, +}; +const stations = [0, 20, 40, 60, 80, 100]; +const baseOf = (pvi) => ({ + policy, + onlyExplicitCurves: true, + basePvi: pvi.map(([chainage_m, elevation_m]) => ({ chainage_m, elevation_m })), + chainage: stations, + ground: stations.map(() => 100), + stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })), +}); +const savedBase = baseOf([ + [0, 100], + [100, 110], +]); +// 노선이 바뀌면 바뀐 구간 첫 계획선을 새 지반에 다시 맞춤 — 40 m 계획고가 104 → 102 쪽으로 쓸림 +const refitBase = baseOf([ + [0, 100], + [60, 102.5], + [100, 110], +]); +let generation = 0; +const listeners = new Set(); +const state = { base: savedBase, edits: align.emptyEdits() }; +const emit = () => { + const gen = generation; + setTimeout(() => { + for (const fn of [...listeners]) fn({ kind: "stage", stage: "profile", generation: gen }); + }, 5); +}; +pipe = { + get generation() { + return generation; + }, + subscribe: (fn) => (listeners.add(fn), () => listeners.delete(fn)), + output: (name) => + name === "planline" + ? state.base + : name === "profile" + ? align.buildAlignment(state.base, state.edits) + : null, + input: (key) => (key === "alignment" ? state.edits : null), + route(planned) { + state.base = refitBase; + state.edits = align.emptyEdits(); // 바뀐 구간 안 편집은 버림 + sessionStorage.setItem("aislo:draft:route-draft:p", JSON.stringify(planned)); + sessionStorage.setItem("aislo:draft:alignment-draft:0", JSON.stringify(state.edits)); + generation += 1; + emit(); + }, + grade(edits) { + state.edits = edits; + sessionStorage.setItem("aislo:draft:alignment-draft:0", JSON.stringify(edits)); + generation += 1; + emit(); + }, +}; + +const album = history.createProfileHistory(() => undefined); +// 편집기 흉내 — 사진(산 배열) · 한 걸음(사진첩에 쌓고 묶음에 노선을 넘김) +const editor = { + planned: straight.map((p) => [...p]), + curveOn: [true, true], + curveRadius: [null, null], + curveLock: [null, null], + curveArc: [null, null], + apexLock: [true, true], + picked: -1, +}; +const applied = []; +const disposers = []; +const host = { onDispose: (fn) => disposers.push(fn) }; +const mountEditor = () => + editShift.bindRouteShift( + host, + "p", + () => editor, + (message) => { + applied.push(message); + album.record(); // 사진첩이 멈춰 있으면 안 쌓임 — 끝에 한 번 + pipe.route(editor.planned); + }, + () => [editor.planned, []], + ); + +(async () => { + const zBefore = align.planElevationAt(pipe.output("profile"), 40); + const sessionBefore = JSON.stringify([...session.entries()]); + // B06 바로 열기 — host 없음 → 페이지가 단 길(종단 보기 숨긴 채 세움)이 편집기를 세움 + let opened = 0; + center.setRouteShiftOpener( + async () => { + opened += 1; + setTimeout(mountEditor, 20); + }, + (text, kind) => toasts.push([text, kind]), + ); + out.moved = await center.shiftRouteAt(40, 1); + out.opened = opened; + out.planned = editor.planned; + out.applied = applied; + out.graded = graded.length; + out.zBefore = zBefore; + out.zAfter = align.planElevationAt(pipe.output("profile"), 40); + out.zRefit = align.planElevationAt(align.buildAlignment(refitBase, align.emptyEdits()), 40); + out.canUndo = album.canUndo(); + album.undo(); + out.undoOne = JSON.stringify([...session.entries()]) === sessionBefore; + out.canUndoAfter = album.canUndo(); + // 고정 노드 — 안 옮기고 까닭 알림(되돌리기 안 쌓임) + editor.planned = kinked.map((p) => [...p]); + editor.apexLock = [true, true, true]; + const lockedMoved = await center.shiftRouteAt(40, -0.5); + out.locked = { moved: lockedMoved, toast: toasts.at(-1), planned: editor.planned }; + disposers.forEach((fn) => fn()); + process.stdout.write(JSON.stringify(out)); +})().catch((error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); +}); diff --git a/resources/tester/helper_m02_hopyo_check.cjs b/resources/tester/helper_m02_hopyo_check.cjs index 9e2f83b33..5dc94488c 100644 --- a/resources/tester/helper_m02_hopyo_check.cjs +++ b/resources/tester/helper_m02_hopyo_check.cjs @@ -14,6 +14,7 @@ const STUB_FILES = [ "M02_MasterTemplete_Labor_Api", "M02_MasterTemplete_Labor_Input", "M01_MasterData_UI_LogicLab_Source", + "M02_MasterTemplete_Hopyo_CheckRows", // 표 그리기(DOM · Labor_Rows) — 359-3 ]; const opened = []; const stub = new Proxy( @@ -74,6 +75,17 @@ let out0 = null; const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts")); const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts")); const pick = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Pick.ts")); + const draft = loadTs(path.join(M02, "logic", "M02_MasterTemplete_Hopyo_Draft.ts")); + if (input.drafts) { + // 359-3 — 실물 줄 → 미리 셈 문서 · 표(서버 calc 에 그대로 보냄) + const d = check.loadDraft("drafts"); + out0 = input.drafts.map((l) => { + const parts = check.partsOf(l, d); + return { 차례: l.차례, table: draft.tableOf(l, parts), doc: draft.draftDoc(l, parts) }; + }); + process.stdout.write(JSON.stringify(out0)); + return; + } if (input.real) { // 실물 맞춤 응답(338-4) — 서버 줄을 그대로 먹여 체크 · 몸 · 빠질 줄 · 품 비교 · 글 const lines = input.real; @@ -159,6 +171,42 @@ let out0 = null; word: [choose.builtWord({ 다시씀: false }), choose.builtWord({ 다시씀: true })], qty: choose.qtyText(lines[1].맞춤.품.엑셀), target: choose.targetText({ 호표: 46, 단위: "㎡" }), + // 358-1 후보 묶음 — 1위 / 출처 같음 / 이름 닮은 로직 / 숫자만 닮음(약한 후보 끝) · 같은 이름 책 + groups: (() => { + const m = { + 후보: [ + { 로직: "A", 이름: "되메우기", 판정: "맞음", 뜻겹침: true, 책: "산림품셈" }, + { 로직: "B", 이름: "야면석 채집", 판정: "후보", 뜻겹침: false }, + { + 로직: "C", + 이름: "되메우기", + 판정: "후보", + 뜻겹침: true, + 출처같음: true, + 책: "건설품셈", + }, + { 로직: "D", 이름: "표토펴기", 판정: "다른 점", 뜻겹침: false }, + ], + 이름후보: [ + { 로직: "A", 이름: "되메우기" }, + { 로직: "E", 이름: "되메우기(노상재)" }, + ], + }; + const g = choose.candGroups(m); + const twins = choose.sameNames(g); + return { + groups: g.map((x) => [x.제목, x.접힘, x.후보.map((c) => c.로직)]), + text: g.flatMap((x) => x.후보).map((c) => choose.candText(c, twins)), + material: choose + .candGroups({ 후보: [{ 자재: "M1" }, { 자재: "M2" }] }, true) + .map((x) => x.제목), + pick: [ + choose.canPickMaterial({ 갈래: "단가산출", 단가: { 재료비: 10 } }), + choose.canPickMaterial({ 갈래: "단가산출", 단가: { 재료비: null } }), + choose.canPickMaterial({ 갈래: "별도계상", 단가: {} }), + ], + }; + })(), }, ledger: (() => { const rows = input.ledger; @@ -177,6 +225,32 @@ let out0 = null; entry: ledger.entryOf({ ...rows[0], 갈래: "우리에 없음" }), }; })(), + draft: (() => { + // 359-3 — 미리 셈 문서(우리 수량 = 엑셀 × 배 · 별도계상 · 같은 자재 더함 · 같은 로직 둘째 · 호표 · 배 없음 뺌) + const L = { 로직: "GC000198", 입력값: { 소량감률: 50 }, 배: 2 }; + const M = { 자재: "MT1", 입력값: {}, 배: 1 }; + return { + logic: draft.draftDoc({ 수량: 0.5, 갈래: "단가산출" }, [ + L, + { ...L, 배: 3 }, + { 호표: 46, 입력값: {}, 배: 1 }, + { 로직: "GF000101", 입력값: {}, 배: null }, + ]), + mat: draft.draftDoc({ 수량: 2, 갈래: "별도계상" }, [M, { ...M, 배: 0.5 }]), + direct: draft.draftDoc({ 수량: null, 갈래: "자재" }, [M]).자재[0], + none: [ + draft.draftDoc({ 수량: 1, 갈래: "단가산출" }, []), + draft.draftDoc({ 수량: 1, 갈래: "호표" }, [{ 호표: 3, 입력값: {}, 배: 1 }]), + ], + table: [ + draft.tableOf({ 갈래: "단가산출" }, [M]), + draft.tableOf({ 갈래: "자재" }, [L]), + draft.tableOf({ 갈래: "자재" }, [L, M]), + draft.tableOf({ 갈래: "별도계상" }, []), + draft.tableOf({ 갈래: "%" }, []), + ], + }; + })(), sheet: { order: sheet.buildOrder([ { 번: 45, 아래: [46] }, diff --git a/resources/tester/test_213_1_hidden_station_pvi.py b/resources/tester/test_213_1_hidden_station_pvi.py index b75c20104..1f2fc4833 100644 --- a/resources/tester/test_213_1_hidden_station_pvi.py +++ b/resources/tester/test_213_1_hidden_station_pvi.py @@ -15,6 +15,7 @@ from __future__ import annotations import copy import json +import re import shutil import subprocess import sys @@ -136,6 +137,9 @@ def ts_alignment(tmp_path_factory): capture_output=True, ) (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + for js in out.glob("*.js"): # 355-1 Alignment 이 Pivot 을 부름 — Node ESM 은 확장자 필요 + text = js.read_text(encoding="utf-8") + js.write_text(re.sub(r'(from "\./\w+)"', r'\1.js"', text), encoding="utf-8") data = _synthetic() base = { "policy": AlignmentPolicy.from_dict({}).as_dict(), diff --git a/resources/tester/test_213_2_ghost_edit_drop.py b/resources/tester/test_213_2_ghost_edit_drop.py index 2e84ee6e5..203185130 100644 --- a/resources/tester/test_213_2_ghost_edit_drop.py +++ b/resources/tester/test_213_2_ghost_edit_drop.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import re import subprocess import sys from pathlib import Path @@ -163,6 +164,9 @@ def test_followed_station_ts(tmp_path): capture_output=True, ) (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + for js in out.glob("*.js"): # 355-1 Alignment 이 Pivot 을 부름 — Node ESM 은 확장자 필요 + text = js.read_text(encoding="utf-8") + js.write_text(re.sub(r'(from "\./\w+)"', r'\1.js"', text), encoding="utf-8") src, dst = tmp_path / "in.json", tmp_path / "out.json" src.write_text(json.dumps([case for case, _ in CASES]), encoding="utf-8") subprocess.run( # noqa: S603 diff --git a/resources/tester/test_346_2_footprint_stage.py b/resources/tester/test_346_2_footprint_stage.py index 4691b4804..e2aad6a0a 100644 --- a/resources/tester/test_346_2_footprint_stage.py +++ b/resources/tester/test_346_2_footprint_stage.py @@ -165,11 +165,19 @@ def test_계약_꼴_선(result) -> None: first = result["first"] for name in ("roadLeft", "roadRight", "slopeLeft", "slopeRight"): line = first[name] - assert len(line) == 51 # 0 ~ 100 m · 2 m + # 0 ~ 100 m · 2 m + 깎기↔쌓기 바뀜 자리(353-1 · 비탈 끝이 도로 끝으로 모임) + assert {round(p[0], 3) for p in line} >= {float(2 * i) for i in range(51)} assert [p[0] for p in line] == sorted(p[0] for p in line) # 시점 → 종점 assert all(p[1] == pytest.approx(2.5) for p in first["roadLeft"]) assert all(p[1] == pytest.approx(-2.5) for p in first["roadRight"]) - assert len(first["slopeLeftCut"]) == 51 and len(first["slopeRightCut"]) == 51 + assert len(first["slopeLeftCut"]) == len(first["slopeLeft"]) + assert len(first["slopeRightCut"]) == len(first["slopeRight"]) + for side in ("slopeLeft", "slopeRight"): # 바뀜 자리 = 도로 끝 위 + flags, points = first[f"{side}Cut"], first[side] + edge = 2.5 if side == "slopeLeft" else -2.5 + for i in range(1, len(flags)): + if flags[i] != flags[i - 1]: + assert points[i][1] == pytest.approx(edge, abs=1e-3) assert any(first["slopeLeftCut"]) and not all(first["slopeLeftCut"]) # 깎기 · 쌓기 섞임 diff --git a/resources/tester/test_353_1_cut_fill_line_smooth.py b/resources/tester/test_353_1_cut_fill_line_smooth.py new file mode 100644 index 000000000..2be252fc6 --- /dev/null +++ b/resources/tester/test_353_1_cut_fill_line_smooth.py @@ -0,0 +1,199 @@ +"""353-1 절/성토선 매끄럽게 — TS(`B05_Profile/logic/B05_Profile_PlanFootprint.ts`) ↔ 파이썬 +(`B07_DesignDetail_Engine_Cad_PlanSlope.py`) 거울 + 측점 폭 = 횡단 값 · 꺾임 각 상한 · 접힘 버림 · +깎기↔쌓기 바뀜 자리 = 도로 끝. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_353_1_cut_fill_line_smooth.py -q +""" + +from __future__ import annotations + +import json +import math +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import ( # noqa: E402 + route_normal, + slope_pieces, +) + +TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_PlanFootprint.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +TOL = 2e-4 +RADIUS = 20.0 +#: 꺾임 각 상한(°) — 이웃 선분 사이 방향 차 +MAX_TURN_DEG = 10.0 + + +def _route() -> list[tuple[float, float, float]]: + """+x 직선 50 m → 좌회전 90° 원호(R 20 · 5° 편) → +y 직선 40 m.""" + points = [(0.0, 0.0)] + points += [ + (50.0 + RADIUS * math.sin(math.radians(a)), RADIUS - RADIUS * math.cos(math.radians(a))) + for a in range(0, 91, 5) + ] + points.append((70.0, 60.0)) + route, chain = [], 0.0 + for index, (x, y) in enumerate(points): + if index: + step = math.hypot(x - points[index - 1][0], y - points[index - 1][1]) + if step < 1e-9: + continue + chain += step + route.append((chain, x, y)) + return route + + +def _records() -> list[dict]: + """20 m 측점 — 좌(곡선 안쪽) 비탈 폭이 곡선에서 R 보다 커져 접힘 · 우는 깎기↔쌓기 번갈아.""" + route = _route() + total = route[-1][0] + records = [] + for i in range(int(total // 10) + 1): + chainage = 10.0 * i + x, y, nx, ny = route_normal(route, chainage) + inside_curve = 50.0 <= chainage <= 50.0 + RADIUS * math.pi / 2 + records.append( + { + "chainage_m": chainage, + "left_edge_m": 2.5, + "left_end_m": 2.5 + (22.0 if inside_curve else 3.0 + 2.0 * math.sin(chainage / 7)), + "right_edge_m": -2.5 - 0.3 * (i % 2), + "right_end_m": -6.0 - 2.0 * math.cos(chainage / 11), + "left_cut": True, + "right_cut": (i // 2) % 2 == 0, + "x": x, + "y": y, + "nx": nx, + "ny": ny, + } + ) + return records + + +DRIVER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { slopePieces } from "./B05_Profile_PlanFootprint.js"; +const c = JSON.parse(readFileSync(process.argv[2], "utf-8")); +writeFileSync(process.argv[3], JSON.stringify(slopePieces(c.route, c.records, c.start, c.end))); +""" + + +@pytest.fixture(scope="module") +def pieces() -> dict: + route = _route() + edge, fill, cut, ticks = slope_pieces(route, _records(), 0.0, route[-1][0]) + return { + "edge": edge["lines"], + "fill": fill["lines"], + "cut": cut["lines"], + "ticks": ticks["ticks"], + } + + +@pytest.fixture(scope="module") +def ts_pieces(tmp_path_factory: pytest.TempPathFactory) -> dict: + out = tmp_path_factory.mktemp("ts_353_1") + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext", + "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", + "--noResolve"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") + route = _route() + payload = {"route": route, "records": _records(), "start": 0.0, "end": route[-1][0]} + (out / "in.json").write_text(json.dumps(payload), encoding="utf-8") + run = subprocess.run( # noqa: S603 + ["node", str(out / "driver.mjs"), str(out / "in.json"), str(out / "out.json")], + capture_output=True, text=True, cwd=str(out), + ) # fmt: skip + assert run.returncode == 0, run.stderr + return json.loads((out / "out.json").read_text(encoding="utf-8")) + + +def test_두_쪽이_같다(pieces, ts_pieces) -> None: + for name in ("edge", "fill", "cut", "ticks"): + want, got = pieces[name], ts_pieces[name] + assert len(want) == len(got), name + for line_w, line_g in zip(want, got): + assert len(line_w) == len(line_g), name + for pw, pg in zip(line_w, line_g): + assert abs(pw[0] - pg[0]) < TOL and abs(pw[1] - pg[1]) < TOL, (name, pw, pg) + + +def _nearest(lines: list, point: tuple[float, float]) -> float: + return min(math.hypot(p[0] - point[0], p[1] - point[1]) for line in lines for p in line) + + +def test_측점_폭은_횡단_값_그대로(pieces) -> None: + left_edge, right_edge = pieces["edge"] + ends = pieces["fill"] + pieces["cut"] + for record in _records(): + x, y, nx, ny = record["x"], record["y"], record["nx"], record["ny"] + for key, lines in (("left_edge_m", [left_edge]), ("right_edge_m", [right_edge]), + ("right_end_m", ends)): # fmt: skip + value = record[key] + assert _nearest(lines, (x + nx * value, y + ny * value)) < 1e-3, (record, key) + if not 50.0 <= record["chainage_m"] <= 50.0 + RADIUS * math.pi / 2: # 접히지 않는 자리 + value = record["left_end_m"] + assert _nearest(ends, (x + nx * value, y + ny * value)) < 1e-3, record + + +def _turns(line: list) -> list[float]: + turns = [] + for a, b, c in zip(line, line[1:], line[2:]): + u = (b[0] - a[0], b[1] - a[1]) + v = (c[0] - b[0], c[1] - b[1]) + turns.append( + abs(math.degrees(math.atan2(u[0] * v[1] - u[1] * v[0], u[0] * v[0] + u[1] * v[1]))) + ) + return turns + + +def _right_runs(pieces: dict) -> list: + """우측 비탈 끝 조각 — 좌측은 늘 깎기라 깎기 첫 조각이 좌측 한 줄.""" + return pieces["fill"] + pieces["cut"][1:] + + +def test_꺾임_각_상한(pieces) -> None: + # 도로 끝 · 곡선 바깥(우) 비탈 끝 — 원호 5° 편 위에서도 톱니 없이 고름 + for line in [*pieces["edge"], *_right_runs(pieces)]: + assert max(_turns(line), default=0.0) < MAX_TURN_DEG + + +def _crosses(p1, p2, p3, p4) -> bool: + def side(a, b, c) -> float: + return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) + + return side(p1, p2, p3) * side(p1, p2, p4) < 0 and side(p3, p4, p1) * side(p3, p4, p2) < 0 + + +def test_안쪽_접힘은_버림(pieces) -> None: + left_end = pieces["cut"][0] + segments = list(zip(left_end, left_end[1:])) + for i, (a, b) in enumerate(segments): # 스스로 꼬이지 않음 + for c, d in segments[i + 2 :]: + assert not _crosses(a, b, c, d), (a, b, c, d) + + +def test_깎기_쌓기_바뀜_자리는_도로_끝(pieces) -> None: + right_edge = pieces["edge"][1] + runs = _right_runs(pieces) + assert len(runs) >= 4 + ends = sorted((p for line in runs for p in (line[0], line[-1])), key=lambda p: p[0] + p[1]) + for point in ends[1:-1]: # 시점 · 종점 빼고 조각 끝 = 바뀜 자리 + assert _nearest([right_edge], point) < 0.05, point + + +def test_단추_이름은_절성토선() -> None: + chrome = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text("utf-8") + at = chrome.index('data-act="footprint"') + assert ">절/성토선" in chrome[at : at + 200] and ">발자국<" not in chrome diff --git a/resources/tester/test_353_2_contour_lock.py b/resources/tester/test_353_2_contour_lock.py new file mode 100644 index 000000000..9dc0a4c58 --- /dev/null +++ b/resources/tester/test_353_2_contour_lock.py @@ -0,0 +1,81 @@ +"""353-2 평면 등고선 자물쇠 — 규칙(`logic/B05_Profile_ContourLock.ts`)을 Node 로 돌려 판정 + +편집기 이음(고르기 막음 · 지형 바꿔도 같은 높이 · 호버 = 집기와 같은 hitPreparedLayer). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_353_2_contour_lock.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +B05 = ROOT / "B05_Profile" +LOGIC = B05 / "logic" / "B05_Profile_ContourLock.ts" +TS = ROOT / "config" / "node_modules" / "typescript" + +DRIVER = r""" +const fs = require("fs"); +const ts = require(process.argv[1]); +const js = ts.transpileModule(fs.readFileSync(process.argv[2], "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const m = { exports: {} }; +new Function("exports", "module", js)(m.exports, m); +const L = m.exports; +const levels = [101, 102, 105, 102, null]; +console.log(JSON.stringify({ + hoverFree: L.lockButtonState(102, null), + hoverNone: L.lockButtonState(null, null), + hoverLocked: L.lockButtonState(102, 102), + hoverOther: L.lockButtonState(105, 102), + lock: L.toggleLock(null, 102), + unlock: L.toggleLock(102, 105), + pickFree: L.canPickContour(null), + pickLocked: L.canPickContour(102), + again: L.featureOfLevel(levels, 102), + gone: L.featureOfLevel(levels, 110), + none: L.featureOfLevel(levels, null), +})); +""" + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", "-e", DRIVER, str(TS), str(LOGIC)], capture_output=True, text=True, timeout=60 + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_안_잠김_단추는_높이_있는_가닥_위에서(got) -> None: + assert got["hoverFree"] == {"visible": True, "pressed": False} + assert got["hoverNone"]["visible"] is False + + +def test_잠김_단추는_그_높이_위에서만_눌린_모양(got) -> None: + assert got["hoverLocked"] == {"visible": True, "pressed": True} + assert got["hoverOther"]["visible"] is False + + +def test_누르면_잠금_다시_누르면_풂(got) -> None: + assert got["lock"] == 102 and got["unlock"] is None + + +def test_잠긴_동안_고르기_막음(got) -> None: + assert got["pickFree"] is True and got["pickLocked"] is False + + +def test_지형_바꿔도_같은_높이를_다시_짚음(got) -> None: + assert got["again"] == 1 and got["gone"] == -1 and got["none"] == -1 + + +def test_편집기_이음() -> None: + edit = (B05 / "B05_Profile_UI_RouteEdit.ts").read_text("utf-8") + assert "if (contourLock.canPick()) pickedContour = index;" in edit + assert "pickedContour = contourLock.reattach(contours?.layer ?? null);" in edit + lock = (B05 / "B05_Profile_UI_RouteEdit_ContourLock.ts").read_text("utf-8") + assert "hitPreparedLayer(" in lock and 'setAttribute("aria-pressed"' in lock + assert "sessionStorage" not in lock and "writeState" not in lock # 페이지 동안만 diff --git a/resources/tester/test_353_3_contour3d_style.py b/resources/tester/test_353_3_contour3d_style.py new file mode 100644 index 000000000..6164ac994 --- /dev/null +++ b/resources/tester/test_353_3_contour3d_style.py @@ -0,0 +1,30 @@ +"""353-3 3D 등고선 — 주곡선 5 m 고정 · 1 m 옅게 · 평면 색 토큰(B05 · B04 3D 같은 재질). + +./venv/Scripts/python.exe -m pytest resources/tester/test_353_3_contour3d_style.py -q +""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +STYLE = ROOT / "ui_template" / "ui_template_contour3d.ts" +VIEWERS = [ + ROOT / "B05_Profile" / "B05_Profile_UI_Viewer.ts", + ROOT / "B04_PreProcess" / "B04_PreProcess_UI_TerrainViewer_Contours.ts", +] + + +def test_재질은_평면_색_토큰과_5m_고정() -> None: + src = STYLE.read_text("utf-8") + assert '"--map-contour-major"' in src and '"--map-contour-minor"' in src + assert "CONTOUR_MAJOR_STEP_M = 5;" in src + # 1 m 선 불투명 = 토큰 알파(0.45) · 주곡선은 진하게 + assert "opacity: minor.alpha" in src and "opacity: 0.95 * major.alpha" in src + + +def test_두_3D_뷰어가_같은_재질을_씀() -> None: + for path in VIEWERS: + src = path.read_text("utf-8") + assert 'from "@ui/ui_template_contour3d"' in src, path.name + assert "isMajorContour(" in src and "contourMaterials()" in src, path.name + # 옛 갈래(간격 × 5 · 주황 두 색) 없음 + assert "interval * 5" not in src and "0xf59e0b" not in src, path.name diff --git a/resources/tester/test_355_1_pivot_tilt.py b/resources/tester/test_355_1_pivot_tilt.py new file mode 100644 index 000000000..eb24bcbf5 --- /dev/null +++ b/resources/tester/test_355_1_pivot_tilt.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""355-1 B05 종단 ▲▼ — 누른 점이 꺾임이면 이웃 꺾임을 축으로 사이 직선이 따라 돎(요구 235-15). + +계산(`B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts` adjustStation · +`_Pivot.ts` pivotAxes)은 Node 헬퍼(`helper_355_1_pivot_tilt.cjs`)가 트랜스파일해 돌리고, +부르는 곳 셋(▲▼ 버튼 · 표 값 입력 · 방향키)이 같은 함수인지는 소스로 본다. +""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent + + +def _read(relative: str) -> str: + return (ROOT / relative).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_355_1_pivot_tilt.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_처음_계획선은_옛_값(out): + initial = out["initial"] + assert initial["sameUp"] and initial["sameDown"] + assert initial["upKeys"] == ["100.000"] + assert initial["radiiKeys"] == [] + + +def test_꺾임_누르면_사이_측점이_한_직선(out): + kink = out["kink"] + assert kink["planRise"] == pytest.approx(0.1, abs=1e-4) + assert kink["axisMove"] == [0, 0] + assert kink["outsideMove"] == [0, 0] + assert all(abs(value) < 1e-6 for value in kink["innerResidual"]) + # 축에서 멀수록 더 오름(40 → 60 → 80 → 100 · 160 → 140 → 120 → 100) + moved = kink["innerMoved"] + assert 0 < moved[0] < moved[1] < kink["pivotRise"] + assert 0 < moved[3] < moved[2] < kink["pivotRise"] + # 옛 길은 사이 측점이 그대로라 P±1 에서 꺾였음 + assert kink["oldWouldBreak"] == [0, 0] + + +def test_직선_안쪽은_옛_값(out): + assert out["inside"]["same"] + assert out["inside"]["planRise"] == pytest.approx(0.1, abs=1e-4) + + +def test_부르는_곳_셋이_같은_함수(): + assert "adjustStation(base, store.edits(), chainage, delta)" in _read( + "B05_Profile/B05_Profile_UI_Profile_Render.ts" + ) + assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in _read( + "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts" + ) + assert "pivotAxes(current, chainageM)" in _read( + "B05_Profile/logic/B05_Profile_UI_Profile_Alignment.ts" + ) diff --git a/resources/tester/test_356_b06_card_center.py b/resources/tester/test_356_b06_card_center.py index 4f7fd6684..9455940de 100644 --- a/resources/tester/test_356_b06_card_center.py +++ b/resources/tester/test_356_b06_card_center.py @@ -2,12 +2,23 @@ 못박는 것 - 356-2 카드 초점 없음 — 마우스로 고른 뒤 키를 눌러도 초점 테두리(다른 색) 안 뜸 · ▲▼ 계획고는 창 듣개. + - 356-3 중심점 = 보라 선 고름 끝 조각(판정 원 · 노면 판정보다 위 · 다른 고름과 하나만) · ▲▼ = 종단 계획고 · + ◀▶ · 가로 끌기 = 그 측점 노선 좌우(0.5 m 안 노드 옮김 · 곡선 안 = 교각점 · 아니면 끼움 · 고정 노드 안 옮김) · + 놓을 때 한 번 · 뒤 측점은 묶음이 다시 셈(바뀐 범위 = 앞 노드 ~ 끝) · 그 측점 계획고 그대로 · 되돌리기 한 걸음 · + B06 을 바로 열어 편집기가 없으면 종단 보기를 숨긴 채 세움. + +진짜 TS 는 `helper_356_center.cjs` 가 Node 로 돌림. """ from __future__ import annotations +import json +import subprocess from pathlib import Path +import pytest + +HERE = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parents[2] B06 = ROOT / "B06_Section" @@ -17,3 +28,58 @@ def test_카드_초점_없음(): assert "card.tabIndex" not in view assert 'card.addEventListener("keydown"' not in view assert 'card.addEventListener("click", () => onSelect(section.station_id));' in view + + +@pytest.fixture(scope="module") +def ts() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_356_center.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_노드_고름_끼움_옮김_고정_교각점(ts): + assert ts["insert"] == { + "kind": "insert", + "index": 1, + "at": [40, 1], + } # 직선 = 측점 자리 + 좌 1 m 끼움 + assert ts["near"] == {"kind": "move", "index": 1, "to": [40.3, -0.5]} # 0.5 m 안 노드 = 옮김 + assert ts["nearLocked"] == {"kind": "locked", "index": 1} # 고정 노드 = 안 옮김 + hit = ts["curveHit"] + assert hit["kind"] == "move" and hit["index"] == 1 # 곡선 안 = 그 교각점 + ax, ay = ts["curveApex"] + assert ((hit["to"][0] - ax) ** 2 + (hit["to"][1] - ay) ** 2) ** 0.5 == pytest.approx(1.0) + + +def test_뒤_측점은_묶음이_다시_셈(ts): + assert ts["range"] == {"fromM": 0, "toM": None} # 앞 노드 ~ 끝(Infinity) + + +def test_높이_그대로_되돌리기_한_걸음_바로_열기(ts): + assert ts["opened"] == 1 and ts["moved"] is True # 편집기 없음 → 종단 보기 숨긴 채 세움 + assert ts["planned"] == [[0, 0], [40, 1], [100, 0]] + assert len(ts["applied"]) == 1 # 편집기 한 걸음 + assert ts["zRefit"] != pytest.approx(ts["zBefore"], abs=1e-3) # 묶음 다시 맞춤이면 쓸림 + assert ts["graded"] == 1 and ts["zAfter"] == pytest.approx(ts["zBefore"], abs=1e-3) + assert ts["canUndo"] and ts["undoOne"] and not ts["canUndoAfter"] # 노선 + 계획고 = 한 걸음 + locked = ts["locked"] + assert locked["moved"] is False and locked["toast"][1] == "info" + assert locked["planned"] == [[0, 0], [40.3, 0], [100, 0]] + + +def test_배선(): + pick = (B06 / "B06_Section_UI_Cross_Design_Pick.ts").read_text(encoding="utf-8") + assert "center: section.chainage_m," in pick # designLineMoves → 카드 고름 끝 조각 + assert 'if (axis === "center") return centerShift?.step(dir);' in pick + order = (B06 / "B06_Section_UI_Cross_Pick_Order.ts").read_text(encoding="utf-8") + assert "layer.append(...surfaces, ...edges, ...lines, ...centers);" in order + edit = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8") + assert "bindRouteShift(host, projectId, snapshotNow, applyEdit," in edit + page = (ROOT / "A00_Common" / "design" / "design_page.ts").read_text(encoding="utf-8") + assert 'setRouteShiftOpener(() => shared.ensureView("profile"), showToast);' in page diff --git a/resources/tester/test_m02_hopyo_draft_calc.py b/resources/tester/test_m02_hopyo_draft_calc.py new file mode 100644 index 000000000..a6769e95a --- /dev/null +++ b/resources/tester/test_m02_hopyo_draft_calc.py @@ -0,0 +1,65 @@ +"""확인 창 금액 미리 셈 이음매(PLAN 359-3) — 북부청 실물 호표 줄 → 화면 문서 꼴 +(`logic/M02_MasterTemplete_Hopyo_Draft.ts` · Node) → 서버 `/labor/{열}/calc`(저장 안 함). + +체크 제안 로직 줄(제1~10호표) = 200 · 반영합 있음 · 우리 수량 = 엑셀 수량 × 배 · +자재 줄 = 자재 표 · 셈 뒤 층 폴더에 문서 안 생김. +""" + +import json +import os +import subprocess + +from M02_MasterTemplete import M02_Hopyo_Fit as fit +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 _post + +HERE = os.path.dirname(os.path.abspath(__file__)) +client = base.client # 공용 고정물(임시 층) +CALC = "/api/m02/layers/personal/labor/hopyo-draft/calc" + + +def _drafts(lines: list[dict]) -> list[dict]: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_hopyo_check.cjs")], + input=json.dumps({"drafts": lines}, 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 + folder = layers.personal_dir(7, 42) + fit.run_job(folder, "HP-000001") # 셈 끝(338 숫자 판정) + before = sorted(p.name for p in folder.rglob("*") if p.is_file()) + lines = [ + {**line, "차례": no * 1000 + line["차례"]} # 호표 여럿 줄 한 번에(차례 = 열쇠만) + for no in range(1, 11) + for line in client.get(f"/api/m02/layers/personal/hopyo/HP-000001/{no}").json()["호표"][ + "줄" + ] + ] + drafts = {x["차례"]: x for x in _drafts(lines)} + sure = [x for x in lines if x["맞춤"]["체크"] and any("로직" in p for p in x["맞춤"]["대상"])] + assert len(sure) >= 5, [x["맞춤"]["상태"] for x in lines] + for line in sure: + draft = drafts[line["차례"]] + assert draft["doc"] is not None, line["이름"] + res = client.post(CALC, json={"doc": draft["doc"], "수량": {}}) + assert res.status_code == 200, (line["이름"], res.text) + got = res.json() + if draft["table"] == "logic": + row = draft["doc"]["담은로직"][0] + part = line["맞춤"]["대상"][0] + assert row["수량"]["손"] == line["수량"] * part["배"] + assert got["반영합"]["계"] > 0, line["이름"] + for line in lines: # 자재 · 별도계상 줄(대상 없음) = 자재 표 + if line["갈래"] in ("자재", "별도계상") and not line["맞춤"]["대상"]: + assert drafts[line["차례"]]["table"] == "mat" + after = sorted(p.name for p in folder.rglob("*") if p.is_file()) + assert after == before # 미리 셈 = 저장 안 함 diff --git a/resources/tester/test_m02_hopyo_golden.py b/resources/tester/test_m02_hopyo_golden.py index 1006a4aa4..76a4a915c 100644 --- a/resources/tester/test_m02_hopyo_golden.py +++ b/resources/tester/test_m02_hopyo_golden.py @@ -80,3 +80,18 @@ def test_씨앗_끔_1순위(no_seeds): def test_원본_그대로_셈_무게(run): assert master_hash() == run["원본"] assert run["결과"]["시간"]["북부청임도"] <= 300 # 북부청 임도 맞춤 셈 ≤ 5 분 + + +def test_이름후보_묶음_덧(run): + """358-1 — 이름후보(옛 324 다섯) = 묶음 덧 · 숫자 차례 그대로 → 다섯 안 ∪ 이름후보 ≥ 다섯 안.""" + have = [ + r for r in run["결과"]["줄"].values() if r["lab"]["무리"] == "사람" and r["lab"]["정답"] + ] + got = hm.targets() + five = [set(r["셈"]["차례"][:5]) for r in have] + named = [{c["로직"] for c in hm.name_cands(r["lab"], got)} for r in have] + near = sum(bool(f & set(r["lab"]["정답"])) for f, r in zip(five, have, strict=True)) + both = sum( + bool((f | n) & set(r["lab"]["정답"])) for f, n, r in zip(five, named, have, strict=True) + ) + assert both >= near >= 122 diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index e593dd4d1..adc1a1ba7 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -245,3 +245,26 @@ def test_뜻_낱말이_안_겹치면_후보_아님(): assert got["상태"] == "없음" and got["top"] == [] fill = {"이름": "구조물되메우기(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"} assert hm.candidates(fill, "로직")[0]["이름"] == "되메우기" # 한쪽 낱말에 듦 = 겹침 + + +# ── 이름 닮은 로직(358-1 · 옛 324 데스크탑 다섯 그대로) ───────────────────── +def test_이름후보_옛_데스크탑_다섯(): + fill = {"이름": "구조물되메우기(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"} + got = [c["로직"] for c in hm.name_cands(fill)] + assert got == ["GF000113", "GF000194", "GC000108", "GC000109", "GC000451"] # 되메우기 다섯 + dowel = {"이름": "다웰바설치", "규격": "Φ32×1000", "단위": "EA", "갈래": "단가산출"} + assert [c["로직"] for c in hm.name_cands(dowel)][:3] == ["GC000226", "GF000201", "GF000202"] + soil = {"이름": "잔토처리(인력100%)", "규격": "", "단위": "㎥", "갈래": "단가산출"} + assert hm.name_cands(soil) == [] # 뜻 낱말 안 겹치면 후보 아님 그대로 + same = {"이름": "되메우기", "규격": "", "단위": "㎥", "갈래": "단가산출"} + assert hm.name_cands(same)[0] == {**hm.name_cands(same)[0], "로직": "GF000113", "점수": 9} + + +def test_이름후보_뜻겹침_출처_표시(client): + _ready(client) + dig = _hopyo(client, 3)["구조물터파기(인력100%)"] + assert dig["이름후보"] and all({"뜻겹침", "출처같음", "책"} <= set(c) for c in dig["이름후보"]) + assert dig["후보"][0]["출처같음"] is True and dig["후보"][0]["책"] == "건설품셈" # 3-2-1 + assert [c["로직"] for c in dig["후보"]][0] == "GC000083" # 차례 · 판정 그대로(표시만) + mat = _hopyo(client, 1)["레미콘(25-21-120)"] + assert "이름후보" not in mat # 자재 줄은 로직 이름후보 없음 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index c3b7b89ff..00e50f37b 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -26,6 +26,7 @@ NEW = ( "Hopyo_Side", "Hopyo_Sheet", "Hopyo_Check", + "Hopyo_CheckRows", "Hopyo_Choose", "Hopyo_Ledger", ) @@ -125,7 +126,7 @@ def test_확인_창_체크한_줄만_몸(): ] assert got["leftOut"] == {"확인 안 함": 3, "뺌": 1, "% 줄": 1} assert got["checkable"] == [True, True, True, True, False, False, True, False] - assert got["sure"] == [1, 2] # [맞음 · 별칭 모두 체크] + assert got["sure"] == [1, 2] # [맞음 · 별칭 일괄 체크] assert got["compare"] == "콘크리트공 0.1091 → 0.1091 ✓ · 굴착기 0.29 → 0.31 ≠" assert got["compareMany"] == "굴착기 0.1594 → 0.0889 ≠" # 같은 장비 여러 줄 = 합쳐 한 줄 assert got["toTarget"] == [{"호표": 46, "배": 2.3}, {"자재": "MT1", "배": 1}] @@ -135,7 +136,16 @@ def test_확인_창_체크한_줄만_몸(): def test_고르기_창_도우미(): got = _check()["choose"] - assert got["hint"] == ["6-1-1", "고임돌"] # 인용 조항 번호 → 없으면 품명 첫 뜻 낱말 + assert got["hint"] == ["레미콘타설", "고임돌"] # 품명 첫 뜻 낱말(358-1 · 조항 번호 아님) + assert got["groups"]["groups"] == [ + ["1위", False, ["A"]], + ["출처 같음", False, ["C"]], + ["이름 닮은 로직", False, ["E"]], + ["숫자만 닮음", True, ["D", "B"]], # 뜻 낱말 안 겹치는 「후보」 = 끝 + ] + assert got["groups"]["text"][:2] == ["되메우기 · A · 산림", "되메우기 · C · 건설"] + assert got["groups"]["material"] == ["1위", "그 밖 후보"] + assert got["groups"]["pick"] == [True, False, True] # 재료비 있는 단가산출 줄도 자재 검색 assert got["picked"] == [ {"로직": "GC000206", "입력값": {}, "배": 1, "몫": None}, {"로직": "GF000211", "입력값": {}, "배": 0.1, "몫": 0.1}, @@ -282,7 +292,8 @@ def test_옛_즉시_확정과_바로_만들기_없음(): users = sorted(name for name, text in texts.items() if "buildHopyo(" in text) assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 엑셀 보기 [저장] 물음 뒤 check = texts["Hopyo_Check"] - assert '"확인하고 사전조합 만들기"' in check and "맞음 · 별칭 모두 체크" in check + assert '"사전조합 생성"' in check and "맞음 · 별칭 일괄 체크" in check + assert "확인하고" not in check # 359-4 명사형 assert "showLoadingOverlay()" in check and "dropDraft(k)" in check assert "m02-hopyo-check:" in check and "POLL_MS" in check sheet = texts["Hopyo_Sheet"] @@ -352,7 +363,8 @@ def test_엑셀_보기_창_여는_자리와_늦게_불러옴(): assert "await showSheet(got);" in side # 올린 직후 자동 assert lazy in side.split("async function showSheet(")[1].split("async function showLedger(")[0] check = _text("M02_MasterTemplete_Hopyo_Check.ts") - assert lazy in check and '"엑셀에서 보기"' in check and "호표: opt.no" in check + assert lazy in check and 'label: "엑셀 보기"' in check and "호표: opt.no" in check + assert "엑셀에서 보기" not in check assert "단산행: 행" in check pick = _text("M02_MasterTemplete_Hopyo_Pick.ts") assert lazy in pick @@ -398,16 +410,14 @@ def test_확인_창_손질_339(): ② 만들기 뒤 좌측 사전조합 목록 다시 ③ 엑셀 보기 창 목록 셈 끝까지 다시 읽음 · 펼침 표 숫자 판정 칸.""" css = _text("M02_MasterTemplete_Hopyo.css") assert "word-break: keep-all;" in css and "white-space: pre-line;" in css - assert ( - ".m02-hopyo__lines .m02-hopyo__acts > *" in css - and "text-overflow" - not in css.split(".m02-hopyo__lines td:is(:nth-child(9)")[-1].split("}")[0] - ) + assert ".m02-hopyo__lines .m02-hopyo__acts > *" in css + assert "nth-child(9)" not in css # 359-3 — 칸 차례 대신 칸 이름 assert ".m02-hopyo-ledger__table td:nth-child(1)" in css assert "m02-hopyo-ledger__table" in _text("M02_MasterTemplete_Hopyo_Ledger.ts") + rows = _text("M02_MasterTemplete_Hopyo_CheckRows.ts") + assert r'text: compare.split(" · ").join("\n")' in rows # 품 비교 = 항목마다 한 줄 + assert "m02-hopyo__opts" in rows # 옵션 = 항목마다 한 줄 check = _text("M02_MasterTemplete_Hopyo_Check.ts") - assert r'td(opts.join("\n"))' in check and r'.split(" · ")' in check - assert r'.join("\n"),' in check # 품 비교 = 항목마다 한 줄(판정 글 함수는 그대로) make = check.split("async function make(")[1].split("function builtBlock(")[0] assert make.index("document.dispatchEvent(new Event(HOPYO_BUILT));") < make.index("opt.onBuilt") side = _text("M02_MasterTemplete_UI_Side.ts") @@ -431,3 +441,70 @@ def test_엑셀_보기_머리_막대_셈_끝까지_다시_읽음_359(): assert drop.index("deleteHopyo(") < drop.index("modal.close();") < drop.index("opt.onDeleted") poll = sheet.split("function poll(")[1].split("async function load(")[0] assert "paintHead(next);" in poll and "paintList();" in poll and "poll(next);" in poll + + +def test_확인_창_표_일위대가_상세_꼴_359(): + """359-3 — 로직 표 · 자재 표(일위대가 상세 꼴) + 품 비교 · 단추 · 드롭다운 글(저장 값 그대로) · + 금액 = 줄마다 서버 calc 미리 셈(`logic/` 문서 꼴 · Build 와 같은 수량 규칙).""" + got = _check()["draft"] + assert got["logic"] == { + "종류": "일위대가", + "열": "", + "단위": None, + "담은로직": [ # 같은 로직 둘째 · 호표 · 배 없음 = 뺌 + {"로직": "GC000198", "별칭": None, "메모": None, "입력값": {"소량감률": 50}, + "수량": {"손": 1}}, + ], + "자재": [], + } # fmt: skip + assert got["mat"]["자재"] == [ # 같은 자재 = 2 × 1 + 2 × 0.5 + {"코드": "MT1", "이름": None, "규격": None, "단위": None, "수량": {"손": 3}, + "별도계상": "별도계상"}, + ] # fmt: skip + assert got["direct"]["별도계상"] == "직접계산" and got["direct"]["수량"] == {"손": 0} + assert got["none"] == [None, None] + assert got["table"] == ["mat", "logic", "logic", "mat", "logic"] + rows = _text("M02_MasterTemplete_Hopyo_CheckRows.ts") + assert '["", "추가"],' in rows and '["우리에 없음", "로직없음"],' in rows + assert '["안 씀", "사용안함"],' in rows and "뺌 — " not in rows + for word in ('"고르기"', '"원문 보기"', '"엑셀 보기"', '"옵션"', '"품 비교"'): + assert word in rows, word + assert 'import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";' in rows + assert "...markCells({ 별도계상:" in rows and "...markHeads()," in rows + for head in ("Col_Order", "Sub", "Col_Name", "Col_Alias", "Labor_QtyCol", "Col_Unit"): + assert f'tc("{head}")' in rows, head + check = _text("M02_MasterTemplete_Hopyo_Check.ts") + assert "calcLabor(target.layer, DRAFT_COLUMN, doc, {}, target.owner)" in check + assert "checkTables(h.줄, ctx())" in check and "draftDoc(line, partsOf(line, d))" in check + assert "function lineRow(" not in check and "function partCell(" not in check + draft = _text("logic/M02_MasterTemplete_Hopyo_Draft.ts") + assert "M02_Hopyo_Build" in draft # 정본 짝 표시 + code = draft.split("*/", 1)[1] # 머리 글 밖 + for gone in ("document", "fetch(", "sessionStorage", "@ui/"): + assert gone not in code, gone + + +def test_명사형_단추와_별칭_장부_풀이_359(): + """359-4 — 별칭 장부 뜻 한 줄(좌측 단추 title · 화면 머리) · M02 실무자료 단추 = 명사형 · + 갈래 글 = 확인 창 드롭다운과 같음(저장 값 그대로).""" + ledger = _text("M02_MasterTemplete_Hopyo_Ledger.ts") + hint = "엑셀 품명 ↔ 우리 로직 · 자재 짝을 기억해 두었다가 같은 품명이 나오면 먼저 제안" + assert hint in ledger and "note(`${LEDGER_HINT}" in ledger + side = _text("M02_MasterTemplete_Hopyo_Side.ts") + assert "title: LEDGER_HINT" in side + for label in ('"변경 취소"', '"로직 추가"', '"내 층 복사"', '"삭제"'): + assert f"label: {label}" in ledger, label + assert '"우리에 없음": "로직없음"' in ledger and '"안 씀": "사용안함"' in ledger + mine = ("Hopyo_Side", "Hopyo_Sheet", "Hopyo_Check", "Hopyo_CheckRows", "Hopyo_Ledger") + every = "\n".join(_text(f"M02_MasterTemplete_{n}.ts") for n in mine) + for gone in ( + "고친 것 버림", + "로직 더함", + "내 층으로 고쳐 씀", + "확인하고 사전조합 만들기", + "엑셀 올리기", + "엑셀에서 보기", + "모두 체크 (", + 'label: "지우기"', + ): + assert gone not in every, gone diff --git a/ui_template/ui_template_contour3d.ts b/ui_template/ui_template_contour3d.ts new file mode 100644 index 000000000..d8411d452 --- /dev/null +++ b/ui_template/ui_template_contour3d.ts @@ -0,0 +1,49 @@ +/* ============================================================================= + * ui_template_contour3d.ts + * 3D 등고선 선 재질 — 평면 그림(노선 편집)과 같은 갈래 · 같은 색 토큰(353-3). + * + * 주곡선 = 5 m 배수 고정(간격 무관 · 평면 `B05_Profile_UI_RouteEdit_Render` 와 같음) · + * 색 `--map-contour-major` 진하게 · 나머지(1 m) = `--map-contour-minor` 옅게 · 낮은 불투명. + * B05 3D(`B05_Profile_UI_Viewer`) · B04 3D(`B04_PreProcess_UI_TerrainViewer_Contours`) 가 함께 씀. + * ========================================================================== */ + +import * as THREE from "three"; +import { themeColor } from "./ui_template_palette"; + +/** 주곡선 간격(m) — 평면 그림과 같은 5 m. */ +export const CONTOUR_MAJOR_STEP_M = 5; + +/** 5 m 배수 높이면 주곡선. */ +export function isMajorContour(level: number): boolean { + return Math.abs(level - Math.round(level / CONTOUR_MAJOR_STEP_M) * CONTOUR_MAJOR_STEP_M) < 1e-6; +} + +/** CSS 색(#rrggbb · rgba(...)) → 색 + 불투명(알파 없으면 1). */ +function parseColor(value: string): { color: THREE.Color; alpha: number } { + const rgba = value.match(/rgba?\(([^)]+)\)/); + if (!rgba) return { color: new THREE.Color(value), alpha: 1 }; + const [r, g, b, a] = rgba[1].split(",").map((part) => Number(part.trim())); + return { color: new THREE.Color(r / 255, g / 255, b / 255), alpha: Number.isFinite(a) ? a : 1 }; +} + +/** 주곡선 · 1 m 선 재질 — 1 m 는 주곡선보다 옅게. */ +export function contourMaterials(): { + major: THREE.LineBasicMaterial; + minor: THREE.LineBasicMaterial; +} { + const major = parseColor(themeColor("--map-contour-major", "#818cf8")); + const minor = parseColor(themeColor("--map-contour-minor", "rgba(165, 180, 252, 0.45)")); + return { + major: new THREE.LineBasicMaterial({ + color: major.color, + transparent: true, + opacity: 0.95 * major.alpha, + }), + minor: new THREE.LineBasicMaterial({ + color: minor.color, + transparent: true, + opacity: minor.alpha, + depthWrite: false, + }), + }; +}