diff --git a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts index 9f42f25f..651a46e2 100644 --- a/B04_PreProcess/B04_PreProcess_Api_Fetch.ts +++ b/B04_PreProcess/B04_PreProcess_Api_Fetch.ts @@ -439,13 +439,14 @@ export type PipeSource = "stream" | "spacing" | "user"; /** 계곡 통과 시설 종류(2026-08-17 컨테이너 병합). 같은 계곡 교차점에서 유량·지형에 * 따라 택일한다 — 정의처는 백엔드 `common_util_drainage_pipes`. 교량은 임도용이 아니다. */ -export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_bridge"; +export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_bridge" | "revetment"; export const PIPE_FACILITY_LABELS: ReadonlyArray<[PipeFacility, string]> = [ ["pipe", "배수관"], ["box_culvert", "BOX암거"], ["ford_pavement", "물넘이포장"], ["ford_bridge", "세월교"], + ["revetment", "기슭막이"], ]; export interface DetailPipePoint { diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index 263d039e..c4b0defa 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -29,6 +29,7 @@ from B05_Profile.B05_Profile_Structures_Schema import ( from common_util.common_util_drainage_pipes import ( PIPE_FACILITY_FORD_PAVEMENT, PIPE_FACILITY_PIPE, + PIPE_FACILITY_REVET, PipePoint, parse_pipe_points, pipe_anchor_clearances, @@ -174,7 +175,16 @@ def resolve_extra_stations( if definition is None: continue label = definition.name - derived.append((float(pipe.chainage_m), label)) + # 독립 기슭막이는 구간(시작~종료)이라 **시작·기준·종료** 세 측점을 심는다 — + # 길수록 횡단도가 여러 장 나온다(구 D군 구간형과 같은 규칙). 나머지 관 시설은 + # 기준 한 곳(관이 노선을 가로지르는 점형). + if pipe.facility == PIPE_FACILITY_REVET: + marks = (pipe.start_m, pipe.chainage_m, pipe.end_m) + else: + marks = (pipe.chainage_m,) + for mark in marks: + if mark is not None: + derived.append((float(mark), label)) # 구조물 정본에서 측점이 필요한 것들. 관 정본이 관리하는 타입(managed_by)은 # structures.json에 저장되지 않아 중복되지 않는다. # · 점형(A군 노출형 횡단수로·개거): 기준 측점 한 곳. diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 478b3871..3cd0db4e 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -976,7 +976,8 @@ "type_id": "revetment", "group": "D", "name": "기슭막이", - "placement": "interval", + "placement": "point", + "managed_by": "pipe_points", "style": { "color": "#9b6bdc", "abbr": "기슭" @@ -987,8 +988,8 @@ "key": "side", "label": "설치 측", "input": "select", - "choices": ["좌", "우"], - "default": "좌", + "choices": ["양쪽", "좌", "우"], + "default": "양쪽", "required": false, "phase": "b05" }, diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 16d44fab..90a08089 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -21,6 +21,7 @@ import type { CrossSection, CulvertSideSpec } from "../B06_Section/B06_Section_A import type { WallAdjust } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types"; import { ZERO_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types"; import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert"; +import { restrictToSide } 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 { @@ -223,13 +224,18 @@ export function culvertLayoutOf(section: CrossSection): CulvertLayout | null { if (cached && cached.hash === hash) return cached.layout; let layout: CulvertLayout | null = null; try { + // 독립 기슭막이(관 숨김)는 유입측도 기슭막이 벽으로 강제하고(집수정 금지), 설치 측만 + // 남긴다 — 횡단도(computeCardCulvert)와 같은 규칙으로 3D도 맞춘다(2026-08-28 이관). layout = computeCulvertLayout( section, section.samples, storedAdjusts(section), - section.design.inlet_structure ?? "auto", + section.culvert.hidden_pipe ? "revet" : (section.design.inlet_structure ?? "auto"), section.design.basin_adjust, ); + if (layout && section.culvert.hidden_pipe) { + layout = restrictToSide(layout, section.culvert.side); + } } catch { layout = null; // 한 측점의 기하 실패가 3D 전체를 막으면 안 된다. } @@ -275,9 +281,12 @@ export function buildCorridorStructures( for (const section of crossSections) { const layout = culvertLayoutOf(section); const fordLayout = fordLayoutOf(section); - // 독립 기슭막이 — 횡단 카드와 **같은 폴리곤**을 그대로 스윕한다(2026-08-28 사용자). - // 조정창 조작값도 같이 읽는다 — 3D는 정본(design.revet_adjust)만 본다. - const revetLayout = computeRevetmentLayout(section, section.design?.revet_adjust?.own); + // 독립 기슭막이(옛 D군 경로) — 측점에 배관/숨김 기슭막이 세트가 붙으면 그쪽(위 + // culvertLayoutOf)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 스윕한다 + // (2026-08-28 이관 이중그리기 방지 — 횡단도 가드와 같은 규칙). + const revetLayout = section.culvert + ? null + : computeRevetmentLayout(section, section.design?.revet_adjust?.own); if (!layout && !fordLayout && !section.box && !revetLayout) continue; const chainage = section.chainage_m; const stationFrame: StructureFrame = { @@ -521,10 +530,13 @@ export function buildCorridorStructures( const inletSpan = wallSpanOf(section, "inlet"); const outletSpan = wallSpanOf(section, "outlet"); const basinSpan = basinSpanOf(section); + const hiddenPipe = section.culvert?.hidden_pipe === true; const culvertBore = pipeBore(layout.culvert.diameter_m, layout.pipe.inlet, layout.pipe.outlet); for (const wall of layout.walls) { const span = wall.role === "inlet" ? inletSpan : outletSpan; - pushPierced("revet", wall.points, span.beforeM, span.afterM, culvertBore); + // 독립 기슭막이는 관이 없다 — 벽을 관통 컷 없이 그대로 스윕한다. + if (hiddenPipe) pushSwept("revet", wall.points, span.beforeM, span.afterM); + else pushPierced("revet", wall.points, span.beforeM, span.afterM, culvertBore); } // 다단(성토부) 벽은 유출측 연장을, 집수정 계류측 다단은 집수정 연장을 따른다. for (const wall of layout.extraWalls) { @@ -565,17 +577,20 @@ export function buildCorridorStructures( } } - solids.push({ - chainage_m: chainage, - kind: "pipe", - pipe: { - start: [layout.pipe.inlet.offset, layout.pipe.inlet.elevation], - end: [layout.pipe.outlet.offset, layout.pipe.outlet.elevation], - diameterM: layout.culvert.diameter_m, - wallThicknessM: pipeWallThicknessM(layout.culvert.pipe_kind, layout.culvert.diameter_m), - }, - frame: stationFrame, - }); + // 독립 기슭막이는 관 실린더를 그리지 않는다(숨김). + if (!hiddenPipe) { + solids.push({ + chainage_m: chainage, + kind: "pipe", + pipe: { + start: [layout.pipe.inlet.offset, layout.pipe.inlet.elevation], + end: [layout.pipe.outlet.offset, layout.pipe.outlet.elevation], + diameterM: layout.culvert.diameter_m, + wallThicknessM: pipeWallThicknessM(layout.culvert.pipe_kind, layout.culvert.diameter_m), + }, + frame: stationFrame, + }); + } } return solids; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 9ad5659c..c8046944 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -227,8 +227,23 @@ export function createFacilityOptionsForm( ...outletRevet.rows, ); - // 독립 기슭막이(D4)는 서브폼을 떠났다 — C군과 같은 레지스트리 옵션 방식 - // (측점 범위 + 기준측점 전/후, 2026-08-19 사용자 지시 3). + // ── 독립 기슭막이(2026-08-28 이관: 배관처럼 pipe_points 관리) ───────────── + // 배관 없는 기슭막이 — 설치 측(양쪽/좌/우) + 형태·높이·길이·전/후(배관 유입·유출과 + // 같은 조각) + 다단 단 수. B06 횡단도·3D·조정창이 배관 기슭막이 경로를 그대로 탄다. + const revetGroup = group("기슭막이"); + const revetSide = optionalSelect("양쪽", ["양쪽", "좌", "우"]); + revetSide.value = "양쪽"; + const revetTiers = numberInput("1", "1"); + revetTiers.value = "1"; + const standaloneRevet = createRevetmentFields( + { form: "form", length: "length_m", height: "height_m", before: "before_m", after: "after_m" }, + { ...REVET_COMMON_DEFAULTS, form: "돌쌓기(메)" }, + ); + revetGroup.body.append( + grid(labeled("설치 측", revetSide), standaloneRevet.formField), + ...standaloneRevet.rows, + grid(labeled("단 수(다단)", revetTiers)), + ); // ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ──── // 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정). @@ -329,6 +344,7 @@ export function createFacilityOptionsForm( pipeRow, inletGroup.root, outletGroup.root, + revetGroup.root, boxWrap, wingInFields.root, wingOutFields.root, @@ -360,6 +376,7 @@ export function createFacilityOptionsForm( // 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다. fordSlopeRow.hidden = current !== "ford_pavement"; fordSummary.hidden = !isFord; + revetGroup.root.hidden = current !== "revetment"; if (isFord) syncFordSummary(); if (isBox) syncBoxSize(); if (hasWing) { @@ -393,6 +410,8 @@ export function createFacilityOptionsForm( ); inletRevet.onChange(emit); outletRevet.onChange(emit); + standaloneRevet.onChange(emit); + [revetSide, revetTiers].forEach((input) => input.addEventListener("change", emit)); [ pipeMaterial, pipeDiameter, @@ -451,6 +470,9 @@ export function createFacilityOptionsForm( fordWidth.value = text("ford_width_m"); fordHeight.value = text("ford_height_m"); fordSlope.value = text("ford_slope_pct"); + revetSide.value = text("side") || "양쪽"; + revetTiers.value = text("tiers") || "1"; + standaloneRevet.write(options); // 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정, config 정의처). if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) { fordWidth.value = String( @@ -497,6 +519,11 @@ export function createFacilityOptionsForm( putFordHeight(options); wingInFields.read(options); wingOutFields.read(options); + } else if (current === "revetment") { + options.side = revetSide.value; + const tiers = Number.parseInt(revetTiers.value, 10); + if (Number.isFinite(tiers) && tiers >= 1) options.tiers = tiers; + standaloneRevet.read(options); } return options; }, diff --git a/B05_Profile/B05_Profile_UI_Page_Helpers.ts b/B05_Profile/B05_Profile_UI_Page_Helpers.ts index 235ddc47..729ebf91 100644 --- a/B05_Profile/B05_Profile_UI_Page_Helpers.ts +++ b/B05_Profile/B05_Profile_UI_Page_Helpers.ts @@ -240,4 +240,5 @@ export const FACILITY_NAMES: Record = { box_culvert: "BOX암거", ford_pavement: "물넘이포장", ford_bridge: "세월교", + revetment: "기슭막이", }; diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 615828de..7d78cd31 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -225,6 +225,12 @@ export interface CulvertSet { min_cover_m: number; inlet: CulvertSideSpec; outlet: CulvertSideSpec; + /** 독립 기슭막이(관 없는 벽) — true면 관을 그리지 않고 수량에서도 뺀다(2026-08-28). */ + hidden_pipe?: boolean; + /** 독립 기슭막이 설치 측 — "양쪽" | "좌" | "우". 좌/우면 반대쪽 벽을 숨긴다. */ + side?: string; + /** 독립 기슭막이 다단 요청 수(1이면 단일 벽). */ + tiers?: number; } /** 세월교 날개벽 한쪽 — 횡단면엔 안 보이고 바닥판 연장량만 넘긴다. */ diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index dec6cd75..de13eb45 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -30,6 +30,7 @@ from common_util.common_util_drainage_pipes import ( PIPE_FACILITY_FORD_BRIDGE, PIPE_FACILITY_FORD_PAVEMENT, PIPE_FACILITY_PIPE, + PIPE_FACILITY_REVET, parse_pipe_points, ) from config.config_system import ( @@ -213,6 +214,51 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]: } +def _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]: + """독립 기슭막이 한쪽 벽 제원 — 배관 유입/유출 벽과 같은 스펙 모양으로 만든다. + + 독립 기슭막이는 형태·높이·길이·전후를 한 벌만 가지므로 양쪽 벽에 같은 값을 싣는다. + 실제로 어느 쪽(좌/우)을 세울지는 측점 방향을 아는 B06이 `side`로 가린다. + """ + height = _number(values.get("height_m"), None) + form = values.get("form") + spec: dict[str, Any] = { + "role": role, + "structure": "기슭막이", + "revet_form": str(form) if form else None, + "revet_height_m": height, + "revet_length_m": _number(values.get("length_m"), None), + "revet_before_m": _number(values.get("before_m"), None), + "revet_after_m": _number(values.get("after_m"), None), + "face_slope": REVET_FACE_SLOPE, + } + if height is not None and height > 0: + spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3) + spec["apron_thickness_m"] = APRON_THICKNESS_M + return spec + + +def _revet_set(options: dict[str, Any] | None) -> dict[str, Any]: + """독립 기슭막이 1개소 세트 — 배관 세트와 같은 모양이되 **관을 숨긴다**(hidden_pipe). + + 관경은 표시·수량에서 빠지므로 토큰값(0.3)만 둔다 — 벽 높이는 형태(재질) 한계와 + 사용자 지정 높이가 정한다. `side`(양쪽/좌/우)로 어느 벽을 세울지 B06이 가리고, + `tiers`로 다단을 요청한다. + """ + values = dict(options or {}) + return { + "type": "pipe", + "hidden_pipe": True, + "side": str(values.get("side") or "양쪽"), + "tiers": int(_number(values.get("tiers"), 1.0) or 1), + "pipe_kind": None, + "diameter_m": 0.3, + "min_cover_m": 0.0, + "inlet": _revet_side(values, "inlet"), + "outlet": _revet_side(values, "outlet"), + } + + def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]: """날개벽 한쪽 제원 + 그 각도가 만드는 바닥판 연장량. @@ -344,6 +390,10 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]: elif point.facility == PIPE_FACILITY_FORD_PAVEMENT: # 구조물이 아니라 파인 노면이다 — 스펙 모양도 소비처도 다르다(2026-08-28). spec = _ford_pavement_set(point.options) + elif point.facility == PIPE_FACILITY_REVET: + # 독립 기슭막이 — 배관 세트 모양이되 관을 숨긴다. `culvert` 키로 얹혀 배관 + # 경로(컴퓨트·렌더·패널·연동·경사·3D·수량)를 그대로 탄다. + spec = _revet_set(point.options) else: continue sets[round(float(point.chainage_m), 2)] = spec diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 063980b2..c106628e 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -21,7 +21,6 @@ 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_Revetment import attach_revetments from B06_Section.B06_Section_Engine_Design import compute_cross_design from B06_Section.B06_Section_Repository import ( count_cross_sections, @@ -253,9 +252,8 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic ): raise ValueError("종횡단 상세 파일 형식이 올바르지 않습니다.") # 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다. + # 독립 기슭막이도 2026-08-28 이관으로 **관 숨김 세트**로 여기 함께 얹힌다(pipe_points). attach_culvert_sets(root, cross_sections) - # 배관과 무관한 **독립 기슭막이**(구조물 정본 D군)는 구간 안 측점 전부에 얹는다. - attach_revetments(root, cross_sections) return {"longitudinal": longitudinal, "cross_sections": cross_sections} @@ -477,7 +475,6 @@ async def regenerate_sections( result = sections["result"] # 재생성 응답도 상세 조회와 같은 배수관 세트 정보를 실어야 화면이 어긋나지 않는다. attach_culvert_sets(project_root, result["cross_sections"]) - attach_revetments(project_root, result["cross_sections"]) return SectionDetailResponse( longitudinal=result["longitudinal"], cross_sections=result["cross_sections"] ) diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts index 3398e71d..e34e16d3 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -172,8 +172,11 @@ export function computeCulvertLayout( const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => { const material = adjust.m ?? materialFromForm(spec.revet_form); const limit = materialLimit(material); - const floor = pipeWallMinPureHeight(diameter); - const pureHeight = Math.min(Math.max(adjust.h ?? floor, floor), Math.max(limit, floor)); + // 독립 기슭막이(관 숨김)는 관경이 없다 — 하한은 근입만, **기본 높이 = 사용자가 고른 + // 형태 높이**(revet_height_m). 배관은 관경 기준 최소 높이(관이 벽 밖으로 안 삐져나오게). + const floor = culvert.hidden_pipe ? REVET_EMBED_DEPTH_M : pipeWallMinPureHeight(diameter); + const preferred = culvert.hidden_pipe ? (spec.revet_height_m ?? floor) : floor; + const pureHeight = Math.min(Math.max(adjust.h ?? preferred, floor), Math.max(limit, floor)); return { material, limit, pureHeight, height: pureHeight - REVET_EMBED_DEPTH_M }; }; const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet); @@ -219,18 +222,22 @@ export function computeCulvertLayout( height: inletWallSpec.height, baseElevation0: inletInfo.edge.elevation_m - inletWallSpec.height, edgeElevation: inletInfo.edge.elevation_m, - invertCap: invertCap(inletInfo.edge), + // 독립 기슭막이(관 숨김)는 관 토피·역경사 제약이 없다 — 유입도 노견까지 자유롭게 + // 오르내린다(관이 없어 유출 수용 검사 outletGuard도 뺀다 — 위 이동을 막던 원인). + invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : invertCap(inletInfo.edge), adjust: adjInlet, defaultD: PIPE_WALL_DEFAULT_RUN_M, groundAt, limitOffset: inletInfo.limit, - outletGuard: { - edge: outletInfo.edge, - outward: outletInfo.outward, - height: outletWallSpec.height, - toeOffset: slopeToeOffset(designAt, groundAt, outletInfo.edge.offset_m, outletInfo.limit), - limitOffset: outletInfo.limit, - }, + outletGuard: culvert.hidden_pipe + ? undefined + : { + edge: outletInfo.edge, + outward: outletInfo.outward, + height: outletWallSpec.height, + toeOffset: slopeToeOffset(designAt, groundAt, outletInfo.edge.offset_m, outletInfo.limit), + limitOffset: outletInfo.limit, + }, }); appliedAdjust.inlet.x = placed.x; appliedAdjust.inlet.d = placed.d; @@ -425,7 +432,8 @@ export function computeCulvertLayout( height: outletWallSpec.height, baseElevation0: outletZeroInvert, edgeElevation: outletInfo.edge.elevation_m, - invertCap: inlet.elevation, + // 독립 기슭막이는 양쪽 벽이 독립 — 유출을 유입 invert로 묶는 역경사 클램프를 뺀다. + invertCap: culvert.hidden_pipe ? Number.POSITIVE_INFINITY : inlet.elevation, adjust: adjOutlet, defaultD: PIPE_WALL_DEFAULT_RUN_M, groundAt, diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts index 35d1b2bb..14d5b79b 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Wire.ts @@ -264,6 +264,31 @@ function trimLinkedLayout(layout: CulvertLayout, link: CulvertLink): CulvertLayo }; } +/** + * 독립 기슭막이(관 숨김)의 **설치 측**(양쪽/좌/우)만 남긴다 — 배관은 유입·유출 두 쪽을 + * 늘 세우지만 독립 기슭막이는 사용자가 고른 쪽만 세운다. 좌(+offset=좌측)=trimMax 쪽, + * 우=trimMin 쪽. 반대쪽 벽·다단·성토부선·트림을 모두 지운다(링크 필터와 같은 규칙). + */ +export function restrictToSide(layout: CulvertLayout, side: string | undefined): CulvertLayout { + if (side !== "좌" && side !== "우") return layout; // 양쪽·미지정 → 둘 다 + const inletIsMax = layout.pipe.inlet.offset > layout.pipe.outlet.offset; + const keepMax = side === "좌"; + const keepMin = side === "우"; + const keepInlet = inletIsMax ? keepMax : keepMin; + const keepOutlet = inletIsMax ? keepMin : keepMax; + const empty = { segments: [], addable: false }; + return { + ...layout, + walls: layout.walls.filter((wall) => (wall.role === "inlet" ? keepInlet : keepOutlet)), + extraWalls: keepOutlet ? layout.extraWalls : [], + outletFill: keepOutlet ? layout.outletFill : empty, + basinExtras: keepInlet ? layout.basinExtras : [], + basinFill: keepInlet ? layout.basinFill : empty, + basin: keepInlet ? layout.basin : null, + designTrim: keepTrimSides(layout.designTrim, keepMin, keepMax), + }; +} + export function computeCardCulvert( section: CrossSection, sourceSamples: SectionSample[], @@ -295,14 +320,14 @@ export function computeCardCulvert( link.source, link.source.samples, adjustsInput(link.source, revetOffset, extraWalls), - inletStructure?.valueFor(link.source), + link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source), inletStructure?.adjustFor(link.source), )?.revetShift ?? adjustsInput(link.source, revetOffset, extraWalls)); const layout = computeCulvertLayout( hosted, sourceSamples, ownerAdjusts, - inletStructure?.valueFor(link.source), + link.source.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(link.source), inletStructure?.adjustFor(detached ? section : link.source), ); if (!layout) return null; @@ -310,14 +335,19 @@ export function computeCardCulvert( // 그 측점 계획고로 그린다 — 레이아웃 전체를 −dz로 밀면 벽뿐 아니라 노견에서 // 출발하는 성토선까지 같이 내려가 **성토선이 노견에서 떨어진다**(사용자 지적). // 3D 스윕에서만 프레임 dz를 0으로 눕힌다(B05_Profile_UI_Corridor_Structures). - return trimLinkedLayout(layout, link); + const linked = trimLinkedLayout(layout, link); + return link.source.culvert?.hidden_pipe + ? restrictToSide(linked, link.source.culvert.side) + : linked; } const equalizeExtras = extraWalls?.consumeEqualize(section) ?? false; + // 독립 기슭막이(관 숨김)는 유입측도 **집수정이 아니라 기슭막이 벽**으로 강제한다 — + // 관이 없어 집수정 자동 전환이 의미 없다(설치 측은 아래 restrictToSide가 가린다). const layout = computeCulvertLayout( section, sourceSamples, adjustsInput(section, revetOffset, extraWalls), - inletStructure?.valueFor(section), + section.culvert?.hidden_pipe ? "revet" : inletStructure?.valueFor(section), inletStructure?.adjustFor(section), equalizeExtras, ); @@ -411,7 +441,8 @@ export function computeCardCulvert( }); } } - return layout; + // 독립 기슭막이는 사용자가 고른 설치 측(양쪽/좌/우)만 남긴다. + return section.culvert?.hidden_pipe ? restrictToSide(layout, section.culvert.side) : layout; } function adjustsInput( diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index 17ec7a2b..8d0971cb 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -1,22 +1,44 @@ /* ============================================================================= * B06_Section_UI_Cross_Revetment.ts - * 독립 기슭막이(배관과 무관) — 횡단 단면 기하 + 그리기(2026-08-28 사용자 확정). + * 독립 기슭막이(배관과 무관) — 배관 세트 기슭막이와 **같은 배치·같은 산식·같은 도형**. + * 배관만 없을 뿐, 벽 자리·이동·한계는 배관 유출 벽과 한 몸으로 푼다(2026-08-28 사용자: + * "배관용에서 배관만 빼면 대부분 동일해야"). * - * 자리: **성토면 끝(설계선이 원지반과 만나는 지점)**. 벽 상단이 그 지점 표고이고 - * 아래로 높이 + 근입 0.5m만큼 내려간다. 전면(사면 반대쪽)은 1:0.3으로 기운다 - * (돌쌓기 전면, 교본 7-3). 설치 측은 **사용자가 좌/우로 지정**한다 — 자동 판정 없음. + * · 자리: `placePipeWall`(배관 공용 4축 배치) — d=0이면 벽 상단이 노견, d로 성토 사면을 + * 1:1.2 타고 내려간다. base(=invert)는 **성토선을 따라** 정해지고(지반밀착 아님), + * 지반보다 뜨면 `floatGap`으로 잡는다. x·d 한계(노견 안쪽 금지·매몰 금지)도 여기서. + * · 실제 적용값(한계 절삭 후)은 `appliedAdjust`로 돌려주고, 화면이 조작 저장소에 되받는다 + * — 눌러도 안 움직이는데 숫자만 커지는 것 방지(배관과 같은 syncApplied 규칙). + * · 다단: 배관과 **같은 함수** `buildExtrasAt`. 그리기(폴리곤·이음선·돌·성토부선)는 공용 + * `_Cross_Wall`. 노견→벽 성토선은 designTrim의 slope로 그린다. * - * 3D 코리도도 이 폴리곤을 그대로 스윕한다(`B05_Profile_UI_Corridor_Structures`) — - * 단면 기하를 두 벌 만들지 않는다. + * 3D 코리도는 `tiers[].polygon`·`span`을 그대로 스윕한다. * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import type { CrossSection } from "./B06_Section_Api_Fetch"; import { + PIPE_WALL_DEFAULT_RUN_M, REVET_EMBED_DEPTH_M, REVET_LEAN_RATIO, REVET_THICKNESS_M, + revetHeightLimit, } from "./B06_Section_UI_Cross_Culvert_Const"; +import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; +import { + fillWallBaseWidth, + groundInterpolator, + placePipeWall, +} from "./B06_Section_UI_Cross_Culvert_Solve"; +import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra"; +import { + ZERO_ADJUST, + type CulvertDesignTrim, + type OutletFillSegment, + type WallAdjust, + type WallLayout, +} from "./B06_Section_UI_Cross_Culvert_Types"; +import { appendPlanLine, buildRevetWallGeometry, drawRevetWall } from "./B06_Section_UI_Cross_Wall"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -34,9 +56,8 @@ export interface RevetmentSpec { side?: string | null; /** 단 수(다단). 1이면 단일 벽. */ tiers?: number | null; - /** 1단(기준·맨 위) 벽을 성토면 끝에서 도로 쪽으로 올린 사면 거리(m). 0이면 성토면 끝. */ + /** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */ lift_m?: number | null; - /** 기준 벽 좌우 이동(m) — + = 계류 쪽(바깥). 0이면 이동 없음. */ shift_m?: number | null; } @@ -52,199 +73,142 @@ export interface RevetPoint { elevation: number; } -/** 한 단(段) — 벽 하나. 1단이 기준(맨 위)이고 추가 단은 아래로 붙는다. */ +/** 한 단(段) — 3D 스윕용 폴리곤(배관 벽과 같은 합성 단면). */ export interface RevetmentTier { - /** 벽 상단 자리(배면 = 도로측). 1단은 성토면 끝. */ - top: RevetPoint; - /** 벽 하단 표고(근입 포함). */ - bottomElevation: number; - /** 단면 폴리곤(도로측 수직 배면 → 상단 → 기운 전면 → 바닥). */ polygon: RevetPoint[]; } export interface RevetmentLayout { side: "left" | "right"; - /** 실제로 쓴 벽 높이(m) — 조정창 높이 표시·조작의 기준값. */ + /** 1단 벽 높이(m) — 조정창 높이 표시·조작의 기준값. */ heightM: number; - /** 이 단면의 누가거리(m) — 부족 안내를 측점 단위로 세는 데 쓴다(재렌더 중복 방지). */ + /** 이 단면의 누가거리(m) — 부족 안내를 측점 단위로 센다(재렌더 중복 방지). */ chainageM: number; - /** 사용자가 요청한 단 수 — 실제로 선 단 수(`tiers.length`)와 다르면 자리가 부족한 것이다. */ + /** 사용자가 요청한 단 수 — 실제로 선 단 수(`walls.length`)와 다르면 자리가 부족한 것이다. */ requestedTiers: number; - /** 1단 벽 상단 = 성토면 끝(설계선-지반 교차점). */ + /** 1단 벽 이음선 상단점(성토 설계선 트림 경계). */ top: RevetPoint; - /** 단 목록 — 사용자가 지정한 단 수만큼, 벽이 지반에 묻히면 거기서 끝난다. */ + /** 벽 목록(1단 + 다단) — 배관 벽과 같은 `WallLayout`, 그리기가 그대로 쓴다. */ + walls: WallLayout[]; + /** 벽 사이·벽 아래 성토부선(배관 다단과 같은 체계). */ + fillSegments: OutletFillSegment[]; + /** 성토 설계선을 벽 상단에서 끊고 노견→벽 성토선을 대신 그리는 트림. */ + designTrim: CulvertDesignTrim; + /** 한계 절삭 후 **실제 적용된** 1단 조작값 — 화면이 조작 저장소에 되받는다. */ + appliedAdjust: WallAdjust; + /** 3D 스윕용 단 목록 — 벽 폴리곤 그대로. */ tiers: RevetmentTier[]; /** 기준 측점 전/후 점유 길이(m) — 3D 스윕 범위. */ span: { beforeM: number; afterM: number }; } -/** 지반 표고 보간 — 샘플 사이는 선형. 유효 표본이 없으면 null. */ -function groundElevationAt(section: CrossSection, offsetM: number): number | null { - const points = section.samples - .filter( - (sample) => - sample.valid !== false && - typeof sample.offset_m === "number" && - typeof sample.elevation_m === "number", - ) - .map((sample) => ({ - offset: sample.offset_m as number, - elevation: sample.elevation_m as number, - })) - .sort((a, b) => a.offset - b.offset); - const first = points[0]; - const last = points[points.length - 1]; - if (!first || !last || points.length < 2) return null; - if (offsetM <= first.offset) return first.elevation; - if (offsetM >= last.offset) return last.elevation; - for (let i = 1; i < points.length; i += 1) { - const a = points[i - 1]; - const b = points[i]; - if (!a || !b) continue; - if (offsetM <= b.offset) { - const t = (offsetM - a.offset) / (b.offset - a.offset || 1); - return a.elevation + (b.elevation - a.elevation) * t; - } - } - return null; +/** 형태(구조물 옵션) → 재질(높이 한계·라벨). 메 계열은 dry, 찰=wet, 콘크리트=concrete. */ +function materialOfForm(form?: string | null): RevetMaterial { + if (!form) return "dry"; + if (form.includes("찰")) return "wet"; + if (form.includes("콘크리트")) return "concrete"; + return "dry"; // 메쌓기·돌망태·통나무·바자 — 메 계열(2.0m 한계) +} + +/** 유효 지반 샘플의 바깥 한계 offset(설치 측). 없으면 null. */ +function sampleLimit(section: CrossSection, outward: number): number | null { + const offsets = section.samples + .filter((s) => s.valid !== false && typeof s.offset_m === "number") + .map((s) => s.offset_m as number); + if (!offsets.length) return null; + return outward > 0 ? Math.max(...offsets) : Math.min(...offsets); } /** - * 성토면 끝 — 노견 바깥으로 나가며 설계선이 원지반과 만나는 첫 지점. - * 만나지 않으면(설계선이 지반 위로만 지나면) 설계선 끝점을 쓴다. - */ -function fillToeAt(section: CrossSection, side: "left" | "right"): RevetPoint | null { - const design = section.design; - const line = design?.design_line; - if (!design || !line || line.length < 2) return null; - const outward = side === "left" ? 1 : -1; - const edge = design.road_edges[side].offset_m; - const beyond = line - .filter((point) => (point.offset_m - edge) * outward > 1e-9) - .sort((a, b) => (a.offset_m - b.offset_m) * outward); - let previous: { offset: number; gap: number } | null = null; - for (const point of beyond) { - const ground = groundElevationAt(section, point.offset_m); - if (ground === null) continue; - const gap = point.elevation_m - ground; - if (previous && previous.gap > 0 && gap <= 0) { - const t = previous.gap / (previous.gap - gap || 1); - const offset = previous.offset + (point.offset_m - previous.offset) * t; - const elevation = groundElevationAt(section, offset); - if (elevation !== null) return { offset, elevation }; - } - previous = { offset: point.offset_m, gap }; - } - const tail = beyond[beyond.length - 1]; - if (!tail) return null; - return { offset: tail.offset_m, elevation: tail.elevation_m }; -} - -/** - * 추가 단 자리 — 기준에서 **설계 성토면을 따라 아래로** 벽 높이만큼 내려간 지점. - * 계단처럼 한 단씩 내려가며, 성토면 끝(toe)을 지나면 더 놓을 자리가 없다 - * (그 부족분은 화면이 토스트로 알린다 — 2026-08-28 사용자 확정). - */ -function tierAnchors( - anchor: RevetPoint, - toe: RevetPoint, - height: number, - count: number, -): RevetPoint[] { - const anchors: RevetPoint[] = [anchor]; - const drop = anchor.elevation - toe.elevation; - const run = toe.offset - anchor.offset; - if (!(drop > 1e-6) || !(height > 0)) return anchors; - for (let index = 1; index < count; index += 1) { - const fall = height * index; - if (fall > drop + 1e-6) break; // 성토면 끝을 지난다 — 자리 없음 - anchors.push({ - offset: anchor.offset + (run * fall) / drop, - elevation: anchor.elevation - fall, - }); - } - return anchors; -} - -function anchorAt( - section: CrossSection, - side: "left" | "right", - toe: RevetPoint, - liftM: number, - shiftM: number, - nudgeM: number, -): RevetPoint { - let anchor = toe; - const edge = section.design?.road_edges?.[side]; - if (edge && liftM > 0) { - const run = edge.offset_m - toe.offset; - const rise = edge.elevation_m - toe.elevation; - const length = Math.hypot(run, rise); - if (length > 1e-6) { - const t = Math.min(liftM / length, 1); - anchor = { offset: toe.offset + run * t, elevation: toe.elevation + rise * t }; - } - } - const outward = side === "left" ? 1 : -1; - const lateral = outward * shiftM + nudgeM; - if (!lateral) return anchor; - // 좌우는 수평 이동이라 표고는 그 자리 원지반을 따른다(벽 밑이 뜨지 않게). - const offset = anchor.offset + lateral; - const ground = groundElevationAt(section, offset); - return { offset, elevation: ground ?? anchor.elevation }; -} - -/** - * 독립 기슭막이 단면. 제원·높이가 없거나 성토면 끝을 못 찾으면 null. - * - * 다단 전개는 배관 세트 기슭막이와 **같은 규칙**이다(2026-08-22 확정, 2026-08-28 승계): - * · 기준(1단)이 맨 위, 추가 단은 그 **아래**로 붙는다. - * · 다음 단 성토선 시작점 = 윗단 벽 하단 수평선 +근입(0.5m)과 전면 경사선(1:0.3)의 교차점. - * · 그 시작점이 이미 원지반 아래면(벽이 묻힘) 더 세우지 않는다 — 화면이 "자리가 없다"고 - * 알리고, 사용자가 기준을 위로 올린 뒤(`lift_m`) 다시 늘린다. - * · 단 사이 성토사면은 정확히 1:1.2. + * 독립 기슭막이 단면 — 제원·높이·설계선이 없거나 자리를 못 찾으면 null. + * 1단 벽 = 배관 유출 벽과 같은 `placePipeWall`+`buildRevetWallGeometry`, + * 다단 = 배관과 같은 `buildExtrasAt`. */ export function computeRevetmentLayout( section: CrossSection, adjust?: RevetmentAdjust | null, ): RevetmentLayout | null { const spec = section.revetment; - // 높이는 조정창 값이 있으면 그것이 우선한다(배관 벽과 같은 규칙). - const height = Number(adjust?.h ?? spec?.height_m); - if (!spec || !Number.isFinite(height) || height <= 0) return null; - const side: "left" | "right" = spec.side === "우" ? "right" : "left"; - const toe = fillToeAt(section, side); - if (!toe) return null; + const design = section.design; + if (!spec || !design) return null; + const requestedHeight = Number(adjust?.h ?? spec.height_m); + if (!Number.isFinite(requestedHeight) || requestedHeight <= 0) return null; + const side: "left" | "right" = spec.side === "우" ? "right" : "left"; const outward = side === "left" ? 1 : -1; - const requestedTiers = Math.max(1, Math.round(Number(spec.tiers) || 1)); - // 조정창 ▲▼(d)는 **사면 아래가 +** — 기준 올림과 반대 방향이다. - const liftM = (Number(spec.lift_m) || 0) - (adjust?.d ?? 0); - // 조정창 ◀▶(x)는 편거리 증가(+ = 화면 왼쪽)로 들어온다. - const anchor = anchorAt(section, side, toe, liftM, Number(spec.shift_m) || 0, adjust?.x ?? 0); - const tiers: RevetmentTier[] = tierAnchors(anchor, toe, height, requestedTiers).map((top) => { - const bottomElevation = top.elevation - height - REVET_EMBED_DEPTH_M; - const frontTop = top.offset + outward * REVET_THICKNESS_M; - const frontBottom = frontTop + outward * REVET_LEAN_RATIO * (top.elevation - bottomElevation); - return { - top, - bottomElevation, - polygon: [ - { offset: top.offset, elevation: top.elevation }, - { offset: frontTop, elevation: top.elevation }, - { offset: frontBottom, elevation: bottomElevation }, - { offset: top.offset, elevation: bottomElevation }, - ], - }; + const edge = design.road_edges?.[side]; + const groundAt = groundInterpolator(section.samples); + const limit = sampleLimit(section, outward); + if (!edge || !groundAt || limit === null) return null; + + // 재질 한계로 높이를 자른다(배관 벽과 같은 규칙). + const height = Math.min(requestedHeight, revetHeightLimit(spec.form)); + + // 배관 유출 벽과 **같은 4축 배치**. 기준(d=0) = 벽 상단이 노견인 자리. + const autoOffset = edge.offset_m + outward * (fillWallBaseWidth(height) / 2 - REVET_THICKNESS_M / 2); + const placed = placePipeWall({ + autoOffset, + outward, + height, + baseElevation0: edge.elevation_m - height, // d=0에서 벽 상단 = 노견 + edgeElevation: edge.elevation_m, + invertCap: Number.POSITIVE_INFINITY, // 관 없음 — 위 한계는 d≥0(노견)만 + adjust: { x: adjust?.x ?? 0, d: adjust?.d ?? null, h: null, m: null }, + defaultD: PIPE_WALL_DEFAULT_RUN_M, + groundAt, + limitOffset: limit, + requireCrossing: true, // 유출 벽처럼 매몰(원지반 아래 daylight 없음) 금지 }); + const baseElevation = placed.invert; + const floatGapM = Math.max(0, baseElevation - groundAt(placed.anchorOffset)); + + const material = materialOfForm(spec.form); + const lengthM = Number.isFinite(spec.end_m - spec.start_m) ? spec.end_m - spec.start_m : null; + const tier1 = buildRevetWallGeometry({ + anchor: { offset: placed.anchorOffset, elevation: baseElevation }, + outward, + height, + material, + role: "outlet", + form: spec.form, + lengthM, + floatGapM, + }); + + // 다단 — 배관과 같은 함수. 1단 벽 전면 하단 꼭짓점을 성토부선 시작점으로. + const requestedTiers = Math.max(1, Math.round(Number(spec.tiers) || 1)); + const extras = + requestedTiers > 1 + ? buildExtrasAt(tier1.bottomFront, { + startBottomElevation: tier1.bottomBack.elevation, + outward, + groundAt, + limitOffset: limit, + adjusts: Array.from({ length: requestedTiers - 1 }, () => ({ ...ZERO_ADJUST })), + equalize: false, + }) + : { walls: [], segments: [], appliedAdjusts: [], addable: false }; + + const walls: WallLayout[] = [tier1, ...extras.walls]; + const jt = tier1.topJoint; + // 노견 → 벽 이음선 상단점 성토선(1:1.2, 벽이 밖으로 밀리면 그만큼 노견이 연장된다). + const slope = { points: [{ offset: edge.offset_m, elevation: edge.elevation_m }, jt] }; + const designTrim: CulvertDesignTrim = + outward > 0 + ? { minOffset: -Infinity, maxOffset: jt.offset, maxElevation: jt.elevation, maxSlope: slope } + : { minOffset: jt.offset, maxOffset: Infinity, minElevation: jt.elevation, minSlope: slope }; return { side, heightM: height, chainageM: Number(section.chainage_m) || 0, requestedTiers, - top: tiers[0]?.top ?? anchor, - tiers, + top: jt, + walls, + fillSegments: extras.segments, + designTrim, + appliedAdjust: { x: placed.x, d: placed.d, h: adjust?.h ?? null, m: null }, + tiers: walls.map((wall) => ({ polygon: wall.points })), span: { beforeM: Math.max(spec.anchor_m - spec.start_m, 0), afterM: Math.max(spec.end_m - spec.anchor_m, 0), @@ -258,15 +222,14 @@ let shortfallTimer = 0; let shortfallLast = ""; function noticeShortfall(layout: RevetmentLayout): void { - if (layout.tiers.length >= layout.requestedTiers) return; - // 같은 측점이 다시 그려져도 한 번만 센다 — 카드는 조작할 때마다 다시 그린다. + if (layout.walls.length >= layout.requestedTiers) return; shortfallStations.add(Math.round(layout.chainageM * 100)); window.clearTimeout(shortfallTimer); shortfallTimer = window.setTimeout(() => { const message = - `기슭막이 ${layout.requestedTiers}단 중 ${layout.tiers.length}단만 세울 수 있습니다` + + `기슭막이 ${layout.requestedTiers}단 중 ${layout.walls.length}단만 세울 수 있습니다` + `(측점 ${shortfallStations.size}곳) — 아래 자리가 원지반에 묻힙니다. ` + - "기준 올림(사면 위로)으로 1단을 올린 뒤 단을 늘리세요."; + "기준 벽을 사면 위로 올린 뒤(▲) 단을 늘리세요."; shortfallStations.clear(); if (message === shortfallLast) return; shortfallLast = message; @@ -274,7 +237,7 @@ function noticeShortfall(layout: RevetmentLayout): void { }, 400); } -/** 횡단 카드에 벽 단면을 그린다(다단이면 단마다 하나). 그렸으면 true. */ +/** 횡단 카드에 독립 기슭막이(1단+다단)를 그린다 — 배관 벽과 같은 도형. 그렸으면 setter. */ export function appendRevetmentOverlay( svg: SVGElement, layout: RevetmentLayout | null, @@ -282,26 +245,53 @@ export function appendRevetmentOverlay( y: (elevation: number) => number, onSelect?: () => void, ): ((active: boolean) => void) | null { - if (!layout || !layout.tiers.length) return null; + if (!layout || !layout.walls.length) return null; noticeShortfall(layout); - const drawn: SVGPolygonElement[] = []; - for (const tier of layout.tiers) { - const polygon = document.createElementNS(SVG_NS, "polygon"); - polygon.setAttribute( - "points", - tier.polygon.map((point) => `${x(point.offset)},${y(point.elevation)}`).join(" "), + // 성토부선(벽 사이·벽 아래) 먼저 — 벽 밑에 깔린다. + for (const segment of layout.fillSegments) { + if (segment.kind === "cut") continue; + appendPlanLine( + svg, + segment.points, + x, + y, + `독립 기슭막이 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` + + (segment.overLimit ? " (법정 5m 이상 — 단 추가 검토)" : " (법정 5m 이내)") + + ` · 성토 물매 1:${(segment.ratio ?? 1.2).toFixed(2)}`, ); - polygon.setAttribute("class", "b06-chart__revetment"); - if (onSelect) { - // 배관 기슭막이와 같은 조작 — 벽을 누르면 조정창이 열린다(2026-08-28 사용자). - polygon.classList.add("is-selectable"); - polygon.addEventListener("click", (event) => { + } + const drawn: SVGPolygonElement[] = []; + layout.walls.forEach((wall, index) => { + const keyId = index === 0 ? "own" : `own-extra${index - 1}`; + const tooltip = + `독립 기슭막이 ${wall.form ?? ""} H=${(wall.height + REVET_EMBED_DEPTH_M).toFixed(1)}m` + + `(상단 = 성토선 접점, 전면 1:${REVET_LEAN_RATIO}, 높이 한계 ${revetHeightLimit( + wall.form, + ).toFixed(1)}m — 교본 7-3)` + + (wall.floatGapM > 0.01 + ? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 별도` + : ` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m`) + + (wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : ""); + const shape = drawRevetWall(svg, wall, x, y, "b06-chart__culvert-revet", tooltip, keyId); + if (onSelect) shape.classList.add("is-selectable"); + drawn.push(shape); + }); + // 클릭 판정용 투명 겹면 — **맨 위**에 얹는다. 돌 해칭이 벽 폴리곤을 덮어 직접 핸들러를 + // 달면 가운데 클릭이 돌에 먹힌다(배관 벽과 같은 규칙 — 판정면만 위로). + if (onSelect) { + for (const wall of layout.walls) { + const hit = document.createElementNS(SVG_NS, "polygon"); + hit.setAttribute( + "points", + wall.points.map((p) => `${x(p.offset)},${y(p.elevation)}`).join(" "), + ); + hit.setAttribute("class", "b06-chart__culvert-revet-hit"); + hit.addEventListener("click", (event) => { event.stopPropagation(); onSelect(); }); + svg.append(hit); } - svg.append(polygon); - drawn.push(polygon); } return (active: boolean): void => { for (const polygon of drawn) polygon.classList.toggle("is-active", active); diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 23afdb3e..a941a920 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -26,6 +26,7 @@ import { type RockBoundaryControl, } from "./B06_Section_UI_Cross_Design"; import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert"; +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"; @@ -442,27 +443,38 @@ export function createCrossSectionCard( toDisplayY, ); if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY); - // 독립 기슭막이 — 성토면 끝에 서는 벽. 3D도 같은 폴리곤을 스윕한다. - // 조작값(4축)은 배관 벽과 같은 저장소(design.revet_adjust["own"])를 쓴다. - const ownAdjust = revetOffset?.adjustFor(section, "own"); - const ownLayout = computeRevetmentLayout(section, ownAdjust); - const setOwnActive = appendRevetmentOverlay( - plotLayer, - ownLayout, - x, - toDisplayY, - revetOffset ? () => toggleRevet("own") : undefined, - ); - if (ownLayout && setOwnActive) { - // 재질은 구조물 옵션(형태)이 정본이라 조정창에서 숨긴다 — 자리만 채운다. - culvertWallSpecs.set("own", { height: ownLayout.heightM, material: "dry" }); - culvertAppliedD.set("own", ownAdjust?.d ?? 0); - const baseSetActive = setRevetActive; - setRevetActive = (key) => { - baseSetActive(key); - setOwnActive(key === "own"); - }; - if (activeRevet === "own") setOwnActive(true); + // 독립 기슭막이(구 D군 경로) — 이 측점에 배관/숨김 기슭막이 세트(section.culvert)가 + // 붙으면 그쪽(배관 경로)이 그린다. 아직 이관 안 된 정본만 이 옛 경로로 그린다 + // (2026-08-28 이관: pipe_points 숨김 세트로 옮기는 중 — 이중 그리기 방지 가드). + let ownDesignTrim: CulvertDesignTrim | undefined; + // 이 측점에 배관/숨김 기슭막이 세트가 직접 붙었거나(section.culvert) **연동으로 + // 옆에서 이어져 온**(culvertLink) 경우엔 배관 경로가 그린다 — 옛 D경로는 건너뛴다 + // (둘 다 그리면 이웃 카드에 벽이 겹친다 — 2026-08-28 이관 이중그리기 방지). + if (!section.culvert && !culvertLink) { + const ownAdjust = revetOffset?.adjustFor(section, "own"); + const ownLayout = computeRevetmentLayout(section, ownAdjust); + ownDesignTrim = ownLayout?.designTrim; + const setOwnActive = appendRevetmentOverlay( + plotLayer, + ownLayout, + x, + toDisplayY, + revetOffset ? () => toggleRevet("own") : undefined, + ); + if (ownLayout && setOwnActive) { + // 한계에 잘린 실제 적용값을 조작 저장소에 되받는다(배관 벽과 같은 규칙) — + // 눌러도 안 움직이는데 숫자만 커지는 것 방지. + revetOffset?.syncApplied(section.chainage_m, "own", ownLayout.appliedAdjust); + // 재질은 구조물 옵션(형태)이 정본이라 조정창에서 숨긴다 — 자리만 채운다. + culvertWallSpecs.set("own", { height: ownLayout.heightM, material: "dry" }); + culvertAppliedD.set("own", ownLayout.appliedAdjust.d ?? 0); + const baseSetActive = setRevetActive; + setRevetActive = (key) => { + baseSetActive(key); + setOwnActive(key === "own"); + }; + if (activeRevet === "own") setOwnActive(true); + } } appendCrossDesignOverlay( plotLayer, @@ -470,7 +482,10 @@ export function createCrossSectionCard( x, toDisplayY, drawSamples, - culvertLayout?.designTrim ?? fordLayout?.designTrim ?? boxLayout?.designTrim, + culvertLayout?.designTrim ?? + fordLayout?.designTrim ?? + boxLayout?.designTrim ?? + ownDesignTrim, ); // 암 경계선 = 지면선 복사 + 오프셋(계획선 기준 아님). if (rockBoundary && section.design.geometry_preset === "rock") { @@ -484,7 +499,11 @@ export function createCrossSectionCard( } // 배수관 세트 — 조정창이 쓸 카드 상태를 받아 둔다(2026-08-19). 관은 소유 측점 // 전용이라 링크 카드에서는 길이를 비우고, 나머지는 링크 카드도 채운다(2026-08-24). - culvertPipeLengthM = isLinkedCulvert ? null : (culvertLayout?.pipe.lengthM ?? null); + // 숨김 기슭막이·링크 카드는 관이 없거나 옆에서 이어져 온 것 — 관 길이 표기 숨김. + culvertPipeLengthM = + isLinkedCulvert || culvertLayout?.culvert.hidden_pipe + ? null + : (culvertLayout?.pipe.lengthM ?? null); culvertInletIsBasin = !!culvertLayout?.basin; if (culvertLayout) { const state = culvertCardState(culvertLayout); @@ -519,7 +538,8 @@ export function createCrossSectionCard( x, toDisplayY, revetOffset ? toggleRevet : undefined, - isLinkedCulvert, + // 독립 기슭막이(hidden_pipe)는 관을 그리지 않는다 — 연동 링크 카드와 같은 규칙. + isLinkedCulvert || culvertLayout.culvert.hidden_pipe === true, ); if (activeRevet) setRevetActive(activeRevet); } @@ -571,12 +591,6 @@ export function createCrossSectionCard( y2: heightPx - CROSS_PAD.bottom, class: "b06-chart__axis", }), - svgText(L("B06_Profile_View_CrossXAxis"), { - x: widthPx / 2, - y: heightPx - 8, - "text-anchor": "middle", - class: "b06-chart__axis-label", - }), svgText(L("B06_Profile_View_ElevationAxis"), { x: 13, y: heightPx / 2, @@ -620,7 +634,14 @@ export function createCrossSectionCard( // 구조물 조정은 도면 안 오버레이 창(2026-08-21). 화면 좌(◀) = offset 증가 — // 벽 기준 이동량(outward 부호)으로 환산해 넘긴다. const outwardOf = (role: RevetKey): number => - ((section.uphill_side ?? "left") === "left") === (role === "inlet") ? 1 : -1; + // 독립 기슭막이는 배관 유입/유출과 무관 — 정본 설치 측(좌 = +offset)으로 부호를 잡는다. + role === "own" + ? section.revetment?.side === "우" + ? -1 + : 1 + : ((section.uphill_side ?? "left") === "left") === (role === "inlet") + ? 1 + : -1; const heightOfWall = (key: RevetKey): number => culvertWallSpecs.get(key)?.height ?? 0; const materialOfWall = (key: RevetKey): RevetMaterial => culvertWallSpecs.get(key)?.material ?? "dry"; diff --git a/B06_Section/B06_Section_UI_Cross_Wall.ts b/B06_Section/B06_Section_UI_Cross_Wall.ts new file mode 100644 index 00000000..8d014974 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Wall.ts @@ -0,0 +1,209 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Wall.ts + * 기슭막이 벽 **공용 기하 + 그리기** — 배관 세트 벽(`_Cross_Culvert_Geom`/`_Culvert`)과 + * 독립 기슭막이(`_Cross_Revetment`)가 같은 산식·같은 도형을 쓰도록 뽑아낸 층이다 + * (2026-08-28 사용자: "배관용에서 배관만 빼면 대부분 동일해야"). 벽 1매의 합성 단면 + * (하부 사다리꼴 + 상부 평행사변형, 배면 수직·전면 1:0.3)과 그 돌쌓기 표현은 여기 하나뿐. + * + * 배관 벽 계산은 아직 `_Cross_Culvert_Geom`이 자체 인라인으로 만든다 — 결과가 이 함수와 + * 동일함을 화면으로 확인한 뒤 그쪽도 이 함수로 바꾼다(계획서 후속). 지금은 독립 기슭막이가 + * 이 함수를 써서 배관 벽과 같은 모양·자리로 선다. + * ========================================================================== */ + +import { + materialLabel, + REVET_EMBED_DEPTH_M, + REVET_LEAN_RATIO, + REVET_THICKNESS_M, +} from "./B06_Section_UI_Cross_Culvert_Const"; +import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const"; +import type { OffsetPoint, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types"; + +const SVG_NS = "http://www.w3.org/2000/svg"; + +function polygon( + points: Array<[number, number]>, + className: string, + tooltip: string, +): SVGPolygonElement { + const shape = document.createElementNS(SVG_NS, "polygon"); + shape.setAttribute("points", points.map(([px, py]) => `${px},${py}`).join(" ")); + shape.setAttribute("class", className); + if (tooltip) { + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = tooltip; + shape.append(title); + } + return shape; +} + +/** 벽 1매의 기하 입력 — 자리 기준은 **하단선 중점**(anchor.offset)과 그 자리 기준표고 + * (anchor.elevation = 관 invert 또는 지반). 벽은 그 위로 `height`만큼 선다. */ +export interface RevetWallGeometryInput { + /** 하단선 중점 offset + 기준표고(벽이 이 위로 선다). */ + anchor: OffsetPoint; + /** +1 = 화면 좌측(계류측), −1 = 우측. 전면 1:0.3이 이 부호로 기운다. */ + outward: number; + /** 계산용 높이(기준표고~상단, 근입 0.5 제외). */ + height: number; + material: RevetMaterial; + role: "inlet" | "outlet" | "extra"; + /** 표기용 형태 — 미지정이면 재질 라벨(메/찰/콘크리트). */ + form?: string | null; + lengthM?: number | null; + thickness?: number; + floatGapM?: number; + extraIndex?: number; +} + +/** + * 합성 단면(하부 사다리꼴 + 상부 평행사변형) 꼭짓점을 만든다. 배관 벽(`buildWall`, + * `_Cross_Culvert_Geom` 335~376행)과 **같은 산식** — 배면 수직, 상단 변 = 사다리꼴 + * 상단(t/2)+띠(t), 전면 1:0.3, 하단선 = 기준표고 −0.5m 수평 기초. + */ +export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayout { + const { anchor, outward, height, material, role } = input; + const thickness = input.thickness ?? REVET_THICKNESS_M; + const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height; + const backOffset = anchor.offset - outward * (baseWidth / 2); + const topJoint = backOffset + outward * (thickness / 2); + const topElevation = anchor.elevation + height; + const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation }; + const topFront = topJoint + outward * thickness; + const frontXAt = (elevation: number): number => + topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation); + const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M; + const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation }; + const bottomFront: OffsetPoint = { + offset: frontXAt(bottomElevation), + elevation: bottomElevation, + }; + return { + role, + extraIndex: input.extraIndex, + form: input.form ?? materialLabel(material), + lengthM: input.lengthM ?? null, + backOffset, + outerOffset: bottomFront.offset, + base: anchor.elevation, + height, + floatGapM: input.floatGapM ?? 0, + material, + outward, + topBack, + topJoint: { offset: topJoint, elevation: topElevation }, + bottomBack, + bottomFront, + points: [bottomBack, topBack, { offset: topFront, elevation: topElevation }, bottomFront], + }; +} + +/** + * 벽 1매를 SVG로 그린다 — 폴리곤 + 대각 이음선 + 계류측 기운 띠의 돌쌓기 해칭. + * 배관 오버레이(`_Cross_Culvert` 161~246행)와 **같은 그리기**. 선택·강조 배선은 호출자 + * 몫이라 여기서는 본체 폴리곤만 돌려준다. `keyId`는 clipPath id 중복 방지용. + */ +export function drawRevetWall( + layer: SVGElement, + wall: WallLayout, + x: (offset: number) => number, + toDisplayY: (elevation: number) => number, + className: string, + tooltip: string, + keyId: string, +): SVGPolygonElement { + const revetShape = polygon( + wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), + className, + tooltip, + ); + layer.append(revetShape); + + // 평행사변형 띠와 사다리꼴 사이 대각 이음선(내부 경계) — 상단 변 중간점에서 바닥으로. + const joint = document.createElementNS(SVG_NS, "line"); + const jointBaseOffset = wall.outerOffset - wall.outward * REVET_THICKNESS_M; + const bottomSpan = wall.bottomFront.offset - wall.bottomBack.offset; + const bottomAtOffset = (offset: number): number => + Math.abs(bottomSpan) > 1e-9 + ? wall.bottomBack.elevation + + (wall.bottomFront.elevation - wall.bottomBack.elevation) * + ((offset - wall.bottomBack.offset) / bottomSpan) + : wall.bottomBack.elevation; + joint.setAttribute("x1", String(x(wall.topJoint.offset))); + joint.setAttribute("y1", String(toDisplayY(wall.topJoint.elevation))); + joint.setAttribute("x2", String(x(jointBaseOffset))); + joint.setAttribute("y2", String(toDisplayY(bottomAtOffset(jointBaseOffset)))); + joint.setAttribute("class", "b06-chart__culvert-stone"); + layer.append(joint); + + // 돌 해칭 — 큰 돌이 한 줄로 쌓인 계류측 기운 띠. 벽 폴리곤 clip 안에만 그려 어떤 + // 높이에서도 돌이 벽 밖으로 새지 않게 한다. + const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1; + const clipId = `b06-revet-clip-${keyId}-${Math.round(wall.backOffset * 100)}`; + const clip = document.createElementNS(SVG_NS, "clipPath"); + clip.setAttribute("id", clipId); + clip.append( + polygon( + wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]), + "", + "", + ), + ); + const stoneGroup = document.createElementNS(SVG_NS, "g"); + stoneGroup.setAttribute("clip-path", `url(#${clipId})`); + layer.append(clip, stoneGroup); + + const centerBaseOffset = wall.outerOffset - wall.outward * (REVET_THICKNESS_M / 2); + const embedBase = bottomAtOffset(centerBaseOffset); + const stoneSpan = Math.max(0.3, wall.topBack.elevation - embedBase); + const stoneCount = Math.max(2, Math.round(stoneSpan / 0.45)); + const stoneHeight = stoneSpan / stoneCount; + const centerBase = centerBaseOffset; + const centerTop = wall.topJoint.offset + wall.outward * (REVET_THICKNESS_M / 2); + const axisX = x(centerTop) - x(centerBase); + const axisY = toDisplayY(wall.topBack.elevation) - toDisplayY(embedBase); + const leanDegrees = (Math.atan2(axisX, -axisY) * 180) / Math.PI; + for (let i = 0; i < stoneCount; i += 1) { + const fraction = (i + 0.5) / stoneCount; + const centerOffset = centerBase + (centerTop - centerBase) * fraction; + const centerElevation = embedBase + stoneSpan * fraction; + const cx = x(centerOffset); + const cy = toDisplayY(centerElevation); + const stone = document.createElementNS(SVG_NS, "rect"); + const widthPx = REVET_THICKNESS_M * pixelsPerMeter * 0.9; + const heightPx = stoneHeight * pixelsPerMeter * 0.86; + stone.setAttribute("x", String(cx - widthPx / 2)); + stone.setAttribute("y", String(cy - heightPx / 2)); + stone.setAttribute("width", String(widthPx)); + stone.setAttribute("height", String(heightPx)); + stone.setAttribute("rx", String(Math.min(widthPx, heightPx) * 0.3)); + stone.setAttribute("transform", `rotate(${leanDegrees.toFixed(1)} ${cx} ${cy})`); + stone.setAttribute("class", "b06-chart__culvert-stone"); + stoneGroup.append(stone); + } + return revetShape; +} + +/** 성토·절토 접속선(공사 계획선) 폴리라인 — 설계선과 같은 보라 실선. */ +export function appendPlanLine( + layer: SVGElement, + points: OffsetPoint[], + x: (offset: number) => number, + toDisplayY: (elevation: number) => number, + tooltip: string, + className = "b06-chart__design-cross", +): void { + if (points.length < 2) return; + const line = document.createElementNS(SVG_NS, "polyline"); + line.setAttribute( + "points", + points.map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "), + ); + line.setAttribute("class", className); + if (tooltip) { + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = tooltip; + line.append(title); + } + layer.append(line); +} diff --git a/B06_Section/B06_Section_UI_Section_Common.ts b/B06_Section/B06_Section_UI_Section_Common.ts index 90697d4c..a6416965 100644 --- a/B06_Section/B06_Section_UI_Section_Common.ts +++ b/B06_Section/B06_Section_UI_Section_Common.ts @@ -48,7 +48,7 @@ export const CROSS_GRID_GAP = 16; // (2026-08-05 사용자 보고). sticky Y축 마스크·테이블 행제목 폭이 모두 이 값에서 // 파생되므로 함께 넓어져 가로 스크롤 시 값 누출이 없다. export const LONG_PAD = { left: 78, right: 24, top: 12, bottom: 26 }; -export const CROSS_PAD = { left: 58, right: 20, top: 10, bottom: 52 }; +export const CROSS_PAD = { left: 58, right: 20, top: 10, bottom: 26 }; export interface YScaleOptions { pixelsPerMeter: number; diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index 5c37f738..1a434230 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -44,11 +44,15 @@ PIPE_FACILITY_PIPE = "pipe" # 배관(횡단배수관) — 기본 PIPE_FACILITY_BOX = "box_culvert" # BOX암거 PIPE_FACILITY_FORD_PAVEMENT = "ford_pavement" # 물넘이포장 PIPE_FACILITY_FORD_BRIDGE = "ford_bridge" # 세월교 +# 독립 기슭막이(2026-08-28 사용자) — 배관 없이 성토 사면에 세우는 벽. 배관 세트 경로를 +# 그대로 태우되 관을 숨긴다(hidden_pipe). 수량은 관 정보를 빼고 벽만 센다. +PIPE_FACILITY_REVET = "revetment" # 독립 기슭막이(관 숨김) _KNOWN_FACILITIES = ( PIPE_FACILITY_PIPE, PIPE_FACILITY_BOX, PIPE_FACILITY_FORD_PAVEMENT, PIPE_FACILITY_FORD_BRIDGE, + PIPE_FACILITY_REVET, ) @@ -300,6 +304,9 @@ def facility_clearance_m(facility: str, options: dict[str, Any] | None) -> float if facility == PIPE_FACILITY_FORD_PAVEMENT: # 물넘이포장은 도로 위에 그대로 만든다 — 들어 올릴 이유가 없다. return 0.0 + if facility == PIPE_FACILITY_REVET: + # 독립 기슭막이는 성토 사면에 세우는 벽 — 관이 없어 들어 올릴 여유가 필요 없다. + return 0.0 diameter_m = _positive(values.get("pipe_diameter_mm"), DEFAULT_PIPE_DIAMETER_MM) / 1000.0 extra = FORD_BRIDGE_EXTRA_M if facility == PIPE_FACILITY_FORD_BRIDGE else 0.0 return diameter_m + MIN_PIPE_COVER_M + extra diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 28a7b9b8..011cef4b 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -316,7 +316,6 @@ export const ui_locales_b2 = { "BP 기준 누적거리 (횡단 측점)", "Chainage from BP (cross stations)", ], - B06_Profile_View_CrossXAxis: ["중심선 기준 편거리 (m)", "Offset from centerline (m)"], B06_Profile_View_ElevationAxis: ["지반고 (m)", "Elevation (m)"], B06_Profile_View_CenterElevation: ["중심고", "Center elevation"], B06_Profile_View_Azimuth: ["방위각", "Azimuth"],