- 파이썬 · TS 짝(Engine_Design_Geometry ↔ cross_design_geometry) — 차도 반폭에도 이동량 - 확폭 출력(Engine_Design ↔ cross_design)에서 이동 몫 뺌 · 노폭 라벨 「+ 이동」 - 거울 시험 — 확폭 + 이동 겹친 사례 · 노견 폭 고정 · 차도 끝 이동 단정 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
270 lines
14 KiB
TypeScript
270 lines
14 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Cross_Design_Line.ts
|
|
* 횡단 설계선 오버레이 — `_Cross_Design.ts` 에서 분리(700줄 제한 · 91-3).
|
|
* 사면 끝 = 설계선이 원지반과 처음 만나는 자리 · 수치(0.01 m)로 판정 — 화면 축척과 무관.
|
|
* ========================================================================== */
|
|
|
|
import type { CrossDesign, SectionSample } from "./B06_Section_Api_Fetch";
|
|
|
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
|
|
/**
|
|
* 횡단 SVG에 표준단면 설계선을 겹쳐 그린다. 지면선과 겹치는 구간(사면이 지반을 추종하는
|
|
* 부분)만 점선으로 그려 뒤에 깔린 지표선이 비쳐 보이게 하고, 나머지는 실선으로 둔다.
|
|
*
|
|
* `trim`이 오면 그 offset 범위 밖 구간은 그리지 않는다 — 배수관 측점에서 기슭막이가
|
|
* 성토를 받치므로 벽과 교차된 이후의 성토 경사는 끊는다(2026-08-20 사용자 확정).
|
|
* 경계에 걸린 구간은 경계 지점까지 잘라 그린다.
|
|
*/
|
|
export function appendCrossDesignOverlay(
|
|
svg: SVGElement,
|
|
design: CrossDesign,
|
|
x: (offset: number) => number,
|
|
toDisplayY: (elevation: number) => number,
|
|
groundSamples: SectionSample[],
|
|
trim?: {
|
|
minOffset: number;
|
|
maxOffset: number;
|
|
minElevation?: number;
|
|
maxElevation?: number;
|
|
minSlope?: { points: Array<{ offset: number; elevation: number }> };
|
|
maxSlope?: { points: Array<{ offset: number; elevation: number }> };
|
|
},
|
|
// 노폭 라벨 고정 자리(B06 카드 — X 축 라벨 바로 위 가운데). 없으면 노면 위.
|
|
labelAt?: { layer: SVGElement; x: number; y: number },
|
|
): void {
|
|
const line = design.design_line;
|
|
if (!line || line.length < 2) return;
|
|
|
|
// 지면선 표고 보간기(정렬된 유효 샘플, 범위 밖 끝값 클램프).
|
|
const ground = groundSamples
|
|
.filter((s) => s.valid !== false && s.elevation_m !== null && Number.isFinite(s.elevation_m))
|
|
.map((s) => ({ offset: s.offset_m ?? 0, elevation: s.elevation_m as number }))
|
|
.sort((a, b) => a.offset - b.offset);
|
|
const groundAt = (offset: number): number | null => {
|
|
if (!ground.length) return null;
|
|
if (offset <= ground[0].offset) return ground[0].elevation;
|
|
const last = ground[ground.length - 1];
|
|
if (offset >= last.offset) return last.elevation;
|
|
for (let i = 1; i < ground.length; i += 1) {
|
|
if (offset > ground[i].offset) continue;
|
|
const a = ground[i - 1];
|
|
const b = ground[i];
|
|
const span = b.offset - a.offset;
|
|
if (span <= 0) return b.elevation;
|
|
return a.elevation + (b.elevation - a.elevation) * ((offset - a.offset) / span);
|
|
}
|
|
return last.elevation;
|
|
};
|
|
|
|
// 각 설계선 점이 지면선과 겹치는지 — 수치(0.01 m)로 판정 · 화면 축척(줌)과 무관(91-3).
|
|
const OVERLAP_M = 0.01;
|
|
const overlap = line.map((point) => {
|
|
const g = groundAt(point.offset_m);
|
|
if (g === null) return false;
|
|
return Math.abs(point.elevation_m - g) <= OVERLAP_M;
|
|
});
|
|
|
|
// 사면 끝(계획선이 원지반과 처음 만나는 지점) **바깥**은 계획선을 그리지 않는다 —
|
|
// 그 너머는 손대지 않는 원지반이라 지반선이 이미 표현한다(2026-08-20 사용자 지적:
|
|
// 12+0.0 좌측 성토부에서 접점 이후 계획선이 계속 그려짐).
|
|
// 탐색은 **노견 바깥**에서 시작한다 — 노면·측구·노견을 지나는 계획선은 지반과
|
|
// 우연히 겹쳐도 지워선 안 되고, 노견 이후 사면이 지반과 만나는 2차 접점만 종료점이다
|
|
// (2026-08-20 사용자 ①).
|
|
const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m);
|
|
const edgesForTrim = design.road_edges;
|
|
const leftEdge = edgesForTrim
|
|
? Math.max(edgesForTrim.left.offset_m, edgesForTrim.right.offset_m)
|
|
: 0;
|
|
const rightEdge = edgesForTrim
|
|
? Math.min(edgesForTrim.left.offset_m, edgesForTrim.right.offset_m)
|
|
: 0;
|
|
// 측구는 노견 **바깥**에 붙는다(road_edges 밖). 측구가 있는 쪽은 측구 바깥 끝까지
|
|
// 보호하고 그 다음부터 접점을 찾는다 — 노견에서 바로 찾으면 측구가 통째로 잘린다
|
|
// (2026-08-20 사용자: "측구 이후 성토부가 존재하는 경사로의 1차 교차점까지 보호").
|
|
const ditchSpec = design.ditch;
|
|
const ditchWidth =
|
|
design.ditch_enabled === false || !ditchSpec || ditchSpec.type === "none"
|
|
? 0
|
|
: ditchSpec.type === "standard"
|
|
? ditchSpec.top_width_m
|
|
: ditchSpec.width_m;
|
|
// 좌표 규약: +offset = 좌측. ditch_side "left" = 큰 offset 쪽.
|
|
const protectMax = leftEdge + (design.ditch_side === "left" ? ditchWidth : 0);
|
|
const protectMin = rightEdge - (design.ditch_side === "right" ? ditchWidth : 0);
|
|
const meetOffset = (startOffset: number, step: number): number | null => {
|
|
const indices =
|
|
step > 0 ? sorted.map((_, i) => i) : sorted.map((_, i) => sorted.length - 1 - i);
|
|
for (const i of indices) {
|
|
const point = sorted[i];
|
|
// 노견 안쪽(도로 구간)은 건너뛴다.
|
|
if (step > 0 ? point.offset_m <= startOffset : point.offset_m >= startOffset) continue;
|
|
const g = groundAt(point.offset_m);
|
|
if (g === null) continue;
|
|
if (Math.abs(point.elevation_m - g) <= OVERLAP_M) return point.offset_m;
|
|
}
|
|
return null;
|
|
};
|
|
const meetLeft = meetOffset(protectMin, -1); // 작은 offset 쪽(측구까지 지난 뒤)
|
|
const meetRight = meetOffset(protectMax, 1); // 큰 offset 쪽(측구까지 지난 뒤)
|
|
|
|
// 트림 범위로 구간 하나를 자른다. 완전히 밖이면 null, 걸치면 경계에서 보간해 끊는다.
|
|
const clipSegment = (
|
|
a: { offset_m: number; elevation_m: number },
|
|
b: { offset_m: number; elevation_m: number },
|
|
):
|
|
| [{ offset_m: number; elevation_m: number }, { offset_m: number; elevation_m: number }]
|
|
| null => {
|
|
// 배수관 세트 트림 + 사면 끝 접점 트림을 합친 유효 범위.
|
|
// 배수관 레이아웃이 사면 폴리라인(min/maxSlope)을 준 쪽은 **그 시작점(노견)에서**
|
|
// 설계선을 끊는다 — 그 바깥은 폴리라인이 그리므로 이중선이 되고, 트림 경계에서
|
|
// 표고를 벽 상단으로 스냅하면서 수직으로 튀는 선이 남는다(2026-08-22 사용자 지적:
|
|
// 집수정을 좌우로 옮기면 벽 상단이 노견 표고 그대로라 스냅 폭이 그만큼 커진다).
|
|
const minSlopeStart = trim?.minSlope?.points[0]?.offset;
|
|
const maxSlopeStart = trim?.maxSlope?.points[0]?.offset;
|
|
const minOffset = Math.max(
|
|
trim?.minOffset ?? -Infinity,
|
|
meetLeft ?? -Infinity,
|
|
minSlopeStart ?? -Infinity,
|
|
);
|
|
const maxOffset = Math.min(
|
|
trim?.maxOffset ?? Infinity,
|
|
meetRight ?? Infinity,
|
|
maxSlopeStart ?? Infinity,
|
|
);
|
|
if (!Number.isFinite(minOffset) && !Number.isFinite(maxOffset)) return [a, b];
|
|
const effective = { minOffset, maxOffset };
|
|
const lo = Math.min(a.offset_m, b.offset_m);
|
|
const hi = Math.max(a.offset_m, b.offset_m);
|
|
if (hi <= effective.minOffset || lo >= effective.maxOffset) return null;
|
|
const at = (offset: number) => {
|
|
const span = b.offset_m - a.offset_m;
|
|
const t = Math.abs(span) > 1e-9 ? (offset - a.offset_m) / span : 0;
|
|
return { offset_m: offset, elevation_m: a.elevation_m + (b.elevation_m - a.elevation_m) * t };
|
|
};
|
|
// 배수관 트림 경계에서는 끝단 표고를 **벽 이음선 상단점**에 맞춰 내린다 — 벽 높이가
|
|
// 관경+여유고로 고정된 뒤로는 설계 사면선 표고와 어긋나 사면선 끝이 벽에서 떨어진다
|
|
// (2026-08-21 사용자 ②). 사면 끝 접점 트림이 이긴 경계에는 적용하지 않는다.
|
|
const snap = (
|
|
p: { offset_m: number; elevation_m: number },
|
|
elevation: number | undefined,
|
|
wins: boolean,
|
|
) => (elevation != null && wins ? { offset_m: p.offset_m, elevation_m: elevation } : p);
|
|
const minIsCulvert = trim != null && Math.abs(effective.minOffset - trim.minOffset) < 1e-9;
|
|
const maxIsCulvert = trim != null && Math.abs(effective.maxOffset - trim.maxOffset) < 1e-9;
|
|
const clamp = (p: { offset_m: number; elevation_m: number }) =>
|
|
p.offset_m < effective.minOffset
|
|
? snap(at(effective.minOffset), trim?.minElevation, minIsCulvert)
|
|
: p.offset_m > effective.maxOffset
|
|
? snap(at(effective.maxOffset), trim?.maxElevation, maxIsCulvert)
|
|
: p;
|
|
return [clamp(a), clamp(b)];
|
|
};
|
|
|
|
// 세그먼트별로 그린다. 지면선과 겹치는 구간(= 계획선이 원지반과 차이가 없는 구간)은
|
|
// **그리지 않는다** — 지반선이 이미 같은 자리를 표현하므로 정보가 없다
|
|
// (2026-08-20 사용자 지시: 보라색 점선 삭제).
|
|
// 단 **노면·노견·측구부터 1차 접점까지는 언제나 그린다** — 계획고가 지반과 우연히
|
|
// 몇 ㎝ 차이면 노면 계획선이 통째로 끊겨 보이고(8·10·10+0.9), 노견 끝 성토고가
|
|
// 4㎝ 정도면 접점까지의 짧은 사면 조각도 사라진다(9+0.0 — 2026-08-20 사용자 지적).
|
|
// 접점을 못 찾은 쪽은 보호 구간을 노견·측구까지로 둔다.
|
|
const keepMin = meetLeft ?? protectMin;
|
|
const keepMax = meetRight ?? protectMax;
|
|
const isProtected = (a: { offset_m: number }, b: { offset_m: number }): boolean =>
|
|
Math.max(a.offset_m, b.offset_m) > keepMin && Math.min(a.offset_m, b.offset_m) < keepMax;
|
|
for (let i = 1; i < line.length; i += 1) {
|
|
if (overlap[i - 1] && overlap[i] && !isProtected(line[i - 1], line[i])) continue;
|
|
const clipped = clipSegment(line[i - 1], line[i]);
|
|
if (!clipped) continue;
|
|
const seg = document.createElementNS(SVG_NS, "line");
|
|
seg.setAttribute("x1", String(x(clipped[0].offset_m)));
|
|
seg.setAttribute("y1", String(toDisplayY(clipped[0].elevation_m)));
|
|
seg.setAttribute("x2", String(x(clipped[1].offset_m)));
|
|
seg.setAttribute("y2", String(toDisplayY(clipped[1].elevation_m)));
|
|
seg.setAttribute("class", "b06-chart__design-cross");
|
|
svg.append(seg);
|
|
}
|
|
|
|
// 배수관 측점 성토 사면 — 백엔드 설계선(1:1.2 고정) 대신 **배수관 레이아웃이 준 직선**
|
|
// 을 그린다. 벽 자리가 관 길이 맞춤으로 옮겨져도 노견 → 벽 이음선 상단점을 잇는 단일
|
|
// 각도가 유지된다(2026-08-21 사용자 ②, 성토 사면은 단일 각도).
|
|
for (const segment of [trim?.minSlope, trim?.maxSlope]) {
|
|
if (!segment || segment.points.length < 2) continue;
|
|
// 폴리라인 — [원 노견, (연장된 노견), 벽 이음선 상단점]. 물매가 1:1.2 고정이라
|
|
// 벽이 밖으로 나간 만큼 노면이 연장된다(2026-08-21 사용자 확정).
|
|
for (let i = 1; i < segment.points.length; i += 1) {
|
|
const a = segment.points[i - 1];
|
|
const b = segment.points[i];
|
|
const seg = document.createElementNS(SVG_NS, "line");
|
|
seg.setAttribute("x1", String(x(a.offset)));
|
|
seg.setAttribute("y1", String(toDisplayY(a.elevation)));
|
|
seg.setAttribute("x2", String(x(b.offset)));
|
|
seg.setAttribute("y2", String(toDisplayY(b.elevation)));
|
|
seg.setAttribute("class", "b06-chart__design-cross");
|
|
svg.append(seg);
|
|
}
|
|
}
|
|
|
|
// 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안
|
|
// 구분이 안 되는 경계를 표시한다.
|
|
const edges = design.carriageway_edges;
|
|
if (edges) {
|
|
const tickAt = (offsetM: number, elevationM: number, className: string, half: number) => {
|
|
const cx = x(offsetM);
|
|
const cy = toDisplayY(elevationM);
|
|
const tick = document.createElementNS(SVG_NS, "line");
|
|
tick.setAttribute("x1", String(cx));
|
|
tick.setAttribute("y1", String(cy - half));
|
|
tick.setAttribute("x2", String(cx));
|
|
tick.setAttribute("y2", String(cy + half));
|
|
tick.setAttribute("class", className);
|
|
svg.append(tick);
|
|
};
|
|
for (const edge of [edges.left, edges.right]) {
|
|
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__carriageway-tick", 3.6);
|
|
}
|
|
// 노견 바깥 끝에도 틱을 세운다(2026-09-12 사용자) — 종전에는 차도에만 표기가 있어
|
|
// **노견이 늘어난 건지 차도가 늘어난 건지 화면에서 못 가렸다**. 노견은 좌·우 0.5m 로
|
|
// 고정이고 확폭은 차도에만 붙으므로, 두 틱 사이 간격이 그 사실을 그대로 보여 준다.
|
|
const roadEdges = design.road_edges;
|
|
if (roadEdges) {
|
|
for (const edge of [roadEdges.left, roadEdges.right]) {
|
|
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__shoulder-tick", 2.4);
|
|
}
|
|
}
|
|
// 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다.
|
|
// 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다.
|
|
const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0);
|
|
// 사면 이동(151-2) — 노견째 바깥으로 옮겨 늘어난 차도 몫.
|
|
const moved = (design.shoulder_extend_left_m ?? 0) + (design.shoulder_extend_right_m ?? 0);
|
|
const standardWidth = design.carriageway_standard_width_m;
|
|
const shoulderLeft = roadEdges ? roadEdges.left.offset_m - edges.left.offset_m : null;
|
|
const shoulderRight = roadEdges ? edges.right.offset_m - roadEdges.right.offset_m : null;
|
|
const label = document.createElementNS(SVG_NS, "text");
|
|
label.setAttribute(
|
|
"x",
|
|
String(labelAt?.x ?? (x(edges.left.offset_m) + x(edges.right.offset_m)) / 2),
|
|
);
|
|
label.setAttribute("y", String(labelAt?.y ?? toDisplayY(edges.left.elevation_m) - 6));
|
|
label.setAttribute("class", "b06-chart__carriageway-label");
|
|
const extras = [
|
|
widened > 0.001 ? ` + 확폭 ${widened.toFixed(2)}` : "",
|
|
moved > 0.001 ? ` + 이동 ${moved.toFixed(2)}` : "",
|
|
].join("");
|
|
const widthText =
|
|
extras && typeof standardWidth === "number"
|
|
? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)}${extras})`
|
|
: `노폭 ${design.carriageway_width_m.toFixed(2)}m`;
|
|
// 노견은 좌우가 같으면 한 번만 적는다 — 라벨이 길어지면 옆 측점 라벨과 겹친다.
|
|
label.textContent =
|
|
shoulderLeft != null && shoulderRight != null
|
|
? `${widthText} · 노견 ${
|
|
Math.abs(shoulderLeft - shoulderRight) < 0.005
|
|
? `${shoulderLeft.toFixed(2)}m`
|
|
: `좌 ${shoulderLeft.toFixed(2)} / 우 ${shoulderRight.toFixed(2)}m`
|
|
}`
|
|
: widthText;
|
|
(labelAt?.layer ?? svg).append(label);
|
|
}
|
|
}
|