feat(b06): 고르기 차례 = 좁은 것 위 — 카드 판정면 넓이 큰 것부터 아래 · 보라 선 맨 위 · 끌기 = 고름 아님 · 모달 선 > 작은 면 > 큰 면 · 띠 · 도수로 띠 위 성토 사면 고름 (295-1 · 294-5)

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-12 22:01:08 +09:00
co-authored by Claude Opus 5.5
parent bef62e411e
commit 1ca7e61b08
5 changed files with 87 additions and 10 deletions
@@ -419,7 +419,7 @@ export function attachDesignLinePick(input: {
const pieces = visiblePieces(mergeStraightPieces(runs), occluders);
if (input.stationId) keysByStation.set(input.stationId, pieceKeys(design, pieces));
// 판정 선은 구조물 판정면 **아래** — 겹친 자리는 구조물이 이김.
// 판정 선은 구조물 판정면 아래에 꽂음 — 카드가 끝에 `stackPickTargets` 로 맨 위에 다시 세움(295-1).
const firstStructureHit = kids.find((el) =>
el.classList.contains("b06-chart__culvert-revet-hit"),
);
@@ -236,6 +236,8 @@ const inside = (p: Pt, ring: Pt[]): boolean =>
* 누른 자리 → 선 · 영역(261-3) — 좌표는 그림 px · `scale` = 화면 px / 그림 px · `areas` = 영역 넓이.
* 가장 가까운 선이 `LINE_PICK_PX` 안이면 선 — 단 영역 안이면 그 선과 끝이 안 닿는 맞은편 선보다
* 절반 이하로 가까워야 선(얇은 띠 가운데 ⅓ = 영역) · 아니면 누른 자리를 품은 가장 작은 영역.
* 고르기 차례(295-1 · 카드와 같은 잣대) = 선 > 작은 면 > 큰 면 · 띠 — `bandArea`(그 자리 면적 띠
* 넓이 · 그림 px²)가 영역보다 작을 때만 띠(표가 맡음).
*/
export function pickAt(
point: Pt,
@@ -244,7 +246,8 @@ export function pickAt(
rings: Pt[][],
areas: number[],
pieces: SplitPiece[],
): { piece: number } | { face: number } | null {
bandArea: number | null = null,
): { piece: number } | { face: number } | { band: true } | null {
const gaps = paths.map((path) => pathGap(point, path) * scale);
const nearest = gaps.reduce((best, gap, i) => (gap < gaps[best] ? i : best), 0);
const ends = (piece: SplitPiece): Pt[] => [
@@ -268,9 +271,17 @@ export function pickAt(
(face === undefined || gaps[nearest] * 2 <= across)
)
return { piece: nearest };
const faceArea = face === undefined ? Infinity : Math.abs(shoelace(rings[face]));
if (bandArea !== null && bandArea < faceArea) return { band: true };
return face === undefined ? null : { face };
}
const shoelace = (ring: Pt[]): number =>
ring.reduce((sum, [x0, y0], i) => {
const [x1, y1] = ring[(i + 1) % ring.length];
return sum + (x0 * y1 - x1 * y0) / 2;
}, 0);
/** 조각의 자리 이름 — 선 종류 + mm 로 맞춘 꼭짓점(px → m 되짚기의 부동소수 흔들림 거름). */
const pieceKey = (piece: SplitPiece | undefined): string =>
piece ? `${piece.line}|${piece.points.map((p) => p.map((v) => v.toFixed(3))).join(";")}` : "";
@@ -283,8 +294,8 @@ export function attachDetailSelect(input: {
picked: (additive: boolean) => void;
/** [칸에 넣기] 단추. */
commit: () => void;
/** 면적 고르기 중 그 자리에 면적 띠가 있나 — 있으면 영역 대신 띠(표가 맡음). */
band?: (event: MouseEvent) => boolean;
/** 면적 고르기 중 그 자리 면적 띠 넓이(그림 px²) · 없음 = null — 영역보다 작을 때만 띠(295-1). */
band?: (event: MouseEvent) => number | null;
}): DetailSelect {
const { cad } = input;
let pieces: SplitPiece[] = [];
@@ -343,7 +354,8 @@ export function attachDetailSelect(input: {
if (!ctm || !layer?.ownerSVGElement?.contains(event.target as Node)) return null;
const p = new DOMPoint(event.clientX, event.clientY).matrixTransform(ctm.inverse());
const areas = faces.map((face) => face.area);
return pickAt([p.x, p.y], Math.hypot(ctm.a, ctm.b), paths, rings, areas, pieces);
const band = mode === "area" ? (input.band?.(event) ?? null) : null;
return pickAt([p.x, p.y], Math.hypot(ctm.a, ctm.b), paths, rings, areas, pieces, band);
};
const onClick = (event: MouseEvent): void => {
@@ -352,9 +364,10 @@ export function attachDetailSelect(input: {
const additive = shift || mode === "area";
noteText = null;
if (!pick || !("piece" in pick)) {
if (pick && !(mode === "area" && input.band?.(event))) {
// 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter)
event.stopPropagation();
if (pick && "face" in pick) {
// 영역 안 누름 = 그 영역 둘레 조각 고름(259-2) · 길이 고르기여도 바로 넣지 않음(Enter) ·
// 띠보다 좁은 영역이라 표 띠 누름(같은 cad 의 다음 듣기)보다 앞섬(295-1)
event.stopImmediatePropagation();
const index = pick.face;
chosenFaces = toggleSelection(chosenFaces, index, shift);
selected = regionEdges(chosenFaces.map((i) => faces[i]));
@@ -25,6 +25,7 @@ import type { CrossPickPiece, SectionSample } from "./B06_Section_Api_Types";
import type { CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
import { pickLines, piecesValue } from "./B06_Section_UI_Cross_Detail_Geom";
import { attachDetailSelect } from "./B06_Section_UI_Cross_Detail_Select";
import { pointsArea } from "./B06_Section_UI_Cross_Pick_Order";
/** 서버 `MAX_ROWS` · `MAX_COLUMNS` · `LINE_TOLERANCE_M` 와 같은 값. */
export const MAX_ROWS = 12;
@@ -387,13 +388,16 @@ export function attachDetailTable(host: DetailTableHost): {
if (picking?.kind === "length" && !additive) commitSelection();
},
commit: commitSelection,
band: (event) => !!bandAt(event),
band: (event) => {
const hit = bandAt(event);
return hit ? pointsArea(hit.getAttribute("points")) : null;
},
});
const bandAt = (event: MouseEvent): Element | undefined =>
document
.elementsFromPoint(event.clientX, event.clientY)
.find((el) => cad.contains(el) && el.closest(AREA_PICK_SELECTOR));
// 면적 띠 · 사토장 누름 = 그 띠 면적(179-4 그대로 · 영역보다 앞) — 선 누름은 위가 막음(261-3 거리 판정)
// 면적 띠 · 사토장 누름 = 그 띠 면적(179-4) — 선 · 띠보다 좁은 영역은 위가 막음(261-3 거리 판정 · 295-1)
const onPick = (event: MouseEvent): void => {
if (picking?.kind !== "area") return;
const hit = bandAt(event);
@@ -0,0 +1,58 @@
/* =============================================================================
* B06_Section_UI_Cross_Pick_Order.ts
* 횡단 카드 고르기 차례(295-1) — 고를 자리가 좁은 것이 위(모달 `pickAt` 과 같은 잣대).
*
* 판정면(구조물 · 띠 · 형상)을 다 꽂은 뒤 넓이 큰 것부터 아래로 다시 세움(작은 것 위) ·
* 보라 선 판정(넓이 0 · 요구 185 사면 손잡이)은 맨 위 · 면적 띠(절 · 성토)는 맨 아래 그대로.
* 끌고 뗀 누름(3 px 넘게 움직임)은 고름 아님.
* ========================================================================== */
/** 면 판정 — 구조물(벽 · 관 · 박스 · 도수로 띠 등) · 물넘이포장 · 맹암거 형상. */
const SURFACE_HIT_CLASSES = ["b06-chart__culvert-revet-hit", "b06-chart__shape-hit"];
/** 선 판정 — 보라 설계선 조각(`_Cross_Design_Pick`). */
const LINE_HIT_CLASS = "b06-chart__design-hit";
/** 끌기로 볼 움직임(px) — 형상 고름(`SHAPE_DRAG_PX`)과 같은 값. */
const DRAG_PX = 3;
/** `points` 속성 다각형 넓이(px²) — 점이 셋 못 되면 0. */
export function pointsArea(points: string | null): number {
const pts = (points ?? "")
.trim()
.split(/\s+/)
.map((pair) => pair.split(",").map(Number))
.filter((p) => p.length === 2 && p.every(Number.isFinite));
if (pts.length < 3) return 0;
let twice = 0;
pts.forEach(([x0, y0], i) => {
const [x1, y1] = pts[(i + 1) % pts.length];
twice += x0 * y1 - x1 * y0;
});
return Math.abs(twice) / 2;
}
/** 판정면을 넓이 차례로 다시 세우고 보라 선 판정을 맨 위로 · 끌기 누름을 막음. */
export function stackPickTargets(layer: SVGElement): void {
const kids = [...layer.children];
const surfaces = kids
.filter((el) => SURFACE_HIT_CLASSES.some((name) => el.classList.contains(name)))
.map((el) => ({ el, area: pointsArea(el.getAttribute("points")) }))
.sort((a, b) => b.area - a.area)
.map(({ el }) => el);
const lines = kids.filter((el) => el.classList.contains(LINE_HIT_CLASS));
layer.append(...surfaces, ...lines);
let down: { x: number; y: number } | null = null;
layer.addEventListener(
"pointerdown",
(event) => (down = { x: (event as PointerEvent).clientX, y: (event as PointerEvent).clientY }),
true,
);
layer.addEventListener(
"click",
(event) => {
const { clientX, clientY } = event as MouseEvent;
if (down && Math.hypot(clientX - down.x, clientY - down.y) > DRAG_PX) event.stopPropagation();
down = null;
},
true,
);
}
+2
View File
@@ -21,6 +21,7 @@ import {
} from "./B06_Section_UI_Cross_Ford_Pavement";
import { ownWallsDrawAt } from "./B06_Section_UI_Cross_Culvert_Wire";
import { attachShapePick, shapePickRoute } from "./B06_Section_UI_Cross_Shape_Pick";
import { stackPickTargets } from "./B06_Section_UI_Cross_Pick_Order";
import { appendUnderdrainOverlay } from "./B06_Section_UI_Cross_Underdrain";
import { raiseWalls } from "./B06_Section_UI_Cross_Wall";
import { attachChuteBandPick } from "./B06_Section_UI_Cross_Wall_Hatch";
@@ -604,6 +605,7 @@ export function createCrossSectionCard(
});
setLineActive = linePick.setActive;
setLineActive(pickedLineAt(section.station_id));
stackPickTargets(plotLayer); // 고르기 차례 = 좁은 것 위 · 보라 선 맨 위(295-1)
}
// 십자선(계획 노선 자리) · 축선 · 높이 축 이름 — `_Cross_View_Frame`(700줄 제한).