Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_2
# Conflicts: # B06_Section/B06_Section_Cross_Refresh.ts # B06_Section/B06_Section_Router_Design.py # B06_Section/B06_Section_UI_Page_Persist.ts # B08_Quantity/B08_Quantity_Router_Summary.py
This commit is contained in:
@@ -170,6 +170,8 @@ export const STATE_REGISTRY = {
|
||||
* 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고
|
||||
* [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */
|
||||
crossdesign: { bucket: "draft", scope: "route" },
|
||||
/** 설계선 쉬프트 = 쪽마다 노견 확장 `{측점: {left, right}}`(139-6) — [저장] 때 patch 로. */
|
||||
shoulderext: { bucket: "draft", scope: "route" },
|
||||
/** B08 자재 집계표 셋 사용자 값 `{units, mix, surcharge}`(고친 칸만) — [저장] 때 프로젝트 설정으로(121). */
|
||||
"b08-materials": { bucket: "draft", scope: "project" },
|
||||
/** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · B05 연결값과 같은 칸은 null(연동 · 96). */
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/* =============================================================================
|
||||
* b_polygon_clip.ts
|
||||
* 선분 · 점열을 폴리곤 경계에서 자르는 공용 조각 — B07 횡단 오버레이(안쪽 조각)와
|
||||
* B06 설계선 고름(벽 · 본체 밖 조각 · 137-2)이 같이 쓴다. 좌표계는 부르는 쪽 것 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
export type XY = [number, number];
|
||||
|
||||
/** 점이 폴리곤 안인가 — 오목한 벽 단면도 되도록 광선 교차 홀짝으로 본다. */
|
||||
export function pointInPolygon(point: XY, polygon: XY[]): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
|
||||
const [xi, yi] = polygon[i];
|
||||
const [xj, yj] = polygon[j];
|
||||
const straddles = yi > point[1] !== yj > point[1];
|
||||
if (straddles && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** 선분을 폴리곤 경계에서 잘라 **안쪽 조각들**(`keep = "outside"` 면 바깥 조각들)만 돌려준다. */
|
||||
export function clipSegment(
|
||||
start: XY,
|
||||
end: XY,
|
||||
polygon: XY[],
|
||||
keep: "inside" | "outside" = "inside",
|
||||
): XY[][] {
|
||||
const [x0, y0] = start;
|
||||
const [x1, y1] = end;
|
||||
const dx = x1 - x0;
|
||||
const dy = y1 - y0;
|
||||
const cuts = [0, 1];
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
|
||||
const [ex, ey] = polygon[j];
|
||||
const [fx, fy] = polygon[i];
|
||||
const denominator = dx * (fy - ey) - dy * (fx - ex);
|
||||
if (Math.abs(denominator) < 1e-12) continue;
|
||||
const t = ((ex - x0) * (fy - ey) - (ey - y0) * (fx - ex)) / denominator;
|
||||
const u = ((ex - x0) * dy - (ey - y0) * dx) / denominator;
|
||||
if (t > 0 && t < 1 && u >= 0 && u <= 1) cuts.push(t);
|
||||
}
|
||||
cuts.sort((a, b) => a - b);
|
||||
const runs: XY[][] = [];
|
||||
for (let index = 0; index + 1 < cuts.length; index += 1) {
|
||||
const from = cuts[index];
|
||||
const to = cuts[index + 1];
|
||||
if (to - from < 1e-9) continue;
|
||||
const mid = (from + to) / 2;
|
||||
if (pointInPolygon([x0 + dx * mid, y0 + dy * mid], polygon) !== (keep === "inside")) continue;
|
||||
runs.push([
|
||||
[x0 + dx * from, y0 + dy * from],
|
||||
[x0 + dx * to, y0 + dy * to],
|
||||
]);
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** 점열을 폴리곤 안으로 자른다 — 조각마다 한 줄. */
|
||||
export function clipPointsToPolygon(points: XY[], polygon: XY[]): XY[][] {
|
||||
const runs: XY[][] = [];
|
||||
for (let index = 0; index + 1 < points.length; index += 1) {
|
||||
runs.push(...clipSegment(points[index], points[index + 1], polygon));
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields";
|
||||
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";
|
||||
|
||||
/** 잇기 값 자리 — 패널 그룹 값(`group_items`)을 그대로 씀. */
|
||||
export interface SpecLinks {
|
||||
@@ -169,6 +170,8 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
||||
raw: string,
|
||||
view: Instance,
|
||||
): void => {
|
||||
if (spec?.typeId === "pipe" && c.id === "pipe" && item.id === "diameter")
|
||||
followPipeHeights(state, raw, values[item.id] ?? "");
|
||||
// 그림 값을 보이던 위 칸 아래를 고침 = 그 위 값을 고른 것 — 다른 갈래 값은 비움(54-13)
|
||||
const parent = item.by?.item;
|
||||
if (parent && view[parent] !== values[parent]) {
|
||||
@@ -343,6 +346,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
|
||||
names = nextNames;
|
||||
groupNames = nextGroups;
|
||||
state = readState(next, options);
|
||||
if (next.typeId === "pipe") followPipeHeights(state, state.pipe?.[0]?.diameter ?? "");
|
||||
recent.clear();
|
||||
paint();
|
||||
},
|
||||
|
||||
@@ -399,7 +399,7 @@ export async function mountCrossView(
|
||||
// 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1)
|
||||
const pipeAt = structuresPanel.currentChainage();
|
||||
const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`);
|
||||
if (mark) sectionView.selectMark(mark);
|
||||
if (mark) sectionView.selectMark(mark, false); // 측점 고름 = 카드 하나(137-4) · 목록 길은 뒤에 여럿
|
||||
structureSelection.syncInletStructure();
|
||||
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
|
||||
// (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다.
|
||||
|
||||
@@ -25,6 +25,7 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
from B06_Section.B06_Section_Engine_Culvert import pipe_wall_min_height_m
|
||||
from common_util.common_util_drainage_context import DrainageContext, load_drainage_context
|
||||
from common_util.common_util_drainage_detail import DrainageDetail, build_detail
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
@@ -89,6 +90,17 @@ def _build(
|
||||
return detail, carried
|
||||
|
||||
|
||||
# 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1)
|
||||
PIPE_HEIGHT_KEYS = ("inlet_revet_height_m", "outlet_revet_height_m", "inlet_basin_height_m")
|
||||
|
||||
|
||||
def _float(value: Any) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _apply_new_defaults(
|
||||
points: list[PipePoint], flows: dict[float, float | None] | None = None
|
||||
) -> None:
|
||||
@@ -111,6 +123,13 @@ def _apply_new_defaults(
|
||||
# 집수정 모양도 실제 값으로(54-39 · 54-36 기본 L형 — 화면 짝 `DEFAULT_BASIN_SHAPE`)
|
||||
if options.get("inlet_type") == "집수정":
|
||||
options.setdefault("inlet_structure", "L")
|
||||
# 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · 화면 짝 `PIPE_HEIGHT_KEYS`)
|
||||
keys = {field.key for field in definition.options}
|
||||
diameter_m = _float(options.get("pipe_diameter_mm")) / 1000
|
||||
if diameter_m > 0:
|
||||
for key in PIPE_HEIGHT_KEYS:
|
||||
if key in keys:
|
||||
options.setdefault(key, pipe_wall_min_height_m(diameter_m))
|
||||
if point.facility == "ford_bridge" and "ford_height_m" not in options:
|
||||
flow = (flows or {}).get(round(point.chainage_m, 2))
|
||||
height = ford_height_m(flow, float(options.get("ford_width_m") or 0))
|
||||
|
||||
@@ -90,7 +90,11 @@ async def sync_uphill_overrides_into_designs(
|
||||
양성(both_fill)은 측구가 없으므로 건드리지 않는다.
|
||||
"""
|
||||
# 지연 import — B06 라우터 모듈 로드는 이 함수가 실제 불릴 때만 필요하다.
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
|
||||
from B06_Section.B06_Section_Engine_Design import (
|
||||
compute_cross_design,
|
||||
curve_widening_args,
|
||||
stored_shoulder_extend,
|
||||
)
|
||||
from B06_Section.B06_Section_Repository import get_cross_section_designs
|
||||
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
@@ -157,6 +161,7 @@ async def sync_uphill_overrides_into_designs(
|
||||
ditch_choice=design.get("ditch_choice"),
|
||||
surface_drop_m=ford_drop_at(float(chainage), ford_drops),
|
||||
**curve_widening_args(cross_record),
|
||||
**stored_shoulder_extend(design), # 노견 확장(139-6)
|
||||
)
|
||||
next_design["status"] = design.get("status", "provisional")
|
||||
next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested)
|
||||
|
||||
@@ -26,10 +26,7 @@ import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culver
|
||||
import { restrictToSide, tierSpanOf } from "../B06_Section/B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { pipeWallThicknessM } from "../B06_Section/B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
|
||||
import {
|
||||
computeFordLayout,
|
||||
DEFAULT_FORD_WALL_ADJUST,
|
||||
} from "../B06_Section/B06_Section_UI_Cross_Ford";
|
||||
import { computeFordLayout, fordAdjustOf } from "../B06_Section/B06_Section_UI_Cross_Ford";
|
||||
import type { FordLayout } from "../B06_Section/B06_Section_UI_Cross_Ford";
|
||||
import {
|
||||
boreBandAt,
|
||||
@@ -205,10 +202,7 @@ export function fordLayoutOf(section: CrossSection): FordLayout | null {
|
||||
if (!section.ford || !section.design) return null;
|
||||
const stored = section.design.ford_adjust;
|
||||
try {
|
||||
return computeFordLayout(section, section.samples, {
|
||||
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) },
|
||||
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) },
|
||||
});
|
||||
return computeFordLayout(section, section.samples, fordAdjustOf(stored));
|
||||
} catch {
|
||||
return null; // 한 측점의 기하 실패가 3D 전체를 막으면 안 된다.
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ export function structureHashParts(section: CrossSection): Array<string | number
|
||||
const value = adjust?.[role];
|
||||
return value ? `${value.heightM ?? ""},${value.lateralM},${value.slopeM}` : "";
|
||||
};
|
||||
extras.push(`dr:${adjust?.dropM ?? ""}`);
|
||||
extras.push(
|
||||
"fd",
|
||||
ford.diameter_m,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station";
|
||||
import { DEFAULT_BASIN_SHAPE } from "../B06_Section/B06_Section_UI_Cross_Culvert_Basin";
|
||||
import { fillPipeHeights } from "../common_util/common_util_culvert_sets";
|
||||
|
||||
export function field(labelText: string, input: HTMLElement): HTMLLabelElement {
|
||||
const wrapper = document.createElement("label");
|
||||
@@ -110,6 +111,11 @@ export function optionDefaults(
|
||||
);
|
||||
// 배관 집수정 모양도 실제 값으로(54-39 · 54-36 기본 · 자동 배치 짝 `_apply_new_defaults`)
|
||||
if (values.inlet_type === "집수정") values.inlet_structure ??= DEFAULT_BASIN_SHAPE;
|
||||
// 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1)
|
||||
fillPipeHeights(
|
||||
values,
|
||||
options.map((option) => option.key),
|
||||
);
|
||||
return values;
|
||||
}
|
||||
|
||||
|
||||
@@ -386,6 +386,8 @@ export interface StoredFordWallAdjust {
|
||||
export interface StoredFordAdjust {
|
||||
inlet: StoredFordWallAdjust;
|
||||
outlet: StoredFordWallAdjust;
|
||||
/** 바닥판 내림(m · 수직 · 양쪽 한 값 · 139-3) — 없으면 옛 저장(읽을 때 옮김). */
|
||||
dropM?: number | null;
|
||||
}
|
||||
|
||||
/** BOX암거 한쪽 끝의 저장형 조작값(2026-08-25). */
|
||||
@@ -450,6 +452,9 @@ export interface CrossDesign {
|
||||
/** 곡선부 확폭(m) — 붙은 쪽만 값이 있다. */
|
||||
widening_left_m?: number;
|
||||
widening_right_m?: number;
|
||||
/** 설계선 쉬프트 = 쪽마다 노견 확장(m · ≥ 0 · 139-6) — 사용자 값 · 노체 · 측구 · 사면 · 구조물 같이. */
|
||||
shoulder_extend_left_m?: number;
|
||||
shoulder_extend_right_m?: number;
|
||||
cross_slope_pct: number;
|
||||
ditch:
|
||||
| { type: "standard"; top_width_m: number; bottom_width_m: number; depth_m: number }
|
||||
@@ -568,6 +573,9 @@ export interface CrossSectionPatch {
|
||||
display_half_width_m?: number;
|
||||
/** 측점별 암 절토 경사비(2026-09-07). 0 = 표준값으로 되돌림. */
|
||||
cut_slope_ratio_user?: number;
|
||||
/** 설계선 쉬프트 = 쪽마다 노견 확장(m · 139-6) — 0 = 되돌림. */
|
||||
shoulder_extend_left_m?: number;
|
||||
shoulder_extend_right_m?: number;
|
||||
/** 배수관 연장(m) — 구조물 면적과 같은 길로 정본에 실린다(2026-09-08). */
|
||||
pipe_length_m?: number;
|
||||
/** B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 관 길이와 같은 길. */
|
||||
|
||||
@@ -68,3 +68,45 @@ export function crossDesignChoices(
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
|
||||
/* ── 설계선 쉬프트 = 쪽마다 노견 확장(m · ≥ 0 · 139-6) — 카드 버튼 선택과 따로 담음(버튼 선택이
|
||||
* 통째로 덮어써도 안 지워지게). [저장]·[확정] 이 patch 로 싣고, 브라우저 재계산이 입력으로 씀. */
|
||||
export type ShoulderSide = "left" | "right";
|
||||
export type ShoulderExtends = Map<number, Partial<Record<ShoulderSide, number>>>;
|
||||
|
||||
/** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */
|
||||
export function readShoulderExtends(
|
||||
projectId: string | null,
|
||||
routeId: number | null,
|
||||
): ShoulderExtends {
|
||||
const out: ShoulderExtends = new Map();
|
||||
if (!projectId || routeId === null) return out;
|
||||
const raw = readState<Record<string, Partial<Record<ShoulderSide, number>>>>(
|
||||
"shoulderext",
|
||||
projectId,
|
||||
routeId,
|
||||
);
|
||||
for (const [key, value] of Object.entries(raw ?? {})) {
|
||||
const chainage = Number(key);
|
||||
if (Number.isFinite(chainage) && value) out.set(chainage, value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 한 측점 한 쪽 노견 확장을 담는다(0 = 되돌림 · 저장 때 정본의 옛 값을 지움). */
|
||||
export function writeShoulderExtend(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
chainageM: number,
|
||||
side: ShoulderSide,
|
||||
valueM: number,
|
||||
): void {
|
||||
const all =
|
||||
readState<Record<string, Partial<Record<ShoulderSide, number>>>>(
|
||||
"shoulderext",
|
||||
projectId,
|
||||
routeId,
|
||||
) ?? {};
|
||||
all[keyOf(chainageM)] = { ...(all[keyOf(chainageM)] ?? {}), [side]: Math.max(valueM, 0) };
|
||||
writeState("shoulderext", all, projectId, routeId);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import { previewCrossDesigns } from "./B06_Section_Api_Fetch";
|
||||
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
|
||||
import type { BermSpec } from "@util/common_util_cross_berm";
|
||||
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
|
||||
import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session";
|
||||
import {
|
||||
effectiveStandardCross,
|
||||
readRockBoundaryDefault,
|
||||
@@ -89,6 +89,9 @@ export const USER_TOUCHED_KEYS = [
|
||||
"cut_slope_ratio_user",
|
||||
// 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
|
||||
"structure_note",
|
||||
// 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력이라 재계산에도 넘김
|
||||
"shoulder_extend_left_m",
|
||||
"shoulder_extend_right_m",
|
||||
] as const;
|
||||
|
||||
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
|
||||
@@ -267,6 +270,13 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
};
|
||||
const choices = crossDesignChoices(projectId, input.routeId);
|
||||
const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100);
|
||||
// 노견 확장(139-6) — 세션 → 저장분 순 · 기하 입력이라 계산에 넣고 결과 칸도 그 값으로
|
||||
const shoulderExtends = readShoulderExtends(projectId, input.routeId);
|
||||
const extendAt = (chainageM: number, design: Record<string, unknown>, side: "left" | "right") => {
|
||||
const session = shoulderExtends.get(Math.round(chainageM * 100) / 100)?.[side];
|
||||
const stored = design[`shoulder_extend_${side}_m`];
|
||||
return Math.max(session ?? (typeof stored === "number" ? stored : 0), 0);
|
||||
};
|
||||
|
||||
const updated: number[] = [];
|
||||
for (const section of detail.cross_sections) {
|
||||
@@ -284,6 +294,8 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
? storedOffset
|
||||
: rockDefault;
|
||||
const choice = choiceAt(section.chainage_m);
|
||||
const extendLeft = extendAt(section.chainage_m, design, "left");
|
||||
const extendRight = extendAt(section.chainage_m, design, "right");
|
||||
let next;
|
||||
try {
|
||||
next = computeCrossDesign(
|
||||
@@ -328,6 +340,8 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
? section.curve_outer_side
|
||||
: null,
|
||||
curveWideningM: section.curve_widening_m ?? null,
|
||||
shoulderExtendLeftM: extendLeft,
|
||||
shoulderExtendRightM: extendRight,
|
||||
// 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라
|
||||
// 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다).
|
||||
// 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다.
|
||||
@@ -350,6 +364,8 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
next as unknown as NonNullable<CrossSection["design"]>,
|
||||
previous,
|
||||
);
|
||||
section.design.shoulder_extend_left_m = extendLeft;
|
||||
section.design.shoulder_extend_right_m = extendRight;
|
||||
updated.push(section.chainage_m);
|
||||
}
|
||||
return updated;
|
||||
|
||||
@@ -71,26 +71,42 @@ CHUTE_THICKNESS_CM = 30.0
|
||||
# 1:0.3~0.5의 급한 쪽. 지식DB 근거가 있는 값이라 교차 참조 표시가 붙지 않는다.
|
||||
REVET_FACE_SLOPE = 0.3
|
||||
|
||||
# ── 관 벽 최소 높이 — ⚠ TS 짝: `B06_Section_UI_Cross_Culvert_Const.ts`
|
||||
# (`REVET_FREEBOARD_M`·`REVET_HEIGHT_STEP_M`·`REVET_EMBED_DEPTH_M`·`pipeWallMinPureHeight`).
|
||||
# ── 관 벽 기본 높이 — ⚠ TS 짝: `B06_Section_UI_Cross_Culvert_Const.ts`
|
||||
# (`REVET_HEIGHT_STEP_M`·`REVET_EMBED_DEPTH_M`·`revetTargetHeight`·`pipeWallMinPureHeight`).
|
||||
# 횡단도가 관 벽을 그리는 그 높이 — 구조물 표·구조물도 그림도 이 값을 씀(2026-09-14 브레인 판정
|
||||
# 「그림 둘이 같은 벽을 다르게 그리면 결함 · 근거 있는 관경 기준으로 맞춤」). 거울 시험
|
||||
# `test_b06_pipe_wall_height_mirror`.
|
||||
REVET_FREEBOARD_M = 0.5 # 관 상단 ~ 벽 상단 여유고(잠정 · 토피 0.5 와 같은 값)
|
||||
REVET_HEIGHT_STEP_M = 0.1 # 높이 눈금
|
||||
REVET_EMBED_DEPTH_M = 0.5 # 근입 깊이
|
||||
# 옛 정의 기본 높이(10-6 표 · 관경 무관) — 관경 > 1.0 관에 이 값이면 관 윗면보다 낮은 벽이라
|
||||
# 읽을 때 관경 기준 값으로 봄(139-1).
|
||||
OLD_PIPE_WALL_DEFAULT_M = 1.5
|
||||
|
||||
|
||||
def pipe_wall_min_height_m(diameter_m: float) -> float:
|
||||
"""관 벽 **순수 높이**(근입 포함) 최소값 — 관경 + 여유고를 0.1m 로 올린 뒤 근입을 더함.
|
||||
"""관 벽 **순수 높이**(근입 포함) 기본값 = 관경(0.1m 로 올림) + 근입 0.5 — 벽 윗면 = 관 윗면(139-1).
|
||||
|
||||
⚠ 「최소」다 — 실제 설계값은 더 클 수 있어 이 값으로 선 수량은 미확정으로 둠(금액 밖).
|
||||
관 · 유입 집수정 높이 칸의 새 기본값이자 빈 칸 값.
|
||||
"""
|
||||
raw = float(diameter_m) + REVET_FREEBOARD_M
|
||||
raw = float(diameter_m)
|
||||
steps = math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9)
|
||||
return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3)
|
||||
|
||||
|
||||
def pipe_wall_height_m(stored: float | None, diameter_m: float) -> float | None:
|
||||
"""관 벽 · 유입 집수정 높이 칸 읽기 — 옛 기본 1.5 이고 관경 > 1.0 이면 관경 기준 값(139-1).
|
||||
|
||||
빈 칸 = None(부르는 쪽이 빈 칸 규칙). ⚠ TS 짝 `common_util_culvert_sets.pipeWallHeightM`.
|
||||
"""
|
||||
if (
|
||||
stored is not None
|
||||
and abs(stored - OLD_PIPE_WALL_DEFAULT_M) < 1e-9
|
||||
and float(diameter_m) > 1.0 + 1e-9
|
||||
):
|
||||
return pipe_wall_min_height_m(diameter_m)
|
||||
return stored
|
||||
|
||||
|
||||
# (2026-08-20 사용자 확정) 배관은 **수평도 가능** — 그림에서 경사를 강제로 만들지
|
||||
# 않는다. config `DRAINAGE_PIPE_SLOPE_DEG`(10도)는 유효직경 수리계산 전용으로 남는다.
|
||||
|
||||
@@ -188,16 +204,17 @@ def _side_spec(
|
||||
None,
|
||||
)
|
||||
# 집수정 높이(좌측 폼 · 비면 정의 기본값) — 횡단도 집수정 벽 높이.
|
||||
spec["basin_height_m"] = _number(options.get("inlet_basin_height_m"), None)
|
||||
spec["basin_height_m"] = pipe_wall_height_m(
|
||||
_number(options.get("inlet_basin_height_m"), None), diameter_m
|
||||
)
|
||||
# 집수정 모양(54-39) — 빈 값 = 안 고름
|
||||
spec["basin_form"] = str(options.get("inlet_structure") or "") or None
|
||||
return spec
|
||||
|
||||
# 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(2026-09-14 · 옛 기본 2.5 걷음).
|
||||
height = _number(
|
||||
options.get(f"{side}_revet_height_m"),
|
||||
pipe_wall_min_height_m(diameter_m),
|
||||
)
|
||||
# 높이를 안 적었으면 **관경 기준 높이**(관경 + 0.5 · 139-1) · 옛 1.5 도 관경 > 1.0 이면 그 값.
|
||||
height = pipe_wall_height_m(_number(options.get(f"{side}_revet_height_m"), None), diameter_m)
|
||||
if height is None:
|
||||
height = pipe_wall_min_height_m(diameter_m)
|
||||
length = _number(
|
||||
options.get(f"{side}_revet_length_m"),
|
||||
None,
|
||||
|
||||
@@ -141,6 +141,20 @@ def _ground_interpolator(valid: list[tuple[float, float]]):
|
||||
return ground_at
|
||||
|
||||
|
||||
def stored_shoulder_extend(stored: dict[str, Any] | None) -> dict[str, float]:
|
||||
"""저장분의 설계선 쉬프트(쪽마다 노견 확장 · 139-6) — `compute_cross_design(**...)` 로 넘긴다.
|
||||
|
||||
⚠ 소단 · 암 절토 경사와 같은 **기하 입력**이다 — 다시 계산한 뒤 키만 베껴 붙이면 설계선은 옛
|
||||
노견으로 나오고 값만 남아 어긋난다. 없거나 음수 = 0.
|
||||
"""
|
||||
out: dict[str, float] = {}
|
||||
for side in ("left", "right"):
|
||||
value = (stored or {}).get(f"shoulder_extend_{side}_m")
|
||||
ok = isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
out[f"shoulder_extend_{side}_m"] = max(float(value), 0.0) if ok else 0.0
|
||||
return out
|
||||
|
||||
|
||||
def curve_widening_args(section: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""측점 기록에서 곡선부 확폭 입력을 뽑는다 — `compute_cross_design(**...)` 로 넘긴다.
|
||||
|
||||
@@ -178,6 +192,8 @@ def compute_cross_design(
|
||||
curve_widening_m: float | None = None,
|
||||
berm: BermSpec | None = None,
|
||||
spoil_fill: dict[str, Any] | None = None,
|
||||
shoulder_extend_left_m: float = 0.0,
|
||||
shoulder_extend_right_m: float = 0.0,
|
||||
) -> dict[str, Any]:
|
||||
"""측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다.
|
||||
|
||||
@@ -198,6 +214,8 @@ def compute_cross_design(
|
||||
높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로
|
||||
횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자).
|
||||
spoil_fill: 이 측점에 선 유용토운반작업장(구 사토장) — `{"side", "width_m", "slope_ratio_n"}`.
|
||||
shoulder_extend_left_m · shoulder_extend_right_m: 설계선 쉬프트 = 쪽마다 노견 확장(m · ≥ 0 ·
|
||||
139-6) — 사용자 값 · 저장분은 `stored_shoulder_extend`. TS 짝 `shoulderExtendLeftM` 등.
|
||||
폭은 **노면 끝**(노견이 시작하는 자리)에서 재고, 그 바깥 성토는 **노선 몫이 아니라
|
||||
사토장 몫**이라 `fill_area_m2` 에서 뺀다(2026-09-09 확정 ㉠ — 두 번 세지 않기).
|
||||
`slope_ratio_n` 이 비면 그 측점의 **노선 성토 기울기**를 그대로 쓴다.
|
||||
@@ -279,6 +297,8 @@ def compute_cross_design(
|
||||
widening_left_m=widening_left,
|
||||
widening_right_m=widening_right,
|
||||
berm=berm,
|
||||
shoulder_extend_left_m=shoulder_extend_left_m,
|
||||
shoulder_extend_right_m=shoulder_extend_right_m,
|
||||
)
|
||||
|
||||
# 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만). 꼭짓점을 넣어야
|
||||
@@ -515,6 +535,13 @@ def compute_cross_design(
|
||||
"interval_m": round(float(berm.interval_m), 4),
|
||||
"slope_deg": round(float(berm.slope_deg), 4),
|
||||
}
|
||||
# 노견 확장(139-6) — 넓힌 쪽만 되싣는다(사용자 값 · 다시 셀 때 입력)
|
||||
for side, extend in (
|
||||
("left", geometry.shoulder_extend_left),
|
||||
("right", geometry.shoulder_extend_right),
|
||||
):
|
||||
if extend > 0:
|
||||
result[f"shoulder_extend_{side}_m"] = round(extend, 4)
|
||||
if paved:
|
||||
result["pavement_thickness_m"] = round(paved_group["pavement_thickness_m"], 4)
|
||||
# 암 지반은 경계선 오프셋을 echo해 프론트가 세션값 없이도 오버레이·재계산에 쓰게 한다.
|
||||
|
||||
@@ -59,6 +59,8 @@ class _SectionGeometry:
|
||||
widening_left_m: float = 0.0,
|
||||
widening_right_m: float = 0.0,
|
||||
berm: BermSpec | None = None,
|
||||
shoulder_extend_left_m: float = 0.0,
|
||||
shoulder_extend_right_m: float = 0.0,
|
||||
) -> None:
|
||||
half_road = group["road_width_m"] / 2.0
|
||||
# 곡선부 확폭은 **한쪽으로만** 붙는다(2026-09-06 사용자 확정: 곡선 바깥쪽).
|
||||
@@ -138,6 +140,13 @@ class _SectionGeometry:
|
||||
#: 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다.
|
||||
self.ditch_choice = choice
|
||||
|
||||
# 설계선 쉬프트 = 쪽마다 노견 확장(≥ 0 · 139-6) — 단면유형 · 측구 판정은 **옮기기 전**
|
||||
# 노견에서(위) · 노체 · 측구 · 사면이 같이 바깥으로 · 차도는 그대로. ⚠ TS 짝 `SectionGeometry`.
|
||||
self.shoulder_extend_left = max(shoulder_extend_left_m, 0.0)
|
||||
self.shoulder_extend_right = max(shoulder_extend_right_m, 0.0)
|
||||
self.left_extent += self.shoulder_extend_left
|
||||
self.right_extent += self.shoulder_extend_right
|
||||
|
||||
# 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
|
||||
self.ditch_points: list[tuple[float, float]] = []
|
||||
edge_offset = self.left_extent if ditch_side == "left" else -self.right_extent
|
||||
|
||||
@@ -24,6 +24,7 @@ from B06_Section.B06_Section_Engine_Design import (
|
||||
_SLOPE_CLOSE_TOLERANCE_M,
|
||||
compute_cross_design,
|
||||
curve_widening_args,
|
||||
stored_shoulder_extend,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
@@ -156,6 +157,7 @@ def extend_unclosed_sections(
|
||||
surface_drop_m=ford_drop_at(chainage, drops),
|
||||
berm=stored_berm(design),
|
||||
**curve_widening_args(section),
|
||||
**stored_shoulder_extend(design), # 노견 확장(139-6)
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -23,7 +23,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design, stored_shoulder_extend
|
||||
from common_util.common_util_structure_face_role import structure_face_role
|
||||
|
||||
#: 폭을 좁혀 가는 이분법 반복 수. TS 짝(`solveSpoilWidthM`)과 같은 값이다.
|
||||
@@ -216,6 +216,7 @@ def enforce_spoil_fills(
|
||||
"slope_ratio_n": slope_ratio_n,
|
||||
},
|
||||
**curve_widening_args(section),
|
||||
**stored_shoulder_extend(design), # 노견 확장(139-6)
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
|
||||
@@ -12,7 +12,6 @@ from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
prune_stale_cross_files,
|
||||
@@ -21,7 +20,11 @@ from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||
from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
|
||||
from B06_Section.B06_Section_Engine_Design import (
|
||||
compute_cross_design,
|
||||
curve_widening_args,
|
||||
stored_shoulder_extend,
|
||||
)
|
||||
from B06_Section.B06_Section_Engine_Structures_Wall import attach_wall_structures
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
count_cross_sections,
|
||||
@@ -61,6 +64,10 @@ from B06_Section.B06_Section_Router_Design import (
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
stored_standard_cross_section as _stored_standard_cross_section,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_Regenerate_Options import ( # noqa: F401 — 시험이 여기서 씀
|
||||
_regeneration_grade_options,
|
||||
_regeneration_options,
|
||||
)
|
||||
from B06_Section.B06_Section_Schema import (
|
||||
CompanyStandardListResponse,
|
||||
CompanyStandardProject,
|
||||
@@ -373,67 +380,6 @@ async def get_section_detail(
|
||||
)
|
||||
|
||||
|
||||
def _regeneration_grade_options(stage_params: dict[str, Any] | None) -> GradeDesignOptions | None:
|
||||
"""재생성 시 종단 계획선 기준을 확정 당시 저장 파라미터(stage 2)로 재구성한다.
|
||||
|
||||
grade_options 없이 재생성하면 종단 계획선(profile_alignment)이 통째로 소실돼 계획
|
||||
횡단도선·유토곡선·테이블이 사라진다(2026-08-06 사용자 보고 — 옛 재계산 버튼 사고의
|
||||
원인). B04 파일입력→B06 자동 계산 파이프라인이 쓰는 같은 엔진 경로를 재활용한다.
|
||||
"""
|
||||
if not stage_params:
|
||||
return None
|
||||
options = stage_params.get("options") or {}
|
||||
grade_class = options.get("grade_class")
|
||||
if not grade_class:
|
||||
return None
|
||||
stored = {
|
||||
key: stage_params.get(key)
|
||||
for key in (
|
||||
"max_grade_pct",
|
||||
"min_vertical_radius_m",
|
||||
"min_tangent_length_m",
|
||||
"balance_segment_length_m",
|
||||
"start_elevation_offset_m",
|
||||
"end_elevation_offset_m",
|
||||
# 횡단배수 최소고 강제 스위치도 확정 당시 값을 따라야 한다 — 빠뜨리면 재생성이
|
||||
# 기본(해제)으로 돌아가 켜 둔 계획선이 내려앉는다(2026-09-01).
|
||||
"enforce_pipe_clearance",
|
||||
)
|
||||
if stage_params.get(key) is not None
|
||||
}
|
||||
# 설계속도는 B05가 저장해 둔 값을 그대로 따른다 — 없으면 종류별 기본(20).
|
||||
speed = options.get("design_speed_kph")
|
||||
return resolve_grade_options(
|
||||
str(grade_class),
|
||||
terrain_type=str(options.get("terrain_type") or "normal"),
|
||||
paved=bool(options.get("paved", False)),
|
||||
design_speed_kph=int(speed) if speed is not None else None,
|
||||
stored=stored,
|
||||
)
|
||||
|
||||
|
||||
def _regeneration_options(
|
||||
stored_options: dict[str, Any] | None,
|
||||
stage_params: dict[str, Any] | None,
|
||||
cross_half_width_m: float,
|
||||
) -> SectionGenerationOptions:
|
||||
"""DB 저장 옵션(단일 소스) → stage 2 params → config 순으로 유지하고 반폭만 교체한다."""
|
||||
defaults = SectionGenerationOptions()
|
||||
stored = stored_options or {}
|
||||
params = stage_params or {}
|
||||
|
||||
def pick(key: str, default: float) -> float:
|
||||
return stored.get(key) or params.get(key) or default
|
||||
|
||||
return SectionGenerationOptions(
|
||||
station_interval_m=pick("station_interval_m", defaults.station_interval_m),
|
||||
cross_half_width_m=cross_half_width_m,
|
||||
cross_sample_interval_m=pick("cross_sample_interval_m", defaults.cross_sample_interval_m),
|
||||
long_sample_interval_m=pick("long_sample_interval_m", defaults.long_sample_interval_m),
|
||||
include_endpoint=defaults.include_endpoint,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/sections/{route_id}/regenerate", response_model=SectionDetailResponse)
|
||||
async def regenerate_sections(
|
||||
project_id: UUID, route_id: int, request: SectionRegenerateRequest
|
||||
@@ -653,6 +599,7 @@ async def compute_cross_section_design(
|
||||
surface_drop_m=ford_drop_at(request.chainage_m, ford_surface_drops(project_root)),
|
||||
berm=stored_berm(stored_design or {}),
|
||||
**curve_widening_args(cross_record),
|
||||
**stored_shoulder_extend(stored_design or {}), # 노견 확장(139-6)
|
||||
)
|
||||
# B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다.
|
||||
design["status"] = "provisional"
|
||||
|
||||
@@ -9,7 +9,11 @@ from B05_Profile.B05_Profile_Engine_Sections import find_cross_file
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||
from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
|
||||
from B06_Section.B06_Section_Engine_Design import (
|
||||
compute_cross_design,
|
||||
curve_widening_args,
|
||||
stored_shoulder_extend,
|
||||
)
|
||||
from common_util.common_util_cross_berm import (
|
||||
BERM_DEFAULT_INTERVAL_M,
|
||||
BERM_DEFAULT_SLOPE_DEG,
|
||||
@@ -150,6 +154,9 @@ USER_TOUCHED_KEYS = (
|
||||
"cut_slope_ratio_user",
|
||||
# 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
|
||||
"structure_note",
|
||||
# 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 기하 입력(`stored_shoulder_extend`)
|
||||
"shoulder_extend_left_m",
|
||||
"shoulder_extend_right_m",
|
||||
)
|
||||
|
||||
# 노선 [확인] 체인이 옛 노선에서 누가거리로 넘기는 **사용자 입력**(PLAN 74-3) — 계획고 · 면적 ·
|
||||
@@ -222,6 +229,59 @@ def enforce_pavement_ranges(
|
||||
surface_drop_m=ford_drop_at(chainage, ford_drops),
|
||||
berm=stored_berm(design),
|
||||
**curve_widening_args(section),
|
||||
**stored_shoulder_extend(design), # 노견 확장(139-6)
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS):
|
||||
if design.get(key) is not None:
|
||||
recomputed[key] = design[key]
|
||||
section["design"] = recomputed
|
||||
changed += 1
|
||||
return changed
|
||||
|
||||
|
||||
def enforce_design_lines(
|
||||
longitudinal: dict[str, Any],
|
||||
cross_sections: list[dict[str, Any]],
|
||||
project_root: Path,
|
||||
standard: dict[str, Any] | None = None,
|
||||
) -> int:
|
||||
"""[저장]·[확정] 때 **모든 측점** 설계선을 저장된 입력으로 서버가 다시 낸다(139-6).
|
||||
|
||||
그전에는 포장 · 세월교 · 사토장 측점만 다시 냈고 보통 측점은 브라우저가 보낸 설계를 그대로
|
||||
받아 적었다(CLAUDE.md 5장 「[저장]은 서버가 정본으로 다시 계산」 어긋남 · B07 이 저장된 선을
|
||||
그림). 입력 = 저장분(지반유형 · 단면유형 · 측구 · 포장 · 암 경계 · 암 절토 경사 · 소단 · 노견
|
||||
확장) + 지금 종단 계획고 · 세월교 월류 하강 · 곡선부 확폭. 사용자 조작값은 승계.
|
||||
"""
|
||||
ford_drops = ford_surface_drops(project_root)
|
||||
changed = 0
|
||||
for section in cross_sections:
|
||||
design = section.get("design")
|
||||
if not isinstance(design, dict):
|
||||
continue
|
||||
chainage = float(section.get("chainage_m", 0.0))
|
||||
try:
|
||||
recomputed = compute_cross_design(
|
||||
section.get("samples", []),
|
||||
design_elevation_from_longitudinal(longitudinal, chainage),
|
||||
ground_type=str(design.get("ground_type") or "ripping_rock"),
|
||||
section_mode=str(design.get("section_mode") or "left_cut"),
|
||||
ditch_side=design.get("ditch_side"),
|
||||
ditch_type=str(design.get("ditch_type") or "standard"),
|
||||
paved=bool(design.get("paved", False)),
|
||||
standard=standard,
|
||||
rock_boundary_offset_m=design.get(
|
||||
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
|
||||
),
|
||||
two_stage_slope=bool(design.get("two_stage_slope", True)),
|
||||
cut_slope_ratio=stored_cut_slope(design),
|
||||
ditch_enabled=design.get("ditch_enabled"),
|
||||
ditch_choice=design.get("ditch_choice"),
|
||||
surface_drop_m=ford_drop_at(chainage, ford_drops),
|
||||
berm=stored_berm(design),
|
||||
**curve_widening_args(section),
|
||||
**stored_shoulder_extend(design),
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
@@ -276,6 +336,7 @@ def enforce_ford_surface_drops(
|
||||
surface_drop_m=wanted,
|
||||
berm=stored_berm(design),
|
||||
**curve_widening_args(section),
|
||||
**stored_shoulder_extend(design), # 노견 확장(139-6)
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
@@ -472,6 +533,7 @@ def recompute_designs_for_alignment(
|
||||
surface_drop_m=ford_drop_at(chainage, ford_drops),
|
||||
berm=session_berms.get(key) or stored_berm(stored),
|
||||
**curve_widening_args(section),
|
||||
**stored_shoulder_extend(stored), # 노견 확장(139-6)
|
||||
)
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""B06 재생성 옵션 — 저장 옵션 · stage 2 파라미터 → 종단 계획선 기준 · 측점 생성 옵션.
|
||||
|
||||
`B06_Section_Router.py` 에서 700줄 제한으로 분리(139-6). 라우터가 그대로 다시 씀.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_grade_options
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||
|
||||
|
||||
def _regeneration_grade_options(stage_params: dict[str, Any] | None) -> GradeDesignOptions | None:
|
||||
"""재생성 시 종단 계획선 기준을 확정 당시 저장 파라미터(stage 2)로 재구성한다.
|
||||
|
||||
grade_options 없이 재생성하면 종단 계획선(profile_alignment)이 통째로 소실돼 계획
|
||||
횡단도선·유토곡선·테이블이 사라진다(2026-08-06 사용자 보고 — 옛 재계산 버튼 사고의
|
||||
원인). B04 파일입력→B06 자동 계산 파이프라인이 쓰는 같은 엔진 경로를 재활용한다.
|
||||
"""
|
||||
if not stage_params:
|
||||
return None
|
||||
options = stage_params.get("options") or {}
|
||||
grade_class = options.get("grade_class")
|
||||
if not grade_class:
|
||||
return None
|
||||
stored = {
|
||||
key: stage_params.get(key)
|
||||
for key in (
|
||||
"max_grade_pct",
|
||||
"min_vertical_radius_m",
|
||||
"min_tangent_length_m",
|
||||
"balance_segment_length_m",
|
||||
"start_elevation_offset_m",
|
||||
"end_elevation_offset_m",
|
||||
# 횡단배수 최소고 강제 스위치도 확정 당시 값을 따라야 한다 — 빠뜨리면 재생성이
|
||||
# 기본(해제)으로 돌아가 켜 둔 계획선이 내려앉는다(2026-09-01).
|
||||
"enforce_pipe_clearance",
|
||||
)
|
||||
if stage_params.get(key) is not None
|
||||
}
|
||||
# 설계속도는 B05가 저장해 둔 값을 그대로 따른다 — 없으면 종류별 기본(20).
|
||||
speed = options.get("design_speed_kph")
|
||||
return resolve_grade_options(
|
||||
str(grade_class),
|
||||
terrain_type=str(options.get("terrain_type") or "normal"),
|
||||
paved=bool(options.get("paved", False)),
|
||||
design_speed_kph=int(speed) if speed is not None else None,
|
||||
stored=stored,
|
||||
)
|
||||
|
||||
|
||||
def _regeneration_options(
|
||||
stored_options: dict[str, Any] | None,
|
||||
stage_params: dict[str, Any] | None,
|
||||
cross_half_width_m: float,
|
||||
) -> SectionGenerationOptions:
|
||||
"""DB 저장 옵션(단일 소스) → stage 2 params → config 순으로 유지하고 반폭만 교체한다."""
|
||||
defaults = SectionGenerationOptions()
|
||||
stored = stored_options or {}
|
||||
params = stage_params or {}
|
||||
|
||||
def pick(key: str, default: float) -> float:
|
||||
return stored.get(key) or params.get(key) or default
|
||||
|
||||
return SectionGenerationOptions(
|
||||
station_interval_m=pick("station_interval_m", defaults.station_interval_m),
|
||||
cross_half_width_m=cross_half_width_m,
|
||||
cross_sample_interval_m=pick("cross_sample_interval_m", defaults.cross_sample_interval_m),
|
||||
long_sample_interval_m=pick("long_sample_interval_m", defaults.long_sample_interval_m),
|
||||
include_endpoint=defaults.include_endpoint,
|
||||
)
|
||||
@@ -117,6 +117,8 @@ class FordAdjustPatch(BaseModel):
|
||||
|
||||
inlet: FordWallAdjustPatch = Field(default_factory=FordWallAdjustPatch)
|
||||
outlet: FordWallAdjustPatch = Field(default_factory=FordWallAdjustPatch)
|
||||
# 바닥판 내림(수직 · 양쪽 한 값 · 아래로만 · 139-3) — None = 옛 저장(화면이 읽을 때 옮김)
|
||||
dropM: float | None = Field(default=None, ge=0, le=20)
|
||||
|
||||
|
||||
class BoxSideAdjustPatch(BaseModel):
|
||||
@@ -145,6 +147,9 @@ class CrossSectionPatch(BaseModel):
|
||||
# **0 은 「표준값을 씀」**(되돌리기)이라 gt=0 이 아니라 ge=0 이다 — 0 이 와야
|
||||
# 정본에 남은 옛 사용자 값이 지워진다.
|
||||
cut_slope_ratio_user: float | None = Field(default=None, ge=0)
|
||||
# 설계선 쉬프트 = 쪽마다 노견 확장(m · 139-6) — 0 = 되돌림(정본의 옛 값을 지움)
|
||||
shoulder_extend_left_m: float | None = Field(default=None, ge=0, le=20)
|
||||
shoulder_extend_right_m: float | None = Field(default=None, ge=0, le=20)
|
||||
inlet_structure: Literal["auto", "revet", "I", "L", "U"] | None = None
|
||||
basin_adjust: BasinAdjustPatch | None = None
|
||||
# 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…).
|
||||
|
||||
@@ -241,6 +241,13 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
|
||||
standard = stored_standard_cross_section(longitudinal_row)
|
||||
# 포장 구간·세월교 보정 — 고쳐진 설계 위에서 면적·유토곡선이 나와야 한다.
|
||||
before = [json.dumps(item.get("design"), sort_keys=True, default=str) for item in sections]
|
||||
# 모든 측점 설계선을 저장 입력으로 먼저 다시 냄(139-6 · 노견 확장 · 측구 · 지반유형) — 미교차
|
||||
# 판정도 새 설계로 해야 넓힘 지표면을 연다.
|
||||
from B06_Section.B06_Section_Router_Design import enforce_design_lines
|
||||
|
||||
await asyncio.to_thread(
|
||||
enforce_design_lines, detail.get("longitudinal") or {}, sections, project_root, standard
|
||||
)
|
||||
# 확정 지표면 — 미교차 측점이 있을 때만 연다(샘플 넓힘 ㉳ (가) · 없으면 여는 비용을 안 씀).
|
||||
unclosed = any((item.get("design") or {}).get("slope_unclosed") for item in sections)
|
||||
sampler = await asyncio.to_thread(_surface_sampler, project_root, surface) if unclosed else None
|
||||
|
||||
@@ -29,7 +29,7 @@ import type {
|
||||
InletStructureControl,
|
||||
RevetOffsetControl,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { computeFordLayout, DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford_Geom";
|
||||
import { computeFordLayout, fordAdjustOf } from "./B06_Section_UI_Cross_Ford_Geom";
|
||||
import type { FordLayout } from "./B06_Section_UI_Cross_Ford_Geom";
|
||||
import { computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment";
|
||||
|
||||
@@ -119,10 +119,7 @@ export function computeStoredLayouts(section: CrossSection, sections: readonly C
|
||||
culvert,
|
||||
);
|
||||
const ford = withoutGuardedFill(
|
||||
computeFordLayout(section, section.samples, {
|
||||
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.inlet ?? {}) },
|
||||
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(design.ford_adjust?.outlet ?? {}) },
|
||||
}),
|
||||
computeFordLayout(section, section.samples, fordAdjustOf(design.ford_adjust)),
|
||||
culvert,
|
||||
);
|
||||
// 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다.
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
WallAdjust,
|
||||
WallLayout,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { computeFordLayout, DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford_Geom";
|
||||
import { computeFordLayout, fordAdjustOf } from "./B06_Section_UI_Cross_Ford_Geom";
|
||||
import type { FordLayout } from "./B06_Section_UI_Cross_Ford_Geom";
|
||||
import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall";
|
||||
|
||||
@@ -77,10 +77,7 @@ export function bodyGuardBase(
|
||||
}
|
||||
if (body === "ford" && section.ford) {
|
||||
const stored = defaults ? null : adjust?.ford_adjust;
|
||||
const layout = computeFordLayout(section, samples, {
|
||||
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) },
|
||||
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) },
|
||||
});
|
||||
const layout = computeFordLayout(section, samples, fordAdjustOf(stored));
|
||||
if (!layout) return null;
|
||||
// 세월교 끝 = 관 끝(측벽 바깥면) · 윗면 = 측벽 상단(상판 높이) · 바닥 = 바닥판 밑
|
||||
const endOf = (role: Role): BodyEnd | null => {
|
||||
@@ -148,7 +145,9 @@ export type BodyAxes = { x: number; d: number; through: number; outward: number
|
||||
/** 세월교 관은 1 m 단위(80) — 흙막이 전면까지 든 관 길이를 올림하고 모자란 만큼 흙막이를 바깥으로
|
||||
* 미는 기본 x(유출 먼저 · 배관 정수 맞춤과 같은 쪽). 손으로 민 x 는 그 위에 1 m 걸음으로 얹힘.
|
||||
* 맞춤 길이는 **기본 자리**(측벽 조작 0)로만 잼 — 한쪽 이동이 반대쪽 흙막이를 안 밈(107-1 ·
|
||||
* 수량 L 은 그린 관 길이 올림 그대로). */
|
||||
* 수량 L 은 그린 관 길이 올림 그대로). 노견을 넓힌 쪽(139-6)은 사면 우선 — 그 쪽 흙막이는 안 밀고
|
||||
* 반대쪽(안 넓힌 쪽)을 밂 · 둘 다 넓혔으면 표기만 올림.
|
||||
* ponytail: 흙막이 높이 역산 없음 — 윗면이 본체 끝 윗면에 묶임(79-7) · 필요하면 배관 `_Culvert_Fit` 꼴로. */
|
||||
export function fordPipeFit(
|
||||
section: CrossSection,
|
||||
samples: SectionSample[],
|
||||
@@ -170,7 +169,12 @@ export function fordPipeFit(
|
||||
// ponytail: 관 기울기는 작아 바깥 수평 이동으로 길이를 채움
|
||||
const length = Math.hypot(tips[0].offset - tips[1].offset, tips[0].z - tips[1].z);
|
||||
const gap = Math.ceil(length - 1e-6) - length;
|
||||
const at = guarded[1] ? 1 : 0;
|
||||
const shifted = roles.map((role) => {
|
||||
const left = (base.ends[role]?.offset ?? 0) > 0;
|
||||
return (section.design?.[`shoulder_extend_${left ? "left" : "right"}_m`] ?? 0) > 1e-9;
|
||||
});
|
||||
const at = [1, 0].find((i) => guarded[i] && !shifted[i]);
|
||||
if (at === undefined) return axes;
|
||||
const fit = axes[at];
|
||||
if (!guarded[at] || !fit || gap < 1e-6) return axes;
|
||||
axes[at] = { ...fit, x: fit.x + gap };
|
||||
|
||||
@@ -133,8 +133,9 @@ export function computeBoxLayout(
|
||||
const inletOnLeft = (section.uphill_side ?? "left") === "left";
|
||||
const baseTop = Math.min(roadTopAt(leftEdge), roadTopAt(rightEdge), roadTopAt(0)) - box.cover_m;
|
||||
const bodyHeight = box.top_thickness_m + box.inner_height_m + box.slab_thickness_m;
|
||||
// 본체는 늘 수평(79-7) — 좌 · 우 표고 조정이 달라도(옛 저장) 평균 하나로 섬.
|
||||
const riseM = (adjust.left.riseM + adjust.right.riseM) / 2;
|
||||
// 본체는 늘 수평(79-7) — 좌 · 우 표고 조정이 달라도(옛 저장) 평균 하나로 섬 · 아래로만(노면 −
|
||||
// 복토 위 금지 · 옛 위 값은 자름 · 139-3).
|
||||
const riseM = Math.min((adjust.left.riseM + adjust.right.riseM) / 2, 0);
|
||||
const sampleOffsets = groundSamples.map((sample) => sample.offset_m ?? 0);
|
||||
// 끝 절토 = 본선 절토 사면과 같은 복합경사(91-4)
|
||||
const cutSpec = structureCutSpec(section.design);
|
||||
|
||||
@@ -127,7 +127,7 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo
|
||||
slopeOutward: null,
|
||||
clamp: (left, down) => [
|
||||
clampAxis(from.lengthM, left * lengthSign, 0, Infinity) * lengthSign,
|
||||
-clampAxis(from.riseM, -down, -RISE_LIMIT_M, RISE_LIMIT_M),
|
||||
-clampAxis(from.riseM, -down, -RISE_LIMIT_M, 0), // 아래로만(139-3)
|
||||
],
|
||||
apply: (left, down) => deps.move(role, left, -down),
|
||||
});
|
||||
|
||||
@@ -102,6 +102,13 @@ export function appendCulvertOverlay(
|
||||
line.append(title);
|
||||
layer.append(line);
|
||||
};
|
||||
// 묻힌 끝 절토선 툴팁 — 3도 내림 선(끝이 시작보다 낮음) / 못 만나 복합경사 절토(139-4 · 139-5).
|
||||
const cutLineTip = (name: string, segment: (typeof layout.outletFill.segments)[number]) => {
|
||||
const [first, last] = [segment.points[0], segment.points[segment.points.length - 1]];
|
||||
return last.elevation < first.elevation
|
||||
? `${name} 3도 절토선 — 구조물이 원지반에 묻혀 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`
|
||||
: `${name} 절토선 — 3도 선이 원지반을 못 만나 절토 경사로 원지반까지`;
|
||||
};
|
||||
const drawOutletFill = (): void => {
|
||||
// 도수로 · 돌붙임 띠(90-2) — 유출 끝 ~ 원지반 성토 조각(단 사이 포함) · 연동 카드엔 안 그림
|
||||
if (culvert.chute && !linked)
|
||||
@@ -113,13 +120,10 @@ export function appendCulvertOverlay(
|
||||
toDisplayY,
|
||||
);
|
||||
for (const segment of layout.outletFill.segments) {
|
||||
// 유출부가 지면에 닿은 뒤의 절토선은 그리지 않는다(2026-08-24 사용자) — 벽이
|
||||
// 이미 원지반에 묻힌 구간이라 도면에 더 보탤 정보가 없다. 면적·수량 계산은
|
||||
// 건드리지 않는다(그림만 — 면적은 전면 개편 대상).
|
||||
// 세월교 · 박스 흙막이는 수평공간 뒤 절토선을 그림(79-7 · 면적에도 듦).
|
||||
if (segment.kind === "cut" && !culvert.body) continue;
|
||||
// 묻힌 유출 끝 = 3도 수평 절토선(보라 · 면적에도 듦 · 139-4) — 3도 선이 원지반을
|
||||
// 못 만나면 복합경사 절토선(89-4).
|
||||
if (segment.kind === "cut") {
|
||||
planLine(segment.points, `유출부 절토선 — 수평공간 뒤 절토 경사로 원지반까지`);
|
||||
planLine(segment.points, cutLineTip("유출부", segment));
|
||||
continue;
|
||||
}
|
||||
planLine(
|
||||
@@ -144,11 +148,9 @@ export function appendCulvertOverlay(
|
||||
for (const segment of layout.basinFill.segments) {
|
||||
planLine(
|
||||
segment.points,
|
||||
segment.kind === "cut" && culvert.body
|
||||
? `${basinFillName} 절토선 — 수평공간 뒤 절토 경사로 원지반까지`
|
||||
: segment.kind === "cut"
|
||||
? `${basinFillName} 3도 절토선 — 구조물이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`
|
||||
: `${basinFillName} 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
|
||||
segment.kind === "cut"
|
||||
? cutLineTip(basinFillName, segment)
|
||||
: `${basinFillName} 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
|
||||
(segment.overLimit
|
||||
? " (법정 5m 이상 — 흙막이 의무 구간, 조정창에서 추가)"
|
||||
: " (법정 5m 이내)") +
|
||||
|
||||
@@ -24,23 +24,22 @@ export const REVET_LEAN_RATIO = 0.3;
|
||||
export const REVET_TRAP_TOP_M = REVET_THICKNESS_M / 2;
|
||||
|
||||
/**
|
||||
* 기슭막이 여유고(m) — 관 상단에서 벽 상단까지의 간격. 유입·유출 **공통**이다.
|
||||
* 집수정 상부 여유(m) — 관 상단에서 집수정 벽 상단까지의 간격. 관 벽(유입·유출)은
|
||||
* 여유 없이 벽 윗면 = 관 윗면(139-1 · `revetTargetHeight`).
|
||||
*
|
||||
* ⚠ 확정값 아님(2026-08-21 사용자 지정). 지식DB에 임도 배수관 기슭막이 여유고
|
||||
* 규정이 없다(`통수단면.md` §여유고 — "임도·KDS 원문에 수치 없음"). 관 상단 최소
|
||||
* 토피 0.5m와 같은 값을 잠정 채택했고, 추후 **사용자 입력값**으로 교체 예정이다.
|
||||
*
|
||||
* 이 값 때문에 벽 높이는 좌·우가 같은 규칙으로 정해진다 — 관이 경사져 벽 밑(invert)
|
||||
* 이 서로 달라도 관 상단 위 간격은 항상 같다.
|
||||
*/
|
||||
export const REVET_FREEBOARD_M = 0.5;
|
||||
|
||||
/** 기슭막이 높이 눈금(m) — 계산 높이를 이 단위로 올린다(2026-08-21 사용자 확정). */
|
||||
export const REVET_HEIGHT_STEP_M = 0.1;
|
||||
|
||||
/** 관경에 여유고를 더해 눈금(0.1m)으로 올린 기슭막이 높이(m). */
|
||||
/** 관 벽 계산용 높이(m · invert~상단) — 관경을 눈금(0.1m)으로 올림 → 벽 윗면 = 관 윗면(139-1).
|
||||
* ⚠ 파이썬 짝 `pipe_wall_min_height_m` · 세트 짝 `pipeWallMinHeightM`(거울 시험). */
|
||||
export function revetTargetHeight(diameterM: number): number {
|
||||
const raw = diameterM + REVET_FREEBOARD_M;
|
||||
const raw = diameterM;
|
||||
return Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M;
|
||||
}
|
||||
|
||||
@@ -139,7 +138,7 @@ export function revetWallSpec(
|
||||
// 관경 기준 높이는 기본값만 — 하한은 그림이 서는 값뿐(54-51 · 사용자가 고름).
|
||||
const preferred = hiddenPipe
|
||||
? (spec.revet_height_m ?? autoHeightM ?? REVET_EMBED_DEPTH_M)
|
||||
: pipeWallMinPureHeight(diameterM);
|
||||
: (spec.revet_height_m ?? pipeWallMinPureHeight(diameterM)); // 칸 값 → 그림(139-1)
|
||||
const pureHeight = Math.max(adjust.h ?? preferred, WALL_MIN_PURE_HEIGHT_M);
|
||||
return {
|
||||
form,
|
||||
@@ -154,10 +153,9 @@ export function revetWallSpec(
|
||||
export const PIPE_CONNECT_GRADE = Math.tan((3 * Math.PI) / 180);
|
||||
|
||||
/**
|
||||
* 배관 기슭막이 관 하부 여유(m) — 관 invert 아래 벽 바닥까지의 깊이(2026-08-23
|
||||
* 사용자 확정: 배관 기준 아래 0.5·위 0.5 여유). 값은 근입 깊이와 같은 0.5라
|
||||
* 형상은 종전과 동일하고, **순수 높이 하한 = 관경 + 위 0.5 + 아래 0.5**가 된다.
|
||||
* 종전 고정 하한·기본 2.0m(관경 무관)는 폐기.
|
||||
* 배관 기슭막이 관 하부 여유(m) — 관 invert 아래 벽 바닥까지의 깊이 = 근입 0.5.
|
||||
* **순수 높이 기본 = 관경 + 0.5**(땅속 근입 0.5 포함 벽 전체 높이 · 벽 윗면 = 관 윗면 ·
|
||||
* 10-6 표 Ø1000 = 1.5 · 139-1). 칸을 비우면 이 값.
|
||||
*/
|
||||
export function pipeWallMinPureHeight(diameterM: number): number {
|
||||
return revetTargetHeight(diameterM) + REVET_EMBED_DEPTH_M;
|
||||
|
||||
@@ -50,8 +50,8 @@ export interface OutletExtrasInput {
|
||||
equalize?: boolean;
|
||||
/** 단 벽의 전면 기울기 — 형태·순수 높이로 표준경사를 고름(없으면 종전 1:0.3). */
|
||||
leanFor?: (form: string, pureHeightM: number) => number;
|
||||
/** 끝이 원지반에 묻힐 때 수평공간 뒤 절토(본선과 같은 복합경사 · 91-4)로 지반까지(세월교 · 박스
|
||||
* 흙막이 · 79-7). 없으면 종전 3도 선. */
|
||||
/** 끝이 원지반에 묻혀 3도 선이 원지반을 못 만날 때만 — 수평공간 뒤 절토(본선과 같은 복합경사 ·
|
||||
* 91-4)로 지반까지(139-4). 없으면 3도 선만(못 만나면 선 없음). */
|
||||
cutBerm?: { lengthM: number; cut: StructureCutSpec };
|
||||
}
|
||||
|
||||
@@ -379,14 +379,13 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
|
||||
}
|
||||
|
||||
// 끝 구간(2026-08-22 사용자 ③): 시작점이 지반 위면 성토부선, 지반 아래(매몰)면
|
||||
// **0도 절토선**을 원지반 교차까지 연장한다(교차가 없는 자리는 placeable이 막는다).
|
||||
// **3도 내림 절토선**을 원지반 교차까지(139-4 · 139-5 — 끝 연결선 한 규칙). 3도 선이
|
||||
// 원지반을 못 만나면(바깥이 오르막) 그때만 수평공간 뒤 복합경사 절토(`cutBerm` · 89-4).
|
||||
let addable = false;
|
||||
if (groundAt(src.offset) < src.elevation - 0.01) {
|
||||
const tail = trailingFill(src, outward, groundAt);
|
||||
segments.push(tail);
|
||||
addable = tail.overLimit;
|
||||
} else if (input.cutBerm) {
|
||||
segments.push(bermCut(src, outward, input.cutBerm, groundAt, limitOffset));
|
||||
} else {
|
||||
const cross = slopedCrossing(src, outward, -PIPE_CONNECT_GRADE, groundAt, limitOffset);
|
||||
if (cross !== null) {
|
||||
@@ -400,6 +399,8 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
|
||||
overLimit: false,
|
||||
kind: "cut",
|
||||
});
|
||||
} else if (input.cutBerm) {
|
||||
segments.push(bermCut(src, outward, input.cutBerm, groundAt, limitOffset));
|
||||
}
|
||||
}
|
||||
return { walls, segments, appliedAdjusts, addable };
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Culvert_Fit.ts
|
||||
* 관 길이 **m 단위 맞춤** — 배수관 기하(`_Cross_Culvert_Geom.ts`)에서 700줄 제한으로 분리.
|
||||
*
|
||||
* · 보통(2026-08-21 사용자 ①) — 긴 변 기준 올림, 모자란 만큼 유출 벽 자리를 관 축 방향 바깥으로
|
||||
* 민다(벽 두께 0.45 · 높이 고정 · 벽 상단은 1:1.2 성토선을 탐).
|
||||
* · 노견을 넓힌 쪽(139-6 설계선 쉬프트) — 사면 우선: 벽을 관 축을 따라 옮기되 벽 상단이 그 쪽
|
||||
* 성토선(넓힌 노견에서 같은 물매)에 닿도록 **벽 높이를 0.1 m 눈금으로 역산**. 눈금 탓에 남는
|
||||
* 차는 표기 올림.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import {
|
||||
FILL_SLOPE_MAX_LENGTH_M,
|
||||
FILL_SLOPE_RATIO_MAX,
|
||||
FILL_SLOPE_RATIO_MIN,
|
||||
REVET_EMBED_DEPTH_M,
|
||||
REVET_HEIGHT_STEP_M,
|
||||
WALL_MIN_PURE_HEIGHT_M,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
|
||||
export interface OutletFitInput {
|
||||
/** 유출 쪽 노견을 넓혔나(139-6) — 높이 역산 갈래. */
|
||||
shifted: boolean;
|
||||
edge: { offset_m: number; elevation_m: number };
|
||||
outward: number;
|
||||
axis: () => OffsetPoint;
|
||||
thickness: number;
|
||||
anchor: OffsetPoint;
|
||||
/** 계산용 높이(순수 − 근입). */
|
||||
height: number;
|
||||
/** 보통 갈래의 invert 선(높이 고정 · 성토선 평행). */
|
||||
lineInvertAt: (offset: number) => number;
|
||||
slopeAt: (
|
||||
offset: number,
|
||||
thickness: number,
|
||||
height: number,
|
||||
) => { ratio: number; lengthM: number };
|
||||
cutLength: () => number;
|
||||
/** 벽을 새 자리 · 높이로 다시 세우고 관 축 길이 `target` 으로 겨눔 — 벽이 안 서면 false. */
|
||||
rebuild: (anchor: OffsetPoint, height: number, target: number) => boolean;
|
||||
}
|
||||
|
||||
/** 맞춤 뒤 벽 자리 · 높이. */
|
||||
export function fitOutletPipeLength(c: OutletFitInput): { anchor: OffsetPoint; height: number } {
|
||||
const target = Math.ceil(c.cutLength() - 1e-6);
|
||||
let anchor = c.anchor;
|
||||
let height = c.height;
|
||||
const ratio0 = c.slopeAt(anchor.offset, c.thickness, height).ratio;
|
||||
for (let pass = 0; pass < 8; pass += 1) {
|
||||
const gap = target - c.cutLength();
|
||||
if (Math.abs(gap) < 0.005) break;
|
||||
const axis = c.axis();
|
||||
const moved = anchor.offset + axis.offset * gap;
|
||||
// 노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 도면 성립이 먼저.
|
||||
if ((moved - c.edge.offset_m) * c.outward < 0) break;
|
||||
if (c.shifted) {
|
||||
// 사면 우선 — invert 는 관 축을 따라 · 벽 상단 = 같은 물매 성토선 → 높이 역산(0.1 m 눈금)
|
||||
if (!Number.isFinite(ratio0)) break;
|
||||
const invert = anchor.elevation + axis.elevation * gap;
|
||||
const run = Math.abs(moved + c.outward * (c.thickness / 2) - c.edge.offset_m);
|
||||
const pure = c.edge.elevation_m - run / ratio0 - invert + REVET_EMBED_DEPTH_M;
|
||||
const stepped = Math.round(pure / REVET_HEIGHT_STEP_M) * REVET_HEIGHT_STEP_M;
|
||||
if (stepped < WALL_MIN_PURE_HEIGHT_M - 1e-9) break;
|
||||
const next = stepped - REVET_EMBED_DEPTH_M;
|
||||
if (Math.abs(next - height) < 1e-9 && Math.abs(moved - anchor.offset) < 0.01) break;
|
||||
anchor = { offset: moved, elevation: invert };
|
||||
height = next;
|
||||
if (!c.rebuild(anchor, height, target)) break;
|
||||
continue;
|
||||
}
|
||||
const movedSlope = c.slopeAt(moved, c.thickness, height);
|
||||
// 사면 역전(벽 상단이 노견 위)으로는 옮기지 않는다 · 성토고 0(성토선 0점)까지는 허용(2026-08-23).
|
||||
if (c.edge.elevation_m - (c.lineInvertAt(moved) + height) < 0) break;
|
||||
// 자동 자리만 옮긴다(수동 벽은 이 루프에 아예 안 들어온다). 폭은 0.45 고정 —
|
||||
// 폭으로 흡수하면 미는 동안 벽 단면이 변한다(2026-08-21 사용자 지적).
|
||||
if (
|
||||
movedSlope.ratio >= FILL_SLOPE_RATIO_MIN - 1e-6 &&
|
||||
movedSlope.ratio <= FILL_SLOPE_RATIO_MAX &&
|
||||
movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6
|
||||
) {
|
||||
anchor = { offset: moved, elevation: c.lineInvertAt(moved) };
|
||||
if (!c.rebuild(anchor, height, target)) break;
|
||||
continue;
|
||||
}
|
||||
// 물매·사면 5m를 벗어나면 멈춘다. 폭으로 흡수하지 않는다(좌·우 단면이 달라진다).
|
||||
break;
|
||||
}
|
||||
return { anchor, height };
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
revetWallSpec,
|
||||
pipeWallDefaultD,
|
||||
FILL_SLOPE_MAX_LENGTH_M,
|
||||
FILL_SLOPE_RATIO_MAX,
|
||||
FILL_SLOPE_RATIO_MIN,
|
||||
MIN_PIPE_COVER_M,
|
||||
REVET_EMBED_DEPTH_M,
|
||||
@@ -58,6 +57,7 @@ import {
|
||||
} from "./B06_Section_UI_Cross_Culvert_Basin";
|
||||
import { inletGroundConnector } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
import { withBackCut } from "./B06_Section_UI_Cross_Structure_Cut";
|
||||
import { fitOutletPipeLength } from "./B06_Section_UI_Cross_Culvert_Fit";
|
||||
import {
|
||||
buildCulvertTiers,
|
||||
drawnFillPoints,
|
||||
@@ -492,76 +492,61 @@ export function computeCulvertLayout(
|
||||
outlet: endCorners("outlet", outlet),
|
||||
};
|
||||
|
||||
// ── 관 길이 m 단위 맞춤(2026-08-21 사용자 ①) — 긴 변 기준 올림, 모자란 만큼 유출
|
||||
// 벽 자리를 관 축 방향 바깥으로 민다(벽 두께 0.45 고정).
|
||||
// 정수 맞춤이 옮길 후보 자리도 같은 성토선을 탄다(2026-08-23 — 0점 기준 통일).
|
||||
const outletSlopeAt = outletSlopeFactory(outletInfo, outletLineInvertAt, outletWallSpec.height);
|
||||
/** 유출 벽 두께 — 항상 0.45 고정(2026-08-21 사용자 확정). 폭으로 길이를 맞추지 않는다. */
|
||||
const outletThickness = REVET_THICKNESS_M;
|
||||
// ── 관 길이 m 단위 맞춤 — `_Culvert_Fit`(보통 = 유출 벽을 관 축 바깥으로 · 노견을 넓힌
|
||||
// 쪽 = 사면 우선 + 벽 높이 역산 · 139-6). 정수 맞춤 후보 자리도 같은 성토선을 탄다(2026-08-23).
|
||||
const outletThickness = REVET_THICKNESS_M; // 늘 0.45 고정(2026-08-21) — 폭으로 길이를 맞추지 않음
|
||||
// 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22) — 표기만 올림한다.
|
||||
// d = null(기본 자리)만 자동으로 본다(2026-08-23 — d는 0점 기준 절대값).
|
||||
const outletPinned = !!outletBody || Math.abs(adjOutlet.x) > 1e-9 || adjOutlet.d != null;
|
||||
if (outletWall && !outletPinned) {
|
||||
const target = Math.ceil(cutLength(pipeCorners) - 1e-6);
|
||||
const rebuild = (): void => {
|
||||
walls.pop();
|
||||
outletWall = buildWall(
|
||||
culvert.outlet,
|
||||
outletWallAnchor,
|
||||
outletInfo.outward,
|
||||
null,
|
||||
outletThickness,
|
||||
{
|
||||
height: outletWallSpec.height,
|
||||
baseElevation: outletWallAnchor.elevation,
|
||||
floatGapM: Math.max(0, outletWallAnchor.elevation - invertAt(outletWallAnchor.offset)),
|
||||
},
|
||||
outletWallSpec.material,
|
||||
outletWallSpec.form,
|
||||
outletWallSpec.lean,
|
||||
);
|
||||
// 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①).
|
||||
const face = outletWall ? outletPipeEnd(outletWall) : outletWallAnchor;
|
||||
const runW = face.offset - pipeStart.offset;
|
||||
const riseW = face.elevation - pipeStart.elevation;
|
||||
const lenW = Math.hypot(runW, riseW) || 1;
|
||||
outlet.offset = pipeStart.offset + (runW / lenW) * target;
|
||||
outlet.elevation = pipeStart.elevation + (riseW / lenW) * target;
|
||||
deriveAxis();
|
||||
pipeCorners = {
|
||||
inlet: endCorners("inlet", pipeStart),
|
||||
outlet: endCorners("outlet", outlet),
|
||||
};
|
||||
};
|
||||
for (let pass = 0; pass < 8; pass += 1) {
|
||||
const gap = target - cutLength(pipeCorners);
|
||||
if (Math.abs(gap) < 0.005) break;
|
||||
// ① 벽 자리를 관 축 방향으로 gap 만큼 옮긴다(두께 불변 — 사용자 확정 우선순위).
|
||||
const moved = outletWallAnchor.offset + pipeAxis.axis.offset * gap;
|
||||
const movedSlope = outletSlopeAt(moved, outletThickness);
|
||||
// 사면 역전(벽 상단이 노견 위)·노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다
|
||||
// 도면 성립이 먼저다. 성토고 0(성토선 0점)까지는 허용(2026-08-23 사용자).
|
||||
if (
|
||||
(moved - outletInfo.edge.offset_m) * outletInfo.outward < 0 ||
|
||||
outletInfo.edge.elevation_m - (outletLineInvertAt(moved) + outletWallSpec.height) < 0
|
||||
) {
|
||||
break;
|
||||
}
|
||||
// 자동 자리만 옮긴다(수동 벽은 이 루프에 아예 안 들어온다). 폭은 0.45 고정 —
|
||||
// 폭으로 흡수하면 미는 동안 벽 단면이 변한다(2026-08-21 사용자 지적).
|
||||
if (
|
||||
movedSlope.ratio >= FILL_SLOPE_RATIO_MIN - 1e-6 &&
|
||||
movedSlope.ratio <= FILL_SLOPE_RATIO_MAX &&
|
||||
movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6
|
||||
) {
|
||||
outletWallAnchor = { offset: moved, elevation: outletLineInvertAt(moved) };
|
||||
rebuild();
|
||||
if (!outletWall) break;
|
||||
continue;
|
||||
}
|
||||
// ② 물매·사면 5m를 벗어나면 멈춘다. 폭으로 흡수하지 않는다(좌·우 단면이 달라진다).
|
||||
break;
|
||||
}
|
||||
const extend =
|
||||
section.design?.[`shoulder_extend_${outletInfo.outward > 0 ? "left" : "right"}_m`];
|
||||
fitOutletPipeLength({
|
||||
shifted: (extend ?? 0) > 1e-9,
|
||||
edge: outletInfo.edge,
|
||||
outward: outletInfo.outward,
|
||||
axis: () => pipeAxis.axis,
|
||||
thickness: outletThickness,
|
||||
anchor: outletWallAnchor,
|
||||
height: outletWallSpec.height,
|
||||
lineInvertAt: outletLineInvertAt,
|
||||
slopeAt: (offset, thickness, height) =>
|
||||
outletSlopeFactory(outletInfo, outletLineInvertAt, height)(offset, thickness),
|
||||
cutLength: () => cutLength(pipeCorners),
|
||||
rebuild: (anchor, height, target) => {
|
||||
outletWallAnchor = anchor;
|
||||
walls.pop();
|
||||
outletWall = buildWall(
|
||||
culvert.outlet,
|
||||
anchor,
|
||||
outletInfo.outward,
|
||||
null,
|
||||
outletThickness,
|
||||
{
|
||||
height,
|
||||
baseElevation: anchor.elevation,
|
||||
floatGapM: Math.max(0, anchor.elevation - invertAt(anchor.offset)),
|
||||
},
|
||||
outletWallSpec.material,
|
||||
outletWallSpec.form,
|
||||
outletWallSpec.lean,
|
||||
);
|
||||
if (!outletWall) return false;
|
||||
// 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①).
|
||||
const face = outletPipeEnd(outletWall);
|
||||
const runW = face.offset - pipeStart.offset;
|
||||
const riseW = face.elevation - pipeStart.elevation;
|
||||
const lenW = Math.hypot(runW, riseW) || 1;
|
||||
outlet.offset = pipeStart.offset + (runW / lenW) * target;
|
||||
outlet.elevation = pipeStart.elevation + (riseW / lenW) * target;
|
||||
deriveAxis();
|
||||
pipeCorners = {
|
||||
inlet: endCorners("inlet", pipeStart),
|
||||
outlet: endCorners("outlet", outlet),
|
||||
};
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
if (outletWall) {
|
||||
// 표기 길이 = **실제 그려진 관**의 올림값(목표를 적으면 도면보다 길게 적힌다).
|
||||
@@ -642,7 +627,7 @@ export function computeCulvertLayout(
|
||||
trimMinSlope = { points: [...(trimMinSlope?.points ?? []), ...points] };
|
||||
}
|
||||
};
|
||||
extendTrimSlope(drawnFillPoints(extrasForArea, !!body), outletInfo.outward);
|
||||
extendTrimSlope(drawnFillPoints(extrasForArea, true), outletInfo.outward); // 묻힌 유출 3도 절토(139-4)
|
||||
extendTrimSlope(drawnFillPoints(basinExtrasForArea, !!body), inletInfo.outward);
|
||||
|
||||
const ownTrim: CulvertDesignTrim | null =
|
||||
|
||||
@@ -42,8 +42,8 @@ export const outletPipeEnd = (wall: WallLayout): OffsetPoint => {
|
||||
|
||||
// 다단 기슭막이의 성토부선까지 트림에 넣는다 — 넣지 않으면 단을 올려도 폐회로가 그대로라
|
||||
// **물량이 안 바뀐다**(2026-09-07 사용자 확정). 화면이 그리는 선(`outletFill.segments`)과
|
||||
// 같은 선을 면적도 보게 맞춘다 — 그리지 않는 `cut` 갈래(벽이 원지반에 묻힌 자리)는 뺀다.
|
||||
// 세월교 · 박스 흙막이(`withCut`)는 수평공간 뒤 절토선을 그리므로 넣음(79-7).
|
||||
// 같은 선을 면적도 보게 맞춘다 — `withCut` 이면 묻힌 끝 절토선(`cut` 갈래)도 넣음(유출 = 늘 ·
|
||||
// 139-4 · 계류측 = 세월교 · 박스만 79-7). 집수정 계류측 3도 선은 그림만.
|
||||
export const drawnFillPoints = (result: OutletExtrasResult, withCut = false): OffsetPoint[] =>
|
||||
result.segments
|
||||
.filter((segment) => withCut || segment.kind !== "cut")
|
||||
@@ -85,15 +85,16 @@ export function buildCulvertTiers(input: {
|
||||
section_mode: section.design?.section_mode,
|
||||
side: culvert?.hidden_pipe ? culvert.inlet.face_side : null,
|
||||
});
|
||||
// 세월교 · 박스 흙막이 끝 절토 = 컨테이너 수평공간 뒤 절토 경사(79-7) · 배관은 종전(3도 선).
|
||||
const cutBerm = (lengthM: number) =>
|
||||
culvert?.body
|
||||
? { lengthM: Math.max(lengthM, 0), cut: structureCutSpec(section.design) }
|
||||
: undefined;
|
||||
// 묻힌 끝 = 3도 절토선 먼저 · 원지반을 못 만나면 수평공간 뒤 절토 경사(139-4 · 139-5 — 배관 ·
|
||||
// 세월교 · 박스 한 규칙). 수평공간 = 세월교 · 박스 유입 흙막이 값(79-7)만 · 나머지는 0.
|
||||
const cutBerm = (lengthM: number) => ({
|
||||
lengthM: Math.max(lengthM, 0),
|
||||
cut: structureCutSpec(section.design),
|
||||
});
|
||||
// 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로).
|
||||
// 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다.
|
||||
const outletInput = {
|
||||
cutBerm: cutBerm(0), // 유출 수평공간 없음 — 끝이 원지반에 묻히면 바로 절토(89-4)
|
||||
cutBerm: cutBerm(0), // 유출 수평공간 없음 — 3도 선이 못 만날 때만 바로 절토(89-4)
|
||||
startBottomElevation: input.outletBottom,
|
||||
outward: input.outletInfo.outward,
|
||||
groundAt,
|
||||
@@ -107,7 +108,7 @@ export function buildCulvertTiers(input: {
|
||||
// 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 —
|
||||
// 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자).
|
||||
const basinInput = {
|
||||
cutBerm: cutBerm(culvert?.inlet.revet_berm_m ?? GUARD_CUT_BERM_M),
|
||||
cutBerm: cutBerm(culvert?.body ? (culvert.inlet.revet_berm_m ?? GUARD_CUT_BERM_M) : 0),
|
||||
startBottomElevation: input.basinBottom,
|
||||
outward: input.inletInfo.outward,
|
||||
groundAt,
|
||||
|
||||
@@ -409,16 +409,7 @@ export function computeCardCulvert(
|
||||
// 연동이면 소유 측점이 **실제로 적용한 4축**(자동 자리 포함)을 명시값으로 물려준다.
|
||||
// 조작값만 넘기면 손대기 전(전부 자동)에는 이 측점이 자기 지형으로 자동 자리를 다시
|
||||
// 잡아 소유 측점과 어긋난다 — "한 번 움직이면 맞는다"던 증상의 원인(2026-08-24 사용자).
|
||||
const ownerInput = adjustsInput(link.source, revetOffset, extraWalls);
|
||||
// 세월교 · 박스 흙막이는 본체 기준 x 를 절대값으로 바꿔 넘김 — 옆 측점엔 본체가 없음(73-4).
|
||||
const ownerLayout = computeCulvertLayout(
|
||||
link.source,
|
||||
link.source.samples,
|
||||
ownerInput,
|
||||
link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source),
|
||||
inletStructure?.adjustFor(link.source),
|
||||
);
|
||||
const ownerApplied = ownerLayout ? bodyAbsolute(ownerLayout) : ownerInput;
|
||||
const ownerApplied = ownerAppliedAdjusts(link.source, revetOffset, inletStructure, extraWalls);
|
||||
// 연동을 풀어도 **같은 기슭막이**다 — 단 수는 소유 측점 것이고, 이 측점이 손댄
|
||||
// 벽만 자기 값으로 선다(2026-08-30 사용자).
|
||||
const merged = detached ? detachedAdjusts(section, ownerApplied, revetOffset) : ownerApplied;
|
||||
@@ -599,6 +590,25 @@ function detachedAdjusts(
|
||||
};
|
||||
}
|
||||
|
||||
/** 소유 측점이 실제로 적용한 4축 — 연동 카드가 물려받는 값 · 연동을 푸는 순간 굳히는 값(139-2). */
|
||||
export function ownerAppliedAdjusts(
|
||||
owner: CrossSection,
|
||||
revetOffset?: RevetOffsetControl,
|
||||
inletStructure?: InletStructureControl,
|
||||
extraWalls?: ExtraWallControl,
|
||||
): WallAdjustSet {
|
||||
const ownerInput = adjustsInput(owner, revetOffset, extraWalls);
|
||||
// 세월교 · 박스 흙막이는 본체 기준 x 를 절대값으로 바꿔 넘김 — 옆 측점엔 본체가 없음(73-4).
|
||||
const ownerLayout = computeCulvertLayout(
|
||||
owner,
|
||||
owner.samples,
|
||||
ownerInput,
|
||||
owner.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(owner),
|
||||
inletStructure?.adjustFor(owner),
|
||||
);
|
||||
return ownerLayout ? bodyAbsolute(ownerLayout) : ownerInput;
|
||||
}
|
||||
|
||||
function adjustsInput(
|
||||
section: CrossSection,
|
||||
revetOffset?: RevetOffsetControl,
|
||||
|
||||
@@ -114,6 +114,8 @@ export interface CrossDesignChange {
|
||||
/** 측구를 둘지 **사용자가 정한 선택**. `null` = 자동 판정.
|
||||
* ⚠ 결과(`design.ditch_enabled` = 실제 섰나)와 **다른 값**이다(2026-09-09 정리). */
|
||||
ditch_choice: boolean | null;
|
||||
/** 설계선 쉬프트(139-6) — 있으면 그 쪽 노견 확장만 바꾸는 변경(다른 칸은 안 씀). */
|
||||
shoulder_extend?: { side: "left" | "right"; valueM: number };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Design_Pick.ts
|
||||
* 횡단 카드 **보라 요소 고름**(설계선 · 구조물 트림 선 · 137-2).
|
||||
*
|
||||
* 그려진 보라 선(`b06-chart__design-cross`)을 화면 좌표에서 m 로 되돌려 같은 방향 토막을
|
||||
* 직선 조각 하나로 합치고, 벽 · 본체 폴리곤에 가린 부분을 뺀 **보이는 조각**마다 투명 판정
|
||||
* 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 [+ − ⤢] 왼쪽에 치수를 적는다.
|
||||
* 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다.
|
||||
* 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다.
|
||||
* 고른 조각의 쪽 노견을 [◀ ▶] 0.1 m 로 넓히고 좁힘(139-6 쉬프트 · 노폭 안쪽 금지 = 0 에서 멈춤).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign } from "./B06_Section_Api_Fetch";
|
||||
import { clipSegment, type XY } from "../A00_Common/b_polygon_clip";
|
||||
import { sideRole } from "@util/common_util_cross_design_geometry";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const DESIGN_CLASS = "b06-chart__design-cross";
|
||||
const HIT_CLASS = "b06-chart__design-hit";
|
||||
/** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */
|
||||
export const DESIGN_LINE_ATTR = "data-design-line";
|
||||
/** 보라 선을 덮는 벽 · 본체 폴리곤. */
|
||||
const OCCLUDER_CLASSES = [
|
||||
"b06-chart__culvert-revet",
|
||||
"b06-chart__culvert-basin",
|
||||
"b06-chart__ford-slab",
|
||||
"b06-chart__box-barrel",
|
||||
];
|
||||
/** 같은 직선으로 보는 꺾임 허용(m) — 설계선 표고는 0.1 mm 반올림이라 넉넉하다. */
|
||||
const STRAIGHT_TOL_M = 0.005;
|
||||
/** 가리고 남은 조각이 이보다 짧으면 버린다(m) — 누를 수도 없다. */
|
||||
const MIN_PIECE_M = 0.02;
|
||||
|
||||
export interface CrossPoint {
|
||||
offset: number;
|
||||
elevation: number;
|
||||
}
|
||||
/** 그려진 보라 선 한 줄(m 좌표). `structure` = 구조물 트림 선(설계선 오버레이 밖). */
|
||||
export interface DesignRun {
|
||||
points: CrossPoint[];
|
||||
structure: boolean;
|
||||
}
|
||||
/** 직선 조각 — 두 끝(m) · 구조물 트림 선인가. */
|
||||
export interface DesignPiece {
|
||||
from: CrossPoint;
|
||||
to: CrossPoint;
|
||||
structure: boolean;
|
||||
}
|
||||
|
||||
export type CrossZone = "road" | "cut" | "fill";
|
||||
export type CrossSide = "left" | "right";
|
||||
type ZoneDesign = Pick<
|
||||
CrossDesign,
|
||||
"road_edges" | "ditch" | "ditch_side" | "ditch_enabled" | "section_mode"
|
||||
>;
|
||||
|
||||
/**
|
||||
* offset 자리의 구역 — 도로 = 두 노견 끝 사이 · 절토 = 측구 + 절토 사면 · 성토 = 성토 사면 +
|
||||
* 구조물 트림 선. 사면 역할은 단면유형(`sideRole`)으로 · 쪽 = +offset 좌.
|
||||
*/
|
||||
export function crossZoneAt(
|
||||
design: ZoneDesign,
|
||||
offsetM: number,
|
||||
structure = false,
|
||||
): { zone: CrossZone; side: CrossSide } {
|
||||
const side: CrossSide = offsetM >= 0 ? "left" : "right";
|
||||
const edges = design.road_edges;
|
||||
const hi = edges ? Math.max(edges.left.offset_m, edges.right.offset_m) : 0;
|
||||
const lo = edges ? Math.min(edges.left.offset_m, edges.right.offset_m) : 0;
|
||||
if (structure) return { zone: "fill", side };
|
||||
if (offsetM >= lo - 1e-6 && offsetM <= hi + 1e-6) return { zone: "road", side };
|
||||
const ditch = design.ditch;
|
||||
const ditchWidth =
|
||||
design.ditch_enabled === false || !ditch || ditch.type === "none"
|
||||
? 0
|
||||
: ditch.type === "standard"
|
||||
? ditch.top_width_m
|
||||
: ditch.width_m;
|
||||
if (design.ditch_side === "left" && offsetM > hi && offsetM <= hi + ditchWidth + 1e-6)
|
||||
return { zone: "cut", side };
|
||||
if (design.ditch_side === "right" && offsetM < lo && offsetM >= lo - ditchWidth - 1e-6)
|
||||
return { zone: "cut", side };
|
||||
let roles: [string, string] = ["fill", "fill"];
|
||||
try {
|
||||
roles = sideRole(design.section_mode);
|
||||
} catch {
|
||||
// 모르는 단면유형 = 성토로 봄
|
||||
}
|
||||
return { zone: (side === "left" ? roles[0] : roles[1]) === "cut" ? "cut" : "fill", side };
|
||||
}
|
||||
|
||||
/** 조각 구역 = 가운데 자리의 구역. */
|
||||
export const pieceZone = (design: ZoneDesign, piece: DesignPiece) =>
|
||||
crossZoneAt(design, (piece.from.offset + piece.to.offset) / 2, piece.structure);
|
||||
|
||||
const gap = (a: CrossPoint, b: CrossPoint): number =>
|
||||
Math.hypot(a.offset - b.offset, a.elevation - b.elevation);
|
||||
|
||||
/** 점열이 한 직선 위에서 한 방향으로만 가는가(되접힘 없음). */
|
||||
function isStraight(points: CrossPoint[]): boolean {
|
||||
const a = points[0];
|
||||
const b = points[points.length - 1];
|
||||
const length = gap(a, b);
|
||||
if (length < 1e-9) return false;
|
||||
const ux = (b.offset - a.offset) / length;
|
||||
const uy = (b.elevation - a.elevation) / length;
|
||||
let along = -STRAIGHT_TOL_M;
|
||||
for (const p of points) {
|
||||
const dx = p.offset - a.offset;
|
||||
const dy = p.elevation - a.elevation;
|
||||
if (Math.abs(ux * dy - uy * dx) > STRAIGHT_TOL_M) return false;
|
||||
const t = ux * dx + uy * dy;
|
||||
if (t < along - STRAIGHT_TOL_M) return false;
|
||||
along = Math.max(along, t);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 그려진 선들 → 같은 방향 토막을 합친 직선 조각(설계선 · 구조물 선은 따로 합침). */
|
||||
export function mergeStraightPieces(runs: DesignRun[]): DesignPiece[] {
|
||||
const chains: Array<{ points: CrossPoint[]; structure: boolean }> = [];
|
||||
for (const run of runs) {
|
||||
for (let i = 1; i < run.points.length; i += 1) {
|
||||
const start = run.points[i - 1];
|
||||
const end = run.points[i];
|
||||
if (gap(start, end) < 1e-6) continue;
|
||||
const last = chains[chains.length - 1];
|
||||
if (
|
||||
last &&
|
||||
last.structure === run.structure &&
|
||||
gap(last.points[last.points.length - 1], start) < 1e-3 &&
|
||||
isStraight([...last.points, end])
|
||||
)
|
||||
last.points.push(end);
|
||||
else chains.push({ points: [start, end], structure: run.structure });
|
||||
}
|
||||
}
|
||||
// 차례가 떨어진 토막도 끝이 닿고 한 직선이면 합친다(방향이 거꾸로 그려진 선 포함).
|
||||
const joined = (p: CrossPoint[], q: CrossPoint[]): CrossPoint[] | null => {
|
||||
for (const a of [p, [...p].reverse()])
|
||||
for (const b of [q, [...q].reverse()])
|
||||
if (gap(a[a.length - 1], b[0]) < 1e-3 && isStraight([...a, ...b.slice(1)]))
|
||||
return [...a, ...b.slice(1)];
|
||||
return null;
|
||||
};
|
||||
let merged = true;
|
||||
while (merged) {
|
||||
merged = false;
|
||||
for (let i = 0; i < chains.length && !merged; i += 1)
|
||||
for (let j = i + 1; j < chains.length && !merged; j += 1) {
|
||||
if (chains[i].structure !== chains[j].structure) continue;
|
||||
const points = joined(chains[i].points, chains[j].points);
|
||||
if (!points) continue;
|
||||
chains[i] = { points, structure: chains[i].structure };
|
||||
chains.splice(j, 1);
|
||||
merged = true;
|
||||
}
|
||||
}
|
||||
return chains.map((chain) => ({
|
||||
from: chain.points[0],
|
||||
to: chain.points[chain.points.length - 1],
|
||||
structure: chain.structure,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 벽 · 본체 폴리곤(m)에 가린 부분을 뺀 **보이는 조각**만. */
|
||||
export function visiblePieces(pieces: DesignPiece[], occluders: CrossPoint[][]): DesignPiece[] {
|
||||
const polygons = occluders
|
||||
.filter((polygon) => polygon.length >= 3)
|
||||
.map((polygon) => polygon.map((p) => [p.offset, p.elevation] as XY));
|
||||
const out: DesignPiece[] = [];
|
||||
for (const piece of pieces) {
|
||||
let runs: XY[][] = [
|
||||
[
|
||||
[piece.from.offset, piece.from.elevation],
|
||||
[piece.to.offset, piece.to.elevation],
|
||||
],
|
||||
];
|
||||
for (const polygon of polygons)
|
||||
runs = runs.flatMap(([a, b]) => clipSegment(a, b, polygon, "outside"));
|
||||
for (const [a, b] of runs) {
|
||||
const from = { offset: a[0], elevation: a[1] };
|
||||
const to = { offset: b[0], elevation: b[1] };
|
||||
if (gap(from, to) >= MIN_PIECE_M) out.push({ from, to, structure: piece.structure });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 치수 — 사면길이 · 수평 · 높이(m) · 구배(사면 1:n · 노면 %). */
|
||||
export function pieceMeasure(
|
||||
piece: DesignPiece,
|
||||
zone: CrossZone,
|
||||
): { lengthM: number; horizontalM: number; heightM: number; grade: string } {
|
||||
const horizontalM = Math.abs(piece.to.offset - piece.from.offset);
|
||||
const heightM = Math.abs(piece.to.elevation - piece.from.elevation);
|
||||
const grade =
|
||||
zone === "road"
|
||||
? horizontalM < 1e-6
|
||||
? "—"
|
||||
: `${((heightM / horizontalM) * 100).toFixed(1)}%`
|
||||
: heightM < 1e-4
|
||||
? "0.0%"
|
||||
: `1:${(horizontalM / heightM).toFixed(2)}`;
|
||||
return { lengthM: Math.hypot(horizontalM, heightM), horizontalM, heightM, grade };
|
||||
}
|
||||
|
||||
export function pieceLabel(design: ZoneDesign, piece: DesignPiece): string {
|
||||
const { zone } = pieceZone(design, piece);
|
||||
const m = pieceMeasure(piece, zone);
|
||||
return (
|
||||
`${zone === "road" ? "길이" : "사면길이"} ${m.lengthM.toFixed(2)}m · ` +
|
||||
`수평 ${m.horizontalM.toFixed(2)}m · 높이 ${m.heightM.toFixed(2)}m · ${m.grade}`
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 페이지 한 칸 고름 — 측점 + 조각 번호(카드를 다시 그려도 되살림 · 137-3) ───────── */
|
||||
let picked: { stationId: string; index: number } | null = null;
|
||||
export const pickedLineAt = (stationId: string): number | null =>
|
||||
picked?.stationId === stationId ? picked.index : null;
|
||||
export function setPickedLine(stationId: string, index: number | null): void {
|
||||
if (index !== null) picked = { stationId, index };
|
||||
else if (picked?.stationId === stationId) picked = null;
|
||||
}
|
||||
|
||||
/* ── 카드 배선 ─────────────────────────────────────────────────────────── */
|
||||
const pointsOf = (element: Element): XY[] => {
|
||||
if (element.tagName.toLowerCase() === "line") {
|
||||
const n = (name: string) => Number(element.getAttribute(name));
|
||||
return [
|
||||
[n("x1"), n("y1")],
|
||||
[n("x2"), n("y2")],
|
||||
];
|
||||
}
|
||||
return (element.getAttribute("points") ?? "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((pair) => pair.split(",").map(Number) as XY)
|
||||
.filter((p) => p.length === 2 && p.every(Number.isFinite));
|
||||
};
|
||||
|
||||
/** 설계선 쉬프트(139-6) — 쪽마다 노견 확장(m · ≥ 0). */
|
||||
export interface ShoulderShift {
|
||||
valueOf: (side: CrossSide) => number;
|
||||
set: (side: CrossSide, valueM: number) => void;
|
||||
}
|
||||
/** 쉬프트 걸음(m). */
|
||||
export const SHIFT_STEP_M = 0.1;
|
||||
|
||||
/** 화면 ◀(−1) · ▶(+1) 한 걸음 → 그 쪽 노견 확장 새 값(0.1 m 눈금 · 0 아래 금지).
|
||||
* `kx` = 화면 x / offset 기울기(+offset = 좌 · 화면 왼쪽이면 kx < 0). */
|
||||
export function shiftedExtend(now: number, side: CrossSide, screenDir: -1 | 1, kx: number): number {
|
||||
const offsetDelta = screenDir * Math.sign(kx) * SHIFT_STEP_M;
|
||||
const outward = side === "left" ? offsetDelta : -offsetDelta;
|
||||
return Math.max(0, Math.round((now + outward) * 10) / 10);
|
||||
}
|
||||
|
||||
export interface DesignLinePick {
|
||||
pieces: DesignPiece[];
|
||||
/** 치수 칸 — 카드가 [+ − ⤢] 앞에 붙임 · 고름 없으면 숨김. */
|
||||
label: HTMLElement;
|
||||
setActive: (index: number | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그려진 보라 선에 고름을 단다 — 벽 · 관 등을 다 그린 **뒤**(가림 · 판정면 차례) 부른다.
|
||||
* `x` · `toDisplayY` = 카드 좌표 함수(1차식 — 역산해 m 로 되돌림).
|
||||
*/
|
||||
export function attachDesignLinePick(input: {
|
||||
layer: SVGElement;
|
||||
design: ZoneDesign;
|
||||
x: (offset: number) => number;
|
||||
toDisplayY: (elevation: number) => number;
|
||||
onPick: (index: number) => void;
|
||||
/** 있으면 고른 조각 쪽 노견을 [◀ ▶] 로 옮김(139-6). */
|
||||
shift?: ShoulderShift;
|
||||
}): DesignLinePick {
|
||||
const { layer, design, x, toDisplayY } = input;
|
||||
const x0 = x(0);
|
||||
const kx = x(1) - x0;
|
||||
const y0 = toDisplayY(0);
|
||||
const ky = toDisplayY(1) - y0;
|
||||
const toM = ([px, py]: XY): CrossPoint => ({ offset: (px - x0) / kx, elevation: (py - y0) / ky });
|
||||
const kids = [...layer.children];
|
||||
const runs: DesignRun[] = kids
|
||||
.filter((el) => el.classList.contains(DESIGN_CLASS))
|
||||
.map((el) => ({
|
||||
points: pointsOf(el).map(toM),
|
||||
structure: !el.hasAttribute(DESIGN_LINE_ATTR),
|
||||
}));
|
||||
const occluders = kids
|
||||
.filter(
|
||||
(el) =>
|
||||
el.tagName.toLowerCase() === "polygon" &&
|
||||
OCCLUDER_CLASSES.some((name) => el.classList.contains(name)),
|
||||
)
|
||||
.map((el) => pointsOf(el).map(toM));
|
||||
const pieces = visiblePieces(mergeStraightPieces(runs), occluders);
|
||||
|
||||
// 판정 선은 구조물 판정면 **아래** — 겹친 자리는 구조물이 이김.
|
||||
const firstStructureHit = kids.find((el) =>
|
||||
el.classList.contains("b06-chart__culvert-revet-hit"),
|
||||
);
|
||||
const hits = pieces.map((piece, index) => {
|
||||
const hit = document.createElementNS(SVG_NS, "polyline");
|
||||
hit.setAttribute(
|
||||
"points",
|
||||
[piece.from, piece.to].map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "),
|
||||
);
|
||||
hit.setAttribute("class", HIT_CLASS);
|
||||
hit.addEventListener("click", (event) => {
|
||||
event.stopPropagation(); // 카드 고름 · 면적 띠로 번지지 않음
|
||||
input.onPick(index);
|
||||
});
|
||||
layer.insertBefore(hit, firstStructureHit ?? null);
|
||||
return hit;
|
||||
});
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "b06-cross-card__line-dims";
|
||||
label.hidden = true;
|
||||
const text = document.createElement("span");
|
||||
label.append(text);
|
||||
let side: CrossSide | null = null;
|
||||
const { shift } = input;
|
||||
if (shift) {
|
||||
const button = (glyph: string, dir: -1 | 1, tip: string): HTMLButtonElement => {
|
||||
const el = document.createElement("button");
|
||||
el.type = "button";
|
||||
el.className = "b06-cross-card__zoom-btn b06-cross-card__line-shift";
|
||||
el.textContent = glyph;
|
||||
el.title = tip;
|
||||
el.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
if (side) shift.set(side, shiftedExtend(shift.valueOf(side), side, dir, kx));
|
||||
});
|
||||
return el;
|
||||
};
|
||||
label.append(
|
||||
button("◀", -1, "고른 선 쪽 노견을 화면 왼쪽으로 0.1 m(노폭 안쪽은 못 감)"),
|
||||
button("▶", 1, "고른 선 쪽 노견을 화면 오른쪽으로 0.1 m(노폭 안쪽은 못 감)"),
|
||||
);
|
||||
}
|
||||
return {
|
||||
pieces,
|
||||
label,
|
||||
setActive: (index) => {
|
||||
const piece = index === null ? undefined : pieces[index];
|
||||
hits.forEach((hit, i) => hit.classList.toggle("is-active", !!piece && i === index));
|
||||
label.hidden = !piece;
|
||||
side = piece ? pieceZone(design, piece).side : null;
|
||||
const extend = side && shift ? shift.valueOf(side) : 0;
|
||||
text.textContent = piece
|
||||
? pieceLabel(design, piece) + (extend > 0 ? ` · 노견 +${extend.toFixed(1)}m` : "")
|
||||
: "";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export {
|
||||
computeFordLayout,
|
||||
DEFAULT_FORD_ADJUST,
|
||||
DEFAULT_FORD_WALL_ADJUST,
|
||||
fordAdjustOf,
|
||||
fordDropOf,
|
||||
} from "./B06_Section_UI_Cross_Ford_Geom";
|
||||
export type {
|
||||
FordAdjust,
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
* 백엔드 `section.ford` 제원을 좌표로 옮긴다(치수 결정 금지).
|
||||
*
|
||||
* 구성(2026-08-25 사용자 확정):
|
||||
* · 양측 **측벽** = ㄴ형 집수정 부재를 재사용한다(`buildBasin`) — 좌우·상하(대각)
|
||||
* 이동·계류측 성토부선이 집수정과 같은 규칙으로 따라온다. 단 **벽은 수직**이다
|
||||
* · 양측 **측벽** = ㄴ형 집수정 부재를 재사용한다(`buildBasin`) — 좌우 이동·계류측
|
||||
* 성토부선이 집수정과 같은 규칙으로 따라온다. 단 **벽은 수직**이다
|
||||
* (`leanRatio: 0` — 2026-08-30 사용자 확정, 교본 그림 3-18). 집수정의 1:0.3
|
||||
* 기움은 세월교 근거가 아니라 부재를 빌려 오며 딸려 온 값이었다.
|
||||
* · **바닥판** = 두 측벽 사이를 잇는 판 + 각 측 바깥으로 **날개벽 투영**(길이×cos각)
|
||||
* 만큼 뻗은 판. 집수정 내공 폭 자리에 그 연장량을 넣어 같은 기하로 만든다.
|
||||
* · **관**은 바닥판 상면(측벽 바깥면)에 안히고, 관 위 ~ 노면 아래는 **성토(노체)** 다.
|
||||
* · **수평**(139-3 · 박스 「구체 수평」 준용) — 바닥판 표고 하나 = 낮은 쪽 노견 노면 −
|
||||
* (관경 + 토피) − 내림(양쪽 한 값 · 아래로만) · 측벽마다 좌우만(도로 쪽 금지) ·
|
||||
* 측벽 높이 = 그 쪽 노견 노면 − 바닥판(결과).
|
||||
* · 구체는 월류 폭만큼 도로 방향으로 이어져 기준 측점 전후로 절반씩 걸친다
|
||||
* (붙는 측점 판정은 백엔드 `attach_culvert_sets`).
|
||||
* 성토측이라도 기슭막이 세트(다단·재질)와는 연계하지 않는다.
|
||||
@@ -29,20 +32,32 @@ import { bermCut, buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
|
||||
import { structureCutSpec } from "./B06_Section_UI_Cross_Structure_Cut";
|
||||
|
||||
/** 측벽 한 매의 조작값 — 집수정 9키와 같은 축(높이·좌우·상하 대각). */
|
||||
/** 측벽 한 매의 조작값 — 좌우만(139-3). `heightM` · `slopeM` 은 옛 저장(측벽마다 높이 · 1:1.2
|
||||
* 대각) 읽기 전용 — `dropM` 이 없을 때 바닥판 표고로 옮김(`fordDropOf`). */
|
||||
export interface FordWallAdjust {
|
||||
/** 벽 높이(m, 노견 상단 ~ 바닥판 상면). null = 기본 = 관경 + 최소 토피(원지반 안 봄 · 107-2). */
|
||||
/** 옛 벽 높이(m) — 새 값은 결과라 늘 null. */
|
||||
heightM: number | null;
|
||||
/** 좌우(m, + = 노견 바깥). 안쪽으로는 못 간다. */
|
||||
lateralM: number;
|
||||
/** 상하(m, 수평 성분, + = 사면 아래) — 집수정과 같이 1:1.2 대각으로 내려간다. */
|
||||
/** 옛 상하(m, 수평 성분 · 1:1.2 대각) — 새 값은 늘 0. */
|
||||
slopeM: number;
|
||||
}
|
||||
|
||||
/** 측점별 세월교 조정값 — 유입·유출 측벽을 따로 잡는다. */
|
||||
/** 측점별 세월교 조정값 — 측벽마다 좌우 · 바닥판 내림 하나. */
|
||||
export interface FordAdjust {
|
||||
inlet: FordWallAdjust;
|
||||
outlet: FordWallAdjust;
|
||||
/** 바닥판 내림(m · 수직 · ≥ 0 · 양쪽 한 값 · 139-3). null/없음 = 옛 저장 → `fordDropOf`. */
|
||||
dropM?: number | null;
|
||||
}
|
||||
|
||||
/** 저장값 → 조정값(빈 칸 기본) — 화면 · 3D · 서버가 같은 꼴로 읽음. */
|
||||
export function fordAdjustOf(stored?: Partial<FordAdjust> | null): FordAdjust {
|
||||
return {
|
||||
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) },
|
||||
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) },
|
||||
dropM: stored?.dropM ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_FORD_WALL_ADJUST: FordWallAdjust = {
|
||||
@@ -54,6 +69,7 @@ export const DEFAULT_FORD_WALL_ADJUST: FordWallAdjust = {
|
||||
export const DEFAULT_FORD_ADJUST: FordAdjust = {
|
||||
inlet: DEFAULT_FORD_WALL_ADJUST,
|
||||
outlet: DEFAULT_FORD_WALL_ADJUST,
|
||||
dropM: 0,
|
||||
};
|
||||
|
||||
export type FordWallRole = "inlet" | "outlet";
|
||||
@@ -110,6 +126,27 @@ function minWallHeight(ford: FordSet): number {
|
||||
return ford.diameter_m + ford.min_cover_m;
|
||||
}
|
||||
|
||||
/**
|
||||
* 바닥판 내림(m · ≥ 0) — 새 값(`dropM`) 그대로 · 옛 저장(측벽마다 높이 · 1:1.2 대각)은 측벽마다
|
||||
* 바닥판 표고의 평균으로 옮김(박스 79-7 꼴) · 기본 자리 위는 0 에서 자름. 노견이 없으면 0.
|
||||
*/
|
||||
export function fordDropOf(section: CrossSection, adjust: FordAdjust): number {
|
||||
if (adjust.dropM != null) return Math.max(adjust.dropM, 0);
|
||||
const ford = section.ford;
|
||||
const edges = section.design?.road_edges;
|
||||
if (!ford || !edges) return 0;
|
||||
const roadTopAt = roadTopFactory(section, edges.left, edges.right);
|
||||
const minH = minWallHeight(ford);
|
||||
const inletOnLeft = (section.uphill_side ?? "left") === "left";
|
||||
const oldSlab = (role: FordWallRole): number => {
|
||||
const edge = (role === "inlet") === inletOnLeft ? edges.left : edges.right;
|
||||
const wall = adjust[role];
|
||||
return roadTopAt(edge.offset_m) - Math.max(wall.heightM ?? minH, minH) - wall.slopeM / 1.2;
|
||||
};
|
||||
const base = Math.min(roadTopAt(edges.left.offset_m), roadTopAt(edges.right.offset_m)) - minH;
|
||||
return Math.max(base - (oldSlab("inlet") + oldSlab("outlet")) / 2, 0);
|
||||
}
|
||||
|
||||
/** 성토 채움 윗면을 만들 노면 표고 — 설계선이 있으면 그 곡선, 없으면 노견 직선. */
|
||||
function roadTopFactory(
|
||||
section: CrossSection,
|
||||
@@ -150,6 +187,12 @@ export function computeFordLayout(
|
||||
const uphill = section.uphill_side ?? "left";
|
||||
const inletOnLeft = uphill === "left";
|
||||
const roadTopAt = roadTopFactory(section, edges.left, edges.right);
|
||||
// 바닥판 표고 하나(139-3) — 낮은 쪽 노견 노면 − (관경 + 토피) − 내림
|
||||
const dropM = fordDropOf(section, adjust);
|
||||
const slabTop =
|
||||
Math.min(roadTopAt(edges.left.offset_m), roadTopAt(edges.right.offset_m)) -
|
||||
minWallHeight(ford) -
|
||||
dropM;
|
||||
|
||||
const buildSide = (role: FordWallRole): FordSideLayout | null => {
|
||||
const onLeft = role === "inlet" ? inletOnLeft : !inletOnLeft;
|
||||
@@ -159,12 +202,9 @@ export function computeFordLayout(
|
||||
const wing = role === "inlet" ? ford.wing_in : ford.wing_out;
|
||||
const wanted = role === "inlet" ? adjust.inlet : adjust.outlet;
|
||||
const lateralM = Math.max(wanted.lateralM, 0);
|
||||
const heightM = Math.max(wanted.heightM ?? minWallHeight(ford), minWallHeight(ford));
|
||||
// 벽 상단이 노견에 붙도록 바닥판 상면을 역산한다. 상하 이동은 buildBasin이 얹는다.
|
||||
const anchor: OffsetPoint = {
|
||||
offset: edge.offset_m,
|
||||
elevation: roadTopAt(edge.offset_m) - heightM,
|
||||
};
|
||||
// 측벽 높이 = 그 쪽 노견 노면 − 바닥판(결과) — 벽 상단이 노견 표고 · 좌우는 노견 수평 확장
|
||||
const heightM = roadTopAt(edge.offset_m) - slabTop;
|
||||
const anchor: OffsetPoint = { offset: edge.offset_m, elevation: slabTop };
|
||||
const built = buildBasin({
|
||||
anchor,
|
||||
outward,
|
||||
@@ -180,7 +220,7 @@ export function computeFordLayout(
|
||||
innerWidthM: Math.max(wing.slab_extend_m, 0),
|
||||
innerHeightM: heightM,
|
||||
lateralM,
|
||||
slopeM: wanted.slopeM,
|
||||
slopeM: 0,
|
||||
},
|
||||
memberThicknessM: ford.wall_thickness_m,
|
||||
floorThicknessM: ford.slab_thickness_m,
|
||||
@@ -200,11 +240,11 @@ export function computeFordLayout(
|
||||
adjusts: [],
|
||||
equalize: false,
|
||||
});
|
||||
// 접근선 — 좌우 이동분은 노견 표고 그대로 수평, 상하 이동분만 대각(1:1.2).
|
||||
// 접근선 — 좌우 이동분은 노견 표고 그대로 수평(상하는 벽이 길어짐 · 139-3).
|
||||
const approach = basinApproachPoints(
|
||||
{ offset_m: edge.offset_m, elevation_m: roadTopAt(edge.offset_m) },
|
||||
outward,
|
||||
{ innerWidthM: 0, innerHeightM: 0, lateralM, slopeM: wanted.slopeM },
|
||||
{ innerWidthM: 0, innerHeightM: 0, lateralM, slopeM: 0 },
|
||||
{ offset: built.trimOffset, elevation: built.trimElevation },
|
||||
);
|
||||
return {
|
||||
@@ -241,11 +281,7 @@ export function computeFordLayout(
|
||||
limitOffset,
|
||||
).points
|
||||
: built.basin.cutLine,
|
||||
adjust: {
|
||||
heightM: wanted.heightM === null ? null : heightM,
|
||||
lateralM,
|
||||
slopeM: wanted.slopeM,
|
||||
},
|
||||
adjust: { heightM: null, lateralM, slopeM: 0 },
|
||||
};
|
||||
};
|
||||
|
||||
@@ -268,7 +304,7 @@ export function computeFordLayout(
|
||||
Math.min(...sides.map((side) => side.parts[1].points[1].offset)),
|
||||
);
|
||||
|
||||
// 관 — 각 측 바닥판 상면(벽 바깥면)에 안힌다. 양측 표고가 다르면 그만큼 기운다.
|
||||
// 관 — 각 측 바닥판 상면(벽 바깥면)에 안힌다 · 바닥판 표고 하나라 수평(139-3).
|
||||
const pipeWall = pipeWallThicknessM(ford.pipe_kind, ford.diameter_m);
|
||||
const leftInvert = left.pipeEnd;
|
||||
const rightInvert = right.pipeEnd;
|
||||
@@ -315,6 +351,6 @@ export function computeFordLayout(
|
||||
minSlope: right.approach ? { points: right.approach } : undefined,
|
||||
maxSlope: left.approach ? { points: left.approach } : undefined,
|
||||
},
|
||||
adjust: { inlet: inlet.adjust, outlet: outlet.adjust },
|
||||
adjust: { inlet: inlet.adjust, outlet: outlet.adjust, dropM },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* `_Cross_Panel_Base.ts`)을 쓴다 — 머리줄(제목·닫기), 2행 항목 구조,
|
||||
* **십자 조작 세트는 창 맨 아래**, 키보드 방향키 제어 포함(2026-08-25 일원화).
|
||||
*
|
||||
* 조작: 측벽 좌우(노견 확장) · 측벽 상하(1:1.2 대각) — 십자 · 그림 끌기. 측벽 높이 줄 없음(107-3) ·
|
||||
* 조작: 측벽 좌우(노견 확장 · 측벽마다) · 바닥판 상하(수직 · 양쪽 한 값 · 아래로만 · 139-3) — 십자 ·
|
||||
* 그림 끌기. 측벽 높이 = 결과(읽기 줄만) ·
|
||||
* 관경 · 수량 · 날개벽 제원은 폼 칸 하나(계획서 24-4a).
|
||||
* 바닥판 연장은 날개벽 길이×cos(각도)가 정하므로 읽기 전용으로 보여 준다.
|
||||
* ========================================================================== */
|
||||
@@ -32,11 +33,13 @@ export interface FordPanelDeps {
|
||||
heightFor: (role: FordWallRole) => number;
|
||||
/** 좌우: 화면 좌(+)/우(−)로 미는 양(m). 부호 환산은 카드가 한다. */
|
||||
nudge: (role: FordWallRole, screenDeltaM: number) => void;
|
||||
/** 상하: 1:1.2 사면을 타는 대각 이동(수평 성분 m, + = 사면 아래). */
|
||||
/** 상하: 바닥판 수직 내림(m, + = 아래 · 양쪽 한 값 · 139-3). */
|
||||
nudgeSlope: (role: FordWallRole, deltaM: number) => void;
|
||||
/** 지금 바닥판 내림(m · ≥ 0). */
|
||||
dropFor: () => number;
|
||||
/** 사면 바깥이 화면 왼쪽이면 1 · 오른쪽이면 −1(끌기 축 나누기 · 108-2). */
|
||||
outwardOf: (role: FordWallRole) => number;
|
||||
/** 그림 끌기 — 좌우(화면 좌 +) · 상하(사면) 를 한 번에 반영. */
|
||||
/** 그림 끌기 — 좌우(화면 좌 +) · 상하(수직 내림) 를 한 번에 반영. */
|
||||
move: (role: FordWallRole, screenDeltaM: number, slopeDeltaM: number) => void;
|
||||
/** 자동 자리로 되돌린다(세 축 모두). */
|
||||
reset: (role: FordWallRole) => void;
|
||||
@@ -88,7 +91,7 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
|
||||
dpadButton(
|
||||
"▲",
|
||||
"up",
|
||||
"사면 위로(1:1.2 대각)",
|
||||
"바닥판 위로(양쪽 같이 · 기본 자리까지)",
|
||||
act((role) => deps.nudgeSlope(role, -STEP_M)),
|
||||
),
|
||||
dpadButton(
|
||||
@@ -107,7 +110,7 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
|
||||
dpadButton(
|
||||
"▼",
|
||||
"down",
|
||||
"사면 아래로(1:1.2 대각)",
|
||||
"바닥판 아래로(양쪽 같이)",
|
||||
act((role) => deps.nudgeSlope(role, STEP_M)),
|
||||
),
|
||||
);
|
||||
@@ -119,8 +122,8 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
|
||||
const arrows = opts?.dock
|
||||
? { attach: (): void => undefined, detach: (): void => undefined }
|
||||
: bindArrowKeys(root, () => [moveRow.controls]);
|
||||
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 좌우 + 1:1.2 사면 두 축 함께(108-2).
|
||||
// 한계 = 좌우 0 ~ 10 m(노견 안쪽 금지) · 상하 ±10 m.
|
||||
// 그림 위 끌기 손잡이 — 십자와 같은 걸음 · 좌우 + 수직 두 축 함께(108-2 · 139-3).
|
||||
// 한계 = 좌우 0 ~ 10 m(노견 안쪽 금지) · 내림 0 ~ 10 m(기본 자리 위 금지).
|
||||
const drag = opts?.dock
|
||||
? { attach: (): void => undefined, detach: (): void => undefined }
|
||||
: bindDragHandle(root, {
|
||||
@@ -131,10 +134,10 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
|
||||
const outward = deps.outwardOf(role);
|
||||
return snapAxisDrag(leftM, downM, {
|
||||
stepM: STEP_M,
|
||||
slopeOutward: outward,
|
||||
slopeOutward: null,
|
||||
clamp: (left, down) => [
|
||||
clampAxis(from.lateralM, left * outward, 0, MOVE_LIMIT_M) * outward,
|
||||
clampAxis(from.slopeM, down, -MOVE_LIMIT_M, MOVE_LIMIT_M),
|
||||
clampAxis(deps.dropFor(), down, 0, MOVE_LIMIT_M),
|
||||
],
|
||||
apply: (left, down) => deps.move(role, left, down),
|
||||
});
|
||||
@@ -147,9 +150,8 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
|
||||
const role = current === "inlet" ? "유입" : "유출";
|
||||
title.textContent = `세월교 ${role} 측벽`;
|
||||
const adjust: FordWallAdjust = deps.adjustFor(current);
|
||||
heightLine.textContent =
|
||||
`높이 ${deps.heightFor(current).toFixed(2)}m` + (adjust.heightM === null ? " (자동)" : "");
|
||||
moveLine.textContent = `좌우 ${adjust.lateralM.toFixed(1)}m · 상하 ${adjust.slopeM.toFixed(1)}m`;
|
||||
heightLine.textContent = `높이 ${deps.heightFor(current).toFixed(2)}m`;
|
||||
moveLine.textContent = `좌우 ${adjust.lateralM.toFixed(1)}m · 내림 ${deps.dropFor().toFixed(1)}m`;
|
||||
slabLine.textContent = `바닥판 ${deps.slabLengthM().toFixed(2)}m`;
|
||||
}
|
||||
|
||||
@@ -177,7 +179,11 @@ export function buildFordPanel(deps: FordPanelDeps, opts?: { dock?: boolean }):
|
||||
/** 측점별 세월교 조작값 제어 — 페이지(세션·정본)가 구현한다. */
|
||||
export interface FordControl {
|
||||
adjustFor: (chainageM: number) => FordAdjust;
|
||||
update: (chainageM: number, role: FordWallRole, patch: Partial<FordWallAdjust>) => void;
|
||||
update: (
|
||||
chainageM: number,
|
||||
role: FordWallRole,
|
||||
patch: { lateralM?: number; dropM?: number },
|
||||
) => void;
|
||||
reset: (chainageM: number, role: FordWallRole) => void;
|
||||
/** 관경(mm)·수량(련)·관종·월류 폭 저장 — B05 정본(`pipe_points`)으로 간다.
|
||||
* 좌측 「구조물 배치」 폼만 보낸다(조정창에 칸 없음 · 계획서 24-4a). */
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
appendCrossDesignOverlay,
|
||||
appendPavementOverlay,
|
||||
appendRockBoundaryOverlay,
|
||||
type CrossDesignChange,
|
||||
type RockBoundaryControl,
|
||||
} from "./B06_Section_UI_Cross_Design";
|
||||
import { appendCardFooter, appendCardHeader } from "./B06_Section_UI_Cross_Card_Chrome";
|
||||
@@ -36,7 +37,12 @@ import type { CulvertDesignTrim } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { appendBoxOverlay, computeBoxLayout } from "./B06_Section_UI_Cross_Box";
|
||||
import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel";
|
||||
import { createBodyWiring } from "./B06_Section_UI_Cross_View_Bodies";
|
||||
import { appendCardFrame, applyStructureAreas } from "./B06_Section_UI_Cross_View_Frame";
|
||||
import {
|
||||
appendCardFrame,
|
||||
appendGroundProfile,
|
||||
applyStructureAreas,
|
||||
drawnContentBounds,
|
||||
} from "./B06_Section_UI_Cross_View_Frame";
|
||||
import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford";
|
||||
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
|
||||
import { computeCardCulvert } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
@@ -48,16 +54,21 @@ import type {
|
||||
RevetOffsetControl,
|
||||
StructureSpanControl,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel";
|
||||
import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock";
|
||||
import { culvertCardState, structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure";
|
||||
import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure";
|
||||
import { buildCardPanels, emptyCardCulvertState } from "./B06_Section_UI_Cross_View_Panels";
|
||||
import {
|
||||
attachDesignLinePick,
|
||||
DESIGN_LINE_ATTR,
|
||||
type DesignLinePick,
|
||||
pickedLineAt,
|
||||
setPickedLine,
|
||||
} from "./B06_Section_UI_Cross_Design_Pick";
|
||||
import { attachZoomPan, buildZoomControls, cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
import type { CrossWidthActions } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
|
||||
import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes";
|
||||
import type { RevetHighlightSetter, RevetKey, RevetPick } from "./B06_Section_UI_Cross_Culvert";
|
||||
import type { RevetHighlightSetter, RevetPick } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { REVET_FORM_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import {
|
||||
CROSS_PAD,
|
||||
CROSS_WIDTH,
|
||||
@@ -186,20 +197,14 @@ export function createCrossSectionCard(
|
||||
let highlightRevet: RevetPick | null = revetOffset?.highlightFor(section) ?? activeRevet;
|
||||
let setRevetActive: RevetHighlightSetter = () => undefined;
|
||||
let showRevetControl: (visible: boolean) => void = () => undefined;
|
||||
let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기
|
||||
// 이 카드의 기준벽이 **독립 기슭막이**인가(관 없는 숨김 세트). 조정창 걸음이
|
||||
// 배관(1m)과 다르다(0.1m — 2026-08-29 사용자).
|
||||
let culvertHiddenPipe = false;
|
||||
/** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */
|
||||
let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true };
|
||||
/** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd)·세워진 개수. */
|
||||
let culvertExtraState = { canAdd: false, count: 0 };
|
||||
/** 집수정 계류측 다단 상태(2026-08-22) — 조정창의 집수정 단 수 행이 쓴다. */
|
||||
let culvertBasinExtraState = { canAdd: false, count: 0 };
|
||||
/** 마지막 계산의 벽 제원(높이·재질) — 배관 벽은 순수 높이(2026-08-23 사용자). */
|
||||
let culvertWallSpecs = new Map<RevetKey, { height: number; form: string }>();
|
||||
/** 마지막 계산의 실제 적용 d(상하) — d 미지정(자동) 벽의 ▲▼ 시작값. */
|
||||
let culvertAppliedD = new Map<RevetKey, number>();
|
||||
/** 조정창이 읽는 마지막 계산 결과(벽 제원 · 단 수 등) — `_Cross_View_Panels`. */
|
||||
const culvertState = emptyCardCulvertState();
|
||||
/** 보라 선 고름(137-2) — 고름은 페이지 한 칸(`_Cross_Design_Pick`) · 강조는 그린 뒤 붙음. */
|
||||
let setLineActive: (index: number | null) => void = () => undefined;
|
||||
const dropLine = (): void => {
|
||||
setPickedLine(section.station_id, null);
|
||||
setLineActive(null);
|
||||
};
|
||||
/**
|
||||
* 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를
|
||||
* 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도
|
||||
@@ -213,6 +218,7 @@ export function createCrossSectionCard(
|
||||
box,
|
||||
isCardSelected: () => isSelected,
|
||||
clearArea: () => {
|
||||
dropLine(); // 고름 하나만(137-3)
|
||||
if (activeRevet !== null) toggleRevet(activeRevet); // 벽 · 본체 동시 고름 금지(107-1)
|
||||
if (activeArea === null) return;
|
||||
activeArea = null;
|
||||
@@ -236,6 +242,7 @@ export function createCrossSectionCard(
|
||||
setChipActive(null);
|
||||
}
|
||||
if (activeRevet !== null) {
|
||||
dropLine(); // 고름 하나만(137-3)
|
||||
bodies.clearSelection(); // 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1)
|
||||
// 미선택 카드는 **카드 선택만** 부른다. 면적 비우기(onAreaSelect)를 먼저 부르면 그쪽이
|
||||
// 카드를 선택해 버려 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로 먹힌다(2026-08-21
|
||||
@@ -260,7 +267,10 @@ export function createCrossSectionCard(
|
||||
if (owns) revetOffset?.select(section.chainage_m, null);
|
||||
else card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? null);
|
||||
}
|
||||
if (activeArea !== null || !nextSelected) bodies.clearSelection();
|
||||
if (activeArea !== null || !nextSelected) {
|
||||
bodies.clearSelection();
|
||||
dropLine();
|
||||
}
|
||||
};
|
||||
card.applyRevetHighlight = (key) => {
|
||||
highlightRevet = key;
|
||||
@@ -285,10 +295,34 @@ export function createCrossSectionCard(
|
||||
showRevetControl(false);
|
||||
revetOffset?.select(section.chainage_m, null);
|
||||
}
|
||||
if (activeArea !== null) {
|
||||
bodies.clearSelection(); // 고름 하나만 — 면적 강조도 본체 · 선과 같은 칸(137-3)
|
||||
dropLine();
|
||||
}
|
||||
setBandActive(activeArea);
|
||||
setChipActive(activeArea);
|
||||
onAreaSelect?.(section.station_id, activeArea);
|
||||
};
|
||||
/** 보라 선 고름 — 다른 고름(벽 · 본체 · 면적)을 풀고 카드도 고름(137-3). 다시 누르면 풂. */
|
||||
const pickLine = (index: number): void => {
|
||||
const next = pickedLineAt(section.station_id) === index ? null : index;
|
||||
setPickedLine(section.station_id, next);
|
||||
if (next !== null) {
|
||||
if (activeRevet !== null) toggleRevet(activeRevet);
|
||||
bodies.clearSelection();
|
||||
if (activeArea !== null) {
|
||||
activeArea = null;
|
||||
setBandActive(null);
|
||||
setChipActive(null);
|
||||
onAreaSelect?.(section.station_id, null);
|
||||
}
|
||||
if (!isSelected) onSelect(section.station_id);
|
||||
}
|
||||
setLineActive(next);
|
||||
};
|
||||
// 고른 카드 · 다른 고름이 없을 때만 되살림 — 바깥 길(목록 · 3D)이 구조물을 골랐으면 선을 풂.
|
||||
if (!selected || activeRevet !== null || activeArea !== null)
|
||||
setPickedLine(section.station_id, null);
|
||||
|
||||
appendCardHeader(card, section, stationInterval, onDesignChange);
|
||||
|
||||
@@ -367,25 +401,12 @@ export function createCrossSectionCard(
|
||||
// 상태의 가운데 버튼 팬으로 끌어다 본다(2026-08-23 사용자 지시). 축 눈금은 팬을 따라
|
||||
// 다시 그려지므로(_UI_Cross_Axes) 값이 어긋나지 않는다.
|
||||
const drawSamples = section.samples;
|
||||
const segments: string[] = [];
|
||||
let current: string[] = [];
|
||||
for (const sample of drawSamples) {
|
||||
if (!validElevation(sample)) {
|
||||
if (current.length > 1) segments.push(current.join(" "));
|
||||
current = [];
|
||||
continue;
|
||||
}
|
||||
const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration;
|
||||
current.push(`${x(sample.offset_m ?? 0)},${y(elevated)}`);
|
||||
}
|
||||
if (current.length > 1) segments.push(current.join(" "));
|
||||
segments.forEach((points) =>
|
||||
plotLayer.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })),
|
||||
);
|
||||
const toDisplayY = (elevation: number): number =>
|
||||
y(elevationMid + (elevation - elevationMid) * exaggeration);
|
||||
appendGroundProfile(plotLayer, drawSamples, x, toDisplayY);
|
||||
let linePick: DesignLinePick | null = null;
|
||||
|
||||
if (section.design) {
|
||||
const toDisplayY = (elevation: number): number =>
|
||||
y(elevationMid + (elevation - elevationMid) * exaggeration);
|
||||
// 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다.
|
||||
// 선택 여부와 무관하게 항상 깐다 — 평소에는 투명이고, 선택이 바뀔 때 카드를 새로 만들지
|
||||
// 않고 클래스만 켜면 되므로 줌·팬이 살아남는다.
|
||||
@@ -460,11 +481,11 @@ export function createCrossSectionCard(
|
||||
// 눌러도 안 움직이는데 숫자만 커지는 것 방지.
|
||||
revetOffset?.syncApplied(section.chainage_m, "own", ownLayout.appliedAdjust);
|
||||
// 재질은 구조물 옵션(형태)이 정본이라 조정창에서 숨긴다 — 자리만 채운다.
|
||||
culvertWallSpecs.set("own", {
|
||||
culvertState.wallSpecs.set("own", {
|
||||
height: ownLayout.heightM,
|
||||
form: section.revetment?.form ?? REVET_FORM_DEFAULT,
|
||||
});
|
||||
culvertAppliedD.set("own", ownLayout.appliedAdjust.d ?? 0);
|
||||
culvertState.appliedD.set("own", ownLayout.appliedAdjust.d ?? 0);
|
||||
const baseSetActive = setRevetActive;
|
||||
setRevetActive = (key) => {
|
||||
baseSetActive(key);
|
||||
@@ -488,6 +509,10 @@ export function createCrossSectionCard(
|
||||
x: (CROSS_PAD.left + widthPx - CROSS_PAD.right) / 2,
|
||||
y: heightPx - CROSS_PAD.bottom - 4,
|
||||
});
|
||||
// 설계선 오버레이가 그린 보라 선 표시 — 고름 구역에서 구조물 트림 선과 가름(137-2).
|
||||
[...plotLayer.children]
|
||||
.slice(designFrom)
|
||||
.forEach((el) => el.setAttribute(DESIGN_LINE_ATTR, ""));
|
||||
// 물넘이 자리 = 노면 · 측구 설계선 걷음(66-2 · 66-3 — 옛 계획고 점선이 드러남)
|
||||
if (fordPaved && section.ford_pavement)
|
||||
hideFordPavedRoad(
|
||||
@@ -512,16 +537,9 @@ export function createCrossSectionCard(
|
||||
);
|
||||
}
|
||||
// 배수관 세트 — 조정창이 쓸 카드 상태를 받아 둔다(2026-08-19) · 링크 카드도 채운다(2026-08-24).
|
||||
culvertInletIsBasin = !!culvertLayout?.basin;
|
||||
culvertHiddenPipe = culvertLayout?.culvert.hidden_pipe === true;
|
||||
if (culvertLayout) {
|
||||
const state = culvertCardState(culvertLayout);
|
||||
culvertInletOptions = state.inletOptions;
|
||||
culvertExtraState = state.extraState;
|
||||
culvertBasinExtraState = state.basinExtraState;
|
||||
culvertWallSpecs = state.wallSpecs;
|
||||
culvertAppliedD = state.appliedD;
|
||||
}
|
||||
culvertState.inletIsBasin = !!culvertLayout?.basin;
|
||||
culvertState.hiddenPipe = culvertLayout?.culvert.hidden_pipe === true;
|
||||
if (culvertLayout) Object.assign(culvertState, culvertCardState(culvertLayout));
|
||||
if (boxLayout) {
|
||||
bodies.attachBox(
|
||||
appendBoxOverlay(plotLayer, boxLayout, x, toDisplayY, box ? bodies.toggleBox : undefined),
|
||||
@@ -554,6 +572,28 @@ export function createCrossSectionCard(
|
||||
if (highlightRevet) setRevetActive(highlightRevet);
|
||||
}
|
||||
raiseWalls(plotLayer); // 흙막이 · 옹벽 안을 지나는 다른 선 가림(66-5)
|
||||
// 보라 선 고름 — 가림 · 판정면 차례가 정해진 뒤(137-2).
|
||||
const lineDesign = section.design;
|
||||
linePick = attachDesignLinePick({
|
||||
layer: plotLayer,
|
||||
design: lineDesign,
|
||||
x,
|
||||
toDisplayY,
|
||||
onPick: pickLine,
|
||||
// 고른 선 쪽 노견 확장(139-6) — 카드 버튼과 같은 창구(세션 · 브라우저 재계산)
|
||||
shift: onDesignChange
|
||||
? {
|
||||
valueOf: (side) => lineDesign[`shoulder_extend_${side}_m`] ?? 0,
|
||||
set: (side, valueM) =>
|
||||
onDesignChange(section.chainage_m, {
|
||||
...(lineDesign as unknown as CrossDesignChange),
|
||||
shoulder_extend: { side, valueM },
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
setLineActive = linePick.setActive;
|
||||
setLineActive(pickedLineAt(section.station_id));
|
||||
}
|
||||
|
||||
// 십자선(계획 노선 자리) · 축선 · 높이 축 이름 — `_Cross_View_Frame`(700줄 제한).
|
||||
@@ -561,7 +601,7 @@ export function createCrossSectionCard(
|
||||
valid,
|
||||
centerX: x(0),
|
||||
designElevation: hasDesign ? (designElevation as number) : null,
|
||||
toY: (elevation) => y(elevationMid + (elevation - elevationMid) * exaggeration),
|
||||
toY: toDisplayY,
|
||||
widthPx,
|
||||
heightPx,
|
||||
});
|
||||
@@ -570,19 +610,7 @@ export function createCrossSectionCard(
|
||||
chartWrap.className = "b06-cross-card__chart-wrap";
|
||||
chartWrap.style.setProperty("--b06-plot-left", `${CROSS_PAD.left}px`);
|
||||
// 팬 한계로 쓸 **그려진 범위** — 캐시 전 구간의 좌우 끝과 표고 상·하단.
|
||||
const drawnOffsets = drawSamples.map((sample) => sample.offset_m ?? 0);
|
||||
const drawnDisplayY = drawSamples
|
||||
.filter(validElevation)
|
||||
.map((sample) => y(elevationMid + (sample.elevation_m - elevationMid) * exaggeration));
|
||||
const contentBounds =
|
||||
drawnOffsets.length && drawnDisplayY.length
|
||||
? {
|
||||
x: x(Math.max(...drawnOffsets)),
|
||||
width: x(Math.min(...drawnOffsets)) - x(Math.max(...drawnOffsets)),
|
||||
y: Math.min(...drawnDisplayY),
|
||||
height: Math.max(...drawnDisplayY) - Math.min(...drawnDisplayY),
|
||||
}
|
||||
: undefined;
|
||||
const contentBounds = drawnContentBounds(drawSamples, x, toDisplayY);
|
||||
const zoomPan = attachZoomPan(
|
||||
svg,
|
||||
plotLayer,
|
||||
@@ -600,79 +628,24 @@ export function createCrossSectionCard(
|
||||
// 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다.
|
||||
const readout = buildAreaReadout(section.design, toggleArea);
|
||||
setChipActive = readout.setActive;
|
||||
// 구조물 조정은 도면 안 오버레이 창(2026-08-21). 화면 좌(◀) = offset 증가 —
|
||||
// 벽 기준 이동량(outward 부호)으로 환산해 넘긴다.
|
||||
// 유입측 벽 — 기준벽과 유입측 다단(bextra). 빼먹으면 그 벽만 좌우가 뒤집힌다.
|
||||
const inletSideRole = (role: RevetKey): boolean =>
|
||||
role === "inlet" || role.startsWith("bextra");
|
||||
const outwardOf = (role: RevetKey): number =>
|
||||
// 독립 기슭막이는 배관 유입/유출과 무관 — 정본 설치 측(좌 = +offset)으로 부호를 잡는다.
|
||||
role === "own"
|
||||
? section.revetment?.side === "우"
|
||||
? -1
|
||||
: 1
|
||||
: ((section.uphill_side ?? "left") === "left") === inletSideRole(role)
|
||||
? 1
|
||||
: -1;
|
||||
const heightOfWall = (key: RevetKey): number => culvertWallSpecs.get(key)?.height ?? 0;
|
||||
const formOfWall = (key: RevetKey): string =>
|
||||
culvertWallSpecs.get(key)?.form ?? REVET_FORM_DEFAULT;
|
||||
// 연동 중인 링크 카드의 조작은 **소유 측점**에 실린다(2026-08-24) — 풀면 자기 값.
|
||||
const adjustTarget = (): CrossSection =>
|
||||
isLinkedCulvert && culvertLink && (revetLink?.linkedFor(section) ?? true)
|
||||
? culvertLink.source
|
||||
: section;
|
||||
// 연동을 푼 측점에서 **아직 손대지 않은** 벽은 소유 측점 값을 보여 준다 — 창이
|
||||
// 0(자동)을 보여 주면 첫 ◀ 한 번에 벽이 원위치로 튄다(2026-08-30).
|
||||
const adjustOf = (key: RevetKey): WallAdjust => {
|
||||
const stored = revetOffset?.storedAdjustFor(adjustTarget(), key);
|
||||
if (stored) return stored;
|
||||
const source = isLinkedCulvert && culvertLink ? culvertLink.source : section;
|
||||
return revetOffset?.adjustFor(source, key) ?? { x: 0, d: null, h: null, m: null };
|
||||
};
|
||||
const panelContext = {
|
||||
// 배수관 · 흙막이 조정창 두 벌(카드 안 · 좌측 사본) — `_Cross_View_Panels`(137-1).
|
||||
const panel = buildCardPanels({
|
||||
section,
|
||||
scrollKey: section.station_id,
|
||||
adjustChainage: () => adjustTarget().chainage_m,
|
||||
isLinked: isLinkedCulvert,
|
||||
culvertLink,
|
||||
isLinkedCulvert,
|
||||
revetOffset,
|
||||
inletStructure,
|
||||
extraWalls,
|
||||
structureSpan,
|
||||
revetLink,
|
||||
adjustOf,
|
||||
outwardOf,
|
||||
heightOfWall,
|
||||
drawnWallKeys: () => [...culvertWallSpecs.keys()],
|
||||
formOfWall,
|
||||
appliedD: () => culvertAppliedD,
|
||||
inletIsBasin: () => culvertInletIsBasin,
|
||||
hiddenPipe: () => culvertHiddenPipe,
|
||||
bodyGuard: () => !!(section.culvert ?? culvertLink?.source.culvert)?.body,
|
||||
fordGuard: () => (section.culvert ?? culvertLink?.source.culvert)?.body === "ford",
|
||||
inletOptions: () => culvertInletOptions,
|
||||
extraState: () => culvertExtraState,
|
||||
basinExtraState: () => culvertBasinExtraState,
|
||||
state: culvertState,
|
||||
activeRevet: () => activeRevet,
|
||||
setActiveRevet: (key: RevetPick | null) => {
|
||||
setActiveRevet: (key) => {
|
||||
activeRevet = key;
|
||||
},
|
||||
toggleRevet,
|
||||
};
|
||||
const panel = buildStructurePanel(structurePanelDeps(panelContext));
|
||||
// 좌측 [횡단 조정] 사본 — 같은 deps라 어느 쪽 조작이든 카드 재렌더에서 두 창이
|
||||
// 함께 새 값으로 그려진다(2026-08-29 일원화). 스크롤 기억 키만 분리한다.
|
||||
const dockPanel = buildStructurePanel(
|
||||
structurePanelDeps({ ...panelContext, scrollKey: `dock:${section.station_id}` }),
|
||||
{ dock: true },
|
||||
);
|
||||
showRevetControl = (visible) => {
|
||||
const key = visible ? activeRevet : null;
|
||||
panel.show(key);
|
||||
dockPanel.show(key);
|
||||
if (key) adoptAdjustPanel(dockPanel.root);
|
||||
else releaseAdjustPanel(dockPanel.root);
|
||||
};
|
||||
});
|
||||
showRevetControl = (visible) => panel.show(visible ? activeRevet : null);
|
||||
showRevetControl(activeRevet !== null);
|
||||
// 세월교 조정창 — 배수관 창과 조작 축이 달라 따로 만든다(2026-08-25 사용자).
|
||||
// 우측 상단 줌 버튼이 원배율에서 표시 반폭까지 다룬다(하단 ◀/▶/↺ 폐지, 2026-08-23).
|
||||
@@ -686,7 +659,9 @@ export function createCrossSectionCard(
|
||||
// 팬으로 끌어다 본다(PLAN 79-2 · 교차점 폭으로 넓히던 것 폐지).
|
||||
fit: () => stationWidth.request(section.chainage_m, crossHalfWidth ?? maxOffset),
|
||||
};
|
||||
chartWrap.append(svg, readout.root, buildZoomControls(zoomPan, widthActions), panel.root);
|
||||
const zoomBar = buildZoomControls(zoomPan, widthActions);
|
||||
if (linePick) zoomBar.prepend(linePick.label); // 치수 = [+ − ⤢] 왼쪽(137-2)
|
||||
chartWrap.append(svg, readout.root, zoomBar, panel.root);
|
||||
for (const panel of bodies.panels) chartWrap.append(panel.root);
|
||||
if (activeArea) {
|
||||
setBandActive(activeArea);
|
||||
|
||||
@@ -35,16 +35,16 @@ export function fordPanelDeps(context: FordPanelContext): FordPanelDeps {
|
||||
ford.update(chainage, role, {
|
||||
lateralM: ford.adjustFor(chainage)[role].lateralM + screenDeltaM * outwardOf(section, role),
|
||||
}),
|
||||
// 상하 = 바닥판 내림(양쪽 한 값 · 아래로만 · 139-3)
|
||||
nudgeSlope: (role, deltaM) =>
|
||||
ford.update(chainage, role, {
|
||||
slopeM: ford.adjustFor(chainage)[role].slopeM + deltaM,
|
||||
}),
|
||||
ford.update(chainage, role, { dropM: (ford.adjustFor(chainage).dropM ?? 0) + deltaM }),
|
||||
dropFor: () => ford.adjustFor(chainage).dropM ?? 0,
|
||||
outwardOf: (role) => outwardOf(section, role),
|
||||
move: (role, screenDeltaM, slopeDeltaM) => {
|
||||
const from = ford.adjustFor(chainage)[role];
|
||||
move: (role, screenDeltaM, dropDeltaM) => {
|
||||
const from = ford.adjustFor(chainage);
|
||||
ford.update(chainage, role, {
|
||||
lateralM: from.lateralM + screenDeltaM * outwardOf(section, role),
|
||||
slopeM: from.slopeM + slopeDeltaM,
|
||||
lateralM: from[role].lateralM + screenDeltaM * outwardOf(section, role),
|
||||
dropM: (from.dropM ?? 0) + dropDeltaM,
|
||||
});
|
||||
},
|
||||
reset: (role) => ford.reset(chainage, role),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import { computeStructureAreas, type DesignTrim } from "@util/common_util_cross_structure_areas";
|
||||
import { CROSS_PAD, L, svgElement, svgText } from "./B06_Section_UI_Section_Common";
|
||||
import { CROSS_PAD, L, svgElement, svgText, validElevation } from "./B06_Section_UI_Section_Common";
|
||||
|
||||
type Sample = CrossSection["samples"][number];
|
||||
|
||||
@@ -124,3 +124,43 @@ export function applyStructureAreas(
|
||||
design.cut_soil_area_m2 = Number(areas.cutSoilAreaM2.toFixed(4));
|
||||
}
|
||||
}
|
||||
|
||||
/** 원지반선 — 끊긴 샘플(무효 표고)마다 폴리선을 나눠 그린다. `toY` = 표고 → 화면 y(배율 반영). */
|
||||
export function appendGroundProfile(
|
||||
plotLayer: SVGElement,
|
||||
samples: Sample[],
|
||||
x: (offset: number) => number,
|
||||
toY: (elevation: number) => number,
|
||||
): void {
|
||||
const segments: string[] = [];
|
||||
let current: string[] = [];
|
||||
for (const sample of samples) {
|
||||
if (!validElevation(sample)) {
|
||||
if (current.length > 1) segments.push(current.join(" "));
|
||||
current = [];
|
||||
continue;
|
||||
}
|
||||
current.push(`${x(sample.offset_m ?? 0)},${toY(sample.elevation_m)}`);
|
||||
}
|
||||
if (current.length > 1) segments.push(current.join(" "));
|
||||
segments.forEach((points) =>
|
||||
plotLayer.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })),
|
||||
);
|
||||
}
|
||||
|
||||
/** 팬 한계로 쓸 **그려진 범위** — 캐시 전 구간의 좌우 끝과 표고 상 · 하단(없으면 undefined). */
|
||||
export function drawnContentBounds(
|
||||
samples: Sample[],
|
||||
x: (offset: number) => number,
|
||||
toY: (elevation: number) => number,
|
||||
): { x: number; width: number; y: number; height: number } | undefined {
|
||||
const offsets = samples.map((sample) => sample.offset_m ?? 0);
|
||||
const displayY = samples.filter(validElevation).map((sample) => toY(sample.elevation_m));
|
||||
if (!offsets.length || !displayY.length) return undefined;
|
||||
return {
|
||||
x: x(Math.max(...offsets)),
|
||||
width: x(Math.min(...offsets)) - x(Math.max(...offsets)),
|
||||
y: Math.min(...displayY),
|
||||
height: Math.max(...displayY) - Math.min(...displayY),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_View_Panels.ts
|
||||
* 횡단 카드 ↔ **배수관 · 흙막이 조정창 두 벌**(카드 안 · 좌측 [횡단 조정] 사본) 배선 —
|
||||
* 카드 렌더러(`_UI_Cross_View.ts`)가 700줄을 넘어 떼었다(137-1). 그리기 · 기하는 하지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { RevetKey, RevetPick } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { REVET_FORM_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type {
|
||||
CulvertLink,
|
||||
ExtraWallControl,
|
||||
InletStructureControl,
|
||||
RevetLinkControl,
|
||||
RevetOffsetControl,
|
||||
StructureSpanControl,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel";
|
||||
import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock";
|
||||
import { structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure";
|
||||
|
||||
/** 조정창이 읽는 카드 상태 — 마지막 계산 결과(그릴 때마다 바뀜). */
|
||||
export interface CardCulvertState {
|
||||
/** 유입이 집수정인가 — 조정창 표시 · 제목 분기. */
|
||||
inletIsBasin: boolean;
|
||||
/** 기준벽이 **독립 기슭막이**인가(관 없는 숨김 세트) — 걸음 0.1 m(2026-08-29 사용자). */
|
||||
hiddenPipe: boolean;
|
||||
/** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */
|
||||
inletOptions: { revetAllowed: boolean; basinLUAllowed: boolean };
|
||||
/** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd) · 세워진 개수. */
|
||||
extraState: { canAdd: boolean; count: number };
|
||||
/** 집수정 계류측 다단 상태(2026-08-22) — 조정창의 집수정 단 수 행이 쓴다. */
|
||||
basinExtraState: { canAdd: boolean; count: number };
|
||||
/** 마지막 계산의 벽 제원(높이 · 재질) — 배관 벽은 순수 높이(2026-08-23 사용자). */
|
||||
wallSpecs: Map<RevetKey, { height: number; form: string }>;
|
||||
/** 마지막 계산의 실제 적용 d(상하) — d 미지정(자동) 벽의 ▲▼ 시작값. */
|
||||
appliedD: Map<RevetKey, number>;
|
||||
}
|
||||
|
||||
export const emptyCardCulvertState = (): CardCulvertState => ({
|
||||
inletIsBasin: false,
|
||||
hiddenPipe: false,
|
||||
inletOptions: { revetAllowed: true, basinLUAllowed: true },
|
||||
extraState: { canAdd: false, count: 0 },
|
||||
basinExtraState: { canAdd: false, count: 0 },
|
||||
wallSpecs: new Map(),
|
||||
appliedD: new Map(),
|
||||
});
|
||||
|
||||
export interface CardPanelsInput {
|
||||
section: CrossSection;
|
||||
culvertLink?: CulvertLink;
|
||||
isLinkedCulvert: boolean;
|
||||
revetOffset?: RevetOffsetControl;
|
||||
inletStructure?: InletStructureControl;
|
||||
extraWalls?: ExtraWallControl;
|
||||
structureSpan?: StructureSpanControl;
|
||||
revetLink?: RevetLinkControl;
|
||||
state: CardCulvertState;
|
||||
activeRevet: () => RevetPick | null;
|
||||
setActiveRevet: (key: RevetPick | null) => void;
|
||||
toggleRevet: (key: RevetPick) => void;
|
||||
}
|
||||
|
||||
/** 카드 안 조정창 + 좌측 사본을 만든다. `show` = 고른 벽(없으면 null)으로 두 창을 맞춤. */
|
||||
export function buildCardPanels(input: CardPanelsInput): {
|
||||
root: HTMLElement;
|
||||
show: (key: RevetPick | null) => void;
|
||||
} {
|
||||
const { section, culvertLink, isLinkedCulvert, revetOffset, revetLink, state } = input;
|
||||
// 구조물 조정은 도면 안 오버레이 창(2026-08-21). 화면 좌(◀) = offset 증가 —
|
||||
// 벽 기준 이동량(outward 부호)으로 환산해 넘긴다.
|
||||
// 유입측 벽 — 기준벽과 유입측 다단(bextra). 빼먹으면 그 벽만 좌우가 뒤집힌다.
|
||||
const inletSideRole = (role: RevetKey): boolean => role === "inlet" || role.startsWith("bextra");
|
||||
const outwardOf = (role: RevetKey): number =>
|
||||
// 독립 기슭막이는 배관 유입/유출과 무관 — 정본 설치 측(좌 = +offset)으로 부호를 잡는다.
|
||||
role === "own"
|
||||
? section.revetment?.side === "우"
|
||||
? -1
|
||||
: 1
|
||||
: ((section.uphill_side ?? "left") === "left") === inletSideRole(role)
|
||||
? 1
|
||||
: -1;
|
||||
const heightOfWall = (key: RevetKey): number => state.wallSpecs.get(key)?.height ?? 0;
|
||||
const formOfWall = (key: RevetKey): string =>
|
||||
state.wallSpecs.get(key)?.form ?? REVET_FORM_DEFAULT;
|
||||
// 연동 중인 링크 카드의 조작은 **소유 측점**에 실린다(2026-08-24) — 풀면 자기 값.
|
||||
const adjustTarget = (): CrossSection =>
|
||||
isLinkedCulvert && culvertLink && (revetLink?.linkedFor(section) ?? true)
|
||||
? culvertLink.source
|
||||
: section;
|
||||
// 연동을 푼 측점에서 **아직 손대지 않은** 벽은 소유 측점 값을 보여 준다 — 창이
|
||||
// 0(자동)을 보여 주면 첫 ◀ 한 번에 벽이 원위치로 튄다(2026-08-30).
|
||||
const adjustOf = (key: RevetKey): WallAdjust => {
|
||||
const stored = revetOffset?.storedAdjustFor(adjustTarget(), key);
|
||||
if (stored) return stored;
|
||||
const source = isLinkedCulvert && culvertLink ? culvertLink.source : section;
|
||||
return revetOffset?.adjustFor(source, key) ?? { x: 0, d: null, h: null, m: null };
|
||||
};
|
||||
const panelContext = {
|
||||
section,
|
||||
scrollKey: section.station_id,
|
||||
adjustChainage: () => adjustTarget().chainage_m,
|
||||
isLinked: isLinkedCulvert,
|
||||
revetOffset,
|
||||
inletStructure: input.inletStructure,
|
||||
extraWalls: input.extraWalls,
|
||||
structureSpan: input.structureSpan,
|
||||
revetLink,
|
||||
adjustOf,
|
||||
outwardOf,
|
||||
heightOfWall,
|
||||
drawnWallKeys: () => [...state.wallSpecs.keys()],
|
||||
formOfWall,
|
||||
appliedD: () => state.appliedD,
|
||||
inletIsBasin: () => state.inletIsBasin,
|
||||
hiddenPipe: () => state.hiddenPipe,
|
||||
bodyGuard: () => !!(section.culvert ?? culvertLink?.source.culvert)?.body,
|
||||
fordGuard: () => (section.culvert ?? culvertLink?.source.culvert)?.body === "ford",
|
||||
inletOptions: () => state.inletOptions,
|
||||
extraState: () => state.extraState,
|
||||
basinExtraState: () => state.basinExtraState,
|
||||
activeRevet: input.activeRevet,
|
||||
setActiveRevet: input.setActiveRevet,
|
||||
toggleRevet: input.toggleRevet,
|
||||
};
|
||||
const panel = buildStructurePanel(structurePanelDeps(panelContext));
|
||||
// 좌측 [횡단 조정] 사본 — 같은 deps라 어느 쪽 조작이든 카드 재렌더에서 두 창이
|
||||
// 함께 새 값으로 그려진다(2026-08-29 일원화). 스크롤 기억 키만 분리한다.
|
||||
const dockPanel = buildStructurePanel(
|
||||
structurePanelDeps({ ...panelContext, scrollKey: `dock:${section.station_id}` }),
|
||||
{ dock: true },
|
||||
);
|
||||
return {
|
||||
root: panel.root,
|
||||
show: (key) => {
|
||||
panel.show(key);
|
||||
dockPanel.show(key);
|
||||
if (key) adoptAdjustPanel(dockPanel.root);
|
||||
else releaseAdjustPanel(dockPanel.root);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -150,8 +150,11 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
|
||||
canNudge: (key) => key !== "inlet" || !ctx.inletIsBasin(),
|
||||
// 걸음 분류(2026-08-29 사용자): **배관 기준벽만 1m**, 나머지(독립 기슭막이 기준벽·
|
||||
// 배관/독립 종속 추가 벽·옛 D경로 독립 벽)는 0.1m. 세월교 흙막이는 안이 관이라 1m(80).
|
||||
// 연동을 푼 옆 측점 카드는 자기 자리를 잡는 벽이라 0.1m(139-2).
|
||||
fineMove: (key) =>
|
||||
(key !== "inlet" && key !== "outlet") || (ctx.hiddenPipe() && !ctx.fordGuard?.()),
|
||||
(key !== "inlet" && key !== "outlet") ||
|
||||
(ctx.hiddenPipe() && !ctx.fordGuard?.()) ||
|
||||
(ctx.isLinked && ctx.revetLink?.linkedFor(ctx.section) === false),
|
||||
dragAxes: (key, basin) => wallDragAxes(ctx, key, basin),
|
||||
extraState: () => ctx.extraState(),
|
||||
isLinkedCard: () => ctx.isLinked,
|
||||
@@ -203,20 +206,9 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
|
||||
ctx.isLinked && ctx.drawnWallKeys().length
|
||||
? { linked: ctx.revetLink?.linkedFor(ctx.section) ?? true }
|
||||
: null,
|
||||
setLinked: (linked) => {
|
||||
// 연동을 **푸는 순간** 지금 그려진 높이를 이 측점 값으로 굳힌다(2026-09-06 사용자
|
||||
// 지시). 예전에는 위치(4축)만 갈리고 높이는 소유 측점 값을 계속 따라가, 소유
|
||||
// 측점 높이를 바꾸면 푼 측점까지 같이 움직였다. 굳혀 두면 값이 튀지 않으면서
|
||||
// 이후에는 따로 논다. 단 수·형태는 소유 측점을 그대로 따른다(2026-08-30 확정 유지).
|
||||
if (!linked) {
|
||||
ctx.drawnWallKeys().forEach((key) => {
|
||||
if (ctx.adjustOf(key).h !== null && ctx.adjustOf(key).h !== undefined) return;
|
||||
const height = ctx.heightOfWall(key);
|
||||
if (height > 0) ctx.revetOffset?.update(ctx.section.chainage_m, key, { h: height });
|
||||
});
|
||||
}
|
||||
ctx.revetLink?.setLinked(ctx.section.chainage_m, linked);
|
||||
},
|
||||
// 푸는 순간 4축(x · d · h · m) 굳힘 · 다시 연동 = 지움 — 제어기 `setLinked` 한 길(139-2 ·
|
||||
// 좌측 관 보호공 [연동]과 같음). 단 수 · 형식은 소유 측점을 그대로 따름(2026-08-30).
|
||||
setLinked: (linked) => ctx.revetLink?.setLinked(ctx.section.chainage_m, linked),
|
||||
followGrade: () =>
|
||||
ctx.revetLink?.followGradeFor(ctx.structureSpan?.ownerOf(ctx.section) ?? ctx.section) ?? true,
|
||||
setFollowGrade: (follow) => {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영]
|
||||
* ========================================================================== */
|
||||
|
||||
import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session";
|
||||
import { writeCrossDesignChoice, writeShoulderExtend } from "./B06_Section_Cross_Design_Session";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
|
||||
import {
|
||||
@@ -60,6 +60,15 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
|
||||
);
|
||||
if (!target) return;
|
||||
|
||||
// 설계선 쉬프트(139-6) — 그 쪽 노견 확장만 세션에 · 캐시에 바로 · 브라우저 재계산(같은 창구)
|
||||
const shift = change.shoulder_extend;
|
||||
if (shift) {
|
||||
writeShoulderExtend(projectId, currentRouteId, chainageM, shift.side, shift.valueM);
|
||||
if (target.design) target.design[`shoulder_extend_${shift.side}_m`] = shift.valueM;
|
||||
await reconcileStaleDesigns({ force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체.
|
||||
if (target.design) {
|
||||
target.design = {
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
wingSlabExtendM,
|
||||
} from "@util/common_util_culvert_sets";
|
||||
import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import { DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford";
|
||||
import { DEFAULT_FORD_WALL_ADJUST, fordAdjustOf, fordDropOf } from "./B06_Section_UI_Cross_Ford";
|
||||
import type { FordAdjust, FordPick, FordWallAdjust } from "./B06_Section_UI_Cross_Ford";
|
||||
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
|
||||
import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box";
|
||||
@@ -141,10 +141,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
|
||||
FordAdjust
|
||||
>;
|
||||
Object.entries(parsed).forEach(([chainage, value]) =>
|
||||
fordAdjustments.set(chainage, {
|
||||
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...value.inlet },
|
||||
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...value.outlet },
|
||||
}),
|
||||
fordAdjustments.set(chainage, fordAdjustOf(value)),
|
||||
);
|
||||
} catch {
|
||||
/* 손상된 세션 값은 기본값으로 대체. */
|
||||
@@ -157,14 +154,18 @@ export function createFordControls(deps: FordControlDeps): FordControls {
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(fordAdjustments)));
|
||||
}
|
||||
|
||||
// 옛 저장(측벽마다 높이 · 대각)은 읽을 때 바닥판 내림 하나로 옮김(139-3 · `fordDropOf`)
|
||||
const fordAdjustAt = (chainageM: number): FordAdjust => {
|
||||
const stored = sectionAt(chainageM)?.design?.ford_adjust;
|
||||
return (
|
||||
fordAdjustments.get(chainageM.toFixed(2)) ?? {
|
||||
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) },
|
||||
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) },
|
||||
}
|
||||
);
|
||||
const section = sectionAt(chainageM);
|
||||
const adjust =
|
||||
fordAdjustments.get(chainageM.toFixed(2)) ?? fordAdjustOf(section?.design?.ford_adjust);
|
||||
if (adjust.dropM != null || !section) return adjust;
|
||||
const side = (wall: FordWallAdjust): FordWallAdjust => ({ ...wall, heightM: null, slopeM: 0 });
|
||||
return {
|
||||
inlet: side(adjust.inlet),
|
||||
outlet: side(adjust.outlet),
|
||||
dropM: fordDropOf(section, adjust),
|
||||
};
|
||||
};
|
||||
|
||||
const writeFord = (chainageM: number, next: FordAdjust): void => {
|
||||
@@ -176,22 +177,20 @@ export function createFordControls(deps: FordControlDeps): FordControls {
|
||||
|
||||
const fordControl: FordControl = {
|
||||
adjustFor: fordAdjustAt,
|
||||
// 측벽마다 좌우(도로 쪽 금지) · 바닥판 내림은 양쪽 한 값 · 아래로만(139-3)
|
||||
update: (chainageM, role, patch) => {
|
||||
const current = fordAdjustAt(chainageM);
|
||||
const wall: FordWallAdjust = { ...current[role], ...patch };
|
||||
const lateralM = patch.lateralM ?? current[role].lateralM;
|
||||
writeFord(chainageM, {
|
||||
...current,
|
||||
[role]: {
|
||||
// 높이는 0.1m 눈금, 하한(관경+토피)은 기하가 다시 잡는다.
|
||||
heightM: wall.heightM === null ? null : Math.max(round1(wall.heightM), 0),
|
||||
lateralM: Math.max(0, clampMove(wall.lateralM)),
|
||||
slopeM: clampMove(wall.slopeM),
|
||||
},
|
||||
[role]: { heightM: null, lateralM: Math.max(0, clampMove(lateralM)), slopeM: 0 },
|
||||
dropM: Math.max(0, round1(clampMove(patch.dropM ?? current.dropM ?? 0))),
|
||||
});
|
||||
},
|
||||
// 되돌림 = 그 측벽 좌우 + 바닥판 내림(양쪽 한 값이라 함께 0)
|
||||
reset: (chainageM, role) => {
|
||||
const current = fordAdjustAt(chainageM);
|
||||
writeFord(chainageM, { ...current, [role]: { ...DEFAULT_FORD_WALL_ADJUST } });
|
||||
writeFord(chainageM, { ...current, [role]: { ...DEFAULT_FORD_WALL_ADJUST }, dropM: 0 });
|
||||
},
|
||||
setPipe: (chainageM, patch) => {
|
||||
const spec = sectionAt(chainageM)?.ford;
|
||||
@@ -380,8 +379,8 @@ export function createBoxControls(deps: FordControlDeps): {
|
||||
update: (chainageM, role, patch) => {
|
||||
const current = adjustAt(chainageM);
|
||||
const side: BoxSideAdjust = { ...current[role], ...patch };
|
||||
// 본체는 늘 수평(79-7) — 표고는 좌 · 우가 한 값.
|
||||
const riseM = clampMove(side.riseM);
|
||||
// 본체는 늘 수평(79-7) — 표고는 좌 · 우가 한 값 · 아래로만(노면 − 복토 위 금지 · 139-3).
|
||||
const riseM = Math.min(clampMove(side.riseM), 0);
|
||||
const other = role === "left" ? "right" : "left";
|
||||
const next: BoxAdjust = { ...current, [other]: { ...current[other], riseM } };
|
||||
// 길이는 바깥으로만 — 안쪽으로 줄이면 성토선 물매가 깨진다.
|
||||
|
||||
@@ -10,12 +10,21 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { RevetKey } from "./B06_Section_UI_Cross_Culvert";
|
||||
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import {
|
||||
computeCardCulvert,
|
||||
culvertLinkFor,
|
||||
culvertOwnerFor,
|
||||
culvertReach,
|
||||
culvertWallsStandAt,
|
||||
ownerAppliedAdjusts,
|
||||
type ExtraWallControl,
|
||||
type InletStructureControl,
|
||||
type RevetLinkControl,
|
||||
type RevetOffsetControl,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure";
|
||||
import type { PipeRevetChoice } from "../A00_Common/design/design_structure_pipe_calc";
|
||||
|
||||
/** 세션에 담기는 측점별 조작값 한 겹 맵. */
|
||||
@@ -111,10 +120,59 @@ export function createLinkFlagSession(sessionKey: () => string | null): LinkFlag
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 연동을 푸는 순간(카드 [연동] · 좌측 [연동] 둘 다 · 139-2) — 그 카드가 그리던 벽마다 4축을 이
|
||||
* 측점 값으로 굳힘: 위치(x · d) = 소유 측점 적용값(물려받던 값) · 높이 · 형태 = 그 카드가 그린 값.
|
||||
* d 가 차서 이 측점 지형으로 자동 자리 · 관 정수 맞춤을 다시 타지 않음(튐 없음). 연동 중에 부름.
|
||||
*/
|
||||
export function freezeLinkedWalls(
|
||||
section: CrossSection,
|
||||
sections: readonly CrossSection[],
|
||||
c: {
|
||||
revetOffset: RevetOffsetControl;
|
||||
inletStructure?: InletStructureControl;
|
||||
extraWalls?: ExtraWallControl;
|
||||
revetLink: RevetLinkControl;
|
||||
},
|
||||
): void {
|
||||
const link = section.culvert ? undefined : culvertLinkFor(section, sections, () => null);
|
||||
const owner =
|
||||
link && ownerAppliedAdjusts(link.source, c.revetOffset, c.inletStructure, c.extraWalls);
|
||||
if (!link || !owner) return;
|
||||
const card = computeCardCulvert(
|
||||
section,
|
||||
section.samples,
|
||||
null,
|
||||
c.revetOffset,
|
||||
c.inletStructure,
|
||||
c.extraWalls,
|
||||
link,
|
||||
c.revetLink,
|
||||
);
|
||||
const drawn = card ? culvertCardState(card).wallSpecs : new Map();
|
||||
const roles: Array<[RevetKey, WallAdjust]> = [
|
||||
["inlet", owner.inlet],
|
||||
["outlet", owner.outlet],
|
||||
...owner.extras.map((a, i): [RevetKey, WallAdjust] => [`extra${i}` as RevetKey, a]),
|
||||
...owner.basinExtras.map((a, i): [RevetKey, WallAdjust] => [`bextra${i}` as RevetKey, a]),
|
||||
];
|
||||
for (const [role, applied] of roles) {
|
||||
const wall = drawn.get(role);
|
||||
if (!wall) continue;
|
||||
const round3 = (v: number): number => Math.round(v * 1000) / 1000;
|
||||
c.revetOffset.update(section.chainage_m, role, {
|
||||
x: round3(applied.x),
|
||||
d: applied.d == null ? null : round3(applied.d),
|
||||
h: wall.height,
|
||||
m: wall.form,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌측 관 보호공 [연동] · [경사](54-30) — 옛 연동 제어 그대로 읽고 씀. 연동 = 벽이 서는 옆 측점이
|
||||
* 있을 때만 · 켬 = 그 측점이 모두 연동 · 끄면 모두 따로. 경사 = 보호공 구간이 있을 때만.
|
||||
* ponytail: 연동을 풀 때 카드 단추가 하던 높이 굳히기는 안 함 — 필요하면 카드 높이 셈을 여기로.
|
||||
* 풀 때 굳히기 · 다시 걸 때 지우기는 `setLinked` 가 함(139-2 · 카드 단추와 한 길).
|
||||
*/
|
||||
export function pipeRevetChoiceBridge(revetLink: RevetLinkControl) {
|
||||
const linkCards = (owner: CrossSection, sections: readonly CrossSection[]) =>
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
STRUCTURE_ROW_KEYS,
|
||||
structureAreaRows,
|
||||
} from "./B06_Section_Structure_Layouts";
|
||||
import { crossDesignChoices } from "./B06_Section_Cross_Design_Session";
|
||||
import { crossDesignChoices, readShoulderExtends } from "./B06_Section_Cross_Design_Session";
|
||||
import { readStructureNotes } from "./B06_Section_UI_Cross_Detail";
|
||||
import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
|
||||
@@ -393,6 +393,12 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
|
||||
readStructureNotes(ctx.projectId, routeId).forEach((note, chainage) => {
|
||||
patchFor(chainage).structure_note = note;
|
||||
});
|
||||
// 설계선 쉬프트 = 쪽마다 노견 확장(139-6) — 서버가 [저장] 때 이 값으로 설계선을 다시 냄
|
||||
readShoulderExtends(ctx.projectId, ctx.routeId()).forEach((sides, chainage) => {
|
||||
const patch = patchFor(chainage);
|
||||
if (sides.left !== undefined) patch.shoulder_extend_left_m = sides.left;
|
||||
if (sides.right !== undefined) patch.shoulder_extend_right_m = sides.right;
|
||||
});
|
||||
if (detail) {
|
||||
// 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다
|
||||
// (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」).
|
||||
|
||||
@@ -26,7 +26,11 @@ import type {
|
||||
import { culvertOwnerFor, culvertReach, wallStandsAt } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options";
|
||||
import { createBodyControls } from "./B06_Section_UI_Page_Ford_Controls";
|
||||
import { createLinkFlagSession, createSessionMap } from "./B06_Section_UI_Page_Link_Session";
|
||||
import {
|
||||
createLinkFlagSession,
|
||||
createSessionMap,
|
||||
freezeLinkedWalls,
|
||||
} from "./B06_Section_UI_Page_Link_Session";
|
||||
import { createSpanControl } from "./B06_Section_UI_Page_Span_Control";
|
||||
import type { FordAdjust } from "./B06_Section_UI_Cross_Ford";
|
||||
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
|
||||
@@ -528,6 +532,16 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
return section.design?.revet_link_detached !== true;
|
||||
},
|
||||
setLinked: (chainageM, linked) => {
|
||||
// 푸는 순간 그린 4축을 이 측점 값으로 굳힘 · 다시 연동 = 굳힌 값 지움(139-2)
|
||||
const section = sectionAt(chainageM);
|
||||
if (linked) dropLinkedAdjusts(chainageM);
|
||||
else if (section && revetLinkControl.linkedFor(section))
|
||||
freezeLinkedWalls(section, deps.detail()?.cross_sections ?? [], {
|
||||
revetOffset: revetOffsetControl,
|
||||
inletStructure: inletStructureControl,
|
||||
extraWalls: extraWallControl,
|
||||
revetLink: revetLinkControl,
|
||||
});
|
||||
linkDetached.set(chainageKey(chainageM), !linked);
|
||||
persistLinkFlags();
|
||||
patchCachedDesign(chainageM, { revet_link_detached: !linked });
|
||||
|
||||
@@ -529,8 +529,9 @@ export function createSectionView(
|
||||
);
|
||||
}
|
||||
},
|
||||
selectMark: (structureId) => {
|
||||
spanMarkId = structureId;
|
||||
selectMark: (structureId, span = true) => {
|
||||
// span 끔 = 알약만 · 카드는 고른 측점 하나(137-4 — 측점 고름 리스너)
|
||||
spanMarkId = span ? structureId : null;
|
||||
syncSpanHighlights();
|
||||
profile.selectMark(structureId);
|
||||
},
|
||||
|
||||
@@ -40,8 +40,9 @@ export interface SectionViewController {
|
||||
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
|
||||
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */
|
||||
focusStation: (stationId: string | null) => void;
|
||||
/** 종단 알약 강조를 그 구조물로(null = 풀기 — 카드 측점선 짝을 따름). */
|
||||
selectMark: (structureId: string | null) => void;
|
||||
/** 종단 알약 강조를 그 구조물로(null = 풀기 — 카드 측점선 짝을 따름).
|
||||
* `span` = 구간형 범위 안 카드 전부 테두리(기본) · 끄면 카드는 고른 측점 하나(137-4). */
|
||||
selectMark: (structureId: string | null, span?: boolean) => void;
|
||||
/** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을
|
||||
* 올린다(2026-08-29 일원화). null = 선택 해제. */
|
||||
setStationSelectListener: (listener: (stationId: string | null) => void) => void;
|
||||
|
||||
@@ -146,6 +146,21 @@
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
|
||||
/* 보라 선 고름(137-2) — 투명 판정 선 · 고르면 굵은 반투명 띠. */
|
||||
.b06-chart__design-hit {
|
||||
fill: none;
|
||||
stroke: transparent;
|
||||
stroke-width: 10;
|
||||
stroke-linecap: round;
|
||||
pointer-events: stroke;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b06-chart__design-hit.is-active {
|
||||
stroke: color-mix(in srgb, var(--color-royal-amethyst) 45%, transparent);
|
||||
stroke-width: 7;
|
||||
}
|
||||
|
||||
/* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */
|
||||
.b06-chart__carriageway-tick {
|
||||
stroke: var(--color-royal-amethyst);
|
||||
|
||||
@@ -223,6 +223,20 @@
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
|
||||
}
|
||||
|
||||
/* 고른 보라 선 치수(137-2) — [+ − ⤢] 왼쪽 같은 줄. */
|
||||
.b06-cross-card__line-dims {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--spacing-4);
|
||||
color: var(--color-royal-amethyst);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.b06-cross-card__line-dims[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b06-cross-card__zoom-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
@@ -236,7 +250,8 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b06-cross-card__zoom-btn:first-child {
|
||||
.b06-cross-card__zoom-btn:first-child,
|
||||
.b06-cross-card__line-dims[hidden] + .b06-cross-card__zoom-btn {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
appendPavementOverlay,
|
||||
} from "../B06_Section/B06_Section_UI_Cross_Design";
|
||||
import { appendRevetmentOverlay } from "../B06_Section/B06_Section_UI_Cross_Revetment";
|
||||
import { clipPointsToPolygon, clipSegment, pointInPolygon } from "../A00_Common/b_polygon_clip";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
@@ -220,59 +221,6 @@ function clipPolygonOf(element: SVGElement): XY[] | null {
|
||||
return points.length >= 3 ? points : null;
|
||||
}
|
||||
|
||||
/** 점이 폴리곤 안인가 — 오목한 벽 단면도 되도록 광선 교차 홀짝으로 본다. */
|
||||
function pointInPolygon(point: XY, polygon: XY[]): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
|
||||
const [xi, yi] = polygon[i];
|
||||
const [xj, yj] = polygon[j];
|
||||
const straddles = yi > point[1] !== yj > point[1];
|
||||
if (straddles && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** 선분을 폴리곤 경계에서 잘라 **안쪽 조각들**만 돌려준다. */
|
||||
function clipSegment(start: XY, end: XY, polygon: XY[]): XY[][] {
|
||||
const [x0, y0] = start;
|
||||
const [x1, y1] = end;
|
||||
const dx = x1 - x0;
|
||||
const dy = y1 - y0;
|
||||
const cuts = [0, 1];
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
|
||||
const [ex, ey] = polygon[j];
|
||||
const [fx, fy] = polygon[i];
|
||||
const denominator = dx * (fy - ey) - dy * (fx - ex);
|
||||
if (Math.abs(denominator) < 1e-12) continue;
|
||||
const t = ((ex - x0) * (fy - ey) - (ey - y0) * (fx - ex)) / denominator;
|
||||
const u = ((ex - x0) * dy - (ey - y0) * dx) / denominator;
|
||||
if (t > 0 && t < 1 && u >= 0 && u <= 1) cuts.push(t);
|
||||
}
|
||||
cuts.sort((a, b) => a - b);
|
||||
const runs: XY[][] = [];
|
||||
for (let index = 0; index + 1 < cuts.length; index += 1) {
|
||||
const from = cuts[index];
|
||||
const to = cuts[index + 1];
|
||||
if (to - from < 1e-9) continue;
|
||||
const mid = (from + to) / 2;
|
||||
if (!pointInPolygon([x0 + dx * mid, y0 + dy * mid], polygon)) continue;
|
||||
runs.push([
|
||||
[x0 + dx * from, y0 + dy * from],
|
||||
[x0 + dx * to, y0 + dy * to],
|
||||
]);
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** 점열을 폴리곤 안으로 자른다 — 조각마다 한 줄. */
|
||||
function clipPointsToPolygon(points: XY[], polygon: XY[]): XY[][] {
|
||||
const runs: XY[][] = [];
|
||||
for (let index = 0; index + 1 < points.length; index += 1) {
|
||||
runs.push(...clipSegment(points[index], points[index + 1], polygon));
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
function circlePoints(center: XY, radius: number): XY[] {
|
||||
const points: XY[] = [];
|
||||
for (let index = 0; index <= CIRCLE_SEGMENTS; index += 1) {
|
||||
|
||||
@@ -40,7 +40,7 @@ from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from B06_Section.B06_Section_Engine_Culvert import pipe_wall_min_height_m
|
||||
from B06_Section.B06_Section_Engine_Culvert import pipe_wall_height_m, pipe_wall_min_height_m
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_confirmed_route_context,
|
||||
get_cross_section_designs,
|
||||
@@ -636,9 +636,21 @@ def pipe_summary(
|
||||
|
||||
sides = [side for side in ("inlet", "outlet") if options.get(f"{side}_type") == SOIL]
|
||||
sources = [(side, links.get(LINKS[side])) for side in sides]
|
||||
# 관경 빈칸 = 빈 높이도 「미입력」(관경 최소 높이를 모름)
|
||||
# 옛 기본 1.5 · 관경 > 1.0 = 관경 기준 값(139-1 · 그림과 한 값) · 관경 빈칸 = 빈 높이도
|
||||
# 「미입력」(관경 최소 높이를 모름 · 143-2)
|
||||
moved = (
|
||||
{
|
||||
f"{side}_revet_height_m": pipe_wall_height_m(
|
||||
_num(options.get(f"{side}_revet_height_m")), diameter / 1000
|
||||
)
|
||||
for side in sides
|
||||
}
|
||||
if diameter
|
||||
else {}
|
||||
)
|
||||
wall_options = {**options, **moved}
|
||||
wall = pipe_wall_min_height_m(diameter / 1000) if diameter else None
|
||||
_revets(sheet, at, options, links, sources, wall, names)
|
||||
_revets(sheet, at, wall_options, links, sources, wall, names)
|
||||
_chutes(sheet, at, options, links, names)
|
||||
|
||||
out = sheet.result()
|
||||
|
||||
@@ -113,6 +113,9 @@ export interface CrossDesignOptions {
|
||||
* 짝: 파이썬 `compute_cross_design(spoil_fill=…)`.
|
||||
*/
|
||||
spoilFill?: { side: "left" | "right"; widthM: number; slopeRatioN?: number | null } | null;
|
||||
/** 설계선 쉬프트 = 쪽마다 노견 확장(m · ≥ 0 · 139-6). 짝: 파이썬 `shoulder_extend_*_m`. */
|
||||
shoulderExtendLeftM?: number;
|
||||
shoulderExtendRightM?: number;
|
||||
}
|
||||
|
||||
export interface CrossDesignEdge {
|
||||
@@ -137,6 +140,9 @@ export interface CrossDesignResult {
|
||||
/** 곡선부 확폭(m) — 붙은 쪽만 값이 있다. */
|
||||
widening_left_m?: number;
|
||||
widening_right_m?: number;
|
||||
/** 노견 확장(m · 139-6) — 넓힌 쪽만. */
|
||||
shoulder_extend_left_m?: number;
|
||||
shoulder_extend_right_m?: number;
|
||||
cross_slope_pct: number;
|
||||
ditch: Record<string, unknown>;
|
||||
/** 결과 — **실제로 섰나**. */
|
||||
@@ -356,6 +362,8 @@ export function computeCrossDesign(
|
||||
ditchEnabled: options.ditchEnabled ?? null,
|
||||
ditchChoice: options.ditchChoice ?? null,
|
||||
berm: options.berm ?? null,
|
||||
shoulderExtendLeftM: options.shoulderExtendLeftM ?? 0,
|
||||
shoulderExtendRightM: options.shoulderExtendRightM ?? 0,
|
||||
});
|
||||
|
||||
// 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만).
|
||||
@@ -575,6 +583,11 @@ export function computeCrossDesign(
|
||||
slope_deg: round4(options.berm.slopeDeg),
|
||||
};
|
||||
}
|
||||
// 노견 확장(139-6) — 넓힌 쪽만 되싣는다(짝: 파이썬)
|
||||
if (geometry.shoulderExtendLeft > 0)
|
||||
result.shoulder_extend_left_m = round4(geometry.shoulderExtendLeft);
|
||||
if (geometry.shoulderExtendRight > 0)
|
||||
result.shoulder_extend_right_m = round4(geometry.shoulderExtendRight);
|
||||
if (paved) result.pavement_thickness_m = round4(pavedGroup.pavement_thickness_m);
|
||||
if (presetKey === "rock" && rockBoundaryOffsetM !== null) {
|
||||
result.rock_boundary_offset_m = round4(rockBoundaryOffsetM);
|
||||
|
||||
@@ -108,6 +108,9 @@ export class SectionGeometry {
|
||||
hasDitch: boolean;
|
||||
/** 사용자가 정한 선택(`null` = 자동). 결과(`hasDitch`)와 다른 값이다. */
|
||||
ditchChoice: boolean | null;
|
||||
/** 노견 확장(m · 139-6) — 쪽마다. */
|
||||
shoulderExtendLeft = 0;
|
||||
shoulderExtendRight = 0;
|
||||
ditchPoints: Array<[number, number]> = [];
|
||||
private groundAt: ((offsetM: number) => number) | null;
|
||||
private rockOffset: number;
|
||||
@@ -138,6 +141,9 @@ export class SectionGeometry {
|
||||
wideningRightM?: number;
|
||||
/** 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */
|
||||
berm?: BermSpec | null;
|
||||
/** 설계선 쉬프트 = 쪽마다 노견 확장(m · ≥ 0 · 139-6). */
|
||||
shoulderExtendLeftM?: number;
|
||||
shoulderExtendRightM?: number;
|
||||
}) {
|
||||
const { group } = params;
|
||||
const halfRoad = group.road_width_m / 2;
|
||||
@@ -204,6 +210,13 @@ export class SectionGeometry {
|
||||
/** 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. */
|
||||
this.ditchChoice = choice;
|
||||
|
||||
// 설계선 쉬프트 = 쪽마다 노견 확장(≥ 0 · 139-6) — 단면유형 · 측구 판정은 **옮기기 전** 노견에서
|
||||
// (위) · 노체 · 측구 · 사면이 같이 바깥으로 · 차도는 그대로. ⚠ 파이썬 짝 `_SectionGeometry`.
|
||||
this.shoulderExtendLeft = Math.max(params.shoulderExtendLeftM ?? 0, 0);
|
||||
this.shoulderExtendRight = Math.max(params.shoulderExtendRightM ?? 0, 0);
|
||||
this.leftExtent += this.shoulderExtendLeft;
|
||||
this.rightExtent += this.shoulderExtendRight;
|
||||
|
||||
// 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
|
||||
const edgeOffset = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent;
|
||||
const outward = params.ditchSide === "left" ? 1 : -1;
|
||||
|
||||
@@ -46,19 +46,75 @@ export const FORD_DEFAULT_WIDTH_M = 10.0;
|
||||
export const FORD_PAVEMENT_DEFAULT_WIDTH_M = 10.0;
|
||||
export const FORD_PAVEMENT_DEFAULT_SLOPE_DEG = 3.0;
|
||||
export const BOX_COVER_M = 0.5;
|
||||
/** 관 벽 최소 높이 — 횡단도 `B06_Section_UI_Cross_Culvert_Const.ts` 와 같은 값(거울 시험이 셋을 대조).
|
||||
* 높이를 안 적은 관 벽은 이 값으로 섬(2026-09-14 브레인 판정 · 옛 등록부 기본 2.5 걷음). */
|
||||
export const REVET_FREEBOARD_M = 0.5;
|
||||
/** 관 벽 기본 높이 — 횡단도 `B06_Section_UI_Cross_Culvert_Const.ts` 와 같은 값(거울 시험이 셋을 대조).
|
||||
* 높이를 안 적은 관 벽은 이 값으로 섬(139-1 · 관경 + 0.5 = 벽 윗면 = 관 윗면). */
|
||||
export const REVET_HEIGHT_STEP_M = 0.1;
|
||||
export const REVET_EMBED_DEPTH_M = 0.5;
|
||||
export const OLD_PIPE_WALL_DEFAULT_M = 1.5;
|
||||
|
||||
/** 관 벽 순수 높이(근입 포함) 최소값 — 파이썬 `pipe_wall_min_height_m`. */
|
||||
/** 관 벽 순수 높이(근입 포함) 기본값 — 파이썬 `pipe_wall_min_height_m`. */
|
||||
export function pipeWallMinHeightM(diameterM: number): number {
|
||||
const raw = diameterM + REVET_FREEBOARD_M;
|
||||
const raw = diameterM;
|
||||
const steps = Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9);
|
||||
return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3);
|
||||
}
|
||||
|
||||
/** 관 벽 · 유입 집수정 높이 칸 읽기 — 옛 기본 1.5 이고 관경 > 1.0 이면 관경 기준 값(139-1) ·
|
||||
* 빈 칸 = null. 파이썬 `pipe_wall_height_m`. */
|
||||
export function pipeWallHeightM(stored: number | null, diameterM: number): number | null {
|
||||
if (
|
||||
stored !== null &&
|
||||
Math.abs(stored - OLD_PIPE_WALL_DEFAULT_M) < 1e-9 &&
|
||||
diameterM > 1.0 + 1e-9
|
||||
)
|
||||
return pipeWallMinHeightM(diameterM);
|
||||
return stored;
|
||||
}
|
||||
|
||||
/** 관 보호공 · 유입 집수정 높이 칸 — 새 관 기본 = 관경 + 0.5(139-1 · 서버 짝 `PIPE_HEIGHT_KEYS`). */
|
||||
export const PIPE_HEIGHT_KEYS = [
|
||||
"inlet_revet_height_m",
|
||||
"outlet_revet_height_m",
|
||||
"inlet_basin_height_m",
|
||||
];
|
||||
|
||||
/** 새 관 옵션 기본값에 높이 칸을 채움 — 그 칸이 정의에 있고 비었을 때만(B05 `optionDefaults`). */
|
||||
export function fillPipeHeights(
|
||||
values: Record<string, string | number>,
|
||||
keys: ReadonlyArray<string>,
|
||||
): void {
|
||||
const diameterM = Number(values.pipe_diameter_mm) / 1000;
|
||||
if (!(diameterM > 0)) return;
|
||||
for (const key of PIPE_HEIGHT_KEYS)
|
||||
if (keys.includes(key)) values[key] ??= pipeWallMinHeightM(diameterM);
|
||||
}
|
||||
|
||||
/** 배관 관 보호공 높이 칸(유입 · 유출 · 유입 집수정 = 항목 `height`) — 관경 + 0.5 규칙(139-1).
|
||||
* `previousMm` 없음 = 읽을 때(옛 1.5 · 관경 > 1.0 옮김) · 있음 = 관경을 바꿈(옛 관경 규칙 값 · 옛 1.5
|
||||
* 칸만 새 규칙 값으로 · 손 값 · 빈 칸은 그대로). */
|
||||
export function followPipeHeights(
|
||||
state: Record<string, Array<Record<string, string>> | undefined>,
|
||||
diameterMm: string,
|
||||
previousMm?: string,
|
||||
): void {
|
||||
const d = Number(diameterMm) / 1000;
|
||||
if (!(d > 0)) return;
|
||||
const prev = previousMm ? pipeWallMinHeightM(Number(previousMm) / 1000) : null;
|
||||
for (const id of ["inlet", "outlet"]) {
|
||||
const values = state[id]?.[0];
|
||||
const raw = values?.height ?? "";
|
||||
if (!values || raw === "") continue;
|
||||
const now = Number(raw);
|
||||
const moved =
|
||||
previousMm === undefined
|
||||
? pipeWallHeightM(now, d)
|
||||
: [prev, OLD_PIPE_WALL_DEFAULT_M].some((v) => v !== null && Math.abs(now - v) < 1e-9)
|
||||
? pipeWallMinHeightM(d)
|
||||
: now;
|
||||
if (moved !== null && moved !== now) values.height = String(moved);
|
||||
}
|
||||
}
|
||||
|
||||
/** 시설 종류 — 파이썬 `common_util_drainage_pipes` 의 상수와 같은 문자열. */
|
||||
const FACILITY_PIPE = "pipe";
|
||||
const FACILITY_BOX = "box_culvert";
|
||||
@@ -107,12 +163,14 @@ function sideSpec(
|
||||
);
|
||||
spec.basin_before_m = num(options.inlet_basin_before_m, null);
|
||||
spec.basin_after_m = num(options.inlet_basin_after_m, null);
|
||||
spec.basin_height_m = num(options.inlet_basin_height_m, null);
|
||||
spec.basin_height_m = pipeWallHeightM(num(options.inlet_basin_height_m, null), diameterM);
|
||||
spec.basin_form = text(options.inlet_structure); // 집수정 모양(54-39) — 빈 값 = 안 고름
|
||||
return spec;
|
||||
}
|
||||
|
||||
const height = num(options[`${side}_revet_height_m`], pipeWallMinHeightM(diameterM));
|
||||
const height =
|
||||
pipeWallHeightM(num(options[`${side}_revet_height_m`], null), diameterM) ??
|
||||
pipeWallMinHeightM(diameterM);
|
||||
const length = num(options[`${side}_revet_length_m`], null);
|
||||
const form = text(options[`${side}_revet_form`]);
|
||||
const before = num(options[`${side}_revet_before_m`], null);
|
||||
|
||||
@@ -153,10 +153,10 @@
|
||||
"label": "집수정 높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 1.5,
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)"
|
||||
"default_basis": "새 관 = 관경 + 0.5 m(땅속 근입 0.5 포함 벽 전체 높이 · 벽 윗면 = 관 윗면 · Ø1000 = 1.5 = 10-6 배관 기본값 표) — 화면 · 서버가 관경으로 채움 · 관경 바꾸면 따라감 · 옛 1.5 이고 관경 > 1.0 이면 읽을 때 관경 + 0.5(139-1)"
|
||||
},
|
||||
{
|
||||
"key": "inlet_revet_form",
|
||||
@@ -205,11 +205,11 @@
|
||||
"label": "유입 흙막이 높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 1.5,
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)",
|
||||
"default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)"
|
||||
"empty_means": "비우면 관경 기준 높이(관경을 0.1m 로 올림 + 근입 0.5 · 벽 윗면 = 관 윗면)로 그리고 셈 — 횡단도·구조물도·표가 같은 값(139-1)",
|
||||
"default_basis": "새 관 = 관경 + 0.5 m(땅속 근입 0.5 포함 벽 전체 높이 · 벽 윗면 = 관 윗면 · Ø1000 = 1.5 = 10-6 배관 기본값 표) — 화면 · 서버가 관경으로 채움 · 관경 바꾸면 따라감 · 옛 1.5 이고 관경 > 1.0 이면 읽을 때 관경 + 0.5(139-1)"
|
||||
},
|
||||
{
|
||||
"key": "outlet_type",
|
||||
@@ -269,11 +269,11 @@
|
||||
"label": "유출 흙막이 높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 1.5,
|
||||
"default": null,
|
||||
"required": false,
|
||||
"phase": "detail",
|
||||
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)",
|
||||
"default_basis": "사용자 확정 2026-10-06 배관 기본값 표(docs/raw/사용자요구/2026-10-05_수정요구.md)"
|
||||
"empty_means": "비우면 관경 기준 높이(관경을 0.1m 로 올림 + 근입 0.5 · 벽 윗면 = 관 윗면)로 그리고 셈 — 횡단도·구조물도·표가 같은 값(139-1)",
|
||||
"default_basis": "새 관 = 관경 + 0.5 m(땅속 근입 0.5 포함 벽 전체 높이 · 벽 윗면 = 관 윗면 · Ø1000 = 1.5 = 10-6 배관 기본값 표) — 화면 · 서버가 관경으로 채움 · 관경 바꾸면 따라감 · 옛 1.5 이고 관경 > 1.0 이면 읽을 때 관경 + 0.5(139-1)"
|
||||
},
|
||||
{
|
||||
"key": "revet_foundation",
|
||||
|
||||
@@ -252,14 +252,15 @@ out.box.left = boxCase("left", { lengthM: 0, riseM: 0 }, 0.47, 0.33);
|
||||
out.box.right = boxCase("right", { lengthM: 1, riseM: 0 }, -0.47, -0.21);
|
||||
out.box.shorten = boxCase("left", { lengthM: 0.2, riseM: 9.9 }, -0.9, -0.5);
|
||||
|
||||
// ── 세월교 창 — 좌우 + 1:1.2 사면 · 0.1 걸음 ─────────────────────────────────────
|
||||
function fordCase(outward, adjust, leftM, downM) {
|
||||
// ── 세월교 창 — 좌우 + 수직 내림(양쪽 한 값 · 아래로만 · 139-3) · 0.1 걸음 ─────────────
|
||||
function fordCase(outward, adjust, leftM, downM, dropM = 0) {
|
||||
const moves = [];
|
||||
const panel = buildFordPanel({
|
||||
adjustFor: () => adjust,
|
||||
heightFor: () => 1.5,
|
||||
nudge: () => {},
|
||||
nudgeSlope: () => {},
|
||||
dropFor: () => dropM,
|
||||
outwardOf: () => outward,
|
||||
move: (r, screen, slope) => moves.push({ role: r, screen: r3(screen), slope: r3(slope) }),
|
||||
reset: () => {},
|
||||
@@ -278,6 +279,7 @@ function fordCase(outward, adjust, leftM, downM) {
|
||||
out.ford = {
|
||||
diag: fordCase(1, { heightM: null, lateralM: 0, slopeM: 0 }, 0.53, 0.27),
|
||||
inward: fordCase(-1, { heightM: null, lateralM: 0.2, slopeM: 0 }, 0.9, 0),
|
||||
up: fordCase(1, { heightM: null, lateralM: 0, slopeM: 0 }, 0, -0.5, 0.2),
|
||||
};
|
||||
|
||||
// ── BOX 판정면 — 좌측 끝 날개벽(에이프런 4 → 6) · 우측 끝 날개벽 없음 ───────────
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/* PLAN 137-2 · 137-3 단위검증 헬퍼 — B06 보라 선 고름(조각 · 가림 · 치수 · 구역 · 판정 선 · 한 칸 고름).
|
||||
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
|
||||
* 입력 = 손으로 짠 단면 + `fixtures/b07_cross_overlay_sections.json` 배관 측점 85.052. 짝 = `test_137_design_pick.py`.
|
||||
*/
|
||||
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 STUBS = {
|
||||
"@ui/ui_template_toast": { showToast: () => undefined },
|
||||
"@ui/ui_template_elements": { showToast: () => undefined },
|
||||
};
|
||||
|
||||
// ── 화면 그릇 흉내 — 오버레이 · 판정 선이 쓰는 만큼만 ─────────────────────────
|
||||
class FakeNode {
|
||||
constructor(tag) {
|
||||
this.tagName = String(tag);
|
||||
this.children = [];
|
||||
this.attrs = {};
|
||||
this.listeners = {};
|
||||
this.hidden = false;
|
||||
this._text = "";
|
||||
this._classes = new Set();
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...names) => names.forEach((n) => self._classes.add(n)),
|
||||
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
|
||||
toggle: (name, force) => {
|
||||
const on = force === undefined ? !self._classes.has(name) : !!force;
|
||||
on ? self._classes.add(name) : self._classes.delete(name);
|
||||
return on;
|
||||
},
|
||||
contains: (name) => self._classes.has(name),
|
||||
};
|
||||
this.style = { setProperty: () => {} };
|
||||
}
|
||||
set className(value) {
|
||||
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
get className() {
|
||||
return [...this._classes].join(" ");
|
||||
}
|
||||
set textContent(value) {
|
||||
this._text = String(value);
|
||||
this.children = [];
|
||||
}
|
||||
get textContent() {
|
||||
return this._text + this.children.map((c) => c.textContent).join("");
|
||||
}
|
||||
get childElementCount() {
|
||||
return this.children.length;
|
||||
}
|
||||
get nextElementSibling() {
|
||||
const kids = this.parent?.children ?? [];
|
||||
return kids[kids.indexOf(this) + 1] ?? null;
|
||||
}
|
||||
_detach(kid) {
|
||||
if (kid.parent) kid.parent.children = kid.parent.children.filter((k) => k !== kid);
|
||||
kid.parent = this;
|
||||
}
|
||||
append(...kids) {
|
||||
kids.forEach((k) => {
|
||||
this._detach(k);
|
||||
this.children.push(k);
|
||||
});
|
||||
}
|
||||
appendChild(kid) {
|
||||
this.append(kid);
|
||||
return kid;
|
||||
}
|
||||
insertBefore(kid, ref) {
|
||||
this._detach(kid);
|
||||
const at = ref ? this.children.indexOf(ref) : -1;
|
||||
if (at < 0) this.children.push(kid);
|
||||
else this.children.splice(at, 0, kid);
|
||||
return kid;
|
||||
}
|
||||
remove() {
|
||||
if (this.parent) this.parent.children = this.parent.children.filter((k) => k !== this);
|
||||
}
|
||||
matches(selector) {
|
||||
if (selector === "text") return this.tagName === "text";
|
||||
return selector
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.some((part) =>
|
||||
part === "text" ? this.tagName === "text" : this._classes.has(part.slice(1)),
|
||||
);
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attrs[name] = String(value);
|
||||
if (name === "class") this.className = value;
|
||||
}
|
||||
getAttribute(name) {
|
||||
return name === "class" ? this.className : (this.attrs[name] ?? null);
|
||||
}
|
||||
hasAttribute(name) {
|
||||
return name in this.attrs;
|
||||
}
|
||||
addEventListener(type, fn) {
|
||||
(this.listeners[type] ??= []).push(fn);
|
||||
}
|
||||
click() {
|
||||
let stopped = false;
|
||||
const event = { stopPropagation: () => (stopped = true), preventDefault: () => {} };
|
||||
(this.listeners.click ?? []).forEach((fn) => fn(event));
|
||||
return stopped;
|
||||
}
|
||||
}
|
||||
globalThis.document = {
|
||||
createElement: (tag) => new FakeNode(tag),
|
||||
createElementNS: (_ns, tag) => new FakeNode(tag),
|
||||
};
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
if (full.endsWith(".json")) {
|
||||
const data = JSON.parse(fs.readFileSync(full, "utf8"));
|
||||
cache.set(full, data);
|
||||
return data;
|
||||
}
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.endsWith(".css")) return {}; // 화면 꾸밈 — Node 에선 안 씀(138-4 모달 CSS)
|
||||
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
|
||||
for (const [prefix, dir] of Object.entries(alias))
|
||||
if (request.startsWith(prefix))
|
||||
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
|
||||
return loadTs(resolve(path.join(path.dirname(full), request)));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
const resolve = (base) =>
|
||||
[base, `${base}.ts`, path.join(base, "index.ts")].find(
|
||||
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
|
||||
) ?? `${base}.ts`;
|
||||
|
||||
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
|
||||
const pick = loadTs(B06("B06_Section_UI_Cross_Design_Pick"));
|
||||
const { pointInPolygon } = loadTs(path.join(ROOT, "A00_Common", "b_polygon_clip.ts"));
|
||||
const out = {};
|
||||
const r3 = (v) => Math.round(v * 1000) / 1000;
|
||||
const pt = (offset, elevation) => ({ offset, elevation });
|
||||
|
||||
// ① 조각 — 0.5 m 토막 노면(가운데 꺾임) · 측구 · 사면이 직선 조각 다섯으로
|
||||
const sampled = (a, b, step = 0.5) => {
|
||||
const n = Math.max(1, Math.round(Math.abs(b.offset - a.offset) / step));
|
||||
return Array.from({ length: n + 1 }, (_, i) =>
|
||||
pt(
|
||||
a.offset + ((b.offset - a.offset) * i) / n,
|
||||
a.elevation + ((b.elevation - a.elevation) * i) / n,
|
||||
),
|
||||
);
|
||||
};
|
||||
const line = [
|
||||
...sampled(pt(-6, 98), pt(-2.5, 99.95)), // 우 성토 사면
|
||||
...sampled(pt(-2.5, 99.95), pt(0, 100)).slice(1), // 우 노면 2%
|
||||
...sampled(pt(0, 100), pt(2.5, 99.95)).slice(1), // 좌 노면 2%
|
||||
...sampled(pt(2.5, 99.95), pt(3, 99.65)).slice(1), // 측구 안
|
||||
...sampled(pt(3, 99.65), pt(6, 102.65)).slice(1), // 좌 절토 사면
|
||||
].map((p) => ({ offset: r3(p.offset), elevation: Math.round(p.elevation * 1e4) / 1e4 }));
|
||||
// 토막마다 한 줄(설계선 오버레이 꼴) + 차례를 뒤집은 한 토막(거꾸로 그린 선)
|
||||
const runs = [];
|
||||
for (let i = 1; i < line.length; i += 1)
|
||||
runs.push({ points: [line[i - 1], line[i]], structure: false });
|
||||
runs.push({ points: [pt(-6.5, 98 - (0.5 * 1.95) / 3.5), pt(-6, 98)].reverse(), structure: false });
|
||||
const merged = pick.mergeStraightPieces(runs);
|
||||
out.merge = {
|
||||
segments: runs.length,
|
||||
pieces: merged.map((p) => [r3(p.from.offset), r3(p.to.offset)].sort((a, b) => a - b)),
|
||||
};
|
||||
// 구조물 선은 같은 직선이어도 설계선과 안 합침
|
||||
out.mergeStructure = pick.mergeStraightPieces([
|
||||
{ points: [pt(10, 0), pt(11, 1)], structure: false },
|
||||
{ points: [pt(11, 1), pt(12, 2)], structure: true },
|
||||
]).length;
|
||||
|
||||
// ② 가림 — 사면을 가로지르는 벽 → 벽 밖 두 조각
|
||||
const slope = [{ from: pt(3, 99.65), to: pt(6, 102.65), structure: false }];
|
||||
const wall = [pt(4, 100), pt(4.5, 100), pt(4.5, 103), pt(4, 103)];
|
||||
const visible = pick.visiblePieces(slope, [wall]);
|
||||
out.occlude = {
|
||||
pieces: visible.map((p) => [r3(p.from.offset), r3(p.to.offset)]),
|
||||
// 통째로 덮이면 조각 없음
|
||||
covered: pick.visiblePieces(slope, [[pt(2, 90), pt(7, 90), pt(7, 110), pt(2, 110)]]).length,
|
||||
};
|
||||
|
||||
// ③ 치수
|
||||
const fillPiece = { from: pt(-2.5, 99.95), to: pt(-5.5, 97.95), structure: false };
|
||||
const roadPiece = { from: pt(0, 100), to: pt(2.5, 99.95), structure: false };
|
||||
out.measure = {
|
||||
fill: pick.pieceMeasure(fillPiece, "fill"),
|
||||
road: pick.pieceMeasure(roadPiece, "road"),
|
||||
flatSlope: pick.pieceMeasure({ from: pt(4, 1), to: pt(5, 1), structure: false }, "cut").grade,
|
||||
};
|
||||
const design = {
|
||||
road_edges: {
|
||||
left: { offset_m: 2.5, elevation_m: 99.95 },
|
||||
right: { offset_m: -2.5, elevation_m: 99.95 },
|
||||
},
|
||||
ditch: { type: "standard", top_width_m: 0.5, bottom_width_m: 0.2, depth_m: 0.3 },
|
||||
ditch_side: "left",
|
||||
ditch_enabled: true,
|
||||
section_mode: "left_cut",
|
||||
};
|
||||
out.label = { fill: pick.pieceLabel(design, fillPiece), road: pick.pieceLabel(design, roadPiece) };
|
||||
|
||||
// ④ 구역 — 도로 = 두 노견 끝 사이 · 절토 = 측구 + 절토 사면 · 성토 = 성토 사면 + 구조물 선
|
||||
const zone = (o, structure = false, d = design) => pick.crossZoneAt(d, o, structure);
|
||||
out.zone = {
|
||||
road: zone(1),
|
||||
roadEdge: zone(-2.5).zone,
|
||||
ditch: zone(2.8),
|
||||
cutSlope: zone(5),
|
||||
fillSlope: zone(-4),
|
||||
structure: zone(5, true).zone,
|
||||
rightCutDitch: zone(-2.8, false, { ...design, ditch_side: "right", section_mode: "right_cut" })
|
||||
.zone,
|
||||
bothFill: zone(5, false, { ...design, ditch_enabled: false, section_mode: "both_fill" }).zone,
|
||||
pieceZones: merged.map((p) => pick.pieceZone(design, p).zone),
|
||||
};
|
||||
|
||||
// ⑤ 실제 측점 — 설계선 + 배관 오버레이를 그린 뒤 고름을 붙임(카드와 같은 차례)
|
||||
const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts"));
|
||||
const { appendCulvertOverlay } = loadTs(B06("B06_Section_UI_Cross_Culvert"));
|
||||
const { appendCrossDesignOverlay } = loadTs(B06("B06_Section_UI_Cross_Design"));
|
||||
const { raiseWalls } = loadTs(B06("B06_Section_UI_Cross_Wall"));
|
||||
const fixture = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"),
|
||||
);
|
||||
const sections = fixture.cross_sections;
|
||||
const pipeSection = sections.find((s) => s.culvert && !s.culvert.hidden_pipe);
|
||||
const layout = computeStoredLayouts(pipeSection, sections).culvert;
|
||||
const x = (o) => 100 - o * 12;
|
||||
const y = (e) => 900 - (e - 850) * 12;
|
||||
const layer = new FakeNode("g");
|
||||
const from = layer.childElementCount;
|
||||
appendCrossDesignOverlay(layer, pipeSection.design, x, y, pipeSection.samples, layout.designTrim);
|
||||
layer.children.slice(from).forEach((el) => el.setAttribute(pick.DESIGN_LINE_ATTR, ""));
|
||||
appendCulvertOverlay(layer, layout, x, y, () => undefined, false);
|
||||
raiseWalls(layer);
|
||||
const picks = [];
|
||||
const handle = pick.attachDesignLinePick({
|
||||
layer,
|
||||
design: pipeSection.design,
|
||||
x,
|
||||
toDisplayY: y,
|
||||
onPick: (i) => picks.push(i),
|
||||
});
|
||||
const kids = layer.children;
|
||||
const hits = kids.filter((k) => k.classList.contains("b06-chart__design-hit"));
|
||||
const firstStructureHit = kids.findIndex((k) =>
|
||||
k.classList.contains("b06-chart__culvert-revet-hit"),
|
||||
);
|
||||
const lastLineHit = kids.reduce(
|
||||
(last, k, i) => (k.classList.contains("b06-chart__design-hit") ? i : last),
|
||||
-1,
|
||||
);
|
||||
const walls = kids
|
||||
.filter((k) => k.tagName === "polygon" && k.classList.contains("b06-chart__culvert-revet"))
|
||||
.map((k) =>
|
||||
k
|
||||
.getAttribute("points")
|
||||
.split(/\s+/)
|
||||
.map((pair) => pair.split(",").map(Number)),
|
||||
);
|
||||
const insideWall = handle.pieces.filter((p) => {
|
||||
const mid = [x((p.from.offset + p.to.offset) / 2), y((p.from.elevation + p.to.elevation) / 2)];
|
||||
return walls.some((w) => pointInPolygon(mid, w));
|
||||
}).length;
|
||||
const designLines = kids.filter((k) => k.classList.contains("b06-chart__design-cross")).length;
|
||||
const stopped = hits[0].click();
|
||||
handle.setActive(1);
|
||||
const activeOn = hits.map((h) => h.classList.contains("is-active"));
|
||||
const labelOn = { hidden: handle.label.hidden, text: handle.label.textContent };
|
||||
handle.setActive(null);
|
||||
out.real = {
|
||||
designLines,
|
||||
pieces: handle.pieces.length,
|
||||
structurePieces: handle.pieces.filter((p) => p.structure).length,
|
||||
hits: hits.length,
|
||||
wallCount: walls.length,
|
||||
insideWall,
|
||||
hitsBelowStructure: firstStructureHit < 0 || lastLineHit < firstStructureHit,
|
||||
picked: picks,
|
||||
stopped,
|
||||
activeCount: activeOn.filter(Boolean).length,
|
||||
activeIndex: activeOn.indexOf(true),
|
||||
labelOn,
|
||||
labelOff: { hidden: handle.label.hidden, text: handle.label.textContent },
|
||||
zones: [...new Set(handle.pieces.map((p) => pick.pieceZone(pipeSection.design, p).zone))].sort(),
|
||||
};
|
||||
|
||||
// ⑥ 페이지 한 칸 — 다른 측점을 고르면 앞 측점 고름은 사라짐 · 남의 측점 풀기는 안 건드림
|
||||
pick.setPickedLine("A", 2);
|
||||
const a1 = pick.pickedLineAt("A");
|
||||
pick.setPickedLine("B", 1);
|
||||
const a2 = pick.pickedLineAt("A");
|
||||
pick.setPickedLine("A", null);
|
||||
out.slot = { a1, a2, b: pick.pickedLineAt("B") };
|
||||
pick.setPickedLine("B", null);
|
||||
out.slot.cleared = pick.pickedLineAt("B");
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,109 @@
|
||||
/* 139-2 연동 풀림 굳힘 헬퍼 — 화면 TS(`_Page_Link_Session.freezeLinkedWalls` · `_Cross_Culvert_Wire`)를
|
||||
* 그 자리에서 트랜스파일해 Node 로 돌림. 입력 = 73-4 픽스처(박스 352.14 흙막이 · 옆 측점 340 · 360).
|
||||
* 짝 = `test_139_2_link_freeze.py`.
|
||||
*/
|
||||
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 noop = new Proxy({}, { get: () => () => "" });
|
||||
// 조정창 모듈이 불러올 때 문서 이벤트를 검 — 기하엔 안 쓰임
|
||||
globalThis.document = { addEventListener: () => undefined };
|
||||
globalThis.window = globalThis;
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
if (full.endsWith(".json")) {
|
||||
const data = JSON.parse(fs.readFileSync(full, "utf8"));
|
||||
cache.set(full, data);
|
||||
return data;
|
||||
}
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
// 화면 전용(토스트 · 글 · 설정) — 기하엔 안 쓰임
|
||||
if (request.startsWith("@ui/") || request.startsWith("@config/")) return noop;
|
||||
if (request.startsWith("@util/"))
|
||||
return loadTs(resolve(path.join(ROOT, "common_util", request.slice(6))));
|
||||
return loadTs(resolve(path.join(path.dirname(full), request)));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
const resolve = (base) =>
|
||||
[base, `${base}.ts`].find((p) => fs.existsSync(p) && fs.statSync(p).isFile()) ?? `${base}.ts`;
|
||||
|
||||
const B06 = (f) => path.join(ROOT, "B06_Section", f);
|
||||
const { freezeLinkedWalls } = loadTs(B06("B06_Section_UI_Page_Link_Session.ts"));
|
||||
const { computeCardCulvert, culvertLinkFor } = loadTs(B06("B06_Section_UI_Cross_Culvert_Wire.ts"));
|
||||
|
||||
const sections = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"),
|
||||
).cross_sections;
|
||||
const at = (c) => sections.find((s) => Math.abs(s.chainage_m - c) < 0.6);
|
||||
|
||||
// 제어기 흉내 — 측점:역할 → 4축(세션 맵과 같은 꼴)
|
||||
const stored = new Map();
|
||||
const keyOf = (c, role) => `${c.toFixed(2)}:${role}`;
|
||||
const ZERO = { x: 0, d: null, h: null, m: null };
|
||||
const revetOffset = {
|
||||
adjustFor: (s, role) => ({ ...ZERO, ...(stored.get(keyOf(s.chainage_m, role)) ?? {}) }),
|
||||
storedAdjustFor: (s, role) => stored.get(keyOf(s.chainage_m, role)) ?? null,
|
||||
update: (c, role, patch) =>
|
||||
stored.set(keyOf(c, role), { ...ZERO, ...(stored.get(keyOf(c, role)) ?? {}), ...patch }),
|
||||
reset: (c, role) => stored.delete(keyOf(c, role)),
|
||||
syncApplied: () => undefined,
|
||||
selectedFor: () => null,
|
||||
highlightFor: () => null,
|
||||
select: () => undefined,
|
||||
};
|
||||
const extraWalls = { countFor: () => 0, consumeEqualize: () => false };
|
||||
let linked = true;
|
||||
const revetLink = { linkedFor: () => linked, followGradeFor: () => true };
|
||||
const controls = { revetOffset, extraWalls, revetLink };
|
||||
|
||||
const round = (v) => Math.round(v * 1000) / 1000;
|
||||
const shape = (s) => {
|
||||
const link = culvertLinkFor(s, sections, () => null);
|
||||
const layout = computeCardCulvert(
|
||||
s,
|
||||
s.samples,
|
||||
null,
|
||||
revetOffset,
|
||||
undefined,
|
||||
extraWalls,
|
||||
link,
|
||||
revetLink,
|
||||
);
|
||||
return (layout?.walls ?? []).map((w) => ({
|
||||
role: w.role,
|
||||
points: w.points.map((p) => [round(p.offset), round(p.elevation)]),
|
||||
}));
|
||||
};
|
||||
|
||||
const owner = at(352.14);
|
||||
const card = at(360);
|
||||
// 소유 측점 벽을 조금 옮겨 둠 — 옆 카드가 물려받는 자리가 자동 자리가 아니게
|
||||
revetOffset.update(owner.chainage_m, "outlet", { x: 0.7, h: 2.3 });
|
||||
const linkedShape = shape(card);
|
||||
freezeLinkedWalls(card, sections, controls);
|
||||
const frozen = Object.fromEntries(
|
||||
[...stored].filter(([k]) => k.startsWith("360.00:")).map(([k, v]) => [k.slice(7), v]),
|
||||
);
|
||||
linked = false;
|
||||
const detachedShape = shape(card);
|
||||
// 푼 뒤 소유 측점을 또 옮김 — 푼 카드는 안 따라감
|
||||
revetOffset.update(owner.chainage_m, "outlet", { x: 2.0 });
|
||||
const afterOwnerMove = shape(card);
|
||||
linked = true; // 대조 — 연동이면 소유 측점 이동을 따라감(시험이 실제로 재는지)
|
||||
const linkedAfterMove = shape(card);
|
||||
process.stdout.write(
|
||||
JSON.stringify({ linkedShape, frozen, detachedShape, afterOwnerMove, linkedAfterMove }),
|
||||
);
|
||||
@@ -0,0 +1,60 @@
|
||||
/* 139-3 세월교 수평 헬퍼 — `_Cross_Ford_Geom`(computeFordLayout · fordDropOf)를 그 자리에서 트랜스파일해
|
||||
* 73-4 픽스처 세월교(173.087)로 돌림. 짝 = `test_139_3_ford_level.py`.
|
||||
*/
|
||||
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 noop = new Proxy({}, { get: () => () => "" });
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (request.startsWith("@ui/") || request.startsWith("@config/")) return noop;
|
||||
if (request.startsWith("@util/"))
|
||||
return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`));
|
||||
return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const { computeFordLayout, fordAdjustOf } = loadTs(
|
||||
path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Ford_Geom.ts"),
|
||||
);
|
||||
const sections = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "fixtures", "b06_73_4_body_guard_sections.json"), "utf8"),
|
||||
).cross_sections;
|
||||
const ford = sections.find((s) => s.ford);
|
||||
const r3 = (v) => Math.round(v * 1000) / 1000;
|
||||
const run = (stored) => {
|
||||
const layout = computeFordLayout(ford, ford.samples, fordAdjustOf(stored));
|
||||
return {
|
||||
slab: layout.sides.map((s) => r3(s.slabTopElevation)),
|
||||
pipeEnds: layout.sides.map((s) => r3(s.pipeEnd.elevation)),
|
||||
tops: layout.sides.map((s) => r3(s.top.elevation)),
|
||||
heights: layout.sides.map((s) => r3(s.heightM)),
|
||||
wallX: layout.sides.map((s) => r3(s.top.offset)),
|
||||
dropM: r3(layout.adjust.dropM),
|
||||
};
|
||||
};
|
||||
const wall = (o) => ({ heightM: null, lateralM: 0, slopeM: 0, ...o });
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
base: run(null),
|
||||
drop: run({ inlet: wall(), outlet: wall(), dropM: 0.4 }),
|
||||
up: run({ inlet: wall(), outlet: wall(), dropM: -1 }),
|
||||
lateral: run({ inlet: wall({ lateralM: 1 }), outlet: wall({ lateralM: -2 }), dropM: 0 }),
|
||||
// 옛 저장 — 유입 높이 + 0.6 · 유출 대각 1.2(= 수직 1.0) → 평균 내림 0.8 언저리
|
||||
old: run({ inlet: wall({ heightM: 2.1 }), outlet: wall({ slopeM: 1.2 }) }),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,142 @@
|
||||
/* PLAN 139-4 · 139-5 헬퍼 — 묻힌 흙막이 유출 끝 = 3도 절토선(면적에 듦) · 3도 선이 원지반을 못 만나면 복합경사.
|
||||
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. 짝 = `test_139_4_buried_outlet.py`.
|
||||
* 입력 = 박스 352.14(`b06_73_4_body_guard_sections.json`) · 배관 85.052(`b07_cross_overlay_sections.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 cache = new Map();
|
||||
const STUBS = { "@ui/ui_template_toast": { showToast: () => undefined } };
|
||||
|
||||
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,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
|
||||
for (const [prefix, dir] of Object.entries(alias))
|
||||
if (request.startsWith(prefix))
|
||||
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
|
||||
return loadTs(resolve(path.join(path.dirname(full), request)));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
const resolve = (base) =>
|
||||
[base, `${base}.ts`, path.join(base, "index.ts")].find(
|
||||
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
|
||||
) ?? `${base}.ts`;
|
||||
|
||||
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
|
||||
const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts"));
|
||||
const { computeStructureAreas } = loadTs(
|
||||
path.join(ROOT, "common_util", "common_util_cross_structure_areas.ts"),
|
||||
);
|
||||
const { PIPE_CONNECT_GRADE } = loadTs(B06("B06_Section_UI_Cross_Culvert_Const"));
|
||||
const fixture = (name) =>
|
||||
JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", name), "utf8")).cross_sections;
|
||||
const clone = (v) => JSON.parse(JSON.stringify(v));
|
||||
const r3 = (v) => Math.round(v * 1000) / 1000;
|
||||
const same = (a, b) =>
|
||||
Math.abs(a.offset - b.offset) < 1e-9 && Math.abs(a.elevation - b.elevation) < 1e-9;
|
||||
|
||||
const groundOf = (s) =>
|
||||
s.samples
|
||||
.filter((p) => p.valid !== false && typeof p.elevation_m === "number")
|
||||
.map((p) => ({ offset: p.offset_m ?? 0, elevation: p.elevation_m }))
|
||||
.sort((a, b) => a.offset - b.offset);
|
||||
const groundZ = (s, offset) => {
|
||||
const g = groundOf(s);
|
||||
for (let i = 1; i < g.length; i += 1)
|
||||
if (offset <= g[i].offset)
|
||||
return (
|
||||
g[i - 1].elevation +
|
||||
((g[i].elevation - g[i - 1].elevation) * (offset - g[i - 1].offset)) /
|
||||
(g[i].offset - g[i - 1].offset || 1)
|
||||
);
|
||||
return g[g.length - 1].elevation;
|
||||
};
|
||||
const areasOf = (s, trim) =>
|
||||
computeStructureAreas({
|
||||
designLine: s.design.design_line,
|
||||
ground: groundOf(s),
|
||||
trim,
|
||||
rockBoundaryOffsetM: null,
|
||||
});
|
||||
|
||||
/** 측점 하나 — 유출 끝 절토선 모양 · 그 선을 넣은 면적 대 뺀 면적. */
|
||||
function study(section, list) {
|
||||
const culvert = computeStoredLayouts(section, list).culvert;
|
||||
const cut = culvert.outletFill.segments.filter((g) => g.kind === "cut");
|
||||
const trim = culvert.designTrim;
|
||||
// 옛 면적 = 유출 절토선 꼭짓점(시작점 빼고)을 사면 폴리선에서 걷어 낸 것
|
||||
const drop = cut.flatMap((g) => g.points.slice(1));
|
||||
const strip = (slope) =>
|
||||
slope && { points: slope.points.filter((p) => !drop.some((q) => same(p, q))) };
|
||||
const without = { ...trim, minSlope: strip(trim.minSlope), maxSlope: strip(trim.maxSlope) };
|
||||
const withCut = areasOf(section, trim);
|
||||
const withoutCut = areasOf(section, without);
|
||||
return {
|
||||
cut: cut.map((g) => {
|
||||
const [a, b] = [g.points[0], g.points[g.points.length - 1]];
|
||||
return {
|
||||
points: g.points.length,
|
||||
grade: r3((a.elevation - g.points[1].elevation) / Math.abs(g.points[1].offset - a.offset)),
|
||||
rises: b.elevation > a.elevation,
|
||||
endGap: r3(Math.abs(groundZ(section, b.offset) - b.elevation)),
|
||||
};
|
||||
}),
|
||||
cutArea: r3(withCut?.cutAreaM2 ?? 0),
|
||||
cutAreaWithout: r3(withoutCut?.cutAreaM2 ?? 0),
|
||||
inTrim:
|
||||
drop.length > 0 &&
|
||||
[trim.minSlope, trim.maxSlope].some((sl) =>
|
||||
sl?.points.some((p) => drop.some((q) => same(p, q))),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const out = { grade3: r3(PIPE_CONNECT_GRADE) };
|
||||
// ① 박스 352.14 — 유출 흙막이 끝이 원지반에 묻힘
|
||||
const bodies = fixture("b06_73_4_body_guard_sections.json");
|
||||
out.box = study(
|
||||
bodies.find((s) => Math.abs(s.chainage_m - 352.14) < 0.6),
|
||||
bodies,
|
||||
);
|
||||
|
||||
// ② 배관 85.052 — 유출 끝 바깥 원지반을 고쳐 묻힘(내림) · 오르막 두 갈래
|
||||
const pipes = fixture("b07_cross_overlay_sections.json");
|
||||
const pipe = pipes.find((s) => s.culvert && !s.culvert.hidden_pipe);
|
||||
const base = computeStoredLayouts(pipe, pipes).culvert;
|
||||
// 끝 구간 시작점(마지막 단 다음 자리) — 그 바깥 원지반만 고침
|
||||
const tail = base.outletFill.segments[base.outletFill.segments.length - 1];
|
||||
const start = tail.points[0];
|
||||
const outward = Math.sign(tail.points[1].offset - start.offset) || 1;
|
||||
out.pipeBase = base.outletFill.segments.map((g) => g.kind ?? "fill");
|
||||
function reshaped(rise, slope) {
|
||||
const s = clone(pipe);
|
||||
for (const p of s.samples) {
|
||||
const t = ((p.offset_m ?? 0) - start.offset) * outward;
|
||||
if (t >= 0 && typeof p.elevation_m === "number")
|
||||
p.elevation_m = start.elevation + rise + slope * t;
|
||||
}
|
||||
return study(
|
||||
s,
|
||||
pipes.map((x) => (x === pipe ? s : x)),
|
||||
);
|
||||
}
|
||||
out.pipeDown = reshaped(0.3, -0.15); // 3도보다 가파르게 내림 → 3도 선이 만남
|
||||
out.pipeUp = reshaped(0.3, 0.1); // 오르막 → 3도 선이 못 만남 → 복합경사
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,151 @@
|
||||
/* 139-6 고른 설계선 [◀ ▶] 노견 확장 헬퍼 — `_Cross_Design_Pick.attachDesignLinePick` 을 그 자리에서
|
||||
* 트랜스파일해 흉내 DOM 으로 돌림. 짝 = `test_139_6_line_shift.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
class Node {
|
||||
constructor(tag) {
|
||||
this.tagName = tag;
|
||||
this.children = [];
|
||||
this.attrs = {};
|
||||
this.listeners = {};
|
||||
this.hidden = false;
|
||||
this._text = "";
|
||||
this._cls = new Set();
|
||||
const self = this;
|
||||
this.classList = {
|
||||
add: (...n) => n.forEach((c) => self._cls.add(c)),
|
||||
toggle: (c, on) => ((on ?? !self._cls.has(c)) ? self._cls.add(c) : self._cls.delete(c)),
|
||||
contains: (c) => self._cls.has(c),
|
||||
};
|
||||
}
|
||||
set className(v) {
|
||||
this._cls = new Set(String(v).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
set textContent(v) {
|
||||
this._text = String(v);
|
||||
}
|
||||
get textContent() {
|
||||
return this._text + this.children.map((c) => c.textContent).join("");
|
||||
}
|
||||
append(...kids) {
|
||||
this.children.push(...kids);
|
||||
}
|
||||
insertBefore(kid) {
|
||||
this.children.push(kid);
|
||||
}
|
||||
setAttribute(n, v) {
|
||||
this.attrs[n] = String(v);
|
||||
if (n === "class") this.className = v;
|
||||
}
|
||||
getAttribute(n) {
|
||||
return this.attrs[n] ?? null;
|
||||
}
|
||||
hasAttribute(n) {
|
||||
return n in this.attrs;
|
||||
}
|
||||
addEventListener(t, fn) {
|
||||
(this.listeners[t] ??= []).push(fn);
|
||||
}
|
||||
click() {
|
||||
(this.listeners.click ?? []).forEach((fn) => fn({ stopPropagation: () => undefined }));
|
||||
}
|
||||
}
|
||||
globalThis.document = {
|
||||
createElement: (t) => new Node(t),
|
||||
createElementNS: (_n, t) => new Node(t),
|
||||
};
|
||||
|
||||
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 req = (r) =>
|
||||
r.startsWith("@util/")
|
||||
? loadTs(path.join(ROOT, "common_util", `${r.slice(6)}.ts`))
|
||||
: loadTs(path.join(path.dirname(full), `${r}.ts`));
|
||||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const pick = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Design_Pick.ts"));
|
||||
const x = (o) => 100 - o * 12; // +offset = 화면 왼쪽
|
||||
const y = (e) => 900 - (e - 100) * 12;
|
||||
const design = {
|
||||
road_edges: {
|
||||
left: { offset_m: 2, elevation_m: 100 },
|
||||
right: { offset_m: -2, elevation_m: 100 },
|
||||
},
|
||||
ditch: null,
|
||||
ditch_side: "left",
|
||||
ditch_enabled: false,
|
||||
section_mode: "both_fill",
|
||||
};
|
||||
const line = (pts) => {
|
||||
const el = new Node("polyline");
|
||||
el.setAttribute("class", "b06-chart__design-cross");
|
||||
el.setAttribute("points", pts.map(([o, e]) => `${x(o)},${y(e)}`).join(" "));
|
||||
el.setAttribute(pick.DESIGN_LINE_ATTR, "");
|
||||
return el;
|
||||
};
|
||||
const layer = new Node("g");
|
||||
// 좌 성토 사면 · 노면 · 우 성토 사면
|
||||
layer.append(
|
||||
line([
|
||||
[6, 97],
|
||||
[2, 100],
|
||||
]),
|
||||
line([
|
||||
[2, 100],
|
||||
[-2, 100],
|
||||
]),
|
||||
line([
|
||||
[-2, 100],
|
||||
[-6, 97],
|
||||
]),
|
||||
);
|
||||
const values = { left: 0, right: 0 };
|
||||
const handle = pick.attachDesignLinePick({
|
||||
layer,
|
||||
design,
|
||||
x,
|
||||
toDisplayY: y,
|
||||
onPick: () => undefined,
|
||||
shift: {
|
||||
valueOf: (side) => values[side],
|
||||
set: (side, v) => (values[side] = v),
|
||||
},
|
||||
});
|
||||
const [left, right] = handle.label.children.filter((c) => c.tagName === "button");
|
||||
const pieceAt = (offset) =>
|
||||
handle.pieces.findIndex(
|
||||
(p) =>
|
||||
Math.min(p.from.offset, p.to.offset) <= offset &&
|
||||
offset <= Math.max(p.from.offset, p.to.offset),
|
||||
);
|
||||
const log = [];
|
||||
handle.setActive(pieceAt(4)); // 좌 사면
|
||||
left.click(); // 화면 왼쪽 = 좌측 바깥 → +0.1
|
||||
left.click();
|
||||
log.push({ ...values });
|
||||
right.click(); // 안쪽 → 0.1
|
||||
right.click();
|
||||
right.click(); // 0 아래 금지
|
||||
log.push({ ...values });
|
||||
handle.setActive(pieceAt(-4)); // 우 사면
|
||||
left.click(); // 화면 왼쪽 = 우측 안쪽 → 0 에서 멈춤
|
||||
right.click(); // 우측 바깥 → +0.1
|
||||
log.push({ ...values });
|
||||
handle.setActive(pieceAt(-4));
|
||||
const text = handle.label.textContent;
|
||||
process.stdout.write(JSON.stringify({ log, text, buttons: [left.textContent, right.textContent] }));
|
||||
@@ -0,0 +1,78 @@
|
||||
/* 139-6 노견을 넓힌 유출 쪽 관 길이 맞춤 헬퍼 — 설계선 엔진(`common_util_cross_design`)으로 노견 확장
|
||||
* 설계를 내고 배수관 기하(`_Cross_Culvert_Geom`)를 돌림. 입력 = B07 픽스처 배관 측점(85.052 · 유출 좌).
|
||||
* 표준단면 수치 = 인자 JSON 파일(파이썬 config 그대로 · 거울 시험과 같은 방식).
|
||||
* 짝 = `test_139_6_pipe_fit.py`.
|
||||
*/
|
||||
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 noop = new Proxy({}, { get: () => () => "" });
|
||||
globalThis.document = { addEventListener: () => undefined };
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
if (full.endsWith(".json")) return JSON.parse(fs.readFileSync(full, "utf8"));
|
||||
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 resolve = (base) =>
|
||||
fs.existsSync(base) && fs.statSync(base).isFile() ? base : `${base}.ts`;
|
||||
const req = (r) => {
|
||||
if (r.startsWith("@ui/") || r.startsWith("@config/")) return noop;
|
||||
if (r.startsWith("@util/")) return loadTs(resolve(path.join(ROOT, "common_util", r.slice(6))));
|
||||
return loadTs(resolve(path.join(path.dirname(full), r)));
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const { computeCrossDesign } = loadTs(
|
||||
path.join(ROOT, "common_util", "common_util_cross_design.ts"),
|
||||
);
|
||||
const { computeCulvertLayout, REVET_EMBED_DEPTH_M } = loadTs(
|
||||
path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Culvert_Geom.ts"),
|
||||
);
|
||||
const { cutLength } = loadTs(
|
||||
path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Culvert_Solve.ts"),
|
||||
);
|
||||
|
||||
const sections = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"),
|
||||
).cross_sections;
|
||||
const section = sections.find((s) => s.culvert && !s.culvert.hidden_pipe);
|
||||
const d = section.design;
|
||||
const standard = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const designOf = (extendLeft) =>
|
||||
computeCrossDesign(section.samples, d.design_elevation_m, {
|
||||
groundType: d.ground_type,
|
||||
sectionMode: d.section_mode,
|
||||
ditchSide: d.ditch_side,
|
||||
ditchType: d.ditch_type ?? "standard",
|
||||
paved: !!d.paved,
|
||||
standard,
|
||||
shoulderExtendLeftM: extendLeft,
|
||||
});
|
||||
const r3 = (v) => Math.round(v * 1000) / 1000;
|
||||
const run = (extendLeft) => {
|
||||
const design = designOf(extendLeft);
|
||||
const s = { ...section, design };
|
||||
const layout = computeCulvertLayout(s, section.samples, undefined, "L");
|
||||
const wall = layout.walls.find((w) => w.role === "outlet");
|
||||
const edge = design.road_edges.left;
|
||||
return {
|
||||
edge: r3(edge.offset_m),
|
||||
extend: design.shoulder_extend_left_m ?? 0,
|
||||
pureHeight: r3(wall.height + REVET_EMBED_DEPTH_M),
|
||||
lengthM: layout.pipe.lengthM,
|
||||
cut: r3(cutLength(layout.pipeCorners)),
|
||||
slope: r3(layout.fillSlope.ratio),
|
||||
};
|
||||
};
|
||||
process.stdout.write(JSON.stringify({ base: run(0), shifted: run(1.0), shifted2: run(1.3) }));
|
||||
@@ -188,6 +188,7 @@ const fordDeps = {
|
||||
heightFor: () => 1.5,
|
||||
nudge: () => {},
|
||||
nudgeSlope: () => {},
|
||||
dropFor: () => 0,
|
||||
reset: () => {},
|
||||
slabLengthM: () => 7.6,
|
||||
close: () => {},
|
||||
|
||||
@@ -23,11 +23,23 @@ VIEW = _read("B06_Section", "B06_Section_UI_Section_View.ts")
|
||||
|
||||
def test_범위_강조_클래스는_고름_목록갱신_카드재생성_세_길에서_칠한다():
|
||||
assert VIEW.count("b06-cross-card--span") == 2 # syncSpanHighlights · buildCrossCard
|
||||
assert re.search(r"selectMark: \(structureId\) => \{\s+spanMarkId = structureId;", VIEW)
|
||||
assert re.search(
|
||||
r"selectMark: \(structureId, span = true\) => \{\s+(//[^\n]*\s+)?spanMarkId = span \? structureId : null;",
|
||||
VIEW,
|
||||
)
|
||||
assert re.search(r"spanMarks = structures;\s+syncSpanHighlights\(\);", VIEW)
|
||||
assert 'card.classList.toggle("b06-cross-card--span", inSpan(section))' in VIEW
|
||||
|
||||
|
||||
def test_측점_고름은_카드_하나_목록_고름은_여럿():
|
||||
"""137-4 — 측점 고름 리스너는 span 끔 · 목록(노면공) 길 · 알약은 기본(여럿) 그대로."""
|
||||
cross = _read("A00_Common", "design", "design_view_cross.ts")
|
||||
assert "if (mark) sectionView.selectMark(mark, false);" in cross
|
||||
assert "markStructure: (structureId) => sectionView.selectMark(structureId)," in cross
|
||||
types = _read("B06_Section", "B06_Section_UI_Section_View_Types.ts")
|
||||
assert "selectMark: (structureId: string | null, span?: boolean) => void;" in types
|
||||
|
||||
|
||||
def test_범위_판정은_시작_종료가_다른_구간형만():
|
||||
assert "start == null || end == null || start === end" in VIEW
|
||||
|
||||
|
||||
@@ -60,7 +60,11 @@ def test_세월교_원지반을_바꿔도_바닥_그대로(out: dict, variant: s
|
||||
for name, shape in runs.items():
|
||||
assert shape == runs["base"], name
|
||||
base = runs["base"]
|
||||
assert all(side["depth"] == pytest.approx(base["pipeCover"]) for side in base["sides"])
|
||||
# 바닥판 수평(139-3) — 표고 하나 · 낮은 노견 쪽 깊이 = 관경 + 토피 · 높은 쪽은 그보다 깊음
|
||||
sides = base["sides"]
|
||||
assert len({side["slabTop"] for side in sides}) == 1
|
||||
assert min(side["depth"] for side in sides) == pytest.approx(base["pipeCover"])
|
||||
assert all(side["depth"] >= base["pipeCover"] - 1e-6 for side in sides)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("variant", ["guards", "bare"])
|
||||
|
||||
@@ -82,19 +82,26 @@ def test_걸음_반도_안_끌면_반영_없음(out) -> None:
|
||||
def test_BOX_끌기는_길이_표고_함께(out) -> None:
|
||||
assert out["box"]["left"]["move"] == {"role": "left", "screen": 0.5, "rise": -0.3}
|
||||
assert out["box"]["left"]["snap"]["leftM"] == 0.5
|
||||
assert out["box"]["right"]["move"] == {"role": "right", "screen": -0.5, "rise": 0.2}
|
||||
# 위로는 기본 자리(노면 − 복토)까지만(139-3) — 0 에서 0.2 올리기 = 그대로
|
||||
assert out["box"]["right"]["move"] == {"role": "right", "screen": -0.5, "rise": 0}
|
||||
|
||||
|
||||
def test_BOX_한계_길이_0_표고_10(out) -> None:
|
||||
"""안으로 줄이면 길이 0 에서 · 표고는 +10 m 에서 멈춤."""
|
||||
assert out["box"]["shorten"]["move"] == {"role": "left", "screen": -0.2, "rise": 0.1}
|
||||
def test_BOX_한계_길이_0_표고_0(out) -> None:
|
||||
"""안으로 줄이면 길이 0 에서 · 표고는 0(기본 자리) 위로 못 감 — 옛 +9.9 는 0 으로 내려옴(139-3)."""
|
||||
assert out["box"]["shorten"]["move"] == {"role": "left", "screen": -0.2, "rise": -9.9}
|
||||
|
||||
|
||||
def test_세월교_끌기는_좌우_사면_함께(out) -> None:
|
||||
"""사면 d 0.3 · 좌우 0.2(0.1 걸음) — 미리 보기 = 좌 0.2 + 0.3 · 아래 0.3/1.2."""
|
||||
def test_세월교_끌기는_좌우_수직_함께(out) -> None:
|
||||
"""좌우 0.5 · 바닥판 내림 0.3(수직 · 0.1 걸음 · 139-3) — 두 축이 따로."""
|
||||
case = out["ford"]["diag"]
|
||||
assert case["move"] == {"role": "inlet", "screen": 0.2, "slope": 0.3}
|
||||
assert case["snap"] == {"leftM": 0.5, "downM": 0.25, "text": "◀ 0.2m · ▼ 0.3m"}
|
||||
assert case["move"] == {"role": "inlet", "screen": 0.5, "slope": 0.3}
|
||||
assert case["snap"] == {"leftM": 0.5, "downM": 0.3, "text": "◀ 0.5m · ▼ 0.3m"}
|
||||
|
||||
|
||||
def test_세월교_기본_자리_위로는_못_감(out) -> None:
|
||||
"""내림 0.2 에서 0.5 올려도 0(기본 자리)까지만(139-3)."""
|
||||
case = out["ford"]["up"]
|
||||
assert case["move"] == {"role": "inlet", "screen": 0, "slope": -0.2}
|
||||
|
||||
|
||||
def test_세월교_노견_안쪽은_0_에서_멈춤(out) -> None:
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""PLAN 137-2 · 137-3 — B06 보라 선 고름: 직선 조각 · 가림 · 치수 · 구역 · 판정 선 · 페이지 한 칸.
|
||||
|
||||
짝: `B06_Section/B06_Section_UI_Cross_Design_Pick.ts` · `A00_Common/b_polygon_clip.ts`(B07 에서 옮김) ·
|
||||
`B06_Section_UI_Cross_View.ts`(배선 · 하나만). 실제 TS 를 `helper_137_design_pick.cjs` 가 Node 로 돌린다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
ROOT = os.path.join(HERE, "..", "..")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_137_design_pick.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _src(rel: str) -> str:
|
||||
with open(os.path.join(ROOT, rel), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_같은_방향_토막은_직선_조각_하나(out) -> None:
|
||||
"""0.5 m 토막 25개 → 성토 사면 · 노면 둘(가운데 꺾임) · 측구 · 절토 사면 = 다섯 · 거꾸로 그린 토막도 합침."""
|
||||
assert out["merge"]["segments"] == 25
|
||||
assert out["merge"]["pieces"] == [[-6.5, -2.5], [-2.5, 0], [0, 2.5], [2.5, 3], [3, 6]]
|
||||
assert out["mergeStructure"] == 2 # 구조물 선은 설계선과 안 합침
|
||||
|
||||
|
||||
def test_벽에_가린_부분은_빼고_보이는_조각만(out) -> None:
|
||||
assert out["occlude"]["pieces"] == [[3, 4], [4.5, 6]]
|
||||
assert out["occlude"]["covered"] == 0
|
||||
|
||||
|
||||
def test_치수_사면길이_수평_높이_구배(out) -> None:
|
||||
fill = out["measure"]["fill"]
|
||||
assert (fill["horizontalM"], fill["heightM"], fill["grade"]) == (3, 2, "1:1.50")
|
||||
assert abs(fill["lengthM"] - 13**0.5) < 1e-9
|
||||
assert out["measure"]["road"]["grade"] == "2.0%" # 노면 = %
|
||||
assert out["measure"]["flatSlope"] == "0.0%"
|
||||
assert out["label"]["fill"] == "사면길이 3.61m · 수평 3.00m · 높이 2.00m · 1:1.50"
|
||||
assert out["label"]["road"] == "길이 2.50m · 수평 2.50m · 높이 0.05m · 2.0%"
|
||||
|
||||
|
||||
def test_구역_도로_절토_성토(out) -> None:
|
||||
zone = out["zone"]
|
||||
assert zone["road"] == {"zone": "road", "side": "left"}
|
||||
assert zone["roadEdge"] == "road"
|
||||
assert zone["ditch"] == {"zone": "cut", "side": "left"} # 측구 = 절토
|
||||
assert zone["cutSlope"] == {"zone": "cut", "side": "left"}
|
||||
assert zone["fillSlope"] == {"zone": "fill", "side": "right"}
|
||||
assert zone["structure"] == "fill" # 구조물 트림 선 = 성토
|
||||
assert zone["rightCutDitch"] == "cut"
|
||||
assert zone["bothFill"] == "fill"
|
||||
assert zone["pieceZones"] == ["fill", "road", "road", "cut", "cut"]
|
||||
|
||||
|
||||
def test_실제_배관_측점_조각과_판정_선(out) -> None:
|
||||
real = out["real"]
|
||||
assert real["pieces"] < real["designLines"] # 토막이 조각으로 합쳐짐
|
||||
assert real["structurePieces"] > 0 # 구조물 트림 선도 고름 대상
|
||||
assert real["hits"] == real["pieces"]
|
||||
assert real["wallCount"] == 2 and real["insideWall"] == 0 # 벽 안 조각 없음
|
||||
assert real["hitsBelowStructure"] is True # 구조물 판정면이 위
|
||||
assert real["picked"] == [0] and real["stopped"] is True # 카드 고름으로 안 번짐
|
||||
assert real["activeCount"] == 1 and real["activeIndex"] == 1
|
||||
assert real["labelOn"]["hidden"] is False and real["labelOn"]["text"].startswith("사면길이 ")
|
||||
assert real["labelOff"] == {"hidden": True, "text": ""}
|
||||
assert set(real["zones"]) <= {"road", "cut", "fill"}
|
||||
|
||||
|
||||
def test_페이지_한_칸_고름(out) -> None:
|
||||
assert out["slot"] == {"a1": 2, "a2": None, "b": 1, "cleared": None}
|
||||
|
||||
|
||||
def test_카드_배선_하나만() -> None:
|
||||
"""137-3 — 선을 고르면 벽 · 본체 · 면적을 풀고 카드도 고름 · 다른 고름은 선을 풂."""
|
||||
view = _src("B06_Section/B06_Section_UI_Cross_View.ts")
|
||||
assert "const pickLine = (index: number): void => {" in view
|
||||
assert "if (!isSelected) onSelect(section.station_id);" in view
|
||||
assert view.count("dropLine();") >= 4 # 본체 · 벽 · 카드 풂 · 면적
|
||||
assert "setLineActive(pickedLineAt(section.station_id));" in view # 다시 그려도 되살림
|
||||
assert "zoomBar.prepend(linePick.label);" in view # 치수 = [+ − ⤢] 왼쪽
|
||||
css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css")
|
||||
assert ".b06-chart__design-hit.is-active {" in css
|
||||
|
||||
|
||||
def test_폴리곤_자르기는_공용_한_벌() -> None:
|
||||
b07 = _src("B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts")
|
||||
assert "function clipSegment(" not in b07 and "function pointInPolygon(" not in b07
|
||||
assert 'from "../A00_Common/b_polygon_clip"' in b07
|
||||
@@ -0,0 +1,71 @@
|
||||
"""139-1 관 보호공 높이 = 관경 + 0.5 — 화면 쪽(새 관 기본값 · 폼 읽기 옮김 · 관경 따라감).
|
||||
|
||||
- B05 `optionDefaults` → `fillPipeHeights`: 새 관 높이 칸 셋(유입 · 유출 흙막이 · 유입 집수정) = 관경 + 0.5.
|
||||
- 폼 `followPipeHeights`(둘 다 `common_util_culvert_sets.ts`): 읽을 때 옛 1.5 · 관경 > 1.0 → 관경 + 0.5 · 관경을 바꾸면 옛 규칙 값 ·
|
||||
옛 1.5 칸만 따라감(손 값 · 빈 칸 그대로).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
_RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { fillPipeHeights, followPipeHeights } from "./common_util_culvert_sets.js";
|
||||
const keys = ["pipe_diameter_mm", "inlet_revet_height_m", "outlet_revet_height_m", "inlet_basin_height_m"];
|
||||
const fresh = { pipe_diameter_mm: 1200 }; fillPipeHeights(fresh, keys);
|
||||
const noPipe = {}; fillPipeHeights(noPipe, keys);
|
||||
const st = (d, i, o) => ({ pipe: [{ diameter: d }], inlet: [{ height: i }], outlet: [{ height: o }] });
|
||||
const read = st("1200", "1.5", "1.6"); followPipeHeights(read, "1200");
|
||||
const small = st("800", "1.5", ""); followPipeHeights(small, "800");
|
||||
const change = st("1200", "1.7", "2.2"); followPipeHeights(change, "1500", "1200");
|
||||
const old = st("1000", "1.5", ""); followPipeHeights(old, "800", "1000");
|
||||
writeFileSync(process.argv[2], JSON.stringify({ fresh, noPipe, read, small, change, old }));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_화면_높이_규칙(tmp_path: Path) -> None:
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(ROOT / "common_util" / "common_util_culvert_sets.ts"),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--skipLibCheck",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
capture_output=True,
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
result = tmp_path / "out.json"
|
||||
run = subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(result)], capture_output=True, text=True
|
||||
)
|
||||
assert run.returncode == 0, run.stderr[-2000:]
|
||||
got = json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
for key in ("inlet_revet_height_m", "outlet_revet_height_m", "inlet_basin_height_m"):
|
||||
assert got["fresh"][key] == pytest.approx(1.7), key # Φ1200 → 1.7
|
||||
assert "inlet_revet_height_m" not in got["noPipe"] # 관경 없음 = 안 채움
|
||||
assert got["read"]["inlet"][0]["height"] == "1.7" # 옛 1.5 · Φ1200 → 1.7
|
||||
assert got["read"]["outlet"][0]["height"] == "1.6" # 손 값 그대로
|
||||
assert got["small"]["inlet"][0]["height"] == "1.5" # Φ800 의 1.5 는 그대로
|
||||
assert got["change"]["inlet"][0]["height"] == "2" # 옛 규칙 값(1.7) → 새 규칙(2.0)
|
||||
assert got["change"]["outlet"][0]["height"] == "2.2" # 손 값 그대로
|
||||
assert got["old"]["inlet"][0]["height"] == "1.3" # 옛 1.5 → Φ800 규칙
|
||||
assert got["old"]["outlet"][0]["height"] == "" # 빈 칸 그대로
|
||||
@@ -0,0 +1,55 @@
|
||||
"""139-2 관보호공 연동 풀림 — 옆 측점 벽 0.1 m 걸음 · 푸는 순간 4축 굳힘 · 다시 연동 = 지움.
|
||||
|
||||
화면 TS 를 그 자리에서 돌린 값(`helper_139_2_link_freeze.cjs` · 73-4 픽스처 박스 352.14 흙막이 · 옆 측점 360).
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPER = Path(__file__).parent / "helper_139_2_link_freeze.cjs"
|
||||
NODE_MODULES = ROOT / "config" / "node_modules"
|
||||
|
||||
pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
done = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def test_푸는_순간_4축이_차고_그림은_그대로(out: dict) -> None:
|
||||
assert set(out["frozen"]) == {"inlet", "outlet"}
|
||||
for adjust in out["frozen"].values():
|
||||
assert isinstance(adjust["x"], (int, float))
|
||||
assert isinstance(adjust["d"], (int, float)) # d 가 차서 자동 자리 · 정수 맞춤 안 탐
|
||||
assert adjust["h"] > 0 and adjust["m"]
|
||||
assert out["frozen"]["outlet"]["h"] == pytest.approx(2.3) # 소유 측점 높이 그대로
|
||||
assert out["detachedShape"] == out["linkedShape"] # 튐 없음
|
||||
|
||||
|
||||
def test_푼_뒤엔_소유_측점_이동을_안_따라감(out: dict) -> None:
|
||||
assert out["afterOwnerMove"] == out["detachedShape"]
|
||||
assert out["linkedAfterMove"] != out["linkedShape"] # 대조 — 연동이면 따라감
|
||||
|
||||
|
||||
def test_한_길_걸음_다시_연동_지움() -> None:
|
||||
stc = (ROOT / "B06_Section" / "B06_Section_UI_Page_Station_Controls.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
vs = (ROOT / "B06_Section" / "B06_Section_UI_Cross_View_Structure.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
# 카드 [연동] · 좌측 [연동] 모두 제어기 setLinked 로 — 풀면 굳힘 · 걸면 지움
|
||||
assert "if (linked) dropLinkedAdjusts(chainageM);" in stc
|
||||
assert "freezeLinkedWalls(section" in stc
|
||||
assert "setLinked: (linked) => ctx.revetLink?.setLinked(ctx.section.chainage_m, linked)" in vs
|
||||
# 풀린 옆 측점 카드 = 0.1 m 걸음
|
||||
assert "(ctx.isLinked && ctx.revetLink?.linkedFor(ctx.section) === false)" in vs
|
||||
@@ -0,0 +1,55 @@
|
||||
"""139-3 세월교 수평 — 바닥판 표고 하나 · 좌우만(도로 쪽 금지) · 내림 = 양쪽 한 값 · 아래로만 · 옛 값 옮김.
|
||||
|
||||
화면 TS(`_Cross_Ford_Geom`)를 그 자리에서 돌린 값(`helper_139_3_ford_level.cjs` · 73-4 픽스처 세월교 173.087).
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPER = Path(__file__).parent / "helper_139_3_ford_level.cjs"
|
||||
NODE_MODULES = ROOT / "config" / "node_modules"
|
||||
|
||||
pytestmark = pytest.mark.skipif(not NODE_MODULES.is_dir(), reason="프론트엔드 의존성 미설치")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
done = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def test_바닥판_하나_관_수평_벽높이는_결과(out: dict) -> None:
|
||||
base = out["base"]
|
||||
assert base["slab"][0] == base["slab"][1] == base["pipeEnds"][0] == base["pipeEnds"][1]
|
||||
# 낮은 노견 쪽 = 관경 + 토피(1.5) · 높은 쪽은 노견 차만큼 더 김 · 벽 윗면 = 그 쪽 노견
|
||||
assert min(base["heights"]) == pytest.approx(1.5)
|
||||
assert base["heights"][1] - base["heights"][0] == pytest.approx(
|
||||
base["tops"][1] - base["tops"][0], abs=1e-3
|
||||
)
|
||||
|
||||
|
||||
def test_내림은_양쪽_한_값_아래로만(out: dict) -> None:
|
||||
assert out["drop"]["slab"] == [pytest.approx(out["base"]["slab"][0] - 0.4)] * 2
|
||||
assert out["drop"]["tops"] == out["base"]["tops"] # 벽 윗면은 노견에 그대로
|
||||
assert out["up"] == out["base"] # 기본 자리 위 금지 — 음수 내림 = 0
|
||||
|
||||
|
||||
def test_좌우만_도로_쪽_금지(out: dict) -> None:
|
||||
lat, base = out["lateral"], out["base"]
|
||||
assert lat["slab"] == base["slab"]
|
||||
assert abs(lat["wallX"][0] - base["wallX"][0]) == pytest.approx(1.0) # 바깥 1 m
|
||||
assert lat["wallX"][1] == base["wallX"][1] # 안쪽 −2 = 0 에서 멈춤
|
||||
|
||||
|
||||
def test_옛_값은_평균_내림으로_옮김(out: dict) -> None:
|
||||
"""유입 높이 2.1(기본 1.5 + 0.6) · 유출 대각 1.2(수직 1.0) + 노견 차 0.12 → (0.6 + 0.88) / 2 = 0.74."""
|
||||
old = out["old"]
|
||||
assert old["dropM"] == pytest.approx(0.74, abs=1e-3)
|
||||
assert old["slab"][0] == old["slab"][1]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""PLAN 139-4 · 139-5 — 묻힌 흙막이 유출 끝 = 3도 수평 절토선(보라 · 절토 면적) · 못 만나면 복합경사.
|
||||
|
||||
짝: `B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts`(끝 구간 규칙) · `_Culvert_Tiers.ts`(채널 입력) ·
|
||||
`_Culvert_Geom.ts`(유출 절토선 면적에 넣음) · `_Culvert.ts`(그림 · 툴팁). 실제 TS 를 `helper_139_4_buried_outlet.cjs` 가 돌린다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
ROOT = os.path.join(HERE, "..", "..")
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", os.path.join(HERE, "helper_139_4_buried_outlet.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _src(rel: str) -> str:
|
||||
with open(os.path.join(ROOT, rel), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["box", "pipeDown"])
|
||||
def test_묻힌_유출_끝은_3도_절토선_면적에_듦(out: dict, case: str) -> None:
|
||||
"""세월교 · 박스 흙막이도 배관 관보호공도 같은 3도 선 · 원지반 교점에서 끝 · 절토 면적 > 0."""
|
||||
study = out[case]
|
||||
assert study["cut"] == [{"points": 2, "grade": out["grade3"], "rises": False, "endGap": 0}]
|
||||
assert study["inTrim"] is True
|
||||
assert study["cutArea"] - study["cutAreaWithout"] > 0.1
|
||||
|
||||
|
||||
def test_3도_선이_원지반을_못_만나면_복합경사(out: dict) -> None:
|
||||
"""유출 바깥이 오르막 — 그때만 절토 경사(1:1.0)로 원지반까지."""
|
||||
study = out["pipeUp"]
|
||||
assert len(study["cut"]) == 1
|
||||
tail = study["cut"][0]
|
||||
assert tail["rises"] is True and tail["grade"] == -1 and tail["endGap"] == 0
|
||||
assert study["cutArea"] > study["cutAreaWithout"]
|
||||
|
||||
|
||||
def test_성토_끝은_그대로(out: dict) -> None:
|
||||
assert out["pipeBase"] == ["fill", "fill"] # 묻히지 않은 기본 = 성토부선 그대로
|
||||
|
||||
|
||||
def test_유출_절토선은_그리고_면적에_넣음() -> None:
|
||||
culvert = _src("B06_Section/B06_Section_UI_Cross_Culvert.ts")
|
||||
assert 'if (segment.kind === "cut" && !culvert.body) continue;' not in culvert
|
||||
assert 'planLine(segment.points, cutLineTip("유출부", segment));' in culvert
|
||||
geom = _src("B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts")
|
||||
assert "extendTrimSlope(drawnFillPoints(extrasForArea, true), outletInfo.outward);" in geom
|
||||
# 집수정 계류측 3도 선은 그림만(면적 밖) — 세월교 · 박스 계류측만 면적
|
||||
assert (
|
||||
"extendTrimSlope(drawnFillPoints(basinExtrasForArea, !!body), inletInfo.outward);" in geom
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""139-6 고른 설계선 [◀ ▶] — 고른 조각 쪽 노견 확장 0.1 m 걸음 · 노폭 안쪽 금지(0 에서 멈춤).
|
||||
|
||||
화면 TS(`_Cross_Design_Pick`)를 흉내 DOM 으로 돌린 값(`helper_139_6_line_shift.cjs`).
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HELPER = Path(__file__).parent / "helper_139_6_line_shift.cjs"
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
done = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8"
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def test_화면_방향대로_그_쪽_노견만(out: dict) -> None:
|
||||
first, back, right = out["log"]
|
||||
assert first == {"left": 0.2, "right": 0} # 좌 사면 고름 · ◀ 두 번 = 좌 바깥 0.2
|
||||
assert back == {"left": 0, "right": 0} # ▶ 세 번 = 0 에서 멈춤(노폭 안쪽 금지)
|
||||
assert right == {"left": 0, "right": 0.1} # 우 사면 · ◀ = 안쪽 0 · ▶ = 바깥 0.1
|
||||
|
||||
|
||||
def test_치수_줄에_노견_확장_표기(out: dict) -> None:
|
||||
assert "노견 +0.1m" in out["text"]
|
||||
assert out["buttons"] == ["◀", "▶"]
|
||||
|
||||
|
||||
def test_페이지가_같은_창구로_세션_재계산() -> None:
|
||||
sync = (ROOT / "B06_Section" / "B06_Section_UI_Page_Design_Sync.ts").read_text(encoding="utf-8")
|
||||
assert (
|
||||
"writeShoulderExtend(projectId, currentRouteId, chainageM, shift.side, shift.valueM)"
|
||||
in sync
|
||||
)
|
||||
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
|
||||
assert "patch.shoulder_extend_left_m = sides.left" in persist
|
||||
@@ -0,0 +1,60 @@
|
||||
"""139-6 노견을 넓힌 유출 쪽 관 길이 자리 — 사면 우선(물매 1:1.2 그대로) + 유출 벽 높이 역산(0.1 m).
|
||||
|
||||
설계선 엔진으로 노견 확장 설계를 내고 배수관 기하를 돌린 값(`helper_139_6_pipe_fit.cjs` · B07 픽스처
|
||||
배관 85.052 · 유출 = 좌). 안 넓힌 측점은 종전 맞춤 그대로(나눔 전 코드와 같은 값 · 5.772 → 표기 6).
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
|
||||
|
||||
HELPER = Path(__file__).parent / "helper_139_6_pipe_fit.cjs"
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules").is_dir(), reason="프론트엔드 의존성 미설치"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out(tmp_path_factory) -> dict:
|
||||
standard = tmp_path_factory.mktemp("std") / "standard.json"
|
||||
standard.write_text(json.dumps(STANDARD_CROSS_SECTION), encoding="utf-8")
|
||||
done = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER), str(standard)],
|
||||
cwd=str(ROOT),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def test_안_넓힌_측점은_종전_그대로(out: dict) -> None:
|
||||
base = out["base"]
|
||||
assert (base["edge"], base["pureHeight"], base["lengthM"]) == (2, 2, 6)
|
||||
assert base["cut"] == pytest.approx(5.772)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name, extend", [("shifted", 1.0), ("shifted2", 1.3)])
|
||||
def test_넓힌_쪽은_물매_그대로_높이_역산(out: dict, name: str, extend: float) -> None:
|
||||
case = out[name]
|
||||
assert case["edge"] == pytest.approx(2 + extend) and case["extend"] == extend
|
||||
assert case["slope"] == pytest.approx(1.2, abs=0.05) # 사면 우선 — 물매 그대로
|
||||
assert round(case["pureHeight"] * 10) == pytest.approx(case["pureHeight"] * 10) # 0.1 m 눈금
|
||||
assert case["lengthM"] - case["cut"] < 0.1 # 눈금 탓 남는 차만 표기 올림
|
||||
assert float(case["lengthM"]).is_integer()
|
||||
|
||||
|
||||
def test_높이가_실제로_바뀜(out: dict) -> None:
|
||||
"""대조 — 1.3 m 넓히면 유출 벽 높이가 2.0 → 1.8 로 역산됨(시험이 높이 길을 실제로 잼)."""
|
||||
assert out["shifted2"]["pureHeight"] == pytest.approx(1.8)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""139-6 [저장] 때 서버가 모든 측점 설계선을 저장 입력으로 다시 냄 — 노견 확장이 설계선에 들어감.
|
||||
|
||||
그전에는 포장 · 세월교 · 사토장 측점만 다시 냈고 보통 측점은 브라우저 설계를 받아 적음
|
||||
(`B07` 이 저장된 선을 그려 쉬프트가 안 보였을 자리).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Engine_Design import ( # noqa: E402
|
||||
compute_cross_design,
|
||||
stored_shoulder_extend,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_Design import enforce_design_lines # noqa: E402
|
||||
|
||||
|
||||
def _ground(slope: float) -> list[dict]:
|
||||
return [
|
||||
{"offset_m": o / 2, "elevation_m": round(100.0 + slope * o / 2, 4), "valid": True}
|
||||
for o in range(-24, 25)
|
||||
]
|
||||
|
||||
|
||||
LONG = {
|
||||
"design_profiles": [
|
||||
{
|
||||
"samples": [
|
||||
{"chainage_m": 0, "elevation_m": 100.4},
|
||||
{"chainage_m": 200, "elevation_m": 100.4},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_저장값_없으면_0_음수_0() -> None:
|
||||
assert stored_shoulder_extend(None) == {
|
||||
"shoulder_extend_left_m": 0.0,
|
||||
"shoulder_extend_right_m": 0.0,
|
||||
}
|
||||
got = stored_shoulder_extend({"shoulder_extend_left_m": 0.7, "shoulder_extend_right_m": -1})
|
||||
assert got == {"shoulder_extend_left_m": 0.7, "shoulder_extend_right_m": 0.0}
|
||||
|
||||
|
||||
def test_저장_때_노견_확장_설계선이_다시_나옴(tmp_path: Path) -> None:
|
||||
samples = _ground(0.35)
|
||||
old = compute_cross_design(samples, 100.4, ground_type="soil", section_mode="left_cut")
|
||||
# 브라우저 patch 가 사용자 값만 얹은 꼴 — 설계선 · 노견 끝은 옛 값 그대로
|
||||
stored = {
|
||||
**old,
|
||||
"shoulder_extend_left_m": 0.7,
|
||||
"revet_adjust": {"outlet": {"x": 1.0, "d": None, "h": None, "m": None}},
|
||||
}
|
||||
sections = [{"chainage_m": 100.0, "samples": samples, "design": stored}]
|
||||
assert enforce_design_lines(LONG, sections, tmp_path) == 1
|
||||
design = sections[0]["design"]
|
||||
assert design["road_edges"]["left"]["offset_m"] == old["road_edges"]["left"]["offset_m"] + 0.7
|
||||
assert design["road_edges"]["right"] == old["road_edges"]["right"]
|
||||
assert design["shoulder_extend_left_m"] == 0.7
|
||||
assert design["revet_adjust"] == stored["revet_adjust"] # 사용자 값 승계
|
||||
assert design["design_line"] != old["design_line"]
|
||||
|
||||
|
||||
def test_서버_재계산이_먼저_돌고_미교차_판정은_그_뒤() -> None:
|
||||
src = (PROJECT_ROOT / "B06_Section" / "B06_Section_Server_Calc_Prebuild.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert src.index("enforce_design_lines, detail.get") < src.index("unclosed = any(")
|
||||
@@ -8,6 +8,7 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -87,13 +88,16 @@ def test_흙막이_끝_절토는_수평공간_뒤(shape: dict) -> None:
|
||||
assert tail["flat"] == pytest.approx(0, abs=1e-6)
|
||||
assert tail["berm"] == pytest.approx(1.0, abs=1e-3)
|
||||
else:
|
||||
# 유출은 수평공간 없음 — 묻히면 첫 꺾임부터 바로 절토(89-4)
|
||||
assert tail["flat"] > 0
|
||||
# 유출 = 묻히면 3도 내림 절토선 하나로 원지반까지(139-4)
|
||||
assert tail["points"] == 2
|
||||
assert tail["flat"] == pytest.approx(
|
||||
-tail["berm"] * math.tan(math.radians(3)), abs=2e-3
|
||||
)
|
||||
for tail in shape["cutBerm2"]:
|
||||
if tail["role"] == "inlet":
|
||||
assert tail["berm"] == pytest.approx(2.0, abs=1e-3)
|
||||
else:
|
||||
assert tail["flat"] > 0
|
||||
assert tail["points"] == 2 and tail["flat"] < 0 # 유출 수평공간 칸 무시 · 3도 선
|
||||
|
||||
|
||||
def test_유입_수평공간_시작은_본체_바닥(shape: dict) -> None:
|
||||
@@ -105,11 +109,12 @@ def test_유입_수평공간_시작은_본체_바닥(shape: dict) -> None:
|
||||
def test_관_보호공_기본_자리는_제일_위(shape: dict) -> None:
|
||||
pipe = shape["pipe"]
|
||||
assert pipe is not None
|
||||
assert pipe["d"]["inlet"] == pytest.approx(0, abs=1e-6)
|
||||
# 벽 윗면 = 관 윗면(139-1) · 관 위 토피 0.5 → 유입 벽 윗면 = 노견 − 0.5 = 사면 d 0.6(1:1.2)
|
||||
assert pipe["d"]["inlet"] == pytest.approx(0.6, abs=1e-6)
|
||||
# 유출은 관 m 단위 맞춤이 사면 아래로 민 만큼만 내려감(종전 기본 = 경사길이 4 m · d 3.07)
|
||||
assert pipe["d"]["outlet"] < 1.0
|
||||
inlet = next(w for w in pipe["walls"] if w["role"] == "inlet")
|
||||
assert inlet["topBelowEdge"] == pytest.approx(0, abs=0.05)
|
||||
assert inlet["topBelowEdge"] == pytest.approx(0.5, abs=0.05)
|
||||
assert float(pipe["lengthM"]).is_integer()
|
||||
|
||||
|
||||
|
||||
@@ -96,15 +96,20 @@ def _knee(cut: dict) -> bool:
|
||||
def test_흙막이_수평공간_뒤_절토_복합경사(fit: dict) -> None:
|
||||
tails = fit["guardTails"]
|
||||
assert tails and all(cut["endGap"] < 0.01 for cut in tails)
|
||||
# 유입 = 수평공간 먼저 · 유출 = 수평공간 없이 바로 절토(89-4)
|
||||
# 유입 = 3도 선이 못 만나 수평공간 뒤 복합경사 · 유출 = 묻히면 3도 내림 선 하나(139-4)
|
||||
for cut in tails:
|
||||
assert (cut["legs"][0]["ratio"] is None) == (cut["role"] == "inlet")
|
||||
assert cut["legs"][0]["ratio"] is None
|
||||
assert (len(cut["legs"]) == 1) == (cut["role"] == "outlet")
|
||||
assert sum(_knee(cut) for cut in tails) >= 2
|
||||
|
||||
|
||||
def test_박스_끝_세월교_측벽_끝_절토_복합경사(fit: dict) -> None:
|
||||
# 세월교 바닥판 수평(139-3) — 높은 노견 쪽 끝은 암까지 안 닿는 짧은 토사 절토일 수 있음
|
||||
for cuts in (fit["boxEnd"], fit["fordEnd"]):
|
||||
assert cuts and all(_knee(cut) and cut["endGap"] < 0.01 for cut in cuts)
|
||||
assert cuts and all(cut["endGap"] < 0.01 for cut in cuts)
|
||||
assert any(_knee(cut) for cut in cuts)
|
||||
rock = [c for c in cuts if any(leg["ratio"] == 0.4 for leg in c["legs"])]
|
||||
assert all(_knee(cut) for cut in rock)
|
||||
|
||||
|
||||
def test_절토_쪽_배관_유출_벽_뒤_절토사면_살아남(fit: dict) -> None:
|
||||
|
||||
@@ -155,10 +155,10 @@ def test_흙막이_형태_높이마다_길이(root):
|
||||
# 집수정 유입은 흙막이로 안 셈 · 유출만
|
||||
assert _cell(out, 1567, "pipe:revet|돌쌓기(찰)|2.0") is None
|
||||
assert _cell(out, 1567, "pipe:revet|돌쌓기(메)|2.0") == 10
|
||||
# 빈 높이 = 횡단도 관경 최소(Φ800 → 1.8) · 정의 기본 1.5 아님 · 빈 형태 = 미입력
|
||||
assert _cell(out, 1790, "pipe:revet|-|1.8") == 10
|
||||
head = next(c for c in out["columns"] if c["id"] == "pipe:revet|-|1.8")
|
||||
assert head["group"][1:] == ["미입력", "H=1.8"]
|
||||
# 빈 높이 = 횡단도 관경 기준(Φ800 → 1.3 · 139-1) · 빈 형태 = 미입력
|
||||
assert _cell(out, 1790, "pipe:revet|-|1.3") == 10
|
||||
head = next(c for c in out["columns"] if c["id"] == "pipe:revet|-|1.3")
|
||||
assert head["group"][1:] == ["미입력", "H=1.3"]
|
||||
|
||||
|
||||
def test_돌붙임은_형식_면적이_다_있을_때만(root):
|
||||
|
||||
@@ -34,7 +34,9 @@ def test_새_관만_기본값_빈_키에만():
|
||||
fresh = PipePoint(chainage_m=10.0, source="stream", options={"pipe_diameter_mm": 800})
|
||||
basins._apply_new_defaults([fresh])
|
||||
# 추천(먼저 들어간 값)이 이김 · 집수정 모양 L 도 실제 값(54-39)
|
||||
expected = {**_defaults("pipe"), "pipe_diameter_mm": 800, "inlet_structure": "L"}
|
||||
# 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · Φ800 → 1.3)
|
||||
heights = dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.3)
|
||||
expected = {**_defaults("pipe"), "pipe_diameter_mm": 800, "inlet_structure": "L", **heights}
|
||||
assert fresh.options == expected
|
||||
assert fresh.options["pipe_kind"] == "파형강관" and fresh.options["inlet_type"] == "집수정"
|
||||
box = PipePoint(chainage_m=20.0, source="stream", facility="box_culvert")
|
||||
@@ -64,7 +66,8 @@ def test_원본에_있던_관은_빈칸_그대로(monkeypatch):
|
||||
by = {p.chainage_m: p for p in points}
|
||||
assert by[100.0].options is None # 옛 빈칸 그대로
|
||||
# 새로 보충된 관 = 기본값 + 집수정 모양 L(54-39)
|
||||
assert by[300.0].options == {**_defaults("pipe"), "inlet_structure": "L"}
|
||||
heights = dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.5) # 기본 관경 1000 → 1.5(139-1)
|
||||
assert by[300.0].options == {**_defaults("pipe"), "inlet_structure": "L", **heights}
|
||||
|
||||
|
||||
def test_월류_폭_기본값은_등록부_엔진과_같음():
|
||||
|
||||
@@ -466,11 +466,13 @@ def test_pipe_accessory_options_defined():
|
||||
"outlet_pitching_area_m2",
|
||||
):
|
||||
assert gone not in options, f"pipe.{gone}는 보호공으로 합쳐졌다"
|
||||
# 기본 치수 — 길이 10m · 높이 1.5(10-6 배관 기본값 표 · 54-36 · 근거 default_basis).
|
||||
# 기본 치수 — 길이 10m · 높이 = 관경 + 0.5(정의 고정값 없음 · 화면 · 서버가 관경으로 채움 · 139-1).
|
||||
for side in ("inlet", "outlet"):
|
||||
assert options[f"{side}_revet_length_m"].default == 10
|
||||
assert options[f"{side}_revet_height_m"].default == 1.5
|
||||
assert options[f"{side}_revet_height_m"].default is None
|
||||
assert "관경 + 0.5" in options[f"{side}_revet_height_m"].default_basis
|
||||
assert "관경 기준" in options[f"{side}_revet_height_m"].empty_means
|
||||
assert options["inlet_basin_height_m"].default is None
|
||||
# 유무·길이·관경·유입 구조 = b05, 형태·치수 제원 = detail.
|
||||
for key in ("inlet_type", "inlet_revet_length_m", "outlet_revet_length_m"):
|
||||
assert options[key].phase == "b05", f"pipe.{key}는 b05 phase"
|
||||
|
||||
@@ -178,6 +178,32 @@ CASES = [
|
||||
"spoil_fill": {"side": "left", "width_m": 8.0, "slope_ratio_n": 2.0},
|
||||
},
|
||||
},
|
||||
{
|
||||
# 설계선 쉬프트(139-6) — 쪽마다 노견 확장 · 측구 쪽(절토)과 성토 쪽을 같이 넓힘
|
||||
"name": "노견 확장 좌 0.7 · 우 1.2",
|
||||
"samples": _ground(0.35),
|
||||
"design_elevation_m": 100.4,
|
||||
"options": {
|
||||
"ground_type": "soil",
|
||||
"section_mode": "left_cut",
|
||||
"ditch_side": "left",
|
||||
"shoulder_extend_left_m": 0.7,
|
||||
"shoulder_extend_right_m": 1.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
# 노견 끝 근처에서 지반이 설계면을 넘는 자리 — 단면유형 · 측구 판정은 옮기기 전 노견에서
|
||||
"name": "노견 확장 — 판정은 옮기기 전 노견",
|
||||
"samples": _ground(-0.05),
|
||||
"design_elevation_m": 100.0,
|
||||
"options": {
|
||||
"ground_type": "soil",
|
||||
"section_mode": "right_cut",
|
||||
"ditch_side": "right",
|
||||
"shoulder_extend_right_m": 3.0,
|
||||
"shoulder_extend_left_m": 2.5,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
_TS_RUNNER = """
|
||||
@@ -198,6 +224,8 @@ const camel = {
|
||||
plan_radius_m: "planRadiusM",
|
||||
curve_outer_side: "curveOuterSide",
|
||||
curve_widening_m: "curveWideningM",
|
||||
shoulder_extend_left_m: "shoulderExtendLeftM",
|
||||
shoulder_extend_right_m: "shoulderExtendRightM",
|
||||
};
|
||||
// 사토장 제원만 칸 이름이 다르다 — 파이썬은 snake, TS 는 camel 이다.
|
||||
const spoilCamel = { side: "side", width_m: "widthM", slope_ratio_n: "slopeRatioN" };
|
||||
|
||||
@@ -43,14 +43,14 @@ def test_empty_options_have_no_members():
|
||||
|
||||
|
||||
def test_saved_values_drive_members():
|
||||
"""고른 값만 부재 — 빈 높이 = 관경 기준 최소(Ø1000 → 2.0) · 빈 형태 = 없음."""
|
||||
"""고른 값만 부재 — 빈 높이 = 관경 기준(Ø1000 → 1.5 · 139-1) · 빈 형태 = 없음."""
|
||||
spec = _culvert_set({"inlet_type": "집수정", "inlet_structure": "L", "outlet_type": "기슭막이"})
|
||||
assert spec["inlet"]["basin_form"] == "L"
|
||||
assert spec["inlet"]["basin_height_m"] is None
|
||||
part = spec["outlet"]
|
||||
assert part["structure"] == "기슭막이"
|
||||
assert part["revet_form"] is None
|
||||
assert part["revet_height_m"] == 2.0
|
||||
assert part["revet_height_m"] == 1.5
|
||||
assert part["face_slope"] == REVET_FACE_SLOPE
|
||||
# 옛 보호공(apron) 칸은 걷음 — 유출 보호는 도수로/돌붙임 띠(90-2)
|
||||
assert "apron_length_m" not in part
|
||||
@@ -63,7 +63,7 @@ def test_basin_inlet_skips_revet_and_apron():
|
||||
assert "revet_height_m" not in spec["inlet"]
|
||||
# 유출측은 그대로 세트를 갖춘다.
|
||||
assert spec["outlet"]["structure"] == "기슭막이"
|
||||
assert spec["outlet"]["revet_height_m"] == 2.0 # 빈 높이 = 관경 기준 최소
|
||||
assert spec["outlet"]["revet_height_m"] == 1.5 # 빈 높이 = 관경 기준(139-1)
|
||||
|
||||
|
||||
def test_string_diameter_and_custom_height():
|
||||
@@ -75,6 +75,29 @@ def test_string_diameter_and_custom_height():
|
||||
assert spec["outlet"]["revet_height_m"] == 1.5
|
||||
|
||||
|
||||
def test_old_default_moves_for_large_pipe():
|
||||
"""옛 기본 1.5 · 관경 > 1.0 = 관경 + 0.5 로 읽음(139-1) · 관경 ≤ 1.0 · 다른 값은 그대로."""
|
||||
big = _culvert_set(
|
||||
{
|
||||
"pipe_diameter_mm": 1200,
|
||||
"inlet_type": "집수정",
|
||||
"inlet_basin_height_m": 1.5,
|
||||
"outlet_type": "기슭막이",
|
||||
"outlet_revet_height_m": 1.5,
|
||||
}
|
||||
)
|
||||
assert big["outlet"]["revet_height_m"] == 1.7
|
||||
assert big["inlet"]["basin_height_m"] == 1.7
|
||||
small = _culvert_set(
|
||||
{"pipe_diameter_mm": 800, "outlet_type": "기슭막이", "outlet_revet_height_m": 1.5}
|
||||
)
|
||||
assert small["outlet"]["revet_height_m"] == 1.5
|
||||
hand = _culvert_set(
|
||||
{"pipe_diameter_mm": 1500, "outlet_type": "기슭막이", "outlet_revet_height_m": 1.6}
|
||||
)
|
||||
assert hand["outlet"]["revet_height_m"] == 1.6
|
||||
|
||||
|
||||
def test_chute_thickness_cm_to_m():
|
||||
"""도수로/돌붙임(90-2) — 구조를 고르면 `chute` · 두께 cm → m · 빈 두께 30 cm · 안 고르면 없음."""
|
||||
assert "chute" not in _culvert_set({"chute_add_1_thickness": 40})
|
||||
|
||||
@@ -23,13 +23,14 @@ import { buildOutletExtras } from "./B06_Section_UI_Cross_Culvert_Extra.js";
|
||||
const spec = { revet_form: "찰쌓기", revet_height_m: 1.5 };
|
||||
const pipe = (h) => revetWallSpec(spec, { h, m: null }, false, 0.8).pureHeight;
|
||||
const own = (h) => revetWallSpec(spec, { h, m: null }, true, 0).pureHeight;
|
||||
const blank = revetWallSpec({ revet_form: "찰쌓기" }, { h: null, m: null }, false, 0.8).pureHeight;
|
||||
const extras = buildOutletExtras({
|
||||
start: { offset: 10, elevation: 95 }, startBottomElevation: 94.5, outward: 1,
|
||||
groundAt: () => 80, limitOffset: 40,
|
||||
adjusts: [{ x: 0, d: 0, h: 0.3, m: null }],
|
||||
});
|
||||
writeFileSync(process.argv[2], JSON.stringify({
|
||||
pipeLow: pipe(0.7), pipeAuto: pipe(null), pipeZero: pipe(0),
|
||||
pipeLow: pipe(0.7), pipeAuto: pipe(null), pipeBlank: blank, pipeZero: pipe(0),
|
||||
ownLow: own(0.3), ownNeg: own(-1),
|
||||
extraCount: extras.walls.length,
|
||||
extraPure: extras.walls.map((w) => w.topBack.elevation - w.bottomBack.elevation),
|
||||
@@ -74,7 +75,8 @@ def test_높이는_입력_그대로_0이하만_멈춤(tmp_path: Path) -> None:
|
||||
got = json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
assert got["pipeLow"] == pytest.approx(0.7), "관 흙막이가 관경 기준 최소로 되돌아감"
|
||||
assert got["pipeAuto"] == pytest.approx(1.8), "빈 칸 기본값(관경 + 1.0) 틀림"
|
||||
assert got["pipeAuto"] == pytest.approx(1.5), "그림이 칸 값(1.5)을 안 읽음(139-1)"
|
||||
assert got["pipeBlank"] == pytest.approx(1.3), "빈 칸 기본값(관경 + 0.5 · 139-1) 틀림"
|
||||
assert got["pipeZero"] == pytest.approx(0.1), "0 이하가 그림 하한에서 안 멈춤"
|
||||
assert got["ownLow"] == pytest.approx(0.3), "흙막이 0.3 이 근입 기준 최소로 되돌아감"
|
||||
assert got["ownNeg"] == pytest.approx(0.1), "음수 높이가 그림 하한에서 안 멈춤"
|
||||
|
||||
@@ -26,18 +26,33 @@ def _ts_const(source: str, name: str) -> float:
|
||||
|
||||
|
||||
def test_상수_셋이_TS_와_같다() -> None:
|
||||
for name in ("REVET_FREEBOARD_M", "REVET_HEIGHT_STEP_M", "REVET_EMBED_DEPTH_M"):
|
||||
for name in ("REVET_HEIGHT_STEP_M", "REVET_EMBED_DEPTH_M"):
|
||||
assert getattr(culvert, name) == _ts_const(TS, name) == _ts_const(SETS, name), name
|
||||
assert culvert.OLD_PIPE_WALL_DEFAULT_M == _ts_const(SETS, "OLD_PIPE_WALL_DEFAULT_M")
|
||||
|
||||
|
||||
def test_TS_식_모양이_그대로다() -> None:
|
||||
"""식을 바꾸면 여기서 잡힘 — 파이썬 식도 함께 고칠 것."""
|
||||
assert "const raw = diameterM + REVET_FREEBOARD_M;" in TS
|
||||
assert "const raw = diameterM;" in TS and "const raw = diameterM;" in SETS
|
||||
assert "Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M" in TS
|
||||
assert "return revetTargetHeight(diameterM) + REVET_EMBED_DEPTH_M;" in TS
|
||||
# 그림이 칸 값을 읽음(139-1) — 배관 기본 = 칸 → 관경 기준
|
||||
assert "(spec.revet_height_m ?? pipeWallMinPureHeight(diameterM))" in TS
|
||||
# 옛 1.5 옮김 — TS 세트 짝 식
|
||||
assert "Math.abs(stored - OLD_PIPE_WALL_DEFAULT_M) < 1e-9 &&" in SETS
|
||||
assert "diameterM > 1.0 + 1e-9" in SETS
|
||||
|
||||
|
||||
def test_관경별_최소_높이() -> None:
|
||||
assert culvert.pipe_wall_min_height_m(1.0) == 2.0
|
||||
assert culvert.pipe_wall_min_height_m(0.8) == 1.8
|
||||
assert culvert.pipe_wall_min_height_m(1.2) == 2.2
|
||||
def test_관경별_기본_높이() -> None:
|
||||
"""관경 + 0.5 = 땅속 근입 0.5 포함 벽 전체 높이 · 벽 윗면 = 관 윗면(139-1 · 10-6 표 Ø1000 = 1.5)."""
|
||||
assert culvert.pipe_wall_min_height_m(1.0) == 1.5
|
||||
assert culvert.pipe_wall_min_height_m(0.8) == 1.3
|
||||
assert culvert.pipe_wall_min_height_m(1.2) == 1.7
|
||||
assert culvert.pipe_wall_min_height_m(1.5) == 2.0
|
||||
|
||||
|
||||
def test_옛_기본_옮김() -> None:
|
||||
assert culvert.pipe_wall_height_m(1.5, 1.2) == 1.7
|
||||
assert culvert.pipe_wall_height_m(1.5, 1.0) == 1.5
|
||||
assert culvert.pipe_wall_height_m(1.6, 1.5) == 1.6
|
||||
assert culvert.pipe_wall_height_m(None, 1.2) is None
|
||||
|
||||
Reference in New Issue
Block a user