- 헬퍼 동작 그대로(풀이 함수 본문은 옮겨 담음) · 제품 코드 안 고침 - 확인: 이 묶음 헬퍼를 쓰는 시험 218 개 전 218 통과 → 후 218 통과 (시험별 junit 대조 · 어긋남 0) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
298 lines
12 KiB
JavaScript
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));
|