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

This commit is contained in:
2026-10-10 12:05:58 +09:00
127 changed files with 4425 additions and 499 deletions
@@ -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[];
+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);
}
+3 -1
View File
@@ -146,13 +146,15 @@ def is_station_planting_type(item: StructureType) -> bool:
- A군(횡단배수·계곡 통과 시설): 그 자리에 횡단도가 필요하다.
- D군 구간형(기슭막이 등): 구간 시작·기준·종료에 횡단도가 필요하다 — 길이가 길면
그만큼 여러 장이 나온다(사용자 확정).
- C군 구간형(사면안정 — 옹벽·흙막이·소단 등): D군 구간형과 같이 시작·기준·종료.
안 심으면 측점 격자 사이에 놓인 벽이 횡단도에 안 얹힌다.
측점 생성(`resolve_extra_stations`)과 구조물 재이관 차단
(`B05_Profile_Structures_Migration`)이 같은 규칙을 봐야 "기타" 고스트가 안 생긴다.
"""
if item.group == "A":
return True
return item.group == "D" and item.placement == "interval"
return item.group in ("C", "D") and item.placement == "interval"
def station_planting_labels() -> set[str]:
+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];
}
});
@@ -23,7 +23,12 @@
import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures_Hash";
import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_Api_Fetch";
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import { restrictToSide, tierSpanOf } from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire";
import {
culvertOwnerFor,
ownWallsDrawAt,
restrictToSide,
tierSpanOf,
} from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire";
import { pipeWallThicknessM } from "../B06_Section/B06_Section_UI_Cross_Culvert_Const";
import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import { computeFordLayout, fordAdjustOf } from "../B06_Section/B06_Section_UI_Cross_Ford";
@@ -277,9 +282,9 @@ export function buildCorridorStructures(
// 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위
// culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다
// (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙).
const revetLayout = section.culvert
? null
: computeRevetmentLayout(section, section.design?.revet_adjust?.own);
const revetLayout = ownWallsDrawAt(section, culvertOwnerFor(section, crossSections))
? computeRevetmentLayout(section, section.design?.revet_adjust?.own)
: null;
const paveChute = fordPavementChute(section, crossSections);
if (!layout && !fordLayout && !section.box && !revetLayout && !paveChute) continue;
const chainage = section.chainage_m;
+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으로 알리고, 누르는 동안은
@@ -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));
}
+4 -1
View File
@@ -265,7 +265,10 @@ export async function previewCrossDesigns(
/** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */
rockBoundaryOffsets?: Record<string, number>;
/** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */
berms?: Record<string, { width_m: number; interval_m: number; slope_deg: number }>;
berms?: Record<
string,
{ width_m: number; interval_m: number; slope_deg: number; depth_m?: number }
>;
/** 측점별 암 절토 경사비(chainage 키 → 1:n 의 n). 0 은 「표준값을 씀」이다. */
cutSlopeRatios?: Record<string, number>;
},
+1 -1
View File
@@ -528,7 +528,7 @@ export interface CrossDesign {
display_half_width_m?: number;
/** 이 측점에 놓인 소단 제원(계획서 3-9). 없으면 계단 없음.
* 세션이 비어도(확정 뒤·다른 PC) 저장분만으로 계단이 서게 되싣는 값이다. */
berm?: { width_m: number; interval_m: number; slope_deg: number };
berm?: { width_m: number; interval_m: number; slope_deg: number; depth_m?: number };
/** 측점별 **암 절토 경사비**(1:n 의 n) — 사용자가 카드에 넣은 값(2026-09-07).
* 0 은 「표준값을 씀」이다. 계산에 쓰이는 값이 아니라 **입력을 되싣는 자리**다. */
cut_slope_ratio_user?: number;
+9 -3
View File
@@ -29,6 +29,7 @@ import type { StandardCrossSectionSpec } from "@util/common_util_cross_design";
import { previewCrossDesigns } from "./B06_Section_Api_Fetch";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
import type { BermSessionSpec } from "./B06_Section_UI_Page_Persist";
import type { BermSpec } from "@util/common_util_cross_berm";
import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session";
import {
@@ -189,9 +190,9 @@ function bermPayload(
projectId: string,
routeId: number,
detail: SectionDetailResponse,
): Record<string, { width_m: number; interval_m: number; slope_deg: number }> {
): Record<string, BermSessionSpec> {
const spans = readBermSpans(projectId, routeId);
const out: Record<string, { width_m: number; interval_m: number; slope_deg: number }> = {};
const out: Record<string, BermSessionSpec> = {};
if (!spans.length) return out;
for (const section of detail.cross_sections) {
const spec = bermSpecAt(spans, section.chainage_m);
@@ -256,7 +257,12 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
const bermAt = (chainageM: number): BermSpec | null => {
const spec = bermSpecAt(bermSpans, chainageM);
return spec
? { widthM: spec.width_m, intervalM: spec.interval_m, slopeDeg: spec.slope_deg }
? {
widthM: spec.width_m,
intervalM: spec.interval_m,
slopeDeg: spec.slope_deg,
depthM: spec.depth_m,
}
: null;
};
// 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다
+1
View File
@@ -539,6 +539,7 @@ def compute_cross_design(
"width_m": round(float(berm.width_m), 4),
"interval_m": round(float(berm.interval_m), 4),
"slope_deg": round(float(berm.slope_deg), 4),
"depth_m": round(max(float(berm.depth_m), 0.0), 4),
}
# 노견 확장(139-6) — 넓힌 쪽만 되싣는다(사용자 값 · 다시 셀 때 입력)
for side, extend in (
@@ -217,11 +217,8 @@ class _SectionGeometry:
self.cut_ratio,
self.soil_cut_ratio,
boundary,
self.berm,
# 소단이 있으면 경계를 오갈 때마다 꺾는다 — 소단은 평탄한데 경계선은 지반을
# 따라 올라가서 되돌아 들어가는 일이 흔하다. 한 번만 꺾으면 그 구간을 암인데
# 토사 경사로 그려 절토가 조용히 커진다(2026-09-07).
multi_knee=self.berm is not None,
# 절토 소단 끔(178-2 · 소단 = 성토 사면 한 단) — 경계는 한 번만 꺾음(종전 규칙).
None,
)
self._cut_points_cache[side] = points
return points
@@ -235,7 +232,11 @@ class _SectionGeometry:
if side in self._fill_points_cache:
return self._fill_points_cache[side]
start_dist, start_z = self._slope_start(side)
points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm)
# 소단 깊이 기준 = 그쪽 노면 끝(길어깨 끝) 표고 — 측구가 그쪽이어도 같음(178-2).
edge_z = (
self.road_z(self.left_extent) if side == "left" else self.road_z(-self.right_extent)
)
points = fill_profile_points(start_dist, start_z, self.fill_ratio, self.berm, top_z=edge_z)
self._fill_points_cache[side] = points
return points
@@ -428,8 +429,8 @@ class _SectionGeometry:
"""적분·설계선에 반드시 포함할 설계 꼭짓점 오프셋 목록(2단계 무릎·소단 포함)."""
points = [0.0, self.left_extent, -self.right_extent]
points.extend(offset for offset, _z in self.ditch_points)
# 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다.
if self.two_stage or self.berm is not None:
# 절토 사면 꼭짓점(무릎) — 빠뜨리면 꺾임이 설계선에 안 실린다.
if self.two_stage:
for side in ("left", "right"):
role = self.left_role if side == "left" else self.right_role
if role != "cut":
+3
View File
@@ -15,6 +15,7 @@ from B06_Section.B06_Section_Engine_Design import (
stored_shoulder_extend,
)
from common_util.common_util_cross_berm import (
BERM_DEFAULT_DEPTH_M,
BERM_DEFAULT_INTERVAL_M,
BERM_DEFAULT_SLOPE_DEG,
BERM_DEFAULT_WIDTH_M,
@@ -456,6 +457,7 @@ def stored_berm(stored: dict[str, Any]) -> BermSpec | None:
width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)),
interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)),
slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)),
depth_m=max(float(spec.get("depth_m", BERM_DEFAULT_DEPTH_M)), 0.0),
)
except (TypeError, ValueError):
return None
@@ -500,6 +502,7 @@ def recompute_designs_for_alignment(
width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)),
interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)),
slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)),
depth_m=max(float(spec.get("depth_m", BERM_DEFAULT_DEPTH_M)), 0.0),
)
except (TypeError, ValueError, AttributeError):
continue
+1 -1
View File
@@ -329,7 +329,7 @@ class CrossDesignPreviewRequest(BaseModel):
# 측점별 암 경계 오프셋 세션값(chainage 키 → m). B06이 확정 전 세션에만 들고 있는
# 오프셋을 재계산에 반영하기 위한 값 — 없으면 DB 저장분을 쓴다.
rock_boundary_offsets: dict[str, float] | None = None
# 측점별 소단 제원(chainage 키 → {width_m, interval_m, slope_deg}). 위와 같은 성격으로,
# 측점별 소단 제원(chainage 키 → {width_m, interval_m, slope_deg, depth_m}). 위와 같은 성격으로,
# 사용자가 구간에 놓은 소단을 확정 전에도 재계산에 반영한다(계획서 3-9).
# 값이 없는 측점은 소단 없음 — 종전 설계 그대로다.
berms: dict[str, dict[str, float]] | None = None
+4 -2
View File
@@ -22,6 +22,7 @@ import type { BasinShape, WallAdjust, WallLayout } from "./B06_Section_UI_Cross_
import {
computeCardCulvert,
culvertLinkFor,
ownWallsDrawAt,
tierSpanOf,
} from "./B06_Section_UI_Cross_Culvert_Wire";
import type {
@@ -123,8 +124,9 @@ export function computeStoredLayouts(section: CrossSection, sections: readonly C
culvert,
);
// 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다.
const own =
!section.culvert && !link ? computeRevetmentLayout(section, design.revet_adjust?.own) : null;
const own = ownWallsDrawAt(section, link?.source)
? computeRevetmentLayout(section, design.revet_adjust?.own)
: null;
return { design, link, culvert, box, ford, own };
}
@@ -262,6 +262,15 @@ export function culvertWallsStandAt(owner: CrossSection, section: CrossSection):
return keys.some((key) => wallStandsAt(owner, section, key));
}
/**
* 독립 벽(사면안정 C군 · 옛 D경로)을 이 측점에 그리나 — 카드 · 서버 면적 · B07 · 3D 가
* 함께 쓰는 가드 한 벌(178-1). 관 세트가 붙은 측점이거나 연동 관(`owner`)의 벽이 이
* 측점에 실제로 서면 배관 경로가 그린다(겹침 방지) — 링크만 있고 벽이 안 서면 그린다.
*/
export function ownWallsDrawAt(section: CrossSection, owner: CrossSection | undefined): boolean {
return !section.culvert && !(owner && culvertWallsStandAt(owner, section));
}
export function culvertLinkFor(
section: CrossSection,
sections: readonly CrossSection[],
+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;
+47 -19
View File
@@ -9,7 +9,7 @@
* 아래 = B07 횡단 수량표(값 · 칸 배치 모두 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름).
* 공용 큰 창(`wide`) · 공용 막(밖 누르면 닫힘) · 스크롤 없음(150-2).
* 그림 = 공용 줌 · 팬(164-1 · 휠 = 커서 고정 줌 · 가운데 단추 팬 · 더블클릭 원복 · [+ − ⤢]) —
* 복제 자식을 g 하나로 감싸 축 · 눈금째 한 장으로 움직임.
* 카드처럼 도형 층만 움직이고 축 · 눈금은 제자리에서 보이는 범위로 다시 그림(179-3).
* 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤.
* ========================================================================== */
@@ -30,14 +30,19 @@ import {
type ZoomPanState,
} from "../A00_Common/b_svg_zoom_pan";
import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
/** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */
export interface CrossDetailHost {
projectId: string;
routeId: () => number | null;
detail: () => SectionDetailResponse | null;
/** 칸 크기(W × H px)로 새로 그린 카드 svg(173-4 · 뷰 `detailChart`). */
chart: (stationId: string, widthPx: number, heightPx: number) => SVGSVGElement | null;
/** 칸 크기(W × H px)로 새로 그린 카드 svg + 축 옵션(173-4 · 179-3 · 뷰 `detailChart`). */
chart: (
stationId: string,
widthPx: number,
heightPx: number,
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;
}
let host: CrossDetailHost | null = null;
@@ -121,17 +126,32 @@ export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HT
/* ── 그림 줌 · 팬(164-1) ─────────────────────────────────────────────── */
/** 복제 자식을 g 하나로 감싸 공용 줌 · 팬을 검(축 · 눈금째 한 장) · 휠 = 커서 고정 줌. */
/** 도형 층에만 공용 줌 · 팬(179-3 · 축 · 눈금 · 축선 · clip 제자리) · 휠 = 커서 고정 줌 ·
* 바뀔 때마다 축을 보이는 범위로 다시 그림 + 십자 크기 되돌림(카드 `_Cross_View_Frame` 꼴). */
export function attachDetailZoom(
clone: SVGSVGElement,
axes: CrossAxesOptions | null,
initial: ZoomPanState | undefined,
onChange: (state: ZoomPanState) => void,
): ZoomPanHandle {
const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number);
const layer = document.createElementNS("http://www.w3.org/2000/svg", "g");
layer.append(...clone.childNodes);
): ZoomPanHandle | null {
const layer = clone.querySelector<SVGGElement>("g.b06-chart__plot-layer");
if (!layer) return null;
// 칸 = viewBox(칸 크기로 그림 · 173-4) → 커서 고정 · 팬 속도 정확
clone.append(layer);
const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number);
const axisLayer = clone.querySelector<SVGGElement>("g.b06-chart__axes");
const renderAxes = axes && axisLayer ? createCrossAxes(axisLayer, axes) : null;
const marker = layer.querySelector<SVGGElement>("g.b06-chart__center");
const markerLine = marker?.querySelector("line");
const cx = Number(markerLine?.getAttribute("x1"));
const cy = Number(markerLine?.getAttribute("y1")) + 18;
const follow = (state: ZoomPanState): void => {
renderAxes?.(state);
marker?.setAttribute(
"transform",
`translate(${cx} ${cy}) scale(${1 / Math.max(state.scale, 1e-6)}) translate(${-cx} ${-cy})`,
);
};
follow(initial ?? IDENTITY_VIEW);
return attachSvgZoomPan({
svg: clone,
layer,
@@ -143,7 +163,10 @@ export function attachDetailZoom(
content: { x: -width, y: -height, width: 3 * width, height: 3 * height },
wheelZoom: true,
initial,
onChange,
onChange: (state) => {
follow(state);
onChange(state);
},
});
}
@@ -164,12 +187,16 @@ let tableTimer = 0;
let drawing = false;
/** 칸 크기로 새로 그림 → 복제(듣개 없음) · clip id 를 카드와 가름(같은 id 면 카드 clip 을 씀). */
function drawChart(section: CrossSection, widthPx: number, heightPx: number): SVGSVGElement | null {
function drawChart(
section: CrossSection,
widthPx: number,
heightPx: number,
): { clone: SVGSVGElement; axes: CrossAxesOptions | null } | null {
drawing = true;
try {
const svg = host?.chart(section.station_id, widthPx, heightPx);
if (!svg) return null;
const clone = svg.cloneNode(true) as SVGSVGElement;
const drawn = host?.chart(section.station_id, widthPx, heightPx);
if (!drawn) return null;
const clone = drawn.svg.cloneNode(true) as SVGSVGElement;
clone.querySelectorAll("clipPath[id]").forEach((clip) => {
const id = clip.id;
clip.id = `b06-detail-${id}`;
@@ -177,7 +204,7 @@ function drawChart(section: CrossSection, widthPx: number, heightPx: number): SV
.querySelectorAll(`[clip-path="url(#${id})"]`)
.forEach((el) => el.setAttribute("clip-path", `url(#${clip.id})`));
});
return clone;
return { clone, axes: drawn.axes };
} finally {
drawing = false;
}
@@ -246,11 +273,12 @@ export function openCrossDetail(section: CrossSection): void {
const size = `${width}x${height}`;
if (size !== drawnSize) zoom = undefined;
drawnSize = size;
const clone = drawChart(state.section, width, height);
if (!clone) cad.replaceChildren(document.createTextNode("그림 없음"));
const drawn = drawChart(state.section, width, height);
if (!drawn) cad.replaceChildren(document.createTextNode("그림 없음"));
else {
const handle = attachDetailZoom(clone, zoom, (next) => (zoom = next));
cad.replaceChildren(clone, buildZoomControls(handle));
const { clone, axes } = drawn;
const handle = attachDetailZoom(clone, axes, zoom, (next) => (zoom = next));
cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []));
}
showPicked();
},
@@ -8,7 +8,7 @@
* 쓰는 곳 = 흙막이 수평공간 뒤 · 박스 끝 · 세월교 측벽 끝 · ㄴ·ㄷ 집수정 끝 · 절토 쪽 배관 유출 벽 뒤.
* ========================================================================== */
import { bermSpec, cutProfilePoints } from "../common_util/common_util_cross_berm";
import { cutProfilePoints } from "../common_util/common_util_cross_berm";
import type { BermSpec } from "../common_util/common_util_cross_berm";
import type { CrossDesign } from "./B06_Section_Api_Fetch";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
@@ -27,12 +27,12 @@ export function structureCutSpec(design?: Partial<CrossDesign> | null): Structur
const offset = design?.rock_boundary_offset_m;
const twoStage =
design?.geometry_preset === "rock" && design.two_stage_slope === true && offset != null;
const berm = design?.berm;
return {
cutRatio,
soilRatio: Math.max(design?.soil_cut_slope_ratio ?? cutRatio, 0.1),
rockOffsetM: twoStage ? offset : null,
berm: berm ? bermSpec(berm.width_m, berm.interval_m, berm.slope_deg) : null,
// 절토 소단 끔 — 본선 `cutPoints` 와 같음(178-2 · 소단 = 성토 사면 한 단).
berm: null,
};
}
+2 -3
View File
@@ -20,7 +20,7 @@ import {
hideFordPavedRoad,
appendFordSurfaceDropPlan,
} from "./B06_Section_UI_Cross_Ford_Pavement";
import { culvertWallsStandAt } from "./B06_Section_UI_Cross_Culvert_Wire";
import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire";
import { raiseWalls } from "./B06_Section_UI_Cross_Wall";
import { attachChuteBandPick } from "./B06_Section_UI_Cross_Wall_Hatch";
import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill";
@@ -465,8 +465,7 @@ export function createCrossSectionCard(
// 옆에서 이어져 온**(culvertLink) 경우엔 배관 경로가 그린다 — 옛 D경로는 건너뛴다
// (둘 다 그리면 이웃 카드에 벽이 겹친다). 가드는 **그 링크의 벽이 이 카드에 실제로
// 설 때만** 막는다(2026-09-09 — 링크만 있어도 막으면 구조물이 안 보였다).
const linkedWallsHere = !!culvertLink && culvertWallsStandAt(culvertLink.source, section);
if (!section.culvert && !linkedWallsHere) {
if (ownWallsDrawAt(section, culvertLink?.source)) {
const ownAdjust = revetOffset?.adjustFor(section, "own");
const ownLayout = computeRevetmentLayout(section, ownAdjust);
ownDesignTrim = ownLayout?.designTrim;
+17 -2
View File
@@ -19,6 +19,7 @@ import type { DesignViewSave } from "../A00_Common/design/design_shared";
import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches";
import { readByKey, readState, writeByKey, writeState } from "../A00_Common/b_page_state";
import {
BERM_DEFAULT_DEPTH_M,
BERM_DEFAULT_INTERVAL_M,
BERM_DEFAULT_SLOPE_DEG,
BERM_DEFAULT_WIDTH_M,
@@ -59,6 +60,8 @@ export interface BermSessionSpec {
width_m: number;
interval_m: number;
slope_deg: number;
/** 노면 끝 아래 깊이(m · ≥ 0 · 178-2). */
depth_m: number;
}
/**
@@ -122,13 +125,20 @@ export function bermSpansFromStructures(
};
const width = number("width_m", BERM_DEFAULT_WIDTH_M);
const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M);
if (width <= 0 || interval <= 0) continue; // 폭·간격이 0이면 계단이 없다
if (width <= 0) continue; // 폭이 0이면 계단이 없다(간격은 성토 한 단에 안 씀 · 178-2)
// 깊이 = 노면 끝 아래 · 음수는 0(도로보다 높을 수 없음) · 빈 값은 기본 1.0
const rawDepth = Number(options.depth_m);
const depth =
options.depth_m === undefined || options.depth_m === "" || !Number.isFinite(rawDepth)
? BERM_DEFAULT_DEPTH_M
: Math.max(rawDepth, 0);
spans.push({
start_m: Math.min(start, end),
end_m: Math.max(start, end),
width_m: width,
interval_m: interval,
slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG),
depth_m: depth,
});
}
return spans;
@@ -142,7 +152,12 @@ export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpe
chainageM <= Math.max(span.start_m, span.end_m) + 1e-6,
);
return found
? { width_m: found.width_m, interval_m: found.interval_m, slope_deg: found.slope_deg }
? {
width_m: found.width_m,
interval_m: found.interval_m,
slope_deg: found.slope_deg,
depth_m: found.depth_m ?? BERM_DEFAULT_DEPTH_M,
}
: null;
}
+17 -2
View File
@@ -37,6 +37,7 @@ import {
import {
type CrossPlotBase,
crossPlotBase,
crossPlotFromBase,
effectiveCardHalfWidth,
fitPlotBase,
naturalHeightAtWidth,
@@ -561,9 +562,23 @@ export function createSectionView(
// 카드 줌 · 팬은 카드 몫 — 모달 그림은 1배에서 시작(카드 상태는 되돌려 둠)
const cardZoom = cardZoomStates.get(stationId);
cardZoomStates.delete(stationId);
const card = buildCrossCard(section, heightPx, base && fitPlotBase(base, heightPx), widthPx);
const fitted = base && fitPlotBase(base, heightPx);
const card = buildCrossCard(section, heightPx, fitted, widthPx);
if (cardZoom) cardZoomStates.set(stationId, cardZoom);
return card.querySelector<SVGSVGElement>("svg.b06-section__chart");
const svg = card.querySelector<SVGSVGElement>("svg.b06-section__chart");
if (!svg) return null;
// 축 다시 그리기 옵션(179-3) — 카드 `createCrossAxes` 와 같은 기하
const plot = fitted && crossPlotFromBase(fitted, heightPx);
const axes = plot && {
widthPx,
heightPx,
pixelsPerMeter: plot.pixelsPerMeter,
maxOffset: plot.maxOffset,
displayMax: plot.displayMax,
elevationMid: plot.elevationMid,
exaggeration: Math.max(currentExaggeration, 0.1),
};
return { svg, axes };
},
focusStation(stationId) {
if (stationId === null) clearSelection();
@@ -11,6 +11,7 @@ import type {
} from "./B06_Section_Api_Fetch";
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
/**
* 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**.
@@ -37,8 +38,13 @@ export interface SectionViewController {
refreshCard: (chainageM: number) => void;
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
refreshCards: (chainages: ReadonlyArray<number>) => void;
/** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null). */
detailChart: (stationId: string, widthPx: number, heightPx: number) => SVGSVGElement | null;
/** [상세] 모달 그림(173-4) — 카드 그리기 함수로 칸 크기(W × H px)에 맞춰 새로 그린 svg(없으면 null)
* · 축 다시 그리기 옵션(179-3 · 기하 없으면 null). */
detailChart: (
stationId: string,
widthPx: number,
heightPx: number,
) => { svg: SVGSVGElement; axes: CrossAxesOptions | null } | null;
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */
focusStation: (stationId: string | null) => 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;
}
@@ -54,9 +54,11 @@
}
.b06-detail__qtable {
/* 이름 칸 바탕 = 데이터 표 머리색(179-3 · `ui_template_data_table` --dt-head) */
--b06-detail-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface));
width: 100%;
border-collapse: collapse;
font-size: var(--text-caption);
font-size: var(--text-body-sm);
font-variant-numeric: tabular-nums;
}
@@ -70,7 +72,8 @@
.b06-detail__qtable th {
font-weight: 500;
color: var(--color-text-secondary);
background: var(--b06-detail-head);
color: var(--color-text);
}
.b06-detail__qtable tr:first-child > th {
@@ -100,10 +103,18 @@
/* 그림에 대상이 없는 칸(암석 띠 없음 · 면적 0 · 사토장 없음) — 회색 · 누름 없음 */
.b06-detail__qtable [data-key][data-empty] {
cursor: default;
background: none;
color: var(--color-text-muted);
}
/* 빈 칸 = 값 칸만 바탕 지움 · 이름 칸은 머리색 그대로(글만 회색) */
.b06-detail__qtable td[data-key][data-empty] {
background: none;
}
.b06-detail__qtable th[data-key][data-empty] {
background: var(--b06-detail-head);
}
/* 그림 강조 한 벌 = 붉은 계통(164-2 · 카드 색은 그대로) — 띠 · 판정 선 · 중심 십자 · 사토장 */
.b06-detail__cad .b06-chart__area.is-active > polygon {
fill: color-mix(in srgb, var(--color-danger) 35%, transparent);
@@ -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);
@@ -0,0 +1,44 @@
/* =============================================================================
* B07_DesignDetail_UI_GuideToggle.ts
* 횡단 장 안내선 보이기 / 숨기기 단추(PLAN 180-3) — 좌측 「횡단도」 묶음 배치 칸 줄에 붙음.
*
* 끄는 것 = 화면 전용 안내선 둘(붉은 넘침 칸 틀 + 파란 점선 작도 영역) · 인쇄되는 칸선은 그대로
* 상태 = 이 화면을 쓰는 동안만(저장 안 함) · 장을 바꾸거나 화면을 다시 세워도 이어짐(모듈 값)
* 숨김이면 「n 칸 넘침」 토스트도 안 띄움 · CAD 에는 `cad.setGuideVisible` 로 알림
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
/** 안내선 보임 — 모듈 값(화면 다시 세워도 유지 · 새로 고침하면 보임으로) */
let shown = true;
export const guideShown = (): boolean => shown;
const labelOf = (visible: boolean): string => (visible ? "안내선 숨기기" : "안내선 보이기");
/**
* 단추 하나 — 누르면 보임 ↔ 숨김 · `apply(보임)` 로 CAD 에 알림.
* 장이 아닌 도면이면 꺼짐 — 부르는 쪽이 `button.disabled` 로 정함.
*/
export function buildGuideToggle(apply: (visible: boolean) => void): HTMLButtonElement {
const button = createButton({
label: labelOf(shown),
variant: "ghost",
size: "sm",
onClick: () => {
shown = !shown;
paint();
apply(shown);
},
});
const paint = (): void => {
const label = button.children[button.children.length - 1];
if (label) label.textContent = labelOf(shown);
button.setAttribute("aria-pressed", String(!shown));
button.title = shown
? "점선 작도 영역 · 붉은 넘침 틀을 화면에서 숨김(인쇄 칸선은 그대로)"
: "숨긴 점선 작도 영역 · 붉은 넘침 틀을 다시 보임";
};
paint();
return button;
}
+31 -9
View File
@@ -53,6 +53,7 @@ import {
type QuantityTable,
} from "./B07_DesignDetail_Api_Fetch";
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
import { buildGuideToggle, guideShown } from "./B07_DesignDetail_UI_GuideToggle";
// CAD iframe 부모 흐름 · 도각 편집은 M02 와 한 벌로 공용에 둔다(PLAN 10-3).
import { createCadHost, type CadSaveResult } from "@ui/cad_host/cad_host";
import { createFrameTemplateEditor } from "@ui/cad_host/cad_host_frame_edit";
@@ -125,6 +126,10 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
onGuideChange: (area) => frameEditor.guideChanged(area),
});
// 180-3 안내선 보이기 / 숨기기 — 숨김은 화면을 다시 세워도 이어 줌(`…_UI_GuideToggle` 모듈 값)
if (!guideShown()) cad.setGuideVisible(false);
const guideToggle = buildGuideToggle((visible) => cad.setGuideVisible(visible));
let currentDrawing: DesignDrawingItem | undefined;
let currentIndex = -1;
let currentConfirmed = false;
@@ -154,10 +159,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
/** 장 배치 칸 자리(153-1) — 횡단도 묶음 맨 아래 · 정보 칸을 다시 그려도 남음. */
const crossLayoutSlot = document.createElement("div");
crossLayoutSlot.className = "b07-cross-layout-slot";
// 배치 칸을 못 받아도 안내선 단추는 남음 · 받으면 같은 줄 끝에 붙음
const guideOnly = document.createElement("div");
guideOnly.className = "b07-cross-layout";
guideOnly.append(guideToggle);
crossLayoutSlot.append(guideOnly);
let crossLayoutRequest: Promise<CrossLayout> | undefined;
/** 장 배치 칸(140-3) — 적용 = 서버 쓰기 → 목록 다시 · 같은 장 번호를 다시 엶. 장 아니면 기본값 · [이 장] 꺼짐. */
const fillCrossLayout = async (drawing: DesignDrawingItem) => {
guideToggle.disabled = !isCrossSheet(drawing); // 장의 안내선만
if (!projectId) return;
let layout: CrossLayout;
try {
@@ -185,15 +196,21 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
hideLoadingOverlay();
}
};
crossLayoutSlot.replaceChildren(
buildCrossLayoutControls(
(number && layout.sheets[String(number)]) || layout.default,
number
? (grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } })
: null,
(grid) => void save({ default: grid, sheets: {} }),
),
const controls = buildCrossLayoutControls(
(number && layout.sheets[String(number)]) || layout.default,
number
? (grid) => void save({ ...layout, sheets: { ...layout.sheets, [String(number)]: grid } })
: null,
(grid) => void save({ ...layout, default: grid, sheets: {} }),
{
scale: {
value: layout.table_scale ?? 1,
onApply: (scale) => void save({ ...layout, table_scale: scale }),
},
},
);
controls.append(guideToggle);
crossLayoutSlot.replaceChildren(controls);
};
/**
@@ -335,7 +352,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const guide = response.guide ?? null;
const meta = buildMeta(drawing, response, index);
cad.load(stash ?? response.drawing, meta, false, {}, { guide });
if (guide?.marks?.length) {
if (guideShown() && guide?.marks?.length) {
showToast(`${guide.marks.length} 칸 넘침 — 열 · 행을 줄일 것`, "warning");
}
if (response.drawing.stale_layout) {
@@ -343,6 +360,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"
},
@@ -35,7 +35,7 @@ import { runCommandInput } from '../commands/run-command.ts';
import { setRecoveryScope } from '../helpers/autosave.ts';
import { registerBoxResizeDrag } from './box-resize-drag.ts';
import { handleTableMessage } from './aislo-table-bridge.ts';
import { registerGuideDrag, setGuide, setGuideArea } from './guide-area.ts';
import { registerGuideDrag, setGuide, setGuideArea, setGuideVisible } from './guide-area.ts';
export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing';
export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready';
@@ -50,6 +50,7 @@ export const AISLO_DRAWING_HOST_SAVE_MESSAGE = 'aislo:b08:host-save';
/** 작도 영역 가이드(169-1 · 169-3) — 부모 숫자 칸 → CAD 갈기 · CAD 끌기 → 부모 숫자 칸 */
export const AISLO_GUIDE_AREA_MESSAGE = 'aislo:b08:guide-area';
export const AISLO_GUIDE_CHANGED_MESSAGE = 'aislo:b08:guide-changed';
export const AISLO_GUIDE_VISIBLE_MESSAGE = 'aislo:b08:guide-visible';
interface DrawingLoadMessage {
type: typeof AISLO_DRAWING_LOAD_MESSAGE;
@@ -201,8 +202,10 @@ export function registerAisloDrawingBridge() {
window.addEventListener('message', async (event: MessageEvent<unknown>) => {
if (event.origin !== window.location.origin || event.source !== window.parent) return;
if (await handleTableMessage(event.data, notifyParent)) return; // 표 놓기 · 갈기(M02 41장)
const guideMessage = event.data as { type?: string; area?: unknown };
const guideMessage = event.data as { type?: string; area?: unknown; visible?: unknown };
if (guideMessage?.type === AISLO_GUIDE_AREA_MESSAGE) return setGuideArea(guideMessage.area);
if (guideMessage?.type === AISLO_GUIDE_VISIBLE_MESSAGE)
return setGuideVisible(guideMessage.visible);
const message = event.data as Partial<DrawingSaveRequestMessage>;
if (message.type === AISLO_DRAWING_SAVE_REQUEST_MESSAGE) {
try {
@@ -1,13 +1,17 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import {
GUIDE_MIN_MM,
type GuideArea,
dragGuideArea,
drawGuide,
getGuide,
isGuideVisible,
pickGuideHandle,
setGuide,
setGuideArea,
setGuideVisible,
} from './guide-area.ts';
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts';
const AREA: GuideArea = [42, 47, 812, 567];
@@ -40,4 +44,39 @@ describe('작도 영역 가이드(169-1 · 169-3)', () => {
expect(dragGuideArea(AREA, 's', { x: 0, y: 9999 })).toEqual([42, 567 - GUIDE_MIN_MM, 812, 567]);
expect(dragGuideArea(AREA, 'e', { x: -100, y: 0 })).toEqual([42, 47, 42 + GUIDE_MIN_MM, 567]);
});
it('안내선 보이기 / 숨기기(180-3) — 숨김이면 점선 영역 · 붉은 틀 안 그림 · 싣기를 거쳐도 이어짐', () => {
const lines = vi.fn();
const controller = {
setOpacity: vi.fn(),
setLineStyles: vi.fn(),
worldToTarget: (point: unknown) => point,
drawLineScreen: lines,
fillRectScreen: vi.fn(),
} as unknown as ScreenCanvasDrawController;
const guide = { area: AREA, marks: [[0, 0, 5, 5] as GuideArea], editable: false };
setGuide(guide);
expect(isGuideVisible()).toBe(true); // 기본 = 보임
drawGuide(controller);
expect(lines).toHaveBeenCalledTimes(8); // 점선 영역 4 변 + 붉은 틀 4 변
setGuideVisible(false);
lines.mockClear();
drawGuide(controller);
expect(lines).not.toHaveBeenCalled();
setGuide(guide); // 장을 다시 실어도 숨김 유지
setGuideArea([0, 0, 100, 50]);
drawGuide(controller);
expect(isGuideVisible()).toBe(false);
expect(lines).not.toHaveBeenCalled();
expect(getGuide()?.marks).toHaveLength(1); // 값은 그대로 — 그리기만 거름
setGuideVisible(true);
drawGuide(controller);
expect(lines).toHaveBeenCalledTimes(8);
setGuideVisible('x'); // 어긋난 값 = 보임
expect(isGuideVisible()).toBe(true);
setGuide(null);
});
});
@@ -7,7 +7,8 @@ import { getCanvas, getScreenCanvasDrawController } from '../state.ts';
*
* 도면 개체가 아니라 그리기 겹판(기준점 십자와 같은 자리)이라 저장 · DXF · 인쇄에 안 섞인다.
* `editable` 이면 모서리 · 변을 끌어 영역을 고치고, 고친 값은 부모(숫자 칸)에 알린다.
* 부모 짝 = `ui_template/cad_host/cad_host.ts` `guide` 곁값 · `setGuideArea` · `onGuideChange`.
* 부모 짝 = `ui_template/cad_host/cad_host.ts` `guide` 곁값 · `setGuideArea` · `onGuideChange` ·
* `setGuideVisible`(180-3 — 점선 영역 + 붉은 넘침 틀 보이기 / 숨기기 · 도각 칸선은 도면 개체라 그대로).
*/
/** [왼쪽 x, 아래 y, 오른쪽 x, 위 y] — 도면 좌표 mm */
@@ -32,6 +33,8 @@ const GRAB_PIXELS = 9;
export const GUIDE_MIN_MM = 10;
let guide: Guide | null = null;
/** 안내선(점선 영역 + 붉은 틀) 보임 — 싣기 때 안 지움 · 숨김이면 그리지도 · 집지도 않음 */
let guideVisible = true;
const isArea = (value: unknown): value is GuideArea =>
Array.isArray(value) &&
@@ -42,6 +45,13 @@ const isArea = (value: unknown): value is GuideArea =>
export const getGuide = (): Guide | null => guide;
export const isGuideVisible = (): boolean => guideVisible;
/** 부모 단추(180-3) — `true` 만 보임 · 그 밖 값은 보임으로 */
export function setGuideVisible(visible: unknown): void {
guideVisible = visible !== false;
}
/** 싣기 때 — 어긋난 값이면 가이드 없음 */
export function setGuide(next: unknown): void {
const candidate = next as Partial<Guide> | null | undefined;
@@ -103,7 +113,7 @@ function strokeRect(drawController: ScreenCanvasDrawController, area: GuideArea)
/** 그리기 겹판 — 엔티티 장면 위에 매 장 그림(`helpers/draw.ts`) */
export function drawGuide(drawController: ScreenCanvasDrawController): void {
if (!guide) return;
if (!guide || !guideVisible) return;
drawController.setOpacity(0.9);
drawController.setLineStyles(false, false, GUIDE_COLOR, 1, [8, 5]);
strokeRect(drawController, guide.area);
@@ -142,7 +152,7 @@ export function registerGuideDrag(notify: (area: GuideArea) => void): void {
window.addEventListener(
'mousedown',
(event: MouseEvent) => {
if (event.button !== 0 || event.target !== canvas || !guide?.editable) return;
if (event.button !== 0 || event.target !== canvas || !guide?.editable || !guideVisible) return;
const world = worldAt(event);
if (!world) return;
const tolerance = GRAB_PIXELS / getScreenCanvasDrawController().getScreenScale();
+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 -8
View File
@@ -13,8 +13,11 @@
· 견적 = 사용자 손입력(quote · quote_page · 견적서 파일은 `B09_Estimation_Router_Quotes`)
· 관급 · 별도계상 = 사용자 표시(없으면 입력 줄 기본 · B08 파형강관 = 관급)
→ 재료비목록표 단가 = 적용 단가
재료비목록표 = 수량(B08 총괄자재수량집계표 합계 열) × 적용 단가 = 총액 ROUND(·, 0)
(엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸)
재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0)
(엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸) · 수량 =
입력 `material_qty`(183-1 · 서버 1차 셈 역전개 총수량 = 재료비수량금액집계표 수량 ·
없는 키 빈칸) ·
`material_qty` 가 없으면 입력 줄 수량(B08 총괄 합계 열)
중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임)
+ 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100)
줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표
@@ -178,14 +181,18 @@ def compare_sheet(
return rows
def material_sheet(compare: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""재료비목록표 — 단가 = 대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0)."""
keep = ("no", "key", "name", "spec", "unit", "supply", "origin", "qty")
def material_sheet(
compare: list[dict[str, Any]], qty_of: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
"""재료비목록표 — 단가 = 대비표 적용 단가 · 수량 = `qty_of`(183-1) ?? 줄 수량 ·
총액 = ROUND(수량 × 단가, 0)."""
keep = ("no", "key", "name", "spec", "unit", "supply", "origin")
rows = []
for row in compare:
price, q = row["applied"], row["qty"]
price = row["applied"]
q = row["qty"] if qty_of is None else num(qty_of.get(row["key"]))
amount = None if price is None or q is None else round_half(q * price, 0)
rows.append({**{k: row[k] for k in keep}, "price": price, "amount": amount})
rows.append({**{k: row[k] for k in keep}, "qty": q, "price": price, "amount": amount})
return rows
@@ -328,7 +335,7 @@ def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]
"expense": expense,
"basis": basis,
"compare": compare,
"material": material_sheet(compare),
"material": material_sheet(compare, inputs.get("material_qty")),
"machine_blocks": blocks,
"machine": machine,
"gaps": gaps,
+7 -5
View File
@@ -9,7 +9,8 @@
* 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0)
* 자재단가대비표 = 출처 중 최저 자동(견적 포함) · 적용 = 고른 출처 › 사용자 단가 › 최저 ·
* 관급 · 별도 표시 → 재료비목록표
* 재료비목록표 = 수량(B08 총괄 합계 열) × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸
* 재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸 · 수량 = 입력
* material_qty(183-1 · 서버 역전개 총수량 · 없는 키 빈칸) ?? 입력 줄 수량(B08 총괄 합계 열)
* 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표
* 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김
* ========================================================================== */
@@ -157,13 +158,14 @@ function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] {
});
}
function materialSheet(compare: Row[]): Row[] {
const keep = ["no", "key", "name", "spec", "unit", "supply", "origin", "qty"];
function materialSheet(compare: Row[], qtyOf?: Row | null): Row[] {
const keep = ["no", "key", "name", "spec", "unit", "supply", "origin"];
return compare.map((row) => {
const price: Num = row.applied;
const q: Num = row.qty;
const q: Num = qtyOf == null ? row.qty : num(qtyOf[row.key]);
return {
...Object.fromEntries(keep.map((k) => [k, row[k]])),
qty: q,
price,
amount: price === null || q === null ? null : roundHalf(q * price, 0),
};
@@ -301,7 +303,7 @@ export function computeBase(inputs: Row, user: Row): Row {
expense,
basis,
compare,
material: materialSheet(compare),
material: materialSheet(compare, inputs.material_qty),
machine_blocks: blocks,
machine,
gaps,
+70 -3
View File
@@ -1,7 +1,7 @@
"""B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
⚠ 짝 = `B09_Estimation_Calc_Bill.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
거울 시험 `resources/tester/test_145_3_b09_bill.py`.
거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨).
실무 엑셀 설계내역서 꼴 · 공종 나무 = 엑셀 꼴 고정(`SECTIONS` · 공구 안 나눔):
1 토공사(1.1~1.6) · 2 구조물공(2.1~2.3) · 3 운반공 · 4 자재대 · 관급자재대(제외금액)
@@ -18,6 +18,9 @@
단가 = `ref_price(ref)` 하나 — dg = 일위대가목록표 · sg = 단가산출근거목록표 ·
logic = M01 로직 새 산근(서버가 `sg_hand{줄 키: 산근 키}` 로 줌 · 그전엔 빈칸 「[저장] 뒤」) ·
그 밖 = `inputs.hopyo` · 비고 = 「대가n」 · 「산근 n호표」(155-3 꼴).
레벨(182) = 손 줄 `kind: "group"`(묶음 줄 · 수량 · 연결 없음) · `parent`(묶음 손 줄 키 · 없음 = 공종 바로
아래) · 줄마다 `level`(1 = 1 토공사 · 2 = 1.1 · 3 = 그 안 항목 · 손 묶음 + 1) ·
묶음 금액 = 자손 항목 합 · 공종 합 · `sums` = 항목만(묶음 줄 안 셈 · 원가계산서 그대로).
원가계산서로 넘길 합 = `sums`(직접재료비 · 직접노무비 · 산출경비 · 관급 순자재대 — 145-4 이음 꼴 ·
빈 줄이 있으면 None) · 화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료).
"""
@@ -153,6 +156,62 @@ def hand_items(user: dict[str, Any], books: dict[str, Any], hopyo: dict) -> list
return out
def _group_row(item: dict[str, Any]) -> dict[str, Any]:
"""묶음 손 줄(182) — 이름만 · 금액 = 자손 항목 합(`_nest`)."""
return {
"kind": "group",
"key": item["key"],
"section": item["section"],
"name": item.get("name") or "",
"spec": item.get("spec") or "",
"unit": item.get("unit") or "",
"qty": None,
"note": "",
"ref": None,
"hand": True,
"sum": 0.0,
"labor": 0.0,
"material": 0.0,
"expense": 0.0,
"blank": 0,
}
def _nest(rows: list[dict[str, Any]]) -> None:
"""182 레벨 — 부모 = 같은 공종 묶음 손 줄(못 찾거나 돌면 공종 바로 아래) · level · 묶음 금액."""
groups = {row["key"]: row for row in rows if row["kind"] == "group"}
for row in rows:
if not row.get("hand"):
continue
up = groups.get(row.get("parent") or "")
if up is None or up["section"] != row["section"]:
row["parent"] = None
continue
at, n = up, 0
while at is not None and n <= len(groups):
if at["key"] == row["key"]:
row["parent"] = None
break
at, n = groups.get(at.get("parent") or ""), n + 1
for row in rows:
chain = []
at = groups.get(row.get("parent") or "")
while at is not None:
chain.append(at)
at = groups.get(at.get("parent") or "")
depth = 2 if next(p for c, _n, p in SECTIONS if c == row["section"]) else 1
row["level"] = depth if row["kind"] == "title" else depth + 1 + len(chain)
if row["kind"] != "item":
continue
for g in chain:
for p in ("sum", *PARTS):
g[p] += row[p] or 0
if row["sum"] is None:
g["blank"] += 1
for g in groups.values():
g["note"] = f"빈 줄 {g['blank']}" if g["blank"] else ""
def _place(items: list[dict[str, Any]], hand: dict[str, Any], after: str) -> None:
"""손 줄 끼우기 — `after` 줄 바로 뒤 · 제목(`sec:`) = 그 공종 첫 자리 · 못 찾으면 끝."""
at = next((n + 1 for n, row in enumerate(items) if row["key"] == after), None)
@@ -213,8 +272,13 @@ def compute_bill(
prices = None if price is None else {"labor": 0, "material": price, "expense": 0}
items.append(_item_row(item, prices, gaps, qty_of))
for item, prices, why in hand_items(user, books, hopyo):
_place(items, _item_row({**item, "why": why}, prices, gaps), item.get("after") or "")
owner = [row for row in items if row["section"] == "G"]
if item.get("kind") == "group":
row = _group_row(item)
else:
row = _item_row({**item, "why": why}, prices, gaps)
row["parent"] = item.get("parent")
_place(items, row, item.get("after") or "")
owner = [row for row in items if row["section"] == "G" and row["kind"] == "item"]
net = 0.0
for row in owner:
net += row["material"] or 0
@@ -248,6 +312,8 @@ def compute_bill(
for code, name, parent in SECTIONS:
sums[code] = {"sum": 0.0, "labor": 0.0, "material": 0.0, "expense": 0.0, "blank": 0}
for row in items:
if row["kind"] != "item":
continue
code = row["section"]
chain = [code]
parent = next(p for c, _n, p in SECTIONS if c == code)
@@ -276,6 +342,7 @@ def compute_bill(
}
)
rows.extend(row for row in items if row["section"] == code)
_nest(rows)
direct = {p: 0.0 for p in ("sum", *PARTS)}
blank = 0
for code in DIRECT:
+68 -4
View File
@@ -3,7 +3,7 @@
* B09 설계내역서 셈(PLAN 145-3) — 화면 없음 · 저장 안 함 · import 는 짝 바탕 셈 하나(시험이 이어 붙임).
*
* ⚠ 짝 = `B09_Estimation_Calc_Bill.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
* 거울 시험 `resources/tester/test_145_3_b09_bill.py`.
* 거울 시험 `resources/tester/test_145_3_b09_bill.py` · `test_182_b09_bill_level.py`(레벨).
*
* 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔
* 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료
@@ -11,6 +11,9 @@
* 제목 줄 = 아래 항목 금액 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(빈 줄 있으면 null)
* 손질(166-2) = `user.bill` — qty{줄 키: 수} 자동 줄 수량 덮어쓰기 · rows[] 손 줄(after 뒤에 끼움) ·
* 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근 · 그 밖 hopyo)
* 레벨(182) = 손 줄 `kind: "group"`(묶음 줄 · 수량 · 연결 없음) · `parent`(묶음 손 줄 키 · 없음 = 공종 바로 아래) ·
* 줄마다 `level`(1 = 1 토공사 · 2 = 1.1 · 3 = 그 안 항목 · 손 묶음 + 1) · 묶음 금액 = 자손 항목 합 ·
* 공종 합 · sums = 항목만(묶음 줄 안 셈 · 원가계산서 그대로)
* ========================================================================== */
import {
@@ -143,6 +146,62 @@ export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row |
return out;
}
/** 묶음 손 줄(182) — 이름만 · 금액 = 자손 항목 합(`nest`) */
function groupRow(item: Row): Row {
return {
kind: "group",
key: item.key,
section: item.section,
name: item.name || "",
spec: item.spec || "",
unit: item.unit || "",
qty: null,
note: "",
ref: null,
hand: true,
sum: 0,
labor: 0,
material: 0,
expense: 0,
blank: 0,
};
}
/** 182 레벨 — 부모 = 같은 공종 묶음 손 줄(못 찾거나 돌면 공종 바로 아래) · level · 묶음 금액 */
function nest(rows: Row[]): void {
const groups = new Map<string, Row>();
for (const row of rows) if (row.kind === "group") groups.set(row.key, row);
for (const row of rows) {
if (!row.hand) continue;
const up = groups.get(row.parent ?? "");
if (!up || up.section !== row.section) {
row.parent = null;
continue;
}
let at: Row | undefined = up;
for (let n = 0; at && n <= groups.size; n += 1) {
if (at.key === row.key) {
row.parent = null;
break;
}
at = groups.get(at.parent ?? "");
}
}
for (const row of rows) {
const chain: Row[] = [];
for (let at = groups.get(row.parent ?? ""); at; at = groups.get(at.parent ?? ""))
chain.push(at);
const depth = SECTIONS.find(([c]) => c === row.section)![2] ? 2 : 1;
row.level = row.kind === "title" ? depth : depth + 1 + chain.length;
if (row.kind !== "item") continue;
for (const g of chain) {
for (const p of ["sum", ...PARTS]) g[p] += row[p] || 0;
if (row.sum === null) g.blank += 1;
}
}
for (const g of groups.values()) g.note = g.blank ? `빈 줄 ${g.blank}` : "";
}
/** 손 줄 끼우기 — after 줄 바로 뒤 · 제목(sec:) = 그 공종 첫 자리 · 못 찾으면 끝 */
function place(items: Row[], hand: Row, after: string): void {
let at = items.findIndex((row) => row.key === after);
@@ -204,9 +263,12 @@ export function computeBill(
const prices = price === null ? null : { labor: 0, material: price, expense: 0 };
items.push(itemRow(item, prices, gaps, qtyOf));
}
for (const [item, prices, why] of handItems(user, book, hopyo))
place(items, itemRow({ ...item, why }, prices, gaps), item.after || "");
const owner = items.filter((row) => row.section === "G");
for (const [item, prices, why] of handItems(user, book, hopyo)) {
const row = item.kind === "group" ? groupRow(item) : itemRow({ ...item, why }, prices, gaps);
row.parent = item.parent ?? null;
place(items, row, item.after || "");
}
const owner = items.filter((row) => row.section === "G" && row.kind === "item");
let net = 0;
for (const row of owner) net += row.material || 0;
const pct = num((user.estimate || {}).procurement_fee_pct);
@@ -238,6 +300,7 @@ export function computeBill(
for (const [code] of SECTIONS)
sums[code] = { sum: 0, labor: 0, material: 0, expense: 0, blank: 0 };
for (const row of items) {
if (row.kind !== "item") continue;
const code = row.section;
const chain = [code];
const parent = SECTIONS.find(([c]) => c === code)![2];
@@ -264,6 +327,7 @@ export function computeBill(
});
rows.push(...items.filter((row) => row.section === code));
}
nest(rows);
const direct: Row = { sum: 0, labor: 0, material: 0, expense: 0 };
let blank = 0;
for (const code of DIRECT) {
+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()}
+7 -1
View File
@@ -3,7 +3,9 @@
연결된 사본 = `M02_Item_Snapshot.linked_snapshots`(지정 ∩ 같은 항목 사본 · B08 · B09 공용).
사본마다 `일위대가.json` 담은 로직을 그 사본 단가 묶음(`단가.json`) · 입력값 · 고른자재로 돌리며
실제로 부른 자원 줄(인력 LB · 기계 EQ · 자재 MT · 유가 MO)만 모음 — 단가 묶음 통째
(고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음).
(고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음) ·
사본 일위대가 자재 · 굳힌 사전조합 자재 코드도(183-1 · 로직을 안 돌아 `get` 에 안 걸림).
고르기가 훑은 후보 줄도 걸림 → 원가 길이 두 번 셈해 쓰는 줄만 남김(`Summary.used_materials`).
구조물 조합 사본 없으면 그 구조물은 없음 · 일위대가 없는 사본은 자원 없음.
"""
@@ -57,6 +59,10 @@ def used_keys(folder: Path) -> list[str]:
labor_doc.run_line(
snapshot, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름")
)
for mat in [*(doc.get("자재") or []), *(m for d in inner for m in d.get("자재") or [])]:
code = str(mat.get("코드") or "") if isinstance(mat, dict) else ""
if code[:2] in RESOURCE_TABLES and code not in seen:
seen.append(code)
return seen
+16 -3
View File
@@ -37,7 +37,7 @@ from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
from B09_Estimation.B09_Estimation_Linked import linked_resource_keys
from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table
from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines
from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
@@ -81,6 +81,19 @@ def compute_sheets(root: Path, inputs: dict[str, Any], user: dict[str, Any]) ->
return sheets
def compute_twice(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> tuple[dict, dict]:
"""183-1 두 번 셈 — 1차 셈으로 쓰는 자재만 남기고(`used_materials`) 재료비목록표 수량 =
1차 역전개 총수량(`material_qty` = 재료비수량금액집계표 수량) → 2차 셈 · 집계표.
`inputs` 를 고쳐 둠(화면 짝이 거른 입력 · 수량을 그대로 받음)."""
first = compute_sheets(root, inputs, user)
summary = compute_summary(first, hopyo_lines(first))
keep = used_materials(first, inputs, user)
inputs["materials"] = [m for m in inputs.get("materials") or [] if m["key"] in keep]
inputs["material_qty"] = {row["key"]: row["qty"] for row in summary["sum_material"]}
sheets = compute_sheets(root, inputs, user)
return sheets, compute_summary(sheets, hopyo_lines(sheets))
async def _demand(root: Path) -> list[str]:
"""단가 수요 키 — 연결된 사본 + 설계내역서 손 줄 로직(166-2)."""
keys = await asyncio.to_thread(linked_resource_keys, root)
@@ -103,7 +116,7 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
inputs["hopyo"] = hopyo_prices(root)
inputs["daega"] = await asyncio.to_thread(daega_inputs, root)
user = await asyncio.to_thread(store.read_user, root)
sheets = await asyncio.to_thread(compute_sheets, root, inputs, user)
sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, user)
return {
"inputs": inputs,
"user": user,
@@ -113,7 +126,7 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
"rate_table": rate_table(),
"notes": notes,
# 집계표 넷 = 서버 단독(145-5) — 짝 셈 결과를 역전개 · 조작 중엔 안 바뀜
"summary": compute_summary(sheets, hopyo_lines(sheets)),
"summary": summary,
}
+8 -3
View File
@@ -109,7 +109,8 @@ def _clean_ref(ref: Any) -> dict[str, Any] | None:
def clean_bill(raw: Any) -> dict[str, Any]:
"""설계내역서 손질(166-2) — 수량 덮어쓰기(수만) · 손 줄(`hand:` 키 · 아는 공종만)."""
"""설계내역서 손질(166-2) — 수량 덮어쓰기(수만) · 손 줄(`hand:` 키 · 아는 공종만) ·
182 레벨 = `kind`(group) · `parent`(`hand:` 키)."""
raw = raw if isinstance(raw, dict) else {}
qty = raw.get("qty") if isinstance(raw.get("qty"), dict) else {}
codes = {code for code, _name, _parent in SECTIONS}
@@ -119,14 +120,18 @@ def clean_bill(raw: Any) -> dict[str, Any]:
continue
if row.get("section") not in codes:
continue
group = row.get("kind") == "group" # 182 묶음 줄 = 이름만
parent = str(row.get("parent") or "")
rows.append(
{
"key": str(row["key"]),
"section": row["section"],
"after": _text(row.get("after")),
**({"kind": "group"} if group else {}),
**({"parent": parent} if parent.startswith("hand:") else {}),
**{k: _text(row.get(k)) for k in ("name", "spec", "unit")},
"qty": _num(row.get("qty")),
"ref": _clean_ref(row.get("ref")),
"qty": None if group else _num(row.get("qty")),
"ref": None if group else _clean_ref(row.get("ref")),
}
)
out: dict[str, Any] = {}
+19
View File
@@ -155,6 +155,25 @@ def block_lines(blocks: list[dict[str, Any]] | None) -> dict[str, Any]:
return out
def used_materials(sheets: dict[str, Any], inputs: dict[str, Any], user: dict[str, Any]) -> set:
"""재료비목록표에 남길 자재 키(183-1) — 대가 · 산근 · 중기 블록 재료 줄(수량 빈칸 · 0 도 =
집계표 역전개와 같은 줄) · B08 총괄 줄(수량 있는 입력) · 관급 · 별도 표시 줄.
그 밖(고르기 후보 줄) = 뺌."""
blocks = [*(sheets.get("sg") or []), *(sheets.get("daega_blocks") or [])]
blocks += sheets.get("machine_blocks") or []
keep = {
str((line.get("ref") or {}).get("key"))
for block in blocks
for line in block.get("lines") or []
if line.get("kind") == "material" and (line.get("ref") or {}).get("key")
}
marks = user.get("materials") or {}
for m in inputs.get("materials") or []:
if "qty" in m or m.get("supply") or (marks.get(m["key"]) or {}).get("supply"):
keep.add(m["key"])
return keep
def hopyo_lines(sheets: dict[str, Any]) -> dict[str, Any]:
"""호표 구성(1 단위당 자원 · 겹 호표) — 산근(145-1) + 대가(145-2 · 들어오면)."""
return {**block_lines(sheets.get("sg")), **block_lines(sheets.get("daega_blocks"))}
+64 -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;
@@ -39,7 +44,65 @@
gap: var(--spacing-8);
}
/* 182 설계내역서 레벨 — 글자 크기(줄 높이 28px 고정 → 16px 까지) · 첫 칸 들여쓰기 1em × (레벨 − 1) */
.b09-estimation .ui-data-table__table tbody tr.is-lv1 td {
font-size: var(--text-body);
}
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv2, .is-lv3) td {
font-size: var(--text-body-sm);
}
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv4, .is-lv5, .is-lv6) td {
font-size: 13px;
}
.b09-estimation .ui-data-table__table tbody tr.is-group td {
font-weight: var(--font-weight-bold);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv2 td:first-child {
padding-left: calc(var(--spacing-8) + 1em);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv3 td:first-child {
padding-left: calc(var(--spacing-8) + 2em);
}
.b09-estimation .ui-data-table__table tbody tr.is-lv4 td:first-child {
padding-left: calc(var(--spacing-8) + 3em);
}
.b09-estimation .ui-data-table__table tbody tr:is(.is-lv5, .is-lv6) td:first-child {
padding-left: calc(var(--spacing-8) + 4em);
}
/* 166-2 설계내역서 손 줄 — 사용자가 더한 줄 표시(왼쪽 띠) */
.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;
+37 -14
View File
@@ -2,11 +2,13 @@
* B09_Estimation_UI_Tabs_Base.ts
* B09 바탕 단가 탭 일곱(PLAN 144-2~4) — 노무비 · 경비목록표 · 환율및기초자료 · 자재단가대비표 ·
* 재료비목록표 · 중기사용료 · 중기목록표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Base`).
* 재료비목록표 = 엑셀 꼴 + 수량(B08 합계) · 총액 열(166-3)
* 재료비목록표 = 엑셀 꼴 + 수량 · 총액 열(166-3) · 줄 = 대가 · 산근 · 중기가 쓰는 자재 + B08 줄 ·
* 관급 · 별도 줄(183-1 · 서버가 거른 입력) · 수량 = 역전개 총수량 · 비고 = 쓰는 곳(`usesOf`)
*
* 고칠 칸 = 자재단가대비표 적용 단가(빈칸으로 지우면 최저 자동) · 관급 / 별도 칸 누름 = 표시 바꿈
* · 견적 단가 · 면 손입력 · 적용 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처만 · 견적이 높아도)
* · 「견적서」 칸 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4)
* · 견적 단가 손입력 · 적용 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처만 · 견적이 높아도)
* · 「견적서」 칸(견적 묶음 아래 · 183-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4)
* · 출처 묶음 둘째 칸 = 「출처」(M01 면수 · 유가 기준일 값 그대로 · 183-2 · 견적엔 없음)
* 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표
* ========================================================================== */
@@ -148,26 +150,24 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
text("name", "품명", 180),
text("spec", "규격", 140),
text("unit", "단위", 56),
...SOURCES.flatMap((s) => [money(`p:${s}`, "단가", [s]), text(`g:${s}`, "면", 64, [s])]),
// 183-2 — 견적 = 단가 · 견적서 · 그 밖 = 단가 · 출처(면수 값)
...SOURCES.flatMap((s) => [
money(`p:${s}`, "단가", [s]),
s === "견적" ? text("files", "견적서", 72, [s]) : text(`g:${s}`, "출처", 64, [s]),
]),
money("lowest", "최저"),
money("applied", "적용 단가", ["적용"]),
text("applied_from", "출처", 80, ["적용"]),
text("supply", "관급 · 별도", 80),
text("files", "견적서", 72),
],
rows: out,
frozen: 2,
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처 · 견적이 높아도 고름) · 견적 단가 · 면 = 수기 입력 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기",
editable: (_i, key) => key === "applied" || key === "p:견적" || key === "g:견적",
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처 · 견적이 높아도 고름) · 견적 단가 = 수기 입력 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기",
editable: (_i, key) => key === "applied" || key === "p:견적",
onEdit: (i, key, value) => {
const row = out[i];
if (!row) return;
const cell = mine(String(row.key));
if (key === "g:견적") {
if (value.trim()) cell.quote_page = value.trim();
else delete cell.quote_page;
return put(String(row.key), cell);
}
const v = parseNumber(value);
if (v === undefined) return;
const field = key === "applied" ? "applied" : key === "p:견적" ? "quote" : null;
@@ -207,10 +207,33 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
};
};
/** 쓰는 곳(183-1) — 자재 키 → 「대가 n호표」 · 「산근 n호표」 · 「중기 n호표」 · 「내역 자재대」
* (블록 재료 줄 · 설계내역서 자재 항목 · 표시 잇기만 · 셈 아님) */
function usesOf(sheets: Row): Map<string, string[]> {
const out = new Map<string, string[]>();
const add = (key: unknown, label: string): void => {
if (!key) return;
const list = out.get(String(key)) ?? [];
if (!list.includes(label)) out.set(String(key), [...list, label]);
};
const scan = (blocks: Row[] | undefined, label: (b: Row, n: number) => string): void =>
(blocks || []).forEach((b, n) =>
(b.lines || []).forEach((ln: Row) => ln.kind === "material" && add(ln.ref?.key, label(b, n))),
);
scan(sheets.daega_blocks, (b) => `대가 ${b.no}호표`);
scan(sheets.sg, (b) => `산근 ${b.no}호표`);
scan(sheets.machine_blocks, (_b, n) => `중기 ${n + 1}호표`);
for (const r of sheets.bill?.rows || [])
if (r.kind === "item" && r.ref?.tab === "material") add(r.ref.key, "내역 자재대");
return out;
}
export const buildMaterial: TabBuild = ({ sheets }): TabTable => {
const uses = usesOf(sheets);
const rows: DataRow[] = (sheets.material || []).map((r: Row) => ({
...r,
supply: r.supply ?? "",
uses: (uses.get(r.key) ?? []).join(" · ") || (r.origin === "B08" ? "B08 총괄" : ""),
}));
return {
columns: [
@@ -222,14 +245,14 @@ export const buildMaterial: TabBuild = ({ sheets }): TabTable => {
money("price", "단가"),
money("amount", "총액"),
text("supply", "관급 · 별도", 80),
text("origin", "비고", 90),
text("uses", "비고(쓰는 곳)", 240),
],
rows,
total: rows.length
? { name: "계", amount: rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0) }
: null,
frozen: 2,
hint: `수량 = B08 총괄자재수량집계표 합계 열(할증 포함 · 사본 자원 줄은 빈칸) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · ${LINK_HINT}`,
hint: `줄 = 연결된 일위대가 · 산근 · 중기가 쓰는 자재 + B08 총괄 줄 · 관급 · 별도 줄 · 수량 = 설계내역서 × 호표 역전개 총수량(재료비수량금액집계표와 같음 · B08 할증 값과 다를 수 있음) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · 비고 = 쓰는 곳 · ${LINK_HINT}`,
link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null),
};
};
+21 -5
View File
@@ -6,7 +6,9 @@
* 고칠 칸 = 관급자재대 조달수수료 율(%) · 항목 수량(166-2 · 덮어쓰기 · 빈칸 = 자동) ·
* 손 줄 명칭 · 규격 · 단위 · 수량 · 빈칸 = 「연결 없음」(수량 · 단가 원천 없음)
* 줄 누름 = 단가를 낸 탭 줄(자재 → 재료비목록표 · 구조물 → 일위대가목록표)
* 우클릭 = 줄 추가 · 지우기 · 연결(`…_UI_Tabs_Bill_Edit`)
* 우클릭 = 줄 추가 · 지우기 · 연결 · 레벨(`…_UI_Tabs_Bill_Edit`)
* 레벨(182) = 줄 `level`(셈이 냄) → class is-lvN · 글자 크기 · 첫 칸 들여쓰기(CSS) · 묶음 줄 = 이름만 고침
* 「연결 ▾」 칸(182) = 손 항목 줄만 · 누름 = 우클릭 [연결] 과 같은 메뉴(연결됨 = 호표 번호)
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
@@ -17,6 +19,7 @@ import {
editQty,
editText,
handEditable,
openLinkMenu,
type BillTable,
} from "./B09_Estimation_UI_Tabs_Bill_Edit";
@@ -25,7 +28,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
const bill: Row = sheets.bill || { rows: [], totals: null };
const rows: DataRow[] = bill.rows.map((r: Row) => ({
...r,
name: r.kind === "title" ? r.name : ` ${r.name}`,
connect: r.hand && r.kind === "item" ? `${r.note || "연결"} ▾` : "",
}));
const direct: Row = bill.totals?.direct || {};
const pair = (key: string, label: string) => [
@@ -42,6 +45,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
...pair("labor", "노무비"),
...pair("material", "재료비"),
...pair("expense", "경비"),
text("connect", "연결", 96),
text("note", "비고", 200),
],
rows,
@@ -49,10 +53,12 @@ export const buildBill: TabBuild = (ctx): BillTable => {
frozen: 1,
hint:
"공종 나무 = 엑셀 꼴 고정 · 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 4 자재대 = 대비표 「별도」 · 관급자재대 = 「관급」 표시 자재 · 조달수수료 율(%)은 수량 칸에 넣음 · " +
"수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " +
"수량 칸 = 덮어쓰기(빈칸 = 자동) · 우클릭 · 「연결 ▾」 = 줄 추가 · 지우기 · 일위대가 · 단가산출 연결(새 산근 단가 = [저장] 뒤) · " +
"레벨 = 우클릭 묶음 줄로 · 하위 줄 추가 · 들여쓰기 · 내어쓰기(묶음 금액 = 아래 항목 합 · 공종 합은 항목만) · " +
`빈 줄 ${direct.blank ?? 0} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`,
editable: (i, key) => {
const r = rows[i];
if (r?.kind === "group") return key === "name";
if (!r || r.kind !== "item") return false;
return r.hand ? handEditable(key) : key === "qty";
},
@@ -71,9 +77,19 @@ export const buildBill: TabBuild = (ctx): BillTable => {
if (key !== "qty" || !r || !("qty_auto" in r)) return null;
return `자동 값 ${r.qty_auto ?? "빈칸"} — 우클릭 [수량 자동으로 되돌리기]`;
},
rowClass: (i) => (rows[i]?.hand ? "is-hand" : null),
// 182 레벨 — 글자 크기 · 들여쓰기(is-lvN) · 제목 · 묶음 줄 굵게
rowClass: (i) => {
const r = rows[i];
if (!r) return null;
const title = r.kind === "title" ? "is-title" : r.kind === "group" ? "is-group" : "";
return [`is-lv${r.level ?? 1}`, title, r.hand ? "is-hand" : ""].filter((c) => c).join(" ");
},
menu: (i) => billMenu(ctx, rows, i),
link: (i) => {
onCell: (i, key) => {
if (key === "connect") openLinkMenu(ctx, rows, i);
},
link: (i, key) => {
if (key === "connect") return null; // 「연결 ▾」 = 메뉴(이동 안 함)
const ref = rows[i]?.ref as Row | null | undefined;
return ref?.key ? { tab: ref.tab as TabId, key: String(ref.key) } : null;
},
@@ -7,6 +7,8 @@
* rows[] = 손 줄 {key hand:… · section · after(앞 줄 키 · 제목 = sec:공종) · 명칭 · 규격 · 단위 · 수량 · ref}
* 연결 = 있는 호표(일위대가목록표 dg · 단가산출근거목록표 sg) · M01 로직(새 산근 = 서버 셈 · [저장] 뒤 단가)
* 우클릭 = 공용 메뉴(`ui_template_context_menu` · 화면 고정 자리) · 로직 고르기 · 입력값 = M02 창 재사용
* 레벨(182) = 손 줄 kind(group = 묶음 줄 · 이름만) · parent(묶음 손 줄 키) — 하위 줄 추가 · 묶음 줄로 ·
* 항목으로 · 들여쓰기 · 내어쓰기 · 손 묶음 깊이 2 · 「연결 ▾」 칸 = 우클릭 [연결] 과 같은 메뉴
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
@@ -29,6 +31,8 @@ export type BillTable = TabTable & { menu?: (index: number) => MapContextMenuIte
const NEW_NAME = "새 줄";
const TEXT_KEYS = ["name", "spec", "unit"];
/** 182 손 묶음 깊이 — 묶음 안 묶음까지 */
const MAX_GROUP = 2;
interface Bill {
qty: Record<string, number>;
@@ -69,35 +73,138 @@ export function editText(ctx: TabContext, key: string, field: string, value: str
put(ctx, bill);
}
/** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종) */
function slot(rows: DataRow[], i: number, below: boolean): { section: string; after: string } {
const lv = (row: DataRow | undefined): number => Number(row?.level ?? 0);
/** 줄 i 자손 끝(다음 줄 번호) — 더 깊은 레벨 줄이 이어지는 데까지 */
function subtreeEnd(rows: DataRow[], i: number): number {
let k = i + 1;
while (k < rows.length && lv(rows[k]) > lv(rows[i])) k += 1;
return k;
}
/** 부모 바꿈 — 없음 = 공종 바로 아래(키 지움) */
function reparent(h: Row, parent: unknown): void {
if (parent) h.parent = parent;
else delete h.parent;
}
/** 손 묶음 깊이 지킴 — 묶음 줄 위 묶음 ≤ 1 · 항목 위 묶음 ≤ 2(도는 부모는 셈이 끊음 · 여기선 멈춤) */
function fits(bill: Bill): boolean {
const byKey = new Map(bill.rows.map((h) => [String(h.key), h]));
return bill.rows.every((h) => {
let n = 0;
const seen = new Set([h.key]);
let at = byKey.get(String(h.parent ?? ""));
while (at && at.kind === "group" && !seen.has(at.key)) {
seen.add(at.key);
n += 1;
at = byKey.get(String(at.parent ?? ""));
}
return n <= (h.kind === "group" ? MAX_GROUP - 1 : MAX_GROUP);
});
}
/** 새 줄 자리 — 공종 · 앞 줄 키(위 = 앞 줄 · 공종 첫 줄이면 제목 sec:공종 · 아래 = 자손 끝) · 부모 = 그 줄 부모 */
function slot(rows: DataRow[], i: number, below: boolean): Row {
const row = rows[i];
const section = String(row.section);
if (row.kind === "title") return { section, after: `sec:${section}` };
if (below) return { section, after: String(row.key) };
const parent = row.hand && row.parent ? { parent: String(row.parent) } : {};
if (below) return { section, after: String(rows[subtreeEnd(rows, i) - 1].key), ...parent };
const prev = rows[i - 1];
const same = prev && prev.kind !== "title" && prev.section === section;
return { section, after: same ? String(prev.key) : `sec:${section}` };
return { section, after: same ? String(prev.key) : `sec:${section}`, ...parent };
}
function addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void {
function newRow(ctx: TabContext, at: Row): void {
const bill = billOf(ctx.user);
const key = `hand:${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
const at = slot(rows, i, below);
bill.rows.push({ key, ...at, name: NEW_NAME, spec: "", unit: "", qty: null, ref: null });
put(ctx, bill);
}
/** 손 줄 지움 — 그 줄 뒤에 붙던 손 줄은 지운 줄 자리로 옮김 */
function addRow(ctx: TabContext, rows: DataRow[], i: number, below: boolean): void {
newRow(ctx, slot(rows, i, below));
}
/** 묶음 줄 아래 하위 줄 — 자리 = 마지막 자손 뒤 */
function addChild(ctx: TabContext, rows: DataRow[], i: number): void {
const after = String(rows[subtreeEnd(rows, i) - 1].key);
newRow(ctx, { section: String(rows[i].section), after, parent: String(rows[i].key) });
}
/** 손 줄 지움 — 그 줄 뒤에 붙던 손 줄은 지운 줄 자리로 · 자식은 지운 줄 부모로 옮김 */
function removeRow(ctx: TabContext, key: string): void {
const bill = billOf(ctx.user);
const gone = bill.rows.find((h) => h.key === key);
if (!gone) return;
bill.rows = bill.rows.filter((h) => h.key !== key);
for (const h of bill.rows) if (h.after === key) h.after = gone.after;
for (const h of bill.rows) if (h.parent === key) reparent(h, gone.parent);
put(ctx, bill);
}
/** 레벨 바꿈 — 깊이 한도 안이면 [동작] · 넘으면 null(메뉴에 안 보임) */
function levelAction(
ctx: TabContext,
key: string,
change: (own: Row, bill: Bill) => void,
): (() => void) | null {
const bill = billOf(ctx.user);
const own = bill.rows.find((h) => h.key === key);
if (!own) return null;
change(own, bill);
return fits(bill) ? () => put(ctx, bill) : null;
}
/** 들여쓰기 대상 — 바로 위 같은 레벨 형제가 같은 공종 묶음 줄이면 그 줄 */
function indentTarget(rows: DataRow[], i: number): DataRow | null {
const row = rows[i];
for (let j = i - 1; j >= 0; j -= 1) {
const r = rows[j];
if (lv(r) > lv(row)) continue;
return lv(r) === lv(row) && r.kind === "group" && r.section === row.section ? r : null;
}
return null;
}
/** 레벨 메뉴 — 묶음 줄로 · 항목으로 · 들여쓰기 → · 내어쓰기 ←(막내만 · 자리 안 어긋남) */
function levelMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] {
const row = rows[i];
const key = String(row.key);
const out: MapContextMenuItem[] = [];
const add = (label: string, action: (() => void) | null): void => {
if (action) out.push([label, action]);
};
if (row.kind === "group")
add(
"항목으로 바꾸기",
levelAction(ctx, key, (own, bill) => {
delete own.kind;
for (const h of bill.rows) if (h.parent === key) reparent(h, own.parent);
}),
);
else
add(
"묶음 줄로 바꾸기",
levelAction(ctx, key, (own) => Object.assign(own, { kind: "group", qty: null, ref: null })),
);
const target = indentTarget(rows, i);
if (target)
add(
"들여쓰기 →",
levelAction(ctx, key, (own) => reparent(own, String(target.key))),
);
if (row.parent && lv(rows[subtreeEnd(rows, i)]) < lv(row))
add(
"내어쓰기 ←",
levelAction(ctx, key, (own, bill) =>
reparent(own, bill.rows.find((h) => h.key === row.parent)?.parent),
),
);
return out;
}
/** 연결 — 명칭이 비었거나 「새 줄」이면 고른 호표 명칭 · 규격 · 단위를 채움 */
function setRef(ctx: TabContext, key: string, ref: Row | null, from?: Row): void {
const bill = billOf(ctx.user);
@@ -182,7 +289,24 @@ function pickLogic(ctx: TabContext, key: string): void {
openLogicPicker((logic) => askInputs(ctx, key, logic.키, logic.이름));
}
/** 줄 우클릭 메뉴 — 위 · 아래 줄 추가 · (손 줄) 연결 · 연결 풀기 · 지우기 · (자동 줄) 수량 자동으로 */
type Leaf = [string, () => void];
/** 손 항목 줄 연결 — [고르기 셋, 그 밖(로직 입력값 · 연결 풀기)] */
function linkItems(ctx: TabContext, key: string): [Leaf[], Leaf[]] {
const ref: Row | null = billOf(ctx.user).rows.find((h) => h.key === key)?.ref ?? null;
const more: Leaf[] = [];
if (ref?.tab === "logic")
more.push(["로직 입력값 고치기", () => askInputs(ctx, key, ref.code, ref.name || "", ref)]);
if (ref) more.push(["연결 풀기", () => setRef(ctx, key, null)]);
const picks: Leaf[] = [
["일위대가 호표", () => pickHopyo(ctx, key, "dg")],
["단가산출 호표", () => pickHopyo(ctx, key, "sg")],
["단가산출 로직(M01 · 새 산근)", () => pickLogic(ctx, key)],
];
return [picks, more];
}
/** 줄 우클릭 메뉴 — 위 · 아래(· 하위) 줄 추가 · (손 줄) 연결 · 연결 풀기 · 레벨 · 지우기 · (자동 줄) 수량 자동으로 */
export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContextMenuItem[] {
const row = rows[i];
if (!row) return [];
@@ -191,19 +315,13 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
const items: MapContextMenuItem[] = [];
if (row.kind !== "title") items.push(["위에 줄 추가", () => addRow(ctx, rows, i, false)]);
items.push(["아래에 줄 추가", () => addRow(ctx, rows, i, true)]);
if (row.kind === "group") items.push(["아래에 하위 줄 추가", () => addChild(ctx, rows, i)]);
if (row.hand) {
const ref: Row | null = bill.rows.find((h) => h.key === key)?.ref ?? null;
items.push("-", {
label: "연결",
children: [
["일위대가 호표", () => pickHopyo(ctx, key, "dg")],
["단가산출 호표", () => pickHopyo(ctx, key, "sg")],
["단가산출 로직(M01 · 새 산근)", () => pickLogic(ctx, key)],
],
});
if (ref?.tab === "logic")
items.push(["로직 입력값 고치기", () => askInputs(ctx, key, ref.code, ref.name || "", ref)]);
if (ref) items.push(["연결 풀기", () => setRef(ctx, key, null)]);
if (row.kind !== "group") {
const [picks, more] = linkItems(ctx, key);
items.push("-", { label: "연결", children: picks }, ...more);
}
items.push("-", ...levelMenu(ctx, rows, i));
items.push("-", ["줄 지우기", () => removeRow(ctx, key)]);
} else if (row.kind === "item" && key in bill.qty) {
items.push("-", [
@@ -218,6 +336,17 @@ export function billMenu(ctx: TabContext, rows: DataRow[], i: number): MapContex
}
let shared: MapContextMenu | null = null;
/** 마지막으로 누른 칸 — 「연결 ▾」 단추 메뉴 자리 */
let lastCell: Element | null = null;
/** 「연결 ▾」 칸 누름(182) — 우클릭 [연결] 과 같은 메뉴를 그 칸 아래에 엶 */
export function openLinkMenu(ctx: TabContext, rows: DataRow[], i: number): void {
const row = rows[i];
const box = lastCell?.getBoundingClientRect();
if (!shared || !box || !row?.hand || row.kind !== "item") return;
const [picks, more] = linkItems(ctx, String(row.key));
shared.open(box.left, box.bottom, more.length ? [...picks, "-", ...more] : picks);
}
/** 표에 줄 우클릭 메뉴를 붙임(공용 표는 안 고침) — 글 칸 위는 브라우저 메뉴(붙여넣기) 그대로 */
export function attachRowMenu(
@@ -230,6 +359,12 @@ export function attachRowMenu(
ctxMenu.element.style.position = "fixed"; // 화면 기준 자리(clientX · Y)
document.body.append(ctxMenu.element);
}
// 칸 누름보다 먼저(잡기 단계) — 「연결 ▾」 메뉴를 그 칸 자리에 엶
root.addEventListener(
"click",
(event) => (lastCell = (event.target as Element).closest("td")),
true,
);
root.addEventListener("contextmenu", (event) => {
const target = event.target as Element;
if (target.closest("input")) return;
@@ -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.까닭}` : ""}`,
+16 -12
View File
@@ -1,7 +1,7 @@
"""절토 사면의 **계단(소단) 포함 꼭짓점**을 만든다 — 파이썬·TS 짝 (계획서 3-9).
짝: `common_util/common_util_cross_berm.ts`. 두 파일은 같은 값을 내야 하며
`tmp/tests/test_cross_berm_mirror.py` 가 그것을 지킨다.
`resources/tester/test_178_2_berm_fill_mirror.py` 가 그것을 지킨다.
**왜 따로 뺐나** — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다.
지금 코드는 암 경계 무릎을 **하나만** 전제하는데(경계를 한 번 지나면 끝), 사용자가 소단을
@@ -18,6 +18,8 @@
· 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정 — 처음 2° 로 잡았다가 바꿨다).
기울이는 것 자체는 실무이나 **법령·교본 근거가 없어** 기본으로 넣지 않고 폼에서 받는다.
그래서 지식DB 에도 적지 않는다(사용자 지시).
· 성토 소단(178-2) = 노면 끝 아래 깊이 `depth_m`(기본 1.0 · 사용자 값)에 **한 단만** ·
0 미만은 0(도로보다 높을 수 없음). 간격은 성토에 안 씀 · 절토는 소단을 안 넣음.
"""
import logging
@@ -30,6 +32,7 @@ logger = logging.getLogger(__name__)
BERM_DEFAULT_WIDTH_M = 0.5
BERM_DEFAULT_INTERVAL_M = 3.0
BERM_DEFAULT_SLOPE_DEG = 0.0
BERM_DEFAULT_DEPTH_M = 1.0
# 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 무릎 탐색이 쓰던 값과 같다.
_STEP_M = 0.05
@@ -41,11 +44,12 @@ _MAX_STEPS = int(_MAX_REACH_M / _STEP_M) * 10
class BermSpec(NamedTuple):
"""소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도)."""
"""소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도) · 깊이(노면 끝 아래 m · 성토)."""
width_m: float = BERM_DEFAULT_WIDTH_M
interval_m: float = BERM_DEFAULT_INTERVAL_M
slope_deg: float = BERM_DEFAULT_SLOPE_DEG
depth_m: float = BERM_DEFAULT_DEPTH_M
def cut_profile_points(
@@ -166,16 +170,16 @@ def fill_profile_points(
fill_ratio: float,
berm: BermSpec | None,
max_reach_m: float = _MAX_REACH_M,
top_z: float | None = None,
) -> list[tuple[float, float]]:
"""성토 사면 꼭짓점 `[(거리, 표고), ...]` — 사면 시작에서 바깥으로 **내려간다**.
절토와 달리 경사가 하나뿐이라 무릎이 없다(암 경계는 절토만 본다). 소단은 같은 규칙 —
사면길이가 `interval_m` 에 닿을 때마다 폭 `width_m` 의 평탄부를 넣고, 그 평탄부는
안쪽이 낮도록 `slope_deg` 만큼 기울어 **바깥으로 갈수록 조금 올라간다**(물이 노면 쪽으로).
소단은 **한 단**(178-2) — 표고가 `노면 끝(top_z) − depth_m` 에 닿으면 폭 `width_m` 의
평탄부를 넣고(안쪽이 낮도록 `slope_deg` 만큼 기울어 바깥으로 조금 올라감) 그 아래로
같은 성토 경사로 이어 내린다. 그 깊이 전에 원지반을 만나면 지반 교점이 먼저라 안 선다.
"""
points: list[tuple[float, float]] = [(start_dist, start_z)]
dist, elevation = start_dist, start_z
slant_since_berm = 0.0
limit = start_dist + max_reach_m
berm_rise = (
math.tan(math.radians(berm.slope_deg)) * berm.width_m
@@ -183,24 +187,24 @@ def fill_profile_points(
else 0.0
)
ratio = max(fill_ratio, 1e-6)
top = start_z if top_z is None else top_z
berm_z = top - max(berm.depth_m, 0.0) if berm is not None and berm.width_m > 0 else None
placed = berm_z is None
while dist < limit:
drop = _STEP_M / ratio
slant = math.hypot(_STEP_M, drop)
if berm is not None and berm.interval_m > 0 and slant_since_berm + slant >= berm.interval_m:
remain = max(berm.interval_m - slant_since_berm, 0.0)
run = remain / math.hypot(1.0, 1.0 / ratio)
if not placed and berm_z is not None and elevation - drop <= berm_z:
run = max(elevation - berm_z, 0.0) * ratio
dist += run
elevation -= run / ratio
points.append((dist, elevation)) # 소단 안쪽 모서리
dist += berm.width_m
elevation += berm_rise
points.append((dist, elevation)) # 소단 바깥 모서리
slant_since_berm = 0.0
placed = True
continue
dist += _STEP_M
elevation -= drop
slant_since_berm += slant
points.append((dist, elevation))
return _dedupe(points)
+21 -12
View File
@@ -3,7 +3,7 @@
* 절토 사면의 **계단(소단) 포함 꼭짓점** — 파이썬 짝 (계획서 3-9)
*
* ⚠⚠ 짝: `common_util/common_util_cross_berm.py` — 한쪽만 고치면 화면과 저장본이 갈린다.
* 거울 시험: `tmp/tests/test_cross_berm_mirror.py`
* 거울 시험: `resources/tester/test_178_2_berm_fill_mirror.py`
*
* 왜 따로 뺐나 — 소단이 들어가면 절토선이 「하나의 경사」가 아니라 **계단**이 된다.
* 꼭짓점을 한 벌로 만들어 두면 도면·면적·3D 가 전부 그 선을 그대로 읽는다.
@@ -15,12 +15,15 @@
* 폭 0.5·간격 3 → 1:1.24 / 폭 1.0·간격 3 → 1:1.47 / 폭 1.0·간격 2 → **1:1.71**.
* · 안쪽 기울기 기본값은 **0°**다(2026-09-07 사용자 재확정). 기울이는 것은 실무이나
* 법령·교본 근거가 없어 기본으로 넣지 않고 폼에서 받는다 — 지식DB 에도 적지 않는다.
* · 성토 소단(178-2) = 노면 끝 아래 깊이 `depthM`(기본 1.0 · 사용자 값)에 **한 단만** ·
* 0 미만은 0(도로보다 높을 수 없음). 간격은 성토에 안 씀 · 절토는 소단을 안 넣음.
* ========================================================================== */
/** 소단 기본값 — 근거는 위 설명. */
export const BERM_DEFAULT_WIDTH_M = 0.5;
export const BERM_DEFAULT_INTERVAL_M = 3.0;
export const BERM_DEFAULT_SLOPE_DEG = 0.0;
export const BERM_DEFAULT_DEPTH_M = 1.0;
/** 사면을 따라 걸어가는 보폭(m)과 최대 거리 — 파이썬 짝과 같은 값. */
const STEP_M = 0.05;
@@ -30,19 +33,21 @@ const MAX_REACH_M = 200.0;
* 짝: 파이썬 `_MAX_STEPS`. */
const MAX_STEPS = (MAX_REACH_M / STEP_M) * 10;
/** 소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도). */
/** 소단 제원 — 폭(m) · 간격(사면길이 m) · 안쪽 기울기(도) · 깊이(노면 끝 아래 m · 성토). */
export interface BermSpec {
widthM: number;
intervalM: number;
slopeDeg: number;
depthM?: number;
}
export function bermSpec(
widthM = BERM_DEFAULT_WIDTH_M,
intervalM = BERM_DEFAULT_INTERVAL_M,
slopeDeg = BERM_DEFAULT_SLOPE_DEG,
depthM = BERM_DEFAULT_DEPTH_M,
): BermSpec {
return { widthM, intervalM, slopeDeg };
return { widthM, intervalM, slopeDeg, depthM };
}
/**
@@ -148,7 +153,9 @@ export function cutProfilePoints(
/**
* 짝: `fill_profile_points`. 성토 사면 꼭짓점 — 시작에서 바깥으로 **내려간다**.
*
* 절토와 달리 경사가 하나뿐이라 무릎이 없다. 소단 규칙은 같다.
* 소단은 **한 단** — 표고가 `노면 끝(topZ) − 깊이` 에 닿으면 폭만큼 수평(안쪽 기울기)
* 놓고 그 아래로 같은 성토 경사로 이어 내린다(178-2). 그 깊이 전에 원지반을 만나면
* 지반 교점이 먼저라 소단이 안 선다.
*/
export function fillProfilePoints(
startDist: number,
@@ -156,34 +163,36 @@ export function fillProfilePoints(
fillRatio: number,
berm: BermSpec | null,
maxReachM: number = MAX_REACH_M,
topZ: number = startZ,
): Array<[number, number]> {
const points: Array<[number, number]> = [[startDist, startZ]];
let dist = startDist;
let elevation = startZ;
let slantSinceBerm = 0;
const limit = startDist + maxReachM;
const bermRise =
berm !== null && berm.widthM > 0 ? Math.tan((berm.slopeDeg * Math.PI) / 180) * berm.widthM : 0;
const ratio = Math.max(fillRatio, 1e-6);
const bermZ =
berm !== null && berm.widthM > 0
? topZ - Math.max(berm.depthM ?? BERM_DEFAULT_DEPTH_M, 0)
: null;
let placed = bermZ === null;
while (dist < limit) {
const drop = STEP_M / ratio;
const slant = Math.hypot(STEP_M, drop);
if (berm !== null && berm.intervalM > 0 && slantSinceBerm + slant >= berm.intervalM) {
const remain = Math.max(berm.intervalM - slantSinceBerm, 0);
const run = remain / Math.hypot(1, 1 / ratio);
if (!placed && bermZ !== null && elevation - drop <= bermZ) {
const run = Math.max(elevation - bermZ, 0) * ratio;
dist += run;
elevation -= run / ratio;
points.push([dist, elevation]); // 소단 안쪽 모서리
dist += berm.widthM;
dist += (berm as BermSpec).widthM;
elevation += bermRise;
points.push([dist, elevation]); // 소단 바깥 모서리
slantSinceBerm = 0;
placed = true;
continue;
}
dist += STEP_M;
elevation -= drop;
slantSinceBerm += slant;
}
points.push([dist, elevation]);
+3 -2
View File
@@ -23,7 +23,7 @@
* 3. 새 필드를 더하면 양쪽 다 더하고 테스트 비교 목록에도 넣는다.
* ========================================================================== */
import type { BermSpec } from "./common_util_cross_berm";
import { BERM_DEFAULT_DEPTH_M, type BermSpec } from "./common_util_cross_berm";
import type { SpoilFillSection } from "./common_util_spoil_fill";
import { spoilFillSection } from "./common_util_spoil_fill";
import {
@@ -182,7 +182,7 @@ export interface CrossDesignResult {
* 안 들어간다. 소단 기하가 이 셈 위에 서 있어 남겨 둔다. 되살릴 때는 **계산해서** 쓸 것. */
cut_slope_segments: CutSlopeSegment[];
/** 이 측점에 놓인 소단 제원 — 옹벽 의무 판정이 사면을 도막으로 끊는 데 쓴다. */
berm?: { width_m: number; interval_m: number; slope_deg: number };
berm?: { width_m: number; interval_m: number; slope_deg: number; depth_m?: number };
surface_drop_m?: number;
pavement_thickness_m?: number;
rock_boundary_offset_m?: number;
@@ -586,6 +586,7 @@ export function computeCrossDesign(
width_m: round4(options.berm.widthM),
interval_m: round4(options.berm.intervalM),
slope_deg: round4(options.berm.slopeDeg),
depth_m: round4(Math.max(options.berm.depthM ?? BERM_DEFAULT_DEPTH_M, 0)),
};
}
// 노견 확장(139-6) — 넓힌 쪽만 되싣는다(짝: 파이썬)
@@ -283,10 +283,8 @@ export class SectionGeometry {
this.cutRatio,
this.soilCutRatio,
boundary,
this.berm,
undefined,
// 소단이 있으면 경계를 오갈 때마다 꺾는다 — 짝 파이썬과 같은 이유(2026-09-07).
this.berm !== null,
// 절토 소단 끔(178-2 · 소단 = 성토 사면 한 단) — 경계는 한 번만 꺾음(종전 규칙).
null,
);
this.cutPointsCache.set(side, points);
return points;
@@ -302,7 +300,16 @@ export class SectionGeometry {
const cached = this.fillPointsCache.get(side);
if (cached !== undefined) return cached;
const [startDist, startZ] = this.slopeStart(side);
const points = fillProfilePoints(startDist, startZ, this.fillRatio, this.berm);
// 소단 깊이 기준 = 그쪽 노면 끝(길어깨 끝) 표고 — 측구가 그쪽이어도 같음(178-2).
const edgeZ = side === "left" ? this.roadZ(this.leftExtent) : this.roadZ(-this.rightExtent);
const points = fillProfilePoints(
startDist,
startZ,
this.fillRatio,
this.berm,
undefined,
edgeZ,
);
this.fillPointsCache.set(side, points);
return points;
}
@@ -467,8 +474,8 @@ export class SectionGeometry {
breakpoints(): number[] {
const points = [0, this.leftExtent, -this.rightExtent];
for (const [offset] of this.ditchPoints) points.push(offset);
// 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다.
if (this.twoStage || this.berm !== null) {
// 절토 사면 꼭짓점(무릎) — 빠뜨리면 꺾임이 설계선에 안 실린다.
if (this.twoStage) {
for (const side of ["left", "right"]) {
const role = side === "left" ? this.leftRole : this.rightRole;
if (role !== "cut") continue;
@@ -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) | — | — | — | |
+14 -1
View File
@@ -2013,6 +2013,16 @@
"required": false,
"phase": "b05"
},
{
"key": "depth_m",
"label": "소단 위치(도로 아래)",
"input": "number",
"unit": "m",
"default": 1.0,
"required": false,
"phase": "b05",
"default_basis": "사용자 지정 — 성토 사면 노면 끝 아래 1 m · 0 미만 = 0(도로보다 높을 수 없음)"
},
{
"key": "height_m",
"label": "소단 높이",
@@ -3333,6 +3343,8 @@
"inlet.before": "기준측점 전 (m)",
"inlet.after": "기준측점 후 (m)",
"inlet.animal": "동물이동형",
"inlet.animal=true": "유",
"inlet.animal=false": "무",
"outlet.structure": "구조",
"outlet.structure=기슭막이": "흙막이",
"outlet.form": "형태",
@@ -3561,6 +3573,7 @@
"weep_hole_area_m2": false
},
"berm": {
"height_m": false,
"length_m": false,
"before_m": false,
"after_m": false,
@@ -3795,7 +3808,7 @@
"이름": "상세 내용",
"옵션": [
"width_m",
"height_m"
"depth_m"
],
"형상": {}
}
@@ -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));
+88 -11
View File
@@ -11,11 +11,28 @@ const B06 = path.join(ROOT, "B06_Section");
const common = fs.readFileSync(path.join(B06, "B06_Section_UI_Section_Common.ts"), "utf8");
// 카드 여백은 원본에서 읽음(화면과 같은 값)
const CROSS_PAD = Function(`return ${/export const CROSS_PAD = (\{[^}]*\})/.exec(common)[1]}`)();
// 축 눈금 간격도 원본 함수 그대로(179-3 · 축 다시 그리기)
const niceSrc = /export function niceTickStep[\s\S]*?\n\}/.exec(common.replace(/\r/g, ""))[0];
const niceTickStep = ts
.transpileModule(niceSrc.replace("export ", ""), { compilerOptions: {} })
.outputText.concat("\nreturn niceTickStep;");
const svgElement = (tag, attrs = {}) => {
const node = el(tag);
for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value);
return node;
};
const svgText = (text, attrs) => Object.assign(svgElement("text", attrs), { textContent: text });
const STUBS = {
"@ui/ui_template_modal": { openModal: () => {} },
"../A00_Common/b_page_state": {},
"./B06_Section_Api_Fetch": {},
"./B06_Section_UI_Section_Common": { CROSS_PAD, L: (key) => key },
"./B06_Section_UI_Section_Common": {
CROSS_PAD,
L: (key) => key,
niceTickStep: Function(niceTickStep)(),
svgElement,
svgText,
},
"./B06_Section_UI_Cross_Detail_Pick": { DETAIL_TARGETS: {} },
};
const resolve = (base) => (fs.existsSync(`${base}.ts`) ? `${base}.ts` : base);
@@ -52,6 +69,24 @@ function el(tag) {
node.parent = self;
self.childNodes.push(node);
}),
replaceChildren: (...nodes) => {
self.childNodes.splice(0).forEach((node) => (node.parent = null));
self.append(...nodes);
},
// 「tag.class」 · 「tag」 만 — 깊이 우선 첫 것
querySelector: (selector) => {
const [tagName, cls] = selector.split(".");
const walk = (node) => {
for (const child of node.childNodes) {
const classes = (child.attrs.class ?? "").split(" ");
if (child.tag === tagName && (!cls || classes.includes(cls))) return child;
const found = walk(child);
if (found) return found;
}
return null;
};
return walk(self);
},
addEventListener: (type, fn) => (listeners[type] ??= []).push(fn),
fire: (type, event) => {
const full = { preventDefault() {}, stopPropagation() {}, pointerId: 1, ...event };
@@ -69,16 +104,42 @@ globalThis.document = { createElementNS: (_ns, tag) => el(tag), createElement: e
const { attachDetailZoom } = loadTs(path.join(B06, "B06_Section_UI_Cross_Detail.ts"));
const svg = el("svg");
svg.setAttribute("viewBox", "0 0 600 300");
svg.append(el("rect"), el("g"), el("g"));
// 카드 svg 꼴 — 바탕 · 축 층 · clip 그룹 > 도형 층 > 십자(중심 300,150)
const AXES = {
widthPx: 600,
heightPx: 300,
pixelsPerMeter: 10,
maxOffset: 24,
displayMax: 120,
elevationMid: 110,
exaggeration: 1,
};
function chart() {
const svg = el("svg");
svg.setAttribute("viewBox", "0 0 600 300");
const axes = svgElement("g", { class: "b06-chart__axes" });
const clipGroup = svgElement("g", { "clip-path": "url(#c)" });
const plot = svgElement("g", { class: "b06-chart__plot-layer" });
const center = svgElement("g", { class: "b06-chart__center" });
center.append(svgElement("line", { x1: 300, y1: 132, x2: 300, y2: 168 }));
plot.append(center);
clipGroup.append(plot);
svg.append(svgElement("rect"), axes, clipGroup, svgElement("line", { class: "b06-chart__axis" }));
return { svg, axes, plot, center };
}
const ticks = (axes) =>
axes.childNodes.filter((node) => node.tag === "text").map((node) => node.textContent);
const { svg, axes, plot: layer, center } = chart();
let saved;
const handle = attachDetailZoom(svg, undefined, (next) => (saved = next));
const layer = svg.childNodes[0];
const handle = attachDetailZoom(svg, AXES, undefined, (next) => (saved = next));
const ticksBefore = ticks(axes);
const out = {
wrapped: svg.childNodes.length === 1 && layer.tag === "g" && layer.childNodes.length === 3,
// 179-3 — 감싸지 않음 · 자식 넷 그대로
wrapped: svg.childNodes.length === 1 && svg.childNodes[0].tag === "g",
children: svg.childNodes.length,
ratio: svg.attrs["style:--ratio"],
before: layer.getAttribute("transform"),
ticksBefore: ticksBefore.length,
};
// 휠 당김(deltaY > 0) = 확대 · 커서(화면 600,300 = svg 300,150) 고정
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
@@ -97,19 +158,35 @@ svg.fire("pointerdown", { button: 1, clientX: 100, clientY: 100 });
svg.fire("pointermove", { clientX: 300, clientY: 140 });
svg.fire("pointerup", {});
out.middlePan = { ...saved };
// 팬 뒤 — 도형 층만 transform · 축 층 · 바깥 그룹 transform 없음 · 눈금 글 바뀜
out.plotTransform = layer.getAttribute("transform");
out.axesTransform = axes.getAttribute("transform");
out.clipTransform = svg.childNodes[2].getAttribute("transform");
out.ticksChanged = JSON.stringify(ticks(axes)) !== JSON.stringify(ticksBefore);
out.ticksAfter = ticks(axes).length;
out.panClickStopped = svg.fire("click", {
stopPropagation() {
this.stopped = true;
},
}).stopped;
// 다시 떠도 배율 이어 감
const again = el("svg");
again.setAttribute("viewBox", "0 0 600 300");
attachDetailZoom(again, saved, () => {});
out.restored = again.childNodes[0].getAttribute("transform");
const again = chart();
attachDetailZoom(again.svg, AXES, saved, () => {});
out.restored = again.plot.getAttribute("transform");
// 다시 떠도 축은 이어 간 범위로(1배 원래 눈금과 다름)
out.restoredTicksShifted = JSON.stringify(ticks(again.axes)) !== JSON.stringify(ticksBefore);
// 축 옵션 없음 · 도형 층 없음 — 줌은 걸고 축은 그대로 / 줌 없음
const bare = chart();
out.noAxesHandle = attachDetailZoom(bare.svg, null, undefined, () => {}) !== null;
const flat = el("svg");
flat.setAttribute("viewBox", "0 0 600 300");
out.noLayer = attachDetailZoom(flat, AXES, undefined, () => {});
// 더블클릭 원복
svg.fire("dblclick", {});
out.reset = layer.getAttribute("transform");
// 확대 — 십자는 중심에서 배율을 되돌림(크기 그대로)
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
out.centerTransform = center.getAttribute("transform");
out.max = (() => {
for (let i = 0; i < 60; i++) svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: 100 });
return saved.scale;
+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,101 @@
/* 180-3 B07 안내선 보이기 / 숨기기 단추 헬퍼 — `…_UI_GuideToggle.ts` 를 Node 로 돌려 JSON 한 벌로.
* 공용 단추(`ui_template_elements`)는 진짜 · 화면 그릇만 흉내(helper_153 꼴).
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
class FakeNode {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.dataset = {};
this.attrs = {};
this.listeners = {};
this._text = "";
this._classes = new Set();
this.disabled = false;
this.title = "";
const self = this;
this.classList = {
add: (...names) => names.forEach((n) => self._classes.add(n)),
contains: (name) => self._classes.has(name),
};
}
set className(value) {
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
}
get className() {
return [...this._classes].join(" ");
}
set textContent(value) {
this._text = String(value);
this.children = [];
}
get textContent() {
return this._text + this.children.map((c) => c.textContent).join("");
}
setAttribute(key, value) {
this.attrs[key] = String(value);
}
append(...kids) {
kids.forEach((k) => this.children.push(k));
}
addEventListener(type, handler) {
(this.listeners[type] ||= []).push(handler);
}
click() {
if (!this.disabled) (this.listeners.click || []).forEach((handler) => handler({}));
}
}
globalThis.document = { createElement: (tag) => new FakeNode(tag) };
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (request === "@ui/ui_template_elements")
return loadTs(path.join(ROOT, "ui_template", "ui_template_elements.ts"));
if (request.startsWith(".")) {
const target = path.join(path.dirname(full), `${request}.ts`);
return fs.existsSync(target) ? loadTs(target) : {};
}
return {};
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const { buildGuideToggle, guideShown } = loadTs(
path.join(ROOT, "B07_DesignDetail", "B07_DesignDetail_UI_GuideToggle.ts"),
);
const sent = [];
const button = buildGuideToggle((visible) => sent.push(visible));
const view = () => ({
text: button.textContent,
cls: button.className,
pressed: button.attrs["aria-pressed"],
shown: guideShown(),
});
const out = { first: view() };
button.click();
out.hidden = view();
button.click();
out.again = view();
button.click();
out.last = view();
// 화면을 다시 세워 새 단추를 만들어도 숨김 유지(모듈 값)
const rebuilt = buildGuideToggle((visible) => sent.push(visible));
out.rebuilt = { text: rebuilt.textContent, pressed: rebuilt.attrs["aria-pressed"] };
out.sent = sent;
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); }
+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_기억_실패_때_카드():
+48 -4
View File
@@ -40,10 +40,24 @@ def out() -> dict:
return json.loads(proc.stdout)
def test_복제_자식을_g_하나로_감쌈_비율(out):
assert out["wrapped"] is True # 축 · 눈금 · 도형이 한 장으로 움직임
def test_감싸지_않음_비율(out):
# 179-3 — 복제를 g 하나로 감싸지 않음(축 · 눈금 · 도형 한 장 → 도형 층만)
assert out["wrapped"] is False and out["children"] == 4
assert out.get("ratio") is None # 173-4 — 칸 = viewBox(칸 크기로 새로 그림) · 비율 변수 없음
assert out["before"] is None
assert out["ticksBefore"] > 0
def test_축_고정_도형_층만_움직임_눈금_다시(out):
# 179-3 — 팬 뒤 도형 층만 transform · 축 층 · clip 그룹 제자리 · 눈금 글은 보이는 범위로
assert out["plotTransform"] == "translate(100 20) scale(1)"
assert out["axesTransform"] is None and out["clipTransform"] is None
assert out["ticksChanged"] is True and out["ticksAfter"] > 0
assert out["restoredTicksShifted"] is True # 다시 떠도 이어 간 범위로
# 확대 — 십자는 중심(300,150)에서 배율 되돌림
assert out["centerTransform"].startswith("translate(300 150) scale(0.869")
assert out["noAxesHandle"] is True # 축 옵션 없음 = 줌만
assert out["noLayer"] is None # 도형 층 없음 = 줌 없음
def test_휠_당기면_확대_커서_고정(out):
@@ -69,8 +83,14 @@ def test_모달_배선_단추_칸_비율():
src = _read("B06_Section_UI_Cross_Detail.ts")
css = _read("B06_Section_UI_Style_Cross_Detail.css")
assert "wheelZoom: true" in src and "attachSvgZoomPan({" in src
assert "cad.replaceChildren(clone, buildZoomControls(handle))" in src
assert "attachDetailZoom(clone, zoom, (next) => (zoom = next))" in src
assert "cad.replaceChildren(clone, ...(handle ? [buildZoomControls(handle)] : []))" in src
assert "attachDetailZoom(clone, axes, zoom, (next) => (zoom = next))" in src
assert "layer.append(...clone.childNodes)" not in src # 감싸기 지움(179-3)
assert 'clone.querySelector<SVGGElement>("g.b06-chart__plot-layer")' in src
assert "createCrossAxes(axisLayer, axes)" in src
view = _read("B06_Section_UI_Section_View.ts")
assert "const plot = fitted && crossPlotFromBase(fitted, heightPx);" in view
assert "return { svg, axes };" in view
assert "position: relative;" in css
# 173-4 — 그림 = 칸 크기로 새로 그림 · svg 칸 가득
assert ".b06-detail__cad > svg {\n display: block;\n width: 100%;\n height: 100%;" in css
@@ -102,3 +122,27 @@ def test_빈칸_누름_없음_이름_칸_첫_키():
assert 'if (!key || el.hasAttribute("data-empty")) return;' in src
assert 'cell("th", groupL, { row: 2 }, keyL)' in src
assert 'cell("th", groupM, { row: 2 }, keyM)' in src
def test_표_글자_14px_이름_칸_머리색_빈칸_값칸만():
# 179-3 — 표 글자 14px · 이름 칸(th) 바탕 = 데이터 표 머리색 · 빈 칸 바탕 지움은 값 칸(td)만
css = _read("B06_Section_UI_Style_Cross_Detail.css")
table = css.split(".b06-detail__qtable {", 1)[1].split("}", 1)[0]
assert "font-size: var(--text-body-sm);" in table
assert (
"--b06-detail-head: color-mix(in srgb, var(--color-accent) 6%, var(--color-surface));"
in table
)
head = css.split(".b06-detail__qtable th {", 1)[1].split("}", 1)[0]
assert "background: var(--b06-detail-head);" in head and "color: var(--color-text);" in head
empty = css.split(".b06-detail__qtable [data-key][data-empty] {", 1)[1].split("}", 1)[0]
assert "background" not in empty
assert ".b06-detail__qtable td[data-key][data-empty] {\n background: none;" in css
assert (
".b06-detail__qtable th[data-key][data-empty] {\n background: var(--b06-detail-head);"
in css
)
theme = open(
os.path.join(ROOT, "ui_template", "ui_template_theme.css"), encoding="utf-8"
).read()
assert "--text-body-sm: 14px;" in theme
+1 -1
View File
@@ -223,7 +223,7 @@ console.log(JSON.stringify(out));
assert out["title"] == ["아래에 줄 추가"]
assert out["auto"] == ["위에 줄 추가", "아래에 줄 추가", "-", "수량 자동으로 되돌리기"]
assert out["hand"] == ["위에 줄 추가", "아래에 줄 추가", "-", "연결", "연결 풀기", "-",
"줄 지우기"] # fmt: skip
"묶음 줄로 바꾸기", "-", "줄 지우기"] # fmt: skip # 182 레벨
assert out["above"]["after"] == "sec:1.2" and out["above"]["name"] == "새 줄"
assert out["qty"] == {}
# hand:b 지움 — 남은 새 줄은 그대로(자기 자리) · 지운 줄 뒤에 붙던 줄은 지운 줄 자리로
@@ -14,7 +14,7 @@ SPOTS = {
"B09_Estimation/B09_Estimation_Calc_Bill.ts": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
"B09_Estimation/B09_Estimation_Calc_Bill.py": ['"수기 줄 — 연결 없음(우클릭 [연결])"'],
"B09_Estimation/B09_Estimation_Sangeun.py": ['f"수기 줄 {'],
"B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 · 면 = 수기 입력"],
"B09_Estimation/B09_Estimation_UI_Tabs_Base.ts": ["견적 단가 = 수기 입력"],
"B09_Estimation/B09_Estimation_UI_Tabs_Total.ts": ['"수기 입력"', '"수기 입력 = 안전관리비'],
"ui_template/ui_template_locale_b2.ts": [
"수량 원천: 수기 입력(직접비)",
+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"}

Some files were not shown because too many files have changed in this diff Show More