diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index bd27f6d19..a993a1ef1 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -214,7 +214,7 @@ 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 · + shoulder_extend_left_m · shoulder_extend_right_m: 설계선 쉬프트 = 쪽마다 사면 이동(m · ≥ 0 · 139-6) — 사용자 값 · 저장분은 `stored_shoulder_extend`. TS 짝 `shoulderExtendLeftM` 등. 폭은 **노면 끝**(노견이 시작하는 자리)에서 재고, 그 바깥 성토는 **노선 몫이 아니라 사토장 몫**이라 `fill_area_m2` 에서 뺀다(2026-09-09 확정 ㉠ — 두 번 세지 않기). @@ -444,8 +444,13 @@ def compute_cross_design( "carriageway_width_m": round(geometry.half_road_left + geometry.half_road_right, 4), # 규격 폭과 확폭을 따로 남긴다 — 횡단도 라벨·수량 산출이 둘을 나눠 쓴다. "carriageway_standard_width_m": round(group["road_width_m"], 4), - "widening_left_m": round(geometry.half_road_left - geometry.half_road, 4), - "widening_right_m": round(geometry.half_road_right - geometry.half_road, 4), + # 확폭 = 곡선부 몫만 — 사면 이동(151-2)이 넓힌 차도는 뺀다(TS 짝 같음). + "widening_left_m": round( + geometry.half_road_left - geometry.half_road - geometry.shoulder_extend_left, 4 + ), + "widening_right_m": round( + geometry.half_road_right - geometry.half_road - geometry.shoulder_extend_right, 4 + ), "cross_slope_pct": round(cross_slope_pct, 4), "ditch": ditch_spec, # 결과 — **실제로 섰나**. 읽는 쪽 여섯이 이 뜻으로 쓴다. diff --git a/B06_Section/B06_Section_Engine_Design_Geometry.py b/B06_Section/B06_Section_Engine_Design_Geometry.py index 8259639b2..9f900def6 100644 --- a/B06_Section/B06_Section_Engine_Design_Geometry.py +++ b/B06_Section/B06_Section_Engine_Design_Geometry.py @@ -140,10 +140,13 @@ class _SectionGeometry: #: 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. self.ditch_choice = choice - # 설계선 쉬프트 = 쪽마다 노견 확장(≥ 0 · 139-6) — 단면유형 · 측구 판정은 **옮기기 전** - # 노견에서(위) · 노체 · 측구 · 사면이 같이 바깥으로 · 차도는 그대로. ⚠ TS 짝 `SectionGeometry`. + # 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전** + # 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. + # ⚠ 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.half_road_left += self.shoulder_extend_left + self.half_road_right += self.shoulder_extend_right self.left_extent += self.shoulder_extend_left self.right_extent += self.shoulder_extend_right diff --git a/B06_Section/B06_Section_UI_Cross_Design_Line.ts b/B06_Section/B06_Section_UI_Cross_Design_Line.ts index cc9dbfae3..16ab63328 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Line.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Line.ts @@ -235,6 +235,8 @@ export function appendCrossDesignOverlay( // 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다. // 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다. const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0); + // 사면 이동(151-2) — 노견째 바깥으로 옮겨 늘어난 차도 몫. + const moved = (design.shoulder_extend_left_m ?? 0) + (design.shoulder_extend_right_m ?? 0); const standardWidth = design.carriageway_standard_width_m; const shoulderLeft = roadEdges ? roadEdges.left.offset_m - edges.left.offset_m : null; const shoulderRight = roadEdges ? edges.right.offset_m - roadEdges.right.offset_m : null; @@ -245,9 +247,13 @@ export function appendCrossDesignOverlay( ); label.setAttribute("y", String(labelAt?.y ?? toDisplayY(edges.left.elevation_m) - 6)); label.setAttribute("class", "b06-chart__carriageway-label"); + const extras = [ + widened > 0.001 ? ` + 확폭 ${widened.toFixed(2)}` : "", + moved > 0.001 ? ` + 이동 ${moved.toFixed(2)}` : "", + ].join(""); const widthText = - widened > 0.001 && typeof standardWidth === "number" - ? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)} + 확폭 ${widened.toFixed(2)})` + extras && typeof standardWidth === "number" + ? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)}${extras})` : `노폭 ${design.carriageway_width_m.toFixed(2)}m`; // 노견은 좌우가 같으면 한 번만 적는다 — 라벨이 길어지면 옆 측점 라벨과 겹친다. label.textContent = diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index 485538bb5..2c4204455 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -4,15 +4,22 @@ * * 그려진 보라 선(`b06-chart__design-cross`)을 화면 좌표에서 m 로 되돌려 같은 방향 토막을 * 직선 조각 하나로 합치고, 벽 · 본체 폴리곤에 가린 부분을 뺀 **보이는 조각**마다 투명 판정 - * 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 [+ − ⤢] 왼쪽에 치수를 적는다. + * 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 [+ − ⤢] 왼쪽에 치수를 두 줄로 적는다. * 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다. * 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다. - * 고른 조각의 쪽 노견을 [◀ ▶] 0.1 m 로 넓히고 좁힘(139-6 쉬프트 · 노폭 안쪽 금지 = 0 에서 멈춤). + * 고른 조각 쪽 사면을 노견째 이동 패널(◀ ↺ ▶ · 방향키 · 0.1 m)로 옮김(139-6 쉬프트 · 151-3 · + * 노폭 안쪽 금지 = 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"; +import { + bindArrowKeys, + buildPanelShell, + dpadButton, + makeRow, +} from "./B06_Section_UI_Cross_Panel_Base"; const SVG_NS = "http://www.w3.org/2000/svg"; const DESIGN_CLASS = "b06-chart__design-cross"; @@ -205,12 +212,13 @@ export function pieceMeasure( return { lengthM: Math.hypot(horizontalM, heightM), horizontalM, heightM, grade }; } +/** 두 줄(151-1) — 1줄 = 길이 · 수평 · 2줄 = 높이 · 구배(줄바꿈 글자로 가름). */ 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}` + `수평 ${m.horizontalM.toFixed(2)}m\n높이 ${m.heightM.toFixed(2)}m · ${m.grade}` ); } @@ -239,7 +247,7 @@ const pointsOf = (element: Element): XY[] => { .filter((p) => p.length === 2 && p.every(Number.isFinite)); }; -/** 설계선 쉬프트(139-6) — 쪽마다 노견 확장(m · ≥ 0). */ +/** 설계선 쉬프트(139-6) — 쪽마다 사면 이동(m · ≥ 0 · 노견째 · 151-2). */ export interface ShoulderShift { valueOf: (side: CrossSide) => number; set: (side: CrossSide, valueM: number) => void; @@ -247,7 +255,7 @@ export interface ShoulderShift { /** 쉬프트 걸음(m). */ export const SHIFT_STEP_M = 0.1; -/** 화면 ◀(−1) · ▶(+1) 한 걸음 → 그 쪽 노견 확장 새 값(0.1 m 눈금 · 0 아래 금지). +/** 화면 ◀(−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; @@ -259,6 +267,8 @@ export interface DesignLinePick { pieces: DesignPiece[]; /** 치수 칸 — 카드가 [+ − ⤢] 앞에 붙임 · 고름 없으면 숨김. */ label: HTMLElement; + /** 사면 이동 패널(151-3) — 카드가 그림 칸에 붙임 · `shift` 없거나 고름 없으면 숨김. */ + panel: HTMLElement; setActive: (index: number | null) => void; } @@ -272,7 +282,7 @@ export function attachDesignLinePick(input: { x: (offset: number) => number; toDisplayY: (elevation: number) => number; onPick: (index: number) => void; - /** 있으면 고른 조각 쪽 노견을 [◀ ▶] 로 옮김(139-6). */ + /** 있으면 고른 조각 쪽 사면을 이동 패널로 옮김(139-6 · 151-3). */ shift?: ShoulderShift; }): DesignLinePick { const { layer, design, x, toDisplayY } = input; @@ -319,40 +329,62 @@ export function attachDesignLinePick(input: { 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; + let active: number | 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(노폭 안쪽은 못 감)"), - ); - } + + // 사면 이동 패널(151-3) — B06 공용 조정창 꼴(머리 · 「이동」 줄 십자 · 방향키 ←→). + // ✕ = 같은 조각 다시 고름(카드가 풂) · ↺ = 그 쪽 0. + const shell = buildPanelShell(() => { + if (active !== null) input.onPick(active); + }); + const readout = document.createElement("div"); + readout.className = "b06-structure-panel__value"; + const move = makeRow("이동"); + move.controls.classList.add("b06-structure-panel__buttons"); + const step = (dir: -1 | 1) => () => { + if (side && shift) shift.set(side, shiftedExtend(shift.valueOf(side), side, dir, kx)); + }; + move.controls.append( + dpadButton( + "◀", + "left", + "고른 사면을 화면 왼쪽으로 0.1 m(노견째 · 노폭 안쪽은 못 감)", + step(-1), + ), + dpadButton("↺", "reset", "이 쪽 이동 0", () => { + if (side && shift) shift.set(side, 0); + }), + dpadButton( + "▶", + "right", + "고른 사면을 화면 오른쪽으로 0.1 m(노견째 · 노폭 안쪽은 못 감)", + step(1), + ), + ); + shell.root.append(readout, move.row); + const arrows = bindArrowKeys(shell.root, () => [move.controls]); + return { pieces, label, + panel: shell.root, setActive: (index) => { const piece = index === null ? undefined : pieces[index]; hits.forEach((hit, i) => hit.classList.toggle("is-active", !!piece && i === index)); label.hidden = !piece; + active = piece ? index : null; 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` : "") + label.textContent = piece + ? pieceLabel(design, piece) + (extend > 0 ? ` · 이동 +${extend.toFixed(1)}m` : "") : ""; + const open = !!(piece && shift); + shell.root.classList.toggle("is-hidden", !open); + shell.title.textContent = side === "left" ? "사면 이동 (좌)" : "사면 이동 (우)"; + readout.textContent = `이동 ${extend.toFixed(1)}m`; + arrows.detach(); + if (open) arrows.attach(); }, }; } diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index ab9d0181d..0729de1e4 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -662,6 +662,7 @@ export function createCrossSectionCard( const zoomBar = buildZoomControls(zoomPan, widthActions); if (linePick) zoomBar.prepend(linePick.label); // 치수 = [+ − ⤢] 왼쪽(137-2) chartWrap.append(svg, readout.root, zoomBar, panel.root); + if (linePick) chartWrap.append(linePick.panel); // 사면 이동 패널(151-3) for (const panel of bodies.panels) chartWrap.append(panel.root); if (activeArea) { setBandActive(activeArea); diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css index b0e894daf..d12b689e4 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css @@ -223,14 +223,39 @@ background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); } -/* 고른 보라 선 치수(137-2) — [+ − ⤢] 왼쪽 같은 줄. */ +/* 고른 보라 선 치수(137-2) — [+ − ⤢] 왼쪽 · 두 줄(151-1 · 길이 · 수평 / 높이 · 구배). */ .b06-cross-card__line-dims { - display: flex; - align-items: center; - padding: 0 var(--spacing-4); + align-self: center; + padding: 1px var(--spacing-4); color: var(--color-royal-amethyst); font-size: 0.75rem; - white-space: nowrap; + line-height: 1.25; + text-align: right; + white-space: pre-line; +} + +/* 좁은 카드(3열 등)는 막대 옆에 두면 왼쪽 면적표를 덮음 — 표 아래 높이로 내려 오른쪽에 붙임(151-1). */ +.b06-cross-card__chart-wrap { + container-type: inline-size; +} + +@container (max-width: 620px) { + .b06-cross-card__zoom { + overflow: visible; + } + + .b06-cross-card__line-dims { + position: absolute; + top: calc(var(--spacing-8) + var(--b06-areas-height, 3.6rem)); + right: -1px; + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent); + } + + .b06-cross-card__line-dims + .b06-cross-card__zoom-btn { + border-left: none; + } } .b06-cross-card__line-dims[hidden] { diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index a7a24ab93..d7a3a8910 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -113,7 +113,7 @@ 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`. */ + /** 설계선 쉬프트 = 쪽마다 사면 이동(m · ≥ 0 · 139-6 · 151-2 노견째). 짝: 파이썬 `shoulder_extend_*_m`. */ shoulderExtendLeftM?: number; shoulderExtendRightM?: number; } @@ -518,8 +518,13 @@ export function computeCrossDesign( carriageway_width_m: round4(geometry.halfRoadLeft + geometry.halfRoadRight), // 규격 폭과 확폭을 따로 남긴다 — 횡단도 라벨·수량 산출이 둘을 나눠 쓴다(짝: 파이썬). carriageway_standard_width_m: round4(group.road_width_m), - widening_left_m: round4(geometry.halfRoadLeft - geometry.halfRoad), - widening_right_m: round4(geometry.halfRoadRight - geometry.halfRoad), + // 확폭 = 곡선부 몫만 — 사면 이동(151-2)이 넓힌 차도는 뺀다(짝: 파이썬). + widening_left_m: round4( + geometry.halfRoadLeft - geometry.halfRoad - geometry.shoulderExtendLeft, + ), + widening_right_m: round4( + geometry.halfRoadRight - geometry.halfRoad - geometry.shoulderExtendRight, + ), cross_slope_pct: round4(crossSlopePct), ditch: ditchSpec, ditch_enabled: geometry.hasDitch, diff --git a/common_util/common_util_cross_design_geometry.ts b/common_util/common_util_cross_design_geometry.ts index 3f38877d7..d3dbdf042 100644 --- a/common_util/common_util_cross_design_geometry.ts +++ b/common_util/common_util_cross_design_geometry.ts @@ -108,7 +108,7 @@ export class SectionGeometry { hasDitch: boolean; /** 사용자가 정한 선택(`null` = 자동). 결과(`hasDitch`)와 다른 값이다. */ ditchChoice: boolean | null; - /** 노견 확장(m · 139-6) — 쪽마다. */ + /** 사면 이동(m · 139-6 · 151-2) — 쪽마다 · 차도 반폭에 들어 있음. */ shoulderExtendLeft = 0; shoulderExtendRight = 0; ditchPoints: Array<[number, number]> = []; @@ -210,10 +210,12 @@ export class SectionGeometry { /** 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. */ this.ditchChoice = choice; - // 설계선 쉬프트 = 쪽마다 노견 확장(≥ 0 · 139-6) — 단면유형 · 측구 판정은 **옮기기 전** 노견에서 - // (위) · 노체 · 측구 · 사면이 같이 바깥으로 · 차도는 그대로. ⚠ 파이썬 짝 `_SectionGeometry`. + // 설계선 쉬프트 = 쪽마다 사면 이동(≥ 0 · 139-6 · 151-2) — 단면유형 · 측구 판정은 **옮기기 전** + // 노견에서(위) · 노견(폭 고정) · 측구 · 사면이 같이 바깥으로 · 늘어난 폭은 차도. ⚠ 파이썬 짝 `_SectionGeometry`. this.shoulderExtendLeft = Math.max(params.shoulderExtendLeftM ?? 0, 0); this.shoulderExtendRight = Math.max(params.shoulderExtendRightM ?? 0, 0); + this.halfRoadLeft += this.shoulderExtendLeft; + this.halfRoadRight += this.shoulderExtendRight; this.leftExtent += this.shoulderExtendLeft; this.rightExtent += this.shoulderExtendRight; diff --git a/resources/tester/helper_137_design_pick.cjs b/resources/tester/helper_137_design_pick.cjs index e6db8f38b..b30e6e80e 100644 --- a/resources/tester/helper_137_design_pick.cjs +++ b/resources/tester/helper_137_design_pick.cjs @@ -112,6 +112,8 @@ class FakeNode { globalThis.document = { createElement: (tag) => new FakeNode(tag), createElementNS: (_ns, tag) => new FakeNode(tag), + addEventListener: () => {}, // 조정창 공용 부품(사면 이동 패널 · 151-3)이 불러올 때 닮 + removeEventListener: () => {}, }; function loadTs(file) { diff --git a/resources/tester/helper_139_6_line_shift.cjs b/resources/tester/helper_139_6_line_shift.cjs index 97e9207ff..4d146fc17 100644 --- a/resources/tester/helper_139_6_line_shift.cjs +++ b/resources/tester/helper_139_6_line_shift.cjs @@ -1,5 +1,5 @@ -/* 139-6 고른 설계선 [◀ ▶] 노견 확장 헬퍼 — `_Cross_Design_Pick.attachDesignLinePick` 을 그 자리에서 - * 트랜스파일해 흉내 DOM 으로 돌림. 짝 = `test_139_6_line_shift.py`. +/* 139-6 고른 설계선 사면 이동 헬퍼 — `_Cross_Design_Pick.attachDesignLinePick` 을 그 자리에서 + * 트랜스파일해 흉내 DOM 으로 돌림 · 151-3 이동 패널(◀ ↺ ▶ · 방향키 ←→). 짝 = `test_139_6_line_shift.py`. */ const fs = require("fs"); const path = require("path"); @@ -15,6 +15,7 @@ class Node { this.attrs = {}; this.listeners = {}; this.hidden = false; + this.isConnected = true; this._text = ""; this._cls = new Set(); const self = this; @@ -24,6 +25,16 @@ class Node { contains: (c) => self._cls.has(c), }; } + get descendants() { + return this.children.flatMap((c) => [c, ...c.descendants]); + } + /** `.이름` 한 개만 — 패널 공용 부품이 슬롯 단추를 찾는 꼴. */ + querySelector(sel) { + return this.descendants.find((n) => n._cls.has(sel.slice(1))) ?? null; + } + getClientRects() { + return this._cls.has("is-hidden") ? [] : [{}]; + } set className(v) { this._cls = new Set(String(v).split(/\s+/).filter(Boolean)); } @@ -56,10 +67,19 @@ class Node { (this.listeners.click ?? []).forEach((fn) => fn({ stopPropagation: () => undefined })); } } +const docListeners = {}; globalThis.document = { createElement: (t) => new Node(t), createElementNS: (_n, t) => new Node(t), + addEventListener: (t, fn) => (docListeners[t] ??= []).push(fn), + removeEventListener: (t, fn) => { + docListeners[t] = (docListeners[t] ?? []).filter((f) => f !== fn); + }, }; +const key = (name) => + [...(docListeners.keydown ?? [])].forEach((fn) => + fn({ key: name, target: null, preventDefault: () => undefined }), + ); function loadTs(file) { const full = path.resolve(file); @@ -115,37 +135,65 @@ layer.append( ]), ); const values = { left: 0, right: 0 }; +const picks = []; const handle = pick.attachDesignLinePick({ layer, design, x, toDisplayY: y, - onPick: () => undefined, + onPick: (i) => picks.push(i), shift: { valueOf: (side) => values[side], set: (side, v) => (values[side] = v), }, }); -const [left, right] = handle.label.children.filter((c) => c.tagName === "button"); +const panel = handle.panel; +const slot = (name) => panel.querySelector(`.b06-structure-panel__btn--${name}`); +const close = panel.querySelector(".b06-structure-panel__close"); 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 hiddenAtStart = panel.classList.contains("is-hidden"); const log = []; handle.setActive(pieceAt(4)); // 좌 사면 -left.click(); // 화면 왼쪽 = 좌측 바깥 → +0.1 -left.click(); +const titleLeft = panel.descendants.find((n) => + n._cls.has("b06-structure-panel__title"), +).textContent; +slot("left").click(); // 화면 왼쪽 = 좌측 바깥 → +0.1 +key("ArrowLeft"); // 방향키 = 십자 log.push({ ...values }); -right.click(); // 안쪽 → 0.1 -right.click(); -right.click(); // 0 아래 금지 +key("ArrowRight"); // 안쪽 → 0.1 +slot("right").click(); +key("ArrowRight"); // 0 아래 금지 log.push({ ...values }); handle.setActive(pieceAt(-4)); // 우 사면 -left.click(); // 화면 왼쪽 = 우측 안쪽 → 0 에서 멈춤 -right.click(); // 우측 바깥 → +0.1 +key("ArrowLeft"); // 화면 왼쪽 = 우측 안쪽 → 0 에서 멈춤 +slot("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] })); +const readout = panel.descendants.find((n) => n._cls.has("b06-structure-panel__value")).textContent; +slot("reset").click(); // ↺ = 그 쪽 0 +const afterReset = { ...values }; +close.click(); // ✕ = 같은 조각 다시 고름(카드가 풂) +handle.setActive(null); +key("ArrowRight"); // 숨은 패널은 방향키 안 먹음 +process.stdout.write( + JSON.stringify({ + log, + text, + readout, + titleLeft, + afterReset, + afterHidden: { ...values }, + picks, + pieceRight: pieceAt(-4), + hiddenAtStart, + hiddenAfter: panel.classList.contains("is-hidden"), + labelButtons: handle.label.children.length, + slots: ["left", "reset", "right"].map((n) => slot(n).textContent), + }), +); diff --git a/resources/tester/test_137_design_pick.py b/resources/tester/test_137_design_pick.py index c75c230bf..0e88ef6c9 100644 --- a/resources/tester/test_137_design_pick.py +++ b/resources/tester/test_137_design_pick.py @@ -57,8 +57,9 @@ def test_치수_사면길이_수평_높이_구배(out) -> None: 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%" + # 두 줄(151-1) — 1줄 = 길이 · 수평 · 2줄 = 높이 · 구배 + assert out["label"]["fill"] == "사면길이 3.61m · 수평 3.00m\n높이 2.00m · 1:1.50" + assert out["label"]["road"] == "길이 2.50m · 수평 2.50m\n높이 0.05m · 2.0%" def test_구역_도로_절토_성토(out) -> None: @@ -100,10 +101,21 @@ def test_카드_배선_하나만() -> None: assert view.count("dropLine();") >= 4 # 본체 · 벽 · 카드 풂 · 면적 assert "setLineActive(pickedLineAt(section.station_id));" in view # 다시 그려도 되살림 assert "zoomBar.prepend(linePick.label);" in view # 치수 = [+ − ⤢] 왼쪽 + assert "chartWrap.append(linePick.panel);" in view # 사면 이동 패널(151-3) css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css") assert ".b06-chart__design-hit.is-active {" in css +def test_치수_두_줄_좁은_카드는_표_아래로() -> None: + """151-1 — 줄바꿈 글자를 살리고 오른쪽 맞춤 · 좁은 카드(3열 등)는 표 높이 아래로 내려 표를 안 덮음.""" + css = _src("B06_Section/B06_Section_UI_Style_Cross_Controls.css") + dims = css.split(".b06-cross-card__line-dims {", 1)[1].split("}", 1)[0] + assert "white-space: pre-line;" in dims and "text-align: right;" in dims + assert "container-type: inline-size;" in css + narrow = css.split("@container (max-width: 620px) {", 1)[1] + assert "top: calc(var(--spacing-8) + var(--b06-areas-height, 3.6rem));" in narrow + + def test_폴리곤_자르기는_공용_한_벌() -> None: b07 = _src("B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts") assert "function clipSegment(" not in b07 and "function pointInPolygon(" not in b07 diff --git a/resources/tester/test_139_6_line_shift.py b/resources/tester/test_139_6_line_shift.py index ae56ecd91..2430c9a19 100644 --- a/resources/tester/test_139_6_line_shift.py +++ b/resources/tester/test_139_6_line_shift.py @@ -1,4 +1,4 @@ -"""139-6 고른 설계선 [◀ ▶] — 고른 조각 쪽 노견 확장 0.1 m 걸음 · 노폭 안쪽 금지(0 에서 멈춤). +"""139-6 · 151-3 고른 설계선 사면 이동 패널 — 고른 조각 쪽만 0.1 m 걸음 · 노폭 안쪽 금지(0 에서 멈춤). 화면 TS(`_Cross_Design_Pick`)를 흉내 DOM 으로 돌린 값(`helper_139_6_line_shift.cjs`). """ @@ -26,16 +26,27 @@ def out() -> dict: return json.loads(done.stdout) -def test_화면_방향대로_그_쪽_노견만(out: dict) -> None: +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 + 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_치수_줄과_패널에_이동_표기(out: dict) -> None: + assert "이동 +0.1m" in out["text"] and "노견 +" not in out["text"] + assert out["readout"] == "이동 0.1m" + assert out["titleLeft"] == "사면 이동 (좌)" + + +def test_단추는_패널로_옮김_재설정_닫기(out: dict) -> None: + """151-3 — 치수 칸 [◀ ▶] 지움 · 패널 ◀ ↺ ▶ · ↺ = 그 쪽 0 · ✕ = 고름 풂 · 숨으면 방향키 안 먹음.""" + assert out["labelButtons"] == 0 + assert out["slots"] == ["◀", "↺", "▶"] + assert out["afterReset"] == {"left": 0, "right": 0} + assert out["picks"] == [out["pieceRight"]] + assert out["hiddenAtStart"] is True and out["hiddenAfter"] is True + assert out["afterHidden"] == {"left": 0, "right": 0} def test_페이지가_같은_창구로_세션_재계산() -> None: diff --git a/resources/tester/test_b06_cross_design_mirror.py b/resources/tester/test_b06_cross_design_mirror.py index 4f46752ee..151858314 100644 --- a/resources/tester/test_b06_cross_design_mirror.py +++ b/resources/tester/test_b06_cross_design_mirror.py @@ -204,6 +204,20 @@ CASES = [ "shoulder_extend_left_m": 2.5, }, }, + { + # 151-2 — 곡선부 확폭과 사면 이동이 같은 쪽에 겹침(확폭 출력엔 이동 몫 안 섞임) + "name": "확폭 좌 + 사면 이동 좌 0.4", + "samples": _ground(0.28), + "design_elevation_m": 100.3, + "options": { + "ground_type": "soil", + "section_mode": "left_cut", + "ditch_side": "left", + "plan_radius_m": 18.0, + "curve_outer_side": "left", + "shoulder_extend_left_m": 0.4, + }, + }, ] _TS_RUNNER = """ @@ -381,3 +395,32 @@ def test_시험이_빈_결과로_통과하지_않는다() -> None: assert sum(1 for item in results if item["fill_area_m2"] > 0) >= 1 assert any(item.get("widening_left_m") or item.get("widening_right_m") for item in results) assert any(item["cut_rock_area_m2"] > 0 for item in results) + + +def test_사면_이동은_노견째_평행이동_늘어난_폭은_차도() -> None: + """151-2 — 노견 폭 그대로 · 차도 끝이 이동량만큼 바깥 · 확폭 출력엔 이동 몫 안 섞임.""" + by_name = dict(zip((case["name"] for case in CASES), _python_results(), strict=True)) + base = compute_cross_design( + _ground(0.35), + 100.4, + standard=STANDARD_CROSS_SECTION, + ground_type="soil", + section_mode="left_cut", + ditch_side="left", + ) + moved = by_name["노견 확장 좌 0.7 · 우 1.2"] + for side, sign, extend in (("left", 1, 0.7), ("right", -1, 1.2)): + road = moved["road_edges"][side]["offset_m"] + lane = moved["carriageway_edges"][side]["offset_m"] + base_shoulder = ( + base["road_edges"][side]["offset_m"] - base["carriageway_edges"][side]["offset_m"] + ) + assert abs((road - lane) - base_shoulder) < 1e-9 # 노견 폭 고정 + assert abs(lane - (base["carriageway_edges"][side]["offset_m"] + sign * extend)) < 1e-9 + assert abs(moved["carriageway_width_m"] - (base["carriageway_width_m"] + 1.9)) < 1e-9 + assert moved["widening_left_m"] == 0.0 and moved["widening_right_m"] == 0.0 + + curve = by_name["곡선부 확폭 — 바깥쪽 좌"] + both = by_name["확폭 좌 + 사면 이동 좌 0.4"] + assert both["widening_left_m"] == curve["widening_left_m"] > 0 + assert abs(both["carriageway_width_m"] - (curve["carriageway_width_m"] + 0.4)) < 1e-9