feat(b06): 보라 설계선 고름 — 직선 조각 · 가림 뺌 · 치수 라벨 · 구역 판정 · 고름 하나만(137-2 · 137-3)

- 새 B06_Section_UI_Cross_Design_Pick.ts — 조각 합침 · 벽 · 본체 밖 조각 · 투명 판정 선 · 치수 · crossZoneAt(139-6 용) · 페이지 한 칸 고름
- 폴리곤 자르기 = A00_Common/b_polygon_clip.ts 공용(B07 Cross_Overlay 에서 옮김 · 바깥 조각 갈래)
- 카드 배선: 선 고르면 벽 · 본체 · 면적 풂 + 카드 고름 · 면적 고름도 본체 · 선 풂
- 시험 test_137_design_pick.py + helper_137_design_pick.cjs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
This commit is contained in:
2026-10-09 15:00:12 +09:00
co-authored by Claude Opus 5.5
parent 06e6fbebff
commit f6f0f74be5
8 changed files with 898 additions and 56 deletions
+65
View File
@@ -0,0 +1,65 @@
/* =============================================================================
* b_polygon_clip.ts
* 선분 · 점열을 폴리곤 경계에서 자르는 공용 조각 — B07 횡단 오버레이(안쪽 조각)와
* B06 설계선 고름(벽 · 본체 밖 조각 · 137-2)이 같이 쓴다. 좌표계는 부르는 쪽 것 그대로.
* ========================================================================== */
export type XY = [number, number];
/** 점이 폴리곤 안인가 — 오목한 벽 단면도 되도록 광선 교차 홀짝으로 본다. */
export function pointInPolygon(point: XY, polygon: XY[]): boolean {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
const [xi, yi] = polygon[i];
const [xj, yj] = polygon[j];
const straddles = yi > point[1] !== yj > point[1];
if (straddles && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/** 선분을 폴리곤 경계에서 잘라 **안쪽 조각들**(`keep = "outside"` 면 바깥 조각들)만 돌려준다. */
export function clipSegment(
start: XY,
end: XY,
polygon: XY[],
keep: "inside" | "outside" = "inside",
): XY[][] {
const [x0, y0] = start;
const [x1, y1] = end;
const dx = x1 - x0;
const dy = y1 - y0;
const cuts = [0, 1];
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
const [ex, ey] = polygon[j];
const [fx, fy] = polygon[i];
const denominator = dx * (fy - ey) - dy * (fx - ex);
if (Math.abs(denominator) < 1e-12) continue;
const t = ((ex - x0) * (fy - ey) - (ey - y0) * (fx - ex)) / denominator;
const u = ((ex - x0) * dy - (ey - y0) * dx) / denominator;
if (t > 0 && t < 1 && u >= 0 && u <= 1) cuts.push(t);
}
cuts.sort((a, b) => a - b);
const runs: XY[][] = [];
for (let index = 0; index + 1 < cuts.length; index += 1) {
const from = cuts[index];
const to = cuts[index + 1];
if (to - from < 1e-9) continue;
const mid = (from + to) / 2;
if (pointInPolygon([x0 + dx * mid, y0 + dy * mid], polygon) !== (keep === "inside")) continue;
runs.push([
[x0 + dx * from, y0 + dy * from],
[x0 + dx * to, y0 + dy * to],
]);
}
return runs;
}
/** 점열을 폴리곤 안으로 자른다 — 조각마다 한 줄. */
export function clipPointsToPolygon(points: XY[], polygon: XY[]): XY[][] {
const runs: XY[][] = [];
for (let index = 0; index + 1 < points.length; index += 1) {
runs.push(...clipSegment(points[index], points[index + 1], polygon));
}
return runs;
}
@@ -0,0 +1,313 @@
/* =============================================================================
* B06_Section_UI_Cross_Design_Pick.ts
* 횡단 카드 **보라 요소 고름**(설계선 · 구조물 트림 선 · 137-2).
*
* 그려진 보라 선(`b06-chart__design-cross`)을 화면 좌표에서 m 로 되돌려 같은 방향 토막을
* 직선 조각 하나로 합치고, 벽 · 본체 폴리곤에 가린 부분을 뺀 **보이는 조각**마다 투명 판정
* 선을 구조물 판정면 아래에 깐다. 고르면 그 조각을 강조하고 [+ − ⤢] 왼쪽에 치수를 적는다.
* 구역 판정(`crossZoneAt` · 도로 · 절토 · 성토)은 설계선 쉬프트(139-6)도 쓴다.
* 고름 상태는 페이지에 한 칸(측점 + 조각 번호) — 카드를 다시 그려도 되살아난다.
* ========================================================================== */
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";
const SVG_NS = "http://www.w3.org/2000/svg";
const DESIGN_CLASS = "b06-chart__design-cross";
const HIT_CLASS = "b06-chart__design-hit";
/** 설계선 오버레이가 그린 선 표시(구조물 트림 선과 가름) — 카드가 그린 직후 단다. */
export const DESIGN_LINE_ATTR = "data-design-line";
/** 보라 선을 덮는 벽 · 본체 폴리곤. */
const OCCLUDER_CLASSES = [
"b06-chart__culvert-revet",
"b06-chart__culvert-basin",
"b06-chart__ford-slab",
"b06-chart__box-barrel",
];
/** 같은 직선으로 보는 꺾임 허용(m) — 설계선 표고는 0.1 mm 반올림이라 넉넉하다. */
const STRAIGHT_TOL_M = 0.005;
/** 가리고 남은 조각이 이보다 짧으면 버린다(m) — 누를 수도 없다. */
const MIN_PIECE_M = 0.02;
export interface CrossPoint {
offset: number;
elevation: number;
}
/** 그려진 보라 선 한 줄(m 좌표). `structure` = 구조물 트림 선(설계선 오버레이 밖). */
export interface DesignRun {
points: CrossPoint[];
structure: boolean;
}
/** 직선 조각 — 두 끝(m) · 구조물 트림 선인가. */
export interface DesignPiece {
from: CrossPoint;
to: CrossPoint;
structure: boolean;
}
export type CrossZone = "road" | "cut" | "fill";
export type CrossSide = "left" | "right";
type ZoneDesign = Pick<
CrossDesign,
"road_edges" | "ditch" | "ditch_side" | "ditch_enabled" | "section_mode"
>;
/**
* offset 자리의 구역 — 도로 = 두 노견 끝 사이 · 절토 = 측구 + 절토 사면 · 성토 = 성토 사면 +
* 구조물 트림 선. 사면 역할은 단면유형(`sideRole`)으로 · 쪽 = +offset 좌.
*/
export function crossZoneAt(
design: ZoneDesign,
offsetM: number,
structure = false,
): { zone: CrossZone; side: CrossSide } {
const side: CrossSide = offsetM >= 0 ? "left" : "right";
const edges = design.road_edges;
const hi = edges ? Math.max(edges.left.offset_m, edges.right.offset_m) : 0;
const lo = edges ? Math.min(edges.left.offset_m, edges.right.offset_m) : 0;
if (structure) return { zone: "fill", side };
if (offsetM >= lo - 1e-6 && offsetM <= hi + 1e-6) return { zone: "road", side };
const ditch = design.ditch;
const ditchWidth =
design.ditch_enabled === false || !ditch || ditch.type === "none"
? 0
: ditch.type === "standard"
? ditch.top_width_m
: ditch.width_m;
if (design.ditch_side === "left" && offsetM > hi && offsetM <= hi + ditchWidth + 1e-6)
return { zone: "cut", side };
if (design.ditch_side === "right" && offsetM < lo && offsetM >= lo - ditchWidth - 1e-6)
return { zone: "cut", side };
let roles: [string, string] = ["fill", "fill"];
try {
roles = sideRole(design.section_mode);
} catch {
// 모르는 단면유형 = 성토로 봄
}
return { zone: (side === "left" ? roles[0] : roles[1]) === "cut" ? "cut" : "fill", side };
}
/** 조각 구역 = 가운데 자리의 구역. */
export const pieceZone = (design: ZoneDesign, piece: DesignPiece) =>
crossZoneAt(design, (piece.from.offset + piece.to.offset) / 2, piece.structure);
const gap = (a: CrossPoint, b: CrossPoint): number =>
Math.hypot(a.offset - b.offset, a.elevation - b.elevation);
/** 점열이 한 직선 위에서 한 방향으로만 가는가(되접힘 없음). */
function isStraight(points: CrossPoint[]): boolean {
const a = points[0];
const b = points[points.length - 1];
const length = gap(a, b);
if (length < 1e-9) return false;
const ux = (b.offset - a.offset) / length;
const uy = (b.elevation - a.elevation) / length;
let along = -STRAIGHT_TOL_M;
for (const p of points) {
const dx = p.offset - a.offset;
const dy = p.elevation - a.elevation;
if (Math.abs(ux * dy - uy * dx) > STRAIGHT_TOL_M) return false;
const t = ux * dx + uy * dy;
if (t < along - STRAIGHT_TOL_M) return false;
along = Math.max(along, t);
}
return true;
}
/** 그려진 선들 → 같은 방향 토막을 합친 직선 조각(설계선 · 구조물 선은 따로 합침). */
export function mergeStraightPieces(runs: DesignRun[]): DesignPiece[] {
const chains: Array<{ points: CrossPoint[]; structure: boolean }> = [];
for (const run of runs) {
for (let i = 1; i < run.points.length; i += 1) {
const start = run.points[i - 1];
const end = run.points[i];
if (gap(start, end) < 1e-6) continue;
const last = chains[chains.length - 1];
if (
last &&
last.structure === run.structure &&
gap(last.points[last.points.length - 1], start) < 1e-3 &&
isStraight([...last.points, end])
)
last.points.push(end);
else chains.push({ points: [start, end], structure: run.structure });
}
}
// 차례가 떨어진 토막도 끝이 닿고 한 직선이면 합친다(방향이 거꾸로 그려진 선 포함).
const joined = (p: CrossPoint[], q: CrossPoint[]): CrossPoint[] | null => {
for (const a of [p, [...p].reverse()])
for (const b of [q, [...q].reverse()])
if (gap(a[a.length - 1], b[0]) < 1e-3 && isStraight([...a, ...b.slice(1)]))
return [...a, ...b.slice(1)];
return null;
};
let merged = true;
while (merged) {
merged = false;
for (let i = 0; i < chains.length && !merged; i += 1)
for (let j = i + 1; j < chains.length && !merged; j += 1) {
if (chains[i].structure !== chains[j].structure) continue;
const points = joined(chains[i].points, chains[j].points);
if (!points) continue;
chains[i] = { points, structure: chains[i].structure };
chains.splice(j, 1);
merged = true;
}
}
return chains.map((chain) => ({
from: chain.points[0],
to: chain.points[chain.points.length - 1],
structure: chain.structure,
}));
}
/** 벽 · 본체 폴리곤(m)에 가린 부분을 뺀 **보이는 조각**만. */
export function visiblePieces(pieces: DesignPiece[], occluders: CrossPoint[][]): DesignPiece[] {
const polygons = occluders
.filter((polygon) => polygon.length >= 3)
.map((polygon) => polygon.map((p) => [p.offset, p.elevation] as XY));
const out: DesignPiece[] = [];
for (const piece of pieces) {
let runs: XY[][] = [
[
[piece.from.offset, piece.from.elevation],
[piece.to.offset, piece.to.elevation],
],
];
for (const polygon of polygons)
runs = runs.flatMap(([a, b]) => clipSegment(a, b, polygon, "outside"));
for (const [a, b] of runs) {
const from = { offset: a[0], elevation: a[1] };
const to = { offset: b[0], elevation: b[1] };
if (gap(from, to) >= MIN_PIECE_M) out.push({ from, to, structure: piece.structure });
}
}
return out;
}
/** 치수 — 사면길이 · 수평 · 높이(m) · 구배(사면 1:n · 노면 %). */
export function pieceMeasure(
piece: DesignPiece,
zone: CrossZone,
): { lengthM: number; horizontalM: number; heightM: number; grade: string } {
const horizontalM = Math.abs(piece.to.offset - piece.from.offset);
const heightM = Math.abs(piece.to.elevation - piece.from.elevation);
const grade =
zone === "road"
? horizontalM < 1e-6
? "—"
: `${((heightM / horizontalM) * 100).toFixed(1)}%`
: heightM < 1e-4
? "0.0%"
: `1:${(horizontalM / heightM).toFixed(2)}`;
return { lengthM: Math.hypot(horizontalM, heightM), horizontalM, heightM, grade };
}
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}`
);
}
/* ── 페이지 한 칸 고름 — 측점 + 조각 번호(카드를 다시 그려도 되살림 · 137-3) ───────── */
let picked: { stationId: string; index: number } | null = null;
export const pickedLineAt = (stationId: string): number | null =>
picked?.stationId === stationId ? picked.index : null;
export function setPickedLine(stationId: string, index: number | null): void {
if (index !== null) picked = { stationId, index };
else if (picked?.stationId === stationId) picked = null;
}
/* ── 카드 배선 ─────────────────────────────────────────────────────────── */
const pointsOf = (element: Element): XY[] => {
if (element.tagName.toLowerCase() === "line") {
const n = (name: string) => Number(element.getAttribute(name));
return [
[n("x1"), n("y1")],
[n("x2"), n("y2")],
];
}
return (element.getAttribute("points") ?? "")
.trim()
.split(/\s+/)
.map((pair) => pair.split(",").map(Number) as XY)
.filter((p) => p.length === 2 && p.every(Number.isFinite));
};
export interface DesignLinePick {
pieces: DesignPiece[];
/** 치수 칸 — 카드가 [+ − ⤢] 앞에 붙임 · 고름 없으면 숨김. */
label: HTMLElement;
setActive: (index: number | null) => void;
}
/**
* 그려진 보라 선에 고름을 단다 — 벽 · 관 등을 다 그린 **뒤**(가림 · 판정면 차례) 부른다.
* `x` · `toDisplayY` = 카드 좌표 함수(1차식 — 역산해 m 로 되돌림).
*/
export function attachDesignLinePick(input: {
layer: SVGElement;
design: ZoneDesign;
x: (offset: number) => number;
toDisplayY: (elevation: number) => number;
onPick: (index: number) => void;
}): DesignLinePick {
const { layer, design, x, toDisplayY } = input;
const x0 = x(0);
const kx = x(1) - x0;
const y0 = toDisplayY(0);
const ky = toDisplayY(1) - y0;
const toM = ([px, py]: XY): CrossPoint => ({ offset: (px - x0) / kx, elevation: (py - y0) / ky });
const kids = [...layer.children];
const runs: DesignRun[] = kids
.filter((el) => el.classList.contains(DESIGN_CLASS))
.map((el) => ({
points: pointsOf(el).map(toM),
structure: !el.hasAttribute(DESIGN_LINE_ATTR),
}));
const occluders = kids
.filter(
(el) =>
el.tagName.toLowerCase() === "polygon" &&
OCCLUDER_CLASSES.some((name) => el.classList.contains(name)),
)
.map((el) => pointsOf(el).map(toM));
const pieces = visiblePieces(mergeStraightPieces(runs), occluders);
// 판정 선은 구조물 판정면 **아래** — 겹친 자리는 구조물이 이김.
const firstStructureHit = kids.find((el) =>
el.classList.contains("b06-chart__culvert-revet-hit"),
);
const hits = pieces.map((piece, index) => {
const hit = document.createElementNS(SVG_NS, "polyline");
hit.setAttribute(
"points",
[piece.from, piece.to].map((p) => `${x(p.offset)},${toDisplayY(p.elevation)}`).join(" "),
);
hit.setAttribute("class", HIT_CLASS);
hit.addEventListener("click", (event) => {
event.stopPropagation(); // 카드 고름 · 면적 띠로 번지지 않음
input.onPick(index);
});
layer.insertBefore(hit, firstStructureHit ?? null);
return hit;
});
const label = document.createElement("span");
label.className = "b06-cross-card__line-dims";
label.hidden = true;
return {
pieces,
label,
setActive: (index) => {
const piece = index === null ? undefined : pieces[index];
hits.forEach((hit, i) => hit.classList.toggle("is-active", !!piece && i === index));
label.hidden = !piece;
label.textContent = piece ? pieceLabel(design, piece) : "";
},
};
}
+61 -2
View File
@@ -55,6 +55,13 @@ import type {
} from "./B06_Section_UI_Cross_Culvert_Wire";
import { culvertCardState } from "./B06_Section_UI_Cross_View_Structure";
import { buildCardPanels, emptyCardCulvertState } from "./B06_Section_UI_Cross_View_Panels";
import {
attachDesignLinePick,
DESIGN_LINE_ATTR,
type DesignLinePick,
pickedLineAt,
setPickedLine,
} from "./B06_Section_UI_Cross_Design_Pick";
import { attachZoomPan, buildZoomControls, cardZoomStates } from "./B06_Section_UI_Cross_View_Zoom";
import type { CrossWidthActions } from "./B06_Section_UI_Cross_View_Zoom";
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
@@ -191,6 +198,12 @@ export function createCrossSectionCard(
let showRevetControl: (visible: boolean) => void = () => undefined;
/** 조정창이 읽는 마지막 계산 결과(벽 제원 · 단 수 등) — `_Cross_View_Panels`. */
const culvertState = emptyCardCulvertState();
/** 보라 선 고름(137-2) — 고름은 페이지 한 칸(`_Cross_Design_Pick`) · 강조는 그린 뒤 붙음. */
let setLineActive: (index: number | null) => void = () => undefined;
const dropLine = (): void => {
setPickedLine(section.station_id, null);
setLineActive(null);
};
/**
* 기슭막이를 고른다. 구조물 선택과 절·성토 면적 강조는 **같은 레벨**이라 하나를
* 고르면 다른 하나는 풀린다(2026-08-21 사용자). 또 구조물을 고르면 그 **측점 카드도
@@ -204,6 +217,7 @@ export function createCrossSectionCard(
box,
isCardSelected: () => isSelected,
clearArea: () => {
dropLine(); // 고름 하나만(137-3)
if (activeRevet !== null) toggleRevet(activeRevet); // 벽 · 본체 동시 고름 금지(107-1)
if (activeArea === null) return;
activeArea = null;
@@ -227,6 +241,7 @@ export function createCrossSectionCard(
setChipActive(null);
}
if (activeRevet !== null) {
dropLine(); // 고름 하나만(137-3)
bodies.clearSelection(); // 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1)
// 미선택 카드는 **카드 선택만** 부른다. 면적 비우기(onAreaSelect)를 먼저 부르면 그쪽이
// 카드를 선택해 버려 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로 먹힌다(2026-08-21
@@ -251,7 +266,10 @@ export function createCrossSectionCard(
if (owns) revetOffset?.select(section.chainage_m, null);
else card.applyRevetHighlight?.(revetOffset?.highlightFor(section) ?? null);
}
if (activeArea !== null || !nextSelected) bodies.clearSelection();
if (activeArea !== null || !nextSelected) {
bodies.clearSelection();
dropLine();
}
};
card.applyRevetHighlight = (key) => {
highlightRevet = key;
@@ -276,10 +294,34 @@ export function createCrossSectionCard(
showRevetControl(false);
revetOffset?.select(section.chainage_m, null);
}
if (activeArea !== null) {
bodies.clearSelection(); // 고름 하나만 — 면적 강조도 본체 · 선과 같은 칸(137-3)
dropLine();
}
setBandActive(activeArea);
setChipActive(activeArea);
onAreaSelect?.(section.station_id, activeArea);
};
/** 보라 선 고름 — 다른 고름(벽 · 본체 · 면적)을 풀고 카드도 고름(137-3). 다시 누르면 풂. */
const pickLine = (index: number): void => {
const next = pickedLineAt(section.station_id) === index ? null : index;
setPickedLine(section.station_id, next);
if (next !== null) {
if (activeRevet !== null) toggleRevet(activeRevet);
bodies.clearSelection();
if (activeArea !== null) {
activeArea = null;
setBandActive(null);
setChipActive(null);
onAreaSelect?.(section.station_id, null);
}
if (!isSelected) onSelect(section.station_id);
}
setLineActive(next);
};
// 고른 카드 · 다른 고름이 없을 때만 되살림 — 바깥 길(목록 · 3D)이 구조물을 골랐으면 선을 풂.
if (!selected || activeRevet !== null || activeArea !== null)
setPickedLine(section.station_id, null);
appendCardHeader(card, section, stationInterval, onDesignChange);
@@ -361,6 +403,7 @@ export function createCrossSectionCard(
const toDisplayY = (elevation: number): number =>
y(elevationMid + (elevation - elevationMid) * exaggeration);
appendGroundProfile(plotLayer, drawSamples, x, toDisplayY);
let linePick: DesignLinePick | null = null;
if (section.design) {
// 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다.
@@ -465,6 +508,10 @@ export function createCrossSectionCard(
x: (CROSS_PAD.left + widthPx - CROSS_PAD.right) / 2,
y: heightPx - CROSS_PAD.bottom - 4,
});
// 설계선 오버레이가 그린 보라 선 표시 — 고름 구역에서 구조물 트림 선과 가름(137-2).
[...plotLayer.children]
.slice(designFrom)
.forEach((el) => el.setAttribute(DESIGN_LINE_ATTR, ""));
// 물넘이 자리 = 노면 · 측구 설계선 걷음(66-2 · 66-3 — 옛 계획고 점선이 드러남)
if (fordPaved && section.ford_pavement)
hideFordPavedRoad(
@@ -524,6 +571,16 @@ export function createCrossSectionCard(
if (highlightRevet) setRevetActive(highlightRevet);
}
raiseWalls(plotLayer); // 흙막이 · 옹벽 안을 지나는 다른 선 가림(66-5)
// 보라 선 고름 — 가림 · 판정면 차례가 정해진 뒤(137-2).
linePick = attachDesignLinePick({
layer: plotLayer,
design: section.design,
x,
toDisplayY,
onPick: pickLine,
});
setLineActive = linePick.setActive;
setLineActive(pickedLineAt(section.station_id));
}
// 십자선(계획 노선 자리) · 축선 · 높이 축 이름 — `_Cross_View_Frame`(700줄 제한).
@@ -589,7 +646,9 @@ export function createCrossSectionCard(
// 팬으로 끌어다 본다(PLAN 79-2 · 교차점 폭으로 넓히던 것 폐지).
fit: () => stationWidth.request(section.chainage_m, crossHalfWidth ?? maxOffset),
};
chartWrap.append(svg, readout.root, buildZoomControls(zoomPan, widthActions), panel.root);
const zoomBar = buildZoomControls(zoomPan, widthActions);
if (linePick) zoomBar.prepend(linePick.label); // 치수 = [+ − ⤢] 왼쪽(137-2)
chartWrap.append(svg, readout.root, zoomBar, panel.root);
for (const panel of bodies.panels) chartWrap.append(panel.root);
if (activeArea) {
setBandActive(activeArea);
@@ -146,6 +146,21 @@
stroke-dasharray: 4 4;
}
/* 보라 선 고름(137-2) — 투명 판정 선 · 고르면 굵은 반투명 띠. */
.b06-chart__design-hit {
fill: none;
stroke: transparent;
stroke-width: 10;
stroke-linecap: round;
pointer-events: stroke;
cursor: pointer;
}
.b06-chart__design-hit.is-active {
stroke: color-mix(in srgb, var(--color-royal-amethyst) 45%, transparent);
stroke-width: 7;
}
/* 차도·노견 경계 짧은 수직 틱(N-4-2) — 설계선과 같은 계열, 얇게. */
.b06-chart__carriageway-tick {
stroke: var(--color-royal-amethyst);
@@ -223,6 +223,20 @@
background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
}
/* 고른 보라 선 치수(137-2) — [+ − ⤢] 왼쪽 같은 줄. */
.b06-cross-card__line-dims {
display: flex;
align-items: center;
padding: 0 var(--spacing-4);
color: var(--color-royal-amethyst);
font-size: 0.75rem;
white-space: nowrap;
}
.b06-cross-card__line-dims[hidden] {
display: none;
}
.b06-cross-card__zoom-btn {
width: 22px;
height: 22px;
@@ -236,7 +250,8 @@
cursor: pointer;
}
.b06-cross-card__zoom-btn:first-child {
.b06-cross-card__zoom-btn:first-child,
.b06-cross-card__line-dims[hidden] + .b06-cross-card__zoom-btn {
border-left: none;
}
@@ -22,6 +22,7 @@ import {
appendPavementOverlay,
} from "../B06_Section/B06_Section_UI_Cross_Design";
import { appendRevetmentOverlay } from "../B06_Section/B06_Section_UI_Cross_Revetment";
import { clipPointsToPolygon, clipSegment, pointInPolygon } from "../A00_Common/b_polygon_clip";
const SVG_NS = "http://www.w3.org/2000/svg";
@@ -220,59 +221,6 @@ function clipPolygonOf(element: SVGElement): XY[] | null {
return points.length >= 3 ? points : null;
}
/** 점이 폴리곤 안인가 — 오목한 벽 단면도 되도록 광선 교차 홀짝으로 본다. */
function pointInPolygon(point: XY, polygon: XY[]): boolean {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
const [xi, yi] = polygon[i];
const [xj, yj] = polygon[j];
const straddles = yi > point[1] !== yj > point[1];
if (straddles && point[0] < ((xj - xi) * (point[1] - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/** 선분을 폴리곤 경계에서 잘라 **안쪽 조각들**만 돌려준다. */
function clipSegment(start: XY, end: XY, polygon: XY[]): XY[][] {
const [x0, y0] = start;
const [x1, y1] = end;
const dx = x1 - x0;
const dy = y1 - y0;
const cuts = [0, 1];
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
const [ex, ey] = polygon[j];
const [fx, fy] = polygon[i];
const denominator = dx * (fy - ey) - dy * (fx - ex);
if (Math.abs(denominator) < 1e-12) continue;
const t = ((ex - x0) * (fy - ey) - (ey - y0) * (fx - ex)) / denominator;
const u = ((ex - x0) * dy - (ey - y0) * dx) / denominator;
if (t > 0 && t < 1 && u >= 0 && u <= 1) cuts.push(t);
}
cuts.sort((a, b) => a - b);
const runs: XY[][] = [];
for (let index = 0; index + 1 < cuts.length; index += 1) {
const from = cuts[index];
const to = cuts[index + 1];
if (to - from < 1e-9) continue;
const mid = (from + to) / 2;
if (!pointInPolygon([x0 + dx * mid, y0 + dy * mid], polygon)) continue;
runs.push([
[x0 + dx * from, y0 + dy * from],
[x0 + dx * to, y0 + dy * to],
]);
}
return runs;
}
/** 점열을 폴리곤 안으로 자른다 — 조각마다 한 줄. */
function clipPointsToPolygon(points: XY[], polygon: XY[]): XY[][] {
const runs: XY[][] = [];
for (let index = 0; index + 1 < points.length; index += 1) {
runs.push(...clipSegment(points[index], points[index + 1], polygon));
}
return runs;
}
function circlePoints(center: XY, radius: number): XY[] {
const points: XY[] = [];
for (let index = 0; index <= CIRCLE_SEGMENTS; index += 1) {
+317
View File
@@ -0,0 +1,317 @@
/* PLAN 137-2 · 137-3 단위검증 헬퍼 — B06 보라 선 고름(조각 · 가림 · 치수 · 구역 · 판정 선 · 한 칸 고름).
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
* 입력 = 손으로 짠 단면 + `fixtures/b07_cross_overlay_sections.json` 배관 측점 85.052. 짝 = `test_137_design_pick.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
const STUBS = {
"@ui/ui_template_toast": { showToast: () => undefined },
"@ui/ui_template_elements": { showToast: () => undefined },
};
// ── 화면 그릇 흉내 — 오버레이 · 판정 선이 쓰는 만큼만 ─────────────────────────
class FakeNode {
constructor(tag) {
this.tagName = String(tag);
this.children = [];
this.attrs = {};
this.listeners = {};
this.hidden = false;
this._text = "";
this._classes = new Set();
const self = this;
this.classList = {
add: (...names) => names.forEach((n) => self._classes.add(n)),
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
toggle: (name, force) => {
const on = force === undefined ? !self._classes.has(name) : !!force;
on ? self._classes.add(name) : self._classes.delete(name);
return on;
},
contains: (name) => self._classes.has(name),
};
this.style = { setProperty: () => {} };
}
set className(value) {
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
}
get className() {
return [...this._classes].join(" ");
}
set textContent(value) {
this._text = String(value);
this.children = [];
}
get textContent() {
return this._text + this.children.map((c) => c.textContent).join("");
}
get childElementCount() {
return this.children.length;
}
get nextElementSibling() {
const kids = this.parent?.children ?? [];
return kids[kids.indexOf(this) + 1] ?? null;
}
_detach(kid) {
if (kid.parent) kid.parent.children = kid.parent.children.filter((k) => k !== kid);
kid.parent = this;
}
append(...kids) {
kids.forEach((k) => {
this._detach(k);
this.children.push(k);
});
}
appendChild(kid) {
this.append(kid);
return kid;
}
insertBefore(kid, ref) {
this._detach(kid);
const at = ref ? this.children.indexOf(ref) : -1;
if (at < 0) this.children.push(kid);
else this.children.splice(at, 0, kid);
return kid;
}
remove() {
if (this.parent) this.parent.children = this.parent.children.filter((k) => k !== this);
}
matches(selector) {
if (selector === "text") return this.tagName === "text";
return selector
.split(",")
.map((part) => part.trim())
.some((part) =>
part === "text" ? this.tagName === "text" : this._classes.has(part.slice(1)),
);
}
setAttribute(name, value) {
this.attrs[name] = String(value);
if (name === "class") this.className = value;
}
getAttribute(name) {
return name === "class" ? this.className : (this.attrs[name] ?? null);
}
hasAttribute(name) {
return name in this.attrs;
}
addEventListener(type, fn) {
(this.listeners[type] ??= []).push(fn);
}
click() {
let stopped = false;
const event = { stopPropagation: () => (stopped = true), preventDefault: () => {} };
(this.listeners.click ?? []).forEach((fn) => fn(event));
return stopped;
}
}
globalThis.document = {
createElement: (tag) => new FakeNode(tag),
createElementNS: (_ns, tag) => new FakeNode(tag),
};
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) {
const data = JSON.parse(fs.readFileSync(full, "utf8"));
cache.set(full, data);
return data;
}
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
for (const [prefix, dir] of Object.entries(alias))
if (request.startsWith(prefix))
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
const pick = loadTs(B06("B06_Section_UI_Cross_Design_Pick"));
const { pointInPolygon } = loadTs(path.join(ROOT, "A00_Common", "b_polygon_clip.ts"));
const out = {};
const r3 = (v) => Math.round(v * 1000) / 1000;
const pt = (offset, elevation) => ({ offset, elevation });
// ① 조각 — 0.5 m 토막 노면(가운데 꺾임) · 측구 · 사면이 직선 조각 다섯으로
const sampled = (a, b, step = 0.5) => {
const n = Math.max(1, Math.round(Math.abs(b.offset - a.offset) / step));
return Array.from({ length: n + 1 }, (_, i) =>
pt(
a.offset + ((b.offset - a.offset) * i) / n,
a.elevation + ((b.elevation - a.elevation) * i) / n,
),
);
};
const line = [
...sampled(pt(-6, 98), pt(-2.5, 99.95)), // 우 성토 사면
...sampled(pt(-2.5, 99.95), pt(0, 100)).slice(1), // 우 노면 2%
...sampled(pt(0, 100), pt(2.5, 99.95)).slice(1), // 좌 노면 2%
...sampled(pt(2.5, 99.95), pt(3, 99.65)).slice(1), // 측구 안
...sampled(pt(3, 99.65), pt(6, 102.65)).slice(1), // 좌 절토 사면
].map((p) => ({ offset: r3(p.offset), elevation: Math.round(p.elevation * 1e4) / 1e4 }));
// 토막마다 한 줄(설계선 오버레이 꼴) + 차례를 뒤집은 한 토막(거꾸로 그린 선)
const runs = [];
for (let i = 1; i < line.length; i += 1)
runs.push({ points: [line[i - 1], line[i]], structure: false });
runs.push({ points: [pt(-6.5, 98 - (0.5 * 1.95) / 3.5), pt(-6, 98)].reverse(), structure: false });
const merged = pick.mergeStraightPieces(runs);
out.merge = {
segments: runs.length,
pieces: merged.map((p) => [r3(p.from.offset), r3(p.to.offset)].sort((a, b) => a - b)),
};
// 구조물 선은 같은 직선이어도 설계선과 안 합침
out.mergeStructure = pick.mergeStraightPieces([
{ points: [pt(10, 0), pt(11, 1)], structure: false },
{ points: [pt(11, 1), pt(12, 2)], structure: true },
]).length;
// ② 가림 — 사면을 가로지르는 벽 → 벽 밖 두 조각
const slope = [{ from: pt(3, 99.65), to: pt(6, 102.65), structure: false }];
const wall = [pt(4, 100), pt(4.5, 100), pt(4.5, 103), pt(4, 103)];
const visible = pick.visiblePieces(slope, [wall]);
out.occlude = {
pieces: visible.map((p) => [r3(p.from.offset), r3(p.to.offset)]),
// 통째로 덮이면 조각 없음
covered: pick.visiblePieces(slope, [[pt(2, 90), pt(7, 90), pt(7, 110), pt(2, 110)]]).length,
};
// ③ 치수
const fillPiece = { from: pt(-2.5, 99.95), to: pt(-5.5, 97.95), structure: false };
const roadPiece = { from: pt(0, 100), to: pt(2.5, 99.95), structure: false };
out.measure = {
fill: pick.pieceMeasure(fillPiece, "fill"),
road: pick.pieceMeasure(roadPiece, "road"),
flatSlope: pick.pieceMeasure({ from: pt(4, 1), to: pt(5, 1), structure: false }, "cut").grade,
};
const design = {
road_edges: {
left: { offset_m: 2.5, elevation_m: 99.95 },
right: { offset_m: -2.5, elevation_m: 99.95 },
},
ditch: { type: "standard", top_width_m: 0.5, bottom_width_m: 0.2, depth_m: 0.3 },
ditch_side: "left",
ditch_enabled: true,
section_mode: "left_cut",
};
out.label = { fill: pick.pieceLabel(design, fillPiece), road: pick.pieceLabel(design, roadPiece) };
// ④ 구역 — 도로 = 두 노견 끝 사이 · 절토 = 측구 + 절토 사면 · 성토 = 성토 사면 + 구조물 선
const zone = (o, structure = false, d = design) => pick.crossZoneAt(d, o, structure);
out.zone = {
road: zone(1),
roadEdge: zone(-2.5).zone,
ditch: zone(2.8),
cutSlope: zone(5),
fillSlope: zone(-4),
structure: zone(5, true).zone,
rightCutDitch: zone(-2.8, false, { ...design, ditch_side: "right", section_mode: "right_cut" })
.zone,
bothFill: zone(5, false, { ...design, ditch_enabled: false, section_mode: "both_fill" }).zone,
pieceZones: merged.map((p) => pick.pieceZone(design, p).zone),
};
// ⑤ 실제 측점 — 설계선 + 배관 오버레이를 그린 뒤 고름을 붙임(카드와 같은 차례)
const { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts"));
const { appendCulvertOverlay } = loadTs(B06("B06_Section_UI_Cross_Culvert"));
const { appendCrossDesignOverlay } = loadTs(B06("B06_Section_UI_Cross_Design"));
const { raiseWalls } = loadTs(B06("B06_Section_UI_Cross_Wall"));
const fixture = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures", "b07_cross_overlay_sections.json"), "utf8"),
);
const sections = fixture.cross_sections;
const pipeSection = sections.find((s) => s.culvert && !s.culvert.hidden_pipe);
const layout = computeStoredLayouts(pipeSection, sections).culvert;
const x = (o) => 100 - o * 12;
const y = (e) => 900 - (e - 850) * 12;
const layer = new FakeNode("g");
const from = layer.childElementCount;
appendCrossDesignOverlay(layer, pipeSection.design, x, y, pipeSection.samples, layout.designTrim);
layer.children.slice(from).forEach((el) => el.setAttribute(pick.DESIGN_LINE_ATTR, ""));
appendCulvertOverlay(layer, layout, x, y, () => undefined, false);
raiseWalls(layer);
const picks = [];
const handle = pick.attachDesignLinePick({
layer,
design: pipeSection.design,
x,
toDisplayY: y,
onPick: (i) => picks.push(i),
});
const kids = layer.children;
const hits = kids.filter((k) => k.classList.contains("b06-chart__design-hit"));
const firstStructureHit = kids.findIndex((k) =>
k.classList.contains("b06-chart__culvert-revet-hit"),
);
const lastLineHit = kids.reduce(
(last, k, i) => (k.classList.contains("b06-chart__design-hit") ? i : last),
-1,
);
const walls = kids
.filter((k) => k.tagName === "polygon" && k.classList.contains("b06-chart__culvert-revet"))
.map((k) =>
k
.getAttribute("points")
.split(/\s+/)
.map((pair) => pair.split(",").map(Number)),
);
const insideWall = handle.pieces.filter((p) => {
const mid = [x((p.from.offset + p.to.offset) / 2), y((p.from.elevation + p.to.elevation) / 2)];
return walls.some((w) => pointInPolygon(mid, w));
}).length;
const designLines = kids.filter((k) => k.classList.contains("b06-chart__design-cross")).length;
const stopped = hits[0].click();
handle.setActive(1);
const activeOn = hits.map((h) => h.classList.contains("is-active"));
const labelOn = { hidden: handle.label.hidden, text: handle.label.textContent };
handle.setActive(null);
out.real = {
designLines,
pieces: handle.pieces.length,
structurePieces: handle.pieces.filter((p) => p.structure).length,
hits: hits.length,
wallCount: walls.length,
insideWall,
hitsBelowStructure: firstStructureHit < 0 || lastLineHit < firstStructureHit,
picked: picks,
stopped,
activeCount: activeOn.filter(Boolean).length,
activeIndex: activeOn.indexOf(true),
labelOn,
labelOff: { hidden: handle.label.hidden, text: handle.label.textContent },
zones: [...new Set(handle.pieces.map((p) => pick.pieceZone(pipeSection.design, p).zone))].sort(),
};
// ⑥ 페이지 한 칸 — 다른 측점을 고르면 앞 측점 고름은 사라짐 · 남의 측점 풀기는 안 건드림
pick.setPickedLine("A", 2);
const a1 = pick.pickedLineAt("A");
pick.setPickedLine("B", 1);
const a2 = pick.pickedLineAt("A");
pick.setPickedLine("A", null);
out.slot = { a1, a2, b: pick.pickedLineAt("B") };
pick.setPickedLine("B", null);
out.slot.cleared = pick.pickedLineAt("B");
process.stdout.write(JSON.stringify(out));
+110
View File
@@ -0,0 +1,110 @@
"""PLAN 137-2 · 137-3 — B06 보라 선 고름: 직선 조각 · 가림 · 치수 · 구역 · 판정 선 · 페이지 한 칸.
짝: `B06_Section/B06_Section_UI_Cross_Design_Pick.ts` · `A00_Common/b_polygon_clip.ts`(B07 에서 옮김) ·
`B06_Section_UI_Cross_View.ts`(배선 · 하나만). 실제 TS 를 `helper_137_design_pick.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(__file__)
ROOT = os.path.join(HERE, "..", "..")
pytestmark = pytest.mark.skipif(
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
reason="프론트엔드 의존성 미설치",
)
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_137_design_pick.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _src(rel: str) -> str:
with open(os.path.join(ROOT, rel), encoding="utf-8") as f:
return f.read()
def test_같은_방향_토막은_직선_조각_하나(out) -> None:
"""0.5 m 토막 25개 → 성토 사면 · 노면 둘(가운데 꺾임) · 측구 · 절토 사면 = 다섯 · 거꾸로 그린 토막도 합침."""
assert out["merge"]["segments"] == 25
assert out["merge"]["pieces"] == [[-6.5, -2.5], [-2.5, 0], [0, 2.5], [2.5, 3], [3, 6]]
assert out["mergeStructure"] == 2 # 구조물 선은 설계선과 안 합침
def test_벽에_가린_부분은_빼고_보이는_조각만(out) -> None:
assert out["occlude"]["pieces"] == [[3, 4], [4.5, 6]]
assert out["occlude"]["covered"] == 0
def test_치수_사면길이_수평_높이_구배(out) -> None:
fill = out["measure"]["fill"]
assert (fill["horizontalM"], fill["heightM"], fill["grade"]) == (3, 2, "1:1.50")
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%"
def test_구역_도로_절토_성토(out) -> None:
zone = out["zone"]
assert zone["road"] == {"zone": "road", "side": "left"}
assert zone["roadEdge"] == "road"
assert zone["ditch"] == {"zone": "cut", "side": "left"} # 측구 = 절토
assert zone["cutSlope"] == {"zone": "cut", "side": "left"}
assert zone["fillSlope"] == {"zone": "fill", "side": "right"}
assert zone["structure"] == "fill" # 구조물 트림 선 = 성토
assert zone["rightCutDitch"] == "cut"
assert zone["bothFill"] == "fill"
assert zone["pieceZones"] == ["fill", "road", "road", "cut", "cut"]
def test_실제_배관_측점_조각과_판정_선(out) -> None:
real = out["real"]
assert real["pieces"] < real["designLines"] # 토막이 조각으로 합쳐짐
assert real["structurePieces"] > 0 # 구조물 트림 선도 고름 대상
assert real["hits"] == real["pieces"]
assert real["wallCount"] == 2 and real["insideWall"] == 0 # 벽 안 조각 없음
assert real["hitsBelowStructure"] is True # 구조물 판정면이 위
assert real["picked"] == [0] and real["stopped"] is True # 카드 고름으로 안 번짐
assert real["activeCount"] == 1 and real["activeIndex"] == 1
assert real["labelOn"]["hidden"] is False and real["labelOn"]["text"].startswith("사면길이 ")
assert real["labelOff"] == {"hidden": True, "text": ""}
assert set(real["zones"]) <= {"road", "cut", "fill"}
def test_페이지_한_칸_고름(out) -> None:
assert out["slot"] == {"a1": 2, "a2": None, "b": 1, "cleared": None}
def test_카드_배선_하나만() -> None:
"""137-3 — 선을 고르면 벽 · 본체 · 면적을 풀고 카드도 고름 · 다른 고름은 선을 풂."""
view = _src("B06_Section/B06_Section_UI_Cross_View.ts")
assert "const pickLine = (index: number): void => {" in view
assert "if (!isSelected) onSelect(section.station_id);" in view
assert view.count("dropLine();") >= 4 # 본체 · 벽 · 카드 풂 · 면적
assert "setLineActive(pickedLineAt(section.station_id));" in view # 다시 그려도 되살림
assert "zoomBar.prepend(linePick.label);" in view # 치수 = [+ − ⤢] 왼쪽
css = _src("B06_Section/B06_Section_UI_Style_Cross_Areas.css")
assert ".b06-chart__design-hit.is-active {" in css
def test_폴리곤_자르기는_공용_한_벌() -> None:
b07 = _src("B07_DesignDetail/B07_DesignDetail_Cross_Overlay.ts")
assert "function clipSegment(" not in b07 and "function pointInPolygon(" not in b07
assert 'from "../A00_Common/b_polygon_clip"' in b07