Merge remote-tracking branches 'origin/sub_laptop_5' and 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-08 19:43:31 +09:00
13 changed files with 610 additions and 41 deletions
@@ -0,0 +1,60 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Ford_Fill.ts
* 우클릭으로 바로 추가한 세월교 · 물넘이포장의 빈 월류 높이 = 배수유역 재계산이 준 설계유량의
* 필요 수심(PLAN 126-1 · 폼 쪽 65-4 와 같은 풀이 `fordHeightM`).
*
* 기다리는 측점만 채움 · 사용자가 넣은 높이(응답 옵션에 있음)는 안 덮음 · 유량이 아직 없으면 다음
* 응답까지 기다림. 응답 옵션을 고쳐 두면 보관소(`replaceFromResponse`)가 그 값을 정본으로 받음.
* ========================================================================== */
import {
FORD_BRIDGE_DEFAULT_WIDTH_M,
FORD_PAVEMENT_DEFAULT_WIDTH_M,
} from "@config/config_frontend";
import { fordHeightM } from "../A00_Common/design/design_structure_ford_calc";
const FORD_KINDS: Record<string, number> = {
ford_bridge: FORD_BRIDGE_DEFAULT_WIDTH_M,
ford_pavement: FORD_PAVEMENT_DEFAULT_WIDTH_M,
};
/** 새로 놓으면 높이를 기다리는 종류 — 높이 칸이 빈 세월교 · 물넘이포장. */
export function waitsFordHeight(
facility: string | undefined,
options?: Record<string, string | number>,
): boolean {
return !!facility && facility in FORD_KINDS && options?.ford_height_m === undefined;
}
/** 기다리는 측점의 빈 높이를 채움 — 채운(또는 사용자 값이라 그만 기다릴) 측점은 `pending` 에서 뺌.
* 채운 것이 있으면 true(세션 초안에 다시 담을 것). */
export function fillFordHeights(
points: Array<{
chainage_m: number;
facility?: string;
options?: Record<string, string | number>;
}>,
basins: ReadonlyArray<{ chainage_m: number; design_flow_m3s?: number | null }>,
pending: Set<number>,
): boolean {
let filled = false;
for (const wait of [...pending]) {
const point = points.find((entry) => Math.abs(entry.chainage_m - wait) < 0.51);
if (!point?.facility || !(point.facility in FORD_KINDS)) {
pending.delete(wait); // 관이 사라졌거나 종류가 바뀜
continue;
}
if (point.options?.ford_height_m !== undefined && point.options.ford_height_m !== "") {
pending.delete(wait); // 사용자가 넣은 값 — 안 덮음
continue;
}
const flow = basins.find((entry) => Math.abs(entry.chainage_m - point.chainage_m) < 0.51);
const width = Number(point.options?.ford_width_m) || FORD_KINDS[point.facility];
const need = fordHeightM(flow?.design_flow_m3s ?? null, width);
if (need === null) continue; // 유량 아직 없음 — 다음 응답까지 빈칸
point.options = { ...point.options, ford_height_m: need };
pending.delete(wait);
filled = true;
}
return filled;
}
+18 -4
View File
@@ -33,6 +33,7 @@ import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report";
import { fillFordHeights, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill";
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
@@ -172,6 +173,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
let offsetY = 0;
let frameHandle = 0;
let loadSequence = 0;
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
/** 우클릭으로 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1). */
const fordWaits = new Set<number>();
let canvasWidth = 0;
let canvasHeight = 0;
let canvasDpr = 0;
@@ -346,8 +350,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
reason: pipe.source,
})),
);
// 시설 확장 정보(종류·구간·옵션)는 응답이 정본 — 통째로 다시 채운다.
// 시설 확장 정보(종류·구간·옵션)는 응답이 정본 — 통째로 다시 채운다(기다리던 월류 높이를 먼저 얹음).
const fordFilled = fillFordHeights(response.pipe_points, response.basins, fordWaits);
facilityStore.replaceFromResponse(response.pipe_points);
if (fordFilled) rememberPipes();
// 재계산 뒤에는 선택을 다시 알려야 한다 — 관을 새로 넣은 직후에는 목록이 아직
// 비어 폼이 열리지 못했고, 중복 억제 가드에 걸려 두 번째 기회를 잃는다.
lastNotifiedPipeChainage = null;
@@ -367,18 +373,24 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
status.hidden = false;
status.textContent = L("B05_Drainage_Status_Analyzing");
showProgress(null, L("B05_Drainage_Status_Analyzing"));
const seq = ++requestSeq;
try {
apply(await request());
const response = await request();
if (seq !== requestSeq) return; // 뒤에 보낸 요청이 있음 — 그 응답을 기다림
apply(response);
status.hidden = basins.length > 0;
if (basins.length === 0) status.textContent = L("B05_Drainage_Status_NoBasin");
scheduleDraw();
} catch (error) {
if (seq !== requestSeq) return;
status.hidden = false;
status.textContent =
error instanceof Error ? error.message : L("B05_Drainage_Status_AnalyzeFailed");
} finally {
analyzeButton.disabled = false;
showProgress(null, null);
if (seq === requestSeq) {
analyzeButton.disabled = false;
showProgress(null, null);
}
}
}
@@ -591,6 +603,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
addPipe(chainageM, attributes) {
// 시설 정보를 먼저 보관해야 addAtChainage가 촉발하는 재계산 요청에 실려 간다.
facilityStore.set(chainageM, attributes ?? null);
if (waitsFordHeight(attributes?.facility, attributes?.options)) fordWaits.add(chainageM);
pipeEditor.addAtChainage(chainageM);
},
updatePipeFacility(fromChainageM, toChainageM, attributes) {
@@ -598,6 +611,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
const previous = facilityStore.get(fromChainageM);
facilityStore.set(fromChainageM, null);
facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes));
if (fordWaits.delete(fromChainageM)) fordWaits.add(toChainageM);
if (Math.abs(fromChainageM - toChainageM) > 0.005) {
// 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고,
// attach가 새 위치로 시설 정보를 승계한다.
@@ -2,14 +2,19 @@
* B05_Profile_UI_Structures_Panel_AddAt.ts
* 종단 우클릭 → 종류 고름 = 「[추가]까지 끝난 상태」(PLAN 116-1) — 세 길(B05 종단 · B06 종단 ·
* 3D)이 함께 쓰는 패널 `addAt` 의 끝맺음. 필수 빈칸이 없는 종류는 그 측점에 바로 추가하고
* 새 항목을 고른 채로 둠 · 남는 종류는 폼만.
* 새 항목을 고른 채로 둠 · 남는 종류는 폼만. 세월교 · 물넘이포장도 바로 추가 — 빈 월류 높이는
* 배수유역 재계산 유량이 오면 그 항목에 채움(126-1 · `_Drainage_Ford_Fill`).
* ========================================================================== */
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types";
import { showToast } from "@ui/ui_template_elements";
/** 바로 추가하지 않는 종류 — 노면공(겹침 확인창) · 세월교 · 물넘이포장(월류 높이가 유량 뒤 채워짐). */
const FORM_ONLY = new Set(["pavement_concrete", "ford_bridge", "ford_pavement"]);
/** 바로 추가하지 않는 종류 — 노면공(겹침 확인창). */
const FORM_ONLY = new Set(["pavement_concrete"]);
/** 월류 높이를 유량 뒤에 받는 종류 — 추가 때 알림 한 줄. */
const FORD_KINDS = new Set(["ford_bridge", "ford_pavement"]);
export const FORD_WAIT_NOTICE = "월류 높이는 배수유역 재계산(설계유량)이 오면 채워집니다.";
/** 우클릭 한 번으로 [추가]까지 끝낼 수 있는지 — [추가]가 막는 빈 필수 칸이 없어야 함. */
export function addsAtOnce(
@@ -35,6 +40,7 @@ export async function finishAdd(ctx: {
// B05 는 재계산 뒤 배수유역 패널이 새 관 고름을 다시 알려 폼이 열림.
await ctx.commit();
ctx.pick.selectPipeByChainage(ctx.anchorM);
if (FORD_KINDS.has(ctx.type.type_id)) showToast(FORD_WAIT_NOTICE);
return true;
}
const before = new Set(ctx.structures.map((entry) => entry.structure_id));
+69
View File
@@ -7,6 +7,10 @@
* rockSplit · splitRockColumns · splitRockSummary
* ↔ `B08_Quantity_Engine_Earthwork_Summary.py` rock_split ·
* split_rock_columns · summary_rows(연암 · 경암 네 줄)
* mixedRockC ↔ `common_util_project_settings.py` mixed_rock_c(125-1)
* recountRockC · recountAdjustedSummary
* ↔ `B08_Quantity_Engine_Earthwork.py` build_rows · totals ·
* summary_rows(보정량계 · 유용토 · 잔토처리 줄)
* [저장] 뒤에는 서버가 정본으로 다시 셈 — 여기 값을 받아 적지 않음.
* import 없음 — 시험이 이 파일만 옮겨 돌림.
* ========================================================================== */
@@ -106,3 +110,68 @@ export function splitRockSummary<R extends { group: string; kind: string; value:
return { ...row, value: Math.round(row.kind === "연암" ? soft : hard) };
});
}
/** 암 C = 연암 % × 리핑암 C + 경암 % × 발파암 C(0~100 자름) — 연암 = 리핑 · 경암 = 발파 고정(125-1). */
export function mixedRockC(softPct: number, rippingC: number, blastingC: number): number {
const soft = Math.min(Math.max(softPct, 0), 100) / 100;
return soft * rippingC + (1 - soft) * blastingC;
}
const num = (value: unknown): number => (typeof value === "number" ? value : 0);
const RECOUNT_TOTALS = [
"cut_rock_adjusted_m3",
"ditch_rock_adjusted_m3",
"adjusted_total_m3",
"diverted_m3",
"balance_m3",
] as const;
/** 토적표를 새 암 C 로 — 암 보정량 · 보정량계 · 유용토 · 차인 · 누가 · 합계(줄 · 합계 덮어씀). */
export function recountRockC(
rows: Array<Record<string, unknown>>,
totals: Record<string, number>,
c: number,
): void {
let cumulative = 0;
for (const row of rows) {
const cutRock = num(row.cut_rock_volume_m3) * c;
const adjusted = num(row.cut_soil_adjusted_m3) + cutRock;
const fill = num(row.fill_volume_m3);
cumulative += adjusted - fill;
Object.assign(row, {
cut_rock_adjusted_m3: cutRock,
ditch_rock_adjusted_m3: num(row.ditch_rock_volume_m3) * c,
adjusted_total_m3: adjusted,
diverted_m3: Math.min(adjusted, fill),
balance_m3: adjusted - fill,
cumulative_m3: cumulative,
});
}
const sum = (key: string, picked = rows): number =>
picked.reduce((total, row) => total + num(row[key]), 0);
for (const key of RECOUNT_TOTALS) totals[key] = sum(key);
for (const kind of ["ripping_rock", "blasting_rock"])
totals[`cut_${kind}_adjusted_m3`] = sum(
"cut_rock_adjusted_m3",
rows.filter((row) => row.cut_rock_kind === kind),
);
}
/** 엑셀 ROUND(정수) — 반은 0 에서 먼 쪽. */
const excelRound = (value: number): number => Math.sign(value) * Math.round(Math.abs(value));
/** 토공집계표 보정량계 · 유용토 소계 · 횡방향유용토 · 잔토처리 줄 — 합계에서 다시(종방향 무대 Q 는 그대로). */
export function recountAdjustedSummary<R extends { group: string; value: number | null }>(
rows: readonly R[],
totals: Record<string, number>,
freeQ: number,
): R[] {
const diverted = totals.diverted_m3 ?? 0;
const values: Record<string, number> = {
보정량계: excelRound(totals.adjusted_total_m3 ?? 0),
"유용토 소계": diverted + freeQ,
횡방향유용토: diverted,
잔토처리: excelRound(totals.balance_m3 ?? 0),
};
return rows.map((row) => (row.group in values ? { ...row, value: values[row.group] } : row));
}
@@ -6,7 +6,8 @@
운반 Q · L = 종단 레코드에 저장된 유토곡선 배분(`mass_haul.haul_plan`) — B07 토적도와 같은 정본.
단면적 = B06 횡단 설계 저장값(`cross_sections.data.design`) · 노선 = 확정 노선(없으면 최신 노선) ·
토량환산계수 = 유토곡선과 같은 `mixed_conversion_factors(quantity_settings)`.
토량환산계수 = 유토곡선과 같은 `mixed_conversion_factors(quantity_settings)` · 암 = 연암 · 경암 비율 가중 C ·
`rock_c` = 가중 전 리핑암 · 발파암 C(조작 중 브라우저가 다시 가중 · 125-1).
암 → 연암 · 경암 = 암 × 연암 %(`rock_ratios_pct` · 기본 90 · PLAN 122) — 토적표 줄 · 합계 · 토공집계표.
"""
@@ -32,6 +33,7 @@ from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import (
)
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
from common_util.common_util_project_settings import (
earthwork_conversion_factors,
mixed_conversion_factors,
quantity_settings,
soft_rock_pct,
@@ -65,6 +67,11 @@ async def earthwork_table(project_id: UUID) -> dict[str, Any]:
**table,
"summary": summary_rows(table["totals"], haul, soft),
"rock_ratios_pct": {"연암": soft, "경암": 100.0 - soft},
"rock_c": {
kind: entry["compacted"]
for kind, entry in earthwork_conversion_factors(settings).items()
if kind != "soil"
},
"haul": haul,
"haul_saved": bool(mass_haul.get("haul_plan")),
"route_id": route_id,
+24 -6
View File
@@ -7,7 +7,8 @@
* 보정량 · 보정량계 · 성토 · 유용토 · 차인토량 · 누가토량 · 면고르기 · 지장목제거 · 제근 · 노면정리 각
* 거리 · 면적) · 맨 아래 합계 줄. 첫 측점은 앞이 없어 입적 · 면적이 빈 값(—).
* 표 = 공용 많은 자료 표(`ui_template_data_table` · PLAN 70) · 카드 안 · 나머지 셋은 `…_Earthwork_Sheets`.
* 절토 연암 · 경암 = 암 × 좌측 패널 비율 — 조작 중 같은 식으로 바로 다시 셈(`…_Options_Calc` · PLAN 122).
* 절토 연암 · 경암 = 암 × 좌측 패널 비율 · 암 C = 비율 가중 — 조작 중 같은 식으로 바로 다시 셈
* (`…_Options_Calc` · PLAN 122 · 125-1).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
@@ -20,7 +21,13 @@ import {
renderFreeHaul,
renderHaulDistance,
} from "./B08_Quantity_UI_Earthwork_Sheets";
import { splitRockColumns, splitRockSummary } from "./B08_Quantity_Options_Calc";
import {
mixedRockC,
recountAdjustedSummary,
recountRockC,
splitRockColumns,
splitRockSummary,
} from "./B08_Quantity_Options_Calc";
import { liveSoftRock, onOptionsChange } from "./B08_Quantity_UI_Options";
type Face = { length_m: number | null; area_m2: number | null };
@@ -85,6 +92,8 @@ export interface EarthworkTable {
haul_saved: boolean;
/** 저장된 연암 · 경암 %(암 전체 기준) */
rock_ratios_pct: { 연암: number; 경암: number };
/** 가중 전 리핑암(연암) · 발파암(경암) C — 비율로 다시 가중(125-1) */
rock_c: { ripping_rock: number; blasting_rock: number };
}
/** 칸 묶음 — [머리 묶음, 키 앞머리, 풍선 글] · 단면적 · 입적 · 보정량 셋 · 머리 글자는 사용자 견본대로 */
@@ -212,8 +221,9 @@ function renderSheet(host: HTMLElement, table: EarthworkTable, interval: number)
const info = [
note(
`평균단면적법 · 노선 ${table.route_id}${table.confirmed ? "(확정)" : "(확정 전 최신 노선)"} · ` +
`측점 ${table.station_count} · 다짐 환산계수 토사 ${c("soil")} · 리핑암 ${c("ripping_rock")} · ` +
`발파암 ${c("blasting_rock")} · 보정량계 = 절토 보정량 · 면 거리 = B07 횡단도 수량 · ` +
`측점 ${table.station_count} · 다짐 환산계수 토사 ${c("soil")} · 암 ` +
`${table.conversion_factors.ripping_rock?.compacted.toFixed(3) ?? ""}(연암 리핑 ` +
`${table.rock_c.ripping_rock} · 경암 발파 ${table.rock_c.blasting_rock} 비율 가중) · 보정량계 = 절토 보정량 · 면 거리 = B07 횡단도 수량 · ` +
`단위 m · ㎡ · ㎥`,
),
...(flagged.length ? [note(`* ${flagged[0].notes[0]} — ${flagged.length}곳`)] : []),
@@ -260,13 +270,21 @@ export async function renderEarthwork(host: HTMLElement, projectId: string): Pro
return;
}
// 연암 · 경암 칸 · 집계표 줄 = 암 × 지금 비율(캐시 → 저장본)
// 연암 · 경암 칸 · 집계표 줄 = 암 × 지금 비율 · 암 C = 비율 가중(캐시 → 저장본 · 125-1)
const summary = table.summary;
const applyRock = (): void => {
const soft = liveSoftRock(projectId, table.rock_ratios_pct?.연암 ?? 90);
const c = mixedRockC(soft, table.rock_c.ripping_rock, table.rock_c.blasting_rock);
for (const kind of ["ripping_rock", "blasting_rock"])
table.conversion_factors[kind].compacted = c;
recountRockC(table.rows as unknown as Array<Record<string, unknown>>, table.totals, c);
for (const record of [...table.rows, table.totals])
splitRockColumns(record as unknown as Record<string, unknown>, soft);
table.summary = splitRockSummary(summary, table.totals, soft);
table.summary = recountAdjustedSummary(
splitRockSummary(summary, table.totals, soft),
table.totals,
table.haul.free_haul.q,
);
};
applyRock();
+19 -17
View File
@@ -345,27 +345,29 @@ def earthwork_conversion_factors(settings: dict[str, Any]) -> dict[str, dict[str
ROCK_METHOD_KINDS = {ROCK_METHOD_RIPPING: "ripping_rock", ROCK_METHOD_BLASTING: "blasting_rock"}
def mixed_conversion_factors(settings: dict[str, Any]) -> dict[str, dict[str, float]]:
"""토적표·유토곡선·운반표가 **계산에** 쓸 계수 — 암은 구성비 가중 C (㉱ (나) · 2026-09-14).
def mixed_rock_c(soft_pct: float, ripping_c: float, blasting_c: float) -> float:
"""암 C = 연암 % × 리핑암 C + 경암 % × 발파암 C(0~100 자름 · 125-1).
B06 의 암은 한 종류 **자리표시**(`ripping_rock` · 옛 자료 `blasting_rock`)이고 암질은 설계내역에서
구성비로 정함(8-1 사용자 확정). 그래서 구성비와 갈래별 시공법이 **다 서면** 암 두 칸을
Σ몫 × C(시공법)로 갈음 — 몫은 흙깎기와 같은 안분(준 비율끼리 · 합이 100 이 아니어도 안분).
⚠ 구성비가 비거나 시공법이 빠진 갈래가 있으면 **종전 값** — 인계가 막힘 사유를 내고, 지어낸 계수를 안 씀.
짝 — `B08_Quantity_Options_Calc.ts` `mixedRockC`(거울 시험 `test_122_b08_options_mirror`).
"""
soft = min(max(soft_pct, 0.0), 100.0) / 100.0
return soft * ripping_c + (1.0 - soft) * blasting_c
def mixed_conversion_factors(settings: dict[str, Any]) -> dict[str, dict[str, float]]:
"""토적표·유토곡선·운반표가 **계산에** 쓸 계수 — 암은 연암 · 경암 비율 가중 C (125-1).
B06 의 암은 한 종류 **자리표시**(`ripping_rock` · 옛 자료 `blasting_rock`)라 암 두 칸을
`mixed_rock_c(연암 %, 리핑암 C, 발파암 C)` 하나로 갈음 — 비율 = `soft_rock_pct`(기본 90 : 10 · 122).
시공법은 연암 = 리핑암 · 경암 = 발파암 고정(브레인 정함) — `rock_methods` 를 안 읽음.
⚠ 화면 「무엇을 골랐나」(`earthwork_conversion_choices`)는 갈래별 값 그대로 — 여기 값을 보이면 안 됨.
"""
resolved = earthwork_conversion_factors(settings)
ratios = settings.get("rock_ratios_pct") or {}
shares = {
name: float(ratios.get(name) or 0)
for name in rock_classes(settings)
if name != "토사" and float(ratios.get(name) or 0) > 0
}
kinds = {name: ROCK_METHOD_KINDS.get(rock_method(settings, name) or "") for name in shares}
if not shares or not all(kinds.values()):
return resolved
total = sum(shares.values())
mixed = sum(shares[name] / total * resolved[kind]["compacted"] for name, kind in kinds.items())
mixed = mixed_rock_c(
soft_rock_pct(settings),
resolved["ripping_rock"]["compacted"],
resolved["blasting_rock"]["compacted"],
)
for kind in ROCK_METHOD_KINDS.values():
resolved[kind]["compacted"] = mixed
return resolved
+139
View File
@@ -0,0 +1,139 @@
/* 126-1 우클릭 세월교 · 물넘이포장 바로 추가 헬퍼 — `_Structures_Panel_AddAt`(판정 · 끝맺음 · 알림)과
* `_Drainage_Ford_Fill`(유량 응답 뒤 빈 월류 높이 채움)을 Node 로 돌려 표준출력 JSON 한 벌로.
* TS 로더는 `helper_116_right_click_add.cjs` 와 같다 · 설정값은 진짜 `config_frontend` 를 읽음.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
const toasts = [];
const STUBS = { "@ui/ui_template_elements": { showToast: (message) => toasts.push(message) } };
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 (STUBS[request]) return STUBS[request];
if (request === "@config/config_frontend")
return loadTs(path.join(ROOT, "config", "config_frontend.ts"));
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
if (request.startsWith("@")) return new Proxy({}, { get: () => () => {} });
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const B05 = path.join(ROOT, "B05_Profile");
const { addsAtOnce, finishAdd, FORD_WAIT_NOTICE } = loadTs(
path.join(B05, "B05_Profile_UI_Structures_Panel_AddAt.ts"),
);
const { fillFordHeights, waitsFordHeight } = loadTs(
path.join(B05, "B05_Profile_UI_Drainage_Ford_Fill.ts"),
);
const { fordHeightM } = loadTs(
path.join(ROOT, "A00_Common", "design", "design_structure_ford_calc.ts"),
);
const type = (type_id) => ({ type_id, group: "A", managed_by: "pipe_points" });
/** 우클릭 → 바로 추가 — 관 지점 정본에 한 번 넘기고 그 측점 관을 고름 · 알림 한 줄. */
async function rightClick(typeId) {
toasts.length = 0;
const picked = [];
let commits = 0;
const done = await finishAdd({
type: type(typeId),
anchorM: 120,
structures: [],
commit: async () => void commits++,
pick: {
selectById: (id) => picked.push(["id", id]),
selectPipeByChainage: (at) => picked.push(["pipe", at]),
},
});
return { done, commits, picked, toasts: [...toasts] };
}
/** 재계산 응답 한 번 — 응답 관 목록에 기다리던 높이를 얹은 결과. */
function respond(points, basins, pending) {
const filled = fillFordHeights(points, basins, pending);
return {
filled,
heights: points.map((p) => p.options?.ford_height_m ?? null),
pending: [...pending],
};
}
(async () => {
const flow = 8.0;
// 첫 응답은 유량 없음 → 빈칸 · 둘째 응답에 유량 → 그 항목에 채움(폭 = 항목 값)
const pendingA = new Set([120]);
const pointsA = () => [
{ chainage_m: 120.2, facility: "ford_bridge", options: { ford_width_m: 10 } },
];
const noFlow = respond(pointsA(), [{ chainage_m: 120.2, design_flow_m3s: null }], pendingA);
const withFlow = respond(pointsA(), [{ chainage_m: 120.2, design_flow_m3s: flow }], pendingA);
// 손 값은 안 덮음
const pendingB = new Set([300]);
const hand = respond(
[
{
chainage_m: 300,
facility: "ford_pavement",
options: { ford_width_m: 5, ford_height_m: 0.5 },
},
],
[{ chainage_m: 300, design_flow_m3s: flow }],
pendingB,
);
// 기다리지 않는 측점(옛 세월교)의 빈 높이는 그대로 · 폭 없으면 기본 폭
const pendingC = new Set([50]);
const other = respond(
[
{ chainage_m: 50, facility: "ford_pavement", options: {} },
{ chainage_m: 80, facility: "ford_bridge", options: { ford_width_m: 10 } },
],
[
{ chainage_m: 50, design_flow_m3s: flow },
{ chainage_m: 80, design_flow_m3s: flow },
],
pendingC,
);
// 종류가 바뀌었거나 관이 사라짐 → 그만 기다림
const pendingD = new Set([10, 20]);
const gone = respond([{ chainage_m: 10, facility: "pipe", options: {} }], [], pendingD);
const out = {
once: {
fordBridge: addsAtOnce(type("ford_bridge"), []),
fordPavement: addsAtOnce(type("ford_pavement"), []),
pavement: addsAtOnce({ type_id: "pavement_concrete", managed_by: null }, []),
},
bridge: await rightClick("ford_bridge"),
paveRc: await rightClick("ford_pavement"),
pipe: await rightClick("pipe"),
notice: FORD_WAIT_NOTICE,
waits: {
bridgeEmpty: waitsFordHeight("ford_bridge", { ford_width_m: 10 }),
paveEmpty: waitsFordHeight("ford_pavement", undefined),
bridgeHand: waitsFordHeight("ford_bridge", { ford_height_m: 0.4 }),
pipe: waitsFordHeight("pipe", {}),
},
noFlow,
withFlow,
hand,
other,
gone,
need: { w10: fordHeightM(flow, 10), w5: fordHeightM(flow, 5) },
};
process.stdout.write(JSON.stringify(out));
})();
+3 -3
View File
@@ -3,7 +3,7 @@
못박는 것
- 필수 빈칸 없는 종류 = 그 측점에 바로 추가(캐시) + 새 항목을 고른 채(구조물 = 임시 id · A군 = 측점).
- 필수 빈칸 남는 종류 · 노면공(확인창) · 세월교 · 물넘이포장(월류 높이 = 유량 뒤) = 폼만.
- 필수 빈칸 남는 종류 · 노면공(확인창) = 폼만 · 세월교 · 물넘이포장은 바로 추가(126-1 · test_126).
- [추가]가 거절하면 아무것도 안 듦 · 고르지 않음.
- 새로 놓기에서 시작 — 옛 집계 항목 · 그룹 잇기 · 메모가 새 항목에 안 실림.
- 세 길(B05 종단 · B06 종단 · 3D)이 모두 패널 `addAt` 한 곳 · 패널을 열고 「구조물 배치」를 폄.
@@ -47,10 +47,10 @@ def test_필수_빈칸_없는_종류만_바로_추가(out):
assert once["requiredEmpty"] is False
def test_노면공_세월교_물넘이포장은_폼만(out):
def test_노면공만_폼만_세월교_물넘이포장은_바로(out):
once = out["once"]
assert once["pavement"] is False
assert once["fordBridge"] is False and once["fordPavement"] is False
assert once["fordBridge"] is True and once["fordPavement"] is True # 126-1
def test_구조물은_목록에_들고_새_임시_id로_고른_채(out):
@@ -14,6 +14,7 @@ from pathlib import Path
import pytest
from B08_Quantity.B08_Quantity_Engine_Earthwork import StationArea, build_table
from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import (
haul_lists,
rock_split,
@@ -21,6 +22,7 @@ from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import (
summary_rows,
)
from B08_Quantity.B08_Quantity_Router_Summary import couplings
from common_util.common_util_project_settings import mixed_conversion_factors, mixed_rock_c
ROOT = Path(__file__).resolve().parents[2]
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
@@ -85,7 +87,7 @@ writeFileSync(process.argv[2], JSON.stringify(out));
"""
def _ts(tmp_path: Path, payload: dict) -> dict:
def _ts(tmp_path: Path, payload: dict, runner: str = _RUNNER) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
@@ -106,7 +108,7 @@ def _ts(tmp_path: Path, payload: dict) -> dict:
check=True,
capture_output=True,
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
(out / "runner.mjs").write_text(runner, encoding="utf-8")
result = tmp_path / "out.json"
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(result), json.dumps(payload)],
@@ -162,6 +164,68 @@ def test_두_쪽이_같은_값을_낸다(tmp_path: Path) -> None:
assert got == _py_recount(one), one
# 125-1 — 암 C = 비율 가중 · 토적표(90 : 10 으로 선 서버 값)를 새 비율로 다시 센 TS = 서버가 그 비율로 센 값
STATIONS = [
StationArea(0.0, cut_soil_area_m2=1.5, cut_rock_area_m2=2.0, fill_area_m2=0.5),
StationArea(20.0, cut_soil_area_m2=0.7, cut_rock_area_m2=3.3, ditch_area_m2=0.4),
StationArea(40.0, cut_rock_area_m2=1.1, fill_area_m2=9.0, cut_rock_kind="blasting_rock"),
StationArea(52.5, cut_soil_area_m2=2.2, fill_area_m2=1.3),
]
HAUL_PLAN = {
"blocks": [{"bands": [{"equipment": "free_haul", "volume_m3": 12.5, "haul_distance_m": 8}]}]
}
_RUNNER_C = """
import { writeFileSync } from "node:fs";
import * as calc from "./B08_Quantity_Options_Calc.js";
const input = JSON.parse(process.argv[3]);
const out = {
c: input.pcts.map((p) => calc.mixedRockC(p, 1.15, 1.3)),
tables: input.pcts.map((p) => {
const t = structuredClone(input.table);
calc.recountRockC(t.rows, t.totals, calc.mixedRockC(p, 1.15, 1.3));
for (const r of [...t.rows, t.totals]) calc.splitRockColumns(r, p);
const summary = calc.recountAdjustedSummary(
calc.splitRockSummary(t.summary, t.totals, p), t.totals, input.freeQ);
return { rows: t.rows, totals: t.totals, summary };
}),
};
writeFileSync(process.argv[2], JSON.stringify(out));
"""
def _py_table(pct: float) -> dict:
"""서버 길(`Router_Earthwork`) 그대로 — 가중 C 로 표 · 연암 · 경암 칸 · 집계표."""
table = build_table(STATIONS, mixed_conversion_factors({"rock_ratios_pct": {"연암": pct}}))
for record in [*table["rows"], table["totals"]]:
split_rock_columns(record, pct)
summary = summary_rows(table["totals"], haul_lists(HAUL_PLAN), pct)
return {"rows": table["rows"], "totals": table["totals"], "summary": summary}
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_암_C_가중_두_쪽이_같은_값을_낸다(tmp_path: Path) -> None:
pcts = [0, 10, 33.3, 50, 90, 100, 130, -5]
ts = _ts(
tmp_path,
{"pcts": pcts, "table": _py_table(90), "freeQ": haul_lists(HAUL_PLAN)["free_haul"]["q"]},
_RUNNER_C,
)
assert ts["c"] == [mixed_rock_c(p, 1.15, 1.3) for p in pcts]
for pct, got in zip(pcts, ts["tables"]):
want = json.loads(json.dumps(_py_table(pct)))
assert got["rows"] == want["rows"] and got["summary"] == want["summary"], pct
# 합계 — 파이썬 3.12 sum() 은 보정 합산이라 끝자리(1 ulp)만 다를 수 있음
assert got["totals"] == pytest.approx(want["totals"], rel=1e-12), pct
def test_50_50_이면_보정량이_바뀜() -> None:
ninety, half = _py_table(90)["totals"], _py_table(50)["totals"]
assert half["cut_rock_volume_m3"] == ninety["cut_rock_volume_m3"]
assert half["cut_rock_adjusted_m3"] == pytest.approx(ninety["cut_rock_volume_m3"] * 1.225)
assert half["adjusted_total_m3"] > ninety["adjusted_total_m3"]
def test_1본_6_m_면_커플링이_바뀜(tmp_path: Path) -> None:
six, eight = _py_recount(6), _py_recount(8)
assert six["total"]["pipe:cp|800"] == 2 + 1 + 2 and eight["total"]["pipe:cp|800"] == 2 + 1 + 1
@@ -174,4 +238,8 @@ def test_식_모양이_두_쪽_같다() -> None:
assert "Math.max(Math.ceil(lengthM / oneM - 1e-9) - 1, 0)" in ts
assert "const soft = (value * Math.min(Math.max(softPct, 0), 100)) / 100;" in ts
assert "soft = value * min(max(soft_pct, 0.0), 100.0) / 100.0" in py
assert "return soft * rippingC + (1 - soft) * blastingC;" in ts
assert "return soft * ripping_c + (1.0 - soft) * blasting_c" in (
ROOT / "common_util" / "common_util_project_settings.py"
).read_text("utf-8")
assert not re.search(r"^import ", ts, re.M) # 시험이 이 파일만 옮겨 돌림
+78
View File
@@ -0,0 +1,78 @@
"""125-1 연암 · 경암 비율 → 암 환산계수 C 가중(연암 = 리핑암 · 경암 = 발파암 고정).
C = 연암 % × 리핑암 C + 경암 % × 발파암 C · 비율 = 122 `rock_ratios_pct`(기본 90 : 10) ·
B08 토적표 보정량 · B06 유토곡선이 같은 `mixed_conversion_factors` 를 씀.
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import pytest
from B06_Section import B06_Section_Server_Calc_Prebuild as prebuild
from B08_Quantity.B08_Quantity_Engine_Earthwork import StationArea, build_table
from common_util.common_util_project_settings import (
mixed_conversion_factors,
quantity_settings,
settings_path,
)
RIP, BLAST = 1.15, 1.30 # 품셈 토량환산계수 기본(config) — 표 그대로
def _c(settings: dict) -> float:
factors = mixed_conversion_factors(settings)
assert factors["ripping_rock"]["compacted"] == factors["blasting_rock"]["compacted"]
return factors["ripping_rock"]["compacted"]
def test_기본_90_10_이면_가중_C() -> None:
assert _c({}) == pytest.approx(0.9 * RIP + 0.1 * BLAST) # 1.165
assert _c({"rock_ratios_pct": {"연암": 90.0, "경암": 10.0}}) == pytest.approx(1.165)
def test_50_50_이면_C_가_바뀜() -> None:
assert _c({"rock_ratios_pct": {"연암": 50.0, "경암": 50.0}}) == pytest.approx(1.225)
assert _c({"rock_ratios_pct": {"연암": 100.0}}) == pytest.approx(RIP)
assert _c({"rock_ratios_pct": {"연암": 0.0}}) == pytest.approx(BLAST)
def test_시공법_칸은_안_읽음() -> None:
ratios = {"rock_ratios_pct": {"연암": 50.0}}
assert _c({**ratios, "rock_methods": {"연암": "blasting", "경암": "ripping"}}) == _c(ratios)
def test_토사_C_는_그대로() -> None:
assert mixed_conversion_factors({"rock_ratios_pct": {"연암": 50.0}})["soil"]["compacted"] == 0.9
def test_B08_토적표_보정량이_가중_C_로() -> None:
stations = [
StationArea(chainage_m=0.0, cut_rock_area_m2=2.0),
StationArea(chainage_m=20.0, cut_rock_area_m2=4.0, cut_rock_kind="blasting_rock"),
]
for pct, c in ((90.0, 1.165), (50.0, 1.225)):
settings = {"rock_ratios_pct": {"연암": pct}}
table = build_table(stations, mixed_conversion_factors(settings))
assert table["totals"]["cut_rock_volume_m3"] == pytest.approx(60.0)
assert table["totals"]["cut_rock_adjusted_m3"] == pytest.approx(60.0 * c)
def test_B06_유토곡선이_같은_C(tmp_path: Path, monkeypatch) -> None:
settings_path(tmp_path).write_text(
json.dumps({"quantity": {"rock_ratios_pct": {"연암": 50.0, "경암": 50.0}}}), "utf-8"
)
async def fake_run(_fn, _project_id):
return "x"
monkeypatch.setattr(prebuild, "run_with_connection", fake_run)
monkeypatch.setattr(prebuild, "resolve_stored_project_path", lambda _p: tmp_path)
factors = asyncio.run(prebuild.conversion_factors_for("p"))
assert factors["ripping_rock"]["compacted"] == pytest.approx(1.225)
assert factors == mixed_conversion_factors(quantity_settings(tmp_path))
context = prebuild._mass_haul_context(None, factors)
assert context["earthwork_conversion"]["ripping_rock"]["compacted"] == pytest.approx(1.225)
@@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
"""126-1 우클릭 세월교 · 물넘이포장도 바로 추가 · 월류 높이 실시간.
못박는 것
- 세월교 · 물넘이포장 = 바로 추가 + 그 측점 관을 고른 채 · 알림 한 줄(높이는 유량 뒤).
- 배수유역 재계산 응답에 유량이 오면 기다리던 항목의 빈 월류 높이 = 필요 수심(서버 `ford_height_m` 거울).
- 유량이 아직 없으면 빈칸으로 다음 응답까지 · 손 값은 안 덮음 · 기다리지 않는 옛 항목은 그대로.
- 세부유역 요청은 최신 응답만 채택 · 채운 뒤 세션 초안에 다시 담음.
Node 헬퍼 `helper_126_ford_fill.cjs` 가 판정 · 끝맺음 · 채움을 돌림. 화면은 사용자 확인.
"""
import json
import os
import subprocess
import pytest
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
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 f:
return f.read()
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_126_ford_fill.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["once"] == {"fordBridge": True, "fordPavement": True, "pavement": False}
def test_추가_뒤_목록에_들고_그_관을_고른_채_알림_한_줄(out):
for key in ("bridge", "paveRc"):
r = out[key]
assert r["done"] is True and r["commits"] == 1
assert r["picked"] == [["pipe", 120]]
assert r["toasts"] == [out["notice"]]
assert out["pipe"]["toasts"] == [] # 배관은 알림 없음
def test_빈_높이만_기다림(out):
assert out["waits"] == {
"bridgeEmpty": True,
"paveEmpty": True,
"bridgeHand": False,
"pipe": False,
}
def test_유량_오기_전엔_빈칸_오면_그_항목에_채움(out):
assert out["noFlow"] == {"filled": False, "heights": [None], "pending": [120]}
assert out["withFlow"] == {"filled": True, "heights": [0.16], "pending": []}
def test_채운_높이는_서버_풀이와_같음(out):
assert out["need"]["w10"] == basins.ford_height_m(8.0, 10) == 0.16
assert out["need"]["w5"] == basins.ford_height_m(8.0, 5)
def test_손_값은_안_덮음(out):
assert out["hand"] == {"filled": False, "heights": [0.5], "pending": []}
def test_기다리지_않는_옛_항목은_그대로_폭_없으면_기본_폭(out):
assert out["other"]["heights"] == [0.16, None] # 물넘이 기본 폭 10 · 옛 세월교 빈칸 그대로
assert out["other"]["pending"] == []
def test_관이_사라지거나_종류가_바뀌면_그만_기다림(out):
assert out["gone"] == {"filled": False, "heights": [None], "pending": []}
def test_배수유역_패널_배선():
src = _read("B05_Profile", "B05_Profile_UI_Drainage_Panel.ts")
# 최신 응답만 채택
run = src[src.index("async function run(") : src.index("/** 지금 관 목록")]
assert "const seq = ++requestSeq;" in run
assert run.index("await request()") < run.index("if (seq !== requestSeq) return;")
assert run.index("if (seq !== requestSeq) return;") < run.index("apply(response)")
# 채움은 보관소가 응답을 받기 전에 · 채웠으면 세션 초안에 다시 담음
apply = src[src.index("function apply(") : src.index("async function run(")]
order = [
apply.index("fillFordHeights(response.pipe_points, response.basins, fordWaits)"),
apply.index("facilityStore.replaceFromResponse(response.pipe_points)"),
apply.index("if (fordFilled) rememberPipes()"),
apply.index("callbacks.onPipesChanged?.("),
]
assert order == sorted(order)
add = src[src.index(" addPipe(chainageM, attributes) {") : src.index(" removePipe(")]
assert "waitsFordHeight(attributes?.facility, attributes?.options)" in add
assert add.index("fordWaits.add(chainageM)") < add.index("pipeEditor.addAtChainage(chainageM)")
assert "if (fordWaits.delete(fromChainageM)) fordWaits.add(toChainageM);" in add
@@ -44,14 +44,15 @@ def test_고른_계수_위에서_가중() -> None:
@pytest.mark.parametrize(
"settings",
("settings", "soft"),
[
{"rock_class_set": "geochang5"},
{**MIX, "rock_methods": {"연암": "ripping"}},
({"rock_class_set": "geochang5"}, 0.9), # 비율 비면 기본 90 : 10(122)
({**MIX, "rock_methods": {"연암": "ripping"}}, 0.6), # 시공법 안 봄(125-1)
],
)
def test_구성비나_시공법이_비면_종전_값(settings: dict) -> None:
assert mixed_conversion_factors(settings) == earthwork_conversion_factors(settings)
def test_비율이_비면_기본_시공법은_연암_리핑_경암_발파_고정(settings: dict, soft: float) -> None:
mixed = mixed_conversion_factors(settings)["ripping_rock"]["compacted"]
assert mixed == pytest.approx(soft * 1.15 + (1 - soft) * 1.30)
def test_화면_고른_값_표시는_갈래별_그대로() -> None: