// Whole-network editing tools (관망 도구): pipe length recalculation from // geometry and elevation filling from the nearest node. Pure functions; the // UI wraps them with undo. import type { NetworkModel, InpNode } from './inpModel'; import { calculateGeoAwareDistance, type CRS } from './crs.ts'; import { getUnitSystem } from './unitSystem.ts'; export interface Point { x: number; y: number; } /** Link ID -> intermediate vertices, read from the raw [VERTICES] section. */ export function parseVertices(model: NetworkModel): Record { if (model.vertices) return model.vertices; const out: Record = {}; const r = model.sectionIndices['VERTICES']; if (!r) return out; for (let i = r.start + 1; i <= r.end; i++) { const body = model.rawLines[i]?.split(';')[0].trim(); if (!body) continue; const [id, xs, ys] = body.split(/\s+/); const x = Number(xs); const y = Number(ys); if (id && Number.isFinite(x) && Number.isFinite(y)) (out[id] ??= []).push({ x, y }); } return out; } /** Polyline length (node1 -> vertices -> node2) in the model's length units. */ export function linkGeometryLength(model: NetworkModel, node1: string, node2: string, vertices: Point[], crs: CRS): { length: number; isPhysical: boolean } | null { const a = model.nodes[node1]; const b = model.nodes[node2]; if (!a || !b) return null; const system = getUnitSystem(model.options.units); const pts: Point[] = [a, ...vertices, b]; let total = 0; let isPhysical = true; for (let i = 1; i < pts.length; i++) { const d = calculateGeoAwareDistance(pts[i - 1], pts[i], crs, system); total += d.distance; isPhysical = isPhysical && d.isPhysical; } return { length: total, isPhysical }; } export interface LengthChange { id: string; before: number; after: number; } /** * Recalculate pipe lengths from coordinates (and [VERTICES]); `ids` limits the * set. Lengths are rounded to 0.01 and a zero-length result keeps the old value. */ export function recalcPipeLengths(model: NetworkModel, crs: CRS, ids?: Iterable): { model: NetworkModel; changes: LengthChange[]; isPhysical: boolean } { const vertices = parseVertices(model); const target = ids ? new Set(ids) : null; const pipes = { ...model.pipes }; const changes: LengthChange[] = []; let isPhysical = true; for (const p of Object.values(model.pipes)) { if (target && !target.has(p.id)) continue; const g = linkGeometryLength(model, p.node1, p.node2, vertices[p.id] ?? [], crs); if (!g) continue; isPhysical = isPhysical && g.isPhysical; const after = Math.round(g.length * 100) / 100; if (after > 0 && Math.abs(after - p.length) >= 0.005) { pipes[p.id] = { ...p, length: after }; changes.push({ id: p.id, before: p.length, after }); } } return { model: changes.length ? { ...model, pipes } : model, changes, isPhysical }; } /** Pipes touching a node (for re-measuring after the node moved). */ export function pipesAtNode(model: NetworkModel, nodeId: string): string[] { return Object.values(model.pipes) .filter((p) => p.node1 === nodeId || p.node2 === nodeId) .map((p) => p.id); } /** Elevation of the nearest other node (by planar coordinate distance), or null. */ export function nearestNodeElevation(model: NetworkModel, x: number, y: number, exclude?: Set, onlyNonZero = false): number | null { let best: InpNode | null = null; let bestD = Infinity; for (const n of Object.values(model.nodes)) { if (exclude?.has(n.id)) continue; if (onlyNonZero && !n.elevation) continue; const d = (n.x - x) ** 2 + (n.y - y) ** 2; if (d < bestD) { bestD = d; best = n; } } return best ? best.elevation : null; } /** Junctions/tanks with elevation 0 take the nearest non-zero node's elevation. */ export function fillZeroElevations(model: NetworkModel): { model: NetworkModel; filled: string[] } { const zero = Object.values(model.nodes).filter((n) => n.type !== 'RESERVOIR' && !n.elevation); if (!zero.length) return { model, filled: [] }; const nodes = { ...model.nodes }; const filled: string[] = []; const zeroIds = new Set(zero.map((n) => n.id)); for (const n of zero) { const e = nearestNodeElevation(model, n.x, n.y, zeroIds, true); if (e !== null) { nodes[n.id] = { ...n, elevation: e }; filled.push(n.id); } } return { model: filled.length ? { ...model, nodes } : model, filled }; } // ---- edit options (persisted per browser) ---- export interface EditOptions { /** Re-measure connected pipe lengths when a node moves (new pipes are always measured). */ autoLength: boolean; /** New nodes take the nearest existing node's elevation instead of 0. */ nearestElevation: boolean; } const EDIT_OPTIONS_KEY = 'spipe_edit_options'; export const DEFAULT_EDIT_OPTIONS: EditOptions = { autoLength: true, nearestElevation: true }; export function loadEditOptions(): EditOptions { try { return { ...DEFAULT_EDIT_OPTIONS, ...JSON.parse(localStorage.getItem(EDIT_OPTIONS_KEY) || '{}') }; } catch { return DEFAULT_EDIT_OPTIONS; } } export function saveEditOptions(o: EditOptions): void { try { localStorage.setItem(EDIT_OPTIONS_KEY, JSON.stringify(o)); } catch { // storage unavailable: options just won't persist } }