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:
2026-10-09 15:53:07 +09:00
86 changed files with 3172 additions and 544 deletions
+2
View File
@@ -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). */
+65
View File
@@ -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();
},
+1 -1
View File
@@ -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))
+6 -1
View File
@@ -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;
}
+8
View File
@@ -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);
}
+17 -1
View File
@@ -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;
+29 -12
View File
@@ -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,
+27
View File
@@ -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:
+2 -1
View File
@@ -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
+10 -63
View File
@@ -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"
+63 -1
View File
@@ -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,
)
+5
View File
@@ -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
+2 -5
View File
@@ -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군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다.
+11 -7
View File
@@ -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 };
+3 -2
View File
@@ -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),
});
+13 -11
View File
@@ -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` : "")
: "";
},
};
}
+2
View File
@@ -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,
+58 -22
View File
@@ -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 },
};
}
+19 -13
View File
@@ -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). */
+103 -128
View File
@@ -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),
+41 -1
View File
@@ -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) => {
+10 -1
View File
@@ -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[]) =>
+7 -1
View File
@@ -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 });
+3 -2
View File
@@ -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) {
+15 -3
View File
@@ -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()
+13
View File
@@ -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;
+65 -7
View File
@@ -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",
+4 -2
View File
@@ -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) · 우측 끝 날개벽 없음 ───────────
+318
View File
@@ -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
+5 -1
View File
@@ -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"])
+15 -8
View File
@@ -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:
+110
View File
@@ -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
+55
View File
@@ -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
)
+48
View File
@@ -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
+60
View File
@@ -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(")
+10 -5
View File
@@ -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()
+8 -3
View File
@@ -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:
+4 -4
View File
@@ -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" };
+26 -3
View File
@@ -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})
+4 -2
View File
@@ -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