/* 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));