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:
@@ -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) : "";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user