diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 6445daf4d..815d6b440 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -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). */ diff --git a/A00_Common/b_polygon_clip.ts b/A00_Common/b_polygon_clip.ts new file mode 100644 index 000000000..44d0f52bd --- /dev/null +++ b/A00_Common/b_polygon_clip.ts @@ -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; +} diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index cf0a3a431..9a7ee8c20 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -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(); }, diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 7147f89a0..d84a5f93b 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -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 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다. diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index e905d5e03..e299c4754 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -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)) diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 85e6bf6c7..0866d8527 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -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) diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index db11c649d..83c18eafd 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -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 전체를 막으면 안 된다. } diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts index 7ecfc5c77..bd4e7aa1d 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts @@ -74,6 +74,7 @@ export function structureHashParts(section: CrossSection): Array option.key), + ); return values; } diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index ed1888143..e59268017 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -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) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 관 길이와 같은 길. */ diff --git a/B06_Section/B06_Section_Cross_Design_Session.ts b/B06_Section/B06_Section_Cross_Design_Session.ts index 5e5f74df3..2f38122b6 100644 --- a/B06_Section/B06_Section_Cross_Design_Session.ts +++ b/B06_Section/B06_Section_Cross_Design_Session.ts @@ -68,3 +68,45 @@ export function crossDesignChoices( } return choices; } + +/* ── 설계선 쉬프트 = 쪽마다 노견 확장(m · ≥ 0 · 139-6) — 카드 버튼 선택과 따로 담음(버튼 선택이 + * 통째로 덮어써도 안 지워지게). [저장]·[확정] 이 patch 로 싣고, 브라우저 재계산이 입력으로 씀. */ +export type ShoulderSide = "left" | "right"; +export type ShoulderExtends = Map>>; + +/** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */ +export function readShoulderExtends( + projectId: string | null, + routeId: number | null, +): ShoulderExtends { + const out: ShoulderExtends = new Map(); + if (!projectId || routeId === null) return out; + const raw = readState>>>( + "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>>>( + "shoulderext", + projectId, + routeId, + ) ?? {}; + all[keyOf(chainageM)] = { ...(all[keyOf(chainageM)] ?? {}), [side]: Math.max(valueM, 0) }; + writeState("shoulderext", all, projectId, routeId); +} diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 3328b3526..fe8f9b199 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -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, 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, previous, ); + section.design.shoulder_extend_left_m = extendLeft; + section.design.shoulder_extend_right_m = extendRight; updated.push(section.chainage_m); } return updated; diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 278c79a05..48bb3d5de 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -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, diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 33b28829e..bd27f6d19 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -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해 프론트가 세션값 없이도 오버레이·재계산에 쓰게 한다. diff --git a/B06_Section/B06_Section_Engine_Design_Geometry.py b/B06_Section/B06_Section_Engine_Design_Geometry.py index cefdf8c23..8259639b2 100644 --- a/B06_Section/B06_Section_Engine_Design_Geometry.py +++ b/B06_Section/B06_Section_Engine_Design_Geometry.py @@ -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 diff --git a/B06_Section/B06_Section_Engine_SampleExtend.py b/B06_Section/B06_Section_Engine_SampleExtend.py index 5cfe32ce1..b3523a2e6 100644 --- a/B06_Section/B06_Section_Engine_SampleExtend.py +++ b/B06_Section/B06_Section_Engine_SampleExtend.py @@ -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: diff --git a/B06_Section/B06_Section_Engine_SpoilFill.py b/B06_Section/B06_Section_Engine_SpoilFill.py index b07d94d27..28b280e5c 100644 --- a/B06_Section/B06_Section_Engine_SpoilFill.py +++ b/B06_Section/B06_Section_Engine_SpoilFill.py @@ -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 diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 074fd5e88..3247a715e 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -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" diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 67a8484fb..f3c644403 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -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 diff --git a/B06_Section/B06_Section_Router_Regenerate_Options.py b/B06_Section/B06_Section_Router_Regenerate_Options.py new file mode 100644 index 000000000..f0627caba --- /dev/null +++ b/B06_Section/B06_Section_Router_Regenerate_Options.py @@ -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, + ) diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 1084a2fd8..2a864e6b2 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -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"…). diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index 875f81b34..2a965c06d 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -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 diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index ab49b6b31..936694a71 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -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군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다. diff --git a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts index 88d0db750..e0cb89151 100644 --- a/B06_Section/B06_Section_UI_Cross_Body_Guard.ts +++ b/B06_Section/B06_Section_UI_Cross_Body_Guard.ts @@ -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 }; diff --git a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts index 247e0dae7..fecab8916 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Geom.ts @@ -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); diff --git a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts index ed34f7a80..ac1f08861 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts @@ -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), }); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index 874f65baf..45a79ad0c 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -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 이내)") + diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index 1718c17c8..673bfeaaf 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -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; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts index b466cebe8..234aa2bb1 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts @@ -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 }; diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts new file mode 100644 index 000000000..022a7f12f --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Fit.ts @@ -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 }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 1d59b2136..6aceb2af5 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -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 = diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts index ecc4e047b..c97709e44 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Tiers.ts @@ -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, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 4d1bf1d74..3796e0126 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -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, diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 878121f43..723b97f95 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -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 }; } /** diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts new file mode 100644 index 000000000..485538bb5 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -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` : "") + : ""; + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts index d084e7ced..fc9f3324c 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -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, diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts index 41b80dd4a..5c4bfb605 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts @@ -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 | 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 }, }; } diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts index 0b31dd209..9a869638b 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts @@ -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) => void; + update: ( + chainageM: number, + role: FordWallRole, + patch: { lateralM?: number; dropM?: number }, + ) => void; reset: (chainageM: number, role: FordWallRole) => void; /** 관경(mm)·수량(련)·관종·월류 폭 저장 — B05 정본(`pipe_points`)으로 간다. * 좌측 「구조물 배치」 폼만 보낸다(조정창에 칸 없음 · 계획서 24-4a). */ diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 755d162fc..ab9d0181d 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -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(); - /** 마지막 계산의 실제 적용 d(상하) — d 미지정(자동) 벽의 ▲▼ 시작값. */ - let culvertAppliedD = new Map(); + /** 조정창이 읽는 마지막 계산 결과(벽 제원 · 단 수 등) — `_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); diff --git a/B06_Section/B06_Section_UI_Cross_View_Ford.ts b/B06_Section/B06_Section_UI_Cross_View_Ford.ts index 66e70ab11..010673cd2 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Ford.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Ford.ts @@ -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), diff --git a/B06_Section/B06_Section_UI_Cross_View_Frame.ts b/B06_Section/B06_Section_UI_Cross_View_Frame.ts index 66ef2bf9c..ca30247af 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Frame.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Frame.ts @@ -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), + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_View_Panels.ts b/B06_Section/B06_Section_UI_Cross_View_Panels.ts new file mode 100644 index 000000000..7face8a37 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_View_Panels.ts @@ -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; + /** 마지막 계산의 실제 적용 d(상하) — d 미지정(자동) 벽의 ▲▼ 시작값. */ + appliedD: Map; +} + +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); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index d88057f65..00fa535c1 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -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) => { diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index 27b27117a..582edd515 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -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 = { diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index bf422f71b..27aa43bde 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -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 } }; // 길이는 바깥으로만 — 안쪽으로 줄이면 성토선 물매가 깨진다. diff --git a/B06_Section/B06_Section_UI_Page_Link_Session.ts b/B06_Section/B06_Section_UI_Page_Link_Session.ts index c12715271..edafcd95a 100644 --- a/B06_Section/B06_Section_UI_Page_Link_Session.ts +++ b/B06_Section/B06_Section_UI_Page_Link_Session.ts @@ -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[]) => diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index e5473ae7e..88398bdcf 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -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 사용자 확정: 「일단은 브라우저 계산으로」). diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index fd3fc9e73..79bc25659 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -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 }); diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index ded85862f..c27103ecb 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -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); }, diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index 24c05dbc1..fe0dc87f1 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -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; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index f526528d2..de87ddbcc 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -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); diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index c53b382ca..b0e894daf 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -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; } diff --git a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts index 7dc4f5ea0..6d3b1488a 100644 --- a/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts +++ b/B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts @@ -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) { diff --git a/B08_Quantity/B08_Quantity_Router_Summary.py b/B08_Quantity/B08_Quantity_Router_Summary.py index 3fbd3d766..f965c0dd8 100644 --- a/B08_Quantity/B08_Quantity_Router_Summary.py +++ b/B08_Quantity/B08_Quantity_Router_Summary.py @@ -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() diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 4ac4be5ae..a7a24ab93 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -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; /** 결과 — **실제로 섰나**. */ @@ -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); diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index a4cb10817..3f38877d7 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -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; diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index 64e167b35..d6b7a156f 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -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, + keys: ReadonlyArray, +): 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> | 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); diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 2b5b5bbf4..fd51c39d4 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -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", diff --git a/resources/tester/helper_108_drag_box_hit.cjs b/resources/tester/helper_108_drag_box_hit.cjs index aee466c97..1ab75d93f 100644 --- a/resources/tester/helper_108_drag_box_hit.cjs +++ b/resources/tester/helper_108_drag_box_hit.cjs @@ -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) · 우측 끝 날개벽 없음 ─────────── diff --git a/resources/tester/helper_137_design_pick.cjs b/resources/tester/helper_137_design_pick.cjs new file mode 100644 index 000000000..e6db8f38b --- /dev/null +++ b/resources/tester/helper_137_design_pick.cjs @@ -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)); diff --git a/resources/tester/helper_139_2_link_freeze.cjs b/resources/tester/helper_139_2_link_freeze.cjs new file mode 100644 index 000000000..bb5657f8f --- /dev/null +++ b/resources/tester/helper_139_2_link_freeze.cjs @@ -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 }), +); diff --git a/resources/tester/helper_139_3_ford_level.cjs b/resources/tester/helper_139_3_ford_level.cjs new file mode 100644 index 000000000..d1c9e6c21 --- /dev/null +++ b/resources/tester/helper_139_3_ford_level.cjs @@ -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 }) }), + }), +); diff --git a/resources/tester/helper_139_4_buried_outlet.cjs b/resources/tester/helper_139_4_buried_outlet.cjs new file mode 100644 index 000000000..a8d7075b9 --- /dev/null +++ b/resources/tester/helper_139_4_buried_outlet.cjs @@ -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)); diff --git a/resources/tester/helper_139_6_line_shift.cjs b/resources/tester/helper_139_6_line_shift.cjs new file mode 100644 index 000000000..97e9207ff --- /dev/null +++ b/resources/tester/helper_139_6_line_shift.cjs @@ -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] })); diff --git a/resources/tester/helper_139_6_pipe_fit.cjs b/resources/tester/helper_139_6_pipe_fit.cjs new file mode 100644 index 000000000..9a908b470 --- /dev/null +++ b/resources/tester/helper_139_6_pipe_fit.cjs @@ -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) })); diff --git a/resources/tester/helper_b06_adjust_single_form.cjs b/resources/tester/helper_b06_adjust_single_form.cjs index 6e97bc8ed..4e71e340e 100644 --- a/resources/tester/helper_b06_adjust_single_form.cjs +++ b/resources/tester/helper_b06_adjust_single_form.cjs @@ -188,6 +188,7 @@ const fordDeps = { heightFor: () => 1.5, nudge: () => {}, nudgeSlope: () => {}, + dropFor: () => 0, reset: () => {}, slabLengthM: () => 7.6, close: () => {}, diff --git a/resources/tester/test_106_span_highlight_wording.py b/resources/tester/test_106_span_highlight_wording.py index eef9f9cea..3e9ca6238 100644 --- a/resources/tester/test_106_span_highlight_wording.py +++ b/resources/tester/test_106_span_highlight_wording.py @@ -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 diff --git a/resources/tester/test_107_ford_free.py b/resources/tester/test_107_ford_free.py index 5fc90acd0..cad5d2664 100644 --- a/resources/tester/test_107_ford_free.py +++ b/resources/tester/test_107_ford_free.py @@ -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"]) diff --git a/resources/tester/test_108_drag_box_hit.py b/resources/tester/test_108_drag_box_hit.py index d1e417e01..78a1ba35d 100644 --- a/resources/tester/test_108_drag_box_hit.py +++ b/resources/tester/test_108_drag_box_hit.py @@ -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: diff --git a/resources/tester/test_137_design_pick.py b/resources/tester/test_137_design_pick.py new file mode 100644 index 000000000..c75c230bf --- /dev/null +++ b/resources/tester/test_137_design_pick.py @@ -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 diff --git a/resources/tester/test_139_1_pipe_height_follow.py b/resources/tester/test_139_1_pipe_height_follow.py new file mode 100644 index 000000000..978ac0f69 --- /dev/null +++ b/resources/tester/test_139_1_pipe_height_follow.py @@ -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"] == "" # 빈 칸 그대로 diff --git a/resources/tester/test_139_2_link_freeze.py b/resources/tester/test_139_2_link_freeze.py new file mode 100644 index 000000000..c8c6f66d2 --- /dev/null +++ b/resources/tester/test_139_2_link_freeze.py @@ -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 diff --git a/resources/tester/test_139_3_ford_level.py b/resources/tester/test_139_3_ford_level.py new file mode 100644 index 000000000..7acfc6864 --- /dev/null +++ b/resources/tester/test_139_3_ford_level.py @@ -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] diff --git a/resources/tester/test_139_4_buried_outlet.py b/resources/tester/test_139_4_buried_outlet.py new file mode 100644 index 000000000..6ddb7d15e --- /dev/null +++ b/resources/tester/test_139_4_buried_outlet.py @@ -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 + ) diff --git a/resources/tester/test_139_6_line_shift.py b/resources/tester/test_139_6_line_shift.py new file mode 100644 index 000000000..ae56ecd91 --- /dev/null +++ b/resources/tester/test_139_6_line_shift.py @@ -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 diff --git a/resources/tester/test_139_6_pipe_fit.py b/resources/tester/test_139_6_pipe_fit.py new file mode 100644 index 000000000..34c642776 --- /dev/null +++ b/resources/tester/test_139_6_pipe_fit.py @@ -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) diff --git a/resources/tester/test_139_6_save_redesign.py b/resources/tester/test_139_6_save_redesign.py new file mode 100644 index 000000000..bf884c31f --- /dev/null +++ b/resources/tester/test_139_6_save_redesign.py @@ -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(") diff --git a/resources/tester/test_79_guard_shape.py b/resources/tester/test_79_guard_shape.py index 81f6bb302..a89e33d92 100644 --- a/resources/tester/test_79_guard_shape.py +++ b/resources/tester/test_79_guard_shape.py @@ -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() diff --git a/resources/tester/test_91_ground_free.py b/resources/tester/test_91_ground_free.py index 821be3e19..1720f43fd 100644 --- a/resources/tester/test_91_ground_free.py +++ b/resources/tester/test_91_ground_free.py @@ -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: diff --git a/resources/tester/test_98_b08_pipe_summary.py b/resources/tester/test_98_b08_pipe_summary.py index f851b9b4a..87884abab 100644 --- a/resources/tester/test_98_b08_pipe_summary.py +++ b/resources/tester/test_98_b08_pipe_summary.py @@ -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): diff --git a/resources/tester/test_b05_new_structure_defaults.py b/resources/tester/test_b05_new_structure_defaults.py index 3ec8dd184..b85ce87ae 100644 --- a/resources/tester/test_b05_new_structure_defaults.py +++ b/resources/tester/test_b05_new_structure_defaults.py @@ -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_월류_폭_기본값은_등록부_엔진과_같음(): diff --git a/resources/tester/test_b05_structures_registry_policy.py b/resources/tester/test_b05_structures_registry_policy.py index f96d33e2b..3048c6f79 100644 --- a/resources/tester/test_b05_structures_registry_policy.py +++ b/resources/tester/test_b05_structures_registry_policy.py @@ -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" diff --git a/resources/tester/test_b06_cross_design_mirror.py b/resources/tester/test_b06_cross_design_mirror.py index facbdc950..4f46752ee 100644 --- a/resources/tester/test_b06_cross_design_mirror.py +++ b/resources/tester/test_b06_cross_design_mirror.py @@ -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" }; diff --git a/resources/tester/test_b06_culvert_set.py b/resources/tester/test_b06_culvert_set.py index 22a6bd6f2..72bc4e8e1 100644 --- a/resources/tester/test_b06_culvert_set.py +++ b/resources/tester/test_b06_culvert_set.py @@ -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}) diff --git a/resources/tester/test_b06_height_unlock.py b/resources/tester/test_b06_height_unlock.py index 2761ee9c8..293737365 100644 --- a/resources/tester/test_b06_height_unlock.py +++ b/resources/tester/test_b06_height_unlock.py @@ -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), "음수 높이가 그림 하한에서 안 멈춤" diff --git a/resources/tester/test_b06_pipe_wall_height_mirror.py b/resources/tester/test_b06_pipe_wall_height_mirror.py index a41970e06..75707760c 100644 --- a/resources/tester/test_b06_pipe_wall_height_mirror.py +++ b/resources/tester/test_b06_pipe_wall_height_mirror.py @@ -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