Files
Aislo/resources/tester/helper_271_straighten_pick.cjs
T
eomsangdonandClaude Sonnet 5.5 156ce1cf52 test(508-1b 옛 헬퍼 공용 불러오기 3/7): 헬퍼 28 곳 loadTs 베낌 걷고 makeLoader 로
- 헬퍼 동작 그대로(풀이 함수 본문은 옮겨 담음) · 제품 코드 안 고침
- 확인: 이 묶음 헬퍼를 쓰는 시험 218 개 전 218 통과 → 후 218 통과 (시험별 junit 대조 · 어긋남 0)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 17:08:08 +09:00

298 lines
12 KiB
JavaScript

/* 271-1~3 종단 직선화 측점 고름 · 끝 R 기본 L · 실시간 띠 단위검증 헬퍼 — `_Straighten.ts` ·
* `_Tools.ts` · `design_profile_free.ts` · `design_profile_range_band.ts` 를 그 자리에서 트랜스파일해
* 돌리고 파이썬 거울 입력(base · edits · TS 곡선)을 JSON 으로 낸다.
*/
const fs = require("fs");
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const ROOT = path.join(__dirname, "..", "..");
// 화면 틀 파일(@ui 별칭)은 Node 가 못 읽음 — 도구줄 그림만 쓰므로 빈 그림으로 갈음
const STUBS = { "./B05_Profile_UI_RouteEdit_Chrome": { HISTORY_ICON: { undo: "", redo: "" } } };
const { loadTs } = makeLoader({
root: ROOT,
requireFor: (full) => (request) => {
if (STUBS[request]) return STUBS[request];
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
},
});
const LOGIC = path.join(ROOT, "B05_Profile", "logic");
const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts"));
const st = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Straighten.ts"));
const law = loadTs(path.join(LOGIC, "B05_Profile_Profile_CurveLaw.ts"));
const tools = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Tools.ts"));
const DESIGN = path.join(ROOT, "A00_Common", "design");
const free = loadTs(path.join(DESIGN, "logic", "design_profile_free.ts"));
const band = loadTs(path.join(DESIGN, "design_profile_range_band.ts"));
const policy = {
station_interval_m: 20,
curve_radius_ratio: 1,
curve_tangent_max_ratio: 0.5,
curve_skip_legal_exception: false,
default_curve_length_m: 20,
default_curve_radius_m: 300,
balance_tolerance_percent: 10,
edit_step_m: 0.1,
grade_violation_policy: "warn",
max_grade_pct: 14,
curve_skip_delta_pct: 0,
paved: true,
min_vertical_radius_m: 100,
min_curve_length_m: 20,
};
// 0~300 m · 20 m 측점 — 마디마다 기울기가 다른 전체 측점 폴리라인(서버와 같이 지정 없는 꺾임 = 기본 L)
const S = Array.from({ length: 16 }, (_, i) => i * 20);
const g = (s) => 100 + 0.03 * s + 0.6 * Math.sin(s / 13);
const base = {
policy,
onlyExplicitCurves: false,
basePvi: S.map((s) => ({ chainage_m: s, elevation_m: +g(s).toFixed(6) })),
chainage: S,
ground: S.map((s) => +g(s).toFixed(6)),
stations: S.map((s) => ({ station_id: `S${s}`, chainage_m: s })),
};
const tidy = (e) => st.tidyEdits(base, e);
const build = (e) => align.buildAlignment(base, e);
const curves = (e) => build(e).curves.map((c) => ({ at: c.chainage_m, l: c.l_m, r: c.r_m }));
const shortfalls = (e) => build(e).warnings.filter((w) => w.includes("미달"));
const out = {};
const mirror = [];
const keep = (name, edits) => {
mirror.push({ name, edits, curves: curves(edits).map((c) => [c.at, c.r, c.l]) });
return edits;
};
// ① 직선화 40~200 — 사이 곡선 없음 · 양 끝 기본 L 20
const e1 = keep("straight", tidy(st.straightenBetween(base, align.emptyEdits(), 40, 200)));
const a1 = build(e1);
out.straight = { curves: curves(e1), radii: Object.keys(e1.curve_radii) };
// ② 이미 직선인 범위 = 부분 구간 · 반대 방향 같음 · 꺾인 · 섞인 범위 = null
out.sub = st.straightSubRun(a1, 80, 160);
out.subReverse = st.straightSubRun(a1, 160, 80);
out.subBent = st.straightSubRun(build(align.emptyEdits()), 40, 200);
out.subMixed = st.straightSubRun(a1, 100, 260);
out.subOneSpan = st.straightSubRun(a1, 80, 100);
// ③ 부분 구간 ▲(틸트) = 가운데 + 양 끝 L 20 · 거짓 미달 없음
const e3 = keep("tiltSub", tidy(st.tiltStraightRun(base, e1, out.sub, 0.5)));
out.tiltSub = { curves: curves(e3), shortfalls: shortfalls(e3) };
// 판정 오차 짝(1e-3) — 옛 1e-6 이면 L 19.99998 이 미달로 읽힘
out.tolerance = law.SHORTFALL_TOLERANCE_M;
out.nearMiss = law.verticalCurveShortfalls(
[{ chainage_m: 120, l_m: 19.99998, r_m: 800, omitted: false, skip_allowed: false }],
policy,
);
out.realMiss = law.verticalCurveShortfalls(
[{ chainage_m: 120, l_m: 19.9, r_m: 800, omitted: false, skip_allowed: false }],
policy,
);
// ④ 끝에 저장 자동 R → 지움(기본 L 20) · 사용자 R 은 둠
const saved = { "40.000": 5330, "200.000": 1084 };
const savedEdits = { station_offsets: {}, curve_radii: { ...saved } };
const e4 = keep("autoEnds", tidy(st.straightenBetween(base, savedEdits, 40, 200, saved)));
out.autoEnds = { radii: Object.keys(e4.curve_radii), curves: curves(e4) };
const userEdits = { station_offsets: {}, curve_radii: { "40.000": 600, "200.000": 1084 } };
const e4u = tidy(st.straightenBetween(base, userEdits, 40, 200, saved));
out.userEnd = { radii: e4u.curve_radii };
// 자동 R 기준 없이(옛 부름) = 그대로 남음
out.noAuto = Object.keys(tidy(st.straightenBetween(base, savedEdits, 40, 200)).curve_radii);
// ⑤ BP · EP — 시종점 곡선 없음
const e5 = keep("bpEp", tidy(st.straightenBetween(base, align.emptyEdits(), 0, 300)));
out.bpEp = curves(e5);
const eBP = tidy(st.straightenBetween(base, align.emptyEdits(), 0, 200));
const subBP = st.straightSubRun(build(eBP), 0, 80);
const e5t = keep("tiltBp", tidy(st.tiltStraightRun(base, eBP, subBP, 0.5)));
out.tiltBp = curves(e5t).map((c) => c.at);
// ⑥ 곡선 섞인 범위 = 안쪽 R 지움 · 양 끝 L 20
const mixedIn = { station_offsets: {}, curve_radii: { "120.000": 900, "140.000": 700 } };
const e6 = keep("mixed", tidy(st.straightenBetween(base, mixedIn, 100, 260, {})));
out.mixed = { radii: Object.keys(e6.curve_radii), curves: curves(e6) };
// ⑦ 자유지정 끝 자동 R 지움 · 사용자 R 둠
const z = (s) => align.controlElevationAt(build(savedEdits), s);
const vertices = [
{ chainage_m: 40, elevation_m: z(40) },
{ chainage_m: 120, elevation_m: z(120) + 1 },
{ chainage_m: 200, elevation_m: z(200) },
];
const e7 = keep("freeAuto", tidy(free.buildFreeEdits(base, savedEdits, vertices, saved)));
out.freeAuto = Object.keys(e7.curve_radii);
out.freeUser = Object.keys(tidy(free.buildFreeEdits(base, userEdits, vertices, saved)).curve_radii);
// ⑧ 자유지정 range 단계 실시간 띠 · Esc = 시작만 취소
{
const stations = S;
let edits = align.emptyEdits();
const log = { closed: 0 };
const draw = free.createFreeDraw({
base: () => base,
alignment: () => build(edits),
edits: () => edits,
apply: (next) => (edits = next),
close: () => (log.closed += 1),
changed: () => undefined,
});
draw.click(41, 0, stations);
draw.hover({ chainageM: 117, elevationM: 0, stations });
out.liveBand = draw.marks().band;
out.liveEdges = draw.marks().edges;
draw.hover({ chainageM: 13, elevationM: 0, stations });
out.liveReverse = draw.marks().band;
draw.hover(null);
out.outside = { band: draw.marks().band, edges: draw.marks().edges };
out.hintPending = draw.hint();
out.escTaken = draw.key("Escape");
out.afterEsc = { phase: draw.phase(), edges: draw.marks().edges, closed: log.closed };
draw.click(41, 0, stations);
draw.click(158, 0, stations);
draw.hover({ chainageM: 260, elevationM: 0, stations });
out.fixedBand = draw.marks().band;
out.armedPhase = draw.phase();
out.escArmed = draw.key("Escape");
out.afterEscArmed = { phase: draw.phase(), closed: log.closed };
}
// ⑩ 범위 띠 그림 — 가짜 DOM
class El {
constructor(tag) {
this.tag = tag;
this.attrs = {};
this.children = [];
this.parent = null;
this.textContent = "";
this.listeners = {};
this.isConnected = true;
this.classList = { toggle: () => undefined, add: () => undefined };
}
setAttribute(name, value) {
this.attrs[name] = value;
}
append(...items) {
items.forEach((item) => {
item.parent = this;
this.children.push(item);
});
}
remove() {
if (!this.parent) return;
this.parent.children = this.parent.children.filter((c) => c !== this);
this.parent = null;
}
addEventListener(type, fn, opts) {
(this.listeners[type] ??= []).push(fn);
opts?.signal?.addEventListener("abort", () => {
this.listeners[type] = this.listeners[type].filter((f) => f !== fn);
});
}
fire(type, event = {}) {
(this.listeners[type] ?? []).forEach((fn) => fn(event));
}
getBoundingClientRect() {
return { left: 0, top: 0 };
}
}
global.document = {
createElementNS: (_ns, tag) => new El(tag),
createElement: (tag) => new El(tag),
};
const shape = (svg) =>
svg.children.map((c) =>
c.tag === "text"
? `text:${c.textContent}`
: c.tag === "rect"
? `rect:${c.attrs.x}+${c.attrs.width}`
: `line:${c.attrs.x1}`,
);
{
const x = (m) => m * 2;
const svg = new El("svg");
band.appendRangeBand(svg, x, 100, 120, 40);
out.bandShape = shape(svg);
const one = new El("svg");
band.appendRangeBand(one, x, 100, 40, null);
out.bandStart = shape(one);
const chart = new El("div");
const scroller = new El("div");
const geom = {
chart,
scroller,
x,
toChainage: (px) => px / 2,
widthPx: 640,
heightPx: 100,
stations: S,
};
let start = null;
const view = band.createRangeBandView((hover) => (start === null ? null : [start, hover]));
view.mount(geom, true);
out.viewIdle = chart.children.length;
start = 40;
view.redraw();
out.viewStart = shape(chart.children[0]);
chart.fire("mousemove", { clientX: 2 * 117 });
out.viewHover = shape(chart.children[0]);
chart.fire("mouseleave");
out.viewLeave = shape(chart.children[0]);
chart.fire("mousemove", { clientX: 2 * 61 });
view.mount(geom, true); // 다시 그림(줌 · 스크롤 · 계획선 바뀜) 뒤에도 커서 자리 이어짐
out.viewRemount = shape(chart.children[0]);
view.mount(geom, false);
out.viewOff = chart.children.length;
}
// ⑨ 도구 상태 — [직선화] 부분 구간은 붉은 강조(selectedRuns) 아님 · Esc 취소 · 모드 유지
{
const picked = [];
const t = tools.createProfileTools({
onStraighten: (from, to) => picked.push([from, to]),
onShift: () => undefined,
onTilt: (run, delta) => picked.push(["tilt", run.fromM, run.toM, delta]),
onResetToSaved: () => undefined,
canResetToSaved: () => false,
onUndo: () => undefined,
onRedo: () => undefined,
canUndo: () => false,
canRedo: () => false,
hasStraightRuns: () => true,
onChanged: () => undefined,
onModeChange: () => undefined,
freeHint: () => "",
mountOverlay: () => undefined,
});
const press = (label) => {
const button = t.render().children.find((c) => c.textContent === label);
button.fire("click", { stopPropagation: () => undefined });
};
const hintOf = () => t.render().children.find((c) => c.tag === "em")?.textContent ?? null;
out.toolsNone = t.handleStationPick(40);
press("직선화");
t.handleStationPick(40);
out.pending = { at: t.pendingStation(), hint: hintOf() };
out.cancel = { taken: t.cancelPick(), pending: t.pendingStation(), mode: t.mode() };
out.cancelAgain = t.cancelPick();
t.handleRunPick({ fromM: 80, toM: 160, nodes: [80, 100, 120, 140, 160] });
out.subPicked = { sub: t.subRun(), red: t.selectedRuns(), hint: hintOf() };
press("▲");
out.tiltCall = picked.at(-1);
t.handleStationPick(60); // 새 시작 = 부분 구간 풂
out.newStart = { pending: t.pendingStation(), sub: t.subRun() };
t.handleStationPick(180);
out.straightenCall = picked.at(-1);
press("직선화"); // 끔
press("쉬프트");
t.handleRunPick({ fromM: 40, toM: 200, nodes: [40, 200] });
out.shiftRed = { red: t.selectedRuns().length, sub: t.subRun(), cancel: t.cancelPick() };
}
out.mirror = { base, cases: mirror };
process.stdout.write(JSON.stringify(out));