Merge remote-tracking branch 'origin/dev' into sub_desktop_1

This commit is contained in:
2026-10-10 11:57:59 +09:00
89 changed files with 2859 additions and 334 deletions
+4 -4
View File
@@ -622,12 +622,12 @@ export function createDesignProfile(ctx: DesignProfileContext) {
syncDrainageSpans();
draw();
},
/** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */
setSelectedStructure(structureId: string | null) {
// 지금 측점(B06 카드)이 그 구조물 자리면 측점선도 그대로(53-2)
/** 고른 구조물에 알약 · 측점선을 맞춤 — 그 자리면 선 그대로(53-2) · keepStation(B06 카드 = 선 주인) = 선 없는 구조물 · 풂도 카드 선 지킴(177-3). */
setSelectedStructure(structureId: string | null, keepStation = false) {
selectedStructureId = structureId;
const own = stationIdAtStructure(structureId) ?? (keepStation ? selectedStationId : null);
const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId;
selectedStationId = keep ? selectedStationId : stationIdAtStructure(structureId);
selectedStationId = keep ? selectedStationId : own;
draw();
},
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
@@ -41,7 +41,6 @@ import {
import { buildSummaryRow } from "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row";
import type { GroupItems, SummaryItem } from "../../common_util/common_util_summary_item";
import { followPipeHeights } from "../../common_util/common_util_culvert_sets";
import { createCheckbox } from "../../ui_template/ui_template_checkbox";
/** 잇기 값 자리 — 패널 그룹 값(`group_items`)을 그대로 씀. */
export interface SpecLinks {
@@ -144,13 +143,6 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
select.value = tag ? "" : now;
return select;
}
if (item.kind === "bool") {
// 빈 값 = 기본값으로 체크(173-7) · 듣개가 `.value` 를 읽음 — 바뀔 때 "true" / "false"
const box = createCheckbox("", { checked: (values[item.id] || item.default) === "true" });
box.input.value = String(box.input.checked);
box.input.addEventListener("change", () => (box.input.value = String(box.input.checked)));
return box.root;
}
if (item.kind === "text") {
const input = document.createElement("input");
input.type = "text";
+6 -2
View File
@@ -70,11 +70,15 @@ export const PIPE_SPEC: StructureSpec = {
[BASIN]: { form: "inlet_structure", height: "inlet_basin_height_m" },
[SOIL]: revetKeys("inlet"),
}),
// 집수정만 · 빈 값 = 참(키 없는 옛 관도 동물이동형 · 173-7) · 저장만(B07 · B08 안 읽음)
// 집수정만 · 유 / 무 = 저장 "true" / "false" · 빈 값 = 유(필수 상자 기본 채움 · 177-2) · 저장만(B07 · B08 안 읽음)
{
id: "animal",
label: "동물이동형",
kind: "bool",
kind: "select",
choices: [
{ value: "true", label: "유" },
{ value: "false", label: "무" },
],
default: "true",
by: { item: "structure", map: { [BASIN]: { key: "inlet_basin_animal" } } },
},
+1 -2
View File
@@ -32,8 +32,7 @@ export type SplitRole = "length" | "before" | "after";
export interface SpecItem {
id: string;
label: string;
/** bool = 체크박스 · 저장 글 "true" / "false" · 빈 값 = `default`(173-7). */
kind: "select" | "number" | "text" | "auto" | "bool";
kind: "select" | "number" | "text" | "auto";
/** 저장 키(필수 컨테이너 · `by` 없을 때). */
key?: string;
choices?: SpecChoice[];
+5 -4
View File
@@ -115,13 +115,14 @@ export async function mountCrossView(
const crossHalfWidthField = crossView.field;
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤 — 가장 가까운 카드로 간다. */
const focusStationAt = (chainageM: number): void => {
const focusStationAt = (chainageM: number, revealOnly = false): void => {
const sections = sectionDetail?.cross_sections;
if (!sections?.length) return;
const nearest = sections.reduce((best, entry) =>
Math.abs(entry.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM) ? entry : best,
);
sectionView.focusStation(nearest.station_id);
if (revealOnly) sectionView.revealStation(nearest.station_id);
else sectionView.focusStation(nearest.station_id);
};
/** 그 누가거리를 가진 측점 카드(관 찾는 거리 한 값). */
const ownerAt = (chainageM: number) =>
@@ -186,8 +187,8 @@ export async function mountCrossView(
stationInterval: () => stationInterval ?? 20,
// 벽·구체를 누르는 중이면 그 누름이 카드를 고른다 — 폼이 구조물을 열며 카드를 먼저
// 고르면 뒤이은 카드 선택이 「같은 측점 재클릭 = 해제」로 먹힌다.
focusChainage: (chainageM) => {
if (!isWallSelecting()) focusStationAt(chainageM);
focusChainage: (chainageM, revealOnly) => {
if (!isWallSelecting()) focusStationAt(chainageM, revealOnly);
},
releaseCard: () => releaseCross(),
markStructure: (structureId) => sectionView.selectMark(structureId),
+2 -23
View File
@@ -206,10 +206,9 @@ export async function mountProfileView(
let selectionSyncing = false;
/* ── 측점 상단측(=측구 방향) 사용자 변경분 ─────────────────────────────
* solve가 자동 판정한 uphill_side를 3D 램프 클릭으로 바꾼 값. 세션에 보관했다가
* 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다. */
* solve가 자동 판정한 uphill_side를 사용자가 바꾼 값. 세션에 보관했다가
* 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */
const uphillOverrides = loadUphillOverrides(activeProjectId);
const uphillKey = (chainage: number): string => chainage.toFixed(3);
const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({
stations: () => bridge.irregularStations(),
@@ -356,25 +355,6 @@ export async function mountProfileView(
profilePanel.setSelectedStation(stationId);
syncIrregularSelection(stationId);
});
// 3D 램프 클릭 → 그 측을 상단측(측구 방향)으로 지정하고 세션 보관 + 램프 재렌더.
viewer.markers.onUphillPick((stationId, side) => {
if (!currentSectionDetail) return;
const base = currentSectionDetail.longitudinal.stations.find(
(station) => station.station_id === stationId,
);
let chainage = base?.chainage_m;
if (chainage === undefined) {
const prefix = irregularStationId("");
if (stationId.startsWith(prefix)) {
const id = stationId.slice(prefix.length);
chainage = bridge.irregularStations().find((entry) => entry.id === id)?.chainage_m;
}
}
if (chainage === undefined) return;
uphillOverrides.set(uphillKey(chainage), side);
saveUphillOverrides(activeProjectId, uphillOverrides);
renderStationLines(currentSectionDetail);
});
viewer.root.append(panel.viewControls);
function restorePanel(next: RouteLatestResponse): void {
@@ -389,7 +369,6 @@ export async function mountProfileView(
routePoints: latest?.route_points ?? [],
liveSamples: profilePanel.alignmentSamples(),
corridorVisible,
uphillOverrides,
});
viewer.renderStationLines(lines, roadWidths[panel.values().gradeClass] / 2);
}
+15 -1
View File
@@ -69,6 +69,8 @@ const POINT_GAP_M = 0.3;
// 늘리면 더 매끈 · 더 뜸.
const LIFT_SIGMA_M = 3;
const LIFT_CAP_M = 0.5;
/** 성토 덩이 끝(성토 아닌 행)에서 이 누가거리 안은 뜸을 0 까지 줄인다(smoothstep · 176-1 · 절토와 톱니 없게). */
const LIFT_TAPER_M = 3;
/** 구조물 구멍 둘레 이만큼 안 격자 점은 뺀다(구멍 둘레 실오라기 막음 · m). */
const HOLE_CLEAR_M = 0.3;
/** 구조물 구멍 둘레 높이 찾기 — 노면부 끝에서 이 너머는 원지반(m). */
@@ -348,9 +350,21 @@ export const loftDesign: TinDesigner = (build, groundAt, bounds) => {
(r) => fill(r) && stationAt(chainages[r]) >= 0,
fill,
);
// 가장 가까운 성토 아닌 행까지 누가거리(앞 · 뒤) — 덩이 끝 줄이기.
const gap = new Float64Array(n).fill(Infinity);
for (let r = 0, last = -Infinity; r < n; r += 1) {
if (!fill(r)) last = chainages[r];
else gap[r] = chainages[r] - last;
}
for (let r = n - 1, next = Infinity; r >= 0; r -= 1) {
if (!fill(r)) next = chainages[r];
else gap[r] = Math.min(gap[r], next - chainages[r]);
}
for (let r = 0; r < n; r += 1) {
if (!fill(r)) continue;
toeOff[si][r] = Math.max(-LIFT_CAP_M, Math.min(LIFT_CAP_M, smooth[r] - raw[r]));
const t = Math.min(1, gap[r] / LIFT_TAPER_M);
const off = Math.max(-LIFT_CAP_M, Math.min(LIFT_CAP_M, smooth[r] - raw[r]));
toeOff[si][r] = off * t * t * (3 - 2 * t);
raw[r] += toeOff[si][r];
}
});
+10 -8
View File
@@ -38,8 +38,9 @@ const FALLBACK_RGB = [0.6, 0.6, 0.6];
/** 꼬리표 없는 설계 삼각형을 절성토로 칠할 높이 차(m). */
const TINT_MIN_M = 0.01;
const GRID_M = 2;
/** 설계 구역 · 구멍 둘레에 이만큼 붙은 지형 정점은 안으로 본다(m). */
const ON_BOUNDARY_M = 1e-3;
/** 설계 구역 · 구멍 둘레에서 이만큼 안 지형 정점은 빼고 잇는다(m · 176-1) — 비탈 끝 뜸이 짧은 턱 · 홈이 되지 않게
* 잇는 띠를 1.5~2.5 m 로 늘림. 변 판정(boundary.touches)은 그대로. */
export const SEAM_M = 1.5;
/** 노면부 띠 — 행마다 좌우 노면 끝 사이 사각형(리본이 덮는 자리 · 망에서 뺀다). */
class RoadStrip extends CellRegion {
@@ -88,8 +89,8 @@ function harvestPart(
if (!state[v]) {
const x = position.getX(v);
const z = position.getZ(v);
// 경계 선 위 정점도 안으로(제약이 점을 관통하지 않게).
state[v] = inRegion(x, z) || boundary.passesNear(x, z, ON_BOUNDARY_M) ? 1 : 2;
// 경계 선 위 · 둘레 SEAM_M 안 정점도 안으로(제약이 점을 관통하지 않게 · 잇는 띠 완만).
state[v] = inRegion(x, z) || boundary.passesNear(x, z, SEAM_M) ? 1 : 2;
}
return state[v] === 1;
};
@@ -209,11 +210,12 @@ export function tinTerrain(
segment += 1;
}
});
// SEAM_M 만큼 넓힘 — 둘레 띠 안 정점을 쓰는 삼각형이 상자 밖이라 남아 틈이 되지 않게.
const box = {
minX: Math.min(outer.minX, holes.minX),
maxX: Math.max(outer.maxX, holes.maxX),
minZ: Math.min(outer.minZ, holes.minZ),
maxZ: Math.max(outer.maxZ, holes.maxZ),
minX: Math.min(outer.minX, holes.minX) - SEAM_M,
maxX: Math.max(outer.maxX, holes.maxX) + SEAM_M,
minZ: Math.min(outer.minZ, holes.minZ) - SEAM_M,
maxZ: Math.max(outer.maxZ, holes.maxZ) + SEAM_M,
};
const root = new THREE.Group();
+2 -47
View File
@@ -32,8 +32,6 @@ export interface SectionStationMarker {
center_x: number;
center_y: number;
center_z: number | null;
/** 상단측(등고 높은 쪽) — 램프 컬러 표시용. Page가 사용자 변경분을 반영해 넘긴다. */
uphill_side?: "left" | "right" | null;
frame: { left_xy: [number, number] };
/** 측점 종류(`bp`/`ep`/`regular`/`irregular`) — 어느 측점에 라벨을 달지 가르는 기준. */
kind?: string;
@@ -54,10 +52,6 @@ const LABEL_LOD: ReadonlyArray<{ within: number; step: number }> = [
{ within: Infinity, step: 5 },
];
// 측점 바 양 끝 원형 램프 색: 상단(등고 높은 쪽) 예상측=주황, 반대측=회색.
const UPHILL_LAMP_COLOR = 0xf97316;
const UPHILL_LAMP_INACTIVE_COLOR = 0x9ca3af;
const COLORS: Record<RoutePointKind, number> = {
bp: 0x10b981,
ep: 0xef4444,
@@ -124,7 +118,6 @@ export function createRouteMarkers(
let changeListener: ((points: RouteDesignPoints) => void) | undefined;
let selectionListener: ((point: PlacedRoutePoint | null) => void) | undefined;
let stationSelectionListener: ((stationId: string | null) => void) | undefined;
let uphillPickListener: ((stationId: string, side: "left" | "right") => void) | undefined;
let selectedStationId: string | null = null;
function allPoints(): PlacedRoutePoint[] {
@@ -423,7 +416,7 @@ export function createRouteMarkers(
.map((entry) => ({ chainage_m: entry.chainage_m as number })),
);
// 측점선(노란 띠)은 도로 반폭의 1.5배로 그린다 — 지형에 묻혀 짧아 보이는 문제
// (2026-08-19 사용자 지시). 측구 방향 램프는 늘어난 선의 **끝단**에 따라붙는다.
// (2026-08-19 사용자 지시).
const barHalfWidth = halfWidth * 1.5;
stations.forEach((station) => {
if (station.center_z === null) return;
@@ -456,39 +449,12 @@ export function createRouteMarkers(
bar.userData.stationId = station.station_id;
stationGroup.add(bar);
// 측점 바 양 끝 원형 램프: 상단(등고 높은 쪽) 예상측 컬러, 반대측 회색.
// 클릭하면 그 측을 상단측(=측구 방향)으로 지정한다(onUphillPick).
const label = stationLabelText(station, stationIntervalM);
if (label) {
const sprite = stationLabelSprite(label.text, modelToScene(center, bounds));
sprite.userData.stationNumber = label.number;
stationLabelGroup.add(sprite);
}
(["left", "right"] as const).forEach((side, endIndex) => {
const active = station.uphill_side === side;
// 램프 절반 축소(1.1→0.55) — 측점 고를 때 가리는 문제(2026-08-06 보고).
const lamp = new THREE.Mesh(
new THREE.SphereGeometry(0.55, 14, 10),
new THREE.MeshBasicMaterial({
color: active ? UPHILL_LAMP_COLOR : UPHILL_LAMP_INACTIVE_COLOR,
}),
);
lamp.position.copy(points[endIndex]);
lamp.position.y += 0.3;
lamp.userData.uphillStationId = station.station_id;
lamp.userData.uphillSide = side;
stationGroup.add(lamp);
// 클릭 판정은 예전 크기 그대로 — 투명 히트 구를 겹쳐 "작게 보여도 잘 눌리게".
const lampHit = new THREE.Mesh(
new THREE.SphereGeometry(1.1, 8, 6),
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }),
);
lampHit.position.copy(lamp.position);
lampHit.userData.uphillStationId = station.station_id;
lampHit.userData.uphillSide = side;
stationGroup.add(lampHit);
});
});
// 새로 만든 라벨에도 지금 솎기 단계를 그대로 먹인다.
applyLabelStep();
@@ -527,7 +493,7 @@ export function createRouteMarkers(
function selectStation(stationId: string | null): void {
selectedStationId = stationId;
stationGroup.children.forEach((object) => {
// 측점 띠(stationId 보유 Mesh)만 색을 바꾼다 — 램프(uphill*)는 제외.
// 측점 띠(stationId 보유 Mesh)만 색을 바꾼다.
if (!(object instanceof THREE.Mesh) || typeof object.userData.stationId !== "string") return;
const selected = object.userData.stationId === selectedStationId;
(object.material as THREE.MeshBasicMaterial).color.set(selected ? 0xef4444 : 0xfacc15);
@@ -553,14 +519,6 @@ export function createRouteMarkers(
updateSelected,
deleteSelected,
selectObject(object: THREE.Object3D | undefined) {
// 상단측 램프 클릭: 해당 측을 측구 방향으로 지정(측점 선택보다 우선 판정).
if (typeof object?.userData.uphillStationId === "string") {
uphillPickListener?.(
object.userData.uphillStationId,
object.userData.uphillSide as "left" | "right",
);
return;
}
if (typeof object?.userData.stationId === "string") {
selectStation(object.userData.stationId);
selectionListener?.(null);
@@ -610,9 +568,6 @@ export function createRouteMarkers(
onStationSelectionChange(listener: (stationId: string | null) => void) {
stationSelectionListener = listener;
},
onUphillPick(listener: (stationId: string, side: "left" | "right") => void) {
uphillPickListener = listener;
},
dispose() {
disposeGroup(interactionGroup);
disposeGroup(routeGroup);
@@ -17,8 +17,6 @@ export interface StationLinesInput {
liveSamples: ReadonlyArray<{ chainage_m: number; elevation_m: number }> | null;
/** [예상형상] 켜짐 — 측점 바를 계획고 위로 올린다. */
corridorVisible: boolean;
/** 상단측(측구 방향) 사용자 변경분 — 키는 누가거리 3자리. */
uphillOverrides: Map<string, "left" | "right">;
}
export function stationLinesOf(input: StationLinesInput) {
@@ -60,8 +58,6 @@ export function stationLinesOf(input: StationLinesInput) {
// max 로 잡으면 코리도가 절취해 내려간 노면을 두고 막대만 원지반에 떠 있다.
// 코리도가 켜져 있으면(designAt 이 값을 줌) 계획고를 그대로 쓴다.
center_z: design !== null && station.center_z !== null ? design : station.center_z,
uphill_side:
input.uphillOverrides.get(station.chainage_m.toFixed(3)) ?? station.uphill_side ?? null,
};
});
}
+3 -3
View File
@@ -62,13 +62,13 @@ export function createViewControls(callbacks: ViewControlCallbacks): {
toggleButton("지표면", true, callbacks.onSurfaceVisible),
// 예상형상(2026-08-23): ON=공사 후 형상(기본), OFF=원지반 완전체.
toggleButton("예상형상", true, callbacks.onCorridorVisible),
toggleButton("등고선", true, callbacks.onContoursVisible),
// 등고선 · 흑백 지형 기본 꺼짐(176-2) — 뷰어 처음 값과 맞춘다.
toggleButton("등고선", false, callbacks.onContoursVisible),
toggleButton("축 표시", false, callbacks.onAxesVisible),
toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible),
toggleButton(L("B05_Route_Field_StationLabels"), true, callbacks.onStationLabelsVisible),
// 무지개 고도색이 헷갈릴 때 명도만 남기는 흑백 표시(2026-08-05 사용자 요청).
// 기본 켜짐(2026-08-23 사용자 확정) — 예상형상 색이 지형 고도색에 묻히지 않는다.
toggleButton("흑백 지형", true, callbacks.onSurfaceGrayscale),
toggleButton("흑백 지형", false, callbacks.onSurfaceGrayscale),
);
// [3D 업데이트](2026-09-01 사용자 지시) — 계획선을 고칠 때마다 3D가 따라오면 조작이
// 무거워, 갱신을 이 버튼으로 모았다. 편집이 쌓이면 is-pending으로 알리고, 누르는 동안은
@@ -154,9 +154,9 @@ export function createStructuresSection(
}
/** 측점 범위 표시 갱신 — 시작 = 기준 − 전길이, 종료 = 시작 + 길이(2026-08-19
* 사용자 지시: 전/후 분할, 후길이 = 길이 − 전길이 자동). 계산 불가면 "—". */
* 사용자 지시: 전/후 분할, 후길이 = 길이 − 전길이 자동). 계산 불가면 "—" · 사면안정군(C)은 줄 숨김(177-5). */
function syncRangeDisplay(): void {
const computed = hasComputedRange(currentType());
const computed = hasComputedRange(currentType()) && currentType()?.group !== "C";
rangeWrap.hidden = !computed;
if (!computed) return;
const step = interval();
@@ -453,6 +453,12 @@
background: color-mix(in srgb, var(--color-success) 18%, var(--color-surface));
}
/* 미연결 — 연한 노랑 배경 + 노랑 테두리(경고 · 밝음 · 어두움 같은 토큰). */
.b05-structure__summary-name:not(.is-assigned) {
border-color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface));
}
.b05-structure__summary .b05-route__irregular-btn {
flex: none;
}
+3 -2
View File
@@ -182,6 +182,7 @@ export function createRouteViewer(): RouteViewer {
return heightIndex.triangleCount > 0 ? heightIndex : null;
}
const contours = new THREE.Group();
contours.visible = false; // 기본 꺼짐(176-2) — 패널 토글 처음 값과 맞춘다.
scene.add(contours);
let bounds: ModelBounds | null = null;
let current: { projectId: string; modelId: number; smooth: boolean; interval: number } | null =
@@ -386,8 +387,8 @@ export function createRouteViewer(): RouteViewer {
// 지표면 흑백 표시(2026-08-05 사용자 요청). 정점색 배열을 한 번 바꿔치기하는 것뿐이라
// (원본은 색 버퍼 열쇠 `ORIGINAL_COLORS`에 보관, 재질·셰이더 교체 없음) 로딩·회전 속도에 영향이 없다.
// 기본 흑백 지형(2026-08-23 사용자 확정) — 패널 토글 초기값과 맞춘다.
let surfaceGrayscale = true;
// 기본 꺼짐(176-2) — 패널 토글 초기값과 맞춘다.
let surfaceGrayscale = false;
function applySurfaceGrayscale(): void {
[terrain, corridor.clipped()].forEach((target) => applyGrayscaleTo(target));
}
+6 -4
View File
@@ -385,7 +385,7 @@ export function buildDesignControls(
const groundSegment = groundToggle.wrap;
groundSegment.classList.add("b06-design__seg--header");
// 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성).
// 아래 옵션은 상시 노출하되 선행 조건 미충족 시 비활성 처리한다(E-6).
// 아래 옵션은 선행 조건 미충족 시 비활성 = 숨김(179-2 · CSS) — 편측의 쪽은 알약 글에.
const rockCut = isRock(state.ground) && state.mode !== "both_fill";
const hasDitch = state.mode !== "both_fill";
// 측구 생성 여부(자동 판정 or 사용자 override) — 측구형식은 측구가 있을 때만 의미 있다.
@@ -417,9 +417,9 @@ export function buildDesignControls(
!hasDitch,
L("B06_Design_Disabled_NoDitch"),
);
// 측구형식(일반/L형): 암 지반 + 절토 단면 + 측구가 실제 있을 때만 활성.
// 측구형식(일반/L형): 암 지반 + 절토 단면 + 측구가 실제 있을 때만 활성 · 이름 글 없이 툴팁(179-2).
const ditchTypeSeg = segment(
L("B06_Design_DitchType_Legend"),
"",
DITCH_TYPE_OPTIONS,
state.ditchType,
(value) => {
@@ -429,6 +429,7 @@ export function buildDesignControls(
!rockCut || !ditchOn,
!ditchOn ? L("B06_Design_Disabled_NoDitchType") : L("B06_Design_Disabled_RockCut"),
);
ditchTypeSeg.title = L("B06_Design_DitchType_Legend");
// 2단계 경사 토글: 암 지반 + 절토 단면에서만 활성. 활성="복합경사"/비활성="단경사"(E-7).
const twoStage = toggle(
"",
@@ -486,7 +487,8 @@ export function buildDesignControls(
let overflow = bar.scrollWidth - clientWidth;
let moveCount = 0;
while (overflow > 1 && moveCount < moveable.length) {
overflow -= widths[moveable.length - 1 - moveCount] + barGapPx;
const width = widths[moveable.length - 1 - moveCount]; // 숨긴 칸(179-2)은 폭 · 간격 0
overflow -= width ? width + barGapPx : 0;
moveCount += 1;
}
return moveCount;
@@ -84,8 +84,8 @@ export interface B06StructuresPanelDeps {
wallSideLabels?: (chainageM: number) => { inlet: "좌" | "우"; outlet: "좌" | "우" } | null;
/** 측점 간격(m) — 측점번호+잔여거리 환산용. */
stationInterval: () => number;
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */
focusChainage: (chainageM: number) => void;
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다(revealOnly = 고르지 않고 스크롤만 · 177-4). */
focusChainage: (chainageM: number, revealOnly?: boolean) => void;
/** 목록에서 고른 줄을 다시 눌러 풂 — 횡단 카드 선택도 끔(54-8). */
releaseCard?: () => void;
/** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름). */
@@ -216,11 +216,17 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
}
currentChainageM = null; // 폼 = 이 구조물 — 옛 관을 붙든 채 남지 않게(165-2)
// 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다.
preferredId = structure.structure_id ?? null;
deps.focusChainage(focusOf(structure));
// 노면공 = 카드 하나 안 고름 · 범위 카드 전부 테두리 · 그 카드로 스크롤만 · 폼 유지(177-4)
const pave = !coverable(structure);
const id = structure.structure_id ?? null;
preferredId = id;
deps.focusChainage(focusOf(structure), pave);
preferredId = null;
// 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2)
deps.markStructure?.(structure.structure_id ?? null);
deps.markStructure?.(id);
// 카드 고름 리스너를 안 거치니 세션 고름은 여기서(B05 로 가도 그 노면공)
const at = { chainageM: focusOf(structure), structureId: id };
if (pave) selectDesign(deps.projectId, at, { from: "b06" });
},
getInterval: deps.stationInterval,
// 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다.
+11 -2
View File
@@ -235,7 +235,7 @@ export function createSectionView(
/**
* 고른 구간형 구조물(옹벽 · 독립 흙막이 · 사토장)의 시작~종료 안 횡단 카드 전부에 테두리 강조(106-1).
* 노면공(G군)은 테두리 대신 범위 카드 안 그 노면공의 포장층만 강조(152-5) — 겹친 노면공은 범위 표식으로 하나만.
* 노면공(G군)도 테두리 + 범위 카드 안 그 노면공의 포장층 강조(152-5 · 177-4) — 겹친 노면공은 범위 표식으로 하나만.
* 가장 가까운 한 장의 `--selected` 는 그대로 — 클래스만 갈아 끼워 줌·팬을 지킨다.
*/
let spanMarks: ReadonlyArray<StructureInstance> = [];
@@ -257,7 +257,7 @@ export function createSectionView(
: undefined;
const span = inSpan(section, mark);
const pave = span && isPavement(spanMarkId);
card.classList.toggle("b06-cross-card--span", span && !pave);
card.classList.toggle("b06-cross-card--span", span);
highlightPavement(card, pave ? mark : undefined);
};
const syncSpanHighlights = (): void => {
@@ -576,6 +576,15 @@ export function createSectionView(
);
}
},
revealStation(stationId) {
// 노면공 고름 = 카드 하나 안 고름 · 옛 카드는 리스너 없이 풂(폼 유지) · 그 카드로 스크롤만(177-4)
const previous = selectedStationId;
selectedStationId = null;
activeAreaKey = null;
markCard(previous);
profile.showCard(null);
revealCard(stationId, "smooth");
},
selectMark: (structureId, span = true) => {
// span 끔 = 알약만 · 카드는 고른 측점 하나(137-4 — 측점 고름 리스너) · 노면공 포장층은 테두리가 아니라 늘 켬(165-2)
spanMarkId = span || isPavement(structureId) ? structureId : null;
@@ -265,7 +265,7 @@ export function createSectionProfile(
},
selectMark(structureId) {
markedId = structureId;
profile?.setSelectedStructure(structureId);
profile?.setSelectedStructure(structureId, true); // 카드가 측점선 주인 — 선 없는 구조물 · 풂은 카드 선 지킴(177-3)
},
showCard(section) {
if (!profile) return;
@@ -280,7 +280,7 @@ export function createSectionProfile(
// 일반 측점은 측점선 짝 알약이 없어 꺼짐 — 고른 구조물이 이 측점을 덮으면 그 알약을 지킴(53-1)
const marked = markedId ? marks.find((entry) => entry.structure_id === markedId) : undefined;
if (!pipe && marked && coversChainage(marked, section.chainage_m))
profile.setSelectedStructure(markedId);
profile.setSelectedStructure(markedId, true);
},
};
}
@@ -42,6 +42,8 @@ export interface SectionViewController {
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */
focusStation: (stationId: string | null) => void;
/** 카드를 고르지 않고 스크롤만 — 고른 카드는 알림 없이 풂(노면공 목록 고름 · 177-4). */
revealStation: (stationId: string) => void;
/** 종단 알약 강조를 그 구조물로(null = 풀기 — 카드 측점선 짝을 따름).
* `span` = 구간형 범위 안 카드 전부 테두리(기본) · 끄면 카드는 고른 측점 하나(137-4). */
selectMark: (structureId: string | null, span?: boolean) => void;
+2 -6
View File
@@ -532,13 +532,9 @@
white-space: nowrap;
}
/* 선행 조건 미충족 옵션(E-6) — 상시 노출하되 흐리게·클릭 불가. */
/* 선행 조건 미충족 옵션 — 숨김(179-2 · 옛 E-6 「흐리게 늘 보임」 폐기 · 쪽은 단면유형 알약에). */
.b06-design__seg--disabled {
opacity: 0.4;
}
.b06-design__seg--disabled .b06-design__btn {
cursor: not-allowed;
display: none;
}
.b06-design__seg-buttons {
@@ -223,7 +223,7 @@
background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
}
/* 고른 보라 선 치수(137-2) — [+ − ⤢] 왼쪽 · 두 줄(151-1 · 길이 · 수평 / 높이 · 구배). */
/* 고른 보라 선 치수(137-2) — 카드 폭 무관 늘 [+ − ⤢] 왼쪽(179-1) · 두 줄(151-1 · 길이 · 수평 / 높이 · 구배). */
.b06-cross-card__line-dims {
align-self: center;
padding: 1px var(--spacing-4);
@@ -234,30 +234,6 @@
white-space: pre-line;
}
/* 좁은 카드(3열 등)는 막대 옆에 두면 왼쪽 면적표를 덮음 — 표 아래 높이로 내려 오른쪽에 붙임(151-1). */
.b06-cross-card__chart-wrap {
container-type: inline-size;
}
@container (max-width: 620px) {
.b06-cross-card__zoom {
overflow: visible;
}
.b06-cross-card__line-dims {
position: absolute;
top: calc(var(--spacing-8) + var(--b06-areas-height, 3.6rem));
right: -1px;
border: 1px solid var(--color-border);
border-radius: var(--radius-inputs);
background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
}
.b06-cross-card__line-dims + .b06-cross-card__zoom-btn {
border-left: none;
}
}
.b06-cross-card__line-dims[hidden] {
display: none;
}
@@ -39,6 +39,8 @@ export interface CadDrawing {
}[];
/** 횡단 장 — 옛 배치(칸 통일)로 확정된 저장본. [수정]을 누르면 새 배치로 다시 만든다. */
stale_layout?: boolean;
/** 횡단 장 — 지금과 다른 표 · 글 배율로 확정된 저장본(180-2). */
stale_scale?: boolean;
layers: {
id: string;
name: string;
@@ -178,10 +180,11 @@ export function invalidateDesignDrawing(projectId: string, drawingId: string): P
);
}
/** 횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3). */
/** 횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3) · 표 · 글 배율(180-2). */
export interface CrossLayout {
default: [number, number];
sheets: Record<string, [number, number]>;
table_scale?: number;
}
export function fetchCrossLayout(projectId: string): Promise<CrossLayout> {
@@ -19,7 +19,7 @@ from uuid import UUID, uuid5
from config.config_system import DRAWING_SCALE_CROSS
# 레이어 정의 — 지표면선은 상세설계 제어 대상에서 제외하므로 잠금한다(N-1-3).
# 레이어 정의 — 지표면선은 종단에서 잠금(N-1-3) · 횡단은 장에서 옮기므로 풂(180-1).
GROUND_LAYER_ID = "b08-ground"
GROUND_COLOR = "#f5f7fa"
DESIGN_LAYER_ID = "b08-design"
@@ -498,6 +498,7 @@ def build_cross_drawing(
overlay: tuple[float, float, float, float] | None = None,
border: bool = True,
table_bottom: float | None = None,
table_scale: float = 1.0,
) -> dict[str, Any]:
"""횡단도 한 장을 지표/설계/구조물(+암 경계) 레이어 + CAD 수량 산출표로 만든다.
@@ -507,6 +508,7 @@ def build_cross_drawing(
배치에 쓰지 않지만 향후 표고 주석용으로 시그니처를 유지한다.
table_bottom(장 칸 · 174-1)을 주면 표 아래선을 그 y 에 두고 글 · 그림을 위로 쌓는다 —
표 · 글 가운데 x = 도로 중심(origin x) · origin y 는 쌓은 결과로 정함.
table_scale = 표 · 표 위 글 배율(180-2) — 8 mm 간격은 그대로.
"""
ox, oy = origin
raw_ground = points_from_samples(source.get("samples", []), "offset_m")
@@ -546,7 +548,11 @@ def build_cross_drawing(
# 아래에서 위로 — 표 → 글(150-3) → 간격 8 → 그림(174-1)
stack = 0.0
if quantity_table is not None:
stack = cross_table_height() + cross_note_height(cross_note_lines(design)) + 8.0
stack = (
cross_table_height(table_scale)
+ cross_note_height(cross_note_lines(design), table_scale)
+ 8.0
)
oy = table_bottom + stack + own_half_height * CROSS_MM
def paper(point: tuple[float, float]) -> tuple[float, float]:
@@ -576,18 +582,23 @@ def build_cross_drawing(
table_top = oy - half_height - 8.0
# 표 위 글(150-3) — 줄 수만큼 표를 내림
note_lines = cross_note_lines(design)
entities.extend(_cross_note_entities(drawing_id, note_lines, table_top, center_x))
table_top -= cross_note_height(note_lines)
entities.extend(
_cross_table_entities(drawing_id, quantity_table, table_top, title_label, center_x)
_cross_note_entities(drawing_id, note_lines, table_top, center_x, table_scale)
)
table_bottom = table_top - cross_table_height()
table_top -= cross_note_height(note_lines, table_scale)
entities.extend(
_cross_table_entities(
drawing_id, quantity_table, table_top, title_label, center_x, table_scale
)
)
table_bottom = table_top - cross_table_height(table_scale)
# 외곽 테두리: 단면 범위와 표를 함께 감싸고 1 mm 띄운다.
pad = CROSS_BORDER_PAD_MM
# 그림 범위와 표 중 바깥 — 장 칸에서는 표가 도로 중심이라 좌우가 다를 수 있음(174-1)
frame_left = min(ox + x0 * CROSS_MM, center_x - cross_table_width() / 2.0) - pad
frame_right = max(ox + x1 * CROSS_MM, center_x + cross_table_width() / 2.0) + pad
table_half = cross_table_width(table_scale) / 2.0
frame_left = min(ox + x0 * CROSS_MM, center_x - table_half) - pad
frame_right = max(ox + x1 * CROSS_MM, center_x + table_half) + pad
frame_top = oy + half_height + pad
frame_bottom = table_bottom - pad
corners = [
@@ -622,7 +633,7 @@ def build_cross_drawing(
}
],
"layers": [
_layer(GROUND_LAYER_ID, "원지반", locked=True),
_layer(GROUND_LAYER_ID, "원지반"),
_layer(DESIGN_LAYER_ID, "계획선"),
_layer(STRUCTURE_LAYER_ID, "구조물"),
_layer(ROCK_LAYER_ID, "암 경계선"),
@@ -9,9 +9,11 @@
import re
from typing import Any
from uuid import uuid5
from B05_Profile.B05_Profile_Engine_Sections import cross_key
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_ENTITY_NS,
CROSS_BORDER_PAD_MM,
CROSS_MM,
CROSS_TABLE_LAYER_ID,
@@ -70,6 +72,7 @@ def section_block_size(
with_table: bool = True,
design: dict[str, Any] | None = None,
overlay: tuple[float, float, float, float] | None = None,
table_scale: float = 1.0,
) -> tuple[float, float]:
"""단면 블록 하나의 종이 크기(mm) — 엔티티를 만들지 않고 치수만 잰다.
@@ -91,13 +94,14 @@ def section_block_size(
width = 2.0 * max(
(x1 - x0) / 2.0 * CROSS_MM + _BORDER_PAD_MM,
cross_table_width() / 2.0 + _BORDER_PAD_MM,
cross_table_width(table_scale) / 2.0 + _BORDER_PAD_MM,
)
# 위: 단면 반높이 + 테두리 여유 / 아래: 반높이 + (표 간격 + 표) + 테두리 여유
top = half_height + _BORDER_PAD_MM
below = half_height + _BORDER_PAD_MM
if with_table:
below += 8.0 + cross_note_height(cross_note_lines(design_spec)) + cross_table_height()
note_h = cross_note_height(cross_note_lines(design_spec), table_scale)
below += 8.0 + note_h + cross_table_height(table_scale)
return (width, top + below)
@@ -144,6 +148,8 @@ def plan_cross_sheets(
"chainages": group,
"grid": [cols, rows],
"slots": grid_slots(len(group), cols, rows),
# 표 · 글 배율(180-2) — 모든 장 한 값
"table_scale": float((layout or {}).get("table_scale") or 1.0),
}
)
start += len(group)
@@ -205,12 +211,19 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
overlay=section.get("overlay"),
border=False,
table_bottom=cell[1] + CELL_TABLE_PAD_MM,
table_scale=float(sheet.get("table_scale") or 1.0),
)
# 칸을 넘는 단면 — 축척 그대로 두고 그 칸을 화면 가이드로 짚음(169-2)
frames = [p.get("frame") for p in placed.get("cross_placements") or []]
left, _bottom, right, top = frames[0] if frames and frames[0] else cell
if left < cell[0] - 1e-6 or right > cell[2] + 1e-6 or top > cell[3] + 1e-6:
overflow.append(cell)
# 측점 한 벌 = groupId 하나 — 한 번 클릭에 그림 · 표 · 글 함께(180-1)
group_id = str(uuid5(_ENTITY_NS, f"{seed_id}:group"))
for entity in placed["entities"]:
entity["groupId"] = group_id
for placement in placed.get("cross_placements") or []:
placement["group_id"] = group_id
entities.extend(placed["entities"])
placements.extend(placed.get("cross_placements") or [])
@@ -233,7 +246,7 @@ def build_cross_sheet(sheet: dict[str, Any], sections: list[dict[str, Any]]) ->
# 칸보다 큰 단면의 칸 [x0, y0, x1, y1] — 라우터가 CAD 가이드 붉은 틀로 보냄(169-2)
"overflow_cells": overflow,
"layers": [
_layer(GROUND_LAYER_ID, "원지반", locked=True),
_layer(GROUND_LAYER_ID, "원지반"),
_layer(DESIGN_LAYER_ID, "계획선"),
_layer(STRUCTURE_LAYER_ID, "구조물"),
_layer(ROCK_LAYER_ID, "암 경계선"),
@@ -128,16 +128,17 @@ def _cross_table_entities(
table_top: float,
title_label: str,
center_x: float = 0.0,
scale: float = 1.0,
) -> list[dict[str, Any]]:
"""횡단 수량 산출표(납품 양식)를 표 객체 하나로 만든다.
"""횡단 수량 산출표(납품 양식)를 표 객체 하나로 만든다 — scale = 표 · 글 배율(180-2).
머리행은 폭을 8등분하고 본문은 11열 가중치를 쓴다 — 격자가 서로 다르므로 두 경계를
합친 열로 표를 만들고 병합으로 원래 칸을 되살린다. 병합 자리에서 선을 끊는 일은
표가 스스로 하므로, 예전처럼 가로선을 손으로 끊지 않는다.
"""
width = _CROSS_TABLE_WIDTH
row_h = _CROSS_TABLE_ROW_HEIGHT
font = _CROSS_TABLE_FONT
width = _CROSS_TABLE_WIDTH * scale
row_h = _CROSS_TABLE_ROW_HEIGHT * scale
font = _CROSS_TABLE_FONT * scale
left = center_x - width / 2.0
right = center_x + width / 2.0
body_edges = _cross_column_edges(width, center_x)
@@ -331,24 +332,24 @@ def cross_note_lines(design: dict[str, Any] | None) -> list[str]:
return [line.rstrip() for line in note.strip().splitlines()]
def cross_note_height(lines: list[str]) -> float:
"""표 위 글 높이(mm) — 줄 수 × 줄 높이."""
return _CROSS_NOTE_LINE_HEIGHT * len(lines)
def cross_note_height(lines: list[str], scale: float = 1.0) -> float:
"""표 위 글 높이(mm) — 줄 수 × 줄 높이 × 배율."""
return _CROSS_NOTE_LINE_HEIGHT * scale * len(lines)
def _cross_note_entities(
drawing_id: str, lines: list[str], top: float, center_x: float = 0.0
drawing_id: str, lines: list[str], top: float, center_x: float = 0.0, scale: float = 1.0
) -> list[dict[str, Any]]:
"""표 위 글 줄마다 Text — 표 왼쪽 끝 맞춤 · 위에서 아래로(id `{도면}:note:{n}`)."""
left = center_x - _CROSS_TABLE_WIDTH / 2.0
left = center_x - _CROSS_TABLE_WIDTH * scale / 2.0
return [
_text_entity(
f"{drawing_id}:note:{index}",
line,
left,
top - _CROSS_NOTE_LINE_HEIGHT * (index + 0.5),
top - _CROSS_NOTE_LINE_HEIGHT * scale * (index + 0.5),
CROSS_TABLE_LAYER_ID,
_CROSS_TABLE_FONT,
_CROSS_TABLE_FONT * scale,
TABLE_LABEL_COLOR,
align="left",
)
@@ -357,11 +358,11 @@ def _cross_note_entities(
]
def cross_table_width() -> float:
def cross_table_width(scale: float = 1.0) -> float:
"""수량 산출표 가로 폭(mm)."""
return _CROSS_TABLE_WIDTH
return _CROSS_TABLE_WIDTH * scale
def cross_table_height() -> float:
def cross_table_height(scale: float = 1.0) -> float:
"""수량 산출표 세로 높이(mm) — 머리 2행 + 본문 6행."""
return _CROSS_TABLE_ROW_HEIGHT * (len(_CROSS_LEFT_ROWS) + 2)
return _CROSS_TABLE_ROW_HEIGHT * scale * (len(_CROSS_LEFT_ROWS) + 2)
+2 -1
View File
@@ -551,10 +551,11 @@ async def invalidate_design_drawing(
class CrossLayoutRequest(BaseModel):
"""횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3)."""
"""횡단 장 배치 — 기본 [열, 행] · 장마다 {장 번호: [열, 행]}(140-3) · 표 · 글 배율(180-2)."""
default: list[int] = Field(default_factory=lambda: [4, 4])
sheets: dict[str, list[int]] = Field(default_factory=dict)
table_scale: float = 1.0
@router.get("/{project_id}/cross-layout")
@@ -104,6 +104,8 @@ CROSS_SHEET_LAYOUT = 5
CROSS_LAYOUT_FILE = "cross_layout.json"
CROSS_GRID_DEFAULT = [4, 4]
_CROSS_GRID_MAX = 10
# 표 · 표 위 글 배율(180-2) — 모든 장 한 값 · 기본 1
CROSS_SCALE_RANGE = (0.5, 2.0)
def _cross_grid(value: Any) -> list[int]:
@@ -116,13 +118,22 @@ def _cross_grid(value: Any) -> list[int]:
return [int(value[0]), int(value[1])]
def _cross_scale(value: Any) -> float:
low, high = CROSS_SCALE_RANGE
if value is None:
return 1.0
if isinstance(value, bool) or not isinstance(value, (int, float)) or not low <= value <= high:
raise ValueError(f"표 배율은 {low}~{high} 사이로 적어 주세요.")
return round(float(value), 2)
def read_cross_layout(project_root: Path) -> dict[str, Any]:
"""횡단 장 배치 — 파일이 없거나 깨졌으면 기본 4 × 4 만."""
"""횡단 장 배치 — 파일이 없거나 깨졌으면 기본 4 × 4 · 배율 1 만."""
path = _design_root(project_root) / CROSS_LAYOUT_FILE
try:
return checked_cross_layout(_read_json(path))
except (OSError, ValueError, AttributeError):
return {"default": list(CROSS_GRID_DEFAULT), "sheets": {}}
return {"default": list(CROSS_GRID_DEFAULT), "sheets": {}, "table_scale": 1.0}
def checked_cross_layout(layout: dict[str, Any]) -> dict[str, Any]:
@@ -132,6 +143,7 @@ def checked_cross_layout(layout: dict[str, Any]) -> dict[str, Any]:
return {
"default": _cross_grid(layout.get("default") or CROSS_GRID_DEFAULT),
"sheets": {str(int(key)): _cross_grid(grid) for key, grid in sheets.items()},
"table_scale": _cross_scale(layout.get("table_scale")),
}
@@ -449,6 +461,10 @@ def _read_drawing(
label = str(manifest_entry.get("label") or drawing_id)
if kind == "cross" and manifest_entry.get("layout") != CROSS_SHEET_LAYOUT:
saved["stale_layout"] = True # 화면이 [수정] → 새 배치로 다시 만들기를 안내
elif kind == "cross" and drawing_id.startswith("cross_s"):
scale = read_cross_layout(project_root)["table_scale"]
if manifest_entry.get("table_scale", 1.0) != scale:
saved["stale_scale"] = True # 옛 배율로 확정한 장 — 그대로 두고 안내(180-2)
stored_table = manifest_entry.get("quantity_table")
table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None
return kind, label, saved, True, table
@@ -594,6 +610,7 @@ def _store_confirmed_drawing(
}
if item.kind == "cross":
entry["layout"] = CROSS_SHEET_LAYOUT
entry["table_scale"] = read_cross_layout(project_root)["table_scale"]
if item.kind == "cross" and isinstance(quantity_table, dict):
entry["quantity_table"] = quantity_table
if isinstance(quantity_tables, dict) and quantity_tables:
@@ -32,6 +32,8 @@ export interface CrossPlacement {
x1: number;
/** 블록 테두리(종이 mm) [x0, y0, x1, y1] — 그림을 이 안으로 자른다. */
frame?: number[];
/** 측점 한 벌 묶음(180-1) — 덧그림도 같은 묶음으로 함께 골라짐. */
group_id?: string;
}
/** 측점과 도면 배치를 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다. */
@@ -78,6 +80,7 @@ export async function appendStructureEntities(
continue; // 한 측점의 기하 실패가 도면 전체를 막으면 안 된다.
}
if (!entities.length) continue;
if (placement.group_id) for (const entity of entities) entity.groupId = placement.group_id;
// 이 블록 설계선은 우리가 다시 그렸다 — 서버가 낸 트림 전 설계선은 걷어낸다.
replaced.push(frame);
fresh.push(...entities);
+12 -1
View File
@@ -191,7 +191,13 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
number
? (grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } })
: null,
(grid) => void save({ default: grid, sheets: {} }),
(grid) => void save({ ...layout, default: grid, sheets: {} }),
{
scale: {
value: layout.table_scale ?? 1,
onApply: (scale) => void save({ ...layout, table_scale: scale }),
},
},
),
);
};
@@ -343,6 +349,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
"옛 배치로 확정된 장입니다. [수정]을 누르면 표를 칸 아래 가운데에 맞춘 새 배치로 다시 만듭니다.",
"info",
);
} else if (response.drawing.stale_scale) {
showToast(
"옛 표 배율로 확정된 장입니다. [수정]을 누르면 지금 배율로 다시 만듭니다.",
"info",
);
}
} catch (error) {
cad.fail(error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.");
+36 -1
View File
@@ -175,14 +175,24 @@ export function isCrossSheet(drawing: DesignDrawingItem): boolean {
return drawing.kind === "cross" && drawing.id.startsWith("cross_s");
}
/** 표 · 글 배율 범위(180-2) — 서버 `CROSS_SCALE_RANGE` 와 같음. */
const CROSS_SCALE_MIN = 0.5;
const CROSS_SCALE_MAX = 2;
/** 배치 칸 줄 뒤 덧칸 — 표 배율(180-2). */
export interface CrossLayoutExtras {
scale?: { value: number; onApply: (scale: number) => void };
}
/**
* 횡단 장 배치 칸(140-3 · 153-1) — 「배치 열 [4] 행 [4] [이 장] [모든 장]」.
* 횡단 장 배치 칸(140-3 · 153-1) — 「배치 열 [4] 행 [4] [이 장] [모든 장]」 + 「표 배율 [1] [적용]」.
* `onApply` 가 없으면(장이 아닌 도면) [이 장]을 끔.
*/
export function buildCrossLayoutControls(
grid: [number, number],
onApply: ((grid: [number, number]) => void) | null,
onAllDefault: (grid: [number, number]) => void,
extras: CrossLayoutExtras = {},
): HTMLElement {
const box = document.createElement("div");
box.className = "b07-cross-layout";
@@ -222,6 +232,31 @@ export function buildCrossLayoutControls(
action("이 장", "이 장만 이 배치로", onApply),
action("모든 장", "모든 장을 이 배치로(장마다 값 지움)", onAllDefault),
);
if (extras.scale) {
const { onApply: applyScale } = extras.scale;
const scale = createInputField({
label: "표 배율",
type: "number",
value: String(extras.scale.value),
min: CROSS_SCALE_MIN,
max: CROSS_SCALE_MAX,
compact: true,
});
scale.input.setAttribute("step", "0.1");
const button = createButton({
label: "배율 적용",
variant: "ghost",
size: "sm",
onClick: () => {
const value = Number(scale.input.value);
if (value >= CROSS_SCALE_MIN && value <= CROSS_SCALE_MAX) applyScale(value);
else
showToast(`표 배율은 ${CROSS_SCALE_MIN}~${CROSS_SCALE_MAX} 사이로 적어 주세요.`, "error");
},
});
button.title = "모든 장의 표 · 표 위 글 크기(기본 1)";
box.append(scale.root, button);
}
return box;
}
+2
View File
@@ -49,6 +49,8 @@
"name": "forest-road-webapp",
"version": "0.1.0",
"dependencies": {
"@kninnug/constrainautor": "^4.1.0",
"delaunator": "^5.1.0",
"maplibre-gl": "^5.24.0",
"three": "^0.185.0"
},
+6 -1
View File
@@ -57,6 +57,7 @@ def _qty(value: Any) -> float | None:
def _ref_mats(whole: Any, read: Any, ref: dict, trail: tuple[str, ...] = ()) -> list[tuple]:
"""참조 문서 1 단위 재료 `[(재료 줄, 수량)]` — 층 셈 펼침(`M02_Labor_Ref.Expand`)과 같은 길:
승격 = 원 로직 재료 ÷ 배 · 사전조합 = 안 줄 재료 × 손 수량(빈칸 1) · 로직 줄은 알약 배로 나눔 ·
사전조합 자재(175-2) = 손 수량(빈칸 = 안 셈 · 품목 키 = 코드 · `자재` 표시 = 연료 거름 안 함) ·
순환 · 깊이 넘음 · 없는 문서 = 빈 것."""
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Labor_Doc as labor
@@ -94,6 +95,10 @@ def _ref_mats(whole: Any, read: Any, ref: dict, trail: tuple[str, ...] = ()) ->
continue
hand = _qty((one.get("수량") or {}).get("손"))
out += [(m, q * (1.0 if hand is None else hand)) for m, q in sub]
for mat in doc.get("자재") or []:
if hand := _qty((mat.get("수량") or {}).get("손")):
line = {k: mat.get(k) for k in ("이름", "단위")}
out.append(({**line, "품목": {"키": mat.get("코드")}, "자재": True}, hand))
return out
@@ -129,7 +134,7 @@ def _folder_units(root: str | Path, folder: str, meta: dict[str, dict]) -> dict[
def logic_mat(mat: dict, qty: float) -> None:
name, unit = str(mat.get("이름") or ""), str(mat.get("단위") or "")
if _fuel(name, unit):
if _fuel(name, unit) and not mat.get("자재"): # 사전조합 사용자 자재는 거르지 않음
return
goods = mat.get("품목")
code = str(goods.get("키") or "") if isinstance(goods, dict) else ""
+15 -7
View File
@@ -4,7 +4,8 @@
(`M02_Item_Prices.calc` = 142-1 층 셈 꼴)로 돌려 줄 수량을 받고, 블록 꼴로 펼침:
조합 구조물 조합 사본 하나 = 블록(엑셀 「조합」 꼴) · 줄 = 담은 로직 줄 · 자재 줄
사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄(수량 = 손)
사전조합 참조 SC = 겹 대가(굳힌 `참조사본`) · 줄 = 그 안 로직 · 참조 줄(수량 = 손) ·
자재 줄(175-2 · 수량 = 손 · 빈칸 = 수량 없음)
품셈 일위대가화(승격) 줄 · 옛 참조 SP = 로직마다 따로(노무 · 재료 · 중기 · 산근 · % 줄)
— 조합이 수량만큼 부름 · 기계 = 기계경비 로직 줄 · 다른 돈 로직 줄 = 하위 산근
산근 단가산출 줄(일위대가화 아닌 로직 줄) = 산근 호표 참조(키 = 145-1 `sangeun_key`)
@@ -206,7 +207,7 @@ class Builder:
"spec_raw": block.get("spec_raw"), "unit": block["unit"], "qty": each} # fmt: skip
def precombo(self, whole: Any, ref: dict, doc: dict, copies: dict, depth: int = 0) -> str:
"""사전조합(SC) = 겹 대가 — 줄 수량 = 손(기본 1) · 로직 줄 = 품셈 호표."""
"""사전조합(SC) = 겹 대가 — 줄 수량 = 손(기본 1) · 로직 줄 = 품셈 호표 · 자재 줄(175-2)."""
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
inner = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)]
@@ -226,6 +227,10 @@ class Builder:
continue
qty = 1 if hand is None else hand
lines.append(self.line_of(whole, one, qty, times, name, copies, depth))
lines += [
_material(whole, mat, (mat.get("수량") or {}).get("손"))
for mat in doc.get("자재") or []
]
return self.put(
{
"kind": "사전조합",
@@ -262,11 +267,7 @@ class Builder:
if line.get("qty") is None and line["kind"] != "blank":
line = {**line, "why": "수량 없음 — 면적산출 알약 칸 · 손 수량을 넣음"}
lines.append(line)
for mat in result.get("자재") or []:
row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {}
lines.append({"kind": "material", "key": mat.get("코드"), "name": row.get("이름") or "",
"spec": row.get("규격") or "", "unit": row.get("단위") or "",
"qty": _f(mat.get("수량"))}) # fmt: skip
lines += [_material(whole, mat, mat.get("수량")) for mat in result.get("자재") or []]
manifest = read_json(folder / MANIFEST_NAME) or {}
origin = manifest.get("원본") or {}
return self.put(
@@ -282,6 +283,13 @@ class Builder:
)
def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]:
"""자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가."""
row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {}
names = {k: row.get(v) or "" for k, v in (("name", "이름"), ("spec", "규격"), ("unit", "단위"))}
return {"kind": "material", "key": mat.get("코드"), **names, "qty": _f(qty)}
def _engine_bases(engine: list[dict], items: list[dict]) -> dict[str, float]:
"""엔진 호표 줄 → 덧줄 밑수 후보 — `part:비목` 합 · `lines:labor` 인력 줄 합 · `line:이름`."""
out: dict[str, float] = {f"part:{p}": 0.0 for p in PART.values()}
+31 -1
View File
@@ -20,11 +20,16 @@
.b09-estimation__tabs {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
gap: var(--spacing-8); /* B08 위 · 보조 탭 줄과 같은 값(181-1) */
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border);
}
/* 아래 탭 없는 위 탭 — 보이지 않는 단추 한 줄로 높이만 채움(탭 바꿀 때 몸통이 안 튐) */
.b09-estimation__tabs--blank > .ui-btn {
visibility: hidden;
}
/* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈 */
[data-b09-table] thead th {
white-space: normal;
@@ -76,3 +81,28 @@
.b09-estimation .ui-data-table__table tbody tr.is-hand td:first-child {
background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px);
}
/* 181-2 표 보기 — 목록표에서 고른 호표 줄 구간 전체 붉은 외곽선(머리 accent 밑줄은 덮음) */
.b09-estimation .ui-data-table__table tbody tr.is-pick td {
--pick-t: inset 0 0 0 0 transparent;
--pick-b: inset 0 0 0 0 transparent;
--pick-l: inset 0 0 0 0 transparent;
--pick-r: inset 0 0 0 0 transparent;
box-shadow: var(--pick-t), var(--pick-b), var(--pick-l), var(--pick-r);
}
.b09-estimation .ui-data-table__table tbody tr.is-pick.is-pick-top td {
--pick-t: inset 0 2px 0 var(--color-danger);
}
.b09-estimation .ui-data-table__table tbody tr.is-pick.is-pick-end td {
--pick-b: inset 0 -2px 0 var(--color-danger);
}
.b09-estimation .ui-data-table__table tbody tr.is-pick td:first-child {
--pick-l: inset 2px 0 0 var(--color-danger);
}
.b09-estimation .ui-data-table__table tbody tr.is-pick td:last-child {
--pick-r: inset -2px 0 0 var(--color-danger);
}
+10 -20
View File
@@ -28,6 +28,7 @@ import { computeBase, type Row } from "./B09_Estimation_Calc_Base";
import { computeEstimation } from "./B09_Estimation_Calc_Bill";
import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor";
import { computeTotal } from "./B09_Estimation_Calc_Total";
import { createTabBars } from "./B09_Estimation_UI_TabBar";
import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor";
import {
buildCheck,
@@ -150,15 +151,14 @@ export function renderB09Estimation(root: HTMLElement): void {
let sheets: Row = {};
let current: TabId = "check";
const tabBar = document.createElement("div");
tabBar.className = "b09-estimation__tabs";
tabBar.setAttribute("role", "tablist");
// 181-1 위 탭(md) · 아래 탭(sm) 두 줄 — 줄 그리기 = `…_UI_TabBar`
const bars = createTabBars((tab) => show(tab));
const panel = document.createElement("div");
panel.className = "b09-estimation__panel";
panel.setAttribute("role", "tabpanel");
const body = document.createElement("div");
body.className = "b09-estimation";
body.append(tabBar, panel);
body.append(bars.top, bars.sub, panel);
const made = document.createElement("p");
made.className = "b09-estimation__hint";
@@ -181,20 +181,7 @@ export function renderB09Estimation(root: HTMLElement): void {
/** 탭 하나 그림 · focus = 이동해 온 줄 키 */
const show = (tab: TabId, focus?: string): void => {
current = tab;
tabBar.replaceChildren(
...TABS.map(([id, label]) => {
const button = createButton({
label,
variant: id === tab ? "filled" : "ghost",
size: "sm",
onClick: () => id !== current && show(id),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(id === tab));
button.dataset.b09Tab = id;
return button;
}),
);
bars.draw(tab);
if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요."));
if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…"));
const build = BUILDERS[tab];
@@ -205,7 +192,8 @@ export function renderB09Estimation(root: HTMLElement): void {
const goto = (link: Link | null): void => {
if (link) show(link.tab, link.key);
};
const makeTable = (): DataTable => {
// extra = 줄 class 곁값(181-2 표 보기 호표 구간)
const makeTable = (extra?: (index: number) => string | null): DataTable => {
const made = createDataTable({
columns: spec.columns,
rows: spec.rows,
@@ -215,7 +203,9 @@ export function renderB09Estimation(root: HTMLElement): void {
caption: TABS.find(([id]) => id === tab)?.[1],
editable: spec.editable,
onEdit: spec.onEdit,
rowClass: spec.rowClass,
rowClass: extra
? (i) => [spec.rowClass?.(i), extra(i)].filter(Boolean).join(" ") || null
: spec.rowClass,
onCellClick: (i, key) => {
if (spec.onCell) spec.onCell(i, key);
if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));
@@ -0,0 +1,71 @@
/* =============================================================================
* B09_Estimation_UI_TabBar.ts
* B09 탭 줄 두 층(PLAN 181-1) — 위 탭(md) · 아래 탭(sm) · B08 수량산출 페이지 꼴.
*
* 위 탭 = `TAB_GROUPS`(`…_UI_Tabs`) · 아래 탭 하나뿐인 위 탭 = 보이지 않는 sm 단추 한 줄(높이만 채움)
* 위 탭 누름 = 그 묶음에서 마지막으로 본 아래 탭(이 화면 안 Map · 저장 안 함) · 없으면 첫 아래 탭
* 탭 이동은 `draw(탭)` — 이동이 묶음을 넘어도 위 탭이 따라 켜짐
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { groupOf, tabName, TAB_GROUPS, type TabId } from "./B09_Estimation_UI_Tabs";
export interface TabBars {
top: HTMLElement;
sub: HTMLElement;
/** 이 탭이 켜진 꼴로 두 줄을 다시 그림 */
draw: (tab: TabId) => void;
}
function tabButton(label: string, on: boolean, size: "sm" | undefined, pick: () => void) {
const button = createButton({
label,
variant: on ? "filled" : "ghost",
...(size ? { size } : {}),
onClick: () => !on && pick(),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(on));
return button;
}
export function createTabBars(onPick: (tab: TabId) => void): TabBars {
const top = document.createElement("div");
top.className = "b09-estimation__tabs";
top.setAttribute("role", "tablist");
const sub = document.createElement("div");
sub.className = "b09-estimation__tabs";
const lastSeen = new Map<TabId, TabId>();
const draw = (tab: TabId): void => {
const [, picked] = groupOf(tab);
lastSeen.set(picked[0], tab);
top.replaceChildren(
...TAB_GROUPS.map(([label, tabs]) => {
const button = tabButton(label, tabs === picked, undefined, () =>
onPick(lastSeen.get(tabs[0]) ?? tabs[0]),
);
button.dataset.b09Group = tabs[0];
if (tabs.length === 1) button.dataset.b09Tab = tabs[0];
return button;
}),
);
const single = picked.length === 1;
sub.classList.toggle("b09-estimation__tabs--blank", single);
sub.setAttribute("role", single ? "presentation" : "tablist");
sub.setAttribute("aria-hidden", String(single));
if (single) {
const ghost = createButton({ label: tabName(picked[0]), variant: "ghost", size: "sm" });
ghost.tabIndex = -1;
return void sub.replaceChildren(ghost);
}
sub.replaceChildren(
...picked.map((id) => {
const button = tabButton(tabName(id), id === tab, "sm", () => onPick(id));
button.dataset.b09Tab = id;
return button;
}),
);
};
return { top, sub, draw };
}
+18
View File
@@ -56,6 +56,24 @@ export const TABS: Array<[TabId, string]> = [
export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] ?? id;
/** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */
export const TAB_GROUPS: Array<[string, TabId[]]> = [
["연결 점검", ["check"]],
["공사원가계산서", ["cost"]],
["총괄설계내역서", ["total"]],
["설계내역서", ["bill"]],
["일위대가", ["dg_list", "dg"]],
["단가산출", ["sg_list", "sg"]],
["환율및기초자료", ["basis"]],
["중기", ["machine", "machine_blocks", "sum_machine", "expense", "sum_expense"]],
["재료", ["material", "compare", "sum_material"]],
["노무", ["labor", "sum_labor"]],
];
/** 탭이 든 위 탭 묶음 */
export const groupOf = (tab: TabId): [string, TabId[]] =>
TAB_GROUPS.find(([, tabs]) => tabs.includes(tab)) ?? TAB_GROUPS[0];
/** 다른 탭 줄 — 키가 같은 줄로 이동 */
export interface Link {
tab: TabId;
@@ -1,10 +1,11 @@
/* =============================================================================
* B09_Estimation_UI_Tabs_Groups.ts
* B09 묶음 탭 셋(PLAN 171-3) — 일위대가표 · 중기사용료 · 단가산출근거 = 공용 묶음 카드(171-2) 기본 ·
* [표로 보기] = 공용 표 그대로.
* [표로 보기] = 공용 표 그대로(맞은편 단추 = [박스보기]).
*
* 카드 = 탭 표 줄(`TabTable.rows`)을 호표(줄 키 앞머리)마다 묶음 — 값 · 풍선 · 누름 이동은 표와 같음
* 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로)
* 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선
* 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2
* ========================================================================== */
@@ -29,8 +30,7 @@ export const GROUP_TABS: TabId[] = ["dg", "machine_blocks", "sg"];
export const isGroupTab = (tab: TabId): boolean => GROUP_TABS.includes(tab);
/** 단추 글 = 바꿀 보기 이름 */
export const viewLabel = (view: GroupView): string =>
view === "cards" ? "표로 보기" : "호표별 보기";
export const viewLabel = (view: GroupView): string => (view === "cards" ? "표로 보기" : "박스보기");
export const flipView = (view: GroupView): GroupView => (view === "cards" ? "table" : "cards");
@@ -146,6 +146,22 @@ export function tabGroups(tab: TabId, spec: TabTable): GroupCard[] {
return cards;
}
/** 고른 호표의 표 줄 구간 [첫 줄, 끝 줄](키 `|` 앞머리가 같은 줄) · 없으면 null */
export function pickRange(rows: DataRow[], focus: string | undefined): [number, number] | null {
if (focus === undefined) return null;
const head = focus.split("|")[0];
const at = rows.flatMap((r, i) => (str(r.key).split("|")[0] === head ? [i] : []));
return at.length ? [at[0], at[at.length - 1]] : null;
}
/** 표 줄 class — 구간 안 `is-pick` · 첫 줄 `is-pick-top` · 끝 줄 `is-pick-end` */
export function pickClass(range: [number, number] | null, i: number): string | null {
if (!range || i < range[0] || i > range[1]) return null;
return ["is-pick", i === range[0] && "is-pick-top", i === range[1] && "is-pick-end"]
.filter(Boolean)
.join(" ");
}
/** 표 열과 같은 숫자 꼴(열 소수 자리) */
function numberFormat(spec: TabTable, key: string): (v: number | null | undefined) => string {
const c = spec.columns.find((col) => col.key === key);
@@ -168,7 +184,7 @@ export function renderGroupPanel(o: {
hint: HTMLElement | null;
focus?: string;
goto: (link: Link | null) => void;
makeTable: () => DataTable;
makeTable: (rowClass?: (index: number) => string | null) => DataTable;
redraw: () => void;
}): HTMLElement[] {
const view = chosen.get(o.tab) ?? readGroupView(o.tab);
@@ -201,7 +217,8 @@ export function renderGroupPanel(o: {
cards.dataset.b09Table = o.tab;
return [bar, cards];
}
const table = o.makeTable();
const range = pickRange(o.spec.rows, o.focus);
const table = o.makeTable((i) => pickClass(range, i));
if (at >= 0) requestAnimationFrame(() => table.focusRow(at));
return [bar, table.root];
}
+3 -2
View File
@@ -162,10 +162,11 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]:
materials += [ # 고른 로직 자재(103-1) 품목 줄도 — 복사본 계산이 그 단가를 씀
str(key) for one in doc.get("담은로직") or [] for key in (one.get("고름") or {}).values()
]
materials += refs.frozen_materials((labor or {}).get("참조사본") or {}) # 175-2 사전조합 자재
wanted = logic_keys + [code for code in materials if code]
files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {}
if mine := own.bundle(
own.codes(doc), root
): # 개인 자재(148-3) — 원본 목록이 바뀌어도 사본 그대로
[c for c in materials if own.is_own(c)], root
): # 개인 자재(148-3 · 사전조합 것도 175-2) — 원본 목록이 바뀌어도 사본 그대로
files[PRICE_FILE][own.BUNDLE] = mine
return files
+6 -1
View File
@@ -607,8 +607,13 @@ def server(doc: Any) -> dict[str, Any]:
사용자 자재가 로직 자재 품목과 같으면 400(103-1 · 같은 품목 두 번 셈)."""
got = clean(doc)
out = calc(got)
no_twice(got, out)
return {**got, "계산값": out}
def no_twice(got: dict, out: dict) -> None:
"""사용자 자재가 로직 자재 품목과 같으면 400 — 일위대가 · 사전조합(175-1) 저장 한 벌."""
inside = {x["품목"]["키"] for one in out["줄"] for x in one.get("재료") or [] if x.get("품목")}
twice = sorted({m["코드"] for m in got["자재"]} & inside)
if twice:
raise _bad(f"자재 「{', '.join(twice)}」 는 로직 자재에 있음 — 같은 품목 두 번 담음")
return {**got, "계산값": out}
+51 -3
View File
@@ -6,8 +6,10 @@
층 문서를 읽을 때 옛 참조 줄 = 로직 줄 + `승격`(`unpromote`) · 프로젝트 사본은 그대로 셈
precombo `{층}/precombo/SC-######.json` 사전조합
`{종류:"사전조합", 키, 이름, 단위, 본뜬, 담은로직:[{참조, 수량:{손}, 메모}
| {로직, 입력값, 메모, 수량:{손}}]}` — 승격 · 사전조합 · 단가산출 로직(51-11) ·
수량은 손(기본 1) · 값 = Σ 안 줄 1 단위 값 × 수량(로직 줄 = 기준 ÷ 배)
| {로직, 입력값, 메모, 수량:{손}, 고름?, 자재표시?}], 자재:[일위대가 자재 꼴]}` —
승격 · 사전조합 · 단가산출 로직(51-11) · 수량은 손(기본 1) ·
자재 수량은 손 · 빈칸 = 안 셈(175) ·
값 = Σ 안 줄 1 단위 값 × 수량(로직 줄 = 기준 ÷ 배) + Σ 자재 기준단가 × 손
참조 = `{층, 주인, 키}`(마스터 주인 null · 회사 `C` · 개인 `C/U`) — 종류는 키 머리(SP · SC) ·
위 층만 가리킴(개인 → 그 개인 · 그 회사 · 마스터 / 회사 → 그 회사 · 마스터 / 마스터 → 마스터) ·
같은 참조 두 번 · 순환(422)은 저장 때 막음 · 계산 때도 깊이 상한(`DEPTH`)에서 멈추고 까닭.
@@ -142,6 +144,7 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]:
if not name:
raise _bad("사전조합 이름이 빔")
out.update(이름=name, 단위=unit or None, 담은로직=_combo_lines(doc, key))
out["자재"] = _combo_mats(doc, out["담은로직"])
out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")})
out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None
return out
@@ -206,7 +209,7 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]:
head = {"참조": one["참조"]} if "참조" in one else {"로직": one["로직"]}
if "로직" in one:
head["입력값"] = one["입력값"]
head.update({k: one[k] for k in ("켜쌓기율",) if k in one})
head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one})
lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]})
at_ = here()
me = {"층": at_[0], "주인": at_[1], "키": key}
@@ -219,6 +222,18 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]:
return lines
def _combo_mats(doc: dict, lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""사전조합 자재(175-1) — 일위대가 자재와 같은 검사(`labor.clean` · 개인 자재 · 세 칸) ·
로직 자재와 같은 품목 400(`labor.no_twice`)."""
from M02_MasterTemplete import M02_Labor_Doc as labor
mats = labor.clean({"자재": doc.get("자재") or []})["자재"]
if mats:
got = {"담은로직": lines, "자재": mats}
labor.no_twice(got, labor.calc(got, {}))
return mats
def _no_loop(ref: Ref, trail: list[Ref]) -> None:
"""저장 때 순환 · 깊이 — 아래로 따라가 처음 문서를 다시 만나면 422 「A → B → A」."""
if len(trail) > DEPTH:
@@ -446,6 +461,29 @@ class Expand:
why = why or f"「{sub.get('이름')}」 {sub.get('까닭')}"
continue
rows.append({k: v * qty for k, v in sub["기준"].items()})
for mat in doc.get("자재") or []: # 사용자 자재(175-2) = 기준단가 × 손 · 빈칸 = 안 셈
qty = labor._number((mat.get("수량") or {}).get("손"))
if qty is None:
continue
got = labor.material_price(self.whole, mat["코드"], mat.get("단위"))
price = labor._number(got["기준단가"])
zero = dict.fromkeys(labor.COSTS, Decimal(0))
base = None if price is None else {**zero, "재료비": price, "계": price}
name = mat.get("이름") or mat["코드"]
inner.append(
{
"자재": mat["코드"],
"수량": qty,
"이름": name,
"단위": mat.get("단위"),
"기준": base,
"까닭": got["까닭"],
}
)
if base is None:
why = why or f"「{name}」 {got['까닭']}"
continue
rows.append({k: v * qty for k, v in base.items()})
return {
"이름": doc.get("이름"),
"단위": doc.get("단위"),
@@ -498,6 +536,16 @@ def with_pills(doc: dict[str, Any]) -> dict[str, Any]:
return {**doc, "알약": combo_store.combo_pills({"담은로직": lines})["물량"]}
def frozen_materials(copies: dict[str, Any]) -> list[str]:
"""굳힌 사전조합의 자재 · 고른 로직 자재 코드(175-2) — 프로젝트 단가 묶음에 넣음."""
out = []
for doc in copies.values():
out += [str(m.get("코드")) for m in doc.get("자재") or [] if isinstance(m, dict)]
lines = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)]
out += [str(key) for one in lines for key in (one.get("고름") or {}).values()]
return out
def frozen_logics(copies: dict[str, Any]) -> list[str]:
"""굳힌 단가승격 · 사전조합이 담은 로직 키 — 프로젝트 단가 묶음에 넣음."""
return [
@@ -40,12 +40,13 @@ export interface CalcLine {
/** 로직 자재(103-1) — 맨 위 층 재료 줄 · 금액은 `기준` 안(여기서 안 더함) */
재료?: LogicMat[];
/** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④ ·
* 참조 줄 = `참조` · 로직 줄(51-11) = `로직`) */
* 참조 줄 = `참조` · 로직 줄(51-11) = `로직` · 자재 줄(175-2) = `자재` 코드) */
이름?: string | null;
원로직?: string | null;
안?: {
참조?: LaborRef;
로직?: string;
자재?: string;
수량: number;
이름?: string | null;
단위?: string | null;
@@ -10,7 +10,7 @@
* · 자재목록 = 로직 자재 줄(103-1 · `Labor_LogicMats` · [바꾸기] → `담은로직[].고름` · 입력이 바뀌면 걸린 고름 풂) +
* 사용자 자재 줄([삭제]) · 로직 자재와 같은 품목은 사용자 자재로 못 담음
* · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문
* · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) ·
* · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 자재목록(175-1) · 원문(면적 · 다이어그램 없음) ·
* 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1)
* · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음)
* ========================================================================== */
@@ -228,13 +228,13 @@ export function mountLaborDetail(
cur.담은로직,
calc ?? last,
!calc && !fail,
// 세 칸(148-4) = 구조물 일위대가만(사전조합 줄은 `_combo_lines` 가 안 남김)
// 세 칸(148-4) — 사전조합도 같음(175-1 · `_combo_lines` 가 남김)
edit
? {
open: openRow,
swap: swapMat,
reset: resetMat,
mark: lib ? undefined : markLogicMat,
mark: markLogicMat,
}
: { open: openRow },
),
@@ -641,7 +641,7 @@ export function mountLaborDetail(
]),
// 사전조합 = 같은 [로직 추가](51-11)
section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
...(lib ? [] : [section(tc("Labor_Mats"), opt.card ? null : addMat, matHost)]),
section(tc("Labor_Mats"), opt.card ? null : addMat, matHost),
...(opt.card || lib ? [] : [dgBox]),
],
}),
@@ -668,12 +668,11 @@ export function mountLaborDetail(
});
return {
// 서고 모드 = 문서 종류 그대로 · 열 · 자재 없음
// 서고 모드 = 문서 종류 그대로 · 열 없음
getDoc: () => {
if (!lib) return cur;
const out: Record<string, unknown> = { ...cur, 종류: "사전조합" };
delete out.열;
delete out.자재;
return out as unknown as LaborDoc;
},
saveArea: async () => {
@@ -267,14 +267,14 @@ export interface LogicTableData {
}
/** 고른 줄 하위 행(51-2 · 같은 표 · 같은 칸) — 단가산출 · 단가승격 = 그 로직 재료 · 노무 · 경비 줄 ·
* 일위조합 = 세부 항목 한 층(51-13 · 참조 줄 · 로직 줄 · 안을 다시 펼치지 않음 · 금액 = 안 줄 기준 × 안 줄 수량) ·
* 일위조합 = 세부 항목 한 층(51-13 · 참조 줄 · 로직 줄 · 자재 줄 175-2 · 안을 다시 펼치지 않음 · 금액 = 안 줄 기준 × 안 줄 수량) ·
* 금액 칸 = 위 기준(위 줄 기준과 같은 바탕) / 아래 반영(× 위 줄 수량 ÷ 배) ·
* 하위 합 = 위 줄 금액 · 줄이 없으면 빈 한 줄(「…」 · 까닭 · 「—」) */
function subRows(line: LaborCalcLine | undefined, wait: boolean, tools: boolean): HTMLElement[] {
const times = line?.수량 === null || line?.수량 === undefined ? null : line.수량 / (line.배 || 1);
const items = line?.안
? line.안.map((x) => {
const key = x.참조?.키 ?? x.로직 ?? "";
const key = x.참조?.키 ?? x.로직 ?? x.자재 ?? "";
const label = x.이름 && x.이름 !== key ? `${key} ${x.이름}` : key; // 로직 줄 이름 없으면 키만
return {
name: `${label}${x.까닭 ? ` · ${x.까닭}` : ""}`,
@@ -0,0 +1,45 @@
# 사용자 요구 10-10 둘째 묶음 (원문 · 번호만 붙임)
지시: 신규 계획서 작업 및 코드 작업.
## M02 일위대가 사전조합
1. 구조물조합의 일위대가와 동일하게 자재목록도 있어야함. 공통양식이니 코드 재활용
## B05 3D
2. 성토의 스무딩처리가 깨지는 경우가 더 심해짐
3. 흑백지형을 기본값을 false로
4. 등고선도 기본값을 false로
5. 예전로직으로 3D에 측점의 노란색 선 끝단에 주황색 구슬을 선택하여 측구의 방향을 선택하는 기능이 있었음. 3D에서 구슬 삭제
## B05/06
6. 좌측패널의 구조물 조합 연결여부 색상선택은 반영은 잘되었고 연결이 안된 항목은 경고성으로 노란색 계열로 반영
7. 오류 확인및 원인 파악(흙막이휴 횡단도 형상 반영 문제)
8. 동물이동형도 체크박스에서 드롭다운 메뉴로 변경 (항목값. 유 / 무)
9. 구조물 리스트를 선택하는 기능 노면공 선택을 누르면 구조물선택은 되나 박스형상을 잡도록 변경하고 횡단도 컨테이너 선택을 안해도됨.
10. 횡단도 컨테이너 선택시 종단에 측점 세로선 하이라이트 연동이 안됨. 하이라이트 기능 제대로 확인할 것.
11. 사면안정군의 종류들이 기준측점 상단의 범위를 알려주는데 필요없음
12. 소단은 옵션값 중 높이는 필요없음. 형상필요. 성토사면에 도로에서 높이 1m를 기준으로 성토사면에 수평선을 긋고 하단부에 성토를 이으면 됨. 이때 소단의 Y값을 위치를 제어할수 있어야하면 도로보다 높을 수 없음
13. 사면안정군의 옹벽과 흙막이의 경우 단이 존재할수 있음. 기존 유출 컨테이너를 보면 양식 있음. 재활용
## B06 횡단도
14. 횡단도에서 선의 길이를 알려주는 기능에서 정보창이 줌인아웃 버튼의 좌측에 배치
15. 횡단도의 측구여부와 좌우 방향을 선택하는 버튼의 비활성화 여부 확인(확인만 코드 요청아님)
16. 횡단도 내부의 측구관련 정보들이 많은데 조금 컴팩트하게 표현할수 있는 방법이 없는지 제안 및 반영
17. 횡단도의 상세 모달창에서 팬동작으로 그래프 전체가 움직임. 축은 고정하고 내부 내용이 동작해야함. 그리고 하단 테이블의 글자를 더 큰글자로 변경, 항목이름의 셀은 색상 다른게
18. 횡단도의 상세 모달창에서 각 항목의 열이나 행을 추가하는 기능을 부여하고 각 셀에대한 특정항목을 값을 그래프에서 선택할 수 있는 기능을 부여. 선택은 면적과 길이가 될것같고 이때 마우스 우클릭을 하여 값 재 지정을 할수 있게 해줘.
## B07 상세설계
19. 횡단도의 격자에 배치를 옮기기 위해 선을 선택할때 원지반선이 안잡힘. 추가로 레이어(도면층을 활용하여 해당도면에 측점을 한번에 잡을 수 있게 반영)
20. 횡단도의 붉은색 격사선을 하이드쇼할수 있는 버튼을 횡단도 컨테이너 내부에 존재
21. 횡단도의 테이블을 현재값을 1로 두고 테이블과 텍스트의 크기를 스케일 조절할 수 있게 반영, 기본값 1
## B09 원가검토
22. 일위대가표와 단가산출근거에서 구현한 호표별 보기는 이름을 박스보기로 변경하고 접었다 펼 필요는 없음(접으면 목록표와 동일함으로). 그리고 목록표에서 행을 선택할때는 해당 호표를 외곽선을 붉은 색 하이라이트로 구현해줘. 테이블 보기도 역시 해당 호표의 제목이 아니라 영역이 하이라이트 되어야함.
23. 설계내역서에 행을 추가하는 항목이 완료되었는데 이방식은 레벨구분이 없음. 레벨을 구분하고 생성할수 있도록 들여쓰기와 폰트로 구분 사이즈로 구분하여 표기하였으면함. 중요한점은 각 행에대해 레벨이 구분되어야하면 레벨에 따라 상위로 숫자들도 합계되는 구조임. 여기에 단가산출과 일위대가를 연결할 수 있어야함. 마우스 우클릭이과 버튼으로
24. 재료비목록표는 연결되어 있는 일위대가에 대한 내용이 들어와야하는데 지금 어떤값이 들어왔는지 이해가 안됨.
25. 자재단가대비표에 들어오는 견적에는 면이 필요없으며, 다른 항목의 면은 출처로 변경(값은 유지), 견적서는 견적 하위로 이동
26. 자재단가대비표에 적용 열은 사용자가 수기입력이 아니라 적용단가 셀을 선택하면 드롭다운 메뉴로 항목들을 보여주고 선택을 하면 그값을 보여주면 됨. 이때 적용된 값은 하이라이트 표시, 기본값은 최저단가로 반영
27. 관급에 대한 현재 구현 여부를 조사만해서 알려줘. 별도의 아티팩트로 보여주면 됨
28. 지제단가대비표에는 수량열이 빠지는게 맞음.
29. 원가검토 페이지의 상위탭과 하위탭의 구성을 하려고 함. 양식은 B08의 페이지 구성을 보면됨.
30. 원가검토 페이지의 탭구성, 일위대가(일위대가목록표, 일위대가표), 단가산출(단가산출근거목록표, 단가산출근거), 중기(중기목록표, 중기사용료, 중기시간금액집계표, 경비목록표, 경비수량금액집계표), 재료(재료비목록표, 자재단가목록표, 재료비수량금액집계표), 노무(노무비목록표, 노무비수량금액집계표)<<<지정안된 항목들은 상위탭임.
@@ -0,0 +1,91 @@
# 조사 T — M02 사전조합 자재목록 · B05/B06 패널(연결 라벨 노랑 · 동물이동형 드롭다운 · 노면공 박스 · 측점선 연동 · 사면안정 범위) (10-10 · 읽기 전용 조사 에이전트)
원문 = `docs/raw/요구_1010_2/사용자요구_1010_2.md` 1 · 6 · 8 · 9 · 10 · 11번.
| 약칭 | 파일 |
|---|---|
| LD · ROWS · LM · MP | `M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts` · `…_Labor_Rows.ts` · `…_Labor_LogicMats.ts` · `M02_Material_Pick.ts` |
| LR · LDOC · LRT · ISRC | `M02_MasterTemplete/M02_Labor_Ref.py` · `M02_Labor_Doc.py` · `M02_Labor_Router.py` · `M02_Item_Source.py` |
| B8M · B9L | `B08_Quantity/B08_Quantity_Router_Material.py` · `B09_Estimation/B09_Estimation_Labor_Source.py` |
| SR · SS · TH | `B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts` · `B05_Profile/B05_Profile_UI_Style_Structures.css` · `ui_template/ui_template_theme.css` |
| PIPE · SPC · SF · DEF | `A00_Common/design/design_structure_pipe.ts` · `…/design_structure_spec.ts` · `…/design_structure_form.ts` · `resources/master_template/구조물정의.json` |
| SV · SVT · SVP · SPN | `B06_Section/B06_Section_UI_Section_View.ts` · `…_Section_View_Types.ts` · `…_Section_View_Profile.ts` · `…_UI_Page_Structures_Panel.ts` |
| DVC · DP · PST · CSX | `A00_Common/design/design_view_cross.ts` · `A00_Common/design/design_profile.ts` · `B05_Profile/B05_Profile_UI_Profile_Structures.ts` · `B06_Section/B06_Section_UI_Style_Cross.css` |
| FORM · PNL5 · PT · MARKS | `B05_Profile/B05_Profile_UI_Structures_Form.ts` · `…_Structures_Panel.ts` · `…_Structures_Panel_Types.ts` · `…_Structures_Marks.ts` |
| T | `resources/tester/` |
## 0. 코드 지도
| 요구 | 손댈 층 | 계산 자리(5장) | 저장 |
|---|---|---|---|
| T1 사전조합 자재목록 | LD(문 셋 지움) · LR · B8M · B9L · ISRC | 화면 = 서버 calc 답 그대로 · 사전조합 1 단위 값 · B08 · B09 펼침 = ③ 서버 단독 | 사전조합 문서 `{층}/precombo/SC-*.json` 에 `자재` · 줄 `고름` · `자재표시` 새로 남김 |
| T6 미연결 라벨 노랑 | SS 한 규칙 | 화면만 | 없음 |
| T8 동물이동형 드롭다운 | PIPE 한 칸 · SPC · SF 지우기 | 저장만(읽는 곳 없음) | `pipe_points.json` `options.inlet_basin_animal` 키 · 값("true"/"false") 그대로 |
| T9 노면공 박스 | SV · SPN · SVT · DVC 한 줄 | 화면만 | 세션 고름만(카드 고름 안 적음) |
| T10 카드 → 종단 측점선 | DP 두 줄(줄 수 그대로) · SVP 두 곳 | 화면만 | 없음 |
| T11 사면안정 범위 | PNL5 한 줄(줄 수 그대로) | 화면만 | 없음 |
## T1. M02 일위대가 사전조합 — 구조물 조합 일위대가와 같은 자재목록
- 현재: 사전조합 편집 = 일위대가 상세와 **같은 부품** `mountLaborDetail` 의 서고 모드(MAIN:416-420 · `mode: "precombo"`). 자재목록(로직 자재 줄 LM + 사용자 자재 줄 + [자재 추가] MP + 세 칸 자재구분 · 별도계상 · 운반비 168장)은 이미 한 벌(`matTable` ROWS)인데 서고 모드에서만 막음: LD:644(`lib ? [] : [section(Labor_Mats…)]`) · LD:237(로직 자재 세 칸 `mark: lib ? undefined`) · LD:671-677(`getDoc` 이 `자재` 지움). 셈 길은 이미 같음 — 사전조합도 `/labor/{SC키}/calc`(LRT:41-55 · `labor.clean` → `labor.calc` 가 `자재` 를 셈 LDOC:439-481).
- 서버가 버리는 자리: 저장 `clean_doc` → `_combo_lines`(LR:134-147 · 199-219)가 줄에서 `참조 · 로직 · 입력값 · 켜쌓기율 · 수량 · 메모` 만 남김 → `고름`(로직 자재 [바꾸기]) · `자재표시`(세 칸) · 문서 `자재` 가 저장 때 사라짐.
- 값이 안 닿는 자리(자재를 담아도 아무 데도 안 셈): ① 사전조합 1 단위 값 `Expand._combo`(LR:420-455 · 안 줄 기준 × 손 수량만 · 구조물 일위대가의 참조 줄 값 · 목록 기준금액이 이것) ② B08 참조 줄 재료 `_ref_mats`(B8M:57-96 · 160-3) ③ B09 겹 대가 블록 `precombo`(B9L:208-239 · 자재 줄 없음 · 조합 블록은 B9L:265-268 이 자재 줄 냄) ④ 프로젝트 사본 단가 묶음(ISRC:157-163 · 구조물 `자재` · `고름` 코드만 모음 · 굳힌 참조사본 안 사전조합 자재는 안 모음 → 사본 셈 단가 없음).
- 고칠 자리:
- 화면(LD · 지우기만): 644 의 `lib ?` 조건 · 237 의 `lib ? undefined :` · 676 `delete out.자재`. [자재 추가] · 수량 모달(서고는 손만 LD:329) · 같은 품목 막음은 그대로 탐.
- 저장(LR:199-219): 줄에 `고름` · `자재표시` 남김(`labor.clean` 이 이미 검사) · `clean_doc` 에 `자재 = labor.clean({자재}).자재`(개인 `PM` 검사 · 로직 자재 같은 품목 400 = LDOC:605-613 `server` 길 재사용).
- 값(③ 서버 단독 · 파이썬만): LR `_combo` 끝에 사용자 자재 행 = `labor.material_price(self.whole, 코드, 단위)` × 손 수량 → `{재료비, 계}`(LDOC:399-403 `settle` 꼴) · B8M `_ref_mats` 에 문서 `자재` 를 `(재료 줄, 손)` 으로 더함(`logic_mat` 이 코드로 이름 · 규격 맞춤) · B9L `precombo` 줄에 `kind: "material"`(B9L:265-268 꼴) · ISRC:160-163 에 굳힌 사전조합 `자재` · `고름` 코드 더함.
- 재사용: 자재 표 · 세 칸 · 고르기 = 구조물 일위대가 그대로(LD · ROWS · LM · MK · MP) · 자재 단가 `material_price` · 참조사본 굳힘 `frozen`(LR:465-480 · 문서째 굳으니 `자재` 도 따라감).
- 시험: T test_m02_labor_mat_marks:128(`mark: lib ? undefined` 단정 고침) · test_m02_card_shell:85-86(무변 확인) · test_m02_labor_ref(사전조합 저장 꼴 · 값 = 로직 + 자재) · test_160_3_b08_unmatched_total(참조 줄 재료 + 사전조합 자재) · test_145_2_b09_labor_calc(겹 대가 자재 줄) · test_m02_precombo_class · 새 시험(저장 왕복 `자재` · `고름` 남음 · `_combo` 재료비 = 단가 × 손 · 빈 수량 안 셈).
- 위험: 구조물 일위대가의 자재목록은 참조 줄(사전조합) 안 자재를 지금도 안 보임(로직 자재 줄 = 로직 줄만 · 이번 밖 · 기록) · 사본 단가 묶음에 코드가 빠지면 B09 단가 빈칸 · LD 692줄(지우기만 · 늘리지 말 것).
- 크기: 중간~큼(화면 작음 · 서버 네 자리).
- ⬜ 범위: (가) 화면 · 저장 · 사전조합 값 · B08 · B09 · 사본 묶음 한 번에 / (나) 화면 · 저장 · M02 값까지(B08 · B09 · 묶음은 다음 장) — 추천 (가)(담은 자재가 아래에서 조용히 빠지지 않게).
- ⬜ 사전조합 사용자 자재 수량 기본: (가) 빈칸 = 안 셈(일위대가 자재와 같음) / (나) 손 1(사전조합 로직 줄 51-6 과 같음) — 추천 (가).
## T6. 구조물 조합 라벨 — 미연결 = 노란 경고 배경
- 현재: 라벨 = SR 한 벌(B05 폼 FORM:149 · 옵션 그룹 `B05_Profile_UI_Structures_Groups.ts`:190 · 명세 폼 SF:272 — M02 는 안 씀). 연결 = `is-assigned`(SR:58) → SS:450-454 초록 배경 + 테두리(173-1). 미연결 = 클래스 없음 · 「미지정」 글 · 공용 입력 칸 바탕.
- 토큰: `--color-warning` 있음(TH:39 밝음 `#b8860b` · TH:322 어두움 `#e0b64a`). 같은 꼴 선례 `A00_Common/spreadsheet/spreadsheet.css`:206(`color-mix(warning 15%, surface-raised)`).
- 고칠 자리: SS:454 뒤 한 규칙 `.b05-structure__summary-name:not(.is-assigned)` = `border-color: var(--color-warning)` · `background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface))`(173-1 과 대칭 · 글자색 없음). TS 안 고침.
- 시험: T test_173_1_summary_assigned_bg 에 함수 하나(미연결 규칙 · warning · surface · color 없음) · test_b05_structures_form_summary(무변).
- 위험: 숨은 줄(`[hidden]` SS:440)은 안 보임 · 하단 목록 줄 미지정 표시는 요구 밖.
- 크기: 작음.
## T8. 집수정 「동물이동형」 체크박스 → 드롭다운(유 / 무)
- 현재: 칸 = PIPE:73-80(`kind: "bool"` · `default: "true"` · 집수정 갈래 키 `inlet_basin_animal`) · 그리기 = SF:147-153(공용 체크박스 · `.value` = "true"/"false") · 꼴 정의 SPC:35-36 · 체크박스 import SF:44 · 폼 키 목록 MRG:31. `bool` 꼴을 쓰는 칸은 이것 하나 · 읽는 곳 없음(저장만).
- 고칠 자리: PIPE:77 `kind: "select"` + `choices: [{ value: "true", label: "유" }, { value: "false", label: "무" }]`(default "true" 그대로). 그러면 SF:147-153 · SF:44 · SPC:35-36 의 `bool` 은 죽은 갈래 → 지움. DEF 이름 표에 `inlet.animal=true: 유` · `inlet.animal=false: 무`(DEF:3335 옆 · M02 이름 표 `nameTable` SPC:249 와 맞춤 · 없어도 보기 글은 명세 글).
- 빈 값 = 유: `inlet` 은 필수 상자 → 빈 고르기 칸은 그릴 때 기본값을 **실제 값으로 적음**(SF:224-232 · 54-43) → 옛 관 · 자동 관도 「유」로 보이고 다음 [저장]에 "true" 가 적힘. 173-7 「빈 값 저장 안 함」만 바뀜(무해 · 읽는 곳 없음). 흙막이로 바꾸면 갈래 밖 → 비움(SF:206-212 · MRG 키 목록 그대로).
- 재사용: 명세 select · `by` 갈래 · 필수 상자 기본 채움(그대로).
- 시험: T test_173_7_inlet_animal + helper_173_7_inlet_animal.cjs 고침(select · 빈 값 = "true" 적힘 · 무 = "false" · 흙막이 칸 없음) · test_52_structure_spec_pipe · test_b05_facility_options_merge(무변).
- 위험: 없음(서버 · B07 · B08 안 읽음 · `bool` 지워도 M02 `OptionForm` "bool"(`M02_SO_Def_Model.ts`:9)은 다른 꼴 · 무관).
- 크기: 작음.
- ⬜ 저장 값: (가) 키 · 값 그대로("true"/"false" · 보기 글만 유/무 · 이주 없음) / (나) 값을 "유"/"무" 로 — 추천 (가).
## T9. 목록 · 알약에서 노면공 고름 = 박스 형상 · 카드 하나 고르기 없음
- 지금 흐름(목록 누름): 공용 폼 `onSelect` → SPN:210-224 → `focusChainage(focusOf)`(SPN:220 · 범위 안 기준점에 가까운 카드 하나를 **고름** = `--selected` 진한 빨강 테두리 · 세션 · 측점 리스너) → `markStructure(id)` → SV `selectMark`(SV:577-584) → `applySpan`(SV:254-262).
- 「박스 형상」 판정: 다른 구간형(옹벽 · 흙막이 · 사토장) 고름 = 범위 안 카드 **전부** `b06-cross-card--span` 테두리(SV:260 · CSX:231-234 옅은 빨강 2px · 106-1). 노면공(G군)만 152-5 에서 테두리를 빼고 포장층 강조만(`span && !pave` SV:260 · `highlightPavement` `B06_Section_UI_Cross_Design_Surface.ts`:70-86). → 「박스」 = 이 범위 카드 테두리.
- 고칠 자리: ① SV:260 `span && !pave` → `span`(포장층 강조 SV:261 은 둠) ② SPN:220 — 노면공(`!coverable(structure)` SPN:163)이면 카드를 고르지 않고 보이게만: SV 핸들에 `revealSpan(chainageM)` 하나(옛 고른 카드는 리스너 없이 풂 = `selectedStationId = null` · `markCard` · `profile.showCard(null)` · 그 카드로 `revealCard`(SV:208)) · SVT:44 옆 한 줄 · DVC:189-191 옆 deps 한 줄 · SPN deps 한 줄. 리스너(`setStationSelectListener` DVC:381)를 거치면 `showAtChainage(null)` 이 폼을 닫음 → 꼭 리스너 없이.
- 그대로 둠: 범위 안 카드 누름 = 노면공 유지(165-1 · SPN:551-560) · 재누름 유지(167-3 SV:288-298) · 알약 누름(SVP:204-212 → `selectStructure`)도 같은 `onSelect` 길.
- 시험: T test_106_span_highlight_wording:34(`span && !pave` 단정 고침) · test_165_pavement_flow(step1 · step5 카드 40 → 없음 · test_배선 `focusChainage` 단정) + helper_165 · test_152_5_pavement_pick(무변 확인) · 새 시험(노면공 고름 = `--span` 범위 카드 수 · `--selected` 0 · 폼 · 알약 유지).
- 위험: SV 614줄(+ ~12) · DVC 680줄(+1 · 늘리지 말 것 — 가능하면 SPN 이 이미 받는 deps 만) · 카드 고름이 없으니 [상세] · 조정창 등 카드 대상 동작은 카드를 눌러야 함.
- 크기: 중간.
- ⬜ (가) 노면공 = 범위 카드 전부 테두리 + 포장층 강조 · 카드 하나 고르기 없음(첫 카드로 스크롤만) / (나) 테두리만 더하고 가까운 카드 고름은 그대로 — 추천 (가).
## T10. 횡단 카드 고름 → 종단 측점 세로선 하이라이트가 안 따라옴
- 연동 길: 카드 누름 → SV `selectStation`(SV:288) → `applySelection`(SV:279-286) → `profile.showCard`(SVP:270-285 → `setSelectedStation(station_id)` DP:576-581 · 측점선 켬) → 그 **뒤** 측점 리스너(DVC:381-405) → `sectionView.selectMark(mark, false)`(DVC:398) → SVP `selectMark`(SVP:266-269) → `profile.setSelectedStructure(mark)`(DP:626-631).
- 원인(코드 판정): DP:629-630 `keep = 구조물 ≠ null && 그 측점 구조물 === 구조물` · 아니면 `selectedStationId = stationIdAtStructure(구조물)`. 일반 카드는 mark = null → 측점선 = null(**방금 켠 선을 지움**) · 덮는 구조물(벽 · 노면공) 카드도 측점선 없는 구조물(PST:173-184 · 관만 선이 있음) → null. 152-5(4ae7ee25c)가 DVC:398 의 `if (mark)` 를 빼면서 모든 일반 카드에서 지움 · SPN:214(`markStructure(null)`)와 SVP:283(`setSelectedStructure(markedId)`)도 같은 함수로 지움. 관 카드만 mark = `pipe-…` → 관 측점선이라 맞음. 반대 길(종단 측점 누름 → 카드)도 같은 리스너를 타서 누른 선이 곧 꺼짐. 측점 키 · 반올림은 무관(카드 · 종단 같은 `station_id` · 관 맞춤 0.51 m SVP:276-279).
- 고칠 자리(모든 B06 길이 지나는 한 곳): DP:626 `setSelectedStructure(structureId, keepStation = false)` · DP:630 `selectedStationId = keep ? selectedStationId : (stationIdAtStructure(structureId) ?? (keepStation ? selectedStationId : null))`(줄 수 그대로 · 698줄) · SVP:268 · SVP:283 에 `true`(B06 = 카드가 측점선 주인). B05(`design_view_profile.ts`:132 · 331)는 기본값 → 지금 그대로.
- 재사용: 판단을 순수 함수로 PST(184줄)에 빼면(`stationAfterPick(지금, 구조물, keep, 선)`) Node 시험 가능 · DP 는 부름만.
- 시험: 새 시험(일반 카드 → 측점선 유지 · 벽 카드 유지 · 관 카드 = 관 선 · B05 목록 비관 구조물 = 선 끔 그대로) · test_165_pavement_flow · test_b05_b06_selection_single(무변 확인).
- 위험: DP 698줄 — 줄 늘리지 말 것 · 목록 해제는 `releaseCard` → `showCard(null)` 이 선을 끔(유지 확인).
- 크기: 작음.
## T11. 사면안정군 「기준측점 위 범위」 — 필요 없음
- 코드 판정: 「기준 측점 위 범위」 = 좌측 폼 **「측점 범위」 줄**(FORM:98-108 · 위치 줄 첫 칸 = 시작 · **기준 측점** · 종료 칸 위). 켜는 조건 `hasComputedRange`(PT:242-252 · 구간형 + B05 `length_m` 옵션) → PNL5:156-169 가 `시작 ~ 종료` 를 계산해 보임. 해당 종류(DEF 기준): C 사면안정 6(retaining_wall · masonry_wet · masonry_dry · soil_guard · boulder_masonry · berm) + B ditch_berm + E refuge · work_yard · guardrail. B05 · B06 같은 폼 한 벌.
- 종단도 확인: 사면안정 전용 범위 그림 없음 — 범위 띠는 알약 레인(MARKS:171-186 · **모든** 구간형 B · C · E · F · G · 09-07 사용자 지시로 늘 보임 · 기준측점 알약 줄 가운데)뿐 → 이번에 안 건드림 추천.
- 고칠 자리: PNL5:159 `const computed = hasComputedRange(currentType()) && currentType()?.group !== "C";`(줄 수 그대로 · 699줄). `hasComputedRange` 자체는 시작 · 종료 칸 숨김에도 쓰므로 안 고침.
- 시험: 새 소스 시험(PNL5 `rangeWrap.hidden` 조건에 C군 거름) · test_b05_structures_registry_policy · test_54_6_slope_container(무변).
- 위험: C군은 폼에서 범위를 볼 곳이 없어짐(하단 목록 측점 · 알약 띠 · 벌룬엔 남음) · PNL5 699줄 — 한 줄도 늘리지 말 것.
- 크기: 작음.
- ⬜ 지울 자리: (가) 좌측 폼 「측점 범위」 줄 — C군만 / (나) 그 줄 전부(B 소단배수로 · E 대피소 · 작업장 · 가드레일도) / (다) 종단 알약 레인 띠 중 C군 — 추천 (가).
## 고칠 파일 · 겹침
- 700 위험: LD 692(T1 지우기만) · DP 698(T10 줄 수 그대로) · PNL5 699(T11 줄 수 그대로) · DVC 680(T9 +1) · SV 614(T9 + ~12) · SS 628(T6 +5).
- 겹침: T9 · T10 = SV · SVP · DVC 같은 고름 길(한 사람이 차례로 · T10 먼저 — T9 의 `showCard(null)` 이 T10 유지 규칙에 기댐) · T6 · T8 = 좌측 패널 같은 폼(파일 다름) · T1 은 M02 · B08 · B09 서버(다른 창과 안 겹침).
+72
View File
@@ -0,0 +1,72 @@
# 조사 U — B05 3D(성토 깨짐 · 흑백 · 등고선 기본값 · 측구 방향 구슬) (요구 1010_2 2~5번 · 읽기 전용 조사 에이전트)
약칭 `B05_Profile/B05_Profile_` + LO `UI_Corridor_Loft.ts` · LT `UI_Corridor_Loft_Toe.ts` · TIN `UI_Corridor_Tin.ts` · SH `UI_Corridor_Loft_Shade.ts` · VC `UI_Viewer_Corridor.ts` · VW `UI_Viewer.ts` · PV `UI_Panel_View.ts` · PF `UI_Panel_Fields.ts` · PN `UI_Panel.ts` · MK `UI_Markers.ts` · PS `UI_Page_Stations.ts` · PH `UI_Page_Helpers.ts` · PA `UI_Page_Actions.ts` · AF `Api_Fetch.ts` · RL `Router_Lifecycle.py` · RC `Router_Confirm.py` · SC `Schema.py` / DVP `A00_Common/design/design_view_profile.ts` · DS `A00_Common/design/design_save.ts` · CD `B06_Section/B06_Section_UI_Cross_Design.ts` · CC `B06_Section/B06_Section_UI_Cross_Card_Chrome.ts` · B6C `B06_Section/B06_Section_Router_Confirm.py` · T `resources/tester/`
## 0. 코드 지도
| 요구 | 손댈 층 | 계산 자리(5장) | 저장 |
|---|---|---|---|
| U2 성토 깨짐 | TIN 한 상수 + LO 덩이 끝 줄이기 ~10줄 | 브라우저 단독 3D 표시(VC:199 → LO) · 서버 Node(`B05_Profile_Corridor_Node.ts`)는 노면 리본만 · 사면 망은 저장 안 함 → 짝 · 스냅샷 없음 | 없음 |
| U3 흑백 기본 false | PV:71 · VW:390 | 화면만 | 없음(세션 · 사용자 설정 키 없음) |
| U4 등고선 기본 false | PV:65 · VW:184-185 | 화면만 | 없음 |
| U5 구슬 삭제 | MK + DVP 처리기(⬜ 세션 · 서버 사슬) | 화면만 | 세션 `uphill` → 확정 `uphill_overrides`(⬜ 남김 / 지움) |
## U2. 성토 스무딩 깨짐 — 172 뒤 심해짐
- 어떻게 쟀나: 조사 P 입력 그대로(226 · d22 · `aislo_3d_probe/input_*.json` · storage `dtm_classification_smooth_preview.glb` · 읽기만) · 지금 TS 와 `cea89e945~1` TS(git show → 메모리)를 Node 로 그대로 돌리고 안은 메모리에서만 끼움 · 스크래치 `probe_u.cjs` · 저장소 변경 0.
- 172 가 바꾼 것(cea89e945):
- LO:341-355 성토 행 드레이프 보정량을 노선 따라 가우스 σ 3 m(LT:45-69 · `only` 행만 셈) · 측점 행 되돌림(LT:73-99 `pinBack`) · 원래와 차 ±0.5 m 한도 = `toeOff`.
- LO:425-427 비탈 끝 점 높이 = 지형 + toeOff(전 = 지형 그대로).
- LO:376 측점 앞 0.2 m 덧행 없앰 · LT smoothToe 는 같은 동작으로 함수만 꺼냄.
- 원인(판정): **비탈 끝이 지형에서 뜨거나 잠겨 바깥 지형과 잇는 띠가 가파른 턱 · 홈이 됨** — 성토 면 안쪽 주름은 줄었으나 비탈 끝 가장자리가 깨짐.
- 성토 비탈 끝 둘레 점 |높이 − 지형| 중앙 0 → 0.26 · p90 0 → 0.5 m · 성토 행 25 / 22 % 가 한도 0.5 m 에 걸림.
- 잇는 삼각형 = 설계 구역 밖 · 지형 색 · 면 법선(TIN:287-311 · SH:40-48) · 비탈 끝에서 가장 가까운 지형 정점(DTM 1.08 m 격자 · 0~1 m 거리)까지 바로 이음 — 지형 정점은 둘레 1 mm 안 것만 뺌(TIN:42 · 92) → 0.5 m 턱이 0.5 m 안팎 거리 = 45° 넘는 벽 · 홈.
- 성토 ↔ 절토 바뀜 — 성토 덩이 끝 행 toeOff 14 / 14 곳 다 0.5 m(한도) · 이웃 절토 비탈 끝은 지형 그대로 → 둘레가 0.2~0.4 m 사이에서 0.5 m 뜀(톱니).
- 성토 면 정점 중 지형보다 0.2 m 넘게 낮은 것 3,792 → 5,662 / 3,442 → 4,649(비탈 끝 홈).
- 아닌 것: 덧행 없앰 — 되살려도 가장자리 수치 그대로 · 면 안쪽은 오히려 나빠짐(10° 넘음 90 → 106 / 88 → 103) · 제약 실패 3 / 2 그대로 · 삼각형 뒤집힘 없음(평면 들로네 · 감기 맞춤 TIN:292-295) · 구멍 없음.
- 측정(per 100 m · 226 / d22):
| 안 | 잇는 띠 60° 넘음 | 성토 ↔ 잇는 띠 꺾임 45° 넘음 | 면 안쪽 10° 넘음 | 둘레 성토↔절토 뜀 0.2 m 넘음(곳) |
|---|---|---|---|---|
| 172 전 | 17 / 10 | 18 / 18 | 149 / 132 | 0 / 0 |
| 지금(172) | **76 / 67** | **51 / 42** | 90 / 88 | 11 / 14 |
| ㉮ 되돌림(git revert) | 17 / 10 | 18 / 18 | 149 / 132 | 0 / 0 |
| ㉯ 한도 0.25 m | 48 / 43 | 40 / 35 | 107 / 96 | 11 / 14 |
| ㉰ 비탈 끝 = 지형 · 면만 고름 | 17 / 10 | 32 / 31 | 139 / 137 | 0 / 0 |
| ㉱ 잇는 띠 넓힘 1.5 m | 11 / 10 | 12 / 14 | 90 / 88 | 11 / 14 |
| **㉱ + 덩이 끝 3 m 줄이기** | **11 / 9** | **11 / 13** | **93 / 89** | **0 / 0** |
- ㉰ = 턱이 성토 면 마지막 칸으로 옮겨 갈 뿐(둘레 꺾임 20° 넘음 35 → 47 / 30 → 43).
- ㉱ 2 m 는 1.5 m 와 비슷(60° 넘음 10 / 9) · 1 m 는 14 / 9 · 계산 약 1.0 s 그대로 · 제약 실패 3 → 2 / 2.
- 고칠 자리:
- ㉱ TIN:92 지형 정점 「안」 판정 거리 `ON_BOUNDARY_M`(1 mm) → 새 상수 `SEAM_M` 1.5 m(정점 판정만 · 변 판정 TIN:150 은 그대로) → 비탈 끝 1.5 m 안 지형 정점을 빼서 잇는 삼각형이 1.5~2.5 m 로 길어짐(0.5 m 턱 ≈ 15~18°).
- 덩이 끝 줄이기 LO:351-355 뒤 — 성토 아닌 행에서 누가거리 3 m 안 toeOff 를 smoothstep 으로 0 까지 줄이고 liftOf 도 같은 만큼 되돌림(~10 줄 · LO 665 → 약 675 줄).
- 덤(선택): LT:73-99 pinBack 이 고정 행(측점)을 성토 덩이 넘어 다른 덩이 측점까지 이음 → 짧은 덩이(20 m · 측점 1 개) 한도 걸림 59 % · 74 % · 덩이 끝 줄이기로 대부분 덮임.
- 시험: T test_172_1 · test_115_10 · test_135_3 · test_b05_corridor_*(그대로) · test_115_8 `lostVerts == 0` — T helper_115_8_corridor_loft.cjs:277 이 1 mm 기준 → SEAM_M 로 같이 고침(1.5 m 안 빠짐은 의도) · `npm run typecheck` · 새 시험(물결 지형 성토 · 비탈 끝 아래 사인 진폭 1 m): 잇는 삼각형 경사 p99 < 45° · 덩이 끝 toeOff = 0 · 틈 0 · 제약 실패 0.
- 위험: 비탈 끝 바깥 1.5 m 안 지형 굴곡이 긴 삼각형으로 펴짐(절토 둘레 · 구조물 구멍 둘레도 같은 규칙 · 절토 잇는 띠 45° 넘음 91 → 79 로 오히려 고름) · 사진 색은 버린 삼각형에서 보간(TIN:263-274)이라 그 띠 색이 흐려짐 · 3D 참고용이라 수량 무관.
- 크기: 작음(TIN 2 줄 + LO ~10 줄 + 시험 도우미 1 줄).
- ⬜ 고칠 방향: (가) ㉱ + 덩이 끝 줄이기(172 주름 줄임 유지 · 가장자리는 172 전보다 고름) / (나) ㉮ 되돌림(가장자리 예전 · 주름 다시 181 per 100 m) / (다) ㉯ 한도 0.25 m(한 줄 · 반만 나음) — 추천 (가).
- ⬜ 띠 넓이: 1.5 m / 2 m — 추천 1.5 m.
## U3 · U4. 흑백 지형 · 등고선 기본값 false
- 현재: 단추 처음 상태 = PV:65 `toggleButton("등고선", true, …)` · PV:71 `toggleButton("흑백 지형", true, …)`. `toggleButton`(PF:117-135)은 처음 상태를 칠하기만 하고 콜백을 안 부름 → 뷰어 처음 값이 따로: 흑백 VW:390 `let surfaceGrayscale = true` · 등고선 VW:184-185 그룹(visible 설정 없음 = 보임). 콜백 연결 DVP:262 · 266.
- 덮는 저장: 없음 — 두 단추 상태는 sessionStorage · localStorage · 사용자 설정에 안 적음(검색 0) · 다시 들어오면 늘 코드 기본값.
- 고칠 자리: PV:65 · 71 `true → false` + VW:390 `false` + VW:185 뒤 `contours.visible = false` — 두 쪽 같이(한쪽만이면 단추는 꺼짐인데 화면은 켜짐). 주석 PV:69-70 · VW:389(「기본 켜짐 08-23 확정」) 고침 · PN:45 주석(「기본 꺼짐」)은 이번에 맞게 됨.
- 이어지는 것: 흑백 끔 = 지형 무지개 고도색 · 예상형상 청록 · 자주는 keepColor 라 그대로(VW:411) · 등고선은 적재 때 그대로 만들고(VW:483) 숨기기만.
- 시험: T test_135_2_grayscale(기본값 단정 없음 → 그대로) · 새 소스 시험(PV 두 단추 false · VW 처음 값 false · contours.visible = false).
- 위험: 08-23 사용자 확정(흑백 기본 = 예상형상 색이 고도색에 안 묻힘)을 뒤집음 — 이번 요구가 명시라 따름.
- 크기: 아주 작음(4 줄 + 주석).
## U5. 3D 측점 노란 띠 끝 주황 구슬(측구 방향 고르기) 삭제
- 현재:
- 그리기 MK:467-491 — 측점마다 띠 좌 · 우 끝에 구(반지름 0.55 · 상단측 주황 0xf97316 · 반대 회색 MK:57-59) + 투명 맞힘 구(1.1). 띠 = 반폭 × 1.5(MK:427).
- 클릭 MK:556-563 — `selectObject` 가 `uphillStationId` 를 측점 선택보다 먼저 봄 → DVP:359-377 그 측을 세션 `uphill` 키에 적고(PH:238-268) 띠 다시 그림(PS:63-64 = 구 색).
- 저장 — B05 [저장]·[확정] PA:69-73 `uphill_overrides` · B06 저장 DS:20 · 102 → AF:269-280 → RL:69-90 → RC:43-73 종단 정본 `uphill_side`(source user) + RC:75-170 저장 횡단 설계 `section_mode`(편절 left_cut ↔ right_cut) · `ditch_side` 다시 계산.
- B06 에서 되나: **반만** — B06 좌 · 우 단추(CD:396-405)는 양절 · 양성에서만 켜짐(CD:352 `needsDitch` · 편절은 꺼짐 + 「양측 절토·양측 성토만」 안내) · 단면 유형 pill 은 읽기 전용(CC:107-111). → 편절 측점의 절토 · 측구 쪽 뒤집기는 지금 3D 구슬이 유일한 길. 양절 · 양성은 B06 단추로 되고 B6C:369-385 가 정본 `uphill_side` 로 되돌려 적음.
- 고칠 자리(최소): MK:467-491 구 · 맞힘 구 · MK:556-563 분기 · MK:127 · 613-615 `onUphillPick` · MK:57-59 색 · MK:35-36 `uphill_side` 칸 · 주석 MK:426 · 459-460 · 530 / DVP:359-377 처리기 · PS:20-21 · 63-64(구 색 전용).
- 함께 죽는 것(⬜): 세션 `uphill` 사슬 — DVP:208-212 · 392 · 531-532 · 540 · PH:238-268 · PA:33-35 · 69-73 · AF:250-251 · 269-280 · DS:20 · 102 / 서버 SC:257-260 · 280-281 · RL:69-90 · RC:75-170(`sync_uphill_overrides_into_designs`).
- 남김: RC:43-73 `_merge_uphill_overrides_into_longitudinal`(B6C:369-385 가 씀) · `uphill_side` 데이터(구조물 유입측 `_UI_Corridor_Station_Structure.ts`:258 · `_UI_Corridor_Structures_Box.ts`:114 · B06 기본 단면 유형 CD:335).
- 시험: 최소안 = 새 소스 시험(MK 에 측점 구 · `uphillStationId` 없음 · DVP 에 onUphillPick 없음) + test_design_store_save · test_b06_save_channels · test_b05_b06_save_roundtrip 그대로 통과(사슬 남김). 사슬까지 지우면 test_design_store_save.py:51 · 61 · 72-73 · test_b06_save_channels.py:27 · 73 · 84 · 100 · test_b05_b06_save_roundtrip.py:373 · 420-424 고침.
- 위험: 편절 방향을 사용자가 바꿀 길이 없어짐(자동 판정 = 지형 높은 쪽 `B05_Profile_Engine_Sections_Core.py`:443-448 만) · 지난 세션에 남은 `uphill` 값은 최소안이면 다음 저장 때 한 번 더 나감(무해).
- 크기: 최소안 작음(MK 약 −35 줄 · DVP −19 줄 · PS −3 줄) · 사슬까지 중간(파일 10 개 · 시험 3 개).
- ⬜ 지우는 범위: (가) 3D 구슬 · 클릭 · 처리기만(세션 · 서버 사슬은 빈 채로 둠) / (나) 사슬까지 다 지움 — 추천 (가)(편절 뒤집기 자리를 정한 뒤 (나)).
- ⬜ 편절 방향 뒤집기 자리: (가) 없앰(자동 판정만) / (나) B06 좌 · 우 단추를 편절에도 켜서 `section_mode` 를 바꿈(CD:353-366 emit 이 mode 를 같이 보냄 · 서버 재계산은 기존 [저장] 길) — 추천: 사용자 확인(요구 15 단추 확인과 묶음).
@@ -0,0 +1,105 @@
# 조사 V — B05/B06 횡단 형상(7 흙막이 형상 오류 · 12 소단 형상 · 13 옹벽 · 흙막이 단) (10-10 · 읽기 전용 조사 에이전트)
| 약칭 | 파일 | 약칭 | 파일 |
|---|---|---|---|
| WAL | `common_util/common_util_structure_walls.ts` | WPY | `B06_Section/B06_Section_Engine_Structures_Wall.py`(WAL 짝) |
| STO | `B06_Section/B06_Section_Section_Store.ts` | REV | `B06_Section/B06_Section_UI_Cross_Revetment.ts` |
| EXT | `B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts` | CV | `B06_Section/B06_Section_UI_Cross_View.ts` |
| LAY | `B06_Section/B06_Section_Structure_Layouts.ts` | WIRE | `B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts` |
| COR | `B05_Profile/B05_Profile_UI_Corridor_Structures.ts` | NODE | `config/server_calc_node/B06_Section_Server_Calc_Node.js`(번들) |
| SCH | `B05_Profile/B05_Profile_Structures_Schema.py` | ESEC | `B05_Profile/B05_Profile_Engine_Sections.py` |
| BRM | `common_util/common_util_cross_berm.ts` | BPY | `common_util/common_util_cross_berm.py`(BRM 짝) |
| GEO | `common_util/common_util_cross_design_geometry.ts` | GPY | `B06_Section/B06_Section_Engine_Design_Geometry.py`(GEO 짝) |
| CD | `common_util/common_util_cross_design.ts` | ED | `B06_Section/B06_Section_Engine_Design.py`(CD 짝) |
| RD | `B06_Section/B06_Section_Router_Design.py` | PER | `B06_Section/B06_Section_UI_Page_Persist.ts` |
| REF | `B06_Section/B06_Section_Cross_Refresh.ts` | DEF | `resources/master_template/구조물정의.json` |
| GRD | `A00_Common/design/design_structure_guard.ts` | PIPE | `A00_Common/design/design_structure_pipe.ts` |
| SPF | `B05_Profile/B05_Profile_UI_Structures_Spec.ts` | FRM | `B05_Profile/B05_Profile_UI_Structures_Form.ts` |
| DRF | `B05_Profile/B05_Profile_UI_Drainage_Facility.ts` | RCH | `B05_Profile/B05_Profile_UI_Drainage_Revet_Choice.ts` |
| PPO | `B06_Section/B06_Section_UI_Page_Pipe_Options.ts` | T | `resources/tester/` |
## 0. 코드 지도
| 요구 | 손댈 층 | 계산 자리(5장) | 저장 |
|---|---|---|---|
| V7 흙막이 형상 오류 | ① 측점 심기 규칙 SCH ② 벽 가드 한 벌 LAY · CV · COR ④ 옹벽 형태 키 WAL ↔ WPY | ① 서버 단독 ② TS 를 서버 Node 로(NODE 다시 빌드) ④ 짝 | 바뀜 없음(측점 파일은 재생성 때 늘어남) |
| V12 소단 형상 | DEF · BRM ↔ BPY · GEO ↔ GPY · 키 나르기(PER · REF · CD ↔ ED · RD · 타입 2) | ① 짝(있음 · 거울 시험 새로) | structures.json `options.depth_m`(새 키 · 추천) · 측점 결과 = 횡단 설계 patch `design.berm` |
| V13 옹벽 · 흙막이 단 | GRD · PIPE(명세) · WAL ↔ WPY · REV · DEF 그룹 | 제원 = 짝 · 형상 = TS 를 서버 Node 로 | structures.json `options.soil_add_{n}_form · _height` |
## V7. 흙막이(흙막이류) 횡단도 형상 반영 오류
- 원문 「흙막이휴」 = 「흙막이류」 오타로 봄(자판 ㄹ · ㅎ 이웃 · 1009 요구의 「흙막이류」 = 흙막이 계열) — 추정.
- 현재: 사면안정 흙막이 = `soil_guard`(C · 구간형 · DEF:1548) · 옹벽 = `retaining_wall`(DEF:986). **두 종류가 같은 길 하나** — 서버 얹기 WPY:46-121(`B06_Section_Router.py`:286 · 449) · 브라우저 초안 얹기 STO:63-100 → WAL:74-134 → `section.revetment` → REV `computeRevetmentLayout`:157-323 → 카드 CV:461-500 · 서버 면적 LAY:126-127(NODE) · 3D COR:280-282 · B07 `B07_DesignDetail_Cross_Overlay.ts`:484-506. 종류 키로 갈리는 곳 = 형태 기본(WAL:59-65 · WPY:33-39) · 옹벽 물빼기(REV:273)뿐.
- 재현(시스템 임시 폴더 · 읽기만): 픽스처 `T fixtures/b06_73_4_body_guard_sections.json` 관 없는 4측점(160 · 180 · 340 · 360)에 흙막이 기본값(돌쌓기(찰) · H1.5) · 옹벽 기본값(H2)을 얹고 NODE 실행 → 두 종류 성토 면적 같음(0.49 · 1.54 · 2.14 · 2.03 ㎡ · 표준 1.45 · 5.32 · 9.68 · 6.04). 다단 3 · H2 도 같은 트림. T test_b06_structure_walls_mirror · test_b06_wall_shape_unified · test_54_26_slope_table09 7 통과.
- ⇒ 「흙막이만 안 됨」을 만드는 정의 이름 · 종류 키 · 그리기 분기 · patch 누락 **없음**. storage 검증 프로젝트엔 흙막이 · 옹벽 · 소단을 놓은 자료가 없어 사용자 화면 그대로는 재현 못 함.
- 원인(코드로 확인된 「안 그려짐 · 틀림」 갈래 · 종류 무관):
- ① **C군 벽은 측점을 안 심음** — SCH:153-155(A군 · D군 구간형만) · ESEC:297-307 · 시험이 못 박음(T test_b05_extra_stations_supplier:133-157). 기본 길이 10 m(전 5 · 후 5)가 20 m 격자(`config/config_system_design.py`:247) 사이에 놓이면 얹힐 횡단 0장 → 종단엔 보이고 횡단 · 3D 엔 없음. D군 기슭막이는 시작 · 기준 · 종료 셋을 심음(ESEC:283-288). **가장 유력.**
- ② **카드와 다른 곳의 가드가 다름** — 카드 = 「연동 관 벽이 이 측점에 실제로 설 때만」 건너뜀(CV:468-469 · 09-09 고침 WIRE:244-263) / `computeStoredLayouts` = 연동 링크만 있어도 건너뜀(LAY:126-127) → 서버 면적([저장] · NODE) · B07 횡단 그림 · 배관 단 수 셈에서 벽 빠짐 / 3D = 관 측점만 봄(COR:280-282 → 연동 관 벽과 겹쳐 그림). 예: 유입 집수정 관의 집수정 범위 안 측점(WIRE:240 — 집수정은 링크 카드에 안 섬).
- ③ 설치 측 자동 + 양측 절토면 안 그림(REV:172-179) · 높이 빈칸이면 안 그림(REV:164-166) · 설치 측 칸 숨김(DEF:3541 · 3559) → 늘 성토 사면(노견 아래) 모양만 · 절토 쪽 흙막이를 세울 길 없음.
- ④ (잠복) 옹벽 형태 칸 `wall_form`(DEF:1002)을 그림이 안 읽음 — `form`(형식 · 중력식 …)이 있으면 돌 무늬(WAL:93 · `B06_Section_UI_Cross_Wall_Hatch.ts`:36-44). 형식 칸 숨김(DEF:3558)이라 지금은 안 드러남.
- 고칠 자리:
- ① SCH:153-155 에 C군 구간형 더함 → `resolve_extra_stations` 가 시작 · 기준 · 종료 심음(ESEC 그대로 · 라벨 = 정의 이름). 측점은 저장 · 재생성 때 섬.
- ② LAY 에 가드 한 벌(관 측점 또는 `culvertWallsStandAt`) → LAY:126-127 · CV:468-469 · COR:280-282 셋이 같이 씀 · NODE 다시 빌드(`B06_Section/B06_Section_Server_Calc_Prebuild.py`).
- ④ WAL:93 · WPY:73 — 옹벽은 `wall_form` 먼저(짝 둘 · 거울 시험 한 줄).
- 재사용: 기슭막이 측점 규칙(ESEC:283-288) · 카드 가드 `culvertWallsStandAt`(WIRE:253).
- 시험: T test_b05_extra_stations_supplier:133 고침(C 구간형 셋 심음 · E · G 그대로) · test_regeneration_extra_stations · 새 시험(NODE — 링크는 있으나 관 벽이 안 서는 측점에서 벽 면적 행 나옴 · 관 벽이 서는 측점은 안 나옴) · test_b06_structure_walls_mirror(`wall_form`) · test_b06_structure_areas.mjs.
- 위험: ① 카드 · 파일 수 늘어남 · 기존 프로젝트는 재생성해야 섬 · 재이관 차단 라벨(SCH:158-160)도 같이 늘어남(같은 함수 · 의도대로) · 소단 측점도 섬 / ② CV 690 · COR 675줄 — 바꾸기만(늘리지 말 것).
- 크기: ① 작음 · ② 작음~중간 · ④ 작음.
- ⬜ 사용자가 본 증상(어느 측점 · 안 보임/모양 틀림) — 확인 전 추천 = ① + ② 함께.
- ⬜ C군 측점: (가) 흙막이 · 옹벽 · 소단 시작 · 기준 · 종료 심음 / (나) 안 심고 「이 구간 횡단 없음」 안내만 — 추천 (가).
- ⬜ 절토 쪽 흙막이: (가) 이번 밖(기록만) / (나) 설치 측 칸 보이기 + 절토 쪽 모양 새로 — 추천 (가).
## V12. 소단 — 높이 칸 빼고 성토 사면 한 단(도로 아래 Y)
- 현재 칸: DEF:1993 `berm` = 폭 `width_m`(0.5 · DEF:2008) · 높이 `height_m`(빈 값 · 「아직 계산에 안 쓰임」 DEF:2017) · 길이 · 전 · 후 · 간격(사면길이 3 · DEF:2053) · 안쪽 기울기(0° · DEF:2062). 보이는 칸 = 폭 · 높이(그룹 `gbermdetail` DEF:3794 · 표시 DEF:3563-3569).
- 흐름: 구조물 목록 → `bermSpansFromStructures` PER:102-135(세션 `berm` 구간) · `syncBermSpans`(`B06_Section_UI_Page_Design_Sync.ts`:197) → 측점별 제원 REF:188-201(서버 payload) · REF:255-262 · 320(브라우저 재계산) → 짝 BRM ↔ BPY(두 머리 짝 표시 있음).
- 지금 형상 = 사면길이 `interval_m` 마다 폭 평탄부 **되풀이** — 성토 BRM:153-191 · BPY:163 · **절토도** BRM:57-146(GEO:275-293 `cutPoints` 가 같은 소단 씀) · 설계선 꼭짓점 GEO:469-500 · 평탄부 거름 GEO:440-443(짝 GPY:207-244 · 283-286 · 432-443).
- 저장: 결과 `design.berm {width_m, interval_m, slope_deg}` CD:582-589 · ED:535-541 → [저장] 재계산 `stored_berm` RD:449-459 · 세션 `berms` RD:496-503(`B06_Section_Schema.py`:335) · 사용자 키 REF:87(서버 RD:151 · 172). 하류 = B07 평탄부 판정 `B07_DesignDetail_Engine_SlopeGeometry.py`:178-199(폭만 봄) · 3 m 검토 정보 `B06_Section_UI_Cross_Berm_Info.ts`.
- 「형상 필요」 까닭(추정): 성토 사면이 3 m(사면길이) 안 되면 소단이 안 섬 · 높이 칸은 어디서도 안 읽음 · C군이라 측점 안 심음(V7 ①).
- 거울 시험: 머리글의 `tmp/tests/test_cross_berm_mirror.py` 는 git 밖 — T 에 성토 소단 거울 시험 없음(test_berm_knee_progress = 절토 끝남만).
- 고칠 자리(⬜ (가) 기준):
- DEF: `depth_m`「소단 위치(도로 아래)」 m · 기본 1.0(사용자 값) · 그룹 옵션 [width_m, depth_m] · `height_m` 표시 끔(값 남김).
- BRM · BPY: BermSpec 에 깊이 · `fillProfilePoints` = 내려가다 표고가 「노면 끝 − 깊이」에 닿으면 폭만큼 수평 → 그 아래 같은 성토 경사로 이어 내림 · 한 단만 · 깊이 < 0 은 0(도로보다 높을 수 없음) · 그 깊이 전에 원지반을 만나면 소단 없음.
- GEO · GPY: `fillPoints` 에 노면 끝 표고 넘김(`slopeStart` GEO:258-266 은 측구가 그쪽이면 측구 바깥 표고 — 기준 다름) · (⬜) `cutPoints` 소단 끔.
- 키 나르기 `depth_m`: PER:58-62 · 117-131(지금 음수 = 기본값 → 0 으로 자름) · REF:188-201 · 255-262 · `B06_Section_Api_Fetch.ts`:268 · `B06_Section_Api_Types.ts`:531 · CD:185 · 582-589 · ED:535-541 · RD:449-459 · 496-503.
- 재사용: 지금 소단 길(구간 → 세션 → 측점 제원 → 짝 계산 → 설계선 · 면적 · 3D) 그대로 · 칸 = 정의 그룹 숫자 칸([−][숫자][+] `B05_Profile_UI_Structures_Panel_Options.ts`:103-106).
- 시험: 새 T test_cross_berm_mirror(TS ↔ 파이썬 · 깊이 1.0 · 폭 0.5 · 1:1.5 꼭짓점 같음 · 깊이 0 · 깊이 > 사면 높이 = 소단 없음) · test_b06_cross_design_mirror(소단 측점 더함) · test_b07_slope_geometry · test_berm_knee_progress(절토 끄면 고침) · test_54_26_slope_table09 · test_54_6_slope_container · test_b06_berm_review_info.
- 위험: 옛 되풀이 소단이 한 단으로 바뀜(재계산 때 면적 · 토량 바뀜) · 절토 소단 끄면 절토 면적 줄어듦 · 짝 파일 여유 적음(RD 618 · CD 606 · ED 555) · 측구가 성토 쪽인 단면의 기준 표고.
- 크기: 중간.
- ⬜ 절토 쪽 소단: (가) 끔(요구 = 성토 사면) / (나) 지금 되풀이 그대로 — 추천 (가).
- ⬜ 깊이 키: (가) 새 키 `depth_m` · 높이 칸 숨김 / (나) `height_m` 이름만 바꿔 씀(높이를 읽는 공용 코드에 깊이가 섞일 위험) — 추천 (가).
- ⬜ 깊이 기준: (가) 그쪽 노면 끝(길어깨 끝) 표고 / (나) 사면 시작점 — 추천 (가).
- ⬜ Y 조절 자리: (가) 좌측 패널 칸(구간 한 값) / (나) 횡단 카드 끌기(측점마다 · patch 새 키) — 추천 (가).
## V13. 사면안정 옹벽 · 흙막이 「단」 — 유출 양식 재사용
- 현재 칸: 흙막이 = 그룹 `gsoilgdetail`(DEF:3756 · 형태 · 길이 · 높이 · 전 · 후) + 숨은 `tiers`「단 수(다단)」(DEF:1629 · 표시 DEF:3542 끔) · 옹벽 = `gretaindetail`(DEF:3688 · `wall_form` · 길이 · 높이 · 전 · 후) · `tiers` 없음. 둘 다 명세(STRUCTURE_SPECS PIPE:107-114) 밖 — 정의 그룹 상자로 그림. 저장 = structures.json `options`.
- 형상: 다단은 이미 됨 — REV:248-270 이 `spec.tiers` 만큼 `buildExtrasAt`(배관과 같은 함수) · 짝 WAL:98 · WPY:76 이 tiers 나름. 단 조정값은 늘 `ZERO_ADJUST`(REV:257) → 단 형태 = 본 벽 형태 · 단 높이 = 기본 1.5 m(EXT:285-289 · `B06_Section_UI_Cross_Culvert_Const.ts`:166) · 단별 값 못 정함 · 단 수 칸이 화면에 없어 사실상 1단.
- 유출 양식(배관): 유출 상자 「추가 흙막이(단)」 숫자 칸 RCH:60-86(값 = 횡단 설계 patch `extra_wall_counts.outlet`) + 흙막이 상자 `SOIL_ADD`(GRD:113-121 · 형태 · 길이 · 높이 · 전 · 후 · 키 `soil_add_{n}_*` · 잇기 `gsoiladd{n}`) · 상자 수 = 지형이 세운 단(count 훅 DRF:345 · fields 훅 DRF:316-327) · 상자 값 → 단 조정 PPO:221-260(`extra{n-1}` 의 h · m).
- 고칠 자리(⬜ (가) 기준):
- GRD: 단 컨테이너 = SOIL_ADD 꼴 · 항목 형태 · 높이만(`guardItems()` 거름) · 옹벽 몫은 높이만(⬜).
- PIPE:107-114 에 `soil_guard` · `retaining_wall` 명세(선택 컨테이너 하나 · 본 칸은 정의 그룹 그대로) → 좌측 패널이 옵션 칸 아래 공용 폼 [추가] · [빼기] 상자(SPF:28-29 · FRM:171-183 · 앞선 예 `design_structure_road.ts` `ETC_SPEC`).
- WAL:74-106 · WPY:46-90(짝): `soil_add_{n}_form · _height` → `extras: [{form, height_m}]` · tiers = 1 + 상자 수(상자 없으면 옛 `tiers`).
- REV:249-257: 단 조정 = `{...ZERO_ADJUST, m: 형태, h: 높이}` · 자리(x · d)는 다단 자동 규칙 그대로.
- DEF: 두 종류 그룹에 `gsoiladd`(흙막이 n) — 조합 잇기 이름.
- 재사용: SOIL_ADD 칸 · 공용 명세 폼 선택 컨테이너(`design_structure_form.ts`:280-345) · `buildExtrasAt` · 그리기 · 3D(`tiers[].polygon`) · B07 그대로.
- 시험: T test_b06_structure_walls_mirror(extras 두 벌 같음) · 새 시험(tsc — 흙막이 2단 · 둘째 단 콘크리트 H2.0 → 둘째 벽 form · 높이) · test_54_26_slope_table09 · test_54_6_slope_container · test_54_27_soil_tier_field · test_b06_wall_shape_unified · test_52_17_single_container.
- 위험: 단별 수량은 이번 밖(B08 이 C군 다단을 안 셈 · 기록만) · 지형이 단을 못 세우면 상자보다 단이 적음(구간 벽은 측점마다 지형이 달라 배관처럼 상자 수를 맞출 수 없음) · 단 자리 조작(조정창) 없음 · 3D · B07 다단도 함께 바뀜.
- 크기: 중간.
- ⬜ 단 수 정하는 법: (가) 공용 상자 [추가] · [빼기](상자 수 = 추가 단) / (나) 배관 유출과 같은 「추가 흙막이(단)」 숫자 칸 + 상자 수 따라감(RCH `outletTiersRow` 를 저장 자리 받게 넓힘 · SPF 에 count · fields 훅) — 추천 (가).
- ⬜ 단 상자 칸: (가) 형태 · 높이 / (나) SOIL_ADD 그대로(길이 · 전 · 후는 그림에 안 쓰임) — 추천 (가).
- ⬜ 옹벽 단 형태: (가) 흙막이와 같은 형태 6 / (나) 높이만(형태 = 콘크리트) — 추천 (나).
## 고칠 파일 목록
| 파일 | 줄 수 | 700 위험 | 요구 |
|---|---|---|---|
| `B05_Profile/B05_Profile_Structures_Schema.py` | 290 | 없음 | V7 ① |
| LAY · CV · COR | 389 · 690 · 675 | CV · COR 높음 — 바꾸기만 | V7 ② |
| NODE(번들) | — | 다시 빌드 | V7 ② · V13 |
| WAL ↔ WPY | 134 · 121 | 없음 | V7 ④ · V13 |
| BRM ↔ BPY · GEO ↔ GPY | 223 · 240 · 501 · 459 | 낮음 | V12 |
| CD ↔ ED · RD | 606 · 555 · 618 | 중간(+ 2~4) | V12 |
| PER · REF · `B06_Section_Api_Fetch.ts` · `B06_Section_Api_Types.ts` | 462 · 407 · 313 · 620 | 낮음(+ 1~3) | V12 |
| GRD · PIPE · REV | 158 · 114 · 392 | 없음 | V13 |
| DEF | 자료 | 해당 없음 | V7 ④ · V12 · V13 |
- 요구 겹침: V7 ① 측점 심기 = V12 소단 · V13 단 모두 카드가 서는 조건 · V7 ② · V13 = REV · NODE 함께 · V12 · V13 = DEF 함께(M02 판).
- 안 고침: `B05_Profile/B05_Profile_UI_Structures_Panel.ts`(699줄 — 셋 다 필요 없음).
@@ -0,0 +1,99 @@
# 조사 W — B06 횡단도(치수 칸 자리 · 측구 단추 비활성 · 측구 줄 줄임 · [상세] 축 고정 · 표 손질) (10-10 · 읽기 전용 조사 에이전트)
약칭 `B06_Section/B06_Section_` + CV `UI_Cross_View.ts` · CTL `UI_Style_Cross_Controls.css` · CSX `UI_Style_Cross.css` · CSA `UI_Style_Cross_Areas.css` · CA `UI_Cross_Areas.ts` · CD `UI_Cross_Design.ts` · DP `UI_Cross_Design_Pick.ts` · CHR `UI_Cross_Card_Chrome.ts` · DT `UI_Cross_Detail.ts` · DTP `UI_Cross_Detail_Pick.ts` · CSD `UI_Style_Cross_Detail.css` · AX `UI_Cross_Axes.ts` · FR `UI_Cross_View_Frame.ts` · VZ `UI_Cross_View_Zoom.ts` · VM `UI_Cross_View_Metrics.ts` · SV `UI_Section_View.ts` · SVT `UI_Section_View_Types.ts` · DSY `UI_Page_Design_Sync.ts` · PER `UI_Page_Persist.ts` · CR `Cross_Refresh.ts` · AT `Api_Types.ts` · AF `Api_Fetch.ts` · SCH `Schema.py` · RD `Router_Design.py` · RCT `Router_CrossTable.py` / ZP `A00_Common/b_svg_zoom_pan.ts` · DVC `A00_Common/design/design_view_cross.ts` · BPS `A00_Common/b_page_state.ts` · CCD `common_util/common_util_cross_design.ts` · CT `B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py` · RS `B07_DesignDetail/B07_DesignDetail_Router_Support.py` · CQ `B07_DesignDetail/B07_DesignDetail_Engine_Cross_Quantity.py` · LB2 `ui_template/ui_template_locale_b2.ts` · CM `ui_template/ui_template_context_menu.ts` · DTB `ui_template/ui_template_data_table.css` · BE `B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts` · T `resources/tester/`
## 0. 코드 지도
| 요구 | 손댈 층 | 계산 자리(5장) | 저장 |
|---|---|---|---|
| W14 치수 칸 = 줌 단추 왼쪽 | CTL 한 덩이 지움 | 화면만 | 없음 |
| W15 측구 · 좌우 단추 비활성 | 확인만(코드 없음) | — | — |
| W16 측구 줄 줄임 | CSX · CD(· LB2 · CHR) | 화면만 | 없음 |
| W17 [상세] 축 고정 · 표 글자 · 이름 칸 색 | DT · SV · SVT · DSY · CSD | 화면만(카드 축 함수 AX 재사용) | 없음 |
| W18 [상세] 행 · 열 더함 · 그래프 고르기 · 우클릭 | DT(+ 새 파일) · PER · CR ↔ RD 짝 목록 · SCH · AT(· RS) | 면적 = ③ 서버 단독(설계 면적 칸) · 길이 = ⬜ | 횡단 설계 patch 새 칸(`structure_note` 꼴) |
## W14. 선 길이 정보창 → 줌 [+ − ⤢] 왼쪽
- 현재: 보라 선을 누르면 치수 칸(`b06-cross-card__line-dims` · DP:329-331 · 글 두 줄 = 길이 · 수평 / 높이 · 구배 DP:216-224)이 줌 막대 맨 앞에 붙음(CV:676-677 `zoomBar.prepend(linePick.label)`) — 막대는 그림 칸 오른쪽 위 absolute(CTL:214-224). **그림 칸 폭 ≤ 620 px 이면 막대에서 떼어 면적표 아래 오른쪽으로 내림**(151-1 · CTL:242-259 `@container (max-width: 620px)` · top = 표 높이 변수). 카드 폭(조사 O 추정) 1열 1232 · 2열 608 · 3열(기본) 400 → **1열만 단추 왼쪽 · 2열부터 표 아래**.
- 겹침 셈(추정 · CROSS_PAD left 58): 치수 칸 ≈ 176 px(0.75rem · 「사면길이 12.34m · 수평 10.12m」) + 단추 66 px. 2열(608) = 막대 왼끝 ≈ 362 · 면적표 끝 ≈ 311 → **안 겹침**. 3열(400) = 막대 왼끝 ≈ 144 · 면적표 58~283 → 선을 고른 동안 표 오른쪽 ≈ 140 px(RR · BR · 계 칸) 가림.
- 고칠 자리: CTL:242-259 의 line-dims 두 규칙(absolute 내림 · `+ .zoom-btn` 왼선)과 막대 `overflow: visible` 지움 → 모든 폭에서 막대 안 왼쪽(지금 1열 꼴). (⬜ (나)면) CSA 에 `.b06-cross-card__chart-wrap:has(.b06-cross-card__line-dims:not([hidden])) .b06-cross-card__areas { opacity: … }` 한 규칙.
- 재사용: 지금 1열 꼴 그대로(DOM 손 안 댐 · CV 690줄 안 늘림).
- 시험: T test_137_design_pick:107-115(좁은 카드 내림 단정 → 「@container 안에 line-dims 없음」으로 고침) · test_173_areas_detail(표 높이 변수 무변 확인) · test_139_6_line_shift(무변).
- 위험: 3열 이상에서 고름 동안 면적표 일부 가림(막대 바탕 88% 불투명 · z-index 2) · `--b06-areas-height` 를 쓰는 다른 곳(CTL:10 · 15 조정창 자리)은 그대로.
- 크기: 작음(CSS 지우기만).
- ⬜ 3열 이상 겹침: (가) 그대로 가림(고름 동안만) / (나) 가림 + 그동안 면적표 흐림(CSS 한 규칙) / (다) 2열까지만 왼쪽(문턱 620 → 560) · 3열 이상은 지금처럼 표 아래 — 추천 (가)(기본 3열에서 요구대로 보임 · 지우기만).
## W15. 측구 여부 · 좌우 방향 단추 비활성 — 확인 결과
- 단추 = 카드 「측구 줄」(CD:396-467 · CHR:157-163). 비활성 = `button.disabled` + 듣개 안 닮(CD:164-166 · 198-200) + 묶음 `b06-design__seg--disabled`(흐림 0.4 · 손가락 금지 CSX:536-542) + 사유 툴팁.
- 단면유형(`section_mode`)은 지형 자동 판정(RD:351 `default_section_modes`) · **카드에 바꾸는 단추 없음**(MODE_OPTIONS = 이름표만 CD:83-94 · 알약 CHR:108-112).
| 단추 | 비활성 조건(코드) | 툴팁 | 검증_랩탑메인 73 측점(DB SELECT) |
|---|---|---|---|
| 좌 · 우(노면 경사 = 측구 쪽) | 편측 성토(`left_cut` · `right_cut`) — `!needsDitch()` CD:352 · 404 | 「양측 절토·양측 성토 단면에서만…」 LB2:456-459 | right_cut **66 = 비활성**(「우」 보라 켜진 채 흐림) · both_cut 6 · both_fill 1 = 활성 |
| 측구(켬/끔) | 양측 성토(`both_fill`) — `!hasDitch` CD:390 · 417 | 「측구가 있는 단면(양성 제외)…」 LB2:467-470 | both_fill **1 = 비활성** · 72 활성 |
| (참고) 측구형식 일반/L형 | 암 아님 · 양성 · 측구 끔 CD:389 · 429 | LB2:463-466 · 471-474 | 73 모두 암 → both_fill 1 만 비활성 |
| (참고) 복합/단경사 | 암 아님 · 양성 CD:442 | LB2:463-466 | both_fill 1 만 비활성 |
- 비활성이 **아닌** 까닭(코드 판정): 확정 장 · 잠금 · 저장 상태 · B05 연동 — 없음(조작 함수 늘 넘김 DVC:317-321 · 받는 쪽 막기 없음 DSY:60-108). 편측의 쪽은 절토 쪽 자동(CCD:234-239 짝 `_resolve_ditch_side`) — 설계 규칙.
- 사용자 눈에 비활성처럼 보일 까닭: ① 편측(90%)은 좌우가 흐린 채 늘 보임(E-6 「상시 노출 · 흐리게」 규칙) ② 측구 켬 · 끔 단추 글이 같은 「측구」(LB2:454-455) — 끔 = 회색 글 · 바탕색(CSX:551-559)이라 비활성과 구별 약함 ③ 좁은 카드에선 측구 단추가 「⋯」 안으로 옮겨짐(CD:451 · 476-503 · 숨김이지 비활성 아님) ④ 단면유형 알약은 좌 · 우 편측 모두 「편측 성토」(LB2:423-424) — 쪽은 흐린 좌우 단추로만 읽힘 ⑤ 요구 5(B05 3D 구슬 삭제) 뒤 쪽 고르기 = 이 단추뿐.
## W16. 측구 줄 컴팩트
- 현재: 한 줄 고정 · 넘치면 뒤에서부터 「⋯」(CSX:465-473 · 간격 16 px · CD:451-503). 차례 = [좌|우] [측구] [측구형식 일반|L형] [복합경사] [⋯] [상세](CD:467). 폭 ≈ 445 px(추정) > 3열 카드 400 → 복합경사 · 측구형식이 ⋯ 로 숨음(추정). 편측 66 측점은 흐린 좌우가 자리만 차지.
- 안:
- (가) **비활성은 숨김** — CSX:536 규칙을 `display: none` 으로 · 「측구형식」 글 지움(CD:422 legend 빈 글 · 툴팁으로) · 알약에 쪽 더함(「편측 성토(우)」 · LB2:423-424 두 줄 또는 CHR:110 한 줄). 편측 암 = [측구] [일반|L형] [복합경사] [상세] ≈ 280 px → 3열에서 ⋯ 없음. 장: 가장 작음 · W15 혼동 없앰. 단: E-6 뒤집음(사용자 확정 필요).
- (나) **측구 한 단추로 묶음** — [측구 우·일반 ▾] 하나 → 누르면 작은 판(⋯ 판 꼴 `details` CSX:475-521 재사용)에 켬/끔 · 좌/우 · 일반/L형(비활성 그대로 흐림 · 툴팁). 줄 = [측구 우·일반 ▾] [복합경사] [상세]. 장: 늘 한 칸 · 쪽 · 형식 글로 남음. 단: 바꾸려면 두 번 누름 · CD 새 판 ~40줄(527 → ~565) · 넘침 목록 고침.
- (다) **글 · 간격만 줄임** — 「복합경사/단경사」 → 「복합/단」 · 「측구형식」 지움 · 간격 16 → 8 px. 장: 뜻 그대로. 단: 60~80 px 만 줄고(추정) 흐린 단추 그대로.
- 재사용: 넘침 정리(CD:470-516 · 숨긴 칸 폭 0 이라 그대로 동작) · ⋯ 판 CSS.
- 시험: T 새 CSS · 소스 시험(비활성 규칙 · legend · 알약 글) · 측구 줄을 단정하는 기존 시험 없음(T grep — test_b06_cut_slope_user_input 은 바닥 줄만).
- 위험: (가) 비활성 사유 툴팁이 안 보임(숨김) · 좌측 사본 · B05 화면에 같은 클래스 없음(grep) · 알약 글 길어져 머리 줄 넘침.
- 크기: (가) 작음 · (나) 중간 · (다) 작음.
- ⬜ 줄이는 길: 추천 (가)(편측이 대부분이라 효과 큼 · W15 와 한 번에 풀림) — E-6 「상시 노출」 폐기 동의 필요.
## W17. [상세] 모달 — 축 고정 팬 · 줌 · 표 글자 · 이름 칸 색
- 현재 그림 구조: 카드 svg = 바탕 rect → **축 층** `g.b06-chart__axes`(CV:370-382 · 줌 바뀔 때마다 보이는 범위로 눈금 다시 그림 AX:44-131) → clip 그룹(고정) 안 **도형 층** `g.b06-chart__plot-layer`(CV:384-399) → 축선 · 축 이름(FR:60-81 · svg 직속). 카드 줌은 도형 층에만 transform + 축 다시 그림 + 십자 크기 되돌림(CV:628-640 · FR:83-89).
- 모달: 카드 함수로 칸 크기 새로 그림 → **복제**(DT:167-184 · 듣개 · 축 함수 잃음) → `attachDetailZoom` 이 **자식 전부를 g 하나로 감쌈**(DT:124-148 · 164-1 「축 · 눈금째 한 장」) → 팬 · 줌 모두 축 · 눈금 · 축선 · clip 까지 같이 움직임. 줌도 같은 원리(ZP:89-92 · 124-131 둘 다 같은 layer transform).
- 고칠 자리:
- SV:550-567 `detailChart` → svg 와 함께 축 옵션 반환(`crossPlotFromBase(fitPlotBase(base, h), h)` VM:84-115 → pixelsPerMeter · maxOffset · displayMax · elevationMid · exaggeration) · SVT:41 · DT:40 타입 한 줄 · DSY:52 그대로 통과.
- DT `attachDetailZoom`: 감싸기 지우고 layer = 복제의 `g.b06-chart__plot-layer` · `createCrossAxes(복제 g.b06-chart__axes, 옵션)` → onChange 에서 축 다시 그림 + 십자 `g.b06-chart__center` 크기 되돌림(FR:83-89 식 3줄). clip 은 고정이라 도형이 축 밖으로 안 넘침. 1배 팬 범위(content 3배 DT:143) 그대로.
- CSD:56-79 표: 글자 `--text-caption`(12px) → `--text-body-sm`(14px) · 이름 칸 `th` 바탕 = 데이터 표 머리색 `color-mix(in srgb, var(--color-accent) 6%, var(--color-surface))`(DTB:10) · 글 본문색. 빈 칸 규칙 CSD:101-105 `background: none` 이 이름 칸 바탕까지 지움 → `td[data-key][data-empty]` 로 좁힘(이름 칸은 글만 회색).
- 재사용: 카드 축 함수 AX 한 벌 · 공용 줌 ZP 무변 · 데이터 표 머리색 공식.
- 시험: T test_164_detail_zoom(`wrapped` 단정 → 「도형 층에만 transform · 축 층 transform 없음 · 팬 뒤 눈금 글 바뀜」 · helper_164 가짜 svg 에 두 층) · test_173_areas_detail · helper_173_detail_fit(반환 꼴 바뀜) · test_150_detail_pick · test_138_cross_detail(무변 확인) · 새 CSS 단정(14px · th 바탕 · 빈 칸 td 한정).
- 위험: SV 614줄 + ~6 · 표 커져 그림 칸 줄어듦(스크롤 없음 150-2 · 8줄 × ~4 px ≈ 32 px) · 강조 hover(0,3,0) · 고름(0,2,0)은 th 바탕(0,1,1)보다 셈 → 그대로 이김.
- 크기: 중간.
- ⬜ 표 글자: (가) 14px / (나) 16px(`--text-body` · 그림 칸 ≈ 60 px 더 줄어듦) — 추천 (가).
## W18. [상세] 표 — 행 · 열 더함 · 칸 값 그래프에서 고르기 · 우클릭 재지정
- 현재 표: B07 납품 양식 고정(머리 넷 쌍 + 본문 6줄 × 좌 · 중 · 우 CT:25-55) · 값 = 서버 `cross_quantity_sheet`(RCT:44-60 → RS:262-302 `_quantity_table` → CQ:102-128 `derived_cells` · 면적 = 설계 칸 그대로 CQ:47-57 · 사면 계열 = 설계선 유도 CQ:81-89) · **표시 전용 · 저장 없음**(RCT:5 · DT:257-282 열 때 POST). 칸 누름 = 그림 강조만(DTP:17-36 · 47-70). B07 도면 표 높이 = 줄 수 × 5 mm(CT:363-366) → 장 배치가 씀.
- 그래프에서 고를 수 있는 것(복제 svg · 듣개 없음 → `cad` 에 위임 듣개 하나):
- 면적 = 면적 띠 다각형 `g.b06-chart__area--{cut_soil|cut_rock|fill}`(CA:224-227) · 사토장 `.b06-chart__spoil-fill` → 출처 = 띠 키 → 값 = 설계 면적 칸(CQ AREA_KEYS 와 같은 키 → **서버가 [저장] 때 그대로 다시 읽음 · ③**).
- 길이 = 보라 판정 선 `.b06-chart__design-hit`(DP:314-321 · 직선 조각) → 화면 → m 환산 = `data-px-per-m` · `data-exaggeration`(CV:360-361) + 십자 x(0)(DTP:51-54 같은 꼴) → 길이 = `pieceMeasure`(DP:198-213 재사용). 원지반선 · 구조물 벽은 판정 선 없음(지금 못 고름).
- CSD:129-133 이 모달 판정 선 손가락을 지워 둠 → 고르기 상태에서만 되살림.
- 우클릭: 공용 `createMapContextMenu`(CM:82 · 한 번에 하나 규칙) — BE:227-231 꼴(document.body · 화면 고정 자리). 칸 메뉴 = 「그래프에서 고르기(면적)」「…(길이)」「자동값으로」「행 더함 · 열 더함 · 이 행/열 지움」 → 고르기 상태에서 그림 누름 = 그 칸 값.
- 저장(정본 규칙 — 그림 = 횡단 설계가 낳은 값 → **횡단 설계 patch**): 새 칸 `detail_table`(예 `{rows:[{label, cells:[{src:"area", key} | {src:"length", from, to} | {text}]}], overrides:{표키: src}}`) · `structure_note` 길 그대로 — 세션 초안 `readState/writeState("detail-table")`(BPS:447-470 · DT:50-76 꼴) → [저장] PER:390-395 옆 → SCH:200-201 옆 필드 → 재계산에도 살림 목록 **짝 둘** RD:155-156 · CR:90-91 → 타입 AT:472-473 · 618-619. 자동저장 없음.
- 고칠 자리: DT 320줄 → 표 손질 · 고르기 · 메뉴는 새 파일(`UI_Cross_Detail_Table.ts` 가칭) · DT 는 부르기만. (⬜ 덮어쓰기까지면) RS:292-302 `_quantity_table` 에 overrides 반영 한 곳(모달 · B07 도면 둘 다 이 함수 → 한 벌).
- 재사용: 공용 우클릭 메뉴 · `pieceMeasure` · DTP 판정 꼴 · 세션 초안 꼴 · 모달 · (안 (다)) M02 면적산출 시트 + 칸 잇기(`M02_MasterTemplete_AreaCellPick.ts` · `_AreaLink.ts` · `A00_Common/spreadsheet/spreadsheet_link_set.ts`).
- 시험: T 새 Node 시험(면적 출처 → 설계 칸 값 · 길이 = 두 끝 hypot · 우클릭 항목 · 세션 초안 → patch) · 새 pytest(RD ↔ CR 살림 목록 거울 · SCH 새 필드 · RCT 표 응답) · test_150_detail_pick · test_138_cross_detail · test_164_detail_zoom(무변 확인).
- 위험: 길이 출처를 두 끝 m 로 두면 설계선이 바뀐 뒤 어긋남(서버 검산에서 설계선 위 아님 → 값 비우고 「다시 고름」) · 판정 선 조각은 벽 가림을 뺀 「보이는 조각」(DP:127 · 174 · 브라우저 그림에 묶임 → 서버가 같은 조각을 못 만듦) · B07 표에 찍으면 표 높이 · 폭 · 장 배치 · 셀 id(uuid5 CT:3) 영향 · AT 620줄(+ ~10) · 짝 목록 한쪽만 고치면 재계산 때 칸 사라짐.
- 크기: 큼.
- ⬜ 행 · 열의 자리: (가) 모달 전용 「추가 표」(납품 양식 표 아래 · B07 도면엔 안 찍힘) / (나) 납품 양식 표 자체를 늘림(B07 도면까지 · CT 표 그리기 · 장 배치 고침) / (다) 공용 스프레드시트를 모달에 끼움(행열 · 메뉴 · 잇기 다 있음 · 측점마다 책 저장 = patch 큼) — 추천 (가).
- ⬜ 「열 · 행 추가」의 뜻: 그룹(깍기 · 측구 …) 안에 줄 더함 / 맨 끝에 새 항목 줄 · 새 열(비고 등) — 원문 모호 · 사용자 확인.
- ⬜ 기존 칸(깍기 토사 등)도 그래프 값으로 덮나: (가) 더한 칸만 / (나) 기존 칸 덮어쓰기 + 「자동값으로」 되돌림(RS 한 곳) — 추천 (나)(우클릭 재지정 원문과 맞음).
- ⬜ 길이 출처 검산: (가) 두 끝 m 저장 · 서버가 설계선 위인지 확인 후 hypot / (나) 값만 저장(5장 「브라우저 값 받아 적지 않음」 어긋남) — 추천 (가).
## 고칠 파일 목록
| 파일 | 줄 수 | 700 위험 | 요구 |
|---|---|---|---|
| `B06_Section/B06_Section_UI_Style_Cross_Controls.css` | 471 | 없음(− ~15) | W14 |
| `B06_Section/B06_Section_UI_Style_Cross.css` | 628 | 낮음(± 2) | W16 |
| `B06_Section/B06_Section_UI_Cross_Design.ts` | 527 | 없음((가) ± 2 · (나) + ~40) | W16 |
| `B06_Section/B06_Section_UI_Cross_Card_Chrome.ts` · `ui_template/ui_template_locale_b2.ts` | 193 · — | 없음 | W16(알약 쪽) |
| `B06_Section/B06_Section_UI_Cross_Detail.ts` | 320 | 없음(+ ~15 · 표 손질은 새 파일) | W17 · W18 |
| `B06_Section/B06_Section_UI_Section_View.ts` | 614 | 중간(+ ~6) | W17 |
| `B06_Section/B06_Section_UI_Section_View_Types.ts` · `_UI_Page_Design_Sync.ts` | 62 · 236 | 없음 | W17 |
| `B06_Section/B06_Section_UI_Style_Cross_Detail.css` | 133 | 없음(+ ~8) | W17 · W18 |
| 새 `B06_Section/B06_Section_UI_Cross_Detail_Table.ts`(가칭) | 0 | 새 파일 | W18 |
| `B06_Section/B06_Section_UI_Page_Persist.ts` · `_Cross_Refresh.ts` · `_Api_Types.ts` | 462 · 407 · 620 | 낮음(AT + ~10) | W18 |
| `B06_Section/B06_Section_Schema.py` · `_Router_Design.py` · (⬜) `B07_DesignDetail/B07_DesignDetail_Router_Support.py` | 353 · 618 · 615 | 낮음 | W18 |
- 안 고침: `B06_Section/B06_Section_UI_Cross_View.ts`(690줄 — W14 · W17 모두 손 안 댐) · `A00_Common/b_svg_zoom_pan.ts`(공용 · 무변) · `A00_Common/design/design_view_cross.ts`(680줄).
- 겹침: W17 · W18 = 같은 모달 파일(DT · CSD) · 같은 시험(test_150 · 138 · 164) → 한 창이 차례로 · W15 · W16 = 같은 측구 줄(W16 (가)가 W15 혼동을 풂).
@@ -0,0 +1,98 @@
# 조사 X — B07 횡단 장 원지반 고르기 · 측점 한 벌 고르기 · 붉은 칸 보이기 · 표 배율 (10-10 · 읽기 전용 조사 에이전트)
사용자 원문 = `사용자요구_1010_2.md` 19 · 20 · 21번. 지난 기록 = 169-2(격자선 인쇄 · 넘친 칸 붉은 틀) · 174-1(칸 아래 가운데 표 → 글 → 그림 · 배치 판 5 · b5442ccc4).
| 약칭 | 파일 |
|---|---|
| Sheet | `B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py` |
| Cad · Table | `B07_DesignDetail/B07_DesignDetail_Engine_Cad.py` · `…_Engine_Cad_Table.py` |
| Support · Router | `B07_DesignDetail/B07_DesignDetail_Router_Support.py` · `…_Router.py` |
| Page · Panels · Fetch | `B07_DesignDetail/B07_DesignDetail_UI_Page.ts` · `…_UI_Panels.ts` · `…_Api_Fetch.ts` |
| Struct · Overlay | `B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts` · `…_Cross_Overlay.ts` |
| owc | `B07_DesignDetail/openwebcad/src` |
| host | `ui_template/cad_host/cad_host.ts` |
## 0. 코드 지도
| 항목 | 위치 | 역할 |
|---|---|---|
| 장 도면층 목록 | `Sheet:235-242`(원지반 `locked=True` `:236` · 도각 `:241`) | 장 레이어 선언 |
| 측점 도면층 목록 | `Cad:624-631`(원지반 잠금 `:625`) | 측점 한 장 · 장 블록 공통 |
| 측점 블록 만들기 | `Sheet:185-215` → `Cad:488-632` `build_cross_drawing` | 원지반 · 계획선 · 암 · 글 · 표 엔티티 |
| 쌓기(174) | `Cad:545-550`(표 + 글 + 8 mm) · `:572-584`(글 · 표) · `:586-593`(틀) | 표 아래선 = 칸 아래 + `CELL_TABLE_PAD_MM`(`Sheet:52`) |
| 넘침 판정 | `Sheet:209-213` | 틀이 칸 밖이면 `overflow_cells` |
| 칸 격자선 | `Sheet:153-172` | 도각 층 `b08-frame` · 색 `#8ea0b5`(`Cad:34`) · 인쇄 · DXF 나감 |
| 가이드(붉은 틀) | `Router:141-144` → `Page:335-340` → `owc/integration/guide-area.ts:105-125`(붉은 `#ff5252` `:28 · 122-123`) | CAD 겹판 · 저장 · DXF 안 섞임 |
| 고르기 걸름(잠금) | `owc/state.ts:220-221`(집기) · `:355-362`(밝힘) · `:365-371`(선택) · `owc/tools/select-tool.helpers.ts:141-160`(사각형) · `owc/helpers/calculate-angle-guides-and-snap-points.ts:33`(스냅) | 잠금 층 객체는 어느 길로도 안 잡힘 |
| 그룹 | `owc/helpers/entity-groups.ts:7-22` · `select-tool.helpers.ts:82-97` · 싣기 `owc/helpers/import-export-handlers/import-entities-from-json.ts:81-85` · 저장 `export-entities-to-json.ts:17-20` · 리본 GROUP · UNGROUP `owc/ribbon/ribbon.config.ts:116` | 한 개 집으면 같은 `groupId` 전부 선택 |
| 층 선택 단추 | `owc/components/LayerManager.tsx:35-38 · 133` | 층의 객체 전부 선택 |
| 화면 덧그림 | `Struct:63-97` · `Overlay:30-36 · 524-544` | 장의 설계선 · 구조물을 화면이 다시 그림(새 id · `groupId` 없음) |
| 표 크기 상수 | `Table:88-90`(폭 108 · 행 5 · 글 2.0) · 제목 글 ×1.15 `:183` · 글 줄 3.0 `:323` · 글 크기 `:351` · `cross_table_width/height` `:360-367` | 종이 mm |
| 배치 저장 | `Support:102`(판 5) · `:119-150`(읽기 · 검사 · 쓰기) · `Router:553-599` · `Fetch:182-185` | `cross_layout.json` = {default, sheets} |
| 배치 칸 화면 | `Panels:182-232` · 자리 `Page:155-197`(「모든 장」 `:194`) · 횡단도 묶음 맨 아래 `Panels:149` | 「배치 열 · 행 · [이 장] · [모든 장]」 |
## 19. 원지반선 안 잡힘 · 측점 한 벌 고르기
현재: 장의 원지반 층 `b08-ground` = 잠금(`Sheet:236` · `Cad:625`) · 계획선 · 구조물 · 암 · 수량표 층은 안 잠김 · 도각(격자선 포함) 잠김. 엔티티에 `groupId` 없음 · 블록 없음 · 측점 구분은 id 씨앗(`{장id}:{측점}` `Sheet:188`)뿐.
원인(확정): 잠금 층은 집기 · 사각형 · 밝힘 · 선택 · 스냅 다섯 길에서 모두 빠짐(0 지도 「고르기 걸름」) — 원지반이 처음부터 「배경」으로 잠겨 나옴(측점 한 장 시절 규칙 · `owc/state.ts:216-219 · 356-357` 주석). 코드 흠 아님 — 장에서 옮기기가 생기며 맞지 않게 된 설정.
고칠 자리:
- 원지반 잠금 풀기 — `Sheet:236` · `Cad:625` 의 `locked=True` 지움(횡단만 · 종단 `Engine_Cad_Long.py:671` 그대로).
- 측점 한 벌 = `groupId` — `Sheet:214` 곁에서 그 측점 엔티티 전부(원지반 · 계획선 · 암 · 글 · 표)에 `groupId = uuid5(…, f"{seed_id}:group")` · `cross_placements` 에 `group_id` 실음.
- 화면 덧그림도 같은 묶음 — `Struct:25-35` `CrossPlacement.group_id?` · `:80-83` 새 엔티티에 `groupId` 붙임(안 붙이면 설계선 · 구조물만 빠짐).
- 격자선 · 도각 · 넘침 틀은 묶지 않음.
재사용: openwebcad 그룹 고르기 · 저장 · 싣기 그대로(새 CAD 코드 없음) · UNGROUP 리본 단추 · `uuid5` 씨앗 꼴.
시험: 새 `test_19x_b07_cross_group.py` — 장 2 × 2 에서 측점마다 `groupId` 하나 · 측점끼리 다름 · 격자선 · 도각엔 없음 · 원지반 층 `isLocked` 거짓 · placement `group_id` = 엔티티 값. `test_174` · `test_169_drawing_area_guide` · `test_b07_cross_sheet_pad` 그대로 통과 추정. 화면 덧그림은 `npm run typecheck`.
위험: 그룹이면 선 하나만 집기 불가(Ctrl · Shift 클릭도 그룹 확장 `select-tool.helpers.ts:91-97`) → 낱개 손질은 UNGROUP 뒤. 원지반이 마우스에 밝아지고 지워질 수 있음(09-01 「배경은 안 밝힘」 규칙을 횡단만 걷음). 확정 장은 저장본 층 · 그룹 그대로 — [수정](다시 만듦 `Page` reopen → `invalidateDesignDrawing`) 때 새 꼴. CAD 고침 없음 → 빌드 불필요 · 백엔드 재시작.
크기: 작음(파이썬 약 8줄 · 화면 약 4줄).
⬜ 19-1 측점 한 벌 묶는 법 — (a) `groupId`(종류 층 그대로 · DXF 층 이름 그대로 · 한 번 클릭 = 한 벌) / (b) 측점별 층(「No.3」 · 층 관리 선택 단추로 한 벌 · 대신 종류 층 사라짐 → DXF 층 이름 · 덧그림 설계선 걷기 `Struct:89` · 수량표 고침 막기 `owc/integration/aislo-drawing-bridge.ts:121 · 157` 이 층 id 에 기대어 깨짐) — 추천 (a)(원문 「도면층」은 한 벌 고르기 뜻으로 읽음).
⬜ 19-2 배치 판 — 올림(6 · 확정 장 전부 「옛 배치」 안내) / 그대로(배치 자리 같음 · [수정] 때 새 꼴) — 추천 그대로.
## 20. 붉은 칸 틀 보이기 / 숨기기
현재: 「붉은 격자선」 = 넘친 칸 틀(`guide.marks` · `#ff5252` 2 px · `guide-area.ts:122-123`) — 4 × 4 칸(192.5 × 130 mm)에 표(108 × 40) + 글 + 8 mm 를 빼면 그림 높이 약 77 mm(실 7.7 m)라 칸 다수가 붉게 칠해져 격자처럼 보임(추정). CAD 겹판이라 저장 · DXF 에 안 섞임(`guide-area.ts:8`). 인쇄되는 칸 격자선은 회청색 `#8ea0b5` · 도각 층(`Sheet:169`) — 붉은 선 아님. 끄는 단추 없음 · 가이드는 싣기 때 한 번만 받음(`owc/integration/aislo-drawing-bridge.ts:243`).
고칠 자리:
- `owc/integration/guide-area.ts` — 모듈 값 `marksVisible`(싣기 때 지우지 않음) · `drawGuide` 의 붉은 틀 줄 `:122-123` 을 그 값으로 거름 · 다시 그리기 불필요(rAF 상시 `owc/main.tsx:128`).
- `owc/integration/aislo-drawing-bridge.ts:204-205` — 새 메시지 `aislo:b08:guide-marks {visible}` 한 줄(영역 메시지 꼴).
- `host:37-38 · 362` — `setGuideMarks(visible)` (`setGuideArea` 꼴 · `postAfterLoad`).
- `Panels:182-232` — 배치 칸 줄 끝에 ghost 단추 「붉은 칸 숨기기 / 보이기」(장일 때만 켬) · `Page:188-196` 에서 누름 → `cad.setGuideMarks` · 값은 모듈 변수(`reopenCrossSheet` 꼴 `Page:89`)로 다시 세운 화면에도 이어 줌.
재사용: 배치 칸 단추 `action` 꼴(`Panels:204-223`) · 영역 메시지 길 · 넘침 토스트(`Page:338-340` · 숨김이면 토스트도 뺄지 ⬜).
시험: `guide-area.test.ts` 에 「숨김 뒤 setGuide 해도 숨김 유지」 · `helper_153_b07_cross_layout.cjs` + `test_153_1` 에 단추 있음 · 장 아니면 꺼짐 · 누름 콜백 · `npm run typecheck`.
위험: 공용 `host` 고침 → M02 · B08 영향 없음(새 메서드만) · 알릴 일. `Page` 600줄(700 안).
빌드: CAD 고침 → `npm --prefix B07_DesignDetail/openwebcad run build` + 리로드.
크기: 작음(CAD 약 8줄 · 화면 약 15줄).
⬜ 20-1 단추가 끄는 것 — (a) 붉은 넘침 틀만 / (b) 붉은 틀 + 파란 점선 작도 영역 / (c) 인쇄되는 회청 칸선까지(칸선을 새 층 「칸 격자」로 떼어 층 끄기 · DXF 는 층 숨김을 안 봄 `Engine_Frame_Export.py:219-235` 이라 그대로 나감) — 추천 (a).
⬜ 20-2 자리 — 좌측 「횡단도」 묶음 안 배치 칸 줄(추천 · 원문 「횡단도 컨테이너 내부」) / CAD 리본.
## 21. 표 · 글 배율(기본 1)
현재: 표 = 폭 108 · 행 5 × 8행(40) · 글 2.0(`Table:88-90`) · 제목 ×1.15(`:183`) · 표 위 글 줄 3.0 · 글 2.0(`:323 · 351`) · 고정 상수. 그림 속 구조물 글자는 화면 `Overlay:36` 2.0. 배율 칸 · 저장 자리 없음.
고칠 자리:
- `Table` — `_cross_table_entities` · `_cross_note_entities` · `cross_note_height` · `cross_table_width` · `cross_table_height` 에 곁값 `scale=1.0`(폭 · 행 높이 · 글 · 줄 높이 곱함).
- `Cad:488-500` `build_cross_drawing(table_scale=1.0)` → 쌓기 `:549` · 표 `:578-584` · 틀 `:589-590` 에 같은 값 — 174 쌓기(표 아래선 고정 · 글 · 8 mm · 그림 위로)와 넘침 판정(`Sheet:210-213` 이 틀로 셈)이 **자동으로 같이 다시 셈**. 8 mm 간격 · `CELL_TABLE_PAD_MM` 은 그대로.
- `Sheet:124-150` 장 dict 에 `table_scale`(배치에서) · `:197-208` 넘김 · `section_block_size :67-101`(시험 전용)도 같은 곁값.
- `Support:119-136` `checked_cross_layout` 이 `table_scale` 지킴(지금은 모르는 칸을 버림) · 범위 검사 · `Router:553-557` 요청 칸 · `Fetch:182-185` 타입.
- `Panels:182-232` 숫자 칸 「표 배율」(step 0.1 · 기본 1) · `Page:194` [모든 장] 저장이 `{default, sheets: {}}` 로 배율을 지우지 않게 펼쳐 넣음.
재사용: `cross_layout.json` 한 파일 · 배치 칸 입력 꼴(`createInputField compact`) · 저장 → 화면 다시 세우기 길(`Page:173-187`).
시험: `test_174` 늘림 — 배율 1.5 에서 표 폭 162 · 높이 60 · 행 안 표 아랫선 같음 · 그림 아래 = 표(+글) 위 8 mm · 표가 칸보다 넓으면 `overflow_cells` · 배율 1 = 지금과 같은 수. `test_140:122`(배치 기본 dict 통째 비교) 기대값 고침. `test_153_1` 칸 하나 더.
위험: 확정 장은 저장본 크기 그대로(배치 바꿈은 담긴 측점이 바뀐 장만 풂 `Support:138-150`). 배율 ≥ 약 1.78 이면 4 × 4 칸 폭 넘침. `Cad` 632줄 · `Support` 615줄 · `Router` 599줄(700 안). 백엔드 재시작 · CAD 빌드 불필요.
크기: 중간 아래(파이썬 약 30줄 · 화면 약 12줄).
⬜ 21-1 값 하나 / 둘 — 표 · 글 한 배율(추천) / 표 · 글 따로.
⬜ 21-2 범위 · 단위 — 장 전부 한 값(추천) / 장마다 · 범위 0.5~2.0(추천).
⬜ 21-3 「텍스트」 — 표 + 표 위 글만(추천 · 서버 한 곳) / 그림 속 구조물 글자도(`Overlay:36` 에 placement 로 배율 실음).
⬜ 21-4 확정 장 — 그대로 + 안내(manifest 에 배율 적고 다르면 「옛 배치」 꼴 · 추천) / 배율 바꾸면 확정 장 전부 풂.
## 고칠 파일 목록
- `B07_DesignDetail/B07_DesignDetail_Engine_Cad_Sheet.py` — 원지반 잠금 풂 · groupId · 배율 넘김
- `B07_DesignDetail/B07_DesignDetail_Engine_Cad.py` — 원지반 잠금 풂 · `table_scale`
- `B07_DesignDetail/B07_DesignDetail_Engine_Cad_Table.py` — 크기 함수 배율
- `B07_DesignDetail/B07_DesignDetail_Router_Support.py` · `B07_DesignDetail_Router.py` · `B07_DesignDetail_Api_Fetch.ts` — `table_scale` 저장
- `B07_DesignDetail/B07_DesignDetail_UI_Cad_Structures.ts` — 덧그림 groupId
- `B07_DesignDetail/B07_DesignDetail_UI_Panels.ts` · `B07_DesignDetail_UI_Page.ts` — 붉은 칸 단추 · 배율 칸
- `ui_template/cad_host/cad_host.ts` — `setGuideMarks`
- `B07_DesignDetail/openwebcad/src/integration/guide-area.ts` · `aislo-drawing-bridge.ts` — 붉은 틀 보이기 값 · 메시지(CAD build)
- 시험: 새 `test_19x_b07_cross_group.py` · `test_174_b07_cross_cell_stack.py` · `test_140_b07_cross_grid.py` · `test_153_1_b07_cross_layout.py`(+ helper) · `guide-area.test.ts`
## 추천 차례
19(파이썬만 · 작음) → 21(파이썬 + 배치 칸) → 20(CAD 빌드 한 번) · 20 · 21 단추 · 칸은 같은 배치 칸 줄에 한 번에.
@@ -0,0 +1,156 @@
# 조사 Y — B09 탭 두 층 · 박스보기 · 설계내역서 손 줄 레벨 (10-10 둘째 · 요구 22 · 23 · 29 · 30 · 읽기 전용 조사 에이전트)
| 약칭 | 뜻 |
|---|---|
| `B09/` | `B09_Estimation/B09_Estimation_` |
| `B08/` | `B08_Quantity/B08_Quantity_` |
| `ui/` | `ui_template/ui_template_` |
| 손 줄 | 166-2 설계내역서 사용자 줄(`user.bill.rows` · 키 `hand:…`) |
| 「추정」 | 화면 실측 없이 코드 · CSS 셈으로 낸 판단 |
## 0. 코드 지도
| 자리 | 파일:줄 | 줄 수 |
|---|---|---|
| 탭 키 · 이름 · 차례(평평한 한 줄 19) | `B09/UI_Tabs.ts:13-32 · 35-55` | 168 |
| 탭 줄 그리기 · 이동 · 묶음 탭 갈래 | `B09/UI_Shell.ts:151 · 182-254` | 332 |
| 탭 줄 CSS(sm 단추 · gap 4) | `B09/UI_Page.css:20-26` | 45 |
| B08 위 탭(md) · 보조 탭(sm) · 빈 보조 줄 | `B08/UI_Page.ts:30-44 · 56-83 · 90-100 · 118-134` · `B08/UI_Page.css:14-25` | 154 · 62 |
| 묶음 보기 단추 · 카드 · 표 갈래 | `B09/UI_Tabs_Groups.ts:31-35 · 165-207` | 207 |
| 공용 묶음 카드(요약 접기 · 강조) | `ui/group_cards.ts:71-80 · 187-247` · `.css:79-85 · 131-134` | 249 · 153 |
| 공용 표 강조 한 줄(`focusRow`) | `ui/data_table.ts:288-292` · `.css:148-152` | 294 · 174 |
| 설계내역서 표 · 손질 · 우클릭 | `B09/UI_Tabs_Bill.ts:23-81` · `B09/UI_Tabs_Bill_Edit.ts:72-242` | 81 · 242 |
| 설계내역서 셈(짝) | `B09/Calc_Bill.ts:28-43 · 237-266` ↔ `B09/Calc_Bill.py:33-52 · 246-275` | 299 · 310 |
| 손 줄 저장 다듬기 | `B09/Store.py:111-138` | 208 |
## Y29 · Y30. 원가검토 탭 = 상위 · 하위 두 층
### 현재 탭 19 + 새 자리
| 차례 | 키 | 지금 이름 | 짜는 파일:줄 | 새 상위 · 하위 차례 |
|---|---|---|---|---|
| 1 | `check` | 연결 점검 | `B09/UI_Tabs.ts:139` | 상위 단독 |
| 2 | `cost` | 공사원가계산서 | `B09/UI_Tabs_Total.ts:245` | 상위 단독 |
| 3 | `total` | 총괄설계내역서 | `B09/UI_Tabs_Total.ts:81` | 상위 단독 |
| 4 | `bill` | 설계내역서 | `B09/UI_Tabs_Bill.ts:23` | 상위 단독 |
| 5 | `dg_list` | 일위대가목록표 | `B09/UI_Tabs_Labor.ts:45` | 일위대가 ① |
| 6 | `dg` | 일위대가표 | `B09/UI_Tabs_Labor.ts:78` | 일위대가 ② |
| 7 | `sg_list` | 단가산출근거목록표 | `B09/UI_Tabs_Sangeun.ts:39` | 단가산출 ① |
| 8 | `sg` | 단가산출근거 | `B09/UI_Tabs_Sangeun.ts:76` | 단가산출 ② |
| 9 | `basis` | 환율및기초자료 | `B09/UI_Tabs_Base.ts:63` | 상위 단독(사용자 목록에 없음) |
| 10 | `machine` | 중기목록표 | `B09/UI_Tabs_Base.ts:237` | 중기 ① |
| 11 | `machine_blocks` | 중기사용료 | `B09/UI_Tabs_Base.ts:263` | 중기 ② |
| 12 | `material` | 재료비목록표 | `B09/UI_Tabs_Base.ts:210` | 재료 ① |
| 13 | `labor` | 노무비목록표 | `B09/UI_Tabs_Base.ts:45` | 노무 ① |
| 14 | `expense` | 경비목록표 | `B09/UI_Tabs_Base.ts:52` | 중기 ④ |
| 15 | `compare` | 자재단가대비표 | `B09/UI_Tabs_Base.ts:116` | 재료 ②(사용자 글 「자재단가목록표」) |
| 16 | `sum_material` | 재료비수량금액집계표 | `B09/UI_Tabs_Summary.ts:57` | 재료 ③ |
| 17 | `sum_labor` | 노무비수량금액집계표 | `B09/UI_Tabs_Summary.ts:58` | 노무 ② |
| 18 | `sum_expense` | 경비수량금액집계표 | `B09/UI_Tabs_Summary.ts:59` | 중기 ⑤(166-5 · 자원 0 → 안내 한 줄) |
| 19 | `sum_machine` | 중기시간금액집계표 | `B09/UI_Tabs_Summary.ts:61` | 중기 ③ |
- 새 상위 10 = 연결 점검 · 공사원가계산서 · 총괄설계내역서 · 설계내역서 · 일위대가 · 단가산출 · 환율및기초자료 · 중기 · 재료 · 노무(지금 차례 그대로 묶음 첫 자리).
- 이름 짝 — 다른 것은 「자재단가목록표」 ↔ `compare` 「자재단가대비표」 하나. 사용자 25 · 26 · 28번 글은 「대비표」.
- 없는 탭 — 없음. 중기시간금액집계표 · 경비수량금액집계표 · 노무비수량금액집계표 · 재료비수량금액집계표 넷 모두 있음(`B09/UI_Shell.ts:82-85`).
- 하위 차례가 엑셀 평평 차례와 다른 곳 = 중기(경비목록표 · 집계표 둘이 중기 뒤로) · 재료(대비표가 재료비목록표 뒤).
### B08 양식(본뜰 꼴)
- 위 탭 = `createButton` 기본 md(`B08/UI_Page.ts:122-126` · size 없음) · 보조 탭 = sm 줄 `div.b08-quantity__tabs`(`:57-75`) · 보조 탭 없는 탭 = 보이지 않는 sm 단추 한 줄로 높이만 채움(170-4 · `:90-100` · CSS `:22-25`) → 탭 바꿀 때 몸통이 안 튐.
- CSS = `.b08-quantity__tabs` gap 8 · 아래 8 · 밑줄 1px(`B08/UI_Page.css:14-20`) — B08 파일 안 클래스 · `ui/` 공용 탭 양식은 없음(`tablist` 쓰는 곳 = B08 · B09 · M02 DF · `ui/cad_host/cad_host_tabs.ts`(CAD 장 탭 · 결 다름)).
- B09 지금 = 한 줄 sm 19개(`B09/UI_Shell.ts:184-197`) · gap 4(`B09/UI_Page.css:23`).
### 고칠 자리
- `B09/UI_Tabs.ts` — `TABS`(엑셀 차례 · 탭 이름 · 표 caption · 연결 점검 「탭」 칸)는 그대로 · 새 `TAB_GROUPS: [상위 키, 이름, 하위 TabId[]][]` + `groupOf(tab)` 더함(+20줄).
- `B09/UI_Shell.ts:184-197` — 위 줄 = 상위 10(md) · 아래 줄 = 고른 상위의 하위(sm) · 하위 하나뿐인 상위 = 빈 보조 줄(B08 170-4 꼴) · 위 탭 누름 = 그 묶음에서 마지막 본 하위(이 화면 안 Map) 없으면 첫 하위 · `show(tab, focus)` 는 TabId 그대로 받아 상위를 `groupOf` 로 정함 → 이동(`goto` `:205-207` · 연결 점검 줄 · 목록표 → 상세)이 묶음을 넘어도 그대로.
- `B09/UI_Page.css:20-26` — 위 줄 gap 8(B08 값) · 보조 줄 · 빈 줄 규칙 둘(B08 css 본뜸 · +10줄).
### 재사용 · 기억 영향
- `createButton` md · sm · `role=tab` · `aria-selected` · `data-b09-tab`(그대로 하위 키 · 상위엔 `data-b09-group`).
- 탭 기억 — 지금 없음: `current = "check"`(`B09/UI_Shell.ts:151`) · 해시 · sessionStorage 안 씀(`location.hash` 는 라우터만 `A00_Common/router.ts:122`) → 들어올 때마다 「연결 점검」. 두 층으로 바꿔도 같음.
- 보기 기억 `b09-group-view` · `b09-group-cols`(`A00_Common/b_page_state.ts:70-71`)는 하위 키(dg · machine_blocks · sg) 그대로 → 영향 없음.
### 시험 · 위험 · 크기
- 시험 — `test_144_b09_skeleton.py:44-49`(`TABS` 엑셀 차례 · 안 바꾸면 통과) · 새 판정(상위 10 차례 · 하위 묶음 = 사용자 30번 차례 · 19 키 빠짐 · 겹침 없음 · `groupOf` · 묶음 넘는 이동 = 상위 켜짐 · 단독 상위 = 빈 보조 줄).
- 위험 — 위 탭 md 10개 + 보조 줄 = 탭 머리 높이 ≈ +36px(추정 · sm 줄 하나 더) · 「연결 점검」 「탭」 칸은 하위 이름만(상위 안 보임).
- 크기 — TS +45 · CSS +10 · Shell 332 → 360.
- ⬜ Y30-a 「자재단가목록표」 = `compare` 이름을 바꿈? — 추천 그대로 「자재단가대비표」(엑셀 탭 이름 · test_144 · 25 · 26 · 28번 글).
- ⬜ Y30-b 「환율및기초자료」 자리 — 추천 지금 차례(단가산출과 중기 사이 상위 단독).
- ⬜ Y30-c 위 탭 다시 누름 = 마지막 본 하위(가 · 추천 · 저장 안 함) / 늘 첫 하위(나 · B08 꼴).
## Y22. 「호표별 보기」 → 「박스보기」 · 접기 없앰 · 목록표 고른 호표 = 붉은 외곽선
### 현재
- 단추 글 = `viewLabel`(`B09/UI_Tabs_Groups.ts:32-33` · 「호표별 보기」 ↔ 「표로 보기」) · 묶음 탭 = dg · machine_blocks · sg(`:27`).
- 접기 = 묶음 12 넘으면 요약 보기(`ui/group_cards.ts:71 · 80 · 187-189 · 218-225 · 236-237` · CSS `.is-closed` `:79-85`) · 공용 카드 쓰는 곳 = B09 하나(`B09/UI_Tabs_Groups.ts:192`).
- 목록표 → 상세 — 일위대가목록표 `link` = `{tab: "dg", key: 블록 id}`(`B09/UI_Tabs_Labor.ts:68`) · 산근목록표 = `{tab: "sg", key}`(`B09/UI_Tabs_Sangeun.ts:61`) · 중기목록표 → 중기사용료도 같은 꼴(`B09/UI_Tabs_Base.ts:253`) → `show(tab, key)`(`B09/UI_Shell.ts:205-207`) → focus = 블록 머리 줄 번호(`B09/UI_Tabs_Groups.ts:189`).
- 박스 강조 지금 = 머리 띠 한 줄만 `is-focus`(accent 8% 바탕 · `ui/group_cards.ts:241-247` · CSS `:131-134`).
- 표 강조 지금 = 머리 줄 한 줄 `focusRow`(accent 밑줄 2px · `ui/data_table.ts:288-292` · CSS `:148-152`) · 표는 줄 28px 창 그리기(`ui/data_table.ts:239`) → 줄 class 는 `rowClass` 로만 오래 감.
### 고칠 자리
- `B09/UI_Tabs_Groups.ts:33` — 「호표별 보기」 → 「박스보기」.
- `ui/group_cards.ts` — 요약 보기 지움(`GROUP_SUMMARY_OVER` · `isSummary` · `is-closed` · [모두 펼침 · 접기] · 머리 누름 펼침) · 이동해 온 키 = 그 박스(`article`)에 `is-pick`(몸 줄이면 그 줄 `is-focus` 도 유지) · CSS = B06 고름 꼴 `border-color: var(--color-danger); box-shadow: 0 0 0 2px var(--color-danger)`(`B06_Section/B06_Section_UI_Style_Cross.css:219-222`) · `.is-closed` 규칙 지움.
- 표 보기 — `B09/UI_Tabs_Groups.ts:204-205` · `B09/UI_Shell.ts:208` `makeTable(extra?)` 에 줄 class 곁값 → 고른 블록(키 `|` 앞머리 같은 줄 구간) = `is-pick` + 첫 줄 `is-pick-top` · 끝 줄(합계 · 총계) `is-pick-end` · CSS(`B09/UI_Page.css`) = 안쪽 그림자 4면 danger 2px(첫 칸 왼 · 끝 칸 오른 · 위 · 아래) · 머리 줄 accent 밑줄은 `is-pick` 안에서 덮음 · 스크롤 = `focusRow(머리)` 그대로.
- 공용 표(`ui/data_table.ts`)는 안 고침(`rowClass` 곁값으로 끝남).
### 재사용 · 시험 · 위험 · 크기
- 재사용 — `--color-danger`(밝음 #c0392b · 어두움 #e8695a · `ui/theme.css:40 · 323`) · 164-2 「표 강조 = 붉은 한 벌」 · B06 카드 고름 CSS · `rowIndex` · 묶음 키 규칙(`B09/UI_Tabs_Groups.ts:136`).
- 시험 — `test_157_2_b09_sangeun_views.py:69` · `test_171_3_b09_group_tabs.py:165`(글 「박스보기」) · `test_171_2_group_cards.py:42-50 · 114-136`(요약 판정 → 「13 묶음도 접힘 없음 · 머리 누름 = 이동 없음 · 고른 박스 `is-pick`」) · 새 판정(표 보기 블록 구간 class · 첫 · 끝 줄 · 다른 블록 없음).
- 위험 — 묶음이 많은 탭(산근 수십) = 다 펼쳐져 길어짐(요구대로 · 박스 찾기는 목록표 누름 → 스크롤) · OS 어두움 자동 블록엔 danger 어두운 값 없음(R12 공용 빈틈 · 범위 밖).
- 크기 — 공용 TS −25 +6 · CSS −7 +4 · Groups +12 · Shell +3 · B09 CSS +12.
- ⬜ Y22-a 글 = 「박스보기」(사용자 글 그대로 · 추천) / 「박스 보기」 · 맞은편 「표로 보기」 유지.
## Y23. 설계내역서 손 줄 레벨 · 상위 합계 · 연결(우클릭 + 단추)
### 현재
- 줄 꼴 = 제목(`kind: "title"` · 고정 공종 나무 14 `B09/Calc_Bill.ts:28-43`) · 항목(`kind: "item"`) 둘뿐 · 레벨 칸 없음 · 공종 번호 깊이 = 2(1 → 1.1) · 합계 = 항목이 자기 공종 + 부모 하나에 더함(`B09/Calc_Bill.ts:240-249` ↔ `.py:250-260`) · 짝 맞음(⚠ 머리 주석 · 거울 `test_166_2_b09_bill_hand.py:125-135` 전체 같음 판정).
- 화면 레벨 표시 = 항목 이름 앞 빈칸 둘뿐(`B09/UI_Tabs_Bill.ts:28`) · 제목 굵게 없음(`rowClass` = `is-hand` 만 `:74`) · 1 토공사 · 1.1 준비공이 같은 꼴.
- 손 줄 저장 = `user.bill.rows[] {key hand:… · section · after · name · spec · unit · qty · ref}`(`B09/UI_Tabs_Bill_Edit.ts:7 · 87` · 다듬기 `B09/Store.py:111-138` · 저장 `user_values.json`) · 자리 = `after` 줄 뒤(`B09/Calc_Bill.ts:147-152`) · 손 줄은 늘 항목(수량 × 단가).
- 연결 = 우클릭 [연결 ▸ 일위대가 호표 · 단가산출 호표 · M01 로직](`B09/UI_Tabs_Bill_Edit.ts:194-206`) · 고르기 창 = `openModal` + 공용 `createFindList`(`:116-151`) · 로직 = M02 `openLogicPicker` + `openLaborInput`(`:154-183`) — 이미 있음 · 단추는 없음.
- 다른 고르기 창 — B08 [선택] = `openItemPicker`(구조물 조합 고름 · `B08/UI_Link.ts:46`) · 호표가 아니라 조합 → 이번엔 안 맞음.
### 고칠 자리
- 저장 꼴 — 손 줄 곁값 둘: `kind`(`"group"` = 묶음 줄 · 없음 = 항목) · `parent`(묶음 손 줄 키 · 없음 = 공종 바로 아래). 묶음 줄 = 수량 · 연결 없음(이름만).
- 셈(짝 ① · `B09/Calc_Bill.ts` ↔ `.py` 함께) — 묶음 줄 = `itemRow` 안 거침(「연결 없음」 거짓 목록 안 생김) · 금액 = 자손 항목 합 + 「빈 줄 n」 · 공종 합(`:240-249`)은 항목(잎)만 → 원가계산서 `sums` 그대로 · 줄마다 `level` 내보냄(1 = 1 토공사 · 2 = 1.1 · 3 = 그 안 항목 · 손 묶음 + 1 …) · `parent` 못 찾으면 공종 바로 아래(옛 저장도 그대로 읽힘).
- `B09/Store.py:122-130` — `kind` · `parent`(`hand:` 시작만) 남김 · 묶음 줄 `qty` · `ref` = None.
- `B09/UI_Tabs_Bill_Edit.ts` — 우클릭 더함: [아래에 하위 줄 추가](묶음 줄 · 자리 = 마지막 자손 뒤) · [묶음 줄로 · 항목으로] · [들여쓰기 → · 내어쓰기 ←](= `parent` 바꿈) · 지울 때 자식은 지운 줄 부모로(`:92-99` `after` 꼴).
- `B09/UI_Tabs_Bill.ts` — 이름 앞 빈칸 대신 `rowClass` = `is-lv{n}`(+ `is-hand`) · CSS(`B09/UI_Page.css`) Lv1 = 16px 굵게 · Lv2 = 14px 굵게 · Lv3 = 14px · Lv4+ = 13px · 첫 칸 들여쓰기 1em × (레벨 − 1) · 묶음 손 줄 = 굵게.
- 단추 — 손 줄 「비고」 칸 앞 새 좁은 칸 「연결」(글 「연결 ▾」 · 연결됨 = 호표 번호) · 누름(`onCell`) = 우클릭과 같은 [연결 ▸] 메뉴를 그 칸 자리에 엶(`ctxMenu.open` · 칸 `getBoundingClientRect`) · 이 칸은 `link` null(이동 안 함).
### 재사용 · 계산 자리
- `pickHopyo`(dg · sg 목록 · `createFindList`) · `openLogicPicker` · `openLaborInput` · 공용 우클릭 메뉴(`ui/context_menu.ts:130` `open(x, y, items)` · 겹 메뉴) · `rowClass` 여러 class(빈칸으로 이음 · `ui/data_table.ts:164`) · 집계표는 `kind !== "item"` 건너뜀(`B09/Summary.py:53`) → 묶음 줄 안전 · 산근 손 로직(`B09/Sangeun.py:477-489`)은 `ref.tab == "logic"` 만 → 묶음 줄 안 걸림.
- 계산 자리 = ① 파이썬 · TS 짝(이미 짝 · 조작 중 브라우저 · [저장] 때 서버 정본) · 서버 단독 아님(조작 즉시 합계 보여야 함) · 새 계산 없음 — 합 규칙만 더함.
### 시험 · 위험 · 크기
- 시험 — `test_166_2_b09_bill_hand.py`(자리 · 거울 · 저장 다듬기) 손봄 + 새 판정(묶음 줄 합 = 자손 합 · 겹 묶음 · 공종 합 · `sums` 안 바뀜 · 빈 줄 n · 부모 없음 = 공종 아래 · 옛 꼴 그대로 · `level` 값 · TS 거울 · 다듬기 `kind` · `parent`) · `test_145_3_b09_bill.py`(rows 에 `level` 더해짐 → 판정 손봄 여부 확인) · `test_145_5`(집계표 · 묶음 줄 빼고 셈).
- 위험 — ① 표가 줄 28px 고정 창 그리기 → 글자 16px 넘으면 줄 높이 어긋남(16px 까지) ② 1 토공사 굵게 · 크게로 지금 화면 꼴이 다 바뀜 ③ `after` 차례와 `parent` 가 어긋나면(자식이 부모 앞) 합은 맞고 들여쓰기만 이상 → 끼울 때 마지막 자손 뒤 규칙 ④ 둘 짝 한쪽만 고치면 거울 깨짐.
- 크기 — Calc_Bill ts 299 → 335 · py 310 → 350 · Bill_Edit 242 → 300 · Bill 81 → 95 · Store +5 · CSS +12.
- ⬜ Y23-a 저장 꼴 = `parent` 키(가 · 추천 · 차례가 흔들려도 합 맞음) / `level` 수(나 · 엑셀 윤곽 꼴 · 차례로 부모 짐작).
- ⬜ Y23-b 손 줄로 새 1단 공종(예 「5 부대공」) 허용? — 추천 안 함(고정 공종 안에서만 · 원가계산서 직접비 1~4 합 규칙 그대로) · 필요하면 직접비에 넣을지 따로 정함.
- ⬜ Y23-c 손 묶음 깊이 한도 — 추천 2(묶음 안 묶음까지 · Lv5) · 글자 13px 바닥.
- ⬜ Y23-d 자동 줄(B08 수량 줄)도 손 묶음 아래로 옮김? — 추천 안 함(자동 줄은 공종 고정 · 손 줄만).
## 고칠 파일 목록
| 파일 | 지금 | 뒤(추정) | 700 | 요구 |
|---|---|---|---|---|
| `B09_Estimation/B09_Estimation_UI_Tabs.ts` | 168 | 190 | 없음 | 29 · 30 |
| `B09_Estimation/B09_Estimation_UI_Shell.ts` | 332 | 365 | 없음 | 29 · 30 · 22 |
| `B09_Estimation/B09_Estimation_UI_Page.css` | 45 | 80 | 없음 | 29 · 30 · 22 · 23 |
| `B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts` | 207 | 220 | 없음 | 22 |
| `ui_template/ui_template_group_cards.ts` · `.css` | 249 · 153 | 230 · 150 | 없음 | 22 |
| `B09_Estimation/B09_Estimation_Calc_Bill.ts` · `.py` | 299 · 310 | 335 · 350 | 없음 | 23(짝) |
| `B09_Estimation/B09_Estimation_Store.py` | 208 | 213 | 없음 | 23 |
| `B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts` | 242 | 300 | 없음 | 23 |
| `B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts` | 81 | 95 | 없음 | 23 |
| 시험 — `test_144_b09_skeleton` · `test_157_2` · `test_171_2` · `test_171_3` · `test_166_2` · `test_145_3` · `test_145_5` + 새 시험 | — | — | — | 22 · 23 · 29 · 30 |
@@ -0,0 +1,78 @@
# 조사 ZA — 관급(관급자재) 구현 여부
요구 27 · 조사만(코드 안 고침) · 기준 = 지금 `main_laptop_1` 작업 트리 · 상태 = 됨 / 일부 / 없음.
## 1. 한눈 결론
- **원가 계산 끝단은 됨** — 관급자재대 = 순자재대 + 조달수수료 → 천원 올림 · 도급공사비 밖 · 총공사비에 더함 · 일반관리비 · 이윤 · 부가세 밑수에서 빠짐 · 안전관리비 A/B 비교까지 실무 엑셀(기번3) 숫자와 같음.
- **관급 표시는 B09 자재단가대비표 한 곳뿐** — 「관급 · 별도」 칸 누름으로 바꿈 · 기본 관급은 파형강관 하나.
- **M02 자재구분(관급 · 사급)은 저장 · 화면만** — B09 가 안 읽음 → 마스터 · 사본에서 「관급」으로 골라도 원가에 안 이어짐.
- **같은 자재가 두 줄(B08 총괄 줄 · 일위대가 안 자재 줄)** — 관급 표시를 따로 해야 함 · 한쪽만 하면 두 번 셈 또는 사라짐 · 커플링밴드는 기본 관급 아님 → 내역서에서 빠짐.
- **엑셀 내보내기 없음** · 조달수수료율 기본값 없음(빈칸이면 관급자재대 · 총공사비가 빈칸).
## 2. 흐름 단계 표
| 단계 | 상태 | 지금 어떻게 | 근거 파일:줄 | 빈틈 |
|---|---|---|---|---|
| M02 자재구분 칸 | 일부 | 일위대가 자재 줄 · 로직 자재 줄에 「관급 · 사급」 드롭다운 · 빈 칸 = 사급(162-1) · 금액 무관 | M02_Labor_Doc.py:49-54 · :222-224 · M02_MasterTemplete_Labor_Api.ts:143 · :150-151 · M02_MasterTemplete_Labor_MatMarks.ts:3 | 저장만 · `mark_of` 를 시험 말고 부르는 곳 없음 |
| 프로젝트 사본(구조물) 자재구분 | 일부 | 사본 일위대가에 그대로 실림(검증 프로젝트에 「관급」 표시 실제 있음 · 아래 5) | storage …/d2245e3b…/templates/structures/pipe@263.21/일위대가.json | B09 가 안 읽음 |
| M01 자재품목 「관급」 | 됨(단가 출처) | 조달청 관급 단가 열(30,711 줄 중 11,278 줄 값) · 고르기 창에 「관급」 꼬리표 | M01_MasterData_Store.py:490 · :503 · M01_MasterData_UI_Pick.ts:85 | 이건 「관급 가격」 출처 · 「누가 사나」 표시와 다른 뜻 |
| B05 · B06 배관 | 없음 | 관 배치 · 관경만 · 관급 개념 없음 | (해당 코드 없음) | 필요 없음 — B09 기본값이 맡음 |
| B08 구조물 집계표 | 없음 | 관급 표시 칸 없음 · 커플링 열은 B09 내역서 구조물 줄에서 뺌(「관급자재대라」) | B09_Estimation_Bill_Items.py:8 · :47 | 뺀 커플링이 관급자재대에도 안 들어감(아래 B09 기본값) |
| B08 자재 탭(총괄) | 일부 | 파형강관 · 커플링 할증 0(관급 뜻) · 관급 표시 칸 없음 · 안내 글만 | B08_Quantity_Material_Calc.py:99 · :119 · :349-354 · B08_Quantity_UI_Material.ts:55 | 표시는 B09 에서 |
| B09 자재단가대비표 「관급 · 별도」 | 됨 | 칸 누름 = 없음 → 관급 → 별도 · 사용자 값 저장(user_values.materials[키].supply) | B09_Estimation_UI_Tabs_Base.ts:108 · :155 · B09_Estimation_Calc_Base.py:38 · :161 · B09_Estimation_Store.py:151-152 | — |
| B09 기본 관급 | 일부 | B08 총괄 `pipe_` 줄만 관급 기본 | B09_Estimation_Prices.py:231-232 | 커플링밴드(M01 이름도 「(관급)」) · 시멘트 · 모래 등 기본 없음 · M02 표시 안 씀 |
| B09 재료비목록표 관급 칸 | 됨 | 「관급 · 별도」 열 보임 · 총액 = 수량 × 적용 단가 | B09_Estimation_Calc_Base.py:181-189 · B09_Estimation_UI_Tabs_Base.ts:224 | B08 파형강관 줄은 M01 짝 없음 → 관급 단가 수기 입력 |
| 일위대가(대가) 안 관급 자재 | 됨 | 관급 · 별도 표시 자재 = 대가 안 0원(두 번 안 셈) | B09_Estimation_Calc_Labor.py:46-47 | 수량 없는 MT 줄이면 관급자재대에도 안 감 → 사라짐 |
| 단가산출근거(산근) 안 관급 자재 | 없음 | 표시와 무관하게 적용 단가 그대로 셈 | B09_Estimation_Sangeun.py:155 · :414 | 대가와 규칙 어긋남 |
| 설계내역서 관급자재대 단락 | 됨 | 「관급」 표시 + B08 수량 있는 자재 → 관급자재대(제외금액) · 조달수수료 줄 · 순공사비 계에서 뺌 | B09_Estimation_Calc_Bill.py:47-50 · :98-118 · :217-245 · B09_Estimation_UI_Tabs_Bill.ts:48 | 수량 원천 = B08 총괄 줄뿐 |
| 조달수수료 | 일부 | ROUNDDOWN(순자재대 × 율 ÷ 100, 0) · 율 = 사용자 칸(빈칸 시작) | B09_Estimation_Calc_Bill.py:221-245 · B09_Estimation_Calc_Total.py:68-70 · :206-210 | 화면 안내 「빈칸 = 0」(UI_Tabs_Total.ts:203) 인데 셈은 빈칸 → 관급자재대 · 총공사비 빈칸 |
| 원가계산서 · 총괄 관급자재대 | 됨 | ROUNDUP(순자재대 + 수수료, −3) · 도급공사비 밖 · 총공사비 = 도급 + 관급 | B09_Estimation_Calc_Total.py:204-211 · B09_Estimation_UI_Tabs_Total.ts:169-176 · :241 | — |
| 일반관리비 · 이윤 · 부가세 | 됨 | 밑수에 관급 없음(순공사원가 · 다 − 재료비 · 공급가액) | B09_Estimation_Calc_Total.py:196-203 | — |
| 산업안전보건관리비 | 됨 | A = (재료비 + 직노 + 관급 ÷ 1.1) × 율 + 기초액 · B = ((재료비 + 직노) × 율 + 기초액) × 1.2 · 작은 쪽 | B09_Estimation_Calc_Total.py:59-60 · :146-150 · :186-192 | — |
| 추정가격(요율 구간) | 일부 | 손 입력 칸만 | B09_Estimation_UI_Tabs_Total.ts:32 · :131 | 「관급 빼고」 안내 · 자동 후보 없음 |
| 견적가(자재 견적) | 됨(관급 무관) | 대비표 「견적」 열 · 관급 자재도 같은 칸으로 단가 | B09_Estimation_Calc_Base.py:150-158 | — |
| 엑셀 내보내기 | 없음 | B09 엑셀 내보내기 코드 없음 | (해당 코드 없음) | 관급 포함 전체 없음 |
| 옛 자리(안 씀) | — | 프로젝트 설정 `material_supply` · 화면 글 키 넷(관급·사급 탭) — 옛 코드는 tmp/old_code 로 감 | common_util_project_settings.py:100-108 · ui_template_locale_b2.ts:857-863 · :912 · :918 | 정리 거리 |
## 3. 기준 대조
| 기준(근거 문서) | 지금 구현 | 판정 |
|---|---|---|
| 관급자재대 = 총원가 밖 별도 · 총공사비에 합함 — 지식DB 원가계산_체계.md:30 · :40-41 · 산림과임업기술(임도) 5. 설계.md:558 · :581 | Calc_Total.py:204-211 | 맞음 |
| 일반관리비 · 이윤 · 부가세에 관급 안 듦 — 예정가격작성기준 원가 구성 · 원가계산_체계.md 트리 | Calc_Total.py:196-203 | 맞음 |
| 안전관리비 = 관급 포함 값 · 관급 뺀 값 × 1.2 중 작은 값 이상 — 건설업 산업안전보건관리비 계상 및 사용기준 현행_20250212.md:18 · :44 · 제비율 적용기준 현행_20260413.md:125 | Calc_Total.py:186-192 · ÷ 1.1 은 실무 엑셀 꼴(관급가 부가세 포함) · M01 요율 규칙 메모와 같음 | 맞음(÷ 1.1 = 실무 근거) |
| 조달수수료 = 순자재대 × 율 원 버림 · 관급자재대 천원 올림 — 조달수수료 고시 현행_20250701.md(단가계약 0.54 %) · 단수처리_규칙.md:28 · :30 | Calc_Bill.py:222 · Calc_Total.py:205-210 · 기번3 69,474,220 + 375,160 → 69,850,000 같음(PLAN 145-3) | 맞음 · 율 기본값은 사용자 협의 거리 |
| 관급 자재 = 내역 단가 0원 · 관급자재대로 따로 — 자재단가_적용.md:32 | 대가 맞음(Calc_Labor.py:46-47) · 산근 다름(Sangeun.py:414) | 일부 |
| 관급 · 사급 구분 = 발주기관 결정 · 프로그램은 자재별 표시 받아 두 갈래 집계 — 자재단가_적용.md:36 | B09 대비표 칸 됨 · M02 표시 안 이어짐 · 같은 자재 두 줄 | 일부 |
| 관급 흔한 품목 = 시멘트 · 모래 · 자갈 · 깬돌 · 파쇄암 · 파형강관(일부) — 자재단가_적용.md:32 · 원가_기초데이터_원천.md:166 | 기본 관급 = 파형강관만 · 커플링밴드 빠짐 | 다름(기본값 좁음 · 확정은 사용자 몫) |
| 추정가격 = 관급자재 부분 뺀 금액 — 국가계약법 시행령 현행_20260603.md:69(제7조 1호) · 지방계약법 시행령 현행_20260603.md:87 | 손 입력만 · 안내 없음 | 일부 |
| 공사손해보험료 = (총공사원가 + 관급) × 율 — 예정가격작성기준 현행_20260825.md:219 | 비목 자체 없음 | 없음(임도 실무 엑셀 비목 밖 · 참고) |
| 관급자재 관리비(경비 세비목) — 예정가격작성기준 현행_20260825.md:202 | 없음 | 없음(실무 엑셀 밖 · 참고) |
| 산재 · 고용 적용 = (도급 + 관급) 부가세 빼고 2천만 원 이상 — 제비율 적용기준 현행_20260413.md:104 | 문턱 검사 없음 | 없음(작은 공사만 해당) |
## 4. 빈틈 목록과 고칠 거리
| # | 빈틈 | 고칠 거리 | 크기 |
|---|---|---|---|
| 1 | 같은 자재가 B08 총괄 줄(`b08:cement`)과 대가 안 MT 줄(`MT004188`)로 따로 — 관급 표시를 한쪽만 하면 두 번 셈(대가 정가 + 관급자재대) 또는 사라짐(대가 0원 · 관급자재대 줄 없음) | 관급 표시를 자재 한 벌로 묶음(총괄 줄 ↔ MT 짝) · 또는 관급 수량 = 대가 역전개 총수량(Summary.py:50 이미 셈) | 중 |
| 2 | M02 · 사본 「자재구분 관급」이 B09 에 안 이어짐 | B09 입력 짤 때(Prices.build_inputs) 사본 자재표시 「관급」 → 그 자재 기본 supply 관급 · 두 사본이 다르게 고른 자재 규칙은 사용자 협의 | 중 |
| 3 | 산근 안 관급 자재 정가 셈 | 대가와 같은 규칙(관급 · 별도 = 0원) · 파이썬 한 곳(서버 단독) | 소 |
| 4 | 커플링밴드 기본 관급 아님 → 구조물 줄에서도 빠지고 관급자재대에도 안 감 | Prices.py:232 기본 관급에 `coupling_` 더함 | 소 |
| 5 | 조달수수료율 빈칸 = 안내는 0 · 셈은 빈칸 | 안내 글을 셈에 맞추거나 빈칸 = 0 처리 · 기본 0.54 % 여부는 사용자 협의 | 소 |
| 6 | B08 파형강관 · 커플링 줄 M01 짝 없음 → 관급 단가 수기 입력 | B08 총괄 줄 ↔ M01 「파형강관 · 커플링밴드(관급)」 규격 짝 → 관급 단가 자동 | 중 |
| 7 | 추정가격 손 입력 · 관급 제외 안내 없음 | 칸 안내 한 줄(「관급 · 부가세 뺀 금액」) · 자동 후보(공급가액)는 나중 | 소 |
| 8 | 엑셀 내보내기 없음 | B09 탭 전체 내보내기(관급자재대 단락 · 총괄 줄 포함) — 별도 일감 | 대 |
| 9 | 안 쓰는 옛 자리 | `material_supply` 설정 칸 · 화면 글 키 넷 정리 | 소 |
| 10 | 공사손해보험료 · 관급자재 관리비 · 산재 · 고용 2천만 원 문턱 | 실무 엑셀에 없으면 둘 필요 없음 · 필요 여부 사용자 확인만 | 참고 |
## 5. 검증 자료 확인(읽기만)
- storage 검증 프로젝트 `d2245e3b…` 사본 `pipe@263.21` 일위대가 — 콘크리트 로직 자재 시멘트(MT004188) · 모래(MT003816) = 「관급」 표시 · B09 는 이 표시 안 봄.
- 모든 프로젝트에 B09 `user_values.json` 없음 → B09 「관급 · 별도」 칸을 저장한 이력 없음 · `price_book.json` 은 한 곳(MT 6 줄 · 관급 단가 빈칸).
- 시스템 임시 폴더 스크립트(순수 셈 함수만 부름) 결과:
- 파형강관 Φ1000 8 m(기본 관급 · 단가 100,000) → 관급자재대 단락 800,000 · 순공사비 계 0(빠짐 맞음).
- 커플링밴드(표시 없음 · 수량 2) → 설계내역서 어디에도 없음.
- 시멘트 MT(관급 표시 · B08 수량 없음) → 대가 단가 0원 · 관급자재대 줄도 없음(사라짐).
- 조달수수료율 빈칸 → 관급자재대 빈칸 · 율 0.54 % → 800,000 + 4,320 → 805,000(천원 올림).
- 엑셀 대조(PLAN 145-3 · 145-4 검증완료) — 관급 69,474,220 + 0.54 % 375,160 → 69,850,000 · 도급 1,132,029,000 + 관급 = 총공사비 1,201,879,000 · 안전 A 16,586,996 · B 18,494,700 같은 값.
@@ -0,0 +1,135 @@
# 조사 Z — B09 재료비목록표 줄 출처 · 자재단가대비표 열 · 적용 드롭다운 · 수량 열 (10-10 둘째 · 24 · 25 · 26 · 28 · 읽기 전용 조사 에이전트)
| 줄임 | 뜻 |
|---|---|
| `B09/` | `B09_Estimation/B09_Estimation_` |
| `ui/` | `ui_template/ui_template_` |
| 대비표 | 탭 `compare` 「자재단가대비표」(`B09/UI_Tabs.ts:50`) = 사용자 글 「지제단가대비표」 · 「자재단가목록표」(30번) |
| 목록표 | 탭 `material` 「재료비목록표」(`B09/UI_Tabs.ts:47`) |
| 사본 | 프로젝트에 연결된 구조물 조합 사본(`linked_snapshots`) |
| 실측 | 스크래치 스크립트(시스템 임시 폴더) · storage 읽기 + DB SELECT(B08 총괄) · 저장 안 함 |
## 0. 코드 지도
| 자리 | 파일:줄 | 줄 수 |
|---|---|---|
| 수요 키 = 사본 로직을 돌릴 때 `get` 가로채기 | `B09/Linked.py:178-207` · `:210-220` | 73 |
| 설계내역서 수기 줄 수요(같은 꼴) | `B09/Sangeun.py:496-517` | 563 |
| 수요 → 자재 줄 · B08 총괄 줄 덧붙임 · MT 같으면 수량만 | `B09/Prices.py:134-172` · `:213-235` · `:238-268`(합침 :252-259) | 268 |
| GET 길 · 수요 · 입력 · 셈 | `B09/Router.py:84-89` · `:92-117` | 147 |
| 대가 블록 — 조합 자재 줄(MT 코드) | `B09/Labor_Source.py:265-269` | 340 |
| 대가 재료 줄 단가 = 목록표 키 찾기 | `B09/Calc_Labor.py:44-47` | 210 |
| 대비표 셈(최저 · pick › applied › 최저) · 목록표 셈 | `B09/Calc_Base.py:133-178` · `:181-189` ↔ `.ts:119-158` · `:160-171`(거울 `test_144` · `test_166_4`) | 335 · 309 |
| 대비표 · 목록표 화면 | `B09/UI_Tabs_Base.ts:108-208` · `:210-235` | 326 |
| 사용자 값 다듬기 `materials{applied · supply · quote · quote_page · pick}` | `B09/Store.py:144-160` | 208 |
| 집계표 역전개(설계내역서 × 호표) · 「목록표에 없음」 | `B09/Summary.py:47-66` · `:73-92` · `:158-160` | 160 |
| 자재대 · 관급자재대 항목(수량 = 입력 qty) | `B09/Calc_Bill.py:98-118` ↔ `.ts:88-` | 310 · 299 |
| 공용 표(줄 class 만 · 칸 class 없음) | `ui/data_table.ts:40-63` · `:129-178` · `:201-226` | 294 |
| 표 짜기 곁값 · Shell 표 만들기 · 칸 풍선 듣기 | `B09/UI_Tabs.ts:75-92` · `B09/UI_Shell.ts:207-238` | 168 · 332 |
| 공용 우클릭 메뉴 · B09 공유 한 벌 | `ui/context_menu.ts:23 · 71` · `B09/UI_Tabs_Bill_Edit.ts:222-242` | 181 · 242 |
## Z24. 재료비목록표 = 연결된 일위대가의 재료 줄
### 현재 — 줄 넷 갈래
| 갈래 | 출처(파일:줄) | 수량 | 단가 |
|---|---|---|---|
| ① 사본 로직이 **건드린** MT · MO | `Linked.py:187-196`(`snapshot.get` 가로채기 = 고르기 후보 훑기까지) | 빈칸 | M01 출처 → 최저 |
| ② 기계 연료(경유 · 휘발유) | `Prices.py:155-165` | 빈칸 | 유가 |
| ③ B08 총괄자재 줄 `b08:키`(파형강관 · 커플링 · 사본 자재 · 로직 재료) | `Prices.py:213-235` | B08 합계(할증 뒤) | 빈칸(출처 없음) |
| ④ ③ 키가 ① 의 MT 와 같음 | `Prices.py:255-259` | B08 합계를 얹음 | M01 |
- 수량 · 총액 = 166-3 꼴(`Calc_Base.py:181-189`) — B08 줄만 수량 · M01 줄만 단가 → 두 쪽이 엇갈려 총액이 거의 다 빈칸.
- 비고 칸 = `origin`(「M01」 · 「B08」 · 「유가」 · `UI_Tabs_Base.ts:225`) — 어느 대가 · 산근이 쓰는지 안 보임.
### 실측(세 프로젝트)
| 프로젝트 | 줄 | 대가 · 산근 · 중기가 씀 | 아무도 안 씀 | B08 줄(단가 빈칸) | 수량 있는 줄 |
|---|---|---|---|---|---|
| 검증_랩탑메인 226fc805(사본 1) | 6 | 3(잡석 · 경유 · 휘발유) | 0 | 3 — 파형강관 Φ1200 7 · 커플링 0 · **합판 b08:MT000053 7** | 3 |
| 검증_sub1 7e420ed6(사본 14) | 125 | 10 | **110** | 5 — 파형강관 둘 · 커플링 둘 · **부직포 b08:MT023984** | 5 |
| 엄상준_검증 d2245e3b(사본 10) | 141 | 17 | **117** | 7 — 파형강관 둘 · 커플링 둘 · 채움 콘크리트 · **레미콘 b08:MT004253 · 부직포 b08:MT023984** | 9 |
- 안 쓰는 줄 = 같은 이름 지역 갈래가 줄줄이(합판(내수) 서울~제주 19 · 시멘트 전국~제주 · 외송각재 · 어닐링철선) — 쓰인 줄은 그중 하나.
### 원인
1. **후보 줄까지 수요** — `Linked.used_keys`(`Linked.py:187-196`) · `Sangeun.hand_resource_keys`(`:505-514`)가 로직 실행 중 `get` 을 부른 키를 모두 담음 → 재료 고르기(`mf.mp.priced` · `Sangeun.py:401`)가 훑은 후보까지 들어옴(머리 주석 `Linked.py:6-7` 「고르기 후보 전부는 안 씀」과 어긋남 · 추정 = 실측 지역 갈래 꼴).
2. **사본 일위대가 자재 줄 빠짐** — 조합 자재(`Labor_Source.py:265-269` · 사본 `자재` 코드)는 로직이 아니라 수요에 안 듦 → 대가 재료 줄 키 `MT000053` 이 목록표에 없음 → 대가 단가 빈칸(`Calc_Labor.py:45-47`) · 집계표 「목록표에 없음」(`Summary.py:89`) · 같은 자재가 B08 줄 `b08:MT000053`(단가 없음)으로만 보임(조사 ZA 「같은 자재 두 줄」과 같은 뿌리).
3. **수량 원천이 B08 하나** — 산근 · 대가가 쓰는 자재는 수량 빈칸.
### 고칠 자리
- 원인 2 — `Linked.used_keys` 끝에 사본 `doc["자재"]` 코드 더함(`M02_Item_Source.py:161` 꼴 · +3줄) → MT 줄 = M01 단가 · B08 합계는 `Prices.py:255-259` 가 그 줄에 얹음(두 줄 → 한 줄).
- 원인 1 — `Router.payload`(`:101-106`) 두 번 셈: 1차 셈 → 쓰는 키 = 호표 구성 재료 키(`Summary.hopyo_lines` · 빈 키 뺌) ∪ 연료 키 ∪ B08 줄 ∪ 관급 · 별도 표시 줄 → `inputs["materials"]` 거름 → 2차 셈(+12줄 · 거르기 함수 `Summary.py` +8줄). 화면 짝은 거른 입력을 그대로 받음(TS 안 고침). 실측 셈 0.6 초(엄상준) → 두 번 ≈ 1.2 초.
- 비고 → 「쓰는 곳」(대가 n호표 · 산근 n호표 · 중기 · 내역 자재대) — `UI_Tabs_Base.ts:210-235` 에서 `sheets.daega_blocks` · `sg` · `machine_blocks` 줄 `ref.key` · 설계내역서 자재 항목을 키로 모음(표시 잇기 · 셈 아님 · +20줄) · 안내 글(:232) 고침.
- 수량 — ⬜ 24-a 따라.
- 재사용 — 산근 목록 「쓰는 구조물」 꼴(`Sangeun.py:292-295` · `UI_Tabs_Sangeun.ts:42`) · `hopyo_lines` · 줄 누름 → 대비표 이동(`UI_Tabs_Base.ts:233`).
- 시험 — `test_166_3`(B08 줄 · MT 합침 판정 손봄) · `test_144_b09_skeleton.py:107`(compare 키 차례) · 새 시험(후보 줄 거름 · 사본 자재 MT 가 수요에 듦 · 대가 재료 줄 단가 채워짐 · 쓰는 곳 글) · `test_145_2` · `test_145_5` 통과 확인.
- 위험 — ① 두 번 셈 = GET · PUT 느려짐(1초 남짓) ② 대비표 번호(no)가 바뀜 → 내역 자재 항목 비고 「자재 n」(`Calc_Bill.py:113`)도 따라 바뀜(같은 셈이라 어긋남 없음) ③ 산근 재료 줄 키 빈칸(sub1 · 엄상준에 있음 · 고르기 못 한 줄)은 그대로 빈칸 ④ 단가 책(`price_book.json`)엔 후보 줄이 남음(쓰지 않음 · 무해).
- 크기 — py 25줄 · TS 25줄 · 시험 손봄 둘 + 새 시험 하나.
- ⬜ 24-a 수량 열 — ① 지금(B08 합계 · 산근 재료 빈칸) ② 역전개 총수량(재료비수량금액집계표와 같은 값 · 1차 셈 집계를 입력 `material_qty` 로 넣어 2차 · Calc_Base 짝 `material_sheet` 수량 원천만 바꿈 · 거울) ③ 수량 · 총액 열 뺌(엑셀 꼴 · 수량 × 단가 = 재료비수량금액집계표) — 추천 ②(모든 줄이 일위대가 사슬 한 원천 · 166-3 총액 열 유지 · B08 합계는 할증 포함이라 값이 다를 수 있음 → 안내 글).
- ⬜ 24-b B08 줄(파형강관 · 커플링 · 로직 재료) — 남김(내역 자재대 · 관급 원천) 추천.
## Z25. 대비표 — 견적 「면」 뺌 · 다른 출처 「면」 → 「출처」 · 견적서 → 견적 아래
### 지금 열(`UI_Tabs_Base.ts:146-157`)
| 차례 | 머리 1 | 머리 2 | 값 |
|---|---|---|---|
| 1~4 | — | 번호 · 품명 · 규격 · 단위(앞 2 고정) | |
| 5~16 | 관급 · 물가정보 · 물가자료 · 유통물가 · 거래가격 · 유가 | 단가 · **면** | `prices[s]` · `pages[s]`(M01 면수 · 유가 = 기준일 · 관급 면 = 늘 빈칸 `Prices.py:90`) |
| 17~18 | 견적 | 단가(수기) · **면**(수기 `quote_page`) | |
| 19 | — | 최저 | |
| 20~21 | 적용 | 적용 단가(수기) · 출처(누름 = 돌림) | |
| 22 · 23 | — | 관급 · 별도 · **견적서**(「n 파일」 누름 = 모달) | |
- 고칠 자리 — `UI_Tabs_Base.ts:151` 를 갈래로: 견적 밖 = `[money(p:s, "단가", [s]), text(g:s, "출처", 64, [s])]` · 견적 = `[money(p:견적, "단가", ["견적"]), text("files", "견적서", 72, ["견적"])]` · `:156` 견적서 열 지움 · `:161` editable 에서 `g:견적` 뺌 · `:166-170` 면 고침 갈래 지움 · 안내 글(:160) 고침. 머리 두 줄 묶음 = 공용 `group`(`ui/data_table.ts:96-124`) 그대로.
- 재사용 — 공용 열 묶음 · 견적서 모달(`UI_Tabs_Base_Quote.ts`) · `onCell` files 갈래(:185-194) 그대로.
- 시험 — `test_171_1_manual_input_label.py:17`(안내 글 「견적 단가 · 면 = 수기 입력」 판정 → 새 글) + 새 판정(열 키 · 머리 이름 · 견적 아래 files).
- 위험 — 적용 묶음에도 「출처」(applied_from)가 있어 머리 글이 겹침(묶음 머리로 구분됨) · 셈 · 저장 안 고침(`quote_page` 는 저장에 남음 · `test_166_4:26 · 59-63`).
- 크기 — TS 10줄.
- ⬜ 25-a 늘 빈 관급 「출처」 열 — 그대로 둠(지시 = 값 유지) 추천 · 빼면 열 하나 줄음.
## Z26. 적용 단가 = 칸 누름 드롭다운 · 고른 출처 칸 강조 · 기본 최저
- 현재 — 적용 단가 = 수기 칸(`UI_Tabs_Base.ts:161 · 171-178` → `applied` · `pick` 지움) · 출처 칸 누름 = 값 있는 출처 돌림(`:110-114 · 195-200` → `pick`) · 저장 `user_values.json` `materials[키]{applied · pick}`(`Store.py:149-158` · `pick` ∈ SOURCES 만) · 셈 차례 pick › applied › 최저(`Calc_Base.py:153-158` ↔ `.ts:134-139` · 거울 짝) · 최저 = 0 · 빈칸 뺀 최소 · 같으면 앞 칸 · 견적 포함(`Calc_Base.py:133-140` ↔ `.ts:119-`) → **기본 최저는 이미 됨**.
- 공용 표 빈틈 — 칸마다 class 없음(줄 `rowClass` 만 · `ui/data_table.ts:62 · 169-171`) · 칸 누름 콜백에 자리(좌표) 없음(`B09/UI_Shell.ts:219-222`) · 고르는 칸 꼴 없음(글 칸만 `:145-151`).
- 고칠 자리
- `ui/data_table.ts` — 곁값 `cellClass?: (줄, 열 키) => string | null` 하나(옵션 :40-63 · `rowHtml` td class :169-171 · 넘김 :213-221 · +6줄 · 안 주면 그대로).
- `B09/UI_Tabs.ts:75-92` — `TabTable.cellClass` · `choices?: (줄, 열 키) => MapContextMenuItem[]` 두 칸.
- `B09/UI_Shell.ts:207-238` — `cellClass` 넘김 · `choices` 있으면 표 root click 듣기(칸 풍선 듣기 :229-237 꼴 · 칸 `getBoundingClientRect` 아래에 메뉴) · 메뉴 = `B09/UI_Tabs_Bill_Edit.ts:222-232` 공유 한 벌을 내보내 씀(`openMenuAt(x, y, items)` · +6줄).
- `B09/UI_Tabs_Base.ts` buildCompare — `applied` editable 뺌 · `choices`(applied 칸) = 값 > 0 출처마다 「물가정보 12,000 · 최저」 + 견적 + 「최저(자동)」 · 고름 = `pick` 넣고 `applied` 지움 · 「최저(자동)」 = 둘 다 지움 · `cellClass` = `p:{pick ?? 최저 출처}` 칸 `is-picked` · 출처 칸 돌림(:195-200 · `nextPick`) 지움.
- `B09/UI_Page.css` — `.b09-estimation td.is-picked` 바탕 = `color-mix(var(--color-accent) …)`(`ui/data_table.css:9` 토큰 꼴 · 테마 따라감).
- 셈 · 저장 — 안 고침(짝 그대로 · 옛 `applied` 값도 그대로 읽음 · 드롭다운 「최저(자동)」이 지움).
- 재사용 — 공용 우클릭 메뉴(한 번에 하나 · 바깥 누름 닫힘) · 공용 열 묶음 · `setUser` 즉시 다시 셈(네트워크 없음).
- 시험 — `test_144` · `test_166_4`(셈 그대로 통과) + 새 시험(공용 표 `renderDataTableHtml` cellClass 칸 class · 안 주면 글 같음 · buildCompare choices 항목 · 고름 → pick · 자동 → 지움 · 강조 칸 = pick ?? 최저 출처 · applied 칸 editable 아님).
- 위험 — ① 출처 없는 줄(B08 파형강관 · 커플링 · 로직 재료 · 번호판)은 견적 칸으로만 단가를 넣음 → 안내 글에 적음 ② `ui/data_table.ts` 공용(다른 페이지 영향 없음 · 옵션만) ③ 메뉴는 click 으로 열림 — 공용 닫힘은 pointerdown 캡처라 순서 맞음.
- 크기 — TS 45줄 · CSS 4줄 · 시험 하나.
- ⬜ 26-a 수기 적용 단가 — 없앰(견적 = 유일한 수기 출처) 추천 · 남기려면 드롭다운에 「수기 입력…」 항목.
- ⬜ 26-b 「출처」 칸 누름 돌림 — 없앰(드롭다운 하나) 추천.
## Z28. 대비표 수량 열
- 현재 — 대비표 화면에 **수량 열 없음**(`UI_Tabs_Base.ts:146-157` · 모든 갈래 같음). `qty` 는 셈 줄에만 — 대비표 줄 `qty`(`Calc_Base.py:175` ↔ `.ts:155`) → 목록표 수량 · 총액(`Calc_Base.py:183-188` ↔ `.ts:160-171`) · 자재대 항목 수량은 입력에서 따로(`Calc_Bill.py:100` ↔ `.ts:89-90`). 엑셀 내보내기 없음 · 합계 줄 없음(대비표).
- 고칠 자리 — 화면은 할 일 없음. 셈 정리를 원하면 `material_sheet` 가 입력 qty 를 직접 읽게(짝 둘 · 거울 · `test_166_3`) — 보이는 변화 없음.
- 위험 — 없음.
- 크기 — 0(정리 시 py · TS 각 3줄).
- ⬜ 28-a 사용자가 본 「수량」 = 목록표 수량 열(166-3)일 수 있음 — 대비표는 그대로 · 목록표는 Z24-a 로 정함 추천.
## 고칠 파일 목록
| 파일 | 지금 줄 | 고친 뒤(추정) | 700 위험 | 요구 |
|---|---|---|---|---|
| `B09_Estimation/B09_Estimation_Linked.py` | 73 | 76 | 없음 | Z24 |
| `B09_Estimation/B09_Estimation_Router.py` | 147 | 160 | 없음 | Z24 |
| `B09_Estimation/B09_Estimation_Summary.py` | 160 | 168 | 없음 | Z24 |
| `B09_Estimation/B09_Estimation_Calc_Base.py` · `.ts` | 335 · 309 | 339 · 313 | 없음 | Z24-a ②(거울) |
| `B09_Estimation/B09_Estimation_UI_Tabs_Base.ts` | 326 | 365 | 없음 | Z24 · Z25 · Z26 |
| `B09_Estimation/B09_Estimation_UI_Tabs.ts` | 168 | 172 | 없음 | Z26 |
| `B09_Estimation/B09_Estimation_UI_Shell.ts` | 332 | 345 | 없음 | Z26(조사 Y 탭 묶음과 겹침) |
| `B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts` | 242 | 248 | 없음 | Z26 |
| `B09_Estimation/B09_Estimation_UI_Page.css` | 45 | 49 | 없음 | Z26 |
| `ui_template/ui_template_data_table.ts` | 294 | 300 | 없음 | Z26(공용) |
| 시험 — `test_166_3` · `test_144_b09_skeleton` · `test_171_1` 손봄 + 새 시험 둘(Z24 · Z25/26) | — | — | — | |
@@ -3333,6 +3333,8 @@
"inlet.before": "기준측점 전 (m)",
"inlet.after": "기준측점 후 (m)",
"inlet.animal": "동물이동형",
"inlet.animal=true": "유",
"inlet.animal=false": "무",
"outlet.structure": "구조",
"outlet.structure=기슭막이": "흙막이",
"outlet.form": "형태",
@@ -57,6 +57,7 @@ const { taubin } = loadTs(B05("B05_Profile_UI_Corridor_Loft_Grid"));
const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split"));
const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain"));
const PSLG = loadTs(B05("B05_Profile_UI_Corridor_Tin_Pslg"));
const { SEAM_M } = loadTs(B05("B05_Profile_UI_Corridor_Tin"));
const HALF = 3;
const ROAD_Z = 100;
@@ -257,7 +258,7 @@ function gapEdges(tris, out, original) {
return { gaps, maxGapLen };
}
/** 공사 밖(설계 구역 · 구멍 밖 · 둘레에서 1 mm 넘게 떨어진) 원래 정점 중 결과 망에서 사라진 수. */
/** 공사 밖(설계 구역 · 구멍 밖 · 둘레에서 SEAM_M 넘게 떨어진) 원래 정점 중 결과 망에서 사라진 수. */
function lostVertices(tris, before, out) {
const region = new PSLG.LoopIndex([out.design.outer, ...out.design.holes]);
const border = new PSLG.SegGrid(2);
@@ -274,7 +275,7 @@ function lostVertices(tris, before, out) {
for (let v = 0; v < before.length / 3; v += 1) {
const x = before[v * 3];
const z = before[v * 3 + 2];
if (region.contains(x, z) || border.passesNear(x, z, 1e-3)) continue;
if (region.contains(x, z) || border.passesNear(x, z, SEAM_M)) continue;
if (!present.has(key(x, before[v * 3 + 1], z))) lost += 1;
}
return lost;
@@ -450,6 +451,8 @@ function daylightPoints(out, bounds) {
let sectionErr = 0;
for (const side of [1, -1]) {
for (let s = 0.05; s <= 12; s += 0.05) {
// 비탈 끝(s 9) 바깥 이음 띠(SEAM_M + 격자 0.5 m)는 지형 굴곡을 긴 삼각형으로 펴므로 뺀다(176-1).
if (s > 9 && s < 9 + SEAM_M + 0.5) continue;
const z = tin(0, -side * (HALF + s)); // 씬 x = 20 − 20
if (z === null) continue;
sectionErr = Math.max(sectionErr, Math.abs(z + 100 - Math.max(ROAD_Z - s / 1.5, 94)));
@@ -162,4 +162,30 @@ out.notSheet = {
values: inputs().map((n) => n.value),
};
// 180-2 표 배율 칸 — 배치 칸 줄 뒤 · 0.5~2.0 · step 0.1 · [배율 적용]
const scales = [];
const toastStart = toasts.length;
slot.replaceChildren(
buildCrossLayoutControls([4, 4], null, () => {}, {
scale: { value: 1, onApply: (value) => scales.push(value) },
}),
);
const scaleInput = inputs()[2];
out.scale = {
labels: byClass(slot, "ui-field__label").map((n) => n.textContent),
input: {
value: scaleInput.value,
min: scaleInput.attrs.min,
max: scaleInput.attrs.max,
step: scaleInput.attrs.step,
},
buttons: buttons().map((n) => n.textContent),
};
scaleInput.value = "1.5";
buttons()[2].click();
scaleInput.value = "2.5";
buttons()[2].click();
out.scale.applied = scales;
out.scale.toasts = toasts.slice(toastStart).map((t) => t[0]);
process.stdout.write(JSON.stringify(out));
+20 -5
View File
@@ -202,6 +202,13 @@ const view = {
profile.showCard(CARDS.find((card) => card.station_id === this.selected) ?? null);
if (this.selected !== previous) this.listener(this.selected);
},
revealed: null,
revealStation(id) {
// 노면공 목록 고름 = 카드 하나 안 고름 · 리스너 없이 풂 · 스크롤만(177-4 · Section_View `revealStation`)
this.selected = null;
profile.showCard(null);
this.revealed = id;
},
focusStation(id) {
if (id === null) this.selected = null;
else if (this.selected !== id) this.selectStation(id);
@@ -214,11 +221,12 @@ const view = {
};
let wallSelecting = false;
const focusStationAt = (at) => {
const focusStationAt = (at, revealOnly = false) => {
const nearest = CARDS.reduce((best, card) =>
Math.abs(card.chainage_m - at) < Math.abs(best.chainage_m - at) ? card : best,
);
view.focusStation(nearest.station_id);
if (revealOnly) view.revealStation(nearest.station_id);
else view.focusStation(nearest.station_id);
};
const current = { structures: [PAVE] };
@@ -248,6 +256,7 @@ async function build(list) {
form.id = null;
form.pipe = null;
view.selected = null;
view.revealed = null;
view.spanMarkId = null;
profile.markedId = null;
profile.pill = null;
@@ -256,8 +265,8 @@ async function build(list) {
shared: { view: () => "cross" },
detail: () => ({ cross_sections: CARDS }),
stationInterval: () => 20,
focusChainage: (at) => {
if (!wallSelecting) focusStationAt(at);
focusChainage: (at, revealOnly) => {
if (!wallSelecting) focusStationAt(at, revealOnly);
},
markStructure: (id) => view.selectMark(id),
onMarks: (next, types) => {
@@ -308,7 +317,11 @@ const wallPick = (cardAtM, ownerM) => {
await build([PAVE, { structure_id: "sign", type_id: "hazard_sign", chainage_m: 127.86 }]);
listPick("pave");
out.step1 = snap();
cardPick(40); // 고름이 이미 연 범위 첫 카드 재누름(브레인 ORCA ②)
out.reveal1 = view.revealed; // 고르지 않고 스크롤만 한 카드(177-4)
out.pick1 = selection.readSelection(PID);
cardPick(40); // 스크롤만 한 범위 첫 카드 누름
out.step2first = snap();
cardPick(40); // 고른 범위 카드 재누름(167-3 · 브레인 ORCA ②)
out.step2re = snap();
cardPick(140);
out.step2 = snap();
@@ -318,6 +331,7 @@ const wallPick = (cardAtM, ownerM) => {
out.step3 = snap();
pillPick("pave");
out.step4 = snap();
out.reveal4 = view.revealed;
cardPick(80); // 관 자리 카드 = 관(지금처럼)
out.pipeCard = snap();
pillPick("pave");
@@ -339,6 +353,7 @@ const wallPick = (cardAtM, ownerM) => {
await build([PAVE, PAVE2]);
listPick("pave2");
out.step6 = snap();
out.reveal6 = view.revealed;
cardPick(160);
out.step6b = snap();
process.stdout.write(JSON.stringify(out));
+25 -13
View File
@@ -1,5 +1,5 @@
/* 집수정 「동물이동형」 체크 칸(PLAN 173-7) 단위검증 헬퍼 — 공용 명세 폼을 그 자리에서 트랜스파일해 Node 로 돌린다.
* 빈 값 = 체크 · 끄면 "false" · 다시 켜면 "true" · 흙막이 갈래엔 칸 없음. 결과는 표준출력에 JSON 한 벌.
/* 집수정 「동물이동형」 칸(PLAN 173-7 · 177-2 고르기 유 / 무) 단위검증 헬퍼 — 공용 명세 폼을 그 자리에서 트랜스파일해 Node 로 돌린다.
* 빈 값 = 유("true" 적힘) · 무 = "false" · 다시 유 = "true" · 흙막이 갈래엔 칸 없음. 결과는 표준출력에 JSON 한 벌.
* 짝 = `test_173_7_inlet_animal.py` · 화면 그릇 흉내는 `helper_54_10_soil_tiers.cjs` 꼴(+ 듣개).
*/
const fs = require("fs");
@@ -121,32 +121,44 @@ const { createSpecForm } = loadTs(
const { PIPE_SPEC } = loadTs(path.join(ROOT, "A00_Common", "design", "design_structure_pipe.ts"));
const form = createSpecForm(null, {});
// 177-2 — 체크박스 → 고르기(유 / 무) · 저장 글 "true" / "false" 그대로
const box = () =>
form.root
.querySelectorAll("label")
.find((l) => l.dataset.specItem === "inlet.animal")
?.querySelector("input") ?? null;
?.querySelector("select") ?? null;
const animal = () => form.read().inlet_basin_animal ?? null;
const toggle = (on) => {
const input = box();
input.checked = on;
input.fire("change");
const pick = (v) => {
const select = box();
select.value = v;
select.fire("change");
};
const shown = () => {
const select = box();
return select
? {
value: select.value,
options: select.children.map((o) => [o.value, o.textContent]),
saved: animal(),
}
: null;
};
const out = {};
form.write(PIPE_SPEC, { pipe_diameter_mm: 1000, inlet_type: "집수정" });
out.blank = { checked: box()?.checked ?? null, saved: animal() };
toggle(false);
out.off = { checked: box().checked, saved: animal() };
toggle(true);
out.on = { checked: box().checked, saved: animal() };
out.blank = shown();
pick("false");
out.off = shown();
pick("true");
out.on = shown();
form.write(PIPE_SPEC, {
pipe_diameter_mm: 1000,
inlet_type: "집수정",
inlet_basin_animal: "false",
});
out.storedOff = { checked: box().checked, saved: animal() };
out.storedOff = shown();
form.write(PIPE_SPEC, { pipe_diameter_mm: 1000, inlet_type: "기슭막이" });
out.soil = { shown: box() !== null, saved: animal() };
out.checkbox = form.root.querySelectorAll("input").some((i) => i.type === "checkbox");
process.stdout.write(JSON.stringify(out));
+263
View File
@@ -0,0 +1,263 @@
/* PLAN 176-1 단위검증 헬퍼 — B05 3D 성토 비탈 끝 이음 띠(SEAM_M) · 성토 덩이 끝 뜸 줄이기.
* 직선 노선 80 m · 측점 0 · 20 · 40 성토(노면 100 · 지반 94 · 1:1.5) · 60 · 80 절토(지반 104 · 1:1).
* 지반에 노선 방향 사인 물결(진폭 1 m · 파장 10 m · 측점 자리 0) → 성토 비탈 끝이 한도까지 뜸.
* 트랜스파일 틀 · 격자 지형 · 틈 셈은 helper_172_1_loft_wrinkle.cjs 와 같다. 결과는 표준출력 JSON.
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..");
const NM = path.join(ROOT, "config", "node_modules");
const ts = require(path.join(NM, "typescript"));
const THREE = require(path.join(NM, "three"));
const LIBS = {
three: THREE,
delaunator: require(path.join(NM, "delaunator", "index.js")),
"three/examples/jsm/utils/BufferGeometryUtils.js": require(path.join(NM, "three", "examples", "jsm", "utils", "BufferGeometryUtils.js")),
"@kninnug/constrainautor": {
__esModule: true,
default: require(path.join(NM, "@kninnug", "constrainautor", "lib", "Constrainautor.cjs")),
},
};
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 (LIBS[request]) return LIBS[request];
if (request.startsWith("@ui/") || request.startsWith("@config/")) {
return new Proxy({}, { get: () => () => undefined });
}
const target = request.startsWith("@util/")
? path.join(ROOT, "common_util", request.slice(6))
: path.join(path.dirname(full), request);
if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8"));
return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts"));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`);
const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build"));
const { loftTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Loft"));
const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split"));
const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain"));
const PSLG = loadTs(B05("B05_Profile_UI_Corridor_Tin_Pslg"));
const { SEAM_M } = loadTs(B05("B05_Profile_UI_Corridor_Tin"));
const HALF = 3;
const ROAD_Z = 100;
const LENGTH = 80;
const STATIONS = [0, 20, 40, 60, 80];
const AMP = 1;
const WAVE_M = 10;
const r6 = (v) => Math.round(v * 1e6) / 1e6;
/** 측점 단면 — 성토(지반 94 · 1:1.5) 또는 절토(지반 104 · 1:1). */
function station(chainage) {
const fill = chainage <= 40;
const ground = fill ? 94 : 104;
const toe = HALF + Math.abs(ROAD_Z - ground) * (fill ? 1.5 : 1);
const pts = [
[-40, ground],
[-toe, ground],
[-HALF, ROAD_Z],
[-2.5, ROAD_Z],
[2.5, ROAD_Z],
[HALF, ROAD_Z],
[toe, ground],
[40, ground],
];
const samples = [];
for (let o = -40; o <= 40; o += 1) samples.push({ offset_m: o, elevation_m: ground });
return {
station_id: `S${chainage}`,
chainage_m: chainage,
label: "",
kind: "regular",
center_x: chainage,
center_y: 0,
center_z: ground,
frame: { left_xy: [0, 1] },
samples,
design: {
section_mode: fill ? "both_fill" : "both_cut",
road_edges: {
left: { offset_m: HALF, elevation_m: ROAD_Z },
right: { offset_m: -HALF, elevation_m: ROAD_Z },
},
carriageway_edges: {
left: { offset_m: 2.5, elevation_m: ROAD_Z },
right: { offset_m: -2.5, elevation_m: ROAD_Z },
},
design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
ditch: { type: "none" },
ditch_enabled: false,
ditch_side: "left",
},
};
}
const BOUNDS = { x: [-10, 90], y: [-30, 30], z: [85, 115] };
const OX = (BOUNDS.x[0] + BOUNDS.x[1]) / 2;
const OY = (BOUNDS.y[0] + BOUNDS.y[1]) / 2;
const OZ = (BOUNDS.z[0] + BOUNDS.z[1]) / 2;
const X0 = -10;
const X1 = 90;
const Y0 = -30;
const Y1 = 30;
const STEP = 0.5;
function gridTerrain(heightAt) {
const nx = Math.round((X1 - X0) / STEP) + 1;
const ny = Math.round((Y1 - Y0) / STEP) + 1;
const positions = new Float32Array(nx * ny * 3);
for (let j = 0; j < ny; j += 1) {
for (let i = 0; i < nx; i += 1) {
const v = j * nx + i;
const x = X0 + i * STEP;
const y = Y0 + j * STEP;
positions[v * 3] = x - OX;
positions[v * 3 + 1] = heightAt(x, y) - OZ;
positions[v * 3 + 2] = -(y - OY);
}
}
const index = [];
for (let j = 0; j < ny - 1; j += 1) {
for (let i = 0; i < nx - 1; i += 1) {
const a = j * nx + i;
if ((i + j) % 2) index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx);
else index.push(a, a + nx + 1, a + nx, a, a + 1, a + nx + 1);
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(new Uint8Array(nx * ny * 4).fill(200), 4, true));
geometry.setIndex(index);
return new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true }));
}
const key = (x, y, z) => `${Math.round(x * 1e4)},${Math.round(y * 1e4)},${Math.round(z * 1e4)}`;
const pkey = (x, z) => `${Math.round(x * 1e4)},${Math.round(z * 1e4)}`;
const fkey = (x, z) => pkey(Math.fround(x), Math.fround(z));
/** 한 번만 쓰인 변 중 지형 바깥 테두리 · 노면 끝 · 구멍 둘레가 아닌 것(틈). */
function gapCount(root, out) {
const pslg = out.design.pslg;
const tagAt = new Map();
for (let i = 0; i < pslg.count; i += 1) tagAt.set(fkey(pslg.x[i], pslg.z[i]), pslg.tag[i]);
const onFrame = (p) => {
const x = p[0] + OX;
const y = -p[2] + OY;
return Math.abs(x - X0) < 1e-3 || Math.abs(x - X1) < 1e-3 || Math.abs(y - Y0) < 1e-3 || Math.abs(y - Y1) < 1e-3;
};
const uses = new Map();
root.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
const p = child.geometry.getAttribute("position");
const idx = child.geometry.getIndex();
const count = idx ? idx.count : p.count;
for (let t = 0; t < count; t += 3) {
const tri = [0, 1, 2].map((k) => {
const v = idx ? idx.getX(t + k) : t + k;
return [p.getX(v), p.getY(v), p.getZ(v)];
});
const [a, b, c] = tri;
if (Math.abs((b[0] - a[0]) * (c[2] - a[2]) - (c[0] - a[0]) * (b[2] - a[2])) < 1e-9) continue;
for (let k = 0; k < 3; k += 1) {
const ka = key(...tri[k]);
const kb = key(...tri[(k + 1) % 3]);
const e = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
const entry = uses.get(e) ?? { n: 0, a: tri[k], b: tri[(k + 1) % 3] };
entry.n += 1;
uses.set(e, entry);
}
}
});
let gaps = 0;
for (const { n, a, b } of uses.values()) {
if (n !== 1) continue;
const ta = tagAt.get(pkey(a[0], a[2]));
const tb = tagAt.get(pkey(b[0], b[2]));
if (ta === PSLG.TAG_ANCHOR && tb === PSLG.TAG_ANCHOR) continue;
if (ta === PSLG.TAG_HOLE && tb === PSLG.TAG_HOLE) continue;
if (onFrame(a) && onFrame(b)) continue;
gaps += 1;
}
return gaps;
}
/** 성토 비탈 끝 둘레 점 · 이음 띠 경사 · 덩이 끝 뜸 · 틈. */
function run() {
const base = (x) => (x <= 40 ? 94 : x >= 60 ? 104 : 94 + (x - 40) / 2);
const terrain = gridTerrain((x) => base(x) + AMP * Math.sin((2 * Math.PI * x) / WAVE_M));
const sections = STATIONS.map(station);
const build = buildCorridor(sections, [
{ x: 0, y: 0 },
{ x: LENGTH, y: 0 },
]);
build.sections = sections;
build.structures = [];
const split = new TerrainBandSplit(terrain, { minX: -1e4, maxX: 1e4, minZ: -1e4, maxZ: 1e4 });
const heightIndex = new TerrainHeightIndex(terrain);
const out = loftTerrain(split, build, BOUNDS, heightIndex);
const pslg = out.design.pslg;
const groundAt = (x, y) => heightIndex.heightAt(x - OX, -(y - OY)) + OZ;
// 성토 비탈 끝(설계 둘레 · 성토 꼬리표) — 측마다 높이 − 지형 · 덩이 끝(가장 큰 x) 점.
const outer = new Set(out.design.outer.map(([x, z]) => pkey(x, z)));
const rim = new Set();
const ends = { 1: { x: -Infinity, off: 0 }, [-1]: { x: -Infinity, off: 0 } };
let midOff = 0;
for (let i = 0; i < pslg.count; i += 1) {
if (pslg.tag[i] !== PSLG.TAG_FILL || !outer.has(pkey(pslg.x[i], pslg.z[i]))) continue;
rim.add(pkey(pslg.x[i], pslg.z[i]));
const x = pslg.x[i] + OX;
const y = -pslg.z[i] + OY;
const off = Math.abs(pslg.y[i] + OZ - groundAt(x, y));
if (x > 5 && x < 35) midOff = Math.max(midOff, off);
const end = ends[y > 0 ? 1 : -1];
if (x > end.x) Object.assign(end, { x, off });
}
// 이음 띠 — 성토 비탈 끝 점을 쓰는 원지반 색 삼각형의 경사(°).
const mesh = out.root.getObjectByName("terrain-tin-mesh");
const p = mesh.geometry.getAttribute("position");
const keep = mesh.userData.keepColor;
const slopes = [];
for (let t = 0; t < p.count / 3; t += 1) {
if (keep[t * 3]) continue;
const v = [0, 1, 2].map((k) => [p.getX(t * 3 + k), p.getY(t * 3 + k), p.getZ(t * 3 + k)]);
if (!v.some(([x, , z]) => rim.has(pkey(x, z)))) continue;
// 지반 바탕이 평탄한 성토 구간만(지형 자체 경사 ≤ 32°) — 40~60 m 오르막은 지형이 45° 넘음.
const cx = (v[0][0] + v[1][0] + v[2][0]) / 3 + OX;
if (cx < 2 || cx > 38) continue;
const u = [0, 1, 2].map((k) => v[1][k] - v[0][k]);
const w = [0, 1, 2].map((k) => v[2][k] - v[0][k]);
const n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]];
const len = Math.hypot(...n);
if (len < 1e-9) continue;
slopes.push((Math.acos(Math.abs(n[1]) / len) * 180) / Math.PI);
}
slopes.sort((a, b) => a - b);
return {
seam: SEAM_M,
stats: out.stats,
gaps: gapCount(out.root, out),
rimPoints: rim.size,
seamTris: slopes.length,
seamP99: r6(slopes[Math.floor(0.99 * (slopes.length - 1))] ?? 0),
midOff: r6(midOff),
endX: [r6(ends[1].x), r6(ends[-1].x)],
endOff: r6(Math.max(ends[1].off, ends[-1].off)),
};
}
process.stdout.write(JSON.stringify(run()));
@@ -0,0 +1,64 @@
/* 177-3 횡단 카드 ↔ 종단 측점선 헬퍼 — 진짜 design_profile.ts 의 `setSelectedStation` · `setSelectedStructure`
* 본문을 떼어(TS 는 그 자리 트랜스파일) 측점 · 구조물 짝 흉내에 붙이고, B06 카드 고름 차례
* (showCard → setSelectedStation · 리스너 → selectMark → setSelectedStructure(mark, true))와 B05 기본 길을 돌려
* 남은 측점선 id 를 표준출력 JSON 한 벌로.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const src = fs.readFileSync(path.join(ROOT, "A00_Common", "design", "design_profile.ts"), "utf8");
const method = (name) => {
const pattern = String.raw`\r?\n ${name}\([^\r\n]*\) \{\r?\n[\s\S]*?\r?\n \},`;
const match = src.match(new RegExp(pattern));
if (!match) throw new Error(`본문 없음: ${name}`);
return match[0];
};
const body = `
let selectedStationId = null;
let selectedStructureId = null;
// 관(pipe-80.17)만 측점선(irr-80)이 있음 — 벽 · 노면공은 선 없음(PST stationIdAtStructure)
const structureIdAtStation = (stationId) => (stationId === "irr-80" ? "pipe-80.17" : null);
const stationIdAtStructure = (structureId) => (structureId === "pipe-80.17" ? "irr-80" : null);
const draw = () => {};
module.exports = {
line: () => selectedStationId,
${method("setSelectedStation")}
${method("setSelectedStructure")}
};`;
const js = ts.transpileModule(body, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", js)(box.exports, box);
const dp = box.exports;
/** B06 카드 고름 — showCard(카드 · 관 측점이면 관 선) 뒤 리스너가 selectMark(mark) = keepStation true. */
const b06Card = (lineId, mark) => {
dp.setSelectedStation(lineId);
dp.setSelectedStructure(mark, true);
return dp.line();
};
/** B05 목록 고름 — 기본값(keepStation 없음). */
const b05Pick = (lineId, structureId) => {
dp.setSelectedStation(lineId);
dp.setSelectedStructure(structureId);
return dp.line();
};
const out = {
plainCard: b06Card("s40", null), // 일반 카드 — 덮는 것 없음
wallCard: b06Card("s100", "wall"), // 벽(선 없는 구조물) 카드
paveCard: b06Card("s140", "pave"), // 노면공 범위 안 카드
pipeCard: b06Card("irr-80", "pipe-80.17"), // 관 카드 = 관 선
pipeFromPlain: b06Card("s60", "pipe-80.17"), // 관 알약 = 관 선으로 옮김
// 반대 길 — 종단 측점선 누름 → 카드 고름 → 같은 리스너(selectMark null)
profileClick: b06Card("s200", null),
// 카드 풂 = showCard(null) → 선 끔
release: (dp.setSelectedStation(null), dp.line()),
b05Wall: b05Pick("s100", "wall"), // B05 = 지금 그대로(선 없는 구조물 → 선 끔)
b05Pipe: b05Pick("s40", "pipe-80.17"),
b05Keep: b05Pick("irr-80", "pipe-80.17"),
};
process.stdout.write(JSON.stringify(out));
@@ -58,6 +58,7 @@ class E {
get className() { return [...this.cls].join(" "); }
set className(v) { this.cls = new Set(String(v).split(/\s+/).filter(Boolean)); }
append(...xs) { for (const x of xs) { x.parent = this; this.children.push(x); } }
replaceChildren(...xs) { this.children = []; this.append(...xs); }
prepend(...xs) { for (const x of xs.reverse()) { x.parent = this; this.children.unshift(x); } }
addEventListener(t, f) { (this.on[t] ??= []).push(f); }
setAttribute(k, v) { this.attrs[k] = String(v); }
@@ -32,8 +32,8 @@ def test_범위_강조_클래스는_고름_목록갱신_카드재생성_세_길
r"spanMarks = structures;\s+spanTypes = types;\s+syncSpanHighlights\(\);", VIEW
)
assert VIEW.count("applySpan(card, section);") == 2
# 152-5 노면공(G군)은 테두리 대신 포장층
assert 'card.classList.toggle("b06-cross-card--span", span && !pave);' in VIEW
# 152-5 노면공(G군) 포장층 · 177-4 범위 카드 테두리도 함께
assert 'card.classList.toggle("b06-cross-card--span", span);' in VIEW
def test_측점_고름은_카드_하나_목록_고름은_여럿():
+4 -5
View File
@@ -106,14 +106,13 @@ def test_카드_배선_하나만() -> None:
assert ".b06-chart__design-hit.is-active {" in css
def test_치수_두_줄_좁은_카드는_표_아래로() -> None:
"""151-1 — 줄바꿈 글자를 살리고 오른쪽 맞춤 · 좁은 카드(3열 등)는 표 높이 아래로 내려 표를 안 덮음."""
def test_치수_두_줄_늘_단추_왼쪽() -> None:
"""151-1 — 줄바꿈 글자를 살리고 오른쪽 맞춤 · 179-1 — 좁은 카드도 표 아래로 안 내림(늘 [+ − ⤢] 왼쪽)."""
css = _src("B06_Section/B06_Section_UI_Style_Cross_Controls.css")
dims = css.split(".b06-cross-card__line-dims {", 1)[1].split("}", 1)[0]
assert "white-space: pre-line;" in dims and "text-align: right;" in dims
assert "container-type: inline-size;" in css
narrow = css.split("@container (max-width: 620px) {", 1)[1]
assert "top: calc(var(--spacing-8) + var(--b06-areas-height, 3.6rem));" in narrow
assert "position:" not in dims
assert "@container" not in css
def test_폴리곤_자르기는_공용_한_벌() -> None:
+5 -1
View File
@@ -119,7 +119,11 @@ def _project(tmp_path: Path, count: int) -> tuple[Path, Path]:
def test_layout_store_and_release_changed_confirmed(tmp_path: Path) -> None:
root, longitudinal_path = _project(tmp_path, 20)
assert read_cross_layout(root) == {"default": [4, 4], "sheets": {}} # 파일 없으면 기본
assert read_cross_layout(root) == {
"default": [4, 4],
"sheets": {},
"table_scale": 1.0,
} # 파일 없으면 기본
ids = [s["id"] for s in _cross_sheet_plan(root, longitudinal_path)]
assert ids == ["cross_s00000m", "cross_s00320m"] # 16 + 4
manifest = _read_manifest(root)
@@ -61,3 +61,12 @@ def test_장_아니면_이_장_꺼짐(out):
assert not_sheet["disabled"] == [True, False]
assert not_sheet["defaults"] == [[4, 4]]
assert not_sheet["values"] == ["4", "4"]
def test_표_배율_칸(out):
scale = out["scale"]
assert scale["labels"] == ["배치 열", "행", "표 배율"]
assert scale["input"] == {"value": "1", "min": "0.5", "max": "2", "step": "0.1"}
assert scale["buttons"] == ["이 장", "모든 장", "배율 적용"]
assert scale["applied"] == [1.5]
assert scale["toasts"] == ["error"] # 2.5 = 범위 밖
@@ -1,7 +1,7 @@
"""157-2 B09 단가산출근거 탭 보기 둘 · 157-3 산근 규격 칸 「이름 규격」.
- 157-2 보기 B(호표별 카드) = 보기 A(표) 줄 그대로 호표마다 묶음 — 같은 값 · 같은 차례(171-3 공용 묶음 카드로 갈음)
- 단추 = 보기 바꿈(글 「호표별 보기」 ↔ 「표로 보기」) · 기억 못 읽음 · 모르는 값 = 카드(171-3 컨테이너 기본)
- 단추 = 보기 바꿈(글 「박스보기」 ↔ 「표로 보기」 · 181-2) · 기억 못 읽음 · 모르는 값 = 카드(171-3 컨테이너 기본)
- 157-3 규격 = 기계 · 직종 원문번호 → 「이름 규격」(M02 85-1 도우미) · 번호는 풍선(tip)
"""
@@ -66,7 +66,7 @@ def test_157_2_단추_바꿈():
"\nconsole.log(JSON.stringify([viewLabel('table'), viewLabel('cards'),"
" flipView('table'), flipView(flipView('table'))]));"
)
assert out == ["호표별 보기", "표로 보기", "cards", "table"]
assert out == ["박스보기", "표로 보기", "cards", "table"]
def test_157_2_기억_실패_때_카드():
+8 -6
View File
@@ -2,7 +2,7 @@
"""165 B06 노면공 고름 일관성 — 조사 K 재현 차례 6단계.
못박는 것(단계마다 폼 · 알약 · 포장층 · 고른 카드 · 세션 sid 가 같은 것을 가리킴)
1. 목록 노면공 = 범위 안 카드 · 포장층 · 알약 · 폼 · sid 모두 노면공(기준점이 범위 경계여도 범위 밖 카드로 안 감).
1. 목록 노면공 = 포장층 · 알약 · 폼 · sid 모두 노면공 · 카드는 안 고름(스크롤만 · 177-4 · 기준점이 범위 경계여도 범위 밖 카드로 안 감).
2. 범위 안 빈 카드(관 · 덮는 구조물 없음) = 노면공 그대로 · 카드만 옮김(165-1).
3. 고른 카드의 벽 = 관 폼 + 관 알약(알약이 꺼진 채 폼만 남지 않음 · 165-2) · 포장층 풂.
4. 알약 다시 = 1 과 같음. 관 자리 카드 = 관 · 범위 밖 카드 = 모두 풂(지금처럼).
@@ -55,10 +55,12 @@ PIPE = {"form": "pipe-80.17", "pill": "pipe-80.17", "paveLayer": None, "paveCard
def test_1_목록_노면공(out):
assert out["step1"] == _pave(40) # 기준점 30 = 범위 경계 → 범위 안 첫 카드(20 아님)
assert out["step1"] == _pave(None) # 카드 하나 안 고름(177-4)
assert out["reveal1"] == "s40" # 기준점 30 = 범위 경계 → 범위 안 첫 카드(20 아님)
def test_2_범위_안_빈_카드는_노면공_그대로(out):
assert out["step2first"] == _pave(40)
assert out["step2re"] == _pave(40) # 이미 고른 카드 재누름도 그대로(167-3)
assert out["step2"] == _pave(140)
assert out["step2b"] == _pave(100)
@@ -69,7 +71,7 @@ def test_3_벽은_관_폼과_관_알약_함께(out):
def test_4_알약_다시_관_카드_범위_밖(out):
assert out["step4"] == _pave(40)
assert out["step4"] == _pave(None)
assert out["pipeCard"] == {**PIPE, "card": 80, "sid": None}
assert out["outside"] == {
"form": None,
@@ -83,17 +85,17 @@ def test_4_알약_다시_관_카드_범위_밖(out):
def test_5_B05_복귀(out):
assert out["step5"] == _pave(40)
assert out["step5"] == _pave(None)
def test_6_겹친_노면공(out):
assert out["step6"] == _pave(100, "pave2", 6)
assert out["step6"] == _pave(None, "pave2", 6)
assert out["step6b"] == _pave(160, "pave2", 6)
def test_배선():
panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts")
assert "deps.focusChainage(focusOf(structure));" in panel
assert "deps.focusChainage(focusOf(structure), pave);" in panel
assert "return { chainageM, structureId: openId };" in panel
# 관 알약은 폼 뒤(폼이 연 구조물을 닫으며 알약을 끔)
at = panel.index("section.selectPipeByChainage(hit.pipeChainageM);")
+25 -19
View File
@@ -2,7 +2,7 @@
- 머리 = 호표 · 명칭 · 규격 · 단위 · 합계 / 몸 = 이름 · 규격 · 수량 · 단위 · 합계 · 비고 / 합계 줄 = 노무 · 재료 · 경비 · 계
- 줄 금액 = 합계만 · 비목 셋 = 풍선 · 숫자 오른쪽 · 글자 14px 한 벌 · 줄 간격 좁게(공용 카드 16px 끼움 없음)
- 가로 열 수 1~4(기본 2 · 고르면 알림) · 묶음 12 넘으면 요약 보기(머리 · 합계만 · 머리 누름 = 펼침 · 모두 펼침 · 접기)
- 가로 열 수 1~4(기본 2 · 고르면 알림) · 접기 없음(181-2) · 이동해 온 박스 = `is-pick`(붉은 외곽선) · 머리 누름 = 이동
- 157-2 산근 카드 파일 = 이 양식으로 갈음(지움)
"""
@@ -43,11 +43,9 @@ def test_도우미_열수_요약_풍선():
out = _run(
"\nconsole.log(JSON.stringify({"
" cols: [0, 1, 2, 3, 4, 5, '3', null, undefined].map(clampColumns),"
" summary: [12, 13].map(isSummary),"
" tip: [partsTip([1000, 0, null]), partsTip([null, null, null]), partsTip(null)] }));"
)
assert out["cols"] == [2, 1, 2, 3, 4, 2, 2, 2, 2]
assert out["summary"] == [False, True]
assert out["tip"] == ["노무 1,000 · 재료 0 · 경비 -", None, None]
@@ -91,7 +89,7 @@ def test_카드_머리_몸_합계_줄():
assert out["foot"] == ["노무 100", "재료 20", "경비 3", "계 123", "빈 줄 1"]
assert out["link"] == [True, False]
assert out["cols"] == "2" # 기본 2
assert out["summary"] == ["1", "2", "3", "4"] # 12 이하 = 요약 단추 없음
assert out["summary"] == ["1", "2", "3", "4"] # 가로 열 수 단추뿐(요약 · 접기 단추 없음)
def test_가로_열수_고름_알림_누름_이동():
@@ -111,29 +109,35 @@ def test_가로_열수_고름_알림_누름_이동():
assert out["picks"] == [21, 20] # 줄 · 머리 누름 = 표 줄 번호 그대로(갈 곳은 부르는 쪽 link)
def test_묶음_12_넘으면_요약_보기():
def test_묶음_많아도_접힘_없음_고른_박스_붉은_외곽선():
out = _run(
"\nconst root = make(13, { focus: 52 });"
"\nconst cards = root.all('.ui-group-card');"
"\nconst closed = () => cards.map((c) => c.classList.contains('is-closed'));"
"\nconst first = closed();"
"\nconst focused = root.querySelector('[data-at=\"52\"]');"
"\nconst first = cards.map((c) => c.classList.contains('is-pick'));"
"\ncards[0].querySelector('.ui-group-card__head').click();"
"\nconst opened = closed()[0];"
"\nconst btn = (label) => root.all('.ui-btn').find((b) => b.textContent === label);"
"\nbtn('모두 펼침').click(); const all = closed();"
"\nbtn('모두 접기').click(); const none = closed();"
"\nconsole.log(JSON.stringify({ first, opened, all, none, picks,"
"\nconsole.log(JSON.stringify({ first, picks,"
" closed: cards.map((c) => c.classList.contains('is-closed')),"
" buttons: root.all('.ui-btn').map((b) => b.textContent),"
" foot: cards.every((c) => c.querySelector('.ui-group-card__foot')),"
" focus: [focused.classList.contains('is-focus'), !!focused.scrolled] }));"
" focus: [focused.classList.contains('is-focus'), cards[4].scrolled === true] }));"
)
# 이동해 온 줄(5번째 묶음 줄) = 그 묶음만 펼침 · 강조 · 스크롤
assert out["first"] == [True] * 4 + [False] + [True] * 8
# 이동해 온 줄(5번째 묶음 줄) = 그 박스만 붉은 외곽선 · 그 줄 강조 · 박스 스크롤
assert out["first"] == [False] * 4 + [True] + [False] * 8
assert out["focus"] == [True, True]
assert out["opened"] is False # 머리 누름 = 펼침
assert out["picks"] == [] # 요약 보기 머리 누름은 이동 아님
assert out["all"] == [False] * 13 and out["none"] == [True] * 13
assert out["foot"] is True # 접어도 합계 줄은 보임(몸만 숨김)
assert out["closed"] == [False] * 13 # 13 묶음도 접힘 없음
assert out["picks"] == [10] # 머리 누름 = 이동(표 줄 번호 그대로)
assert out["buttons"] == ["1", "2", "3", "4"] # 모두 펼침 · 접기 단추 없음
assert out["foot"] is True
def test_고른_박스_없으면_외곽선_없음():
out = _run(
"\nconst root = make(3);"
"\nconsole.log(JSON.stringify(root.all('.ui-group-card').map("
"(c) => c.classList.contains('is-pick'))));"
)
assert out == [False, False, False]
def test_양식_글자_간격_숫자():
@@ -146,6 +150,8 @@ def test_양식_글자_간격_숫자():
"font-size: inherit;",
]
assert "padding: 1px var(--spacing-4);" in css # 줄 간격 좁게
assert "is-closed" not in css and "is-closed" not in (ROOT / COMMON).read_text("utf-8")
assert "border-color: var(--color-danger);" in css # 고른 박스 = B06 고름 꼴(181-2)
assert "spacing-16" not in css and "card-padding" not in css # 공용 카드 16px 끼움 없음
assert "grid-template-columns: repeat(var(--group-cols, 2), minmax(0, 1fr));" in css
assert "text-align: right;" in css
@@ -151,9 +151,10 @@ def test_화면_보기_바꿈_누름_이동():
"\nview.querySelector('[data-at=\"2\"]').click();"
"\nview.querySelector('[data-at=\"0\"]').click();"
"\nconst lit = view.querySelector('[data-at=\"3\"]').classList.contains('is-focus');"
"\nconst box = view.querySelector('.ui-group-card').classList.contains('is-pick');"
"\nbar.children[0].click();"
"\nconst [bar2, view2] = renderGroupPanel(o);"
"\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, redraws,"
"\nconsole.log(JSON.stringify({ label, table: view.dataset.b09Table, gone, lit, box, redraws,"
" label2: bar2.children[0].textContent, tag2: view2.tagName, focused,"
" kept: Object.fromEntries(kept) }));",
RAW,
@@ -161,8 +162,9 @@ def test_화면_보기_바꿈_누름_이동():
assert out["label"] == "표로 보기" and out["table"] == "dg" # 기본 = 카드
assert out["gone"] == [{"tab": "labor", "key": "L00005"}, None] # 줄 누름 = 표와 같은 이동
assert out["lit"] is True # 이동해 온 줄(키 DG1|1 = 표 3번째 줄) 강조
assert out["box"] is True # 그 호표 박스 = 붉은 외곽선(181-2)
assert out["redraws"] == 1
assert out["label2"] == "호표별 보기" and out["tag2"] == "TABLE" and out["focused"] == [3]
assert out["label2"] == "박스보기" and out["tag2"] == "TABLE" and out["focused"] == [3]
assert json.loads(out["kept"]["b09-group-view"]) == {"dg": "table"}
+11 -7
View File
@@ -1,4 +1,4 @@
"""173-7 집수정 「동물이동형」 체크 칸 — 공용 체크박스 · 저장 키 inlet_basin_animal · 빈 값 = 참.
"""173-7 · 177-2 집수정 「동물이동형」 칸 — 고르기 유 / 무 · 저장 키 inlet_basin_animal("true"/"false") · 빈 값 = 유.
공용 명세 폼(`design_structure_form.ts`)을 그 자리에서 돌린 값(`helper_173_7_inlet_animal.cjs`)
+ 배관 폼 키 목록(`B05_Profile_UI_Drainage_Facility_Merge.ts`).
@@ -28,14 +28,18 @@ def out() -> dict:
return json.loads(done.stdout)
def test_빈_값은_체크_저장_안_함(out: dict) -> None:
assert out["blank"] == {"checked": True, "saved": None}
YU_MU = [["true", "유"], ["false", "무"]]
def test_끄면_false_다시_켜면_true(out: dict) -> None:
assert out["off"] == {"checked": False, "saved": "false"}
assert out["on"] == {"checked": True, "saved": "true"}
assert out["storedOff"] == {"checked": False, "saved": "false"}
def test_빈_값은_유_실제_값으로_적힘(out: dict) -> None:
# 177-2 — 필수 상자 빈 고르기 칸 = 기본값을 실제 값으로(54-43) · 옛 「저장 안 함」 뒤집음
assert out["blank"] == {"value": "true", "options": YU_MU, "saved": "true"}
def test_무면_false_다시_유면_true(out: dict) -> None:
assert out["off"] == {"value": "false", "options": YU_MU, "saved": "false"}
assert out["on"] == {"value": "true", "options": YU_MU, "saved": "true"}
assert out["storedOff"] == {"value": "false", "options": YU_MU, "saved": "false"}
def test_흙막이엔_칸_없음(out: dict) -> None:
+149
View File
@@ -0,0 +1,149 @@
"""M02 일위대가 사전조합 자재목록(PLAN 175) — 구조물 일위대가와 같은 자재목록.
175-1 화면 · 저장 — 서고 모드 자재목록 · 로직 자재 세 칸 막힘 풂 · 저장 `_combo_lines` 가
고름 · 자재표시 남김 · 문서 `자재`(일위대가 자재 검사 · 로직 자재와 같은 품목 400).
175-2 셈 — 담은 자재(손 · 빈칸 = 안 셈)가 사전조합 1 단위 값(`Expand._combo` · 안 줄) · B08 참조 줄
재료(`_ref_mats`) · B09 겹 대가 자재 줄 · 프로젝트 사본 단가 묶음(`frozen_materials`)에 들어감.
"""
import json
import os
from decimal import Decimal
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 임시 층 fixture
HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(HERE, "..", "..", "M02_MasterTemplete")
NET = "GC000337" # 낙석방지망 — 재료 줄 3 · 4 · 5(철망 · 결속선 · 에폭시)
NET_ITEM = "MT006697"
WIRE = "MT000194"
MAT = "MT000008" # 자재품목 · 물가정보 83000
STONE = "GF000219" # 원/㎡
SYS = {"층": "system", "주인": None}
def _save(folder, doc):
with store.at(folder):
key = refs.create("precombo", {"이름": "자재 조합", **doc})["이름"]
return key, json.loads((folder / "precombo" / f"{key}.json").read_bytes())
def test_화면_서고_모드도_자재목록_세_칸_자재_저장(world): # noqa: F811
detail = open(os.path.join(SRC, "M02_MasterTemplete_Labor_Detail.ts"), encoding="utf-8").read()
assert 'section(tc("Labor_Mats"), opt.card ? null : addMat, matHost),' in detail
assert 'lib ? [] : [section(tc("Labor_Mats")' not in detail
assert "mark: markLogicMat," in detail and "lib ? undefined" not in detail
assert "delete out.자재" not in detail and "delete out.열" in detail
def test_저장_왕복_고름_자재표시_자재_남음(world): # noqa: F811
line = {"로직": NET, "고름": {"4": NET_ITEM}, "자재표시": {"3": {"자재구분": "관급"}}}
mats = [
{"코드": MAT, "수량": {"손": 2}, "운반비": "미포함", "이름": "틀린 이름"},
{"코드": WIRE, "수량": None},
]
_, got = _save(world["system"], {"담은로직": [line], "자재": mats})
one = got["담은로직"][0]
assert one["고름"] == {"4": NET_ITEM} and one["자재표시"] == {"3": {"자재구분": "관급"}}
assert [(m["코드"], m["수량"]) for m in got["자재"]] == [(MAT, {"손": 2}), (WIRE, None)]
assert got["자재"][0]["운반비"] == "미포함" and got["자재"][0]["이름"] != "틀린 이름"
# 자재 없음 = 빈 목록(옛 문서 꼴 그대로 읽힘)
_, empty = _save(world["system"], {"담은로직": [{"로직": NET}]})
assert empty["자재"] == []
def test_로직_자재와_같은_품목_400_없는_자재_400(world): # noqa: F811
with pytest.raises(store.StoreError) as e:
_save(world["system"], {"담은로직": [{"로직": NET}], "자재": [{"코드": WIRE}]})
assert e.value.status == 400 and WIRE in str(e.value.detail)
with pytest.raises(store.StoreError) as e:
_save(world["system"], {"담은로직": [], "자재": [{"코드": "MT999999"}]})
assert e.value.status == 400
# ── 175-2 셈 ──────────────────────────────────────────────────────────
def _price(code):
whole = m01.loaded()[1]
return Decimal(str(labor.material_price(whole, code, None)["기준단가"]))
def _world_combo(world): # noqa: F811
"""STONE 1 + MAT 손 2 + WIRE 빈칸(안 셈) · NET 고름 4 = NET_ITEM — (키, 손셈 1 단위 값)."""
lines = [
{"로직": STONE, "수량": {"손": 1}},
{"로직": NET, "수량": {"손": 0}, "고름": {"4": NET_ITEM}},
]
mats = [{"코드": MAT, "수량": {"손": 2}}, {"코드": WIRE, "수량": None}]
with store.at(world["system"]):
key = refs.create("precombo", {"이름": "자재 조합", "담은로직": lines, "자재": mats})[
"이름"
]
stone = Decimal(str(labor.run_line(m01.loaded()[1], STONE, {})["기준"]["계"]))
return key, stone + _price(MAT) * 2
def test_사전조합_값에_자재_금액_빈칸은_안_셈_안_줄에_자재(world): # noqa: F811
key, want = _world_combo(world)
with store.at(world["system"]):
doc = labor.clean(
{"열": "rv", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]}
)
one = labor.calc(doc, {})["줄"][0]
assert one["기준"]["계"] == pytest.approx(float(want))
assert one["반영"]["계"] == pytest.approx(float(want * 3))
mats = [x for x in one["안"] if x.get("자재")]
assert [(x["자재"], x["수량"]) for x in mats] == [(MAT, 2)] # WIRE 빈칸 = 안 셈
assert mats[0]["기준"]["재료비"] == pytest.approx(float(_price(MAT)))
# 목록 기준금액(51-4)도 같은 펼침
rows = [{"종류": "precombo", "이름": key}]
refs.summaries(world["system"], rows)
assert rows[0]["기준금액"] == pytest.approx(float(want))
def test_B08_참조_줄_재료에_사전조합_자재_손_곱(world): # noqa: F811
from B08_Quantity.B08_Quantity_Router_Material import _ref_mats
key, _ = _world_combo(world)
with store.at(world["system"]):
outer = refs.create(
"precombo",
{"이름": "겹", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]},
)["이름"]
got = _ref_mats(m01.loaded()[1], refs.read_ref, {**SYS, "키": outer})
mats = [(m["품목"]["키"], q) for m, q in got if m.get("자재")]
assert mats == [(MAT, pytest.approx(6.0))] # 2 × 겹 손 3 · WIRE 빈칸 없음
def test_B09_겹_대가에_자재_줄(world): # noqa: F811
from B09_Estimation.B09_Estimation_Labor_Source import Builder
key, _ = _world_combo(world)
ref = {**SYS, "키": key}
build = Builder()
bid = build.precombo(m01.loaded()[1], ref, refs.read_ref(ref), {})
mats = [x for x in build.blocks[bid]["lines"] if x["kind"] == "material"]
assert [(x["key"], x["qty"]) for x in mats] == [(MAT, 2.0), (WIRE, None)]
assert mats[0]["name"] and mats[0]["unit"]
def test_사본_단가_묶음에_사전조합_자재_고름_사본_값_같음(world): # noqa: F811
from M02_MasterTemplete import M02_Item_Prices as prices
from M02_MasterTemplete import M02_Item_Source as source
key, want = _world_combo(world)
doc = {"열": "rv", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 1}}], "자재": []}
with store.at(world["system"]):
rows = source.labor_doc_rows(labor.clean(doc))
mats = refs.frozen_materials(rows["참조사본"])
assert {MAT, WIRE, NET_ITEM} <= set(mats)
assert "refs.frozen_materials(" in open(source.__file__, encoding="utf-8").read()
bundle = prices.to_json(prices.collect(refs.frozen_logics(rows["참조사본"]), mats))
(world["system"] / "precombo" / f"{key}.json").unlink() # 원본이 사라져도 굳힌 사본
got = prices.calc(rows, bundle, {}, {}, None)["줄"][0]
assert got["기준"]["계"] == pytest.approx(float(want), rel=1e-9)
+45
View File
@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
"""PLAN 176-1 — B05 3D 성토 비탈 끝 깨짐(172 뒤) 고침 검사.
도우미(helper_176_1_seam.cjs) — 직선 80 m · 성토(0~40 m) → 절토(60~80 m) · 지반 사인 물결(진폭 1 m).
비탈 끝 바깥 SEAM_M(1.5 m) 안 지형 점 빼서 잇는 띠 완만 · 성토 덩이 끝 3 m 안 뜸 0 까지 줄임.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_176_1_seam.cjs")],
capture_output=True,
text=True,
timeout=240,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_seam_band_gentle(r):
# 성토 비탈 끝을 쓰는 원지반 삼각형 경사 p99 < 45°(1 mm 띠 = 54.7°)
assert r["seam"] == 1.5
assert r["seamTris"] > 100, r
assert r["seamP99"] < 45, r
def test_run_end_lift_tapered(r):
# 성토 덩이 가운데는 한도까지 뜸(물결이 실제로 재어짐) · 절토로 바뀌는 끝은 0 근처
assert r["midOff"] > 0.4, r
assert r["endX"][0] > 45 and r["endX"][1] > 45, r
assert r["endOff"] < 0.02, r
def test_no_gap_no_failed_constraint(r):
assert r["gaps"] == 0, r
assert r["stats"]["failed"] == 0, r
@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
"""PLAN 176-2 — B05 3D 흑백 지형 · 등고선 처음 값 false(단추 · 뷰어 두 곳 다)."""
import os
import re
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
def _read(name):
with open(os.path.join(ROOT, "B05_Profile", name), encoding="utf-8") as f:
return f.read()
def test_panel_buttons_start_off():
src = _read("B05_Profile_UI_Panel_View.ts")
assert re.search(r'toggleButton\("등고선", false,', src)
assert re.search(r'toggleButton\("흑백 지형", false,', src)
def test_viewer_starts_off():
src = _read("B05_Profile_UI_Viewer.ts")
assert "let surfaceGrayscale = false;" in src
# 등고선 그룹은 만들자마자 숨김(적재 때 선은 그대로 만들고 단추로 켬)
assert re.search(
r"const contours = new THREE\.Group\(\);\s*\n\s*contours\.visible = false;", src
)
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""PLAN 176-3 — B05 3D 측점 노란 띠 끝 주황 구슬(측구 방향 고르기) · 클릭 · 처리기 지움.
저장 길(세션 uphill → uphill_overrides)과 B06 이 쓰는 종단 병합 함수는 남김.
"""
import os
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
def _read(*parts):
with open(os.path.join(ROOT, *parts), encoding="utf-8") as f:
return f.read()
def test_markers_have_no_lamp():
src = _read("B05_Profile", "B05_Profile_UI_Markers.ts")
for gone in ("uphillStationId", "onUphillPick", "0xf97316", "UPHILL_LAMP", "lampHit"):
assert gone not in src, gone
def test_page_has_no_lamp_handler():
src = _read("A00_Common", "design", "design_view_profile.ts")
assert "onUphillPick" not in src
assert "uphillOverrides" not in _read("B05_Profile", "B05_Profile_UI_Page_Stations.ts")
def test_save_path_and_merge_kept():
# 저장 길(세션 → 저장 몸통)과 B06 이 부르는 종단 병합 함수는 그대로
page = _read("A00_Common", "design", "design_view_profile.ts")
assert "persistUphillOverrides" in page
assert "uphill_overrides" in _read("B05_Profile", "B05_Profile_UI_Page_Actions.ts")
assert "def _merge_uphill_overrides_into_longitudinal" in _read(
"B05_Profile", "B05_Profile_Router_Confirm.py"
)
@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
"""PLAN 177-1 — 구조물 조합 미연결 라벨 = 연한 노랑(경고) 배경 + 테두리(소스 시험)."""
import os
import re
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
CSS = os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Style_Structures.css")
def _rule(sel: str) -> str:
src = open(CSS, encoding="utf-8").read()
m = re.search(re.escape(sel) + r"\s*\{([^}]*)\}", src)
assert m, sel
return m.group(1)
def test_미연결_배경_테두리_경고색_글자색_없음():
body = _rule(".b05-structure__summary-name:not(.is-assigned)")
assert re.search(r"border-color:\s*var\(--color-warning\)", body)
assert re.search(r"background:\s*color-mix\([^;]*--color-warning[^;]*--color-surface", body)
assert not re.search(r"(^|[;\s])color:", body)
def test_테마_토큰_밝음_어두움_둘다():
th = open(os.path.join(ROOT, "ui_template", "ui_template_theme.css"), encoding="utf-8").read()
assert len(re.findall(r"--color-warning:\s*#", th)) >= 2
@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
"""PLAN 177-2 — 동물이동형 = 고르기(유 / 무) · 체크박스 꼴(bool) 지움 · 화면 값은 helper_173_7."""
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).parent
ROOT = HERE.parents[1]
DESIGN = ROOT / "A00_Common" / "design"
@pytest.fixture(scope="module")
def out() -> dict:
if not (ROOT / "config" / "node_modules").is_dir():
pytest.skip("의존성 미설치")
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_173_7_inlet_animal.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
)
assert done.returncode == 0, done.stderr
return json.loads(done.stdout)
def test_체크박스_없음(out: dict) -> None:
assert out["checkbox"] is False
def test_bool_꼴_지움() -> None:
spec = (DESIGN / "design_structure_spec.ts").read_text("utf-8")
form = (DESIGN / "design_structure_form.ts").read_text("utf-8")
pipe = (DESIGN / "design_structure_pipe.ts").read_text("utf-8")
assert '"bool"' not in spec and '"bool"' not in form and '"bool"' not in pipe
assert "createCheckbox" not in form
@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
"""177-3 B06 횡단 카드 고름 → 종단 측점 세로선 유지(반대 방향도).
못박는 것
- 카드 고름 뒤 리스너의 selectMark(mark) 가 측점선을 끄지 않음 — 일반 · 벽 · 노면공 카드 = 그 카드 선.
- 관 카드 · 관 알약 = 관 측점선 · 카드 풂 = 선 끔.
- 종단 측점선 누름(반대 길)도 같은 리스너 — 누른 선 그대로.
- B05(keepStation 기본값) = 지금 그대로(선 없는 구조물 → 선 끔).
Node 헬퍼 `helper_177_3_station_line_keep.cjs` 가 진짜 design_profile.ts 본문을 돌림. 화면은 사용자 확인.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
def _read(*parts: str) -> str:
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
return handle.read()
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_177_3_station_line_keep.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):
assert out["plainCard"] == "s40"
assert out["wallCard"] == "s100"
assert out["paveCard"] == "s140"
def test_관은_관_측점선(out):
assert out["pipeCard"] == "irr-80"
assert out["pipeFromPlain"] == "irr-80"
def test_반대_길_종단_측점선_누름도_유지(out):
assert out["profileClick"] == "s200"
assert out["release"] is None
def test_B05_기본_길은_그대로(out):
assert out["b05Wall"] is None
assert out["b05Pipe"] == "irr-80"
assert out["b05Keep"] == "irr-80"
def test_배선():
svp = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts")
assert "profile?.setSelectedStructure(structureId, true);" in svp
assert "profile.setSelectedStructure(markedId, true);" in svp
cross = _read("A00_Common", "design", "design_view_cross.ts")
assert "sectionView.selectMark(mark, false);" in cross
b05 = _read("A00_Common", "design", "design_view_profile.ts")
assert ", true)" not in "".join(
line for line in b05.splitlines() if "setSelectedStructure(" in line
)
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""177-4 B06 노면공 목록 · 알약 고름 = 범위 카드 전부 테두리(박스) · 카드 하나 안 고름.
못박는 것
- 노면공 고름 = 범위 안 카드 전부 `b06-cross-card--span`(다른 구간형과 같음) + 포장층 강조 그대로.
- 카드 하나 고르기(`--selected`) 없음 · 범위 첫 카드로 스크롤만 · 옛 고른 카드는 리스너 없이 풂(폼 · 알약 유지).
- 세션 고름은 노면공 + 스크롤한 카드 측점(B05 로 가도 그 노면공).
Node 헬퍼 `helper_165_pavement_flow.cjs`(진짜 SPN) 결과를 같이 씀. 화면은 사용자 확인.
"""
import json
import os
import re
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
def _read(*parts: str) -> str:
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
return handle.read()
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_165_pavement_flow.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):
for step, reveal, layer, cards in (
("step1", "reveal1", "pave", 12),
("step4", "reveal4", "pave", 12),
("step6", "reveal6", "pave2", 6),
):
snap = out[step]
assert snap["card"] is None # --selected 0
assert snap["form"] == layer and snap["pill"] == layer # 폼 · 알약 유지
assert snap["paveCards"] == cards # 범위 카드 전부
assert out[reveal] in ("s40", "s100")
assert out["pick1"] == {"chainageM": 40, "structureId": "pave"}
def test_범위_카드_테두리는_노면공도():
view = _read("B06_Section", "B06_Section_UI_Section_View.ts")
assert 'card.classList.toggle("b06-cross-card--span", span);' in view
assert "highlightPavement(card, pave ? mark : undefined);" in view
def test_스크롤만_길은_리스너를_안_거침():
view = _read("B06_Section", "B06_Section_UI_Section_View.ts")
body = re.search(r"revealStation\(stationId\) \{(.*?)\n \},", view, re.S)
assert body, "revealStation 없음"
text = body.group(1)
assert "selectedStationId = null;" in text
assert "profile.showCard(null);" in text
assert 'revealCard(stationId, "smooth");' in text
assert "stationSelectListener" not in text # 폼을 닫지 않음
cross = _read("A00_Common", "design", "design_view_cross.ts")
assert "if (revealOnly) sectionView.revealStation(nearest.station_id);" in cross
panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts")
assert "const pave = !coverable(structure);" in panel
assert 'if (pave) selectDesign(deps.projectId, at, { from: "b06" });' in panel
@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
"""177-5 좌측 폼 「측점 범위」 줄 — 사면안정군(C군)만 숨김.
못박는 것
- PNL5 `syncRangeDisplay` 가 C군이면 `rangeWrap.hidden` · 다른 구간형(B 소단배수로 · E 대피소 등)은 그대로.
- `hasComputedRange` 자체는 그대로(시작 · 종료 칸 숨김에도 씀).
- C군 = 사면안정 6종(정의 기준) · 그 밖 범위 종류는 C 가 아님.
화면은 사용자 확인.
"""
import os
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
def _read(*parts: str) -> str:
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
return handle.read()
def test_범위_줄은_C군만_숨김():
panel = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts")
at = panel.index("function syncRangeDisplay(): void {")
body = panel[at : panel.index("\n }", at)]
assert (
'const computed = hasComputedRange(currentType()) && currentType()?.group !== "C";' in body
)
assert "rangeWrap.hidden = !computed;" in body
def test_hasComputedRange_는_그대로():
types = _read("B05_Profile", "B05_Profile_UI_Structures_Panel_Types.ts")
assert "export function hasComputedRange(" in types
assert '"C"' not in types[types.index("export function hasComputedRange(") :][:600]
def test_C군은_사면안정_6종_다른_범위_종류는_C_아님():
groups = {t.type_id: t.group for t in load_structure_types()}
slope = {
"retaining_wall",
"masonry_wet",
"masonry_dry",
"soil_guard",
"boulder_masonry",
"berm",
}
assert {k for k, g in groups.items() if g == "C"} == slope
for kept in ("ditch_berm", "refuge", "work_yard", "guardrail"):
assert groups.get(kept) not in (None, "C"), kept
@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
"""PLAN 179-1 — 선 길이 정보창 = 열 수 무관 늘 줌 [+ − ⤢] 왼쪽(좁은 카드 내림 규칙 지움 · 소스 시험)."""
import os
import re
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _src(rel: str) -> str:
return open(os.path.join(ROOT, rel), encoding="utf-8").read()
def test_좁은_카드_내림_규칙_없음():
css = _src("B06_Section/B06_Section_UI_Style_Cross_Controls.css")
assert "@container" not in css
# 치수 칸 규칙은 기본 꼴 · 숨김(+ 옆 단추) 뿐 — 절대 자리 · 왼선 지움 규칙 없음
assert re.findall(r"\.b06-cross-card__line-dims[^{,;]*\{", css) == [
".b06-cross-card__line-dims {",
".b06-cross-card__line-dims[hidden] {",
".b06-cross-card__line-dims[hidden] + .b06-cross-card__zoom-btn {",
]
def test_치수_칸은_줌_막대_맨_앞():
view = _src("B06_Section/B06_Section_UI_Cross_View.ts")
assert "zoomBar.prepend(linePick.label);" in view
zoom = (
_src("B06_Section/B06_Section_UI_Style_Cross_Controls.css")
.split(".b06-cross-card__zoom {", 1)[1]
.split("}", 1)[0]
)
assert "display: flex;" in zoom and "position: absolute;" in zoom
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""PLAN 179-2 — 측구 줄 컴팩트(W16 (가)): 막힌 단추 숨김 · 「측구형식」 글 지움(툴팁) · 알약에 편측 쪽(소스 시험)."""
import os
import re
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _src(rel: str) -> str:
return open(os.path.join(ROOT, rel), encoding="utf-8").read()
def test_막힌_묶음은_숨김():
css = _src("B06_Section/B06_Section_UI_Style_Cross.css")
body = re.search(r"\.b06-design__seg--disabled\s*\{([^}]*)\}", css).group(1)
assert "display: none;" in body
assert "opacity" not in body
def test_측구형식_글_없이_툴팁():
cd = _src("B06_Section/B06_Section_UI_Cross_Design.ts")
seg = cd.split("const ditchTypeSeg = segment(", 1)[1].split(");", 1)[0]
assert seg.lstrip().startswith('"",')
assert 'ditchTypeSeg.title = L("B06_Design_DitchType_Legend");' in cd
def test_넘침_셈은_숨긴_칸_폭_간격_뺌():
cd = _src("B06_Section/B06_Section_UI_Cross_Design.ts")
assert "overflow -= width ? width + barGapPx : 0;" in cd
def test_알약에_편측_쪽():
lb = _src("ui_template/ui_template_locale_b2.ts")
assert 'B06_Design_Mode_LeftCut: ["편측 성토(좌 절토)"' in lb
assert 'B06_Design_Mode_RightCut: ["편측 성토(우 절토)"' in lb
@@ -0,0 +1,168 @@
# -*- coding: utf-8 -*-
"""180-2 B07 횡단 장 — 표 · 표 위 글 배율(모든 장 한 값 · 0.5~2.0 · 기본 1).
못박는 것
- 배율 1.5 = 표 폭 162 · 높이 60 · 글 줄 높이 × 1.5 · 표 아래선은 칸 아래 + 여백 그대로.
- 그림 아래 끝 = 글(없으면 표) 위 8 mm(간격은 배율 안 탐) · 넓은 표가 칸을 넘으면 overflow_cells.
- 배율 1 = 배율 칸 없는 배치와 같은 엔티티.
- cross_layout.json 이 배율을 지킴 · 범위 밖 = 거절 · 배율만 바꾸면 확정 장 안 풂.
- 지금과 다른 배율로 확정된 장 = 그대로 + stale_scale 안내.
"""
import json
from pathlib import Path
import pytest
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import DRAWING_FORMAT, GROUND_LAYER_ID
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
CELL_TABLE_PAD_MM,
build_cross_sheet,
plan_cross_sheets,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
entities_bbox,
usable_area,
use_project_templates,
)
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
CROSS_SHEET_LAYOUT,
_cross_sheet_plan,
_read_drawing,
_read_manifest,
_write_manifest,
read_cross_layout,
write_cross_layout,
)
NOTE = {"structure_note": "L형 측구\n집수정 보강"}
def _source(chainage: float, lo: int = -5, hi: int = 5) -> dict:
return {
"chainage_m": chainage,
"samples": [
{"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.05 * x}
for x in range(lo, hi + 1)
],
}
def _drawing(layout: dict, designs: list[dict | None]) -> tuple[dict, dict]:
use_project_templates(None)
sources = [_source(i * 20.0) for i in range(len(designs))]
sheet = plan_cross_sheets([s["chainage_m"] for s in sources], layout)[0]
sections = [
{
"chainage": s["chainage_m"],
"source": s,
"design": design,
"quantity_table": {"ground": 1.0, "planned": 0.5},
"title": "No.0",
}
for s, design in zip(sources, designs, strict=True)
]
return sheet, build_cross_sheet(sheet, sections)
def _tables(drawing: dict) -> list[dict]:
return [e for e in drawing["entities"] if e["type"] == "Table"]
def test_배율_15_표_크기와_쌓기() -> None:
sheet, drawing = _drawing({"default": [2, 2], "table_scale": 1.5}, [NOTE, None])
assert sheet["table_scale"] == 1.5
_w, h = usable_area()
tables = _tables(drawing)
for table, slot in zip(tables, sheet["slots"], strict=True):
shape = table["shapeData"]
assert sum(shape["columnWidths"]) == pytest.approx(162.0)
assert sum(shape["rowHeights"]) == pytest.approx(60.0)
bottom = shape["origin"]["y"] - sum(shape["rowHeights"])
assert bottom == pytest.approx(slot[1] - h / 4 + CELL_TABLE_PAD_MM) # 표 아래선 그대로
grounds = [
entities_bbox([e]) for e in drawing["entities"] if e.get("layerId") == GROUND_LAYER_ID
]
# 글 2 줄 × 3.0 × 1.5 = 9 mm · 간격 8 mm 는 배율 안 탐
tops = [t["shapeData"]["origin"]["y"] for t in tables]
assert grounds[0][1] == pytest.approx(tops[0] + 9.0 + 8.0)
assert grounds[1][1] == pytest.approx(tops[1] + 8.0)
def test_배율_1_은_지금과_같음() -> None:
_s, plain = _drawing({"default": [2, 2]}, [NOTE, None])
_s, one = _drawing({"default": [2, 2], "table_scale": 1.0}, [NOTE, None])
assert one["entities"] == plain["entities"]
assert sum(_tables(one)[0]["shapeData"]["columnWidths"]) == pytest.approx(108.0)
def test_넓은_표_넘침() -> None:
w, _h = usable_area()
# 4 × 4 칸 폭 192.5 — 표 108 × 2.0 = 216 이 칸을 넘음
_s, drawing = _drawing({"default": [4, 4], "table_scale": 2.0}, [None])
assert w / 4 < 216.0
assert len(drawing["overflow_cells"]) == 1
_s, drawing = _drawing({"default": [4, 4], "table_scale": 1.0}, [None])
assert drawing["overflow_cells"] == []
def _project(tmp_path: Path, count: int) -> tuple[Path, Path]:
root = tmp_path / "project"
cross_dir = root / "B06_Section" / "cross_sections"
cross_dir.mkdir(parents=True)
longitudinal_path = root / "B06_Section" / "longitudinal" / "longitudinal.json"
longitudinal_path.parent.mkdir(parents=True)
stations = []
for i in range(count):
chainage = i * 20
stations.append({"chainage_m": float(chainage), "center_x": 0.0, "center_y": 0.0})
(cross_dir / f"cross_{chainage:05d}m.json").write_text(
json.dumps(_source(float(chainage))), "utf-8"
)
longitudinal_path.write_text(json.dumps({"stations": stations}), "utf-8")
return root, longitudinal_path
def test_배치_파일_배율_저장_범위(tmp_path: Path) -> None:
root, longitudinal_path = _project(tmp_path, 5)
ids = [s["id"] for s in _cross_sheet_plan(root, longitudinal_path)]
manifest = _read_manifest(root)
manifest["drawings"] = {i: {"confirmed": True} for i in ids}
_write_manifest(root, manifest)
# 배율만 바꿈 — 담긴 측점 그대로라 확정 장 안 풂
assert (
write_cross_layout(root, longitudinal_path, {"default": [4, 4], "table_scale": 1.3}) == []
)
assert read_cross_layout(root)["table_scale"] == 1.3
assert _read_manifest(root)["drawings"][ids[0]]["confirmed"] is True
assert _cross_sheet_plan(root, longitudinal_path)[0]["table_scale"] == 1.3
for bad in (0.4, 2.1, "1.2", True):
with pytest.raises(ValueError):
write_cross_layout(root, longitudinal_path, {"default": [4, 4], "table_scale": bad})
assert read_cross_layout(root)["table_scale"] == 1.3
def test_옛_배율_확정_장_안내(tmp_path: Path) -> None:
stage = tmp_path / "B07_DesignDetail"
(stage / "drawings").mkdir(parents=True)
saved = {"format": DRAWING_FORMAT, "entities": [], "layers": []}
(stage / "drawings" / "cross_s00000m.json").write_text(json.dumps(saved), encoding="utf-8")
entry = {"kind": "cross", "label": "1장", "confirmed": True, "layout": CROSS_SHEET_LAYOUT}
manifest = {"drawings": {"cross_s00000m": entry}}
(stage / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
def read() -> dict:
_k, _l, drawing, confirmed, _t = _read_drawing(
tmp_path, tmp_path / "x.json", "cross_s00000m"
)
assert confirmed
return drawing
assert "stale_scale" not in read() # 배율 적힘 없음 = 1 · 지금도 1
(stage / "cross_layout.json").write_text(
json.dumps({"default": [4, 4], "sheets": {}, "table_scale": 1.5}), encoding="utf-8"
)
assert read().get("stale_scale") is True
entry["table_scale"] = 1.5
(stage / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
assert "stale_scale" not in read()
@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
"""180-1 B07 횡단 장 — 원지반선 고르기 · 측점 한 벌 groupId.
못박는 것
- 횡단 장 · 측점 한 장의 원지반 층은 안 잠김(종단은 그대로 잠김).
- 측점마다 groupId 하나(원지반 · 계획선 · 표 · 글 전부) · 측점끼리 다름.
- 칸 격자선 · 도각엔 groupId 없음.
- cross_placements 의 group_id = 그 측점 엔티티 값(화면 덧그림이 같은 묶음).
"""
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
FRAME_LAYER_ID,
GROUND_LAYER_ID,
build_cross_drawing,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import (
build_cross_sheet,
plan_cross_sheets,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Template import use_project_templates
def _source(chainage: float) -> dict:
return {
"chainage_m": chainage,
"samples": [
{"valid": True, "offset_m": float(x), "elevation_m": 100.0 + 0.1 * x}
for x in range(-5, 6)
],
}
def _design_line() -> list[dict]:
return [{"offset_m": x, "elevation_m": 100.0} for x in (-3.0, 0.0, 3.0)]
def _drawing() -> dict:
use_project_templates(None)
chainages = [0.0, 20.0, 40.0, 60.0]
sheet = plan_cross_sheets(chainages, {"default": [2, 2]})[0]
sections = [
{
"chainage": c,
"source": _source(c),
"design_line": _design_line(),
"design": {"structure_note": "L형 측구"},
"quantity_table": {"ground": 1.0, "planned": 0.5},
"title": f"No.{int(c // 20)}",
}
for c in chainages
]
return build_cross_sheet(sheet, sections)
def _ground_locked(drawing: dict) -> bool:
return next(layer for layer in drawing["layers"] if layer["id"] == GROUND_LAYER_ID)["isLocked"]
def test_원지반_층_안_잠김() -> None:
assert _ground_locked(_drawing()) is False
single = build_cross_drawing(_source(0.0), "cross_00000m", _design_line(), None, None, "")
assert _ground_locked(single) is False
def test_측점마다_groupId_하나() -> None:
drawing = _drawing()
placements = drawing["cross_placements"]
assert len(placements) == 4
group_ids = [p["group_id"] for p in placements]
assert len(set(group_ids)) == 4 # 측점끼리 다름
grouped = [e for e in drawing["entities"] if "groupId" in e]
assert {e["groupId"] for e in grouped} == set(group_ids)
for group_id in group_ids:
members = [e for e in grouped if e["groupId"] == group_id]
layers = {e["layerId"] for e in members}
types = {e["type"] for e in members}
assert GROUND_LAYER_ID in layers and "Table" in types and "Text" in types
# 격자선 · 도각은 묶지 않음
assert all("groupId" not in e for e in drawing["entities"] if e["layerId"] == FRAME_LAYER_ID)
assert all(e["layerId"] != FRAME_LAYER_ID for e in grouped)
def test_groupId_결정적() -> None:
first = [p["group_id"] for p in _drawing()["cross_placements"]]
assert first == [p["group_id"] for p in _drawing()["cross_placements"]]
@@ -0,0 +1,104 @@
"""181-1 B09 탭 두 층 — 위 탭 10 · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭.
- 위 탭 = `TAB_GROUPS`(`TABS` 19 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로
- 줄 = 위 md · 아래 sm · 아래 탭 없는 위 탭 = 빈 보조 줄(B08 꼴)
"""
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from resources.tester.helper_b09_group_cards import TABS, run # noqa: E402
BAR = "B09_Estimation/B09_Estimation_UI_TabBar.ts"
# 사용자 30번 — 위 탭 차례 = 지금 탭 차례(묶음 첫 자리) · 아래 = 글에 적힌 차례
EXPECTED = [
["연결 점검", ["check"]],
["공사원가계산서", ["cost"]],
["총괄설계내역서", ["total"]],
["설계내역서", ["bill"]],
["일위대가", ["dg_list", "dg"]],
["단가산출", ["sg_list", "sg"]],
["환율및기초자료", ["basis"]],
["중기", ["machine", "machine_blocks", "sum_machine", "expense", "sum_expense"]],
["재료", ["material", "compare", "sum_material"]],
["노무", ["labor", "sum_labor"]],
]
def test_묶음_표_사용자_30번과_같고_19_탭이_한_번씩():
out = run(
[TABS],
"\nconsole.log(JSON.stringify({ groups: TAB_GROUPS, ids: TABS.map(([t]) => t),"
" of: TABS.map(([t]) => groupOf(t)[0]) }));",
)
assert out["groups"] == EXPECTED
flat = [t for _, tabs in out["groups"] for t in tabs]
assert sorted(flat) == sorted(out["ids"]) and len(flat) == len(set(flat)) == 19
# 위 탭 차례 = 엑셀 차례에서 묶음이 처음 나오는 자리
firsts = list(dict.fromkeys(out["of"]))
assert firsts == [g[0] for g in EXPECTED]
assert len(firsts) == 10
def test_줄_그리기_두_층_빈_줄_마지막_본_아래_탭():
out = run(
[TABS, BAR],
"""
const picks = [];
const bars = createTabBars((t) => picks.push(t));
const view = () => ({
top: bars.top.children.map((b) => [b.textContent, b.className, b.attrs['aria-selected'],
b.dataset.b09Group, b.dataset.b09Tab ?? null]),
sub: bars.sub.children.map((b) => [b.textContent, b.attrs['aria-selected'], b.dataset.b09Tab ?? null]),
blank: bars.sub.classList.contains('b09-estimation__tabs--blank'),
role: bars.sub.attrs.role,
});
bars.draw('dg');
const a = view();
bars.top.children[4].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음
bars.sub.children[0].click(); // 일위대가목록표
bars.draw('compare'); // 묶음을 넘는 이동 = 위 탭 따라 켜짐
const b = view();
bars.draw('sum_material');
bars.draw('dg');
bars.top.children[8].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표)
bars.top.children[7].click(); // 중기 = 본 적 없음 → 첫 아래 탭
bars.draw('cost');
const c = view();
bars.top.children[0].click(); // 연결 점검 = 단독 위 탭
bars.sub.children[0].click(); // 빈 줄 단추(숨김) = 누름 없음
console.log(JSON.stringify({ a, b, c, picks }));
""",
)
a, b, c = out["a"], out["b"], out["c"]
assert len(a["top"]) == 10 and [t[0] for t in a["top"]][:5] == [
"연결 점검", "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가"
] # fmt: skip
assert [t[2] for t in a["top"]] == ["false"] * 4 + ["true"] + ["false"] * 5
assert all("ui-btn--ghost" in t[1] for t in a["top"] if t[2] == "false")
assert a["sub"] == [["일위대가목록표", "false", "dg_list"], ["일위대가표", "true", "dg"]]
assert not a["blank"] and a["role"] == "tablist"
assert b["sub"] == [
["재료비목록표", "false", "material"],
["자재단가대비표", "true", "compare"],
["재료비수량금액집계표", "false", "sum_material"],
] # fmt: skip
assert b["top"][8][2] == "true" and b["top"][4][2] == "false"
# 단독 위 탭 — 위 단추가 탭 키도 가짐 · 아래 줄 = 숨긴 단추 한 개
assert c["blank"] and c["role"] == "presentation" and len(c["sub"]) == 1
assert c["top"][1][3] == "cost" and c["top"][1][4] == "cost" and c["top"][4][4] is None
assert out["picks"] == ["dg_list", "sum_material", "machine", "check"]
def test_잇기_쉘이_두_줄을_쓰고_CSS_빈_줄_규칙():
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert "createTabBars((tab) => show(tab))" in shell and "bars.draw(tab);" in shell
assert "body.append(bars.top, bars.sub, panel);" in shell
css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
assert ".b09-estimation__tabs--blank > .ui-btn" in css and "visibility: hidden" in css
@@ -0,0 +1,76 @@
"""181-2 박스보기 — 이름 · 접기 없음 · 목록표에서 고른 호표 = 박스 붉은 외곽선 · 표 보기 = 줄 구간 전체.
- 단추 글 「박스보기」 ↔ 「표로 보기」 · 요약(접기) 보기 지움
- 표 보기 = 고른 호표 줄 구간(키 `|` 앞머리 같은 줄) 모두 `is-pick` · 첫 줄 `is-pick-top` · 끝 줄 `is-pick-end`
- 붉은 색 = `--color-danger`(B06 횡단 카드 고름 꼴)
"""
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from resources.tester.helper_b09_group_cards import COMMON, GROUPS, TABS, run # noqa: E402
ROWS = [ # 호표 A(3줄) · 호표 B(4줄) · 호표 C(1줄)
{"key": "A"}, {"key": "A|1"}, {"key": "A|합계"},
{"key": "B"}, {"key": "B|1"}, {"key": "B|2"}, {"key": "B|총계"},
{"key": "C"},
] # fmt: skip
def test_구간_첫_끝_줄():
out = run(
[COMMON, TABS, GROUPS],
f"\nconst rows = {json.dumps(ROWS)};"
"\nconst show = (focus) => { const r = pickRange(rows, focus);"
" return [r, rows.map((_, i) => pickClass(r, i))]; };"
"\nconsole.log(JSON.stringify({ a: show('A|1'), b: show('B'), c: show('C'),"
" none: show(undefined), unknown: show('Z|1') }));",
)
assert out["a"][0] == [0, 2]
assert out["a"][1] == [
"is-pick is-pick-top", "is-pick", "is-pick is-pick-end", None, None, None, None, None,
] # fmt: skip
assert out["b"][0] == [3, 6]
assert out["b"][1][3:7] == [
"is-pick is-pick-top", "is-pick", "is-pick", "is-pick is-pick-end",
] # fmt: skip
assert out["b"][1][:3] == [None] * 3 # 다른 호표 없음
assert out["c"][0] == [7, 7] and out["c"][1][7] == "is-pick is-pick-top is-pick-end"
assert out["none"] == [None, [None] * 8] and out["unknown"] == [None, [None] * 8]
def test_표_보기_줄_class_넘김():
out = run(
[COMMON, TABS, GROUPS],
f"\nconst spec = {{ columns: [{{ key: 'key' }}], rows: {json.dumps(ROWS)} }};"
"\nlet extra = null;"
"\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {},"
" makeTable: (e) => { extra = e; return { root: document.createElement('table'),"
" focusRow: () => {} }; } };"
"\nchosen.set('dg', 'table');"
"\nrenderGroupPanel(o);"
"\nconsole.log(JSON.stringify(spec.rows.map((_, i) => extra(i))));",
)
assert out == (
[None] * 3 + ["is-pick is-pick-top", "is-pick", "is-pick", "is-pick is-pick-end"] + [None]
)
def test_글_접기_없음_CSS_잇기():
groups = (ROOT / GROUPS).read_text("utf-8")
assert '"표로 보기" : "박스보기"' in groups and "호표별 보기" not in groups
common = (ROOT / COMMON).read_text("utf-8")
for gone in ("isSummary", "GROUP_SUMMARY_OVER", "is-closed", "모두 펼침", "모두 접기"):
assert gone not in common
assert 'box?.classList.add("is-pick")' in common
page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
for sel in ("tr.is-pick td", "tr.is-pick.is-pick-top td", "tr.is-pick.is-pick-end td"):
assert sel in page
assert page.count("var(--color-danger)") == 4 # 위 · 아래 · 왼 · 오른 2px
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert "extra?: (index: number) => string | null" in shell
+1 -1
View File
@@ -125,7 +125,7 @@ def test_화면_칸_부품_사용자_줄_로직_줄_같은_칸():
assert "...markCells(mat, on?.mark" in rows and "...markHeads()," in rows
assert "...markCells(" in _src("M02_MasterTemplete_Labor_LogicMats.ts")
detail = _src("M02_MasterTemplete_Labor_Detail.ts")
assert "mark: lib ? undefined : markLogicMat" in detail
assert "mark: markLogicMat," in detail and "lib ? undefined" not in detail # 사전조합도(175-1)
assert "mark: markMat" in detail
# 서버 허용 값 = 화면 허용 값
api = _src("M02_MasterTemplete_Labor_Api.ts")
+6 -8
View File
@@ -47,6 +47,12 @@
border-radius: var(--radius-cards);
}
/* 목록표에서 고른 호표 — B06 횡단 카드 고름과 같은 붉은 외곽선(181-2) */
.ui-group-card.is-pick {
border-color: var(--color-danger);
box-shadow: 0 0 0 2px var(--color-danger);
}
.ui-group-card__head {
display: flex;
flex-wrap: wrap;
@@ -76,14 +82,6 @@
overflow-x: auto;
}
.ui-group-card.is-closed .ui-group-card__body {
display: none;
}
.ui-group-card.is-closed .ui-group-card__head {
cursor: pointer;
}
.ui-group-card__lines {
width: 100%;
border-collapse: collapse;
+6 -23
View File
@@ -5,8 +5,9 @@
* 머리 = 호표 · 명칭 · 규격 · 단위 · 합계 / 몸 = 줄(이름 · 규격 · 수량 · 단위 · 합계 · 비고) /
* 합계 줄 = 노무 · 재료 · 경비 · 계 · 줄 금액 = 합계만(비목 셋 = 풍선)
* 글자 14px 한 벌 · 줄 간격 좁게 · 숫자 오른쪽 · 가로 열 수 1~4(기억은 부르는 쪽)
* 묶음 12 넘으면 요약 보기(머리 · 합계만 · 머리 누름 = 펼침 · 모두 펼침 · 접기)
* 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 펼침 + 스크롤 + 강조
* 접기 없음 — 묶음 모두 펼친 박스
* 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 그 박스 붉은 외곽선
* (`is-pick`) + 스크롤 + 그 줄 강조(`is-focus`)
* ========================================================================== */
import "./ui_template_group_cards.css";
@@ -68,7 +69,6 @@ export interface GroupCardsOptions {
actions?: HTMLElement[];
}
export const GROUP_SUMMARY_OVER = 12;
export const GROUP_COLUMNS = [1, 2, 3, 4] as const;
export const PART_LABELS = ["노무", "재료", "경비"] as const;
@@ -76,9 +76,6 @@ export const PART_LABELS = ["노무", "재료", "경비"] as const;
export const clampColumns = (value: unknown): number =>
GROUP_COLUMNS.includes(value as 1) ? (value as number) : 2;
/** 요약 보기 — 묶음 12 넘음 */
export const isSummary = (count: number): boolean => count > GROUP_SUMMARY_OVER;
const won = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 1 });
const plain = (value: number | null | undefined): string =>
value === null || value === undefined || !Number.isFinite(value) ? "" : won.format(value);
@@ -173,7 +170,7 @@ function card(g: GroupCard, o: Required<Pick<GroupCardsOptions, "money" | "qty">
return box;
}
/** 묶음 카드 판 — 머리 띠(가로 열 수 · 요약 단추 · 부르는 쪽 단추) + 카드 격자 */
/** 묶음 카드 판 — 머리 띠(가로 열 수 · 부르는 쪽 단추) + 카드 격자 */
export function createGroupCards(opts: GroupCardsOptions): HTMLElement {
const fmt = { money: opts.money ?? plain, qty: opts.qty ?? plain };
const root = document.createElement("div");
@@ -184,9 +181,6 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement {
grid.style.setProperty("--group-cols", String(columns));
const cards = opts.groups.map((g) => card(g, fmt));
grid.append(...cards);
const summary = isSummary(opts.groups.length);
const setOpen = (box: Element, open: boolean) => box.classList.toggle("is-closed", !open);
if (summary) cards.forEach((c) => setOpen(c, false));
const bar = document.createElement("div");
bar.className = "ui-group-cards__bar";
@@ -215,14 +209,6 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement {
});
pick.append(...colButtons);
bar.append(pick, span("ui-group-cards__count", `묶음 ${opts.groups.length}`));
if (summary) {
const all = (open: boolean) => () => cards.forEach((c) => setOpen(c, open));
bar.append(
span("ui-group-cards__count", "요약 보기 — 머리를 누르면 펼침"),
createButton({ label: "모두 펼침", variant: "ghost", size: "sm", onClick: all(true) }),
createButton({ label: "모두 접기", variant: "ghost", size: "sm", onClick: all(false) }),
);
}
const tail = document.createElement("div");
tail.className = "ui-group-cards__actions";
tail.append(...(opts.actions ?? []));
@@ -232,18 +218,15 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement {
grid.addEventListener("click", (event) => {
const el = (event.target as Element).closest<HTMLElement>("[data-at]");
if (!el || !grid.contains(el)) return;
const box = el.closest(".ui-group-card");
if (summary && box && el.classList.contains("ui-group-card__head"))
return setOpen(box, box.classList.contains("is-closed"));
opts.onPick?.(Number(el.dataset.at));
});
if (opts.focus !== undefined && opts.focus >= 0) {
const el = grid.querySelector<HTMLElement>(`[data-at="${opts.focus}"]`);
const box = el?.closest(".ui-group-card");
if (box) setOpen(box, true);
box?.classList.add("is-pick");
el?.classList.add("is-focus");
requestAnimationFrame(() => el?.scrollIntoView({ block: "center" }));
requestAnimationFrame(() => (box ?? el)?.scrollIntoView({ block: "center" }));
}
return root;
}
+2 -2
View File
@@ -420,8 +420,8 @@ export const ui_locales_b2 = {
B06_Design_Ground_Ripping: ["리핑암", "Ripping rock"],
B06_Design_Ground_Blasting: ["발파암", "Blasting rock"],
B06_Design_Mode_Legend: ["단면유형", "Section type"],
B06_Design_Mode_LeftCut: ["편측 성토", "One-side fill"],
B06_Design_Mode_RightCut: ["편측 성토", "One-side fill"],
B06_Design_Mode_LeftCut: ["편측 성토(좌 절토)", "One-side fill (left cut)"],
B06_Design_Mode_RightCut: ["편측 성토(우 절토)", "One-side fill (right cut)"],
B06_Design_Mode_BothCut: ["양측 절토", "Both cut"],
B06_Design_Mode_BothFill: ["양측 성토", "Both fill"],
B06_Design_Ditch_Legend: ["측구위치", "Ditch side"],