/* 467 헬퍼 — 노선 초안 없는 차례(관만 옮김)의 갈아끼움이 정본(서버) 횡단 기하 · 표본을 지키는지. * node helper_272_swap_canon.mjs <묶음.mjs> * 정본 = 서버 틀(표본 A) · 묶음 측점 = 브라우저 틀(중심 0.5 mm · R 12 곡선 축 3.3e-4 차 · 표본 B). * 짝 = `test_272_swap_keeps_canon.py` · 표준출력 JSON 한 벌. */ import { pathToFileURL } from "node:url"; const memory = () => { const m = new Map(); return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k), key: (i) => [...m.keys()][i] ?? null, get length() { return m.size; }, clear: () => m.clear(), }; }; globalThis.window = globalThis; globalThis.sessionStorage = memory(); globalThis.localStorage = memory(); const any = new Proxy(function () {}, { get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), apply: () => any, construct: () => any, }); globalThis.document = any; globalThis.requestAnimationFrame = (run) => setTimeout(run, 0); globalThis.fetch = async () => new Response("{}", { status: 404 }); const m = await import(pathToFileURL(process.argv[2]).href); const idOf = (c) => `station_${String(Math.round(c * 1000)).padStart(12, "0")}`; const OFFSETS = [-10, -6, -3, 0, 3, 6, 10]; const groundA = (c, o) => 100 + 0.03 * c + 0.4 * o; // 서버 표본(정본) const groundB = (c, o) => groundA(c, o) + 0.0004 * Math.sin(c + o) + 0.0001; // 브라우저 지형(mm 차) /* 측점 — [열쇠 · 누가거리 · 묶음 종류 · 상세 종류 · 축 차(rad)] · 곡선 셋 = R 12 */ const LIST = [ ["end:BP", 0, "end", "bp", 1e-5], ["grid:1", 20, "grid", "regular", 1e-5], ["curve:27.50", 27.5, "curve", "curve", 3.3e-4], ["curve:33.20", 33.2, "curve", "curve", -3.3e-4], ["curve:38.90", 38.9, "curve", "curve", 2.1e-4], ["grid:2", 40, "grid", "regular", 1e-5], ["pipe:p1", 45, "pipe", "irregular", 1e-5], ["grid:3", 60, "grid", "regular", 1e-5], ["end:EP", 70, "end", "ep", 1e-5], ]; const azOf = (c) => 0.6 + c / 40; // 진행 방향(rad · 곡선 둘레가 휨) const leftOf = (a) => [-Math.sin(a), Math.cos(a)]; const xyOf = (c) => [1000 + 12 * Math.sin(azOf(c)), 2000 - 12 * Math.cos(azOf(c))]; const canonSection = ([, c, , kind]) => { const [x, y] = xyOf(c); const samples = OFFSETS.map((o) => ({ offset_m: o, elevation_m: groundA(c, o), valid: true })); return { station_id: idOf(c), chainage_m: c, label: "", kind, center_z: groundA(c, 0), azimuth_deg: (azOf(c) * 180) / Math.PI, plan_radius_m: kind === "curve" ? 12 : null, curve_outer_side: kind === "curve" ? "left" : null, curve_widening_m: kind === "curve" ? 1.25 : null, center_x: x, center_y: y, frame: { left_xy: leftOf(azOf(c)) }, uphill_side: "left", samples, design: { design_elevation_m: groundA(c, 0) + 0.3, mark: `canon@${c}` }, ...(kind === "irregular" ? { structure: "관", culvert: {} } : {}), }; }; const canonDetail = () => ({ longitudinal: { length_m: 70, stations: [], profile_alignment: { policy: { station_interval_m: 20 } }, }, cross_sections: LIST.map(canonSection), }); /** 묶음 측점 — 브라우저 틀(중심 0.5 mm · 축 차) · 표본 B · `shift` = 노선 초안이 옮긴 자리(m · y). */ const station = ([key, c0, kind, , tilt], pipeAt, shift = () => 0) => { const c = key === "pipe:p1" ? pipeAt : c0; const [x, y] = xyOf(c); const a = azOf(c) + tilt; const side = (sign) => OFFSETS.filter((o) => o * sign > 0).map((o) => ({ offsetM: Math.abs(o), z: groundB(c, o) })); return { key, chainageM: c, x: x + 0.0004, y: y - 0.0003 + shift(c), azimuthDeg: (a * 180) / Math.PI, left: leftOf(a), kind, id: key === "pipe:p1" ? "p1" : null, ground: { centerZ: groundB(c, 0), left: side(1), right: side(-1) }, planRadiusM: kind === "curve" ? 12.0004 : null, curveOuterSide: kind === "curve" ? "left" : null, curveWideningM: kind === "curve" ? 1.2501 : null, }; }; const stationsAt = (pipeAt, shift) => LIST.map((row) => station(row, pipeAt, shift)).sort((p, q) => p.chainageM - q.chainageM); const KEYS = LIST.map(([key]) => key); const keysOf = (list) => list.map((s) => s.key); const json = (v) => JSON.stringify(v); const canonOf = new Map(canonDetail().cross_sections.map((s) => [s.chainage_m, s])); /** 상세 측점마다 [정본과 같은가(기하 · 표본 · 중심고) · 표본이 브라우저 B 인가 · 설계 표시]. */ const GEOM = ["center_x", "center_y", "frame", "azimuth_deg", "plan_radius_m", "curve_widening_m"]; const report = (detail) => Object.fromEntries( detail.cross_sections.map((s) => { const c = canonOf.get(s.chainage_m); const same = !!c && GEOM.every((k) => json(s[k]) === json(c[k])) && json(s.samples) === json(c.samples) && s.center_z === c.center_z; const browser = s.samples.every( (p) => Math.abs(p.elevation_m - groundB(s.chainage_m, p.offset_m)) < 1e-12, ); return [s.chainage_m.toFixed(2), { same, browser, mark: s.design?.mark ?? null }]; }), ); /** `skip` 밖 횡단이 정본과 바이트 같은가(이름 글은 갈아끼움이 새로 씀 · 뺌). */ const bytesSame = (detail, skip) => detail.cross_sections .filter((s) => !skip.includes(s.chainage_m)) .every((s) => { const c = canonOf.get(s.chainage_m); if (!c) return false; const { label: _a, ...got } = s; const { label: _b, ...want } = c; return ( json( Object.keys(got) .sort() .map((k) => [k, got[k]]), ) === json( Object.keys(want) .sort() .map((k) => [k, want[k]]), ) ); }); const out = {}; /* 1. 순수 — 관만 옮김(45 → 55) · 노선 초안 없음 대 있음 · 되돌림 */ { const moved = stationsAt(55); const keep = m.swapCrossSections(canonDetail(), moved, KEYS, 20, false); const draft = m.swapCrossSections(canonDetail(), moved, KEYS, 20, true); const back = m.swapCrossSections(keep.detail, stationsAt(45), keysOf(moved), 20, false); out.pure = { keep: report(keep.detail), draft: report(draft.detail), keepMoved: keep.moved, back: report(back.detail), backBytes: bytesSame(back.detail, [45]), defaultIsKeep: json(m.swapCrossSections(canonDetail(), moved, KEYS, 20).detail) === json(keep.detail), }; } /* 1-2. 격자 측점(40 m)에 구조물을 놓고 뺌 — 열쇠가 바뀌어도 같은 자리 = 정본 표본 · 기하 */ { const onGrid = stationsAt(45).map((s) => s.key === "grid:2" ? { ...s, key: "structure:s1", kind: "structure", id: "s1" } : s, ); const placed = m.swapCrossSections(canonDetail(), onGrid, KEYS, 20, false); const removed = m.swapCrossSections(placed.detail, stationsAt(45), keysOf(onGrid), 20, false); out.gridStructure = { placed: report(placed.detail)["40.00"], removed: report(removed.detail)["40.00"], removedBytes: bytesSame(removed.detail, [45, 40]), }; } /* 1-3. 관을 옮겼다 제자리로 — 저장 정본(saved)이 있으면 관 측점도 정본 표본 · 기하 */ { const moved = stationsAt(55); const keep = m.swapCrossSections(canonDetail(), moved, KEYS, 20, false); const saved = canonDetail().cross_sections; const back = m.swapCrossSections(keep.detail, stationsAt(45), keysOf(moved), 20, false, saved); out.pipeBack = { pipe: report(back.detail)["45.00"], bytes: bytesSame(back.detail, [45]) }; } /* 1-4. 일반 측점 자리(40)에 선 관을 지움 — 자리 · 수 같아도 다른 목록(관 카드가 안 남음) */ { const onGrid = stationsAt(45).map((s) => s.key === "grid:2" ? { ...s, key: "pipe:p9", kind: "pipe", id: "p9" } : s, ); const placed = m.swapCrossSections(canonDetail(), onGrid, KEYS, 20, false).detail; placed.cross_sections.find((s) => s.chainage_m === 40).culvert = { pipe: "p9" }; const gone = stationsAt(45); out.pipeGone = { placedKind: placed.cross_sections.find((s) => s.chainage_m === 40).kind, same: m.sameStationList(placed.cross_sections, keysOf(onGrid), gone), sameUntouched: m.sameStationList(canonDetail().cross_sections, KEYS, gone), }; const after = m.swapCrossSections(placed, gone, keysOf(onGrid), 20, false).detail; const at40 = after.cross_sections.find((s) => s.chainage_m === 40); out.pipeGone.culvertLeft = "culvert" in at40; out.pipeGone.kind = at40.kind; out.pipeGone.canon = report(after)["40.00"].same; } /* 1-5. 관 지움 → 되돌림(472 덧) — 지금 상세에 없던 측점이 정본 자리로 = 정본 설계(빈 기본 설계 아님) */ { const all = stationsAt(45); const without = all.filter((s) => s.key !== "pipe:p1"); const gone = m.swapCrossSections(canonDetail(), without, KEYS, 20, false); const saved = canonDetail().cross_sections; const back = m.swapCrossSections(gone.detail, all, keysOf(without), 20, false, saved); const at45 = back.detail.cross_sections.find((s) => s.chainage_m === 45); const bare = m .swapCrossSections(gone.detail, all, keysOf(without), 20, false) .detail.cross_sections.find((s) => s.chainage_m === 45); out.pipeRestore = { design: json(at45.design) === json(canonOf.get(45).design), copy: at45.design !== saved.find((s) => s.chainage_m === 45).design, place: report(back.detail)["45.00"].same, bytes: bytesSame(back.detail, [45]), bareFresh: bare.design?.mark === undefined, // 정본 없음 = 기본 설계 그대로 }; } /* 2. 가게 길(swapSectionStations) — 가게 routeDraft 로 가름: 없음 · 있음 · 초안 버린 직후 */ const seed = (P, R) => { m.writeState("section-detail", canonDetail(), P, R); m.writeState("pipe-ids", { next: 2, ids: [{ id: "p1", at: 45 }] }, P); }; const pipeAt = (P, at) => m.writeState("pipe-ids", { next: 2, ids: [{ id: "p1", at }] }, P); const keyList = () => KEYS.map((key, i) => ({ key, chainageM: LIST[i][1] })); { const P = "p272a"; const R = 3; seed(P, R); await m.loadSectionDetail(P, R); m.useStationKeyList(P, R, keyList()); pipeAt(P, 55); const moved = await m.swapSectionStations(P, R, stationsAt(55)); pipeAt(P, 45); const back = await m.swapSectionStations(P, R, stationsAt(45)); out.storeNoDraft = { moved: moved && report(moved.detail), back: back && report(back.detail), backBytes: !!back && bytesSame(back.detail, [45]), }; } { const P = "p272b"; const R = 4; seed(P, R); await m.loadSectionDetail(P, R); m.useStationKeyList(P, R, keyList()); const shop = m.useDesignStore(P, null); shop.set("routeDraft", { nodes: [], rev: 1 }); pipeAt(P, 55); // 초안 = 곡선 둘레만 3 cm 옮김(나머지 자리 그대로) const bend = (c) => (c > 25 && c < 39 ? 0.03 : 0); const draft = await m.swapSectionStations(P, R, stationsAt(55, bend)); shop.set("routeDraft", null); // 초안 버림 → 저장 노선 측점(묶음 「no-saved」 길) pipeAt(P, 45); const dropped = await m.swapSectionStations(P, R, stationsAt(45)); out.storeDraft = { routeDraftSet: draft !== null, draft: draft && report(draft.detail), dropped: dropped && report(dropped.detail), }; } process.stdout.write(JSON.stringify(out));