Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_7
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;
|
||||
}
|
||||
@@ -399,7 +399,7 @@ export async function mountCrossView(
|
||||
// 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1)
|
||||
const pipeAt = structuresPanel.currentChainage();
|
||||
const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`);
|
||||
if (mark) sectionView.selectMark(mark);
|
||||
if (mark) sectionView.selectMark(mark, false); // 측점 고름 = 카드 하나(137-4) · 목록 길은 뒤에 여럿
|
||||
structureSelection.syncInletStructure();
|
||||
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
|
||||
// (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다.
|
||||
|
||||
@@ -102,6 +102,13 @@ export function appendCulvertOverlay(
|
||||
line.append(title);
|
||||
layer.append(line);
|
||||
};
|
||||
// 묻힌 끝 절토선 툴팁 — 3도 내림 선(끝이 시작보다 낮음) / 못 만나 복합경사 절토(139-4 · 139-5).
|
||||
const cutLineTip = (name: string, segment: (typeof layout.outletFill.segments)[number]) => {
|
||||
const [first, last] = [segment.points[0], segment.points[segment.points.length - 1]];
|
||||
return last.elevation < first.elevation
|
||||
? `${name} 3도 절토선 — 구조물이 원지반에 묻혀 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`
|
||||
: `${name} 절토선 — 3도 선이 원지반을 못 만나 절토 경사로 원지반까지`;
|
||||
};
|
||||
const drawOutletFill = (): void => {
|
||||
// 도수로 · 돌붙임 띠(90-2) — 유출 끝 ~ 원지반 성토 조각(단 사이 포함) · 연동 카드엔 안 그림
|
||||
if (culvert.chute && !linked)
|
||||
@@ -113,13 +120,10 @@ export function appendCulvertOverlay(
|
||||
toDisplayY,
|
||||
);
|
||||
for (const segment of layout.outletFill.segments) {
|
||||
// 유출부가 지면에 닿은 뒤의 절토선은 그리지 않는다(2026-08-24 사용자) — 벽이
|
||||
// 이미 원지반에 묻힌 구간이라 도면에 더 보탤 정보가 없다. 면적·수량 계산은
|
||||
// 건드리지 않는다(그림만 — 면적은 전면 개편 대상).
|
||||
// 세월교 · 박스 흙막이는 수평공간 뒤 절토선을 그림(79-7 · 면적에도 듦).
|
||||
if (segment.kind === "cut" && !culvert.body) continue;
|
||||
// 묻힌 유출 끝 = 3도 수평 절토선(보라 · 면적에도 듦 · 139-4) — 3도 선이 원지반을
|
||||
// 못 만나면 복합경사 절토선(89-4).
|
||||
if (segment.kind === "cut") {
|
||||
planLine(segment.points, `유출부 절토선 — 수평공간 뒤 절토 경사로 원지반까지`);
|
||||
planLine(segment.points, cutLineTip("유출부", segment));
|
||||
continue;
|
||||
}
|
||||
planLine(
|
||||
@@ -144,11 +148,9 @@ export function appendCulvertOverlay(
|
||||
for (const segment of layout.basinFill.segments) {
|
||||
planLine(
|
||||
segment.points,
|
||||
segment.kind === "cut" && culvert.body
|
||||
? `${basinFillName} 절토선 — 수평공간 뒤 절토 경사로 원지반까지`
|
||||
: segment.kind === "cut"
|
||||
? `${basinFillName} 3도 절토선 — 구조물이 원지반에 묻혀 성토 불필요, 원지반 교차까지 ${segment.lengthM.toFixed(2)}m`
|
||||
: `${basinFillName} 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
|
||||
segment.kind === "cut"
|
||||
? cutLineTip(basinFillName, segment)
|
||||
: `${basinFillName} 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m` +
|
||||
(segment.overLimit
|
||||
? " (법정 5m 이상 — 흙막이 의무 구간, 조정창에서 추가)"
|
||||
: " (법정 5m 이내)") +
|
||||
|
||||
@@ -50,8 +50,8 @@ export interface OutletExtrasInput {
|
||||
equalize?: boolean;
|
||||
/** 단 벽의 전면 기울기 — 형태·순수 높이로 표준경사를 고름(없으면 종전 1:0.3). */
|
||||
leanFor?: (form: string, pureHeightM: number) => number;
|
||||
/** 끝이 원지반에 묻힐 때 수평공간 뒤 절토(본선과 같은 복합경사 · 91-4)로 지반까지(세월교 · 박스
|
||||
* 흙막이 · 79-7). 없으면 종전 3도 선. */
|
||||
/** 끝이 원지반에 묻혀 3도 선이 원지반을 못 만날 때만 — 수평공간 뒤 절토(본선과 같은 복합경사 ·
|
||||
* 91-4)로 지반까지(139-4). 없으면 3도 선만(못 만나면 선 없음). */
|
||||
cutBerm?: { lengthM: number; cut: StructureCutSpec };
|
||||
}
|
||||
|
||||
@@ -379,14 +379,13 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
|
||||
}
|
||||
|
||||
// 끝 구간(2026-08-22 사용자 ③): 시작점이 지반 위면 성토부선, 지반 아래(매몰)면
|
||||
// **0도 절토선**을 원지반 교차까지 연장한다(교차가 없는 자리는 placeable이 막는다).
|
||||
// **3도 내림 절토선**을 원지반 교차까지(139-4 · 139-5 — 끝 연결선 한 규칙). 3도 선이
|
||||
// 원지반을 못 만나면(바깥이 오르막) 그때만 수평공간 뒤 복합경사 절토(`cutBerm` · 89-4).
|
||||
let addable = false;
|
||||
if (groundAt(src.offset) < src.elevation - 0.01) {
|
||||
const tail = trailingFill(src, outward, groundAt);
|
||||
segments.push(tail);
|
||||
addable = tail.overLimit;
|
||||
} else if (input.cutBerm) {
|
||||
segments.push(bermCut(src, outward, input.cutBerm, groundAt, limitOffset));
|
||||
} else {
|
||||
const cross = slopedCrossing(src, outward, -PIPE_CONNECT_GRADE, groundAt, limitOffset);
|
||||
if (cross !== null) {
|
||||
@@ -400,6 +399,8 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
|
||||
overLimit: false,
|
||||
kind: "cut",
|
||||
});
|
||||
} else if (input.cutBerm) {
|
||||
segments.push(bermCut(src, outward, input.cutBerm, groundAt, limitOffset));
|
||||
}
|
||||
}
|
||||
return { walls, segments, appliedAdjusts, addable };
|
||||
|
||||
@@ -642,7 +642,7 @@ export function computeCulvertLayout(
|
||||
trimMinSlope = { points: [...(trimMinSlope?.points ?? []), ...points] };
|
||||
}
|
||||
};
|
||||
extendTrimSlope(drawnFillPoints(extrasForArea, !!body), outletInfo.outward);
|
||||
extendTrimSlope(drawnFillPoints(extrasForArea, true), outletInfo.outward); // 묻힌 유출 3도 절토(139-4)
|
||||
extendTrimSlope(drawnFillPoints(basinExtrasForArea, !!body), inletInfo.outward);
|
||||
|
||||
const ownTrim: CulvertDesignTrim | null =
|
||||
|
||||
@@ -42,8 +42,8 @@ export const outletPipeEnd = (wall: WallLayout): OffsetPoint => {
|
||||
|
||||
// 다단 기슭막이의 성토부선까지 트림에 넣는다 — 넣지 않으면 단을 올려도 폐회로가 그대로라
|
||||
// **물량이 안 바뀐다**(2026-09-07 사용자 확정). 화면이 그리는 선(`outletFill.segments`)과
|
||||
// 같은 선을 면적도 보게 맞춘다 — 그리지 않는 `cut` 갈래(벽이 원지반에 묻힌 자리)는 뺀다.
|
||||
// 세월교 · 박스 흙막이(`withCut`)는 수평공간 뒤 절토선을 그리므로 넣음(79-7).
|
||||
// 같은 선을 면적도 보게 맞춘다 — `withCut` 이면 묻힌 끝 절토선(`cut` 갈래)도 넣음(유출 = 늘 ·
|
||||
// 139-4 · 계류측 = 세월교 · 박스만 79-7). 집수정 계류측 3도 선은 그림만.
|
||||
export const drawnFillPoints = (result: OutletExtrasResult, withCut = false): OffsetPoint[] =>
|
||||
result.segments
|
||||
.filter((segment) => withCut || segment.kind !== "cut")
|
||||
@@ -85,15 +85,16 @@ export function buildCulvertTiers(input: {
|
||||
section_mode: section.design?.section_mode,
|
||||
side: culvert?.hidden_pipe ? culvert.inlet.face_side : null,
|
||||
});
|
||||
// 세월교 · 박스 흙막이 끝 절토 = 컨테이너 수평공간 뒤 절토 경사(79-7) · 배관은 종전(3도 선).
|
||||
const cutBerm = (lengthM: number) =>
|
||||
culvert?.body
|
||||
? { lengthM: Math.max(lengthM, 0), cut: structureCutSpec(section.design) }
|
||||
: undefined;
|
||||
// 묻힌 끝 = 3도 절토선 먼저 · 원지반을 못 만나면 수평공간 뒤 절토 경사(139-4 · 139-5 — 배관 ·
|
||||
// 세월교 · 박스 한 규칙). 수평공간 = 세월교 · 박스 유입 흙막이 값(79-7)만 · 나머지는 0.
|
||||
const cutBerm = (lengthM: number) => ({
|
||||
lengthM: Math.max(lengthM, 0),
|
||||
cut: structureCutSpec(section.design),
|
||||
});
|
||||
// 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로).
|
||||
// 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다.
|
||||
const outletInput = {
|
||||
cutBerm: cutBerm(0), // 유출 수평공간 없음 — 끝이 원지반에 묻히면 바로 절토(89-4)
|
||||
cutBerm: cutBerm(0), // 유출 수평공간 없음 — 3도 선이 못 만날 때만 바로 절토(89-4)
|
||||
startBottomElevation: input.outletBottom,
|
||||
outward: input.outletInfo.outward,
|
||||
groundAt,
|
||||
@@ -107,7 +108,7 @@ export function buildCulvertTiers(input: {
|
||||
// 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 —
|
||||
// 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자).
|
||||
const basinInput = {
|
||||
cutBerm: cutBerm(culvert?.inlet.revet_berm_m ?? GUARD_CUT_BERM_M),
|
||||
cutBerm: cutBerm(culvert?.body ? (culvert.inlet.revet_berm_m ?? GUARD_CUT_BERM_M) : 0),
|
||||
startBottomElevation: input.basinBottom,
|
||||
outward: input.inletInfo.outward,
|
||||
groundAt,
|
||||
|
||||
@@ -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) : "";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -36,7 +36,12 @@ import type { CulvertDesignTrim } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { appendBoxOverlay, computeBoxLayout } from "./B06_Section_UI_Cross_Box";
|
||||
import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel";
|
||||
import { createBodyWiring } from "./B06_Section_UI_Cross_View_Bodies";
|
||||
import { appendCardFrame, applyStructureAreas } from "./B06_Section_UI_Cross_View_Frame";
|
||||
import {
|
||||
appendCardFrame,
|
||||
appendGroundProfile,
|
||||
applyStructureAreas,
|
||||
drawnContentBounds,
|
||||
} from "./B06_Section_UI_Cross_View_Frame";
|
||||
import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford";
|
||||
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
|
||||
import { computeCardCulvert } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
@@ -48,16 +53,21 @@ import type {
|
||||
RevetOffsetControl,
|
||||
StructureSpanControl,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel";
|
||||
import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock";
|
||||
import { culvertCardState, structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure";
|
||||
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";
|
||||
import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes";
|
||||
import type { RevetHighlightSetter, RevetKey, RevetPick } from "./B06_Section_UI_Cross_Culvert";
|
||||
import type { RevetHighlightSetter, RevetPick } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { REVET_FORM_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import {
|
||||
CROSS_PAD,
|
||||
CROSS_WIDTH,
|
||||
@@ -186,20 +196,14 @@ export function createCrossSectionCard(
|
||||
let highlightRevet: RevetPick | null = revetOffset?.highlightFor(section) ?? activeRevet;
|
||||
let setRevetActive: RevetHighlightSetter = () => undefined;
|
||||
let showRevetControl: (visible: boolean) => void = () => undefined;
|
||||
let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기
|
||||
// 이 카드의 기준벽이 **독립 기슭막이**인가(관 없는 숨김 세트). 조정창 걸음이
|
||||
// 배관(1m)과 다르다(0.1m — 2026-08-29 사용자).
|
||||
let culvertHiddenPipe = false;
|
||||
/** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */
|
||||
let culvertInletOptions = { revetAllowed: true, basinLUAllowed: true };
|
||||
/** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd)·세워진 개수. */
|
||||
let culvertExtraState = { canAdd: false, count: 0 };
|
||||
/** 집수정 계류측 다단 상태(2026-08-22) — 조정창의 집수정 단 수 행이 쓴다. */
|
||||
let culvertBasinExtraState = { canAdd: false, count: 0 };
|
||||
/** 마지막 계산의 벽 제원(높이·재질) — 배관 벽은 순수 높이(2026-08-23 사용자). */
|
||||
let culvertWallSpecs = new Map<RevetKey, { height: number; form: string }>();
|
||||
/** 마지막 계산의 실제 적용 d(상하) — d 미지정(자동) 벽의 ▲▼ 시작값. */
|
||||
let culvertAppliedD = new Map<RevetKey, number>();
|
||||
/** 조정창이 읽는 마지막 계산 결과(벽 제원 · 단 수 등) — `_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 사용자). 또 구조물을 고르면 그 **측점 카드도
|
||||
@@ -213,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;
|
||||
@@ -236,6 +241,7 @@ export function createCrossSectionCard(
|
||||
setChipActive(null);
|
||||
}
|
||||
if (activeRevet !== null) {
|
||||
dropLine(); // 고름 하나만(137-3)
|
||||
bodies.clearSelection(); // 세월교 · 박스 고름을 풂 — 측벽 · 흙막이 동시 고름 금지(107-1)
|
||||
// 미선택 카드는 **카드 선택만** 부른다. 면적 비우기(onAreaSelect)를 먼저 부르면 그쪽이
|
||||
// 카드를 선택해 버려 뒤이은 onSelect가 "같은 측점 재클릭 = 해제"로 먹힌다(2026-08-21
|
||||
@@ -260,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;
|
||||
@@ -285,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);
|
||||
|
||||
@@ -367,25 +400,12 @@ export function createCrossSectionCard(
|
||||
// 상태의 가운데 버튼 팬으로 끌어다 본다(2026-08-23 사용자 지시). 축 눈금은 팬을 따라
|
||||
// 다시 그려지므로(_UI_Cross_Axes) 값이 어긋나지 않는다.
|
||||
const drawSamples = section.samples;
|
||||
const segments: string[] = [];
|
||||
let current: string[] = [];
|
||||
for (const sample of drawSamples) {
|
||||
if (!validElevation(sample)) {
|
||||
if (current.length > 1) segments.push(current.join(" "));
|
||||
current = [];
|
||||
continue;
|
||||
}
|
||||
const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration;
|
||||
current.push(`${x(sample.offset_m ?? 0)},${y(elevated)}`);
|
||||
}
|
||||
if (current.length > 1) segments.push(current.join(" "));
|
||||
segments.forEach((points) =>
|
||||
plotLayer.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })),
|
||||
);
|
||||
const toDisplayY = (elevation: number): number =>
|
||||
y(elevationMid + (elevation - elevationMid) * exaggeration);
|
||||
appendGroundProfile(plotLayer, drawSamples, x, toDisplayY);
|
||||
let linePick: DesignLinePick | null = null;
|
||||
|
||||
if (section.design) {
|
||||
const toDisplayY = (elevation: number): number =>
|
||||
y(elevationMid + (elevation - elevationMid) * exaggeration);
|
||||
// 면적 밴드는 지면선 위·설계선 아래에 깔아 선이 밴드에 가리지 않게 한다.
|
||||
// 선택 여부와 무관하게 항상 깐다 — 평소에는 투명이고, 선택이 바뀔 때 카드를 새로 만들지
|
||||
// 않고 클래스만 켜면 되므로 줌·팬이 살아남는다.
|
||||
@@ -460,11 +480,11 @@ export function createCrossSectionCard(
|
||||
// 눌러도 안 움직이는데 숫자만 커지는 것 방지.
|
||||
revetOffset?.syncApplied(section.chainage_m, "own", ownLayout.appliedAdjust);
|
||||
// 재질은 구조물 옵션(형태)이 정본이라 조정창에서 숨긴다 — 자리만 채운다.
|
||||
culvertWallSpecs.set("own", {
|
||||
culvertState.wallSpecs.set("own", {
|
||||
height: ownLayout.heightM,
|
||||
form: section.revetment?.form ?? REVET_FORM_DEFAULT,
|
||||
});
|
||||
culvertAppliedD.set("own", ownLayout.appliedAdjust.d ?? 0);
|
||||
culvertState.appliedD.set("own", ownLayout.appliedAdjust.d ?? 0);
|
||||
const baseSetActive = setRevetActive;
|
||||
setRevetActive = (key) => {
|
||||
baseSetActive(key);
|
||||
@@ -488,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(
|
||||
@@ -512,16 +536,9 @@ export function createCrossSectionCard(
|
||||
);
|
||||
}
|
||||
// 배수관 세트 — 조정창이 쓸 카드 상태를 받아 둔다(2026-08-19) · 링크 카드도 채운다(2026-08-24).
|
||||
culvertInletIsBasin = !!culvertLayout?.basin;
|
||||
culvertHiddenPipe = culvertLayout?.culvert.hidden_pipe === true;
|
||||
if (culvertLayout) {
|
||||
const state = culvertCardState(culvertLayout);
|
||||
culvertInletOptions = state.inletOptions;
|
||||
culvertExtraState = state.extraState;
|
||||
culvertBasinExtraState = state.basinExtraState;
|
||||
culvertWallSpecs = state.wallSpecs;
|
||||
culvertAppliedD = state.appliedD;
|
||||
}
|
||||
culvertState.inletIsBasin = !!culvertLayout?.basin;
|
||||
culvertState.hiddenPipe = culvertLayout?.culvert.hidden_pipe === true;
|
||||
if (culvertLayout) Object.assign(culvertState, culvertCardState(culvertLayout));
|
||||
if (boxLayout) {
|
||||
bodies.attachBox(
|
||||
appendBoxOverlay(plotLayer, boxLayout, x, toDisplayY, box ? bodies.toggleBox : undefined),
|
||||
@@ -554,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줄 제한).
|
||||
@@ -561,7 +588,7 @@ export function createCrossSectionCard(
|
||||
valid,
|
||||
centerX: x(0),
|
||||
designElevation: hasDesign ? (designElevation as number) : null,
|
||||
toY: (elevation) => y(elevationMid + (elevation - elevationMid) * exaggeration),
|
||||
toY: toDisplayY,
|
||||
widthPx,
|
||||
heightPx,
|
||||
});
|
||||
@@ -570,19 +597,7 @@ export function createCrossSectionCard(
|
||||
chartWrap.className = "b06-cross-card__chart-wrap";
|
||||
chartWrap.style.setProperty("--b06-plot-left", `${CROSS_PAD.left}px`);
|
||||
// 팬 한계로 쓸 **그려진 범위** — 캐시 전 구간의 좌우 끝과 표고 상·하단.
|
||||
const drawnOffsets = drawSamples.map((sample) => sample.offset_m ?? 0);
|
||||
const drawnDisplayY = drawSamples
|
||||
.filter(validElevation)
|
||||
.map((sample) => y(elevationMid + (sample.elevation_m - elevationMid) * exaggeration));
|
||||
const contentBounds =
|
||||
drawnOffsets.length && drawnDisplayY.length
|
||||
? {
|
||||
x: x(Math.max(...drawnOffsets)),
|
||||
width: x(Math.min(...drawnOffsets)) - x(Math.max(...drawnOffsets)),
|
||||
y: Math.min(...drawnDisplayY),
|
||||
height: Math.max(...drawnDisplayY) - Math.min(...drawnDisplayY),
|
||||
}
|
||||
: undefined;
|
||||
const contentBounds = drawnContentBounds(drawSamples, x, toDisplayY);
|
||||
const zoomPan = attachZoomPan(
|
||||
svg,
|
||||
plotLayer,
|
||||
@@ -600,79 +615,24 @@ export function createCrossSectionCard(
|
||||
// 절·성토 면적값은 그래프 중상단 오버레이로 표시(E-4). 값 칸은 항상 강조 토글이다.
|
||||
const readout = buildAreaReadout(section.design, toggleArea);
|
||||
setChipActive = readout.setActive;
|
||||
// 구조물 조정은 도면 안 오버레이 창(2026-08-21). 화면 좌(◀) = offset 증가 —
|
||||
// 벽 기준 이동량(outward 부호)으로 환산해 넘긴다.
|
||||
// 유입측 벽 — 기준벽과 유입측 다단(bextra). 빼먹으면 그 벽만 좌우가 뒤집힌다.
|
||||
const inletSideRole = (role: RevetKey): boolean =>
|
||||
role === "inlet" || role.startsWith("bextra");
|
||||
const outwardOf = (role: RevetKey): number =>
|
||||
// 독립 기슭막이는 배관 유입/유출과 무관 — 정본 설치 측(좌 = +offset)으로 부호를 잡는다.
|
||||
role === "own"
|
||||
? section.revetment?.side === "우"
|
||||
? -1
|
||||
: 1
|
||||
: ((section.uphill_side ?? "left") === "left") === inletSideRole(role)
|
||||
? 1
|
||||
: -1;
|
||||
const heightOfWall = (key: RevetKey): number => culvertWallSpecs.get(key)?.height ?? 0;
|
||||
const formOfWall = (key: RevetKey): string =>
|
||||
culvertWallSpecs.get(key)?.form ?? REVET_FORM_DEFAULT;
|
||||
// 연동 중인 링크 카드의 조작은 **소유 측점**에 실린다(2026-08-24) — 풀면 자기 값.
|
||||
const adjustTarget = (): CrossSection =>
|
||||
isLinkedCulvert && culvertLink && (revetLink?.linkedFor(section) ?? true)
|
||||
? culvertLink.source
|
||||
: section;
|
||||
// 연동을 푼 측점에서 **아직 손대지 않은** 벽은 소유 측점 값을 보여 준다 — 창이
|
||||
// 0(자동)을 보여 주면 첫 ◀ 한 번에 벽이 원위치로 튄다(2026-08-30).
|
||||
const adjustOf = (key: RevetKey): WallAdjust => {
|
||||
const stored = revetOffset?.storedAdjustFor(adjustTarget(), key);
|
||||
if (stored) return stored;
|
||||
const source = isLinkedCulvert && culvertLink ? culvertLink.source : section;
|
||||
return revetOffset?.adjustFor(source, key) ?? { x: 0, d: null, h: null, m: null };
|
||||
};
|
||||
const panelContext = {
|
||||
// 배수관 · 흙막이 조정창 두 벌(카드 안 · 좌측 사본) — `_Cross_View_Panels`(137-1).
|
||||
const panel = buildCardPanels({
|
||||
section,
|
||||
scrollKey: section.station_id,
|
||||
adjustChainage: () => adjustTarget().chainage_m,
|
||||
isLinked: isLinkedCulvert,
|
||||
culvertLink,
|
||||
isLinkedCulvert,
|
||||
revetOffset,
|
||||
inletStructure,
|
||||
extraWalls,
|
||||
structureSpan,
|
||||
revetLink,
|
||||
adjustOf,
|
||||
outwardOf,
|
||||
heightOfWall,
|
||||
drawnWallKeys: () => [...culvertWallSpecs.keys()],
|
||||
formOfWall,
|
||||
appliedD: () => culvertAppliedD,
|
||||
inletIsBasin: () => culvertInletIsBasin,
|
||||
hiddenPipe: () => culvertHiddenPipe,
|
||||
bodyGuard: () => !!(section.culvert ?? culvertLink?.source.culvert)?.body,
|
||||
fordGuard: () => (section.culvert ?? culvertLink?.source.culvert)?.body === "ford",
|
||||
inletOptions: () => culvertInletOptions,
|
||||
extraState: () => culvertExtraState,
|
||||
basinExtraState: () => culvertBasinExtraState,
|
||||
state: culvertState,
|
||||
activeRevet: () => activeRevet,
|
||||
setActiveRevet: (key: RevetPick | null) => {
|
||||
setActiveRevet: (key) => {
|
||||
activeRevet = key;
|
||||
},
|
||||
toggleRevet,
|
||||
};
|
||||
const panel = buildStructurePanel(structurePanelDeps(panelContext));
|
||||
// 좌측 [횡단 조정] 사본 — 같은 deps라 어느 쪽 조작이든 카드 재렌더에서 두 창이
|
||||
// 함께 새 값으로 그려진다(2026-08-29 일원화). 스크롤 기억 키만 분리한다.
|
||||
const dockPanel = buildStructurePanel(
|
||||
structurePanelDeps({ ...panelContext, scrollKey: `dock:${section.station_id}` }),
|
||||
{ dock: true },
|
||||
);
|
||||
showRevetControl = (visible) => {
|
||||
const key = visible ? activeRevet : null;
|
||||
panel.show(key);
|
||||
dockPanel.show(key);
|
||||
if (key) adoptAdjustPanel(dockPanel.root);
|
||||
else releaseAdjustPanel(dockPanel.root);
|
||||
};
|
||||
});
|
||||
showRevetControl = (visible) => panel.show(visible ? activeRevet : null);
|
||||
showRevetControl(activeRevet !== null);
|
||||
// 세월교 조정창 — 배수관 창과 조작 축이 달라 따로 만든다(2026-08-25 사용자).
|
||||
// 우측 상단 줌 버튼이 원배율에서 표시 반폭까지 다룬다(하단 ◀/▶/↺ 폐지, 2026-08-23).
|
||||
@@ -686,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);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import { computeStructureAreas, type DesignTrim } from "@util/common_util_cross_structure_areas";
|
||||
import { CROSS_PAD, L, svgElement, svgText } from "./B06_Section_UI_Section_Common";
|
||||
import { CROSS_PAD, L, svgElement, svgText, validElevation } from "./B06_Section_UI_Section_Common";
|
||||
|
||||
type Sample = CrossSection["samples"][number];
|
||||
|
||||
@@ -124,3 +124,43 @@ export function applyStructureAreas(
|
||||
design.cut_soil_area_m2 = Number(areas.cutSoilAreaM2.toFixed(4));
|
||||
}
|
||||
}
|
||||
|
||||
/** 원지반선 — 끊긴 샘플(무효 표고)마다 폴리선을 나눠 그린다. `toY` = 표고 → 화면 y(배율 반영). */
|
||||
export function appendGroundProfile(
|
||||
plotLayer: SVGElement,
|
||||
samples: Sample[],
|
||||
x: (offset: number) => number,
|
||||
toY: (elevation: number) => number,
|
||||
): void {
|
||||
const segments: string[] = [];
|
||||
let current: string[] = [];
|
||||
for (const sample of samples) {
|
||||
if (!validElevation(sample)) {
|
||||
if (current.length > 1) segments.push(current.join(" "));
|
||||
current = [];
|
||||
continue;
|
||||
}
|
||||
current.push(`${x(sample.offset_m ?? 0)},${toY(sample.elevation_m)}`);
|
||||
}
|
||||
if (current.length > 1) segments.push(current.join(" "));
|
||||
segments.forEach((points) =>
|
||||
plotLayer.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })),
|
||||
);
|
||||
}
|
||||
|
||||
/** 팬 한계로 쓸 **그려진 범위** — 캐시 전 구간의 좌우 끝과 표고 상 · 하단(없으면 undefined). */
|
||||
export function drawnContentBounds(
|
||||
samples: Sample[],
|
||||
x: (offset: number) => number,
|
||||
toY: (elevation: number) => number,
|
||||
): { x: number; width: number; y: number; height: number } | undefined {
|
||||
const offsets = samples.map((sample) => sample.offset_m ?? 0);
|
||||
const displayY = samples.filter(validElevation).map((sample) => toY(sample.elevation_m));
|
||||
if (!offsets.length || !displayY.length) return undefined;
|
||||
return {
|
||||
x: x(Math.max(...offsets)),
|
||||
width: x(Math.min(...offsets)) - x(Math.max(...offsets)),
|
||||
y: Math.min(...displayY),
|
||||
height: Math.max(...displayY) - Math.min(...displayY),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_View_Panels.ts
|
||||
* 횡단 카드 ↔ **배수관 · 흙막이 조정창 두 벌**(카드 안 · 좌측 [횡단 조정] 사본) 배선 —
|
||||
* 카드 렌더러(`_UI_Cross_View.ts`)가 700줄을 넘어 떼었다(137-1). 그리기 · 기하는 하지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { RevetKey, RevetPick } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { REVET_FORM_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type {
|
||||
CulvertLink,
|
||||
ExtraWallControl,
|
||||
InletStructureControl,
|
||||
RevetLinkControl,
|
||||
RevetOffsetControl,
|
||||
StructureSpanControl,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel";
|
||||
import { adoptAdjustPanel, releaseAdjustPanel } from "./B06_Section_UI_Adjust_Dock";
|
||||
import { structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure";
|
||||
|
||||
/** 조정창이 읽는 카드 상태 — 마지막 계산 결과(그릴 때마다 바뀜). */
|
||||
export interface CardCulvertState {
|
||||
/** 유입이 집수정인가 — 조정창 표시 · 제목 분기. */
|
||||
inletIsBasin: boolean;
|
||||
/** 기준벽이 **독립 기슭막이**인가(관 없는 숨김 세트) — 걸음 0.1 m(2026-08-29 사용자). */
|
||||
hiddenPipe: boolean;
|
||||
/** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */
|
||||
inletOptions: { revetAllowed: boolean; basinLUAllowed: boolean };
|
||||
/** 추가 기슭막이 상태(2026-08-22) — 끝 성토부 5m 이상(canAdd) · 세워진 개수. */
|
||||
extraState: { canAdd: boolean; count: number };
|
||||
/** 집수정 계류측 다단 상태(2026-08-22) — 조정창의 집수정 단 수 행이 쓴다. */
|
||||
basinExtraState: { canAdd: boolean; count: number };
|
||||
/** 마지막 계산의 벽 제원(높이 · 재질) — 배관 벽은 순수 높이(2026-08-23 사용자). */
|
||||
wallSpecs: Map<RevetKey, { height: number; form: string }>;
|
||||
/** 마지막 계산의 실제 적용 d(상하) — d 미지정(자동) 벽의 ▲▼ 시작값. */
|
||||
appliedD: Map<RevetKey, number>;
|
||||
}
|
||||
|
||||
export const emptyCardCulvertState = (): CardCulvertState => ({
|
||||
inletIsBasin: false,
|
||||
hiddenPipe: false,
|
||||
inletOptions: { revetAllowed: true, basinLUAllowed: true },
|
||||
extraState: { canAdd: false, count: 0 },
|
||||
basinExtraState: { canAdd: false, count: 0 },
|
||||
wallSpecs: new Map(),
|
||||
appliedD: new Map(),
|
||||
});
|
||||
|
||||
export interface CardPanelsInput {
|
||||
section: CrossSection;
|
||||
culvertLink?: CulvertLink;
|
||||
isLinkedCulvert: boolean;
|
||||
revetOffset?: RevetOffsetControl;
|
||||
inletStructure?: InletStructureControl;
|
||||
extraWalls?: ExtraWallControl;
|
||||
structureSpan?: StructureSpanControl;
|
||||
revetLink?: RevetLinkControl;
|
||||
state: CardCulvertState;
|
||||
activeRevet: () => RevetPick | null;
|
||||
setActiveRevet: (key: RevetPick | null) => void;
|
||||
toggleRevet: (key: RevetPick) => void;
|
||||
}
|
||||
|
||||
/** 카드 안 조정창 + 좌측 사본을 만든다. `show` = 고른 벽(없으면 null)으로 두 창을 맞춤. */
|
||||
export function buildCardPanels(input: CardPanelsInput): {
|
||||
root: HTMLElement;
|
||||
show: (key: RevetPick | null) => void;
|
||||
} {
|
||||
const { section, culvertLink, isLinkedCulvert, revetOffset, revetLink, state } = input;
|
||||
// 구조물 조정은 도면 안 오버레이 창(2026-08-21). 화면 좌(◀) = offset 증가 —
|
||||
// 벽 기준 이동량(outward 부호)으로 환산해 넘긴다.
|
||||
// 유입측 벽 — 기준벽과 유입측 다단(bextra). 빼먹으면 그 벽만 좌우가 뒤집힌다.
|
||||
const inletSideRole = (role: RevetKey): boolean => role === "inlet" || role.startsWith("bextra");
|
||||
const outwardOf = (role: RevetKey): number =>
|
||||
// 독립 기슭막이는 배관 유입/유출과 무관 — 정본 설치 측(좌 = +offset)으로 부호를 잡는다.
|
||||
role === "own"
|
||||
? section.revetment?.side === "우"
|
||||
? -1
|
||||
: 1
|
||||
: ((section.uphill_side ?? "left") === "left") === inletSideRole(role)
|
||||
? 1
|
||||
: -1;
|
||||
const heightOfWall = (key: RevetKey): number => state.wallSpecs.get(key)?.height ?? 0;
|
||||
const formOfWall = (key: RevetKey): string =>
|
||||
state.wallSpecs.get(key)?.form ?? REVET_FORM_DEFAULT;
|
||||
// 연동 중인 링크 카드의 조작은 **소유 측점**에 실린다(2026-08-24) — 풀면 자기 값.
|
||||
const adjustTarget = (): CrossSection =>
|
||||
isLinkedCulvert && culvertLink && (revetLink?.linkedFor(section) ?? true)
|
||||
? culvertLink.source
|
||||
: section;
|
||||
// 연동을 푼 측점에서 **아직 손대지 않은** 벽은 소유 측점 값을 보여 준다 — 창이
|
||||
// 0(자동)을 보여 주면 첫 ◀ 한 번에 벽이 원위치로 튄다(2026-08-30).
|
||||
const adjustOf = (key: RevetKey): WallAdjust => {
|
||||
const stored = revetOffset?.storedAdjustFor(adjustTarget(), key);
|
||||
if (stored) return stored;
|
||||
const source = isLinkedCulvert && culvertLink ? culvertLink.source : section;
|
||||
return revetOffset?.adjustFor(source, key) ?? { x: 0, d: null, h: null, m: null };
|
||||
};
|
||||
const panelContext = {
|
||||
section,
|
||||
scrollKey: section.station_id,
|
||||
adjustChainage: () => adjustTarget().chainage_m,
|
||||
isLinked: isLinkedCulvert,
|
||||
revetOffset,
|
||||
inletStructure: input.inletStructure,
|
||||
extraWalls: input.extraWalls,
|
||||
structureSpan: input.structureSpan,
|
||||
revetLink,
|
||||
adjustOf,
|
||||
outwardOf,
|
||||
heightOfWall,
|
||||
drawnWallKeys: () => [...state.wallSpecs.keys()],
|
||||
formOfWall,
|
||||
appliedD: () => state.appliedD,
|
||||
inletIsBasin: () => state.inletIsBasin,
|
||||
hiddenPipe: () => state.hiddenPipe,
|
||||
bodyGuard: () => !!(section.culvert ?? culvertLink?.source.culvert)?.body,
|
||||
fordGuard: () => (section.culvert ?? culvertLink?.source.culvert)?.body === "ford",
|
||||
inletOptions: () => state.inletOptions,
|
||||
extraState: () => state.extraState,
|
||||
basinExtraState: () => state.basinExtraState,
|
||||
activeRevet: input.activeRevet,
|
||||
setActiveRevet: input.setActiveRevet,
|
||||
toggleRevet: input.toggleRevet,
|
||||
};
|
||||
const panel = buildStructurePanel(structurePanelDeps(panelContext));
|
||||
// 좌측 [횡단 조정] 사본 — 같은 deps라 어느 쪽 조작이든 카드 재렌더에서 두 창이
|
||||
// 함께 새 값으로 그려진다(2026-08-29 일원화). 스크롤 기억 키만 분리한다.
|
||||
const dockPanel = buildStructurePanel(
|
||||
structurePanelDeps({ ...panelContext, scrollKey: `dock:${section.station_id}` }),
|
||||
{ dock: true },
|
||||
);
|
||||
return {
|
||||
root: panel.root,
|
||||
show: (key) => {
|
||||
panel.show(key);
|
||||
dockPanel.show(key);
|
||||
if (key) adoptAdjustPanel(dockPanel.root);
|
||||
else releaseAdjustPanel(dockPanel.root);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -507,8 +507,9 @@ export function createSectionView(
|
||||
);
|
||||
}
|
||||
},
|
||||
selectMark: (structureId) => {
|
||||
spanMarkId = structureId;
|
||||
selectMark: (structureId, span = true) => {
|
||||
// span 끔 = 알약만 · 카드는 고른 측점 하나(137-4 — 측점 고름 리스너)
|
||||
spanMarkId = span ? structureId : null;
|
||||
syncSpanHighlights();
|
||||
profile.selectMark(structureId);
|
||||
},
|
||||
|
||||
@@ -40,8 +40,9 @@ export interface SectionViewController {
|
||||
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
|
||||
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). null = 카드 선택 풂(54-8). */
|
||||
focusStation: (stationId: string | null) => void;
|
||||
/** 종단 알약 강조를 그 구조물로(null = 풀기 — 카드 측점선 짝을 따름). */
|
||||
selectMark: (structureId: string | null) => void;
|
||||
/** 종단 알약 강조를 그 구조물로(null = 풀기 — 카드 측점선 짝을 따름).
|
||||
* `span` = 구간형 범위 안 카드 전부 테두리(기본) · 끄면 카드는 고른 측점 하나(137-4). */
|
||||
selectMark: (structureId: string | null, span?: boolean) => void;
|
||||
/** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을
|
||||
* 올린다(2026-08-29 일원화). null = 선택 해제. */
|
||||
setStationSelectListener: (listener: (stationId: string | null) => void) => void;
|
||||
|
||||
@@ -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,142 @@
|
||||
/* PLAN 139-4 · 139-5 헬퍼 — 묻힌 흙막이 유출 끝 = 3도 절토선(면적에 듦) · 3도 선이 원지반을 못 만나면 복합경사.
|
||||
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌. 짝 = `test_139_4_buried_outlet.py`.
|
||||
* 입력 = 박스 352.14(`b06_73_4_body_guard_sections.json`) · 배관 85.052(`b07_cross_overlay_sections.json` · 원지반 고친 사본).
|
||||
*/
|
||||
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 } };
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
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 { computeStoredLayouts } = loadTs(B06("B06_Section_Structure_Layouts"));
|
||||
const { computeStructureAreas } = loadTs(
|
||||
path.join(ROOT, "common_util", "common_util_cross_structure_areas.ts"),
|
||||
);
|
||||
const { PIPE_CONNECT_GRADE } = loadTs(B06("B06_Section_UI_Cross_Culvert_Const"));
|
||||
const fixture = (name) =>
|
||||
JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", name), "utf8")).cross_sections;
|
||||
const clone = (v) => JSON.parse(JSON.stringify(v));
|
||||
const r3 = (v) => Math.round(v * 1000) / 1000;
|
||||
const same = (a, b) =>
|
||||
Math.abs(a.offset - b.offset) < 1e-9 && Math.abs(a.elevation - b.elevation) < 1e-9;
|
||||
|
||||
const groundOf = (s) =>
|
||||
s.samples
|
||||
.filter((p) => p.valid !== false && typeof p.elevation_m === "number")
|
||||
.map((p) => ({ offset: p.offset_m ?? 0, elevation: p.elevation_m }))
|
||||
.sort((a, b) => a.offset - b.offset);
|
||||
const groundZ = (s, offset) => {
|
||||
const g = groundOf(s);
|
||||
for (let i = 1; i < g.length; i += 1)
|
||||
if (offset <= g[i].offset)
|
||||
return (
|
||||
g[i - 1].elevation +
|
||||
((g[i].elevation - g[i - 1].elevation) * (offset - g[i - 1].offset)) /
|
||||
(g[i].offset - g[i - 1].offset || 1)
|
||||
);
|
||||
return g[g.length - 1].elevation;
|
||||
};
|
||||
const areasOf = (s, trim) =>
|
||||
computeStructureAreas({
|
||||
designLine: s.design.design_line,
|
||||
ground: groundOf(s),
|
||||
trim,
|
||||
rockBoundaryOffsetM: null,
|
||||
});
|
||||
|
||||
/** 측점 하나 — 유출 끝 절토선 모양 · 그 선을 넣은 면적 대 뺀 면적. */
|
||||
function study(section, list) {
|
||||
const culvert = computeStoredLayouts(section, list).culvert;
|
||||
const cut = culvert.outletFill.segments.filter((g) => g.kind === "cut");
|
||||
const trim = culvert.designTrim;
|
||||
// 옛 면적 = 유출 절토선 꼭짓점(시작점 빼고)을 사면 폴리선에서 걷어 낸 것
|
||||
const drop = cut.flatMap((g) => g.points.slice(1));
|
||||
const strip = (slope) =>
|
||||
slope && { points: slope.points.filter((p) => !drop.some((q) => same(p, q))) };
|
||||
const without = { ...trim, minSlope: strip(trim.minSlope), maxSlope: strip(trim.maxSlope) };
|
||||
const withCut = areasOf(section, trim);
|
||||
const withoutCut = areasOf(section, without);
|
||||
return {
|
||||
cut: cut.map((g) => {
|
||||
const [a, b] = [g.points[0], g.points[g.points.length - 1]];
|
||||
return {
|
||||
points: g.points.length,
|
||||
grade: r3((a.elevation - g.points[1].elevation) / Math.abs(g.points[1].offset - a.offset)),
|
||||
rises: b.elevation > a.elevation,
|
||||
endGap: r3(Math.abs(groundZ(section, b.offset) - b.elevation)),
|
||||
};
|
||||
}),
|
||||
cutArea: r3(withCut?.cutAreaM2 ?? 0),
|
||||
cutAreaWithout: r3(withoutCut?.cutAreaM2 ?? 0),
|
||||
inTrim:
|
||||
drop.length > 0 &&
|
||||
[trim.minSlope, trim.maxSlope].some((sl) =>
|
||||
sl?.points.some((p) => drop.some((q) => same(p, q))),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const out = { grade3: r3(PIPE_CONNECT_GRADE) };
|
||||
// ① 박스 352.14 — 유출 흙막이 끝이 원지반에 묻힘
|
||||
const bodies = fixture("b06_73_4_body_guard_sections.json");
|
||||
out.box = study(
|
||||
bodies.find((s) => Math.abs(s.chainage_m - 352.14) < 0.6),
|
||||
bodies,
|
||||
);
|
||||
|
||||
// ② 배관 85.052 — 유출 끝 바깥 원지반을 고쳐 묻힘(내림) · 오르막 두 갈래
|
||||
const pipes = fixture("b07_cross_overlay_sections.json");
|
||||
const pipe = pipes.find((s) => s.culvert && !s.culvert.hidden_pipe);
|
||||
const base = computeStoredLayouts(pipe, pipes).culvert;
|
||||
// 끝 구간 시작점(마지막 단 다음 자리) — 그 바깥 원지반만 고침
|
||||
const tail = base.outletFill.segments[base.outletFill.segments.length - 1];
|
||||
const start = tail.points[0];
|
||||
const outward = Math.sign(tail.points[1].offset - start.offset) || 1;
|
||||
out.pipeBase = base.outletFill.segments.map((g) => g.kind ?? "fill");
|
||||
function reshaped(rise, slope) {
|
||||
const s = clone(pipe);
|
||||
for (const p of s.samples) {
|
||||
const t = ((p.offset_m ?? 0) - start.offset) * outward;
|
||||
if (t >= 0 && typeof p.elevation_m === "number")
|
||||
p.elevation_m = start.elevation + rise + slope * t;
|
||||
}
|
||||
return study(
|
||||
s,
|
||||
pipes.map((x) => (x === pipe ? s : x)),
|
||||
);
|
||||
}
|
||||
out.pipeDown = reshaped(0.3, -0.15); // 3도보다 가파르게 내림 → 3도 선이 만남
|
||||
out.pipeUp = reshaped(0.3, 0.1); // 오르막 → 3도 선이 못 만남 → 복합경사
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -23,11 +23,23 @@ VIEW = _read("B06_Section", "B06_Section_UI_Section_View.ts")
|
||||
|
||||
def test_범위_강조_클래스는_고름_목록갱신_카드재생성_세_길에서_칠한다():
|
||||
assert VIEW.count("b06-cross-card--span") == 2 # syncSpanHighlights · buildCrossCard
|
||||
assert re.search(r"selectMark: \(structureId\) => \{\s+spanMarkId = structureId;", VIEW)
|
||||
assert re.search(
|
||||
r"selectMark: \(structureId, span = true\) => \{\s+(//[^\n]*\s+)?spanMarkId = span \? structureId : null;",
|
||||
VIEW,
|
||||
)
|
||||
assert re.search(r"spanMarks = structures;\s+syncSpanHighlights\(\);", VIEW)
|
||||
assert 'card.classList.toggle("b06-cross-card--span", inSpan(section))' in VIEW
|
||||
|
||||
|
||||
def test_측점_고름은_카드_하나_목록_고름은_여럿():
|
||||
"""137-4 — 측점 고름 리스너는 span 끔 · 목록(노면공) 길 · 알약은 기본(여럿) 그대로."""
|
||||
cross = _read("A00_Common", "design", "design_view_cross.ts")
|
||||
assert "if (mark) sectionView.selectMark(mark, false);" in cross
|
||||
assert "markStructure: (structureId) => sectionView.selectMark(structureId)," in cross
|
||||
types = _read("B06_Section", "B06_Section_UI_Section_View_Types.ts")
|
||||
assert "selectMark: (structureId: string | null, span?: boolean) => void;" in types
|
||||
|
||||
|
||||
def test_범위_판정은_시작_종료가_다른_구간형만():
|
||||
assert "start == null || end == null || start === end" in VIEW
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,73 @@
|
||||
"""PLAN 139-4 · 139-5 — 묻힌 흙막이 유출 끝 = 3도 수평 절토선(보라 · 절토 면적) · 못 만나면 복합경사.
|
||||
|
||||
짝: `B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts`(끝 구간 규칙) · `_Culvert_Tiers.ts`(채널 입력) ·
|
||||
`_Culvert_Geom.ts`(유출 절토선 면적에 넣음) · `_Culvert.ts`(그림 · 툴팁). 실제 TS 를 `helper_139_4_buried_outlet.cjs` 가 돌린다.
|
||||
"""
|
||||
|
||||
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_139_4_buried_outlet.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()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["box", "pipeDown"])
|
||||
def test_묻힌_유출_끝은_3도_절토선_면적에_듦(out: dict, case: str) -> None:
|
||||
"""세월교 · 박스 흙막이도 배관 관보호공도 같은 3도 선 · 원지반 교점에서 끝 · 절토 면적 > 0."""
|
||||
study = out[case]
|
||||
assert study["cut"] == [{"points": 2, "grade": out["grade3"], "rises": False, "endGap": 0}]
|
||||
assert study["inTrim"] is True
|
||||
assert study["cutArea"] - study["cutAreaWithout"] > 0.1
|
||||
|
||||
|
||||
def test_3도_선이_원지반을_못_만나면_복합경사(out: dict) -> None:
|
||||
"""유출 바깥이 오르막 — 그때만 절토 경사(1:1.0)로 원지반까지."""
|
||||
study = out["pipeUp"]
|
||||
assert len(study["cut"]) == 1
|
||||
tail = study["cut"][0]
|
||||
assert tail["rises"] is True and tail["grade"] == -1 and tail["endGap"] == 0
|
||||
assert study["cutArea"] > study["cutAreaWithout"]
|
||||
|
||||
|
||||
def test_성토_끝은_그대로(out: dict) -> None:
|
||||
assert out["pipeBase"] == ["fill", "fill"] # 묻히지 않은 기본 = 성토부선 그대로
|
||||
|
||||
|
||||
def test_유출_절토선은_그리고_면적에_넣음() -> None:
|
||||
culvert = _src("B06_Section/B06_Section_UI_Cross_Culvert.ts")
|
||||
assert 'if (segment.kind === "cut" && !culvert.body) continue;' not in culvert
|
||||
assert 'planLine(segment.points, cutLineTip("유출부", segment));' in culvert
|
||||
geom = _src("B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts")
|
||||
assert "extendTrimSlope(drawnFillPoints(extrasForArea, true), outletInfo.outward);" in geom
|
||||
# 집수정 계류측 3도 선은 그림만(면적 밖) — 세월교 · 박스 계류측만 면적
|
||||
assert (
|
||||
"extendTrimSlope(drawnFillPoints(basinExtrasForArea, !!body), inletInfo.outward);" in geom
|
||||
)
|
||||
@@ -8,6 +8,7 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -87,13 +88,16 @@ def test_흙막이_끝_절토는_수평공간_뒤(shape: dict) -> None:
|
||||
assert tail["flat"] == pytest.approx(0, abs=1e-6)
|
||||
assert tail["berm"] == pytest.approx(1.0, abs=1e-3)
|
||||
else:
|
||||
# 유출은 수평공간 없음 — 묻히면 첫 꺾임부터 바로 절토(89-4)
|
||||
assert tail["flat"] > 0
|
||||
# 유출 = 묻히면 3도 내림 절토선 하나로 원지반까지(139-4)
|
||||
assert tail["points"] == 2
|
||||
assert tail["flat"] == pytest.approx(
|
||||
-tail["berm"] * math.tan(math.radians(3)), abs=2e-3
|
||||
)
|
||||
for tail in shape["cutBerm2"]:
|
||||
if tail["role"] == "inlet":
|
||||
assert tail["berm"] == pytest.approx(2.0, abs=1e-3)
|
||||
else:
|
||||
assert tail["flat"] > 0
|
||||
assert tail["points"] == 2 and tail["flat"] < 0 # 유출 수평공간 칸 무시 · 3도 선
|
||||
|
||||
|
||||
def test_유입_수평공간_시작은_본체_바닥(shape: dict) -> None:
|
||||
|
||||
@@ -96,9 +96,10 @@ def _knee(cut: dict) -> bool:
|
||||
def test_흙막이_수평공간_뒤_절토_복합경사(fit: dict) -> None:
|
||||
tails = fit["guardTails"]
|
||||
assert tails and all(cut["endGap"] < 0.01 for cut in tails)
|
||||
# 유입 = 수평공간 먼저 · 유출 = 수평공간 없이 바로 절토(89-4)
|
||||
# 유입 = 3도 선이 못 만나 수평공간 뒤 복합경사 · 유출 = 묻히면 3도 내림 선 하나(139-4)
|
||||
for cut in tails:
|
||||
assert (cut["legs"][0]["ratio"] is None) == (cut["role"] == "inlet")
|
||||
assert cut["legs"][0]["ratio"] is None
|
||||
assert (len(cut["legs"]) == 1) == (cut["role"] == "outlet")
|
||||
assert sum(_knee(cut) for cut in tails) >= 2
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user