feat(B06 횡단): 교차점 = 사면처럼 — 올림 강조 · 제목 「사면 이동」 · 절·성토 없음 알림 · 판정 원 8 px (379-1)

- 보라 선 · 교차점 손잡이 · 암 경계선 마우스 올림 = 고른 모양(고름은 누를 때만)
- 손잡이 제목 「사면 이동 (좌/우)」 · 노견 끝 = 원지반(±5 mm · edgeOnGround)이면 값 앞 「절·성토 없음 · 」
- 시험: 정확히 0 자리(멈춘 자리) 경우 — 절·성토 없음 · ◀↺▶ · 방향키 듣개 · 원 8 px

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
2026-10-17 14:48:30 +09:00
co-authored by Claude Opus 5.5
parent 7329065c3b
commit d849110478
5 changed files with 78 additions and 10 deletions
@@ -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<typeof toePoints>;
bare?: ReturnType<typeof edgeOnGround>;
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<typeof toePoints>;
/** 있으면 쪽마다 노견 끝 = 원지반(절·성토 없음 · 379-1) — 손잡이 값 앞 알림. */
bare?: ReturnType<typeof edgeOnGround>;
/** 있으면 암 경계선 고름(키 `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
@@ -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;
}
@@ -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 사용자 지시).
*
+35 -2
View File
@@ -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));
+14 -3
View File
@@ -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 # 방향키 듣개