diff --git a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts index 1a5cf4395..9ea0e2d1b 100644 --- a/B06_Section/B06_Section_UI_Cross_Design_Pick.ts +++ b/B06_Section/B06_Section_UI_Cross_Design_Pick.ts @@ -37,7 +37,7 @@ import { type LineCellState, type RockCellSpec, } from "./B06_Section_UI_Cross_Line_Cell"; -import { extendStop, toePoints } from "./logic/B06_Section_UI_Cross_Fit"; +import { edgeOnGround, extendStop, toePoints } from "./logic/B06_Section_UI_Cross_Fit"; const SVG_NS = "http://www.w3.org/2000/svg"; const DESIGN_CLASS = "b06-chart__design-cross"; @@ -379,6 +379,7 @@ export function designLineMoves( berm?: BermDepthControl; center?: number; toes?: ReturnType; + bare?: ReturnType; rock?: RockCellSpec; } { const design = section.design; @@ -400,6 +401,7 @@ export function designLineMoves( stopAt: (side, now, next) => extendStop(section.samples, design.road_edges, side, now, next), }, toes: toePoints(section), // 교차점 손잡이(370-2) + bare: edgeOnGround(section.samples, design.road_edges), // 절·성토 없는 쪽(379-1) berm: { set: (side, valueM) => send({ berm_depth: { side, valueM } }), // 끔은 이 측점이 켠 때만 — 다른 카드 소단을 고르며 옛 카드가 풀릴 때 새 켬을 안 지움 @@ -438,6 +440,8 @@ export function attachDesignLinePick(input: { center?: number; /** 있으면 쪽마다 교차점 손잡이(설계선 ∩ 원지반 · 키 `toe:left|right` · 늘 보임 · 370-2). */ toes?: ReturnType; + /** 있으면 쪽마다 노견 끝 = 원지반(절·성토 없음 · 379-1) — 손잡이 값 앞 알림. */ + bare?: ReturnType; /** 있으면 암 경계선 고름(키 `rock` · ▲▼↺ · 세로 끌기) · 안 고른 때 공용 칸 = 암(370-3). */ rock?: RockCellSpec; }): DesignLinePick { @@ -500,7 +504,6 @@ export function attachDesignLinePick(input: { const toe = input.toes?.[at] as { offset_m: number; elevation_m: number }; const hit = centerHit(x(toe.offset_m), toDisplayY(toe.elevation_m)); hit.classList.add(TOE_CLASS); - hit.setAttribute("r", "5"); hits.push(hit); } // 암 경계선(370-3) — 점선(`appendRockBoundaryOverlay`) 위 판정 선 하나(끊긴 토막도 한 path) · 키 rock @@ -631,6 +634,7 @@ export function attachDesignLinePick(input: { const moved = extend > 0 ? ` · 이동 +${extendText(extend)}m` : ""; const toe = toeSide ? input.toes?.[toeSide] : null; const dims = piece ? pieceLabel(design, piece).replace("\n", " · ") : ""; + const bare = toeSide && input.bare?.[toeSide] ? "절·성토 없음 · " : ""; // 379-1 // 아래 띠 공용 칸(370-3) — 안 고르면 암(암 지반만) · 고르면 그 선 제목 · 버튼 · 값 한 줄 const state: LineCellState | null = onRock || (!chosen && rock) @@ -639,8 +643,8 @@ export function attachDesignLinePick(input: { ? { title: "중심점", value: centerPreviewText(0), buttons: sideways(false) } : toe ? { - title: `교차점 이동 ${sideName}`, - value: `노선에서 ${Math.abs(toe.offset_m).toFixed(2)}m${moved}`, + title: `사면 이동 ${sideName}`, + value: `${bare}노선에서 ${Math.abs(toe.offset_m).toFixed(2)}m${moved}`, buttons: shift ? sideways(true) : {}, } : !piece diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 0fe12440b..12c8a2a21 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -170,6 +170,8 @@ cursor: pointer; } +/* 마우스 올림 = 고른 모양(379-1) · 고름은 누를 때만 */ +.b06-chart__design-hit:hover, .b06-chart__design-hit.is-active { stroke: color-mix(in srgb, var(--color-royal-amethyst) 45%, transparent); stroke-width: 7; @@ -193,21 +195,26 @@ cursor: ew-resize; } -/* 교차점 손잡이(370-2) — 사면 ∩ 원지반 작은 원 · 늘 보임 · 고르면 채움. */ +/* 교차점 손잡이(370-2) — 사면 ∩ 원지반 작은 원 · 늘 보임 · 고르면 채움 · 올리면 같은 모양(379-1). */ .b06-chart__toe-hit { fill: var(--color-surface); stroke: var(--color-royal-amethyst); stroke-width: 1.5; } +.b06-chart__toe-hit:hover, .b06-chart__toe-hit.is-active { fill: var(--color-royal-amethyst); cursor: pointer; } /* 암 경계선 고름(370-3) — 점선 위 투명 판정 선 · 고르면 경고색 띠 · 세로 끌기. */ +.b06-chart__design-hit.b06-chart__rock-hit:hover, .b06-chart__design-hit.b06-chart__rock-hit.is-active { stroke: color-mix(in srgb, var(--color-warning) 40%, transparent); +} + +.b06-chart__design-hit.b06-chart__rock-hit.is-active { cursor: ns-resize; } diff --git a/B06_Section/logic/B06_Section_UI_Cross_Fit.ts b/B06_Section/logic/B06_Section_UI_Cross_Fit.ts index 0b4250e69..d30b011af 100644 --- a/B06_Section/logic/B06_Section_UI_Cross_Fit.ts +++ b/B06_Section/logic/B06_Section_UI_Cross_Fit.ts @@ -112,6 +112,19 @@ export function extendStop( return next; } +/** 절·성토 없는 쪽(379-1) — 노견 끝 = 원지반(±5 mm · 멈춤 반올림 0.001 m 를 덮음). 지반 모르면 false. */ +export function edgeOnGround( + samples: CrossSection["samples"], + edges: CrossDesign["road_edges"] | undefined, +): Record<"left" | "right", boolean> { + const groundAt = groundInterpolator(samples ?? []); + const on = (side: "left" | "right") => + !!groundAt && + !!edges && + Math.abs(groundAt(edges[side].offset_m) - edges[side].elevation_m) <= 0.005; + return { left: on("left"), right: on("right") }; +} + /** * 성토측 사면의 **본래** 경사길이(m) — 성토가 아닌 측은 null (2026-09-03 사용자 지시). * diff --git a/resources/tester/helper_370_2_toe_stop.cjs b/resources/tester/helper_370_2_toe_stop.cjs index 3d64dda20..c62046e96 100644 --- a/resources/tester/helper_370_2_toe_stop.cjs +++ b/resources/tester/helper_370_2_toe_stop.cjs @@ -107,11 +107,12 @@ class FakeNode { (this.listeners.click ?? []).forEach((fn) => fn(event)); } } +const docKeys = new Set(); // 방향키 듣개(379-1 · 정확히 0 자리) globalThis.document = { createElement: (tag) => new FakeNode(tag), createElementNS: (_ns, tag) => new FakeNode(tag), - addEventListener: () => {}, - removeEventListener: () => {}, + addEventListener: (type, fn) => type === "keydown" && docKeys.add(fn), + removeEventListener: (type, fn) => type === "keydown" && docKeys.delete(fn), }; globalThis.window = { setTimeout, clearTimeout, addEventListener: () => {} }; @@ -302,4 +303,36 @@ out.inherit = { after.handle.setActive(afterIndex); out.inherit.label = cellOf(after.handle).title; +// ④ 정확히 0 자리(379-1) — 노견 끝 = 원지반(±5 mm = 절·성토 없음) · 손잡이 · 누르면 사면처럼 ◀↺▶ · 방향키 +// 조각 0 은 안 봄 — 그림 규칙(노견 끝 → 첫 접점 보호 구간)이 지반 위 짧은 선을 남김(브레인 A) +const zero = section(out.stop.value); +const zeroPicks = []; +const zeroLayer = new FakeNode("g"); +appendCrossDesignOverlay(zeroLayer, zero.design, x, y, zero.samples, undefined); +zeroLayer.children.forEach((el) => el.setAttribute(pick.DESIGN_LINE_ATTR, "")); +const zeroHandle = pick.attachDesignLinePick({ + layer: zeroLayer, + design: zero.design, + x, + toDisplayY: y, + onPick: (i) => zeroPicks.push(i), + ...pick.designLineMoves(zero, () => undefined), +}); +const zeroToes = zeroLayer.children.filter((k) => k.classList.contains("b06-chart__toe-hit")); +const rightToe = zeroToes.find((t) => Number(t.getAttribute("cx")) > x(0)); +docKeys.clear(); +rightToe?.click(); +zeroHandle.setActive(zeroPicks[0]); +const zeroCell = cellOf(zeroHandle); +out.zero = { + bare: pick.designLineMoves(zero, () => undefined).bare, + nearBare: pick.designLineMoves(near, () => undefined).bare, + toe: !!rightToe, + radius: rightToe ? rightToe.getAttribute("r") : null, + title: zeroCell.title, + value: zeroCell.value, + buttons: ["left", "reset", "right"].filter((slot) => zeroCell.button(slot)), + keys: docKeys.size, +}; + process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_370_2_toe_stop.py b/resources/tester/test_370_2_toe_stop.py index e40accd7e..42e9f4e94 100644 --- a/resources/tester/test_370_2_toe_stop.py +++ b/resources/tester/test_370_2_toe_stop.py @@ -1,6 +1,6 @@ """PLAN 370-2 — B06 사면 이동 멈춤 · 교차점 손잡이 · 고름 이어받기(요구 240-14). -짝: `B06_Section/logic/B06_Section_UI_Cross_Fit.ts`(`extendStop` · `toePoints`) · +짝: `B06_Section/logic/B06_Section_UI_Cross_Fit.ts`(`extendStop` · `toePoints` · `edgeOnGround`) · `B06_Section/B06_Section_UI_Cross_Design_Pick.ts`(손잡이 · 키 이어받기). 실제 TS 를 `helper_370_2_toe_stop.cjs` 가 Node 로 돌린다. """ @@ -63,7 +63,7 @@ def test_교차점_손잡이_쪽마다_늘_보임(out) -> None: def test_손잡이를_고르면_그_쪽_사면_좌우_이동(out) -> None: got = out["toePick"] assert got["active"] is True and got["panelOpen"] is True - assert got["title"] == "교차점 이동 (우)" # 아래 띠 공용 칸(370-3) + assert got["title"] == "사면 이동 (우)" # 아래 띠 공용 칸(370-3) assert got["value"].startswith("노선에서 ") assert len(got["sent"]) == 1 and got["sent"][0]["side"] == "right" low, high = out["stop"]["crossing"] @@ -76,4 +76,15 @@ def test_절에서_성으로_넘어가도_고름_유지(out) -> None: assert inherit["cutIndex"] >= 0 and inherit["before"] == inherit["cutIndex"] assert inherit["farMode"] == "left_cut" # 우 = 성토로 넘어감 assert inherit["afterIndex"] is not None - assert inherit["label"] == "교차점 이동 (우)" + assert inherit["label"] == "사면 이동 (우)" + + +def test_정확히_0_자리는_절성토_없음_사면처럼_고름(out) -> None: + """379-1 — 멈춘 자리(노견 끝 = 원지반) 손잡이를 누르면 사면과 같은 조작.""" + zero = out["zero"] + assert zero["bare"]["right"] is True and zero["nearBare"]["right"] is False + assert zero["toe"] is True and zero["radius"] == "8" # 판정 원 8 px + assert zero["title"] == "사면 이동 (우)" + assert zero["value"].startswith("절·성토 없음 · 노선에서 ") + assert zero["buttons"] == ["left", "reset", "right"] # ◀↺▶ + assert zero["keys"] >= 1 # 방향키 듣개