// Geometry / topology editing tools (편집 도구): transform, equal-interval and // connection split, node dissolve / merge, pipe auto-check and flow tracing. // Pure functions returning a new model; the UI applies them with undo. import type { NetworkModel, VertexPoint } from './inpModel'; import { deletePipe, splitPipe } from './inpModel.ts'; import { getDemandCategories, setDemandCategories } from './extrasActions.ts'; import type { SimulationResult } from './types'; // ---------------------------------------------------------------- transform export interface Transform { dx?: number; dy?: number; /** Degrees, counter-clockwise, about `origin`. */ rotate?: number; scale?: number; origin?: VertexPoint; } /** Similarity transform (scale + rotation + shift) mapping a1->b1 and a2->b2. */ export function similarityFrom2Points(a1: VertexPoint, a2: VertexPoint, b1: VertexPoint, b2: VertexPoint): (p: VertexPoint) => VertexPoint { const ax = a2.x - a1.x, ay = a2.y - a1.y; const bx = b2.x - b1.x, by = b2.y - b1.y; const den = ax * ax + ay * ay; if (den === 0) throw new Error('기준점 두 개가 같은 위치입니다.'); // b = s*R*a: complex division (bx + i by) / (ax + i ay) const c = (bx * ax + by * ay) / den; const s = (by * ax - bx * ay) / den; return (p) => ({ x: b1.x + c * (p.x - a1.x) - s * (p.y - a1.y), y: b1.y + s * (p.x - a1.x) + c * (p.y - a1.y) }); } export function transformFn(t: Transform): (p: VertexPoint) => VertexPoint { const o = t.origin ?? { x: 0, y: 0 }; const k = t.scale ?? 1; const a = ((t.rotate ?? 0) * Math.PI) / 180; const cos = Math.cos(a), sin = Math.sin(a); return (p) => { const x = (p.x - o.x) * k, y = (p.y - o.y) * k; return { x: o.x + x * cos - y * sin + (t.dx ?? 0), y: o.y + x * sin + y * cos + (t.dy ?? 0) }; }; } const round = (v: number) => Math.round(v * 1e6) / 1e6; /** * Apply `fn` to the given nodes (default all) and to the vertices of links * whose both ends move. Lengths are not changed (use 관로길이 재계산 after). */ export function transformNetwork(model: NetworkModel, fn: (p: VertexPoint) => VertexPoint, nodeIds?: string[]): { model: NetworkModel; moved: number } { const ids = new Set(nodeIds ?? Object.keys(model.nodes)); const nodes = { ...model.nodes }; for (const id of ids) { const n = nodes[id]; if (!n) continue; const q = fn(n); nodes[id] = { ...n, x: round(q.x), y: round(q.y) }; } let vertices = model.vertices; if (vertices) { const links = [...Object.values(model.pipes), ...Object.values(model.pumps), ...Object.values(model.valves ?? {})]; const next = { ...vertices }; for (const l of links) { if (next[l.id] && ids.has(l.node1) && ids.has(l.node2)) next[l.id] = next[l.id].map((v) => { const q = fn(v); return { x: round(q.x), y: round(q.y) }; }); } vertices = next; } const labels = model.extras?.labels; const extras = labels?.length && !nodeIds ? { ...model.extras!, labels: labels.map((l) => { const q = fn(l); return { ...l, x: round(q.x), y: round(q.y) }; }) } : model.extras; return { model: { ...model, nodes, vertices, extras }, moved: [...ids].filter((id) => model.nodes[id]).length }; } export function centroid(model: NetworkModel, nodeIds?: string[]): VertexPoint { const pts = (nodeIds ?? Object.keys(model.nodes)).map((id) => model.nodes[id]).filter(Boolean); if (!pts.length) return { x: 0, y: 0 }; return { x: pts.reduce((a, p) => a + p.x, 0) / pts.length, y: pts.reduce((a, p) => a + p.y, 0) / pts.length }; } // ---------------------------------------------------------------- splitting /** Split a pipe into `parts` equal-length pieces (n-1 new junctions). */ export function splitPipeEqual(model: NetworkModel, pipeId: string, parts: number): { model: NetworkModel; junctions: string[]; pipes: string[] } { const n = Math.floor(parts); if (!(n >= 2 && n <= 200)) throw new Error('2~200 사이로 나누세요.'); let m = model; let current = pipeId; const junctions: string[] = []; const pipes = [pipeId]; for (let k = n; k >= 2; k--) { const r = splitPipe(m, current, 1 / k); m = r.model; junctions.push(r.newJunctionId); pipes.push(r.newPipeId); current = r.newPipeId; } return { model: m, junctions, pipes }; } /** Split a pipe into pieces no longer than `maxLength` (model length units). */ export function splitPipeByLength(model: NetworkModel, pipeId: string, maxLength: number) { const pipe = model.pipes[pipeId]; if (!pipe) throw new Error(`관로 ${pipeId}가 없습니다.`); if (!(maxLength > 0)) throw new Error('간격은 0보다 커야 합니다.'); const parts = Math.ceil(pipe.length / maxLength - 1e-9); return parts >= 2 ? splitPipeEqual(model, pipeId, parts) : { model, junctions: [], pipes: [pipeId] }; } /** * 접속 분할: split `pipeId` at the point nearest to `nodeId` and connect the * node there (the split junction is merged into the node). */ export function connectNodeToPipe(model: NetworkModel, nodeId: string, pipeId: string): { model: NetworkModel; newPipeId: string } { const node = model.nodes[nodeId]; const pipe = model.pipes[pipeId]; if (!node || !pipe) throw new Error('절점 또는 관로가 없습니다.'); if (pipe.node1 === nodeId || pipe.node2 === nodeId) throw new Error('이미 이 관로의 끝 절점입니다.'); const r = splitPipe(model, pipeId, 0.5, { x: node.x, y: node.y }); return { model: mergeNodes(r.model, nodeId, r.newJunctionId).model, newPipeId: r.newPipeId }; } // ---------------------------------------------------------------- dissolve / merge /** * 절점 제거: a junction joining exactly two pipes is removed and the pipes are * joined into one (lengths and minor losses add, the node becomes a vertex). * Refused when the node carries demand/emitter/quality data or the pipes differ * in diameter/roughness (that would change the hydraulics). */ export function dissolveNode(model: NetworkModel, nodeId: string): NetworkModel { const node = model.nodes[nodeId]; if (!node || node.type !== 'JUNCTION') throw new Error(`${nodeId}는 접합점이 아닙니다.`); const links = [...Object.values(model.pumps), ...Object.values(model.valves ?? {})].filter((l) => l.node1 === nodeId || l.node2 === nodeId); if (links.length) throw new Error('펌프·밸브가 연결된 절점은 제거할 수 없습니다.'); const pipes = Object.values(model.pipes).filter((p) => p.node1 === nodeId || p.node2 === nodeId); if (pipes.length !== 2) throw new Error(`관로 2개가 만나는 절점만 제거할 수 있습니다 (현재 ${pipes.length}개).`); const [a, b] = pipes; if (getDemandCategories(model, nodeId).some((c) => c.demand !== 0) || model.extras?.emitters[nodeId] || model.extras?.sources[nodeId]) { throw new Error('수요·이미터·수원 수질이 있는 절점은 제거할 수 없습니다. 먼저 옮기거나 지우세요.'); } if (a.diameter !== b.diameter || a.roughness !== b.roughness) throw new Error('양쪽 관로의 관경·조도가 달라 합칠 수 없습니다.'); if (model.controls.some((c) => c.nodeId === nodeId)) throw new Error('컨트롤이 이 절점을 참조합니다.'); // Orient: a runs other->node, b runs node->other. const va = model.vertices?.[a.id] ?? []; const vb = model.vertices?.[b.id] ?? []; const aStart = a.node1 === nodeId ? a.node2 : a.node1; const aVerts = a.node2 === nodeId ? va : [...va].reverse(); const bEnd = b.node1 === nodeId ? b.node2 : b.node1; const bVerts = b.node1 === nodeId ? vb : [...vb].reverse(); if (aStart === bEnd) throw new Error('두 관로가 같은 절점을 잇고 있어 합치면 자기 자신으로 돌아오는 관로가 됩니다.'); let m = deletePipe(model, b.id); m = { ...m, pipes: { ...m.pipes, [a.id]: { ...a, node1: aStart, node2: bEnd, length: Math.round((a.length + b.length) * 1e4) / 1e4, minorLoss: Math.round((a.minorLoss + b.minorLoss) * 1e4) / 1e4 } }, vertices: { ...(m.vertices ?? {}), [a.id]: [...aVerts, { x: node.x, y: node.y }, ...bVerts] }, }; const nodes = { ...m.nodes }; delete nodes[nodeId]; return { ...m, nodes, extras: m.extras ? { ...m.extras, quality: omit(m.extras.quality, nodeId), demands: omit(m.extras.demands, nodeId) } : m.extras }; } function omit(rec: Record, id: string): Record { if (!(id in rec)) return rec; const out = { ...rec }; delete out[id]; return out; } /** * 절점 병합: reconnect every link of `dropId` to `keepId`, move its demand * categories over, delete links that would join keep to itself, then delete it. */ export function mergeNodes(model: NetworkModel, keepId: string, dropId: string): { model: NetworkModel; removedLinks: string[] } { if (keepId === dropId) throw new Error('같은 절점입니다.'); const keep = model.nodes[keepId]; const drop = model.nodes[dropId]; if (!keep || !drop) throw new Error('절점이 없습니다.'); if (drop.type !== 'JUNCTION') throw new Error(`${dropId}는 ${drop.type === 'TANK' ? '탱크' : '배수지'}입니다. 남길 절점으로 지정하세요.`); if (model.controls.some((c) => c.nodeId === dropId) || model.rules.some((r) => r.split(/\s+/).includes(dropId))) { throw new Error(`컨트롤·규칙이 ${dropId}를 참조합니다. 먼저 고치세요.`); } const removed: string[] = []; const relink = (rec: Record): Record => { const out: Record = {}; for (const [id, l] of Object.entries(rec)) { const n1 = l.node1 === dropId ? keepId : l.node1; const n2 = l.node2 === dropId ? keepId : l.node2; if (n1 === n2 && (l.node1 === dropId || l.node2 === dropId)) { removed.push(id); continue; } out[id] = n1 === l.node1 && n2 === l.node2 ? l : { ...l, node1: n1, node2: n2 }; } return out; }; let m: NetworkModel = { ...model, pipes: relink(model.pipes), pumps: relink(model.pumps), valves: relink(model.valves ?? {}) }; if (keep.type === 'JUNCTION' && drop.type === 'JUNCTION') { const cats = [...getDemandCategories(m, keepId), ...getDemandCategories(m, dropId)].filter((c) => c.demand !== 0 || c.category); m = setDemandCategories(m, keepId, cats.length ? cats : [{ demand: 0 }]); } m = { ...m, controls: m.controls.filter((c) => !removed.includes(c.linkId)), vertices: m.vertices ? Object.fromEntries(Object.entries(m.vertices).filter(([id]) => !removed.includes(id))) : m.vertices, }; const nodes = { ...m.nodes }; delete nodes[dropId]; m = { ...m, nodes }; if (m.extras) m = { ...m, extras: { ...m.extras, demands: omit(m.extras.demands, dropId), emitters: omit(m.extras.emitters, dropId), quality: omit(m.extras.quality, dropId), sources: omit(m.extras.sources, dropId) } }; if (m.assets?.nodes[dropId]) m = { ...m, assets: { ...m.assets, nodes: omit(m.assets.nodes, dropId) } }; return { model: m, removedLinks: removed }; } // ---------------------------------------------------------------- auto check export interface PipeIssues { selfLoops: string[]; nonPositiveLength: string[]; zeroDiameter: string[]; /** Groups of pipes joining the same node pair. */ parallel: string[][]; /** Junctions without any link. */ orphanNodes: string[]; /** Pipes whose end nodes are missing. */ dangling: string[]; } export function findPipeIssues(model: NetworkModel): PipeIssues { const out: PipeIssues = { selfLoops: [], nonPositiveLength: [], zeroDiameter: [], parallel: [], orphanNodes: [], dangling: [] }; const pairs = new Map(); const used = new Set(); for (const l of [...Object.values(model.pumps), ...Object.values(model.valves ?? {})]) used.add(l.node1).add(l.node2); for (const p of Object.values(model.pipes)) { used.add(p.node1).add(p.node2); if (!model.nodes[p.node1] || !model.nodes[p.node2]) out.dangling.push(p.id); if (p.node1 === p.node2) { out.selfLoops.push(p.id); continue; } if (!(p.length > 0)) out.nonPositiveLength.push(p.id); if (!(p.diameter > 0)) out.zeroDiameter.push(p.id); const key = [p.node1, p.node2].sort().join('\u0000'); pairs.set(key, [...(pairs.get(key) ?? []), p.id]); } out.parallel = [...pairs.values()].filter((g) => g.length > 1); out.orphanNodes = Object.values(model.nodes).filter((n) => n.type === 'JUNCTION' && !used.has(n.id)).map((n) => n.id); return out; } /** Safe automatic fixes: drop self loops, dangling pipes and orphan junctions without demand. */ export function autoFixPipes(model: NetworkModel): { model: NetworkModel; removedPipes: string[]; removedNodes: string[] } { const issues = findPipeIssues(model); let m = model; const removedPipes = [...new Set([...issues.selfLoops, ...issues.dangling])]; for (const id of removedPipes) m = deletePipe(m, id); const removedNodes = issues.orphanNodes.filter((id) => getDemandCategories(m, id).every((c) => c.demand === 0) && !m.extras?.emitters[id] && !m.controls.some((c) => c.nodeId === id)); if (removedNodes.length) { const nodes = { ...m.nodes }; for (const id of removedNodes) delete nodes[id]; m = { ...m, nodes }; } return { model: m, removedPipes, removedNodes }; } // ---------------------------------------------------------------- tracing /** * 상류/하류 추적 along the mean flow direction of a result (links with no flow * stop the trace). Returns the reached nodes and links, start node included. */ export function traceFlow(model: NetworkModel, result: SimulationResult, startId: string, dir: 'up' | 'down'): { nodes: string[]; links: string[] } { const adj = new Map>(); const links = [...Object.values(model.pipes), ...Object.values(model.pumps), ...Object.values(model.valves ?? {})]; for (const l of links) { const r = result.links[l.id]; if (!r) continue; const s = r.timeSeries?.flow?.length ? r.timeSeries.flow : [r.flow]; const mean = s.reduce((a, b) => a + b, 0) / s.length; const eps = 1e-6 * Math.max(1, ...s.map(Math.abs)); if (Math.abs(mean) <= eps) continue; const [from, to] = mean > 0 ? [l.node1, l.node2] : [l.node2, l.node1]; const [a, b] = dir === 'down' ? [from, to] : [to, from]; adj.set(a, [...(adj.get(a) ?? []), { link: l.id, to: b }]); } const seen = new Set([startId]); const seenLinks = new Set(); const queue = [startId]; while (queue.length) { const cur = queue.shift()!; for (const e of adj.get(cur) ?? []) { seenLinks.add(e.link); if (!seen.has(e.to)) { seen.add(e.to); queue.push(e.to); } } } return { nodes: [...seen], links: [...seenLinks] }; }