- 새 셈 logic/D03_PlanAnalysis_Fan.ts(화면 고르기 전용 · 파이썬 짝 없음 · B05 626-3 이 그대로 씀) — 이웃 노드 사이 허용 호 · 정방향 = 앞 구간 쪽(첫 구간 없음 · 갈래 = 붙은 선 · 거의 같으면 마우스 쪽) · 완만한 땅 = 어느 방향이나 · Shift = 호 위 마우스 방위(길이 그대로 · 기울기 실제 지형 · 호 밖 = 호 끝) - 미리보기 paintFan — 정방향 반투명 채움 · 역방향 테두리 · 노드 + 따뜻한 색 · − 찬 색 · 고리 = 고른 점 부호 색 - 놓인 꺾임점 = 들어온 구간 기울기 부호 색(저장 없이 셈) - Shift · 완만한 땅으로 찍은 노드 기록 = 길이 고정(저장 꼴 그대로) - 색 토큰 --draw-map-slope-up · --draw-map-slope-down · test_551_2 토큰 8 → 10 - 시험 test_625_draw(부채꼴 · 정방향 · +/− · 노드 2/4/6/8 · 완만한 땅 · Shift 실제 지형) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
130 lines
5.6 KiB
TypeScript
130 lines
5.6 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_Fan.ts
|
|
* D 예정노선 길이·기울기 고정 긋기 — 부채꼴 고르기 셈(PLAN 625-2 · 요구 530 · 531 B1~B7).
|
|
* 노드 = 반지름 L 원 위 |기울기| = s 자리(양각기 후보 · 562-1 짝 `compassCandidates`) · 부호 = 오르막 + · 내리막 −.
|
|
* 부채꼴 = 각도 차례 이웃 두 노드 사이 호 중 가운데가 |기울기| ≤ s 인 호(노드 2 · 4 · 6 · 8 개 모두 같은 규칙).
|
|
* 정방향 = 앞 구간 방향과 가장 가까운 호(앞 구간 없음 = 없음 · 거의 같으면 마우스 쪽) · 나머지 = 역방향.
|
|
* 완만한 땅(노드 0 · 원 전체 ≤ s) = 어느 방향이나(길이 고정처럼).
|
|
* Shift 사이 고르기 = 마우스 방위를 허용 호 안으로 묶음 · 길이 그대로 · 기울기 = 그 자리 실제 지형.
|
|
* 화면 고르기 전용 — 서버는 [저장] 때 꼭짓점으로 다시 셈(파이썬 짝 없음) · DOM · fetch · three 없음.
|
|
* B05 평면(626-3)이 그대로 씀.
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
compassCandidates,
|
|
segmentLength,
|
|
type HeightAt,
|
|
type Vertex,
|
|
} from "./D03_PlanAnalysis_Draw";
|
|
|
|
const TAU = 2 * Math.PI;
|
|
/** 정방향 두 호 점수 차가 이보다 작으면 마우스 쪽 호(cos 차 · 약 3°). */
|
|
const FORWARD_TIE = 0.05;
|
|
|
|
/** 원 위 노드 — 방위(라디안 · 동쪽 0 · 반시계 · 작업 좌표 y 위) · 부호(+1 오르막 · −1 내리막). */
|
|
export interface FanNode {
|
|
point: Vertex;
|
|
angle: number;
|
|
sign: 1 | -1;
|
|
}
|
|
|
|
/** 허용 호 — `from` 에서 반시계로 `to`(to > from · 2π 넘을 수 있음). */
|
|
export interface FanArc {
|
|
from: number;
|
|
to: number;
|
|
forward: boolean;
|
|
}
|
|
|
|
export interface Fan {
|
|
nodes: FanNode[];
|
|
arcs: FanArc[];
|
|
/** 원 전체가 |기울기| ≤ s(노드 0) — 어느 방향이나. */
|
|
flat: boolean;
|
|
}
|
|
|
|
const norm = (angle: number): number => ((angle % TAU) + TAU) % TAU;
|
|
const bearing = (from: Vertex, to: Vertex): number =>
|
|
norm(Math.atan2(to[1] - from[1], to[0] - from[0]));
|
|
/** 두 방위 사이 각(0 ~ π). */
|
|
const gap = (a: number, b: number): number => Math.min(norm(a - b), norm(b - a));
|
|
|
|
/** 원 위 방위 자리. */
|
|
export function fanPoint(from: Vertex, lengthM: number, angle: number): Vertex {
|
|
return [from[0] + Math.cos(angle) * lengthM, from[1] + Math.sin(angle) * lengthM];
|
|
}
|
|
|
|
/** 부채꼴 — `prev` = 앞 구간 시작(정방향 · 없으면 null) · `cursor` = 정방향이 거의 같을 때 가름. */
|
|
export function compassFan(
|
|
from: Vertex,
|
|
lengthM: number,
|
|
slopePct: number,
|
|
heightAt: HeightAt,
|
|
prev: Vertex | null,
|
|
cursor: Vertex | null = null,
|
|
): Fan {
|
|
const z0 = heightAt(from[0], from[1]);
|
|
if (!(lengthM > 0) || !Number.isFinite(z0)) return { nodes: [], arcs: [], flat: false };
|
|
const rise = (Math.abs(slopePct) / 100) * lengthM;
|
|
const allowed = (angle: number): boolean => {
|
|
const p = fanPoint(from, lengthM, angle);
|
|
return Math.abs(heightAt(p[0], p[1]) - z0) <= rise; // NaN = 지형 밖 = 거짓
|
|
};
|
|
const nodes: FanNode[] = compassCandidates(from, lengthM, slopePct, heightAt)
|
|
.map((point): FanNode => ({
|
|
point,
|
|
angle: bearing(from, point),
|
|
sign: heightAt(point[0], point[1]) >= z0 ? 1 : -1,
|
|
}))
|
|
.sort((a, b) => a.angle - b.angle);
|
|
if (!nodes.length) return { nodes, arcs: [], flat: allowed(0) };
|
|
const arcs: FanArc[] = [];
|
|
nodes.forEach((node, i) => {
|
|
const to = i + 1 < nodes.length ? nodes[i + 1].angle : nodes[0].angle + TAU;
|
|
if (allowed((node.angle + to) / 2)) arcs.push({ from: node.angle, to, forward: false });
|
|
});
|
|
if (prev && arcs.length && segmentLength(prev, from) > 1e-9) {
|
|
const heading = bearing(prev, from);
|
|
const mid = (arc: FanArc): number => (arc.from + arc.to) / 2;
|
|
const ranked = arcs
|
|
.map((arc) => ({ arc, score: Math.cos(mid(arc) - heading) }))
|
|
.sort((a, b) => b.score - a.score);
|
|
let pick = ranked[0].arc;
|
|
if (cursor && ranked.length > 1 && ranked[0].score - ranked[1].score < FORWARD_TIE) {
|
|
const toward = bearing(from, cursor);
|
|
if (gap(mid(ranked[1].arc), toward) < gap(mid(pick), toward)) pick = ranked[1].arc;
|
|
}
|
|
pick.forward = true;
|
|
}
|
|
return { nodes, arcs, flat: false };
|
|
}
|
|
|
|
/** 고르기 — 완만한 땅 = 마우스 방향 원 위(길이 고정처럼) · 아니면 마우스에 가장 가까운 노드 · 없으면 null. */
|
|
export function fanPick(fan: Fan, from: Vertex, cursor: Vertex, lengthM: number): Vertex | null {
|
|
if (fan.flat)
|
|
return segmentLength(from, cursor) > 1e-9
|
|
? fanPoint(from, lengthM, bearing(from, cursor))
|
|
: null;
|
|
let best: Vertex | null = null;
|
|
let bestD = Infinity;
|
|
for (const node of fan.nodes) {
|
|
const d = segmentLength(node.point, cursor);
|
|
if (d < bestD) [best, bestD] = [node.point, d];
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/** Shift 사이 고르기 — 마우스 방위가 허용 호 안이면 그 방위 · 밖이면 가장 가까운 호 끝(= 노드) · 길이 그대로. */
|
|
export function fanBetween(fan: Fan, from: Vertex, cursor: Vertex, lengthM: number): Vertex | null {
|
|
if (segmentLength(from, cursor) < 1e-9) return null;
|
|
const toward = bearing(from, cursor);
|
|
if (fan.flat) return fanPoint(from, lengthM, toward);
|
|
let best: number | null = null;
|
|
let bestGap = Infinity;
|
|
for (const arc of fan.arcs) {
|
|
if (norm(toward - arc.from) <= arc.to - arc.from) return fanPoint(from, lengthM, toward);
|
|
for (const end of [arc.from, arc.to])
|
|
if (gap(end, toward) < bestGap) [best, bestGap] = [end, gap(end, toward)];
|
|
}
|
|
return best === null ? null : fanPoint(from, lengthM, best);
|
|
}
|