From bfa2b3a97ff1b0dfa0de30e3f5aa48685a0ab72c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 29 Oct 2026 13:13:39 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(=ED=8F=89=EB=A9=B4):=20=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=20=EB=B9=84=ED=83=88=20=EB=81=9D=20=C2=B7=20B07=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=ED=8F=89=EB=A9=B4=EB=8F=84=20=3D=20=EB=B2=BD?= =?UTF-8?q?=20=EC=B8=A1=EC=A0=90=20=ED=9A=A1=EB=8B=A8=20=EC=B5=9C=EC=A2=85?= =?UTF-8?q?=20=EB=AA=A8=EC=96=91=EC=9D=B4=20=EB=95=85=EA=B3=BC=20=EB=A7=8C?= =?UTF-8?q?=EB=82=98=EB=8A=94=20=EC=A0=90=20(PLAN=20578-2=20=C2=B7=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20438=20=C2=B7=20439)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B06 STRUCTURE_ROW_KEYS · 서버 _AREA_KEYS 에 slope_end_left_m · slope_end_right_m — 벽 트림 측점만 · 트림 사면 → 단마다 벽 앞 → 사이 성토부선 윤곽이 처음 땅에 닿는 오프셋 - 평면 발자국 finalSlopeEnd · B07 final_slope_end(짝) — 얹은 값 먼저 · 없으면 설계선 끝(구조물 없는 측점 그대로) - 시험 test_578_2_plan_slope_end 새로 · test_346_2 거울에 벽 측점 줄 Co-Authored-By: Claude Opus 5.5 --- .../logic/B05_Profile_PlanFootprint.ts | 27 ++++- .../B06_Section_Server_Calc_Prebuild.py | 2 + .../logic/B06_Section_Structure_Layouts.ts | 31 ++++++ .../B07_DesignDetail_Engine_Cad_PlanSlope.py | 17 ++- .../B07_DesignDetail_Router_Support_Plan.py | 9 +- .../test_346_2_plan_footprint_mirror.py | 11 +- resources/tester/test_578_2_plan_slope_end.py | 100 ++++++++++++++++++ 7 files changed, 181 insertions(+), 16 deletions(-) create mode 100644 resources/tester/test_578_2_plan_slope_end.py diff --git a/B05_Profile/logic/B05_Profile_PlanFootprint.ts b/B05_Profile/logic/B05_Profile_PlanFootprint.ts index 709ca7d57..17674a6c5 100644 --- a/B05_Profile/logic/B05_Profile_PlanFootprint.ts +++ b/B05_Profile/logic/B05_Profile_PlanFootprint.ts @@ -3,8 +3,8 @@ * 평면 발자국 — 측점 횡단 설계 결과를 평면 선으로(도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금) (346-2) * * ⚠⚠ 짝: `B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py` - * (`route_frame` · `route_normal` · `side_is_cut` · `slope_end` · `slope_pieces` 와 그 아래 - * 단조 3차 보간 · 촘촘히 · 접힘 버림 · 353-1 · 모서리 둥글림 · 364-3) — 한쪽만 고치면 + * (`route_frame` · `route_normal` · `side_is_cut` · `slope_end` · `final_slope_end` · + * `slope_pieces` 와 그 아래 단조 3차 보간 · 촘촘히 · 접힘 버림 · 353-1 · 모서리 둥글림 · 364-3) — 한쪽만 고치면 * 종단 · 횡단 편집 중 평면과 B07 계획평면도가 갈린다. * `footprintRecord` = `B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄. * 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py` @@ -35,6 +35,9 @@ export interface FootprintStation { right?: FootprintPoint | null; } | null; design_line?: FootprintPoint[] | null; + /** 구조물 처리 뒤 비탈 끝(B06 `STRUCTURE_ROW_KEYS` · 벽 측점만 · 578-2). */ + slope_end_left_m?: number | null; + slope_end_right_m?: number | null; } | null; samples: FootprintPoint[]; } @@ -156,6 +159,20 @@ export function slopeEnd( return end; } +/** 구조물 처리 뒤 비탈 끝 — 벽 측점은 B06 이 얹은 `slope_end_<쪽>_m`(578-2) · 없으면 설계선 끝. + * 짝: `final_slope_end`. */ +export function finalSlopeEnd( + design: NonNullable, + samples: FootprintPoint[], + edge: number, + side: Side, +): number { + const stored = design[`slope_end_${side}_m`]; + return typeof stored === "number" + ? stored + : slopeEnd(design.design_line ?? [], samples, edge, side); +} + /** 측점 한 자리 → `slope_pieces` 줄 · 도로 끝이 없으면 null. 짝: `slope_records` 한 줄. */ export function footprintRecord(station: FootprintStation): FootprintRecord | null { const edges = station.design?.road_edges; @@ -163,14 +180,14 @@ export function footprintRecord(station: FootprintStation): FootprintRecord | nu const right = Number(edges?.right?.offset_m); if (edges?.left?.offset_m == null || edges?.right?.offset_m == null) return null; if (!Number.isFinite(left) || !Number.isFinite(right)) return null; - const line = station.design?.design_line ?? []; + const design = station.design ?? {}; const samples = station.samples ?? []; const record: FootprintRecord = { chainage_m: station.chainage_m, left_edge_m: left, - left_end_m: slopeEnd(line, samples, left, "left"), + left_end_m: finalSlopeEnd(design, samples, left, "left"), right_edge_m: right, - right_end_m: slopeEnd(line, samples, right, "right"), + right_end_m: finalSlopeEnd(design, samples, right, "right"), }; for (const side of SIDES) { const elevation = edges[side]?.elevation_m; diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index bf32e7e97..34798e2e8 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -73,6 +73,8 @@ _AREA_KEYS = ( "box_ground_slope_pct", "box_length_m", "ford_pipe_length_m", + "slope_end_left_m", + "slope_end_right_m", ) diff --git a/B06_Section/logic/B06_Section_Structure_Layouts.ts b/B06_Section/logic/B06_Section_Structure_Layouts.ts index f9581a7c9..21d073dfa 100644 --- a/B06_Section/logic/B06_Section_Structure_Layouts.ts +++ b/B06_Section/logic/B06_Section_Structure_Layouts.ts @@ -33,6 +33,7 @@ import type { import { computeFordLayout, fordAdjustOf } from "./B06_Section_Cross_Ford_Geom"; import type { FordLayout } from "./B06_Section_Cross_Ford_Geom"; import { computeRevetmentLayout } from "./B06_Section_Cross_Revetment_Geom"; +import type { RevetmentLayout } from "./B06_Section_Cross_Revetment_Geom"; /** 같은 측점으로 볼 누가거리 오차(m) — 정본 반올림 자릿수보다 크게 잡는다. */ const CHAINAGE_TOLERANCE_M = 0.02; @@ -163,6 +164,9 @@ export const STRUCTURE_ROW_KEYS = [ "box_length_m", // B08 자재 집계표 세월교 관 한 련 길이(131) — 그린 관 길이 1 m 올림 · 같은 길로 실음. "ford_pipe_length_m", + // 평면 비탈 끝(578-2) — 벽이 선 쪽 횡단 최종 모양이 땅과 만나는 오프셋 · 평면 발자국 · B07 계획평면도가 읽음. + "slope_end_left_m", + "slope_end_right_m", ] as const; /** 박스 두 끝(구체 끝) 원지반 경사(%) — 유입 − 유출 ÷ 수평거리. 본체는 늘 수평이라 원지반으로(110). */ @@ -191,6 +195,28 @@ function fordPipeLengthM(ford: FordLayout): number | null { return length > 0 ? Math.ceil(length - 1e-6) : null; } +/** 벽(옹벽 · 흙막이 · 돌쌓기)이 선 쪽 — 구조물 처리 뒤 횡단이 땅과 만나는 오프셋(578-2). + * 윤곽 = 트림 사면(노견 → 벽 이음) → 단마다 벽 앞(성토 위 이음 → 전면 발끝) · 사이 성토부선(3D 실루엣 차례). + * 위에서 내려와 처음 땅에 닿는 자리 · 끝까지 안 닿으면(뜬 벽) 마지막 발끝 · 노견부터 땅 밑이면 null. */ +function wallSlopeEnd(section: CrossSection, own: RevetmentLayout): number | null { + const groundAt = groundInterpolator(section.samples); + const trim = own.designTrim; + const chain = [...((own.side === "left" ? trim.maxSlope : trim.minSlope)?.points ?? [])]; + const segments = own.fillSegments.filter((segment) => segment.kind !== "cut"); + own.walls.forEach((wall, index) => { + chain.push(wall.fillTop, wall.bottomFront, ...(segments[index]?.points ?? [])); + }); + if (!groundAt || chain.length < 2) return null; + let [prev, above] = [chain[0], chain[0].elevation - groundAt(chain[0].offset)]; + if (above <= 0) return null; + for (const point of chain.slice(1)) { + const now = point.elevation - groundAt(point.offset); + if (now <= 0) return prev.offset + ((point.offset - prev.offset) * above) / (above - now); + [prev, above] = [point, now]; + } + return prev.offset; +} + /** 폐회로 면적 입력(설계선·지반선·트림) — 트림이나 설계선이 모자라면 null. */ function areaInputOf(section: CrossSection, layouts: StoredLayouts) { const trim = trimOfLayouts(layouts); @@ -254,6 +280,11 @@ function areaRowOf( const fordLength = layouts.ford && isOwner(section.ford?.chainage_m) ? fordPipeLengthM(layouts.ford) : null; if (fordLength !== null) ownRow.ford_pipe_length_m = fordLength; + // 벽 트림이 그려지는 측점만(관 · 세월교 · BOX 트림이 앞서면 그쪽 모양 · 578-2 범위 밖) + const own = layouts.own; + const wallEnd = + own && trimOfLayouts(layouts) === own.designTrim ? wallSlopeEnd(section, own) : null; + if (own && wallEnd !== null) ownRow[`slope_end_${own.side}_m`] = Number(wallEnd.toFixed(4)); const pipeRow = Object.keys(ownRow).length > 1 ? ownRow : null; const areaInput = areaInputOf(section, layouts); if (!areaInput) return pipeRow; diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py index 7624ceb54..f400bf5bc 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py @@ -3,8 +3,8 @@ `_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m. ⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · route_normal · side_is_cut · -slope_end · slope_pieces 와 그 아래 보간 · 촘촘히 · 접힘 버림 · 모서리 둥글림) — 종단 · 횡단 -편집 중 평면 발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림. +slope_end · final_slope_end · slope_pieces 와 그 아래 보간 · 촘촘히 · 접힘 버림 · 모서리 둥글림) +— 종단 · 횡단 편집 중 평면 발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림. 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py` """ @@ -86,6 +86,19 @@ def slope_end( return end +def final_slope_end( + design: dict[str, Any], samples: list[dict[str, Any]], edge: float, side: str +) -> float: + """구조물 처리 뒤 비탈 끝 — 벽 측점은 B06 이 얹은 `slope_end_<쪽>_m`(578-2) · 없으면 설계선 끝. + + 짝: `finalSlopeEnd`(TS) — 값은 B06 `STRUCTURE_ROW_KEYS` 가 서버 Node 로 셈해 설계 칸에 얹음. + """ + stored = design.get(f"slope_end_{side}_m") + if isinstance(stored, (int, float)) and not isinstance(stored, bool): + return float(stored) + return slope_end(design.get("design_line") or [], samples, edge, side) + + def _split_runs( points: list[list[float]], flags: list[bool] ) -> tuple[list[list[list[float]]], list[list[list[float]]]]: diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index 4aef25d20..c554a7be8 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -42,10 +42,9 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( curve_pieces, route_frame, side_is_cut, - slope_end, slope_pieces, ) -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import tick_step_m +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import final_slope_end, tick_step_m from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( _chunk_route, _hillshade_for_box, @@ -375,13 +374,13 @@ def slope_records( right_edge = float(edges["right"]["offset_m"]) except (KeyError, TypeError, ValueError): continue - line, samples = (design or {}).get("design_line") or [], source.get("samples") or [] + samples = source.get("samples") or [] record: dict[str, Any] = { "chainage_m": float(chainage), "left_edge_m": left_edge, - "left_end_m": slope_end(line, samples, left_edge, "left"), + "left_end_m": final_slope_end(design, samples, left_edge, "left"), "right_edge_m": right_edge, - "right_end_m": slope_end(line, samples, right_edge, "right"), + "right_end_m": final_slope_end(design, samples, right_edge, "right"), } for side in ("left", "right"): elevation = edges[side].get("elevation_m") diff --git a/resources/tester/test_346_2_plan_footprint_mirror.py b/resources/tester/test_346_2_plan_footprint_mirror.py index 1f121ef45..6694f5e64 100644 --- a/resources/tester/test_346_2_plan_footprint_mirror.py +++ b/resources/tester/test_346_2_plan_footprint_mirror.py @@ -19,9 +19,9 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import ( # noqa: E402 + final_slope_end, route_frame, side_is_cut, - slope_end, slope_pieces, ) @@ -93,6 +93,9 @@ def _stations() -> list[dict]: "samples": samples, } ) + # 벽 측점(578-2) — B06 이 얹은 구조물 처리 뒤 비탈 끝이 설계선 끝보다 앞섬 + stations[5]["design"]["slope_end_left_m"] = 4.2 + stations[6]["design"]["slope_end_right_m"] = -3.1 stations.append({**stations[3], "chainage_m": 70.0, "design": {"road_edges": None}}) stations.append({**stations[4], "chainage_m": 90.0, "samples": []}) # 지반 없음 return stations @@ -107,13 +110,13 @@ def _py_record(station: dict) -> dict | None: right_edge = float(edges["right"]["offset_m"]) except (KeyError, TypeError, ValueError): return None - line, samples = design.get("design_line") or [], station["samples"] or [] + samples = station["samples"] or [] record = { "chainage_m": float(station["chainage_m"]), "left_edge_m": left_edge, - "left_end_m": slope_end(line, samples, left_edge, "left"), + "left_end_m": final_slope_end(design, samples, left_edge, "left"), "right_edge_m": right_edge, - "right_end_m": slope_end(line, samples, right_edge, "right"), + "right_end_m": final_slope_end(design, samples, right_edge, "right"), } for side in ("left", "right"): elevation = edges[side].get("elevation_m") diff --git a/resources/tester/test_578_2_plan_slope_end.py b/resources/tester/test_578_2_plan_slope_end.py new file mode 100644 index 000000000..8d7878cc7 --- /dev/null +++ b/resources/tester/test_578_2_plan_slope_end.py @@ -0,0 +1,100 @@ +"""578-2 평면 비탈 끝 = 횡단 최종 모양(구조물 처리 뒤)이 땅과 만나는 점. + +- 벽(옹벽) 측점 — `structure.layouts`(서버 Node 묶음 · B06 `STRUCTURE_ROW_KEYS`)가 + `slope_end_<쪽>_m` 을 얹음 · 벽 앞(성토 위 이음 → 전면 발끝)이 땅과 만나는 자리 + · 설계선 끝보다 안쪽 +- 구조물 없는 측점 · 벽 반대쪽 — 키 없음(평면 선 전후 0 차) +- 파이썬 짝 `final_slope_end` — 키가 있으면 그 값 · 없으면 설계선 끝 + + ./venv/Scripts/python.exe -m pytest resources/tester/test_578_2_plan_slope_end.py -q +""" + +from __future__ import annotations + +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 B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import ( # noqa: E402 + final_slope_end, + slope_end, +) +from common_util.common_util_calc_runner import BUNDLE, run_calc # noqa: E402 + + +def _ground(offset: float) -> float: + """좌(+)로 내려가는 지반 — 좌 = 쌓기 · 우 = 깎기.""" + return 100.0 - 0.5 * offset + + +def _section(chainage: float, wall: bool) -> dict: + offsets = [-20.0 + 0.5 * i for i in range(81)] + line = [] + for o in offsets: + if -2.5 <= o <= 2.5: + z = 100.0 + elif o > 2.5: # 쌓기 1:1.5 → 10 m 에서 땅 + z = max(100.0 - (o - 2.5) / 1.5, _ground(o)) + else: # 깎기 1:1 → -5 m 에서 땅 + z = min(100.0 + (-2.5 - o), _ground(o)) + line.append({"offset_m": o, "elevation_m": z}) + section = { + "chainage_m": chainage, + "center_x": 0.0, + "center_y": chainage, + "samples": [{"offset_m": o, "elevation_m": _ground(o), "valid": True} for o in offsets], + "design": { + "design_elevation_m": 100.0, + "section_mode": "right_cut", + "road_edges": { + "left": {"offset_m": 2.5, "elevation_m": 100.0}, + "right": {"offset_m": -2.5, "elevation_m": 100.0}, + }, + "design_line": line, + "cut_area_m2": 1.0, + "fill_area_m2": 1.0, + }, + } + if wall: + section["revetment"] = { + "structure_id": "w1", + "type_id": "retaining_wall", + "name": "옹벽", + "start_m": 0.0, + "end_m": 20.0, + "anchor_m": 10.0, + "form": "콘크리트", + "height_m": 3.0, + "side": "좌", + } + return section + + +@pytest.mark.skipif(not BUNDLE.is_file(), reason="design_calc 묶음 없음(npm run build:design-calc)") +def test_벽_측점만_비탈_끝을_얹음() -> None: + sections = [_section(0.0, False), _section(10.0, True), _section(20.0, False)] + results = run_calc(["structure.layouts"], {"detail": {"cross_sections": sections}}) + assert results is not None, "Node 묶음 실패" + rows = {row["chainage_m"]: row for row in results["structure.layouts"]["areas"]} + plain = slope_end(sections[1]["design"]["design_line"], sections[1]["samples"], 2.5, "left") + assert plain == pytest.approx(10.0) + end = rows[10.0]["slope_end_left_m"] + assert 2.5 < end < plain, f"벽이 자른 끝({end})이 설계선 끝({plain}) 안쪽이어야" + assert "slope_end_right_m" not in rows[10.0], "벽 반대쪽은 그대로" + for chainage in (0.0, 20.0): + assert not any(key.startswith("slope_end_") for key in rows.get(chainage, {})) + + +def test_파이썬_짝은_얹은_값을_먼저() -> None: + section = _section(10.0, False) + design, samples = section["design"], section["samples"] + assert final_slope_end(design, samples, 2.5, "left") == pytest.approx(10.0) + assert final_slope_end({**design, "slope_end_left_m": 4.25}, samples, 2.5, "left") == 4.25 + assert final_slope_end({**design, "slope_end_left_m": None}, samples, 2.5, "left") == ( + pytest.approx(10.0) + ) From ee49754b466511bd374b12281ace1a8f8b2d57bc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 29 Oct 2026 13:31:03 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(=ED=8F=89=EB=A9=B4):=20=EB=B2=BD=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EB=B9=84=ED=83=88=20=EB=81=9D=20=EC=9C=A4?= =?UTF-8?q?=EA=B3=BD=EC=97=90=20=EB=B2=BD=20=EC=9C=97=EB=A9=B4=20=C2=B7=20?= =?UTF-8?q?=EC=95=9E=EB=A9=B4=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=E2=80=94=20?= =?UTF-8?q?3D=20=EB=B2=BD=20=EB=8F=84=ED=98=95=20=EB=95=85=20=EB=A7=8C?= =?UTF-8?q?=EB=82=A8=EA=B3=BC=200=20=EC=B0=A8=20(PLAN=20578-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 성토 위 이음 → 전면 발끝 대각선 대신 벽 도형 앞 위 → 앞 밑(buildRevetWallGeometry 차례) · 이음 안쪽 0.1 m 만큼 갈리던 것 Co-Authored-By: Claude Opus 5.5 --- B06_Section/logic/B06_Section_Structure_Layouts.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/B06_Section/logic/B06_Section_Structure_Layouts.ts b/B06_Section/logic/B06_Section_Structure_Layouts.ts index 21d073dfa..7677b3505 100644 --- a/B06_Section/logic/B06_Section_Structure_Layouts.ts +++ b/B06_Section/logic/B06_Section_Structure_Layouts.ts @@ -196,7 +196,7 @@ function fordPipeLengthM(ford: FordLayout): number | null { } /** 벽(옹벽 · 흙막이 · 돌쌓기)이 선 쪽 — 구조물 처리 뒤 횡단이 땅과 만나는 오프셋(578-2). - * 윤곽 = 트림 사면(노견 → 벽 이음) → 단마다 벽 앞(성토 위 이음 → 전면 발끝) · 사이 성토부선(3D 실루엣 차례). + * 윤곽 = 트림 사면(노견 → 벽 이음) → 단마다 벽 윗면 · 앞면(→ 전면 발끝) · 사이 성토부선(3D 실루엣 차례). * 위에서 내려와 처음 땅에 닿는 자리 · 끝까지 안 닿으면(뜬 벽) 마지막 발끝 · 노견부터 땅 밑이면 null. */ function wallSlopeEnd(section: CrossSection, own: RevetmentLayout): number | null { const groundAt = groundInterpolator(section.samples); @@ -204,7 +204,8 @@ function wallSlopeEnd(section: CrossSection, own: RevetmentLayout): number | nul const chain = [...((own.side === "left" ? trim.maxSlope : trim.minSlope)?.points ?? [])]; const segments = own.fillSegments.filter((segment) => segment.kind !== "cut"); own.walls.forEach((wall, index) => { - chain.push(wall.fillTop, wall.bottomFront, ...(segments[index]?.points ?? [])); + // 벽 도형 = [뒤 밑, 뒤 위, 앞 위, 앞 밑](`buildRevetWallGeometry`) — 앞면이 그려지는 모양 그대로 + chain.push(wall.fillTop, ...wall.points.slice(2), ...(segments[index]?.points ?? [])); }); if (!groundAt || chain.length < 2) return null; let [prev, above] = [chain[0], chain[0].elevation - groundAt(chain[0].offset)];