Files
Aislo/B06_Section/B06_Section_UI_Cross_Design_Line.ts
T
eomsangdonandClaude Opus 5.5 19b167caff feat(B06): 사면 이동 = 노견째 평행이동 · 늘어난 폭은 차도(151-2)
- 파이썬 · 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
2026-10-09 19:08:22 +09:00

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);
}
}