Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_6
This commit is contained in:
@@ -622,12 +622,12 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
syncDrainageSpans();
|
||||
draw();
|
||||
},
|
||||
/** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */
|
||||
setSelectedStructure(structureId: string | null) {
|
||||
// 지금 측점(B06 카드)이 그 구조물 자리면 측점선도 그대로(53-2)
|
||||
/** 고른 구조물에 알약 · 측점선을 맞춤 — 그 자리면 선 그대로(53-2) · keepStation(B06 카드 = 선 주인) = 선 없는 구조물 · 풂도 카드 선 지킴(177-3). */
|
||||
setSelectedStructure(structureId: string | null, keepStation = false) {
|
||||
selectedStructureId = structureId;
|
||||
const own = stationIdAtStructure(structureId) ?? (keepStation ? selectedStationId : null);
|
||||
const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId;
|
||||
selectedStationId = keep ? selectedStationId : stationIdAtStructure(structureId);
|
||||
selectedStationId = keep ? selectedStationId : own;
|
||||
draw();
|
||||
},
|
||||
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
import { buildSummaryRow } from "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row";
|
||||
import type { GroupItems, SummaryItem } from "../../common_util/common_util_summary_item";
|
||||
import { followPipeHeights } from "../../common_util/common_util_culvert_sets";
|
||||
import { createCheckbox } from "../../ui_template/ui_template_checkbox";
|
||||
|
||||
/** 잇기 값 자리 — 패널 그룹 값(`group_items`)을 그대로 씀. */
|
||||
export interface SpecLinks {
|
||||
@@ -144,13 +143,6 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
||||
select.value = tag ? "" : now;
|
||||
return select;
|
||||
}
|
||||
if (item.kind === "bool") {
|
||||
// 빈 값 = 기본값으로 체크(173-7) · 듣개가 `.value` 를 읽음 — 바뀔 때 "true" / "false"
|
||||
const box = createCheckbox("", { checked: (values[item.id] || item.default) === "true" });
|
||||
box.input.value = String(box.input.checked);
|
||||
box.input.addEventListener("change", () => (box.input.value = String(box.input.checked)));
|
||||
return box.root;
|
||||
}
|
||||
if (item.kind === "text") {
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
|
||||
@@ -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" } } },
|
||||
},
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -115,13 +115,14 @@ export async function mountCrossView(
|
||||
const crossHalfWidthField = crossView.field;
|
||||
|
||||
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤 — 가장 가까운 카드로 간다. */
|
||||
const focusStationAt = (chainageM: number): void => {
|
||||
const focusStationAt = (chainageM: number, revealOnly = false): void => {
|
||||
const sections = sectionDetail?.cross_sections;
|
||||
if (!sections?.length) return;
|
||||
const nearest = sections.reduce((best, entry) =>
|
||||
Math.abs(entry.chainage_m - chainageM) < Math.abs(best.chainage_m - chainageM) ? entry : best,
|
||||
);
|
||||
sectionView.focusStation(nearest.station_id);
|
||||
if (revealOnly) sectionView.revealStation(nearest.station_id);
|
||||
else sectionView.focusStation(nearest.station_id);
|
||||
};
|
||||
/** 그 누가거리를 가진 측점 카드(관 찾는 거리 한 값). */
|
||||
const ownerAt = (chainageM: number) =>
|
||||
@@ -186,8 +187,8 @@ export async function mountCrossView(
|
||||
stationInterval: () => stationInterval ?? 20,
|
||||
// 벽·구체를 누르는 중이면 그 누름이 카드를 고른다 — 폼이 구조물을 열며 카드를 먼저
|
||||
// 고르면 뒤이은 카드 선택이 「같은 측점 재클릭 = 해제」로 먹힌다.
|
||||
focusChainage: (chainageM) => {
|
||||
if (!isWallSelecting()) focusStationAt(chainageM);
|
||||
focusChainage: (chainageM, revealOnly) => {
|
||||
if (!isWallSelecting()) focusStationAt(chainageM, revealOnly);
|
||||
},
|
||||
releaseCard: () => releaseCross(),
|
||||
markStructure: (structureId) => sectionView.selectMark(structureId),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,13 +62,13 @@ export function createViewControls(callbacks: ViewControlCallbacks): {
|
||||
toggleButton("지표면", true, callbacks.onSurfaceVisible),
|
||||
// 예상형상(2026-08-23): ON=공사 후 형상(기본), OFF=원지반 완전체.
|
||||
toggleButton("예상형상", true, callbacks.onCorridorVisible),
|
||||
toggleButton("등고선", true, callbacks.onContoursVisible),
|
||||
// 등고선 · 흑백 지형 기본 꺼짐(176-2) — 뷰어 처음 값과 맞춘다.
|
||||
toggleButton("등고선", false, callbacks.onContoursVisible),
|
||||
toggleButton("축 표시", false, callbacks.onAxesVisible),
|
||||
toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible),
|
||||
toggleButton(L("B05_Route_Field_StationLabels"), true, callbacks.onStationLabelsVisible),
|
||||
// 무지개 고도색이 헷갈릴 때 명도만 남기는 흑백 표시(2026-08-05 사용자 요청).
|
||||
// 기본 켜짐(2026-08-23 사용자 확정) — 예상형상 색이 지형 고도색에 묻히지 않는다.
|
||||
toggleButton("흑백 지형", true, callbacks.onSurfaceGrayscale),
|
||||
toggleButton("흑백 지형", false, callbacks.onSurfaceGrayscale),
|
||||
);
|
||||
// [3D 업데이트](2026-09-01 사용자 지시) — 계획선을 고칠 때마다 3D가 따라오면 조작이
|
||||
// 무거워, 갱신을 이 버튼으로 모았다. 편집이 쌓이면 is-pending으로 알리고, 누르는 동안은
|
||||
|
||||
@@ -154,9 +154,9 @@ export function createStructuresSection(
|
||||
}
|
||||
|
||||
/** 측점 범위 표시 갱신 — 시작 = 기준 − 전길이, 종료 = 시작 + 길이(2026-08-19
|
||||
* 사용자 지시: 전/후 분할, 후길이 = 길이 − 전길이 자동). 계산 불가면 "—". */
|
||||
* 사용자 지시: 전/후 분할, 후길이 = 길이 − 전길이 자동). 계산 불가면 "—" · 사면안정군(C)은 줄 숨김(177-5). */
|
||||
function syncRangeDisplay(): void {
|
||||
const computed = hasComputedRange(currentType());
|
||||
const computed = hasComputedRange(currentType()) && currentType()?.group !== "C";
|
||||
rangeWrap.hidden = !computed;
|
||||
if (!computed) return;
|
||||
const step = interval();
|
||||
|
||||
@@ -453,6 +453,12 @@
|
||||
background: color-mix(in srgb, var(--color-success) 18%, var(--color-surface));
|
||||
}
|
||||
|
||||
/* 미연결 — 연한 노랑 배경 + 노랑 테두리(경고 · 밝음 · 어두움 같은 토큰). */
|
||||
.b05-structure__summary-name:not(.is-assigned) {
|
||||
border-color: var(--color-warning);
|
||||
background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface));
|
||||
}
|
||||
|
||||
.b05-structure__summary .b05-route__irregular-btn {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -385,7 +385,7 @@ export function buildDesignControls(
|
||||
const groundSegment = groundToggle.wrap;
|
||||
groundSegment.classList.add("b06-design__seg--header");
|
||||
// 단면 유형은 지형에서 자동 판정되며(D-2), 제목행 pill로 표시한다(E-3, Cross_View에서 생성).
|
||||
// 아래 옵션은 상시 노출하되 선행 조건 미충족 시 비활성 처리한다(E-6).
|
||||
// 아래 옵션은 선행 조건 미충족 시 비활성 = 숨김(179-2 · CSS) — 편측의 쪽은 알약 글에.
|
||||
const rockCut = isRock(state.ground) && state.mode !== "both_fill";
|
||||
const hasDitch = state.mode !== "both_fill";
|
||||
// 측구 생성 여부(자동 판정 or 사용자 override) — 측구형식은 측구가 있을 때만 의미 있다.
|
||||
@@ -417,9 +417,9 @@ export function buildDesignControls(
|
||||
!hasDitch,
|
||||
L("B06_Design_Disabled_NoDitch"),
|
||||
);
|
||||
// 측구형식(일반/L형): 암 지반 + 절토 단면 + 측구가 실제 있을 때만 활성.
|
||||
// 측구형식(일반/L형): 암 지반 + 절토 단면 + 측구가 실제 있을 때만 활성 · 이름 글 없이 툴팁(179-2).
|
||||
const ditchTypeSeg = segment(
|
||||
L("B06_Design_DitchType_Legend"),
|
||||
"",
|
||||
DITCH_TYPE_OPTIONS,
|
||||
state.ditchType,
|
||||
(value) => {
|
||||
@@ -429,6 +429,7 @@ export function buildDesignControls(
|
||||
!rockCut || !ditchOn,
|
||||
!ditchOn ? L("B06_Design_Disabled_NoDitchType") : L("B06_Design_Disabled_RockCut"),
|
||||
);
|
||||
ditchTypeSeg.title = L("B06_Design_DitchType_Legend");
|
||||
// 2단계 경사 토글: 암 지반 + 절토 단면에서만 활성. 활성="복합경사"/비활성="단경사"(E-7).
|
||||
const twoStage = toggle(
|
||||
"",
|
||||
@@ -486,7 +487,8 @@ export function buildDesignControls(
|
||||
let overflow = bar.scrollWidth - clientWidth;
|
||||
let moveCount = 0;
|
||||
while (overflow > 1 && moveCount < moveable.length) {
|
||||
overflow -= widths[moveable.length - 1 - moveCount] + barGapPx;
|
||||
const width = widths[moveable.length - 1 - moveCount]; // 숨긴 칸(179-2)은 폭 · 간격 0
|
||||
overflow -= width ? width + barGapPx : 0;
|
||||
moveCount += 1;
|
||||
}
|
||||
return moveCount;
|
||||
|
||||
@@ -84,8 +84,8 @@ export interface B06StructuresPanelDeps {
|
||||
wallSideLabels?: (chainageM: number) => { inlet: "좌" | "우"; outlet: "좌" | "우" } | null;
|
||||
/** 측점 간격(m) — 측점번호+잔여거리 환산용. */
|
||||
stationInterval: () => number;
|
||||
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */
|
||||
focusChainage: (chainageM: number) => void;
|
||||
/** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다(revealOnly = 고르지 않고 스크롤만 · 177-4). */
|
||||
focusChainage: (chainageM: number, revealOnly?: boolean) => void;
|
||||
/** 목록에서 고른 줄을 다시 눌러 풂 — 횡단 카드 선택도 끔(54-8). */
|
||||
releaseCard?: () => void;
|
||||
/** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름). */
|
||||
@@ -216,11 +216,17 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
|
||||
}
|
||||
currentChainageM = null; // 폼 = 이 구조물 — 옛 관을 붙든 채 남지 않게(165-2)
|
||||
// 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다.
|
||||
preferredId = structure.structure_id ?? null;
|
||||
deps.focusChainage(focusOf(structure));
|
||||
// 노면공 = 카드 하나 안 고름 · 범위 카드 전부 테두리 · 그 카드로 스크롤만 · 폼 유지(177-4)
|
||||
const pave = !coverable(structure);
|
||||
const id = structure.structure_id ?? null;
|
||||
preferredId = id;
|
||||
deps.focusChainage(focusOf(structure), pave);
|
||||
preferredId = null;
|
||||
// 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2)
|
||||
deps.markStructure?.(structure.structure_id ?? null);
|
||||
deps.markStructure?.(id);
|
||||
// 카드 고름 리스너를 안 거치니 세션 고름은 여기서(B05 로 가도 그 노면공)
|
||||
const at = { chainageM: focusOf(structure), structureId: id };
|
||||
if (pave) selectDesign(deps.projectId, at, { from: "b06" });
|
||||
},
|
||||
getInterval: deps.stationInterval,
|
||||
// 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다.
|
||||
|
||||
@@ -235,7 +235,7 @@ export function createSectionView(
|
||||
|
||||
/**
|
||||
* 고른 구간형 구조물(옹벽 · 독립 흙막이 · 사토장)의 시작~종료 안 횡단 카드 전부에 테두리 강조(106-1).
|
||||
* 노면공(G군)은 테두리 대신 범위 카드 안 그 노면공의 포장층만 강조(152-5) — 겹친 노면공은 범위 표식으로 하나만.
|
||||
* 노면공(G군)도 테두리 + 범위 카드 안 그 노면공의 포장층 강조(152-5 · 177-4) — 겹친 노면공은 범위 표식으로 하나만.
|
||||
* 가장 가까운 한 장의 `--selected` 는 그대로 — 클래스만 갈아 끼워 줌·팬을 지킨다.
|
||||
*/
|
||||
let spanMarks: ReadonlyArray<StructureInstance> = [];
|
||||
@@ -257,7 +257,7 @@ export function createSectionView(
|
||||
: undefined;
|
||||
const span = inSpan(section, mark);
|
||||
const pave = span && isPavement(spanMarkId);
|
||||
card.classList.toggle("b06-cross-card--span", span && !pave);
|
||||
card.classList.toggle("b06-cross-card--span", span);
|
||||
highlightPavement(card, pave ? mark : undefined);
|
||||
};
|
||||
const syncSpanHighlights = (): void => {
|
||||
@@ -576,6 +576,15 @@ export function createSectionView(
|
||||
);
|
||||
}
|
||||
},
|
||||
revealStation(stationId) {
|
||||
// 노면공 고름 = 카드 하나 안 고름 · 옛 카드는 리스너 없이 풂(폼 유지) · 그 카드로 스크롤만(177-4)
|
||||
const previous = selectedStationId;
|
||||
selectedStationId = null;
|
||||
activeAreaKey = null;
|
||||
markCard(previous);
|
||||
profile.showCard(null);
|
||||
revealCard(stationId, "smooth");
|
||||
},
|
||||
selectMark: (structureId, span = true) => {
|
||||
// span 끔 = 알약만 · 카드는 고른 측점 하나(137-4 — 측점 고름 리스너) · 노면공 포장층은 테두리가 아니라 늘 켬(165-2)
|
||||
spanMarkId = span || isPavement(structureId) ? structureId : null;
|
||||
|
||||
@@ -265,7 +265,7 @@ export function createSectionProfile(
|
||||
},
|
||||
selectMark(structureId) {
|
||||
markedId = structureId;
|
||||
profile?.setSelectedStructure(structureId);
|
||||
profile?.setSelectedStructure(structureId, true); // 카드가 측점선 주인 — 선 없는 구조물 · 풂은 카드 선 지킴(177-3)
|
||||
},
|
||||
showCard(section) {
|
||||
if (!profile) return;
|
||||
@@ -280,7 +280,7 @@ export function createSectionProfile(
|
||||
// 일반 측점은 측점선 짝 알약이 없어 꺼짐 — 고른 구조물이 이 측점을 덮으면 그 알약을 지킴(53-1)
|
||||
const marked = markedId ? marks.find((entry) => entry.structure_id === markedId) : undefined;
|
||||
if (!pipe && marked && coversChainage(marked, section.chainage_m))
|
||||
profile.setSelectedStructure(markedId);
|
||||
profile.setSelectedStructure(markedId, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface SectionViewController {
|
||||
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
|
||||
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */
|
||||
focusStation: (stationId: string | null) => void;
|
||||
/** 카드를 고르지 않고 스크롤만 — 고른 카드는 알림 없이 풂(노면공 목록 고름 · 177-4). */
|
||||
revealStation: (stationId: string) => void;
|
||||
/** 종단 알약 강조를 그 구조물로(null = 풀기 — 카드 측점선 짝을 따름).
|
||||
* `span` = 구간형 범위 안 카드 전부 테두리(기본) · 끄면 카드는 고른 측점 하나(137-4). */
|
||||
selectMark: (structureId: string | null, span?: boolean) => void;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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;
|
||||
@@ -43,3 +48,28 @@
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-hand td:first-child {
|
||||
background-image: linear-gradient(to right, var(--color-primary) 3px, transparent 3px);
|
||||
}
|
||||
|
||||
/* 181-2 표 보기 — 목록표에서 고른 호표 줄 구간 전체 붉은 외곽선(머리 accent 밑줄은 덮음) */
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-pick td {
|
||||
--pick-t: inset 0 0 0 0 transparent;
|
||||
--pick-b: inset 0 0 0 0 transparent;
|
||||
--pick-l: inset 0 0 0 0 transparent;
|
||||
--pick-r: inset 0 0 0 0 transparent;
|
||||
box-shadow: var(--pick-t), var(--pick-b), var(--pick-l), var(--pick-r);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-pick.is-pick-top td {
|
||||
--pick-t: inset 0 2px 0 var(--color-danger);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-pick.is-pick-end td {
|
||||
--pick-b: inset 0 -2px 0 var(--color-danger);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-pick td:first-child {
|
||||
--pick-l: inset 2px 0 0 var(--color-danger);
|
||||
}
|
||||
|
||||
.b09-estimation .ui-data-table__table tbody tr.is-pick td:last-child {
|
||||
--pick-r: inset -2px 0 0 var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -56,6 +56,24 @@ export const TABS: Array<[TabId, string]> = [
|
||||
|
||||
export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] ?? id;
|
||||
|
||||
/** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */
|
||||
export const TAB_GROUPS: Array<[string, TabId[]]> = [
|
||||
["연결 점검", ["check"]],
|
||||
["공사원가계산서", ["cost"]],
|
||||
["총괄설계내역서", ["total"]],
|
||||
["설계내역서", ["bill"]],
|
||||
["일위대가", ["dg_list", "dg"]],
|
||||
["단가산출", ["sg_list", "sg"]],
|
||||
["환율및기초자료", ["basis"]],
|
||||
["중기", ["machine", "machine_blocks", "sum_machine", "expense", "sum_expense"]],
|
||||
["재료", ["material", "compare", "sum_material"]],
|
||||
["노무", ["labor", "sum_labor"]],
|
||||
];
|
||||
|
||||
/** 탭이 든 위 탭 묶음 */
|
||||
export const groupOf = (tab: TabId): [string, TabId[]] =>
|
||||
TAB_GROUPS.find(([, tabs]) => tabs.includes(tab)) ?? TAB_GROUPS[0];
|
||||
|
||||
/** 다른 탭 줄 — 키가 같은 줄로 이동 */
|
||||
export interface Link {
|
||||
tab: TabId;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Groups.ts
|
||||
* B09 묶음 탭 셋(PLAN 171-3) — 일위대가표 · 중기사용료 · 단가산출근거 = 공용 묶음 카드(171-2) 기본 ·
|
||||
* [표로 보기] = 공용 표 그대로.
|
||||
* [표로 보기] = 공용 표 그대로(맞은편 단추 = [박스보기]).
|
||||
*
|
||||
* 카드 = 탭 표 줄(`TabTable.rows`)을 호표(줄 키 앞머리)마다 묶음 — 값 · 풍선 · 누름 이동은 표와 같음
|
||||
* 머리 줄 = 묶음 첫 줄 · 합계 줄 = 키 끝 `|합계` · `|총계` · 그 밖 = 몸 줄(표 줄 번호 그대로)
|
||||
* 목록표에서 온 호표(181-2) = 박스 붉은 외곽선 · 표 보기 = 그 호표 줄 구간 전체 붉은 외곽선
|
||||
* 보기 · 가로 열 수 기억 = `b_page_state`(`b09-group-view` · `b09-group-cols` · 탭마다) · 못 읽으면 카드 · 2
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -29,8 +30,7 @@ export const GROUP_TABS: TabId[] = ["dg", "machine_blocks", "sg"];
|
||||
export const isGroupTab = (tab: TabId): boolean => GROUP_TABS.includes(tab);
|
||||
|
||||
/** 단추 글 = 바꿀 보기 이름 */
|
||||
export const viewLabel = (view: GroupView): string =>
|
||||
view === "cards" ? "표로 보기" : "호표별 보기";
|
||||
export const viewLabel = (view: GroupView): string => (view === "cards" ? "표로 보기" : "박스보기");
|
||||
|
||||
export const flipView = (view: GroupView): GroupView => (view === "cards" ? "table" : "cards");
|
||||
|
||||
@@ -146,6 +146,22 @@ export function tabGroups(tab: TabId, spec: TabTable): GroupCard[] {
|
||||
return cards;
|
||||
}
|
||||
|
||||
/** 고른 호표의 표 줄 구간 [첫 줄, 끝 줄](키 `|` 앞머리가 같은 줄) · 없으면 null */
|
||||
export function pickRange(rows: DataRow[], focus: string | undefined): [number, number] | null {
|
||||
if (focus === undefined) return null;
|
||||
const head = focus.split("|")[0];
|
||||
const at = rows.flatMap((r, i) => (str(r.key).split("|")[0] === head ? [i] : []));
|
||||
return at.length ? [at[0], at[at.length - 1]] : null;
|
||||
}
|
||||
|
||||
/** 표 줄 class — 구간 안 `is-pick` · 첫 줄 `is-pick-top` · 끝 줄 `is-pick-end` */
|
||||
export function pickClass(range: [number, number] | null, i: number): string | null {
|
||||
if (!range || i < range[0] || i > range[1]) return null;
|
||||
return ["is-pick", i === range[0] && "is-pick-top", i === range[1] && "is-pick-end"]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** 표 열과 같은 숫자 꼴(열 소수 자리) */
|
||||
function numberFormat(spec: TabTable, key: string): (v: number | null | undefined) => string {
|
||||
const c = spec.columns.find((col) => col.key === key);
|
||||
@@ -168,7 +184,7 @@ export function renderGroupPanel(o: {
|
||||
hint: HTMLElement | null;
|
||||
focus?: string;
|
||||
goto: (link: Link | null) => void;
|
||||
makeTable: () => DataTable;
|
||||
makeTable: (rowClass?: (index: number) => string | null) => DataTable;
|
||||
redraw: () => void;
|
||||
}): HTMLElement[] {
|
||||
const view = chosen.get(o.tab) ?? readGroupView(o.tab);
|
||||
@@ -201,7 +217,8 @@ export function renderGroupPanel(o: {
|
||||
cards.dataset.b09Table = o.tab;
|
||||
return [bar, cards];
|
||||
}
|
||||
const table = o.makeTable();
|
||||
const range = pickRange(o.spec.rows, o.focus);
|
||||
const table = o.makeTable((i) => pickClass(range, i));
|
||||
if (at >= 0) requestAnimationFrame(() => table.focusRow(at));
|
||||
return [bar, table.root];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.까닭}` : ""}`,
|
||||
|
||||
@@ -3333,6 +3333,8 @@
|
||||
"inlet.before": "기준측점 전 (m)",
|
||||
"inlet.after": "기준측점 후 (m)",
|
||||
"inlet.animal": "동물이동형",
|
||||
"inlet.animal=true": "유",
|
||||
"inlet.animal=false": "무",
|
||||
"outlet.structure": "구조",
|
||||
"outlet.structure=기슭막이": "흙막이",
|
||||
"outlet.form": "형태",
|
||||
|
||||
@@ -57,6 +57,7 @@ const { taubin } = loadTs(B05("B05_Profile_UI_Corridor_Loft_Grid"));
|
||||
const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split"));
|
||||
const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain"));
|
||||
const PSLG = loadTs(B05("B05_Profile_UI_Corridor_Tin_Pslg"));
|
||||
const { SEAM_M } = loadTs(B05("B05_Profile_UI_Corridor_Tin"));
|
||||
|
||||
const HALF = 3;
|
||||
const ROAD_Z = 100;
|
||||
@@ -257,7 +258,7 @@ function gapEdges(tris, out, original) {
|
||||
return { gaps, maxGapLen };
|
||||
}
|
||||
|
||||
/** 공사 밖(설계 구역 · 구멍 밖 · 둘레에서 1 mm 넘게 떨어진) 원래 정점 중 결과 망에서 사라진 수. */
|
||||
/** 공사 밖(설계 구역 · 구멍 밖 · 둘레에서 SEAM_M 넘게 떨어진) 원래 정점 중 결과 망에서 사라진 수. */
|
||||
function lostVertices(tris, before, out) {
|
||||
const region = new PSLG.LoopIndex([out.design.outer, ...out.design.holes]);
|
||||
const border = new PSLG.SegGrid(2);
|
||||
@@ -274,7 +275,7 @@ function lostVertices(tris, before, out) {
|
||||
for (let v = 0; v < before.length / 3; v += 1) {
|
||||
const x = before[v * 3];
|
||||
const z = before[v * 3 + 2];
|
||||
if (region.contains(x, z) || border.passesNear(x, z, 1e-3)) continue;
|
||||
if (region.contains(x, z) || border.passesNear(x, z, SEAM_M)) continue;
|
||||
if (!present.has(key(x, before[v * 3 + 1], z))) lost += 1;
|
||||
}
|
||||
return lost;
|
||||
@@ -450,6 +451,8 @@ function daylightPoints(out, bounds) {
|
||||
let sectionErr = 0;
|
||||
for (const side of [1, -1]) {
|
||||
for (let s = 0.05; s <= 12; s += 0.05) {
|
||||
// 비탈 끝(s 9) 바깥 이음 띠(SEAM_M + 격자 0.5 m)는 지형 굴곡을 긴 삼각형으로 펴므로 뺀다(176-1).
|
||||
if (s > 9 && s < 9 + SEAM_M + 0.5) continue;
|
||||
const z = tin(0, -side * (HALF + s)); // 씬 x = 20 − 20
|
||||
if (z === null) continue;
|
||||
sectionErr = Math.max(sectionErr, Math.abs(z + 100 - Math.max(ROAD_Z - s / 1.5, 94)));
|
||||
|
||||
@@ -202,6 +202,13 @@ const view = {
|
||||
profile.showCard(CARDS.find((card) => card.station_id === this.selected) ?? null);
|
||||
if (this.selected !== previous) this.listener(this.selected);
|
||||
},
|
||||
revealed: null,
|
||||
revealStation(id) {
|
||||
// 노면공 목록 고름 = 카드 하나 안 고름 · 리스너 없이 풂 · 스크롤만(177-4 · Section_View `revealStation`)
|
||||
this.selected = null;
|
||||
profile.showCard(null);
|
||||
this.revealed = id;
|
||||
},
|
||||
focusStation(id) {
|
||||
if (id === null) this.selected = null;
|
||||
else if (this.selected !== id) this.selectStation(id);
|
||||
@@ -214,11 +221,12 @@ const view = {
|
||||
};
|
||||
|
||||
let wallSelecting = false;
|
||||
const focusStationAt = (at) => {
|
||||
const focusStationAt = (at, revealOnly = false) => {
|
||||
const nearest = CARDS.reduce((best, card) =>
|
||||
Math.abs(card.chainage_m - at) < Math.abs(best.chainage_m - at) ? card : best,
|
||||
);
|
||||
view.focusStation(nearest.station_id);
|
||||
if (revealOnly) view.revealStation(nearest.station_id);
|
||||
else view.focusStation(nearest.station_id);
|
||||
};
|
||||
|
||||
const current = { structures: [PAVE] };
|
||||
@@ -248,6 +256,7 @@ async function build(list) {
|
||||
form.id = null;
|
||||
form.pipe = null;
|
||||
view.selected = null;
|
||||
view.revealed = null;
|
||||
view.spanMarkId = null;
|
||||
profile.markedId = null;
|
||||
profile.pill = null;
|
||||
@@ -256,8 +265,8 @@ async function build(list) {
|
||||
shared: { view: () => "cross" },
|
||||
detail: () => ({ cross_sections: CARDS }),
|
||||
stationInterval: () => 20,
|
||||
focusChainage: (at) => {
|
||||
if (!wallSelecting) focusStationAt(at);
|
||||
focusChainage: (at, revealOnly) => {
|
||||
if (!wallSelecting) focusStationAt(at, revealOnly);
|
||||
},
|
||||
markStructure: (id) => view.selectMark(id),
|
||||
onMarks: (next, types) => {
|
||||
@@ -308,7 +317,11 @@ const wallPick = (cardAtM, ownerM) => {
|
||||
await build([PAVE, { structure_id: "sign", type_id: "hazard_sign", chainage_m: 127.86 }]);
|
||||
listPick("pave");
|
||||
out.step1 = snap();
|
||||
cardPick(40); // 고름이 이미 연 범위 첫 카드 재누름(브레인 ORCA ②)
|
||||
out.reveal1 = view.revealed; // 고르지 않고 스크롤만 한 카드(177-4)
|
||||
out.pick1 = selection.readSelection(PID);
|
||||
cardPick(40); // 스크롤만 한 범위 첫 카드 누름
|
||||
out.step2first = snap();
|
||||
cardPick(40); // 고른 범위 카드 재누름(167-3 · 브레인 ORCA ②)
|
||||
out.step2re = snap();
|
||||
cardPick(140);
|
||||
out.step2 = snap();
|
||||
@@ -318,6 +331,7 @@ const wallPick = (cardAtM, ownerM) => {
|
||||
out.step3 = snap();
|
||||
pillPick("pave");
|
||||
out.step4 = snap();
|
||||
out.reveal4 = view.revealed;
|
||||
cardPick(80); // 관 자리 카드 = 관(지금처럼)
|
||||
out.pipeCard = snap();
|
||||
pillPick("pave");
|
||||
@@ -339,6 +353,7 @@ const wallPick = (cardAtM, ownerM) => {
|
||||
await build([PAVE, PAVE2]);
|
||||
listPick("pave2");
|
||||
out.step6 = snap();
|
||||
out.reveal6 = view.revealed;
|
||||
cardPick(160);
|
||||
out.step6b = snap();
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/* PLAN 176-1 단위검증 헬퍼 — B05 3D 성토 비탈 끝 이음 띠(SEAM_M) · 성토 덩이 끝 뜸 줄이기.
|
||||
* 직선 노선 80 m · 측점 0 · 20 · 40 성토(노면 100 · 지반 94 · 1:1.5) · 60 · 80 절토(지반 104 · 1:1).
|
||||
* 지반에 노선 방향 사인 물결(진폭 1 m · 파장 10 m · 측점 자리 0) → 성토 비탈 끝이 한도까지 뜸.
|
||||
* 트랜스파일 틀 · 격자 지형 · 틈 셈은 helper_172_1_loft_wrinkle.cjs 와 같다. 결과는 표준출력 JSON.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const NM = path.join(ROOT, "config", "node_modules");
|
||||
const ts = require(path.join(NM, "typescript"));
|
||||
const THREE = require(path.join(NM, "three"));
|
||||
const LIBS = {
|
||||
three: THREE,
|
||||
delaunator: require(path.join(NM, "delaunator", "index.js")),
|
||||
"three/examples/jsm/utils/BufferGeometryUtils.js": require(path.join(NM, "three", "examples", "jsm", "utils", "BufferGeometryUtils.js")),
|
||||
"@kninnug/constrainautor": {
|
||||
__esModule: true,
|
||||
default: require(path.join(NM, "@kninnug", "constrainautor", "lib", "Constrainautor.cjs")),
|
||||
},
|
||||
};
|
||||
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (LIBS[request]) return LIBS[request];
|
||||
if (request.startsWith("@ui/") || request.startsWith("@config/")) {
|
||||
return new Proxy({}, { get: () => () => undefined });
|
||||
}
|
||||
const target = request.startsWith("@util/")
|
||||
? path.join(ROOT, "common_util", request.slice(6))
|
||||
: path.join(path.dirname(full), request);
|
||||
if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8"));
|
||||
return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts"));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`);
|
||||
const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build"));
|
||||
const { loftTerrain } = loadTs(B05("B05_Profile_UI_Corridor_Loft"));
|
||||
const { TerrainBandSplit } = loadTs(B05("B05_Profile_UI_Corridor_Split"));
|
||||
const { TerrainHeightIndex } = loadTs(B05("B05_Profile_UI_Corridor_Terrain"));
|
||||
const PSLG = loadTs(B05("B05_Profile_UI_Corridor_Tin_Pslg"));
|
||||
|
||||
const { SEAM_M } = loadTs(B05("B05_Profile_UI_Corridor_Tin"));
|
||||
|
||||
const HALF = 3;
|
||||
const ROAD_Z = 100;
|
||||
const LENGTH = 80;
|
||||
const STATIONS = [0, 20, 40, 60, 80];
|
||||
const AMP = 1;
|
||||
const WAVE_M = 10;
|
||||
const r6 = (v) => Math.round(v * 1e6) / 1e6;
|
||||
|
||||
/** 측점 단면 — 성토(지반 94 · 1:1.5) 또는 절토(지반 104 · 1:1). */
|
||||
function station(chainage) {
|
||||
const fill = chainage <= 40;
|
||||
const ground = fill ? 94 : 104;
|
||||
const toe = HALF + Math.abs(ROAD_Z - ground) * (fill ? 1.5 : 1);
|
||||
const pts = [
|
||||
[-40, ground],
|
||||
[-toe, ground],
|
||||
[-HALF, ROAD_Z],
|
||||
[-2.5, ROAD_Z],
|
||||
[2.5, ROAD_Z],
|
||||
[HALF, ROAD_Z],
|
||||
[toe, ground],
|
||||
[40, ground],
|
||||
];
|
||||
const samples = [];
|
||||
for (let o = -40; o <= 40; o += 1) samples.push({ offset_m: o, elevation_m: ground });
|
||||
return {
|
||||
station_id: `S${chainage}`,
|
||||
chainage_m: chainage,
|
||||
label: "",
|
||||
kind: "regular",
|
||||
center_x: chainage,
|
||||
center_y: 0,
|
||||
center_z: ground,
|
||||
frame: { left_xy: [0, 1] },
|
||||
samples,
|
||||
design: {
|
||||
section_mode: fill ? "both_fill" : "both_cut",
|
||||
road_edges: {
|
||||
left: { offset_m: HALF, elevation_m: ROAD_Z },
|
||||
right: { offset_m: -HALF, elevation_m: ROAD_Z },
|
||||
},
|
||||
carriageway_edges: {
|
||||
left: { offset_m: 2.5, elevation_m: ROAD_Z },
|
||||
right: { offset_m: -2.5, elevation_m: ROAD_Z },
|
||||
},
|
||||
design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
|
||||
ditch: { type: "none" },
|
||||
ditch_enabled: false,
|
||||
ditch_side: "left",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const BOUNDS = { x: [-10, 90], y: [-30, 30], z: [85, 115] };
|
||||
const OX = (BOUNDS.x[0] + BOUNDS.x[1]) / 2;
|
||||
const OY = (BOUNDS.y[0] + BOUNDS.y[1]) / 2;
|
||||
const OZ = (BOUNDS.z[0] + BOUNDS.z[1]) / 2;
|
||||
const X0 = -10;
|
||||
const X1 = 90;
|
||||
const Y0 = -30;
|
||||
const Y1 = 30;
|
||||
const STEP = 0.5;
|
||||
|
||||
function gridTerrain(heightAt) {
|
||||
const nx = Math.round((X1 - X0) / STEP) + 1;
|
||||
const ny = Math.round((Y1 - Y0) / STEP) + 1;
|
||||
const positions = new Float32Array(nx * ny * 3);
|
||||
for (let j = 0; j < ny; j += 1) {
|
||||
for (let i = 0; i < nx; i += 1) {
|
||||
const v = j * nx + i;
|
||||
const x = X0 + i * STEP;
|
||||
const y = Y0 + j * STEP;
|
||||
positions[v * 3] = x - OX;
|
||||
positions[v * 3 + 1] = heightAt(x, y) - OZ;
|
||||
positions[v * 3 + 2] = -(y - OY);
|
||||
}
|
||||
}
|
||||
const index = [];
|
||||
for (let j = 0; j < ny - 1; j += 1) {
|
||||
for (let i = 0; i < nx - 1; i += 1) {
|
||||
const a = j * nx + i;
|
||||
if ((i + j) % 2) index.push(a, a + 1, a + nx, a + 1, a + nx + 1, a + nx);
|
||||
else index.push(a, a + nx + 1, a + nx, a, a + 1, a + nx + 1);
|
||||
}
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(new Uint8Array(nx * ny * 4).fill(200), 4, true));
|
||||
geometry.setIndex(index);
|
||||
return new THREE.Mesh(geometry, new THREE.MeshLambertMaterial({ vertexColors: true }));
|
||||
}
|
||||
|
||||
const key = (x, y, z) => `${Math.round(x * 1e4)},${Math.round(y * 1e4)},${Math.round(z * 1e4)}`;
|
||||
const pkey = (x, z) => `${Math.round(x * 1e4)},${Math.round(z * 1e4)}`;
|
||||
const fkey = (x, z) => pkey(Math.fround(x), Math.fround(z));
|
||||
|
||||
/** 한 번만 쓰인 변 중 지형 바깥 테두리 · 노면 끝 · 구멍 둘레가 아닌 것(틈). */
|
||||
function gapCount(root, out) {
|
||||
const pslg = out.design.pslg;
|
||||
const tagAt = new Map();
|
||||
for (let i = 0; i < pslg.count; i += 1) tagAt.set(fkey(pslg.x[i], pslg.z[i]), pslg.tag[i]);
|
||||
const onFrame = (p) => {
|
||||
const x = p[0] + OX;
|
||||
const y = -p[2] + OY;
|
||||
return Math.abs(x - X0) < 1e-3 || Math.abs(x - X1) < 1e-3 || Math.abs(y - Y0) < 1e-3 || Math.abs(y - Y1) < 1e-3;
|
||||
};
|
||||
const uses = new Map();
|
||||
root.traverse((child) => {
|
||||
if (!(child instanceof THREE.Mesh)) return;
|
||||
const p = child.geometry.getAttribute("position");
|
||||
const idx = child.geometry.getIndex();
|
||||
const count = idx ? idx.count : p.count;
|
||||
for (let t = 0; t < count; t += 3) {
|
||||
const tri = [0, 1, 2].map((k) => {
|
||||
const v = idx ? idx.getX(t + k) : t + k;
|
||||
return [p.getX(v), p.getY(v), p.getZ(v)];
|
||||
});
|
||||
const [a, b, c] = tri;
|
||||
if (Math.abs((b[0] - a[0]) * (c[2] - a[2]) - (c[0] - a[0]) * (b[2] - a[2])) < 1e-9) continue;
|
||||
for (let k = 0; k < 3; k += 1) {
|
||||
const ka = key(...tri[k]);
|
||||
const kb = key(...tri[(k + 1) % 3]);
|
||||
const e = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
|
||||
const entry = uses.get(e) ?? { n: 0, a: tri[k], b: tri[(k + 1) % 3] };
|
||||
entry.n += 1;
|
||||
uses.set(e, entry);
|
||||
}
|
||||
}
|
||||
});
|
||||
let gaps = 0;
|
||||
for (const { n, a, b } of uses.values()) {
|
||||
if (n !== 1) continue;
|
||||
const ta = tagAt.get(pkey(a[0], a[2]));
|
||||
const tb = tagAt.get(pkey(b[0], b[2]));
|
||||
if (ta === PSLG.TAG_ANCHOR && tb === PSLG.TAG_ANCHOR) continue;
|
||||
if (ta === PSLG.TAG_HOLE && tb === PSLG.TAG_HOLE) continue;
|
||||
if (onFrame(a) && onFrame(b)) continue;
|
||||
gaps += 1;
|
||||
}
|
||||
return gaps;
|
||||
}
|
||||
|
||||
/** 성토 비탈 끝 둘레 점 · 이음 띠 경사 · 덩이 끝 뜸 · 틈. */
|
||||
function run() {
|
||||
const base = (x) => (x <= 40 ? 94 : x >= 60 ? 104 : 94 + (x - 40) / 2);
|
||||
const terrain = gridTerrain((x) => base(x) + AMP * Math.sin((2 * Math.PI * x) / WAVE_M));
|
||||
const sections = STATIONS.map(station);
|
||||
const build = buildCorridor(sections, [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: LENGTH, y: 0 },
|
||||
]);
|
||||
build.sections = sections;
|
||||
build.structures = [];
|
||||
const split = new TerrainBandSplit(terrain, { minX: -1e4, maxX: 1e4, minZ: -1e4, maxZ: 1e4 });
|
||||
const heightIndex = new TerrainHeightIndex(terrain);
|
||||
const out = loftTerrain(split, build, BOUNDS, heightIndex);
|
||||
const pslg = out.design.pslg;
|
||||
const groundAt = (x, y) => heightIndex.heightAt(x - OX, -(y - OY)) + OZ;
|
||||
|
||||
// 성토 비탈 끝(설계 둘레 · 성토 꼬리표) — 측마다 높이 − 지형 · 덩이 끝(가장 큰 x) 점.
|
||||
const outer = new Set(out.design.outer.map(([x, z]) => pkey(x, z)));
|
||||
const rim = new Set();
|
||||
const ends = { 1: { x: -Infinity, off: 0 }, [-1]: { x: -Infinity, off: 0 } };
|
||||
let midOff = 0;
|
||||
for (let i = 0; i < pslg.count; i += 1) {
|
||||
if (pslg.tag[i] !== PSLG.TAG_FILL || !outer.has(pkey(pslg.x[i], pslg.z[i]))) continue;
|
||||
rim.add(pkey(pslg.x[i], pslg.z[i]));
|
||||
const x = pslg.x[i] + OX;
|
||||
const y = -pslg.z[i] + OY;
|
||||
const off = Math.abs(pslg.y[i] + OZ - groundAt(x, y));
|
||||
if (x > 5 && x < 35) midOff = Math.max(midOff, off);
|
||||
const end = ends[y > 0 ? 1 : -1];
|
||||
if (x > end.x) Object.assign(end, { x, off });
|
||||
}
|
||||
|
||||
// 이음 띠 — 성토 비탈 끝 점을 쓰는 원지반 색 삼각형의 경사(°).
|
||||
const mesh = out.root.getObjectByName("terrain-tin-mesh");
|
||||
const p = mesh.geometry.getAttribute("position");
|
||||
const keep = mesh.userData.keepColor;
|
||||
const slopes = [];
|
||||
for (let t = 0; t < p.count / 3; t += 1) {
|
||||
if (keep[t * 3]) continue;
|
||||
const v = [0, 1, 2].map((k) => [p.getX(t * 3 + k), p.getY(t * 3 + k), p.getZ(t * 3 + k)]);
|
||||
if (!v.some(([x, , z]) => rim.has(pkey(x, z)))) continue;
|
||||
// 지반 바탕이 평탄한 성토 구간만(지형 자체 경사 ≤ 32°) — 40~60 m 오르막은 지형이 45° 넘음.
|
||||
const cx = (v[0][0] + v[1][0] + v[2][0]) / 3 + OX;
|
||||
if (cx < 2 || cx > 38) continue;
|
||||
const u = [0, 1, 2].map((k) => v[1][k] - v[0][k]);
|
||||
const w = [0, 1, 2].map((k) => v[2][k] - v[0][k]);
|
||||
const n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]];
|
||||
const len = Math.hypot(...n);
|
||||
if (len < 1e-9) continue;
|
||||
slopes.push((Math.acos(Math.abs(n[1]) / len) * 180) / Math.PI);
|
||||
}
|
||||
slopes.sort((a, b) => a - b);
|
||||
return {
|
||||
seam: SEAM_M,
|
||||
stats: out.stats,
|
||||
gaps: gapCount(out.root, out),
|
||||
rimPoints: rim.size,
|
||||
seamTris: slopes.length,
|
||||
seamP99: r6(slopes[Math.floor(0.99 * (slopes.length - 1))] ?? 0),
|
||||
midOff: r6(midOff),
|
||||
endX: [r6(ends[1].x), r6(ends[-1].x)],
|
||||
endOff: r6(Math.max(ends[1].off, ends[-1].off)),
|
||||
};
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(run()));
|
||||
@@ -0,0 +1,64 @@
|
||||
/* 177-3 횡단 카드 ↔ 종단 측점선 헬퍼 — 진짜 design_profile.ts 의 `setSelectedStation` · `setSelectedStructure`
|
||||
* 본문을 떼어(TS 는 그 자리 트랜스파일) 측점 · 구조물 짝 흉내에 붙이고, B06 카드 고름 차례
|
||||
* (showCard → setSelectedStation · 리스너 → selectMark → setSelectedStructure(mark, true))와 B05 기본 길을 돌려
|
||||
* 남은 측점선 id 를 표준출력 JSON 한 벌로.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const src = fs.readFileSync(path.join(ROOT, "A00_Common", "design", "design_profile.ts"), "utf8");
|
||||
const method = (name) => {
|
||||
const pattern = String.raw`\r?\n ${name}\([^\r\n]*\) \{\r?\n[\s\S]*?\r?\n \},`;
|
||||
const match = src.match(new RegExp(pattern));
|
||||
if (!match) throw new Error(`본문 없음: ${name}`);
|
||||
return match[0];
|
||||
};
|
||||
const body = `
|
||||
let selectedStationId = null;
|
||||
let selectedStructureId = null;
|
||||
// 관(pipe-80.17)만 측점선(irr-80)이 있음 — 벽 · 노면공은 선 없음(PST stationIdAtStructure)
|
||||
const structureIdAtStation = (stationId) => (stationId === "irr-80" ? "pipe-80.17" : null);
|
||||
const stationIdAtStructure = (structureId) => (structureId === "pipe-80.17" ? "irr-80" : null);
|
||||
const draw = () => {};
|
||||
module.exports = {
|
||||
line: () => selectedStationId,
|
||||
${method("setSelectedStation")}
|
||||
${method("setSelectedStructure")}
|
||||
};`;
|
||||
const js = ts.transpileModule(body, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", js)(box.exports, box);
|
||||
const dp = box.exports;
|
||||
|
||||
/** B06 카드 고름 — showCard(카드 · 관 측점이면 관 선) 뒤 리스너가 selectMark(mark) = keepStation true. */
|
||||
const b06Card = (lineId, mark) => {
|
||||
dp.setSelectedStation(lineId);
|
||||
dp.setSelectedStructure(mark, true);
|
||||
return dp.line();
|
||||
};
|
||||
/** B05 목록 고름 — 기본값(keepStation 없음). */
|
||||
const b05Pick = (lineId, structureId) => {
|
||||
dp.setSelectedStation(lineId);
|
||||
dp.setSelectedStructure(structureId);
|
||||
return dp.line();
|
||||
};
|
||||
|
||||
const out = {
|
||||
plainCard: b06Card("s40", null), // 일반 카드 — 덮는 것 없음
|
||||
wallCard: b06Card("s100", "wall"), // 벽(선 없는 구조물) 카드
|
||||
paveCard: b06Card("s140", "pave"), // 노면공 범위 안 카드
|
||||
pipeCard: b06Card("irr-80", "pipe-80.17"), // 관 카드 = 관 선
|
||||
pipeFromPlain: b06Card("s60", "pipe-80.17"), // 관 알약 = 관 선으로 옮김
|
||||
// 반대 길 — 종단 측점선 누름 → 카드 고름 → 같은 리스너(selectMark null)
|
||||
profileClick: b06Card("s200", null),
|
||||
// 카드 풂 = showCard(null) → 선 끔
|
||||
release: (dp.setSelectedStation(null), dp.line()),
|
||||
b05Wall: b05Pick("s100", "wall"), // B05 = 지금 그대로(선 없는 구조물 → 선 끔)
|
||||
b05Pipe: b05Pick("s40", "pipe-80.17"),
|
||||
b05Keep: b05Pick("irr-80", "pipe-80.17"),
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -58,6 +58,7 @@ class E {
|
||||
get className() { return [...this.cls].join(" "); }
|
||||
set className(v) { this.cls = new Set(String(v).split(/\s+/).filter(Boolean)); }
|
||||
append(...xs) { for (const x of xs) { x.parent = this; this.children.push(x); } }
|
||||
replaceChildren(...xs) { this.children = []; this.append(...xs); }
|
||||
prepend(...xs) { for (const x of xs.reverse()) { x.parent = this; this.children.unshift(x); } }
|
||||
addEventListener(t, f) { (this.on[t] ??= []).push(f); }
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); }
|
||||
|
||||
@@ -32,8 +32,8 @@ def test_범위_강조_클래스는_고름_목록갱신_카드재생성_세_길
|
||||
r"spanMarks = structures;\s+spanTypes = types;\s+syncSpanHighlights\(\);", VIEW
|
||||
)
|
||||
assert VIEW.count("applySpan(card, section);") == 2
|
||||
# 152-5 노면공(G군)은 테두리 대신 포장층
|
||||
assert 'card.classList.toggle("b06-cross-card--span", span && !pave);' in VIEW
|
||||
# 152-5 노면공(G군) 포장층 · 177-4 범위 카드 테두리도 함께
|
||||
assert 'card.classList.toggle("b06-cross-card--span", span);' in VIEW
|
||||
|
||||
|
||||
def test_측점_고름은_카드_하나_목록_고름은_여럿():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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_기억_실패_때_카드():
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"""165 B06 노면공 고름 일관성 — 조사 K 재현 차례 6단계.
|
||||
|
||||
못박는 것(단계마다 폼 · 알약 · 포장층 · 고른 카드 · 세션 sid 가 같은 것을 가리킴)
|
||||
1. 목록 노면공 = 범위 안 카드 · 포장층 · 알약 · 폼 · sid 모두 노면공(기준점이 범위 경계여도 범위 밖 카드로 안 감).
|
||||
1. 목록 노면공 = 포장층 · 알약 · 폼 · sid 모두 노면공 · 카드는 안 고름(스크롤만 · 177-4 · 기준점이 범위 경계여도 범위 밖 카드로 안 감).
|
||||
2. 범위 안 빈 카드(관 · 덮는 구조물 없음) = 노면공 그대로 · 카드만 옮김(165-1).
|
||||
3. 고른 카드의 벽 = 관 폼 + 관 알약(알약이 꺼진 채 폼만 남지 않음 · 165-2) · 포장층 풂.
|
||||
4. 알약 다시 = 1 과 같음. 관 자리 카드 = 관 · 범위 밖 카드 = 모두 풂(지금처럼).
|
||||
@@ -55,10 +55,12 @@ PIPE = {"form": "pipe-80.17", "pill": "pipe-80.17", "paveLayer": None, "paveCard
|
||||
|
||||
|
||||
def test_1_목록_노면공(out):
|
||||
assert out["step1"] == _pave(40) # 기준점 30 = 범위 경계 → 범위 안 첫 카드(20 아님)
|
||||
assert out["step1"] == _pave(None) # 카드 하나 안 고름(177-4)
|
||||
assert out["reveal1"] == "s40" # 기준점 30 = 범위 경계 → 범위 안 첫 카드(20 아님)
|
||||
|
||||
|
||||
def test_2_범위_안_빈_카드는_노면공_그대로(out):
|
||||
assert out["step2first"] == _pave(40)
|
||||
assert out["step2re"] == _pave(40) # 이미 고른 카드 재누름도 그대로(167-3)
|
||||
assert out["step2"] == _pave(140)
|
||||
assert out["step2b"] == _pave(100)
|
||||
@@ -69,7 +71,7 @@ def test_3_벽은_관_폼과_관_알약_함께(out):
|
||||
|
||||
|
||||
def test_4_알약_다시_관_카드_범위_밖(out):
|
||||
assert out["step4"] == _pave(40)
|
||||
assert out["step4"] == _pave(None)
|
||||
assert out["pipeCard"] == {**PIPE, "card": 80, "sid": None}
|
||||
assert out["outside"] == {
|
||||
"form": None,
|
||||
@@ -83,17 +85,17 @@ def test_4_알약_다시_관_카드_범위_밖(out):
|
||||
|
||||
|
||||
def test_5_B05_복귀(out):
|
||||
assert out["step5"] == _pave(40)
|
||||
assert out["step5"] == _pave(None)
|
||||
|
||||
|
||||
def test_6_겹친_노면공(out):
|
||||
assert out["step6"] == _pave(100, "pave2", 6)
|
||||
assert out["step6"] == _pave(None, "pave2", 6)
|
||||
assert out["step6b"] == _pave(160, "pave2", 6)
|
||||
|
||||
|
||||
def test_배선():
|
||||
panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts")
|
||||
assert "deps.focusChainage(focusOf(structure));" in panel
|
||||
assert "deps.focusChainage(focusOf(structure), pave);" in panel
|
||||
assert "return { chainageM, structureId: openId };" in panel
|
||||
# 관 알약은 폼 뒤(폼이 연 구조물을 닫으며 알약을 끔)
|
||||
at = panel.index("section.selectPipeByChainage(hit.pipeChainageM);")
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""173-7 집수정 「동물이동형」 체크 칸 — 공용 체크박스 · 저장 키 inlet_basin_animal · 빈 값 = 참.
|
||||
"""173-7 · 177-2 집수정 「동물이동형」 칸 — 고르기 유 / 무 · 저장 키 inlet_basin_animal("true"/"false") · 빈 값 = 유.
|
||||
|
||||
공용 명세 폼(`design_structure_form.ts`)을 그 자리에서 돌린 값(`helper_173_7_inlet_animal.cjs`)
|
||||
+ 배관 폼 키 목록(`B05_Profile_UI_Drainage_Facility_Merge.ts`).
|
||||
@@ -28,14 +28,18 @@ def out() -> dict:
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def test_빈_값은_체크_저장_안_함(out: dict) -> None:
|
||||
assert out["blank"] == {"checked": True, "saved": None}
|
||||
YU_MU = [["true", "유"], ["false", "무"]]
|
||||
|
||||
|
||||
def test_끄면_false_다시_켜면_true(out: dict) -> None:
|
||||
assert out["off"] == {"checked": False, "saved": "false"}
|
||||
assert out["on"] == {"checked": True, "saved": "true"}
|
||||
assert out["storedOff"] == {"checked": False, "saved": "false"}
|
||||
def test_빈_값은_유_실제_값으로_적힘(out: dict) -> None:
|
||||
# 177-2 — 필수 상자 빈 고르기 칸 = 기본값을 실제 값으로(54-43) · 옛 「저장 안 함」 뒤집음
|
||||
assert out["blank"] == {"value": "true", "options": YU_MU, "saved": "true"}
|
||||
|
||||
|
||||
def test_무면_false_다시_유면_true(out: dict) -> None:
|
||||
assert out["off"] == {"value": "false", "options": YU_MU, "saved": "false"}
|
||||
assert out["on"] == {"value": "true", "options": YU_MU, "saved": "true"}
|
||||
assert out["storedOff"] == {"value": "false", "options": YU_MU, "saved": "false"}
|
||||
|
||||
|
||||
def test_흙막이엔_칸_없음(out: dict) -> None:
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""M02 일위대가 사전조합 자재목록(PLAN 175) — 구조물 일위대가와 같은 자재목록.
|
||||
|
||||
175-1 화면 · 저장 — 서고 모드 자재목록 · 로직 자재 세 칸 막힘 풂 · 저장 `_combo_lines` 가
|
||||
고름 · 자재표시 남김 · 문서 `자재`(일위대가 자재 검사 · 로직 자재와 같은 품목 400).
|
||||
175-2 셈 — 담은 자재(손 · 빈칸 = 안 셈)가 사전조합 1 단위 값(`Expand._combo` · 안 줄) · B08 참조 줄
|
||||
재료(`_ref_mats`) · B09 겹 대가 자재 줄 · 프로젝트 사본 단가 묶음(`frozen_materials`)에 들어감.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 임시 층 fixture
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SRC = os.path.join(HERE, "..", "..", "M02_MasterTemplete")
|
||||
NET = "GC000337" # 낙석방지망 — 재료 줄 3 · 4 · 5(철망 · 결속선 · 에폭시)
|
||||
NET_ITEM = "MT006697"
|
||||
WIRE = "MT000194"
|
||||
MAT = "MT000008" # 자재품목 · 물가정보 83000
|
||||
STONE = "GF000219" # 원/㎡
|
||||
SYS = {"층": "system", "주인": None}
|
||||
|
||||
|
||||
def _save(folder, doc):
|
||||
with store.at(folder):
|
||||
key = refs.create("precombo", {"이름": "자재 조합", **doc})["이름"]
|
||||
return key, json.loads((folder / "precombo" / f"{key}.json").read_bytes())
|
||||
|
||||
|
||||
def test_화면_서고_모드도_자재목록_세_칸_자재_저장(world): # noqa: F811
|
||||
detail = open(os.path.join(SRC, "M02_MasterTemplete_Labor_Detail.ts"), encoding="utf-8").read()
|
||||
assert 'section(tc("Labor_Mats"), opt.card ? null : addMat, matHost),' in detail
|
||||
assert 'lib ? [] : [section(tc("Labor_Mats")' not in detail
|
||||
assert "mark: markLogicMat," in detail and "lib ? undefined" not in detail
|
||||
assert "delete out.자재" not in detail and "delete out.열" in detail
|
||||
|
||||
|
||||
def test_저장_왕복_고름_자재표시_자재_남음(world): # noqa: F811
|
||||
line = {"로직": NET, "고름": {"4": NET_ITEM}, "자재표시": {"3": {"자재구분": "관급"}}}
|
||||
mats = [
|
||||
{"코드": MAT, "수량": {"손": 2}, "운반비": "미포함", "이름": "틀린 이름"},
|
||||
{"코드": WIRE, "수량": None},
|
||||
]
|
||||
_, got = _save(world["system"], {"담은로직": [line], "자재": mats})
|
||||
one = got["담은로직"][0]
|
||||
assert one["고름"] == {"4": NET_ITEM} and one["자재표시"] == {"3": {"자재구분": "관급"}}
|
||||
assert [(m["코드"], m["수량"]) for m in got["자재"]] == [(MAT, {"손": 2}), (WIRE, None)]
|
||||
assert got["자재"][0]["운반비"] == "미포함" and got["자재"][0]["이름"] != "틀린 이름"
|
||||
# 자재 없음 = 빈 목록(옛 문서 꼴 그대로 읽힘)
|
||||
_, empty = _save(world["system"], {"담은로직": [{"로직": NET}]})
|
||||
assert empty["자재"] == []
|
||||
|
||||
|
||||
def test_로직_자재와_같은_품목_400_없는_자재_400(world): # noqa: F811
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
_save(world["system"], {"담은로직": [{"로직": NET}], "자재": [{"코드": WIRE}]})
|
||||
assert e.value.status == 400 and WIRE in str(e.value.detail)
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
_save(world["system"], {"담은로직": [], "자재": [{"코드": "MT999999"}]})
|
||||
assert e.value.status == 400
|
||||
|
||||
|
||||
# ── 175-2 셈 ──────────────────────────────────────────────────────────
|
||||
def _price(code):
|
||||
whole = m01.loaded()[1]
|
||||
return Decimal(str(labor.material_price(whole, code, None)["기준단가"]))
|
||||
|
||||
|
||||
def _world_combo(world): # noqa: F811
|
||||
"""STONE 1 + MAT 손 2 + WIRE 빈칸(안 셈) · NET 고름 4 = NET_ITEM — (키, 손셈 1 단위 값)."""
|
||||
lines = [
|
||||
{"로직": STONE, "수량": {"손": 1}},
|
||||
{"로직": NET, "수량": {"손": 0}, "고름": {"4": NET_ITEM}},
|
||||
]
|
||||
mats = [{"코드": MAT, "수량": {"손": 2}}, {"코드": WIRE, "수량": None}]
|
||||
with store.at(world["system"]):
|
||||
key = refs.create("precombo", {"이름": "자재 조합", "담은로직": lines, "자재": mats})[
|
||||
"이름"
|
||||
]
|
||||
stone = Decimal(str(labor.run_line(m01.loaded()[1], STONE, {})["기준"]["계"]))
|
||||
return key, stone + _price(MAT) * 2
|
||||
|
||||
|
||||
def test_사전조합_값에_자재_금액_빈칸은_안_셈_안_줄에_자재(world): # noqa: F811
|
||||
key, want = _world_combo(world)
|
||||
with store.at(world["system"]):
|
||||
doc = labor.clean(
|
||||
{"열": "rv", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]}
|
||||
)
|
||||
one = labor.calc(doc, {})["줄"][0]
|
||||
assert one["기준"]["계"] == pytest.approx(float(want))
|
||||
assert one["반영"]["계"] == pytest.approx(float(want * 3))
|
||||
mats = [x for x in one["안"] if x.get("자재")]
|
||||
assert [(x["자재"], x["수량"]) for x in mats] == [(MAT, 2)] # WIRE 빈칸 = 안 셈
|
||||
assert mats[0]["기준"]["재료비"] == pytest.approx(float(_price(MAT)))
|
||||
# 목록 기준금액(51-4)도 같은 펼침
|
||||
rows = [{"종류": "precombo", "이름": key}]
|
||||
refs.summaries(world["system"], rows)
|
||||
assert rows[0]["기준금액"] == pytest.approx(float(want))
|
||||
|
||||
|
||||
def test_B08_참조_줄_재료에_사전조합_자재_손_곱(world): # noqa: F811
|
||||
from B08_Quantity.B08_Quantity_Router_Material import _ref_mats
|
||||
|
||||
key, _ = _world_combo(world)
|
||||
with store.at(world["system"]):
|
||||
outer = refs.create(
|
||||
"precombo",
|
||||
{"이름": "겹", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]},
|
||||
)["이름"]
|
||||
got = _ref_mats(m01.loaded()[1], refs.read_ref, {**SYS, "키": outer})
|
||||
mats = [(m["품목"]["키"], q) for m, q in got if m.get("자재")]
|
||||
assert mats == [(MAT, pytest.approx(6.0))] # 2 × 겹 손 3 · WIRE 빈칸 없음
|
||||
|
||||
|
||||
def test_B09_겹_대가에_자재_줄(world): # noqa: F811
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import Builder
|
||||
|
||||
key, _ = _world_combo(world)
|
||||
ref = {**SYS, "키": key}
|
||||
build = Builder()
|
||||
bid = build.precombo(m01.loaded()[1], ref, refs.read_ref(ref), {})
|
||||
mats = [x for x in build.blocks[bid]["lines"] if x["kind"] == "material"]
|
||||
assert [(x["key"], x["qty"]) for x in mats] == [(MAT, 2.0), (WIRE, None)]
|
||||
assert mats[0]["name"] and mats[0]["unit"]
|
||||
|
||||
|
||||
def test_사본_단가_묶음에_사전조합_자재_고름_사본_값_같음(world): # noqa: F811
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
from M02_MasterTemplete import M02_Item_Source as source
|
||||
|
||||
key, want = _world_combo(world)
|
||||
doc = {"열": "rv", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 1}}], "자재": []}
|
||||
with store.at(world["system"]):
|
||||
rows = source.labor_doc_rows(labor.clean(doc))
|
||||
mats = refs.frozen_materials(rows["참조사본"])
|
||||
assert {MAT, WIRE, NET_ITEM} <= set(mats)
|
||||
assert "refs.frozen_materials(" in open(source.__file__, encoding="utf-8").read()
|
||||
bundle = prices.to_json(prices.collect(refs.frozen_logics(rows["참조사본"]), mats))
|
||||
(world["system"] / "precombo" / f"{key}.json").unlink() # 원본이 사라져도 굳힌 사본
|
||||
got = prices.calc(rows, bundle, {}, {}, None)["줄"][0]
|
||||
assert got["기준"]["계"] == pytest.approx(float(want), rel=1e-9)
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 176-1 — B05 3D 성토 비탈 끝 깨짐(172 뒤) 고침 검사.
|
||||
|
||||
도우미(helper_176_1_seam.cjs) — 직선 80 m · 성토(0~40 m) → 절토(60~80 m) · 지반 사인 물결(진폭 1 m).
|
||||
비탈 끝 바깥 SEAM_M(1.5 m) 안 지형 점 빼서 잇는 띠 완만 · 성토 덩이 끝 3 m 안 뜸 0 까지 줄임.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_176_1_seam.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=240,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_seam_band_gentle(r):
|
||||
# 성토 비탈 끝을 쓰는 원지반 삼각형 경사 p99 < 45°(1 mm 띠 = 54.7°)
|
||||
assert r["seam"] == 1.5
|
||||
assert r["seamTris"] > 100, r
|
||||
assert r["seamP99"] < 45, r
|
||||
|
||||
|
||||
def test_run_end_lift_tapered(r):
|
||||
# 성토 덩이 가운데는 한도까지 뜸(물결이 실제로 재어짐) · 절토로 바뀌는 끝은 0 근처
|
||||
assert r["midOff"] > 0.4, r
|
||||
assert r["endX"][0] > 45 and r["endX"][1] > 45, r
|
||||
assert r["endOff"] < 0.02, r
|
||||
|
||||
|
||||
def test_no_gap_no_failed_constraint(r):
|
||||
assert r["gaps"] == 0, r
|
||||
assert r["stats"]["failed"] == 0, r
|
||||
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 176-2 — B05 3D 흑백 지형 · 등고선 처음 값 false(단추 · 뷰어 두 곳 다)."""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
|
||||
|
||||
|
||||
def _read(name):
|
||||
with open(os.path.join(ROOT, "B05_Profile", name), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_panel_buttons_start_off():
|
||||
src = _read("B05_Profile_UI_Panel_View.ts")
|
||||
assert re.search(r'toggleButton\("등고선", false,', src)
|
||||
assert re.search(r'toggleButton\("흑백 지형", false,', src)
|
||||
|
||||
|
||||
def test_viewer_starts_off():
|
||||
src = _read("B05_Profile_UI_Viewer.ts")
|
||||
assert "let surfaceGrayscale = false;" in src
|
||||
# 등고선 그룹은 만들자마자 숨김(적재 때 선은 그대로 만들고 단추로 켬)
|
||||
assert re.search(
|
||||
r"const contours = new THREE\.Group\(\);\s*\n\s*contours\.visible = false;", src
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 176-3 — B05 3D 측점 노란 띠 끝 주황 구슬(측구 방향 고르기) · 클릭 · 처리기 지움.
|
||||
|
||||
저장 길(세션 uphill → uphill_overrides)과 B06 이 쓰는 종단 병합 함수는 남김.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
|
||||
|
||||
|
||||
def _read(*parts):
|
||||
with open(os.path.join(ROOT, *parts), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_markers_have_no_lamp():
|
||||
src = _read("B05_Profile", "B05_Profile_UI_Markers.ts")
|
||||
for gone in ("uphillStationId", "onUphillPick", "0xf97316", "UPHILL_LAMP", "lampHit"):
|
||||
assert gone not in src, gone
|
||||
|
||||
|
||||
def test_page_has_no_lamp_handler():
|
||||
src = _read("A00_Common", "design", "design_view_profile.ts")
|
||||
assert "onUphillPick" not in src
|
||||
assert "uphillOverrides" not in _read("B05_Profile", "B05_Profile_UI_Page_Stations.ts")
|
||||
|
||||
|
||||
def test_save_path_and_merge_kept():
|
||||
# 저장 길(세션 → 저장 몸통)과 B06 이 부르는 종단 병합 함수는 그대로
|
||||
page = _read("A00_Common", "design", "design_view_profile.ts")
|
||||
assert "persistUphillOverrides" in page
|
||||
assert "uphill_overrides" in _read("B05_Profile", "B05_Profile_UI_Page_Actions.ts")
|
||||
assert "def _merge_uphill_overrides_into_longitudinal" in _read(
|
||||
"B05_Profile", "B05_Profile_Router_Confirm.py"
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 177-1 — 구조물 조합 미연결 라벨 = 연한 노랑(경고) 배경 + 테두리(소스 시험)."""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
CSS = os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Style_Structures.css")
|
||||
|
||||
|
||||
def _rule(sel: str) -> str:
|
||||
src = open(CSS, encoding="utf-8").read()
|
||||
m = re.search(re.escape(sel) + r"\s*\{([^}]*)\}", src)
|
||||
assert m, sel
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def test_미연결_배경_테두리_경고색_글자색_없음():
|
||||
body = _rule(".b05-structure__summary-name:not(.is-assigned)")
|
||||
assert re.search(r"border-color:\s*var\(--color-warning\)", body)
|
||||
assert re.search(r"background:\s*color-mix\([^;]*--color-warning[^;]*--color-surface", body)
|
||||
assert not re.search(r"(^|[;\s])color:", body)
|
||||
|
||||
|
||||
def test_테마_토큰_밝음_어두움_둘다():
|
||||
th = open(os.path.join(ROOT, "ui_template", "ui_template_theme.css"), encoding="utf-8").read()
|
||||
assert len(re.findall(r"--color-warning:\s*#", th)) >= 2
|
||||
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 177-2 — 동물이동형 = 고르기(유 / 무) · 체크박스 꼴(bool) 지움 · 화면 값은 helper_173_7."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
ROOT = HERE.parents[1]
|
||||
DESIGN = ROOT / "A00_Common" / "design"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
if not (ROOT / "config" / "node_modules").is_dir():
|
||||
pytest.skip("의존성 미설치")
|
||||
done = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_173_7_inlet_animal.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def test_체크박스_없음(out: dict) -> None:
|
||||
assert out["checkbox"] is False
|
||||
|
||||
|
||||
def test_bool_꼴_지움() -> None:
|
||||
spec = (DESIGN / "design_structure_spec.ts").read_text("utf-8")
|
||||
form = (DESIGN / "design_structure_form.ts").read_text("utf-8")
|
||||
pipe = (DESIGN / "design_structure_pipe.ts").read_text("utf-8")
|
||||
assert '"bool"' not in spec and '"bool"' not in form and '"bool"' not in pipe
|
||||
assert "createCheckbox" not in form
|
||||
@@ -0,0 +1,72 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""177-3 B06 횡단 카드 고름 → 종단 측점 세로선 유지(반대 방향도).
|
||||
|
||||
못박는 것
|
||||
- 카드 고름 뒤 리스너의 selectMark(mark) 가 측점선을 끄지 않음 — 일반 · 벽 · 노면공 카드 = 그 카드 선.
|
||||
- 관 카드 · 관 알약 = 관 측점선 · 카드 풂 = 선 끔.
|
||||
- 종단 측점선 누름(반대 길)도 같은 리스너 — 누른 선 그대로.
|
||||
- B05(keepStation 기본값) = 지금 그대로(선 없는 구조물 → 선 끔).
|
||||
|
||||
Node 헬퍼 `helper_177_3_station_line_keep.cjs` 가 진짜 design_profile.ts 본문을 돌림. 화면은 사용자 확인.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
|
||||
|
||||
def _read(*parts: str) -> str:
|
||||
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_177_3_station_line_keep.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_카드_고름은_그_카드_측점선_유지(out):
|
||||
assert out["plainCard"] == "s40"
|
||||
assert out["wallCard"] == "s100"
|
||||
assert out["paveCard"] == "s140"
|
||||
|
||||
|
||||
def test_관은_관_측점선(out):
|
||||
assert out["pipeCard"] == "irr-80"
|
||||
assert out["pipeFromPlain"] == "irr-80"
|
||||
|
||||
|
||||
def test_반대_길_종단_측점선_누름도_유지(out):
|
||||
assert out["profileClick"] == "s200"
|
||||
assert out["release"] is None
|
||||
|
||||
|
||||
def test_B05_기본_길은_그대로(out):
|
||||
assert out["b05Wall"] is None
|
||||
assert out["b05Pipe"] == "irr-80"
|
||||
assert out["b05Keep"] == "irr-80"
|
||||
|
||||
|
||||
def test_배선():
|
||||
svp = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts")
|
||||
assert "profile?.setSelectedStructure(structureId, true);" in svp
|
||||
assert "profile.setSelectedStructure(markedId, true);" in svp
|
||||
cross = _read("A00_Common", "design", "design_view_cross.ts")
|
||||
assert "sectionView.selectMark(mark, false);" in cross
|
||||
b05 = _read("A00_Common", "design", "design_view_profile.ts")
|
||||
assert ", true)" not in "".join(
|
||||
line for line in b05.splitlines() if "setSelectedStructure(" in line
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""177-4 B06 노면공 목록 · 알약 고름 = 범위 카드 전부 테두리(박스) · 카드 하나 안 고름.
|
||||
|
||||
못박는 것
|
||||
- 노면공 고름 = 범위 안 카드 전부 `b06-cross-card--span`(다른 구간형과 같음) + 포장층 강조 그대로.
|
||||
- 카드 하나 고르기(`--selected`) 없음 · 범위 첫 카드로 스크롤만 · 옛 고른 카드는 리스너 없이 풂(폼 · 알약 유지).
|
||||
- 세션 고름은 노면공 + 스크롤한 카드 측점(B05 로 가도 그 노면공).
|
||||
|
||||
Node 헬퍼 `helper_165_pavement_flow.cjs`(진짜 SPN) 결과를 같이 씀. 화면은 사용자 확인.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
|
||||
|
||||
def _read(*parts: str) -> str:
|
||||
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_165_pavement_flow.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_노면공_고름은_카드_안_고르고_스크롤만(out):
|
||||
for step, reveal, layer, cards in (
|
||||
("step1", "reveal1", "pave", 12),
|
||||
("step4", "reveal4", "pave", 12),
|
||||
("step6", "reveal6", "pave2", 6),
|
||||
):
|
||||
snap = out[step]
|
||||
assert snap["card"] is None # --selected 0
|
||||
assert snap["form"] == layer and snap["pill"] == layer # 폼 · 알약 유지
|
||||
assert snap["paveCards"] == cards # 범위 카드 전부
|
||||
assert out[reveal] in ("s40", "s100")
|
||||
assert out["pick1"] == {"chainageM": 40, "structureId": "pave"}
|
||||
|
||||
|
||||
def test_범위_카드_테두리는_노면공도():
|
||||
view = _read("B06_Section", "B06_Section_UI_Section_View.ts")
|
||||
assert 'card.classList.toggle("b06-cross-card--span", span);' in view
|
||||
assert "highlightPavement(card, pave ? mark : undefined);" in view
|
||||
|
||||
|
||||
def test_스크롤만_길은_리스너를_안_거침():
|
||||
view = _read("B06_Section", "B06_Section_UI_Section_View.ts")
|
||||
body = re.search(r"revealStation\(stationId\) \{(.*?)\n \},", view, re.S)
|
||||
assert body, "revealStation 없음"
|
||||
text = body.group(1)
|
||||
assert "selectedStationId = null;" in text
|
||||
assert "profile.showCard(null);" in text
|
||||
assert 'revealCard(stationId, "smooth");' in text
|
||||
assert "stationSelectListener" not in text # 폼을 닫지 않음
|
||||
cross = _read("A00_Common", "design", "design_view_cross.ts")
|
||||
assert "if (revealOnly) sectionView.revealStation(nearest.station_id);" in cross
|
||||
panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts")
|
||||
assert "const pave = !coverable(structure);" in panel
|
||||
assert 'if (pave) selectDesign(deps.projectId, at, { from: "b06" });' in panel
|
||||
@@ -0,0 +1,53 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""177-5 좌측 폼 「측점 범위」 줄 — 사면안정군(C군)만 숨김.
|
||||
|
||||
못박는 것
|
||||
- PNL5 `syncRangeDisplay` 가 C군이면 `rangeWrap.hidden` · 다른 구간형(B 소단배수로 · E 대피소 등)은 그대로.
|
||||
- `hasComputedRange` 자체는 그대로(시작 · 종료 칸 숨김에도 씀).
|
||||
- C군 = 사면안정 6종(정의 기준) · 그 밖 범위 종류는 C 가 아님.
|
||||
|
||||
화면은 사용자 확인.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
|
||||
|
||||
|
||||
def _read(*parts: str) -> str:
|
||||
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
|
||||
return handle.read()
|
||||
|
||||
|
||||
def test_범위_줄은_C군만_숨김():
|
||||
panel = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts")
|
||||
at = panel.index("function syncRangeDisplay(): void {")
|
||||
body = panel[at : panel.index("\n }", at)]
|
||||
assert (
|
||||
'const computed = hasComputedRange(currentType()) && currentType()?.group !== "C";' in body
|
||||
)
|
||||
assert "rangeWrap.hidden = !computed;" in body
|
||||
|
||||
|
||||
def test_hasComputedRange_는_그대로():
|
||||
types = _read("B05_Profile", "B05_Profile_UI_Structures_Panel_Types.ts")
|
||||
assert "export function hasComputedRange(" in types
|
||||
assert '"C"' not in types[types.index("export function hasComputedRange(") :][:600]
|
||||
|
||||
|
||||
def test_C군은_사면안정_6종_다른_범위_종류는_C_아님():
|
||||
groups = {t.type_id: t.group for t in load_structure_types()}
|
||||
slope = {
|
||||
"retaining_wall",
|
||||
"masonry_wet",
|
||||
"masonry_dry",
|
||||
"soil_guard",
|
||||
"boulder_masonry",
|
||||
"berm",
|
||||
}
|
||||
assert {k for k, g in groups.items() if g == "C"} == slope
|
||||
for kept in ("ditch_berm", "refuge", "work_yard", "guardrail"):
|
||||
assert groups.get(kept) not in (None, "C"), kept
|
||||
@@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 179-1 — 선 길이 정보창 = 열 수 무관 늘 줌 [+ − ⤢] 왼쪽(좁은 카드 내림 규칙 지움 · 소스 시험)."""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def _src(rel: str) -> str:
|
||||
return open(os.path.join(ROOT, rel), encoding="utf-8").read()
|
||||
|
||||
|
||||
def test_좁은_카드_내림_규칙_없음():
|
||||
css = _src("B06_Section/B06_Section_UI_Style_Cross_Controls.css")
|
||||
assert "@container" not in css
|
||||
# 치수 칸 규칙은 기본 꼴 · 숨김(+ 옆 단추) 뿐 — 절대 자리 · 왼선 지움 규칙 없음
|
||||
assert re.findall(r"\.b06-cross-card__line-dims[^{,;]*\{", css) == [
|
||||
".b06-cross-card__line-dims {",
|
||||
".b06-cross-card__line-dims[hidden] {",
|
||||
".b06-cross-card__line-dims[hidden] + .b06-cross-card__zoom-btn {",
|
||||
]
|
||||
|
||||
|
||||
def test_치수_칸은_줌_막대_맨_앞():
|
||||
view = _src("B06_Section/B06_Section_UI_Cross_View.ts")
|
||||
assert "zoomBar.prepend(linePick.label);" in view
|
||||
zoom = (
|
||||
_src("B06_Section/B06_Section_UI_Style_Cross_Controls.css")
|
||||
.split(".b06-cross-card__zoom {", 1)[1]
|
||||
.split("}", 1)[0]
|
||||
)
|
||||
assert "display: flex;" in zoom and "position: absolute;" in zoom
|
||||
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""PLAN 179-2 — 측구 줄 컴팩트(W16 (가)): 막힌 단추 숨김 · 「측구형식」 글 지움(툴팁) · 알약에 편측 쪽(소스 시험)."""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def _src(rel: str) -> str:
|
||||
return open(os.path.join(ROOT, rel), encoding="utf-8").read()
|
||||
|
||||
|
||||
def test_막힌_묶음은_숨김():
|
||||
css = _src("B06_Section/B06_Section_UI_Style_Cross.css")
|
||||
body = re.search(r"\.b06-design__seg--disabled\s*\{([^}]*)\}", css).group(1)
|
||||
assert "display: none;" in body
|
||||
assert "opacity" not in body
|
||||
|
||||
|
||||
def test_측구형식_글_없이_툴팁():
|
||||
cd = _src("B06_Section/B06_Section_UI_Cross_Design.ts")
|
||||
seg = cd.split("const ditchTypeSeg = segment(", 1)[1].split(");", 1)[0]
|
||||
assert seg.lstrip().startswith('"",')
|
||||
assert 'ditchTypeSeg.title = L("B06_Design_DitchType_Legend");' in cd
|
||||
|
||||
|
||||
def test_넘침_셈은_숨긴_칸_폭_간격_뺌():
|
||||
cd = _src("B06_Section/B06_Section_UI_Cross_Design.ts")
|
||||
assert "overflow -= width ? width + barGapPx : 0;" in cd
|
||||
|
||||
|
||||
def test_알약에_편측_쪽():
|
||||
lb = _src("ui_template/ui_template_locale_b2.ts")
|
||||
assert 'B06_Design_Mode_LeftCut: ["편측 성토(좌 절토)"' in lb
|
||||
assert 'B06_Design_Mode_RightCut: ["편측 성토(우 절토)"' in lb
|
||||
@@ -0,0 +1,104 @@
|
||||
"""181-1 B09 탭 두 층 — 위 탭 10 · 아래 탭 묶음(사용자 30번) · 줄 그리기 · 마지막 본 아래 탭.
|
||||
|
||||
- 위 탭 = `TAB_GROUPS`(`TABS` 19 키를 빠짐 · 겹침 없이 묶음) · `TABS`(엑셀 차례)는 그대로
|
||||
- 줄 = 위 md · 아래 sm · 아래 탭 없는 위 탭 = 빈 보조 줄(B08 꼴)
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from resources.tester.helper_b09_group_cards import TABS, run # noqa: E402
|
||||
|
||||
BAR = "B09_Estimation/B09_Estimation_UI_TabBar.ts"
|
||||
|
||||
# 사용자 30번 — 위 탭 차례 = 지금 탭 차례(묶음 첫 자리) · 아래 = 글에 적힌 차례
|
||||
EXPECTED = [
|
||||
["연결 점검", ["check"]],
|
||||
["공사원가계산서", ["cost"]],
|
||||
["총괄설계내역서", ["total"]],
|
||||
["설계내역서", ["bill"]],
|
||||
["일위대가", ["dg_list", "dg"]],
|
||||
["단가산출", ["sg_list", "sg"]],
|
||||
["환율및기초자료", ["basis"]],
|
||||
["중기", ["machine", "machine_blocks", "sum_machine", "expense", "sum_expense"]],
|
||||
["재료", ["material", "compare", "sum_material"]],
|
||||
["노무", ["labor", "sum_labor"]],
|
||||
]
|
||||
|
||||
|
||||
def test_묶음_표_사용자_30번과_같고_19_탭이_한_번씩():
|
||||
out = run(
|
||||
[TABS],
|
||||
"\nconsole.log(JSON.stringify({ groups: TAB_GROUPS, ids: TABS.map(([t]) => t),"
|
||||
" of: TABS.map(([t]) => groupOf(t)[0]) }));",
|
||||
)
|
||||
assert out["groups"] == EXPECTED
|
||||
flat = [t for _, tabs in out["groups"] for t in tabs]
|
||||
assert sorted(flat) == sorted(out["ids"]) and len(flat) == len(set(flat)) == 19
|
||||
# 위 탭 차례 = 엑셀 차례에서 묶음이 처음 나오는 자리
|
||||
firsts = list(dict.fromkeys(out["of"]))
|
||||
assert firsts == [g[0] for g in EXPECTED]
|
||||
assert len(firsts) == 10
|
||||
|
||||
|
||||
def test_줄_그리기_두_층_빈_줄_마지막_본_아래_탭():
|
||||
out = run(
|
||||
[TABS, BAR],
|
||||
"""
|
||||
const picks = [];
|
||||
const bars = createTabBars((t) => picks.push(t));
|
||||
const view = () => ({
|
||||
top: bars.top.children.map((b) => [b.textContent, b.className, b.attrs['aria-selected'],
|
||||
b.dataset.b09Group, b.dataset.b09Tab ?? null]),
|
||||
sub: bars.sub.children.map((b) => [b.textContent, b.attrs['aria-selected'], b.dataset.b09Tab ?? null]),
|
||||
blank: bars.sub.classList.contains('b09-estimation__tabs--blank'),
|
||||
role: bars.sub.attrs.role,
|
||||
});
|
||||
bars.draw('dg');
|
||||
const a = view();
|
||||
bars.top.children[4].click(); // 켜진 위 탭 다시 누름 = 아무 일 없음
|
||||
bars.sub.children[0].click(); // 일위대가목록표
|
||||
bars.draw('compare'); // 묶음을 넘는 이동 = 위 탭 따라 켜짐
|
||||
const b = view();
|
||||
bars.draw('sum_material');
|
||||
bars.draw('dg');
|
||||
bars.top.children[8].click(); // 재료 = 마지막 본 아래 탭(재료비수량금액집계표)
|
||||
bars.top.children[7].click(); // 중기 = 본 적 없음 → 첫 아래 탭
|
||||
bars.draw('cost');
|
||||
const c = view();
|
||||
bars.top.children[0].click(); // 연결 점검 = 단독 위 탭
|
||||
bars.sub.children[0].click(); // 빈 줄 단추(숨김) = 누름 없음
|
||||
console.log(JSON.stringify({ a, b, c, picks }));
|
||||
""",
|
||||
)
|
||||
a, b, c = out["a"], out["b"], out["c"]
|
||||
assert len(a["top"]) == 10 and [t[0] for t in a["top"]][:5] == [
|
||||
"연결 점검", "공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가"
|
||||
] # fmt: skip
|
||||
assert [t[2] for t in a["top"]] == ["false"] * 4 + ["true"] + ["false"] * 5
|
||||
assert all("ui-btn--ghost" in t[1] for t in a["top"] if t[2] == "false")
|
||||
assert a["sub"] == [["일위대가목록표", "false", "dg_list"], ["일위대가표", "true", "dg"]]
|
||||
assert not a["blank"] and a["role"] == "tablist"
|
||||
assert b["sub"] == [
|
||||
["재료비목록표", "false", "material"],
|
||||
["자재단가대비표", "true", "compare"],
|
||||
["재료비수량금액집계표", "false", "sum_material"],
|
||||
] # fmt: skip
|
||||
assert b["top"][8][2] == "true" and b["top"][4][2] == "false"
|
||||
# 단독 위 탭 — 위 단추가 탭 키도 가짐 · 아래 줄 = 숨긴 단추 한 개
|
||||
assert c["blank"] and c["role"] == "presentation" and len(c["sub"]) == 1
|
||||
assert c["top"][1][3] == "cost" and c["top"][1][4] == "cost" and c["top"][4][4] is None
|
||||
assert out["picks"] == ["dg_list", "sum_material", "machine", "check"]
|
||||
|
||||
|
||||
def test_잇기_쉘이_두_줄을_쓰고_CSS_빈_줄_규칙():
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "createTabBars((tab) => show(tab))" in shell and "bars.draw(tab);" in shell
|
||||
assert "body.append(bars.top, bars.sub, panel);" in shell
|
||||
css = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
assert ".b09-estimation__tabs--blank > .ui-btn" in css and "visibility: hidden" in css
|
||||
@@ -0,0 +1,76 @@
|
||||
"""181-2 박스보기 — 이름 · 접기 없음 · 목록표에서 고른 호표 = 박스 붉은 외곽선 · 표 보기 = 줄 구간 전체.
|
||||
|
||||
- 단추 글 「박스보기」 ↔ 「표로 보기」 · 요약(접기) 보기 지움
|
||||
- 표 보기 = 고른 호표 줄 구간(키 `|` 앞머리 같은 줄) 모두 `is-pick` · 첫 줄 `is-pick-top` · 끝 줄 `is-pick-end`
|
||||
- 붉은 색 = `--color-danger`(B06 횡단 카드 고름 꼴)
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from resources.tester.helper_b09_group_cards import COMMON, GROUPS, TABS, run # noqa: E402
|
||||
|
||||
ROWS = [ # 호표 A(3줄) · 호표 B(4줄) · 호표 C(1줄)
|
||||
{"key": "A"}, {"key": "A|1"}, {"key": "A|합계"},
|
||||
{"key": "B"}, {"key": "B|1"}, {"key": "B|2"}, {"key": "B|총계"},
|
||||
{"key": "C"},
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def test_구간_첫_끝_줄():
|
||||
out = run(
|
||||
[COMMON, TABS, GROUPS],
|
||||
f"\nconst rows = {json.dumps(ROWS)};"
|
||||
"\nconst show = (focus) => { const r = pickRange(rows, focus);"
|
||||
" return [r, rows.map((_, i) => pickClass(r, i))]; };"
|
||||
"\nconsole.log(JSON.stringify({ a: show('A|1'), b: show('B'), c: show('C'),"
|
||||
" none: show(undefined), unknown: show('Z|1') }));",
|
||||
)
|
||||
assert out["a"][0] == [0, 2]
|
||||
assert out["a"][1] == [
|
||||
"is-pick is-pick-top", "is-pick", "is-pick is-pick-end", None, None, None, None, None,
|
||||
] # fmt: skip
|
||||
assert out["b"][0] == [3, 6]
|
||||
assert out["b"][1][3:7] == [
|
||||
"is-pick is-pick-top", "is-pick", "is-pick", "is-pick is-pick-end",
|
||||
] # fmt: skip
|
||||
assert out["b"][1][:3] == [None] * 3 # 다른 호표 없음
|
||||
assert out["c"][0] == [7, 7] and out["c"][1][7] == "is-pick is-pick-top is-pick-end"
|
||||
assert out["none"] == [None, [None] * 8] and out["unknown"] == [None, [None] * 8]
|
||||
|
||||
|
||||
def test_표_보기_줄_class_넘김():
|
||||
out = run(
|
||||
[COMMON, TABS, GROUPS],
|
||||
f"\nconst spec = {{ columns: [{{ key: 'key' }}], rows: {json.dumps(ROWS)} }};"
|
||||
"\nlet extra = null;"
|
||||
"\nconst o = { tab: 'dg', spec, hint: null, focus: 'B|1', goto: () => {}, redraw: () => {},"
|
||||
" makeTable: (e) => { extra = e; return { root: document.createElement('table'),"
|
||||
" focusRow: () => {} }; } };"
|
||||
"\nchosen.set('dg', 'table');"
|
||||
"\nrenderGroupPanel(o);"
|
||||
"\nconsole.log(JSON.stringify(spec.rows.map((_, i) => extra(i))));",
|
||||
)
|
||||
assert out == (
|
||||
[None] * 3 + ["is-pick is-pick-top", "is-pick", "is-pick", "is-pick is-pick-end"] + [None]
|
||||
)
|
||||
|
||||
|
||||
def test_글_접기_없음_CSS_잇기():
|
||||
groups = (ROOT / GROUPS).read_text("utf-8")
|
||||
assert '"표로 보기" : "박스보기"' in groups and "호표별 보기" not in groups
|
||||
common = (ROOT / COMMON).read_text("utf-8")
|
||||
for gone in ("isSummary", "GROUP_SUMMARY_OVER", "is-closed", "모두 펼침", "모두 접기"):
|
||||
assert gone not in common
|
||||
assert 'box?.classList.add("is-pick")' in common
|
||||
page = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Page.css").read_text("utf-8")
|
||||
for sel in ("tr.is-pick td", "tr.is-pick.is-pick-top td", "tr.is-pick.is-pick-end td"):
|
||||
assert sel in page
|
||||
assert page.count("var(--color-danger)") == 4 # 위 · 아래 · 왼 · 오른 2px
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "extra?: (index: number) => string | null" in shell
|
||||
@@ -125,7 +125,7 @@ def test_화면_칸_부품_사용자_줄_로직_줄_같은_칸():
|
||||
assert "...markCells(mat, on?.mark" in rows and "...markHeads()," in rows
|
||||
assert "...markCells(" in _src("M02_MasterTemplete_Labor_LogicMats.ts")
|
||||
detail = _src("M02_MasterTemplete_Labor_Detail.ts")
|
||||
assert "mark: lib ? undefined : markLogicMat" in detail
|
||||
assert "mark: markLogicMat," in detail and "lib ? undefined" not in detail # 사전조합도(175-1)
|
||||
assert "mark: markMat" in detail
|
||||
# 서버 허용 값 = 화면 허용 값
|
||||
api = _src("M02_MasterTemplete_Labor_Api.ts")
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
border-radius: var(--radius-cards);
|
||||
}
|
||||
|
||||
/* 목록표에서 고른 호표 — B06 횡단 카드 고름과 같은 붉은 외곽선(181-2) */
|
||||
.ui-group-card.is-pick {
|
||||
border-color: var(--color-danger);
|
||||
box-shadow: 0 0 0 2px var(--color-danger);
|
||||
}
|
||||
|
||||
.ui-group-card__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -76,14 +82,6 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ui-group-card.is-closed .ui-group-card__body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-group-card.is-closed .ui-group-card__head {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-group-card__lines {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
* 머리 = 호표 · 명칭 · 규격 · 단위 · 합계 / 몸 = 줄(이름 · 규격 · 수량 · 단위 · 합계 · 비고) /
|
||||
* 합계 줄 = 노무 · 재료 · 경비 · 계 · 줄 금액 = 합계만(비목 셋 = 풍선)
|
||||
* 글자 14px 한 벌 · 줄 간격 좁게 · 숫자 오른쪽 · 가로 열 수 1~4(기억은 부르는 쪽)
|
||||
* 묶음 12 넘으면 요약 보기(머리 · 합계만 · 머리 누름 = 펼침 · 모두 펼침 · 접기)
|
||||
* 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 펼침 + 스크롤 + 강조
|
||||
* 접기 없음 — 묶음 모두 펼친 박스
|
||||
* 줄 번호(`at`) = 부르는 쪽 표 줄 번호 — 누름 = `onPick(at)` · 이동해 온 줄 = 그 박스 붉은 외곽선
|
||||
* (`is-pick`) + 스크롤 + 그 줄 강조(`is-focus`)
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_group_cards.css";
|
||||
@@ -68,7 +69,6 @@ export interface GroupCardsOptions {
|
||||
actions?: HTMLElement[];
|
||||
}
|
||||
|
||||
export const GROUP_SUMMARY_OVER = 12;
|
||||
export const GROUP_COLUMNS = [1, 2, 3, 4] as const;
|
||||
export const PART_LABELS = ["노무", "재료", "경비"] as const;
|
||||
|
||||
@@ -76,9 +76,6 @@ export const PART_LABELS = ["노무", "재료", "경비"] as const;
|
||||
export const clampColumns = (value: unknown): number =>
|
||||
GROUP_COLUMNS.includes(value as 1) ? (value as number) : 2;
|
||||
|
||||
/** 요약 보기 — 묶음 12 넘음 */
|
||||
export const isSummary = (count: number): boolean => count > GROUP_SUMMARY_OVER;
|
||||
|
||||
const won = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 1 });
|
||||
const plain = (value: number | null | undefined): string =>
|
||||
value === null || value === undefined || !Number.isFinite(value) ? "" : won.format(value);
|
||||
@@ -173,7 +170,7 @@ function card(g: GroupCard, o: Required<Pick<GroupCardsOptions, "money" | "qty">
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 묶음 카드 판 — 머리 띠(가로 열 수 · 요약 단추 · 부르는 쪽 단추) + 카드 격자 */
|
||||
/** 묶음 카드 판 — 머리 띠(가로 열 수 · 부르는 쪽 단추) + 카드 격자 */
|
||||
export function createGroupCards(opts: GroupCardsOptions): HTMLElement {
|
||||
const fmt = { money: opts.money ?? plain, qty: opts.qty ?? plain };
|
||||
const root = document.createElement("div");
|
||||
@@ -184,9 +181,6 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement {
|
||||
grid.style.setProperty("--group-cols", String(columns));
|
||||
const cards = opts.groups.map((g) => card(g, fmt));
|
||||
grid.append(...cards);
|
||||
const summary = isSummary(opts.groups.length);
|
||||
const setOpen = (box: Element, open: boolean) => box.classList.toggle("is-closed", !open);
|
||||
if (summary) cards.forEach((c) => setOpen(c, false));
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "ui-group-cards__bar";
|
||||
@@ -215,14 +209,6 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement {
|
||||
});
|
||||
pick.append(...colButtons);
|
||||
bar.append(pick, span("ui-group-cards__count", `묶음 ${opts.groups.length}`));
|
||||
if (summary) {
|
||||
const all = (open: boolean) => () => cards.forEach((c) => setOpen(c, open));
|
||||
bar.append(
|
||||
span("ui-group-cards__count", "요약 보기 — 머리를 누르면 펼침"),
|
||||
createButton({ label: "모두 펼침", variant: "ghost", size: "sm", onClick: all(true) }),
|
||||
createButton({ label: "모두 접기", variant: "ghost", size: "sm", onClick: all(false) }),
|
||||
);
|
||||
}
|
||||
const tail = document.createElement("div");
|
||||
tail.className = "ui-group-cards__actions";
|
||||
tail.append(...(opts.actions ?? []));
|
||||
@@ -232,18 +218,15 @@ export function createGroupCards(opts: GroupCardsOptions): HTMLElement {
|
||||
grid.addEventListener("click", (event) => {
|
||||
const el = (event.target as Element).closest<HTMLElement>("[data-at]");
|
||||
if (!el || !grid.contains(el)) return;
|
||||
const box = el.closest(".ui-group-card");
|
||||
if (summary && box && el.classList.contains("ui-group-card__head"))
|
||||
return setOpen(box, box.classList.contains("is-closed"));
|
||||
opts.onPick?.(Number(el.dataset.at));
|
||||
});
|
||||
|
||||
if (opts.focus !== undefined && opts.focus >= 0) {
|
||||
const el = grid.querySelector<HTMLElement>(`[data-at="${opts.focus}"]`);
|
||||
const box = el?.closest(".ui-group-card");
|
||||
if (box) setOpen(box, true);
|
||||
box?.classList.add("is-pick");
|
||||
el?.classList.add("is-focus");
|
||||
requestAnimationFrame(() => el?.scrollIntoView({ block: "center" }));
|
||||
requestAnimationFrame(() => (box ?? el)?.scrollIntoView({ block: "center" }));
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -420,8 +420,8 @@ export const ui_locales_b2 = {
|
||||
B06_Design_Ground_Ripping: ["리핑암", "Ripping rock"],
|
||||
B06_Design_Ground_Blasting: ["발파암", "Blasting rock"],
|
||||
B06_Design_Mode_Legend: ["단면유형", "Section type"],
|
||||
B06_Design_Mode_LeftCut: ["편측 성토", "One-side fill"],
|
||||
B06_Design_Mode_RightCut: ["편측 성토", "One-side fill"],
|
||||
B06_Design_Mode_LeftCut: ["편측 성토(좌 절토)", "One-side fill (left cut)"],
|
||||
B06_Design_Mode_RightCut: ["편측 성토(우 절토)", "One-side fill (right cut)"],
|
||||
B06_Design_Mode_BothCut: ["양측 절토", "Both cut"],
|
||||
B06_Design_Mode_BothFill: ["양측 성토", "Both fill"],
|
||||
B06_Design_Ditch_Legend: ["측구위치", "Ditch side"],
|
||||
|
||||
Reference in New Issue
Block a user