- logic/D03_PlanAnalysis_Draw.ts ↔ D03_PlanAnalysis_Draw.py 같은 이름 짝 · 높이는 인자 heightAt - 거울 시험 test_562_1_plan_draw.py — 두 쪽 값 대조 + 손셈 값 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
7.8 KiB
TypeScript
237 lines
7.8 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_Draw.ts
|
|
* D 예정노선 수동 직선 그리기 셈(PLAN 562-1 · 요구 389 12 · 13) — 구간 길이 · 원지반 기울기 · 길이 고정 ·
|
|
* 기울기 고정 · 둘 다(양각기) · 선 위 거리 ↔ 점 · 측점. 높이는 인자 `heightAt(x, y)`(NaN = 지형 밖).
|
|
* DOM · fetch · sessionStorage 없음.
|
|
*
|
|
* ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Draw.py` 같은 이름(snake). 한쪽을 고치면 다른 쪽도 고치고 거울 시험
|
|
* `resources/tester/test_562_1_plan_draw.py`. 화면은 그리는 즉시 이 짝으로 · [저장]은 서버가 다시 셈.
|
|
* ========================================================================== */
|
|
|
|
export type Vertex = [number, number];
|
|
export type HeightAt = (x: number, y: number) => number;
|
|
|
|
/** 기울기 고정 반직선 걸음(m) · 이분 횟수 · 양각기 원 표본 수. */
|
|
const SLOPE_STEP_M = 1;
|
|
const BISECT = 40;
|
|
const COMPASS_SAMPLES = 360;
|
|
const EPS = 1e-9;
|
|
|
|
/** 수평 길이(m). */
|
|
export function segmentLength(a: Vertex, b: Vertex): number {
|
|
const dx = b[0] - a[0];
|
|
const dy = b[1] - a[1];
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
/** 원지반 기울기(%) = (z끝 − z시작) / 수평 길이 × 100 · + 오르막 · null = 지형 밖 · 길이 0. */
|
|
export function groundSlopePct(a: Vertex, b: Vertex, heightAt: HeightAt): number | null {
|
|
const length = segmentLength(a, b);
|
|
if (length < EPS) return null;
|
|
const za = heightAt(a[0], a[1]);
|
|
const zb = heightAt(b[0], b[1]);
|
|
if (!Number.isFinite(za) || !Number.isFinite(zb)) return null;
|
|
return ((zb - za) / length) * 100;
|
|
}
|
|
|
|
/** 길이 고정 — 마우스 방향으로 그 길이 점 · 마우스 = 시작이면 null. */
|
|
export function fixedLengthPoint(from: Vertex, cursor: Vertex, lengthM: number): Vertex | null {
|
|
const d = segmentLength(from, cursor);
|
|
if (d < EPS) return null;
|
|
return [
|
|
from[0] + ((cursor[0] - from[0]) / d) * lengthM,
|
|
from[1] + ((cursor[1] - from[1]) / d) * lengthM,
|
|
];
|
|
}
|
|
|
|
/** 기울기 고정 — 마우스 방향 반직선 위 |기울기| 가 목표가 되는 첫 자리(1 m 걸음 · 이분) ·
|
|
* `maxM` 안에 없거나 지형 밖에 닿으면 null. */
|
|
export function fixedSlopePoint(
|
|
from: Vertex,
|
|
cursor: Vertex,
|
|
slopePct: number,
|
|
heightAt: HeightAt,
|
|
maxM: number = 1000,
|
|
): Vertex | null {
|
|
const d = segmentLength(from, cursor);
|
|
const z0 = heightAt(from[0], from[1]);
|
|
if (d < EPS || !Number.isFinite(z0)) return null;
|
|
const ux = (cursor[0] - from[0]) / d;
|
|
const uy = (cursor[1] - from[1]) / d;
|
|
const grade = Math.abs(slopePct) / 100;
|
|
const at = (t: number): Vertex => [from[0] + ux * t, from[1] + uy * t];
|
|
const f = (t: number): number => {
|
|
const p = at(t);
|
|
return Math.abs(heightAt(p[0], p[1]) - z0) - grade * t;
|
|
};
|
|
let lo = 0;
|
|
let flo = NaN;
|
|
for (let i = 1; i * SLOPE_STEP_M <= maxM; i += 1) {
|
|
const t = i * SLOPE_STEP_M;
|
|
const ft = f(t);
|
|
if (!Number.isFinite(ft)) return null;
|
|
if (ft === 0) return at(t);
|
|
if (i > 1 && flo < 0 !== ft < 0) {
|
|
let hi = t;
|
|
for (let k = 0; k < BISECT; k += 1) {
|
|
const mid = (lo + hi) / 2;
|
|
const fm = f(mid);
|
|
if (!Number.isFinite(fm)) return null;
|
|
if (fm < 0 === flo < 0) {
|
|
lo = mid;
|
|
flo = fm;
|
|
} else hi = mid;
|
|
}
|
|
return at((lo + hi) / 2);
|
|
}
|
|
lo = t;
|
|
flo = ft;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** 둘 다(양각기) — 반지름 `lengthM` 원 위에서 |기울기| = 목표인 후보들(방위 표본 · 이분 · 동쪽부터
|
|
* 반시계). */
|
|
export function compassCandidates(
|
|
from: Vertex,
|
|
lengthM: number,
|
|
slopePct: number,
|
|
heightAt: HeightAt,
|
|
): Vertex[] {
|
|
const z0 = heightAt(from[0], from[1]);
|
|
if (!(lengthM > EPS) || !Number.isFinite(z0)) return [];
|
|
const rise = (Math.abs(slopePct) / 100) * lengthM;
|
|
const step = (2 * Math.PI) / COMPASS_SAMPLES;
|
|
const at = (angle: number): Vertex => [
|
|
from[0] + Math.cos(angle) * lengthM,
|
|
from[1] + Math.sin(angle) * lengthM,
|
|
];
|
|
const h = (angle: number): number => {
|
|
const p = at(angle);
|
|
return Math.abs(heightAt(p[0], p[1]) - z0) - rise;
|
|
};
|
|
const values: number[] = [];
|
|
for (let k = 0; k < COMPASS_SAMPLES; k += 1) values.push(h(k * step));
|
|
const out: Vertex[] = [];
|
|
for (let k = 0; k < COMPASS_SAMPLES; k += 1) {
|
|
const hk = values[k];
|
|
const hn = values[(k + 1) % COMPASS_SAMPLES];
|
|
if (!Number.isFinite(hk) || !Number.isFinite(hn)) continue;
|
|
if (hk === 0) {
|
|
out.push(at(k * step));
|
|
continue;
|
|
}
|
|
if (hn === 0 || hk < 0 === hn < 0) continue;
|
|
let lo = k * step;
|
|
let hi = lo + step;
|
|
let flo = hk;
|
|
let ok = true;
|
|
for (let i = 0; i < BISECT; i += 1) {
|
|
const mid = (lo + hi) / 2;
|
|
const fm = h(mid);
|
|
if (!Number.isFinite(fm)) {
|
|
ok = false;
|
|
break;
|
|
}
|
|
if (fm < 0 === flo < 0) {
|
|
lo = mid;
|
|
flo = fm;
|
|
} else hi = mid;
|
|
}
|
|
if (ok) out.push(at((lo + hi) / 2));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** 둘 다 — 커서에 가장 가까운 양각기 후보 · 없으면 null. */
|
|
export function compassPoint(
|
|
from: Vertex,
|
|
cursor: Vertex,
|
|
lengthM: number,
|
|
slopePct: number,
|
|
heightAt: HeightAt,
|
|
): Vertex | null {
|
|
let best: Vertex | null = null;
|
|
let bestD = Infinity;
|
|
for (const p of compassCandidates(from, lengthM, slopePct, heightAt)) {
|
|
const d = segmentLength(p, cursor);
|
|
if (d < bestD) {
|
|
best = p;
|
|
bestD = d;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/** 선 길이(m) — 앞에서부터 더함. */
|
|
export function lineLength(line: ReadonlyArray<Vertex>): number {
|
|
let total = 0;
|
|
for (let i = 1; i < line.length; i += 1) total += segmentLength(line[i - 1], line[i]);
|
|
return total;
|
|
}
|
|
|
|
/** 선 위 거리 → 점(0 · 끝 밖은 끝에서 멈춤) · 선 점 하나 이상. */
|
|
export function pointAtDistance(line: ReadonlyArray<Vertex>, m: number): Vertex {
|
|
if (m <= 0) return [line[0][0], line[0][1]];
|
|
let acc = 0;
|
|
for (let i = 1; i < line.length; i += 1) {
|
|
const a = line[i - 1];
|
|
const b = line[i];
|
|
const seg = segmentLength(a, b);
|
|
if (seg > 0 && acc + seg >= m) {
|
|
const t = (m - acc) / seg;
|
|
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
|
}
|
|
acc += seg;
|
|
}
|
|
const last = line[line.length - 1];
|
|
return [last[0], last[1]];
|
|
}
|
|
|
|
/** 점 → 선 위 거리(m) — 가장 가까운 선 위 자리(같으면 앞 구간). */
|
|
export function distanceAlong(line: ReadonlyArray<Vertex>, point: Vertex): number {
|
|
let best = 0;
|
|
let bestD = Infinity;
|
|
let acc = 0;
|
|
for (let i = 1; i < line.length; i += 1) {
|
|
const a = line[i - 1];
|
|
const b = line[i];
|
|
const seg = segmentLength(a, b);
|
|
let t = 0;
|
|
if (seg > 0) {
|
|
t = ((point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])) / (seg * seg);
|
|
t = Math.min(1, Math.max(0, t));
|
|
}
|
|
const p: Vertex = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
|
const d = segmentLength(p, point);
|
|
if (d < bestD) {
|
|
bestD = d;
|
|
best = acc + seg * t;
|
|
}
|
|
acc += seg;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/** 선 조각 — `fromM`~`toM` 사이(끝점 + 그 사이 꺾임점). */
|
|
export function sliceLine(line: ReadonlyArray<Vertex>, fromM: number, toM: number): Vertex[] {
|
|
const lo = Math.min(fromM, toM);
|
|
const hi = Math.max(fromM, toM);
|
|
const out: Vertex[] = [pointAtDistance(line, lo)];
|
|
let acc = 0;
|
|
for (let i = 1; i < line.length - 1; i += 1) {
|
|
acc += segmentLength(line[i - 1], line[i]);
|
|
if (acc > lo + EPS && acc < hi - EPS) out.push([line[i][0], line[i][1]]);
|
|
}
|
|
out.push(pointAtDistance(line, hi));
|
|
return out;
|
|
}
|
|
|
|
/** 측점 누가 — No.0(0 m) · 간격 배수 · 끝(선마다 0 부터). */
|
|
export function stationChainages(lengthM: number, intervalM: number = 20): number[] {
|
|
const out = [0];
|
|
for (let k = 1; k * intervalM < lengthM - 1e-6; k += 1) out.push(k * intervalM);
|
|
if (lengthM > 1e-6) out.push(lengthM);
|
|
return out;
|
|
}
|