Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_3', 'origin/sub_laptop_4', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into sub_laptop_5

This commit is contained in:
43 changed files with 3073 additions and 414 deletions
+2
View File
@@ -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 => {
@@ -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));
});
+11 -2
View File
@@ -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<ReturnType<typeof loadBackground>>): 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
@@ -80,7 +80,7 @@ export function createRouteEditChrome(
aria-pressed="false"
title="곡선(호) 안 측점 하나를 곡선 중점(MC)으로 옮깁니다 — [저장] 때 저장">측점 스냅</button>
<button type="button" class="ui-btn ui-btn--glass" data-act="footprint"
title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 파랑)을 평면에 겹칩니다">발자국</button>
title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 파랑)을 평면에 겹칩니다">절/성토선</button>
<i class="b05-route__view-separator" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--glass" data-act="undo"
title="되돌리기 (Ctrl+Z)" aria-label="되돌리기" disabled>↶</button>
@@ -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 = `<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<rect x="3" y="7" width="10" height="7" rx="1.5" fill="currentColor"/>
<path class="b05-contourlock__shackle" d="M5 7V5a3 3 0 0 1 6 0v2" fill="none"
stroke="currentColor" stroke-width="1.6"/></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,
);
},
};
}
@@ -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<boolean> {
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));
}
@@ -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;
}
+8 -8
View File
@@ -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),
);
});
}
@@ -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<number | null>;
/** 자물쇠 단추 — 보일지 · 눌린 모양인지. */
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;
+253 -45
View File
@@ -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<Record<string, number>>, 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<number>(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<Side, boolean[]>;
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<Record<string, number>>;
if (route.length < 2 || !records.length) return null;
const byChainage = new Map<number, Record<string, number>>();
.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<number, FootprintRecord>();
for (const record of records) byChainage.set(roundTo(record.chainage_m, 3), record);
const samples = new Set<number>();
const base = new Set<number>();
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<Side, boolean[]> = { 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<string, number> => {
const values: Record<string, number> = {};
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<Side, boolean[]> = {
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 };
@@ -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<Vertex>;
/** 그려진 계획노선(원호 펴진 폴리라인) — 측점 누가거리의 기준. */
line: ReadonlyArray<Vertex>;
/** 곡선표 — `node_first` = 그 곡선 교각점 노드 자리. */
curves: ReadonlyArray<CurveEnds & { node_first: number }>;
/** 노드마다 고정. */
locked: ReadonlyArray<boolean>;
chainageM: number;
/** 좌(+) · 우(−) 이동(m). */
deltaM: number;
}
/** 점에서 가장 가까운 노드 토막 차례(앞 노드 자리). */
function nearestSegment(nodes: ReadonlyArray<Vertex>, 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);
}
@@ -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);
@@ -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),
};
}
+151
View File
@@ -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<string | null>;
let host: RouteShiftHost | null = null;
const waiters = new Set<(ready: RouteShiftHost | null) => void>();
let opener: (() => Promise<void>) | 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<void>) | null, toast?: Notice): void {
opener = next;
if (toast) notice = toast;
}
async function readyHost(): Promise<RouteShiftHost | null> {
if (host) return host;
const waiting = new Promise<RouteShiftHost | null>((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<boolean> {
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<SVGElement>("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 — 놓으면 반영`;
}
+68 -26
View File
@@ -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<CrossSection, "station_id" | "chainage_m" | "design">,
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<CrossDesignChange>) =>
@@ -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);
@@ -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",
@@ -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);
@@ -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
+25 -3
View File
@@ -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:
+21 -3
View File
@@ -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
+39 -13
View File
@@ -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);
@@ -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<string, boolean> };
@@ -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<number, { sig: string; got: LinePrice }>();
let pending = false;
const keep = (): void => saveDraft(k, d);
async function load(quiet = false): Promise<void> {
@@ -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<void> {
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}호표`,
@@ -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;
}
@@ -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<HopyoLine, "이름" | "근거">): string {
const clause = line.근거?.출처?.find((x) => x[1])?.[1];
if (clause) return clause;
/** [다른 로직 검색] 미리 넣을 글 — 품명 괄호 밖 첫 뜻 낱말(358-1 · 조항 번호는 모든 낱말 맞춤이라 0건 위험) */
export function searchHint(line: Pick<HopyoLine, "이름">): 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<HopyoMatch, "후보" | "이름후보">,
material = false,
): CandGroup[] {
const all = m.후보 ?? [];
const seen = new Set<string>();
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<string> {
const count = new Map<string, number>();
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>): string =>
`${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}${twins.has(c.이름 ?? "") && c.책 ? ` · ${c.책.slice(0, 2)}` : ""}`;
/** 자재 고름 — 자재 · 별도계상 줄 · 재료비 있는 단가산출 줄(358-2 · P.V.C파이프설치 등) */
export const canPickMaterial = (line: Pick<HopyoLine, "갈래" | "단가">): 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<HopyoTarget, HTMLInputElement>();
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] }),
);
},
});
@@ -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;
@@ -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<HopyoLine, "갈래">, 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<HopyoLine, "수량" | "갈래">,
parts: HopyoPart[],
): LaborDoc | null {
const rows: LaborRow[] = [];
const mats = new Map<string, LaborMat>();
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()] };
}
@@ -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));
+227
View File
@@ -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);
});
@@ -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] },
@@ -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(),
@@ -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
+10 -2
View File
@@ -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"]) # 깎기 · 쌓기 섞임
@@ -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 ">절/성토선</button>" in chrome[at : at + 200] and ">발자국<" not in chrome
@@ -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 # 페이지 동안만
@@ -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
+72
View File
@@ -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"
)
@@ -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
@@ -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 # 미리 셈 = 저장 안 함
+15
View File
@@ -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
+23
View File
@@ -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 # 자재 줄은 로직 이름후보 없음
+88 -11
View File
@@ -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
+49
View File
@@ -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,
}),
};
}