diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 177a0ec17..a6c86b1af 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -103,6 +103,8 @@ export const STATE_REGISTRY = { "cross-columns": { bucket: "pref", scope: "global" }, /** B05 · B06 종단 누가토량 알약 자리(309-2) — 그래프 영역 안 비율 `{"x":0~1,"y":0~1}` · 없으면 처음 자리. */ "mass-haul-badge-pos": { bucket: "pref", scope: "global" }, + /** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */ + "b05-3d-projection": { bucket: "pref", scope: "global" }, "section-panel-height": { bucket: "pref", scope: "global", diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 0fde4d5a6..31f2c8a4f 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -170,7 +170,7 @@ async def sync_uphill_overrides_into_designs( paved=bool(design.get("paved", False)), standard=stored_standard, rock_boundary_offset_m=design.get("rock_boundary_offset_m"), - two_stage_slope=bool(design.get("two_stage_slope", True)), + two_stage_slope=bool(design.get("two_stage_slope", False)), ditch_enabled=design.get("ditch_enabled"), ditch_choice=design.get("ditch_choice"), surface_drop_m=ford_drop_at(float(chainage), ford_drops), diff --git a/B05_Profile/B05_Profile_UI_Panel_View.ts b/B05_Profile/B05_Profile_UI_Panel_View.ts index 93e751618..f754a64f9 100644 --- a/B05_Profile/B05_Profile_UI_Panel_View.ts +++ b/B05_Profile/B05_Profile_UI_Panel_View.ts @@ -4,6 +4,7 @@ * 좌측 패널 칸이 아니라 뷰어에 붙는다 — `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b). * ========================================================================== */ +import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; import { L, button, toggleButton } from "./B05_Profile_UI_Panel_Fields"; /** 뷰 조작 줄이 부르는 것 — 좌측 패널 콜백의 일부다. */ @@ -33,7 +34,8 @@ export function createViewControls(callbacks: ViewControlCallbacks): { (["iso", "top", "front", "side"] as const).forEach((preset) => viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")), ); - // 원근·직교 두 단추 — 지금 켜진 쪽을 강조한다. 기본은 직교(카메라 기본과 같음). + // 원근·직교 두 단추 — 지금 켜진 쪽을 강조한다. 기본은 원근 · 고른 값은 화면 설정에 기억. + const initialProjection = readStateRaw("b05-3d-projection") === "ortho" ? "ortho" : "perspective"; const projectionButtons = ( [ ["perspective", "원근"], @@ -47,15 +49,17 @@ export function createViewControls(callbacks: ViewControlCallbacks): { el.classList.toggle("is-active", other === kind); el.setAttribute("aria-pressed", String(other === kind)); }); + writeStateRaw("b05-3d-projection", kind); callbacks.onProjection(kind); }, "glass", ); - element.classList.toggle("is-active", kind === "ortho"); - element.setAttribute("aria-pressed", String(kind === "ortho")); + element.classList.toggle("is-active", kind === initialProjection); + element.setAttribute("aria-pressed", String(kind === initialProjection)); return { kind, element }; }); viewButtons.append(...projectionButtons.map(({ element }) => element)); + if (initialProjection === "ortho") callbacks.onProjection("ortho"); const visibilityButtons = document.createElement("div"); visibilityButtons.className = "b05-route__view-group"; visibilityButtons.append( diff --git a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts index 7dbe1cdd7..0e9f76dcf 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts @@ -2,8 +2,8 @@ * B05_Profile_UI_Viewer_Camera.ts * B05 뷰어의 **원근/직교 두 카메라**와 그 사이 갈아 끼우기. * - * 기본은 직교 — 원근(시야각 45°)은 단추로 고른다. 갈아 끼울 때 위치·시선·근평면· - * 먼평면과 **보이는 크기**를 그대로 옮기므로 화면이 튀지 않는다. + * 기본은 원근(시야각 45°) — 직교는 단추로 고른다(고른 값은 보기 줄이 화면 설정에 기억). + * 갈아 끼울 때 위치·시선·근평면·먼평면과 **보이는 크기**를 그대로 옮기므로 화면이 튀지 않는다. * * 카메라 객체가 바뀌므로 쓰는 쪽은 붙잡아 두지 말고 `camera()`로 그때그때 읽을 것. * ========================================================================== */ @@ -21,7 +21,7 @@ export function createCameraRig() { const perspective = new THREE.PerspectiveCamera(FOV, 1, 0.1, 100000); perspective.position.set(100, 120, 100); const ortho = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 100000); - let kind: ProjectionKind = "ortho"; + let kind: ProjectionKind = "perspective"; let aspect = 1; let halfHeight = 100; diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 95b11ad4d..f74e19f97 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -366,9 +366,7 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { berm: bermAt(section.chainage_m, design), rockBoundaryOffsetM, cutSlopeRatio: cutSlopeAt(section.chainage_m, design), - twoStageSlope: - choice?.two_stage_slope ?? - (design.two_stage_slope === undefined ? true : Boolean(design.two_stage_slope)), + twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope), // ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고, // 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다. ditchChoice: choice diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index a83fe08e0..ca0386692 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -189,7 +189,7 @@ def compute_cross_design( paved: bool = False, standard: dict[str, Any] | None = None, rock_boundary_offset_m: float | None = None, - two_stage_slope: bool = True, + two_stage_slope: bool = False, cut_slope_ratio: float | None = None, ditch_enabled: bool | None = None, ditch_choice: bool | None = None, @@ -212,7 +212,7 @@ def compute_cross_design( paved: 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 바꾼다(기하는 지반유형). standard: B06 설정 패널 편집값(STANDARD_CROSS_SECTION 형태). 요청값 → config 순. rock_boundary_offset_m: 암반 경계선 오프셋(지반선 기준, 음수=하향). 암 지반 2단계 절토용. - two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 True, 토글로 해제). + two_stage_slope: 암 지반에서 암반 경계 기준 2단계 경사 적용 여부(기본 False, 토글로 켬). cut_slope_ratio: 이 측점만 쓰는 절토 경사비(1:n 의 n). 사용자가 카드에서 넣은 값이며 None 이면 표준 횡단면 설정값을 그대로 쓴다(2026-09-07 사용자 지시). plan_radius_m: 이 측점의 평면 곡선반경(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))을 정하는 diff --git a/B06_Section/B06_Section_Engine_SampleExtend.py b/B06_Section/B06_Section_Engine_SampleExtend.py index e2dc0da03..62ca91726 100644 --- a/B06_Section/B06_Section_Engine_SampleExtend.py +++ b/B06_Section/B06_Section_Engine_SampleExtend.py @@ -151,7 +151,7 @@ def extend_unclosed_sections( 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)), + two_stage_slope=bool(design.get("two_stage_slope", False)), cut_slope_ratio=stored_cut_slope(design), ditch_enabled=design.get("ditch_enabled"), ditch_choice=design.get("ditch_choice"), diff --git a/B06_Section/B06_Section_Engine_SpoilFill.py b/B06_Section/B06_Section_Engine_SpoilFill.py index 30d267132..5ee2cd263 100644 --- a/B06_Section/B06_Section_Engine_SpoilFill.py +++ b/B06_Section/B06_Section_Engine_SpoilFill.py @@ -208,7 +208,7 @@ def enforce_spoil_fills( paved=bool(design.get("paved", False)), standard=standard_spec, rock_boundary_offset_m=design.get("rock_boundary_offset_m"), - two_stage_slope=bool(design.get("two_stage_slope", True)), + two_stage_slope=bool(design.get("two_stage_slope", False)), cut_slope_ratio=stored_cut_slope(design), ditch_enabled=design.get("ditch_enabled"), ditch_choice=design.get("ditch_choice"), diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 035ec26ff..b9ba394cc 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -242,7 +242,7 @@ def enforce_pavement_ranges( 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)), + two_stage_slope=bool(design.get("two_stage_slope", False)), cut_slope_ratio=stored_cut_slope(design), ditch_enabled=design.get("ditch_enabled"), ditch_choice=design.get("ditch_choice"), @@ -302,7 +302,7 @@ def enforce_design_lines( 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)), + two_stage_slope=bool(design.get("two_stage_slope", False)), cut_slope_ratio=stored_cut_slope(design), ditch_enabled=design.get("ditch_enabled"), ditch_choice=design.get("ditch_choice"), @@ -358,7 +358,7 @@ def enforce_ford_surface_drops( 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)), + two_stage_slope=bool(design.get("two_stage_slope", False)), cut_slope_ratio=stored_cut_slope(design), ditch_enabled=design.get("ditch_enabled"), ditch_choice=design.get("ditch_choice"), @@ -566,7 +566,7 @@ def recompute_designs_for_alignment( key, stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M), ), - two_stage_slope=bool(stored.get("two_stage_slope", True)), + two_stage_slope=bool(stored.get("two_stage_slope", False)), cut_slope_ratio=( (session_cut_slopes[key] or None) if key in session_cut_slopes @@ -657,7 +657,7 @@ def compute_default_designs( rock_boundary_offset_m=( STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M if rock_offset is None else rock_offset ), - two_stage_slope=bool(user.get("two_stage_slope", True)), + two_stage_slope=bool(user.get("two_stage_slope", False)), cut_slope_ratio=stored_cut_slope(user), ditch_choice=user.get("ditch_choice"), surface_drop_m=ford_drop_at(chainage_m, ford_drops), diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index c0434106b..0a00c7ec8 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -51,7 +51,7 @@ class CrossDesignRequest(BaseModel): # 암 경계선 오프셋(m, 지면선 기준 하향 음수). 암 지반 2단계 절토 무릎 계산에 쓴다. rock_boundary_offset_m: float | None = None # 암 지반 2단계 경사(암반 경계 아래=암 경사, 위=토사 경사) 적용 여부. 토글로 해제 가능. - two_stage_slope: bool = True + two_stage_slope: bool = False # 이 측점만 쓰는 암 절토 경사비(1:n 의 n) — 카드에서 넣은 값(2026-09-07 사용자 지시). # None 이면 표준 횡단면 설정값을 쓴다. cut_slope_ratio: float | None = Field(default=None, gt=0) diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 0c7e07f68..88b037ea8 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -338,8 +338,8 @@ export function buildDesignControls( ditch: design?.ditch_side ?? null, ditchType: design?.ditch_type ?? "standard", paved: design?.paved ?? false, - // 암 design일 때만 저장값을 신뢰(토사는 two_stage=false echo가 무의미) — 암 전환 시 기본 복합경사(4번). - twoStage: design && isRock(design.ground_type) ? (design.two_stage_slope ?? true) : true, + // 저장된 선택은 지반유형과 상관없이 그대로 · 값이 없으면 기본 꺼짐(복합경사 기본 = 꺼짐). + twoStage: design?.two_stage_slope ?? false, // ⚠ **결과가 아니라 선택을 읽는다.** 결과를 읽으면 한 번 저장된 뒤로 // 자동 판정이 영영 안 돈다(2026-09-09에 갈라낸 자리). ditchEnabled: design?.ditch_choice ?? null, diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index 18b92f1bd..f1836b9c0 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -131,7 +131,7 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { ditch_side: design.ditch_side ?? null, ditch_type: design.ditch_type ?? "standard", paved: design.paved, - two_stage_slope: design.two_stage_slope ?? true, + two_stage_slope: design.two_stage_slope ?? false, ditch_choice: design.ditch_choice ?? null, }; } diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index 2c6b95a76..f33151829 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -345,7 +345,7 @@ export function computeCrossDesign( const rockBoundaryOffsetM = options.rockBoundaryOffsetM ?? null; // 2단계 절토는 암 프리셋에서만, 암반 경계 오프셋이 있을 때만 켠다. const enableTwoStage = - presetKey === "rock" && (options.twoStageSlope ?? true) && rockBoundaryOffsetM !== null; + presetKey === "rock" && (options.twoStageSlope ?? false) && rockBoundaryOffsetM !== null; // 곡선부 확폭 — 표는 하한이고, 확폭을 더한 유효너비가 법정 상한(5m)을 넘지 않게 자른다. // 짝: 파이썬 `compute_cross_design`. 표·상한 값은 config 한 곳에서 온다. const outerSide = options.curveOuterSide; @@ -528,7 +528,7 @@ export function computeCrossDesign( // 아니다(2026-09-07, 짝: `B06_Section_Engine_Design.py`). 적용 결과를 저장하면 토사 // 측점처럼 못 쓰는 자리에서 false 가 저장되고, 그 false 가 다음 재계산 인자로 되먹여져 // 사용자의 「켬」이 영구히 사라졌다. - two_stage_slope: options.twoStageSlope ?? true, + two_stage_slope: options.twoStageSlope ?? false, fill_slope_ratio: round4(geometry.fillRatio), roadbed_width_m: round4(geometry.leftExtent + geometry.rightExtent), carriageway_width_m: round4(geometry.halfRoadLeft + geometry.halfRoadRight), diff --git a/resources/tester/test_316_defaults.py b/resources/tester/test_316_defaults.py new file mode 100644 index 000000000..086d0d9b5 --- /dev/null +++ b/resources/tester/test_316_defaults.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +"""PLAN 316 — 기본값 둘. + +316-1 B06 횡단 「복합경사」(two_stage_slope) 기본 = 꺼짐 · 저장된 고른 값은 그대로. + 두 짝(파이썬 `compute_cross_design` ↔ TS `computeCrossDesign`)의 같은 기본은 + `test_b06_cross_design_mirror.py` 「발파암 복합경사 값 없음」 갈래가 실제 대조. +316-2 B05 3D 영역 첫 보기 = 원근 · 화면 설정에 기억한 값이 있으면 그 값. +""" + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 +from B06_Section.B06_Section_Schema import CrossDesignRequest # noqa: E402 +from config.config_system import STANDARD_CROSS_SECTION # noqa: E402 + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def _samples() -> list[dict]: + offsets = [round(-12 + i * 0.5, 3) for i in range(49)] + return [{"offset_m": x, "elevation_m": 100.0 - 0.55 * x, "valid": True} for x in offsets] + + +def _rock(**extra) -> dict: + return compute_cross_design( + _samples(), + 99.0, + ground_type="blasting_rock", + section_mode="left_cut", + ditch_side="left", + standard=STANDARD_CROSS_SECTION, + rock_boundary_offset_m=-1.4, + **extra, + ) + + +def test_engine_default_off(): + plain, off, on = _rock(), _rock(two_stage_slope=False), _rock(two_stage_slope=True) + assert plain["two_stage_slope"] is False + assert plain["cut_area_m2"] == off["cut_area_m2"] + # 고른 「켬」은 그대로 — 면적이 실제로 달라야 기본값 시험이 뜻이 있음 + assert on["two_stage_slope"] is True + assert on["cut_area_m2"] != off["cut_area_m2"] + + +def test_request_schema_default_off(): + field = CrossDesignRequest.model_fields["two_stage_slope"] + assert field.default is False + + +def test_python_stored_reads_default_off(): + for rel in ( + "B06_Section/B06_Section_Router_Design.py", + "B06_Section/B06_Section_Engine_SpoilFill.py", + "B06_Section/B06_Section_Engine_SampleExtend.py", + "B05_Profile/B05_Profile_Router_Confirm.py", + ): + src = _read(rel) + assert '"two_stage_slope", True' not in src, rel + assert '"two_stage_slope", False' in src, rel + + +def test_ts_defaults_off_and_stored_kept(): + engine = _read("common_util/common_util_cross_design.ts") + assert engine.count("options.twoStageSlope ?? false") == 2 + assert "twoStageSlope ?? true" not in engine + # 카드 토글 — 저장된 고른 값 그대로 · 없으면 꺼짐 + assert "twoStage: design?.two_stage_slope ?? false," in _read( + "B06_Section/B06_Section_UI_Cross_Design.ts" + ) + # 브라우저 재계산 인자 — 세션 선택 → 저장값 → 꺼짐 + assert "twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope)," in _read( + "B06_Section/B06_Section_Cross_Refresh.ts" + ) + assert "two_stage_slope: design.two_stage_slope ?? false," in _read( + "B06_Section/B06_Section_UI_Page_Design_Sync.ts" + ) + + +def test_3d_first_view_perspective(): + camera = _read("B05_Profile/B05_Profile_UI_Viewer_Camera.ts") + assert 'let kind: ProjectionKind = "perspective";' in camera + state = _read("A00_Common/b_page_state.ts") + assert re.search(r'"b05-3d-projection": \{ bucket: "pref", scope: "global" \}', state) + + +def test_3d_remembered_projection_wins(): + src = _read("B05_Profile/B05_Profile_UI_Panel_View.ts") + # 기억 = ortho 일 때만 직교 · 그 밖(없음 · 손상)은 원근 + assert re.search( + r'const initialProjection = readStateRaw\("b05-3d-projection"\) === "ortho"\s*\?\s*"ortho"\s*:\s*"perspective";', + src, + ) + assert 'writeStateRaw("b05-3d-projection", kind);' in src + assert 'if (initialProjection === "ortho") callbacks.onProjection("ortho");' in src + # 단추 강조도 처음 값 따라 + assert 'element.classList.toggle("is-active", kind === initialProjection);' in src diff --git a/resources/tester/test_b06_cross_design_mirror.py b/resources/tester/test_b06_cross_design_mirror.py index 22f84dad6..c3789c971 100644 --- a/resources/tester/test_b06_cross_design_mirror.py +++ b/resources/tester/test_b06_cross_design_mirror.py @@ -95,6 +95,18 @@ CASES = [ "two_stage_slope": False, }, }, + { + # 316-1 — 복합경사 값 없음 = 기본 꺼짐(두 짝 같은 기본) + "name": "발파암 복합경사 값 없음", + "samples": _ground(-0.55), + "design_elevation_m": 99.0, + "options": { + "ground_type": "blasting_rock", + "section_mode": "left_cut", + "ditch_side": "left", + "rock_boundary_offset_m": -1.4, + }, + }, { "name": "포장 + 측구 없음", "samples": _ground(0.12),