SPipe 데모 v2 (2026-10-30) 서버 배포본
- SPipe_server_2026-10-30.zip 내용을 풀어서 등록 (압축 파일은 올리지 않음) - README에 데모 v2 서버 업데이트 순서 추가, 저장소에 없는 문서 목차 정리 - 패치내역을 docs/로 이동, _worklog.md 추가 - 임시 파일(backend/temp.*)과 API 키가 든 파일은 제외
This commit is contained in:
commit
21ebb16fb7
181 files changed
+57207
No files matched your search
@@ -0,0 +1,288 @@
|
||||
// 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<T>(rec: Record<string, T>, id: string): Record<string, T> {
|
||||
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 = <T extends { id: string; node1: string; node2: string }>(rec: Record<string, T>): Record<string, T> => {
|
||||
const out: Record<string, T> = {};
|
||||
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<string, string[]>();
|
||||
const used = new Set<string>();
|
||||
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<string, Array<{ link: string; to: string }>>();
|
||||
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<string>();
|
||||
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] };
|
||||
}
|
||||
Reference in new issue
Block a user