도면 출력 추가: 수리 종단면도, 관망도(색인도·부분도·수압표), 범례·색상 선택, DXF(AutoCAD 2018)

- 데모 v2 이후 개발 PC의 변경을 옮김 (이 저장소의 이름 변경·README 정리는 유지)
- 서버 기본 DEM, DEM 등고선, 속성 테이블 범위 선택, WNTR 교차 검증 시험 포함
- 새 라이브러리: ezdxf(Python), maplibre-contour(npm)
- 변경 내역: docs/SPipe_패치내역_도면출력_2026-11-06.txt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SPipe EngineeringandClaude Opus 5.5 committed 2026-11-06 15:03:21 +09:00
1 parent 14f07181db
commit e3f386addf
33 files changed
+3454 -482

No files matched your search

+135
View File
@@ -845,3 +845,138 @@ test('RASTER-1: world file and extent corners', async () => {
assert.deepEqual(extentCorners(0, 0, 10, 5)[0], { x: 0, y: 5 });
assert.throws(() => extentCorners(5, 0, 1, 1));
});
test('DRAWING-1: profile/plan sheets from a model, nice scales, SVG and DXF writers', async () => {
const { buildProfileDrawing, labelledStations } = await import('../src/profileDrawing.ts');
const { buildNetworkDrawing } = await import('../src/networkDrawing.ts');
const { toSvg, toDxf, niceScale } = await import('../src/drawing.ts');
const { profileInput, planInput } = await import('../src/drawingData.ts');
const m = parseInp(`[JUNCTIONS]
J1 50 1 ;
J2 40 1 ;
[RESERVOIRS]
R1 100 ;
[PIPES]
P1 R1 J1 1000 300 120 0 Open ;
P2 J1 J2 500 200 120 0 Open ;
[COORDINATES]
R1 0 0
J1 1000 0
J2 1500 0
[OPTIONS]
Units LPS
[END]`);
const id = (v: number) => v;
const u = { head: id, length: id, diameter: id, flow: id, pressure: id, velocity: id };
const link = (flow: number, headloss: number) => ({ id: '', type: 1, fromNode: '', toNode: '', vertices: [], flow, velocity: 0.5, headloss, timeSeries: { flow: [flow, flow * 2], velocity: [0.5, 1], headloss: [headloss, headloss * 3], quality: [] } });
const node = (h: number, p: number) => ({ id: '', type: 0, coordinates: null, demand: 0, head: h, pressure: p, timeSeries: { head: [h, h - 5], pressure: [p, p - 5], demand: [], quality: [] } });
const res = { nodes: { R1: node(100, 0), J1: node(98, 48), J2: node(97, 57) }, links: { P1: link(10, 2), P2: link(5, 1) } } as unknown as SimulationResult;
const path = { nodeIds: ['R1', 'J1', 'J2'], linkIds: ['P1', 'P2'], linkLengths: [1000, 500] };
const d = profileInput(m, res, path, { elevationIs: 'invert', depth: 1.2, series: ['now', 'min'], timeIndex: 0 }, u, 'L/s');
assert.deepEqual(d.stations.map((s) => s.x), [0, 1000, 1500]);
assert.deepEqual([d.stations[1].ground, d.stations[1].invert], [51.2, 50]);
assert.equal(d.stations[0].ground, 100); // reservoir: water level, no burial depth
assert.deepEqual(d.stations[2].heads, [97, 92]);
assert.deepEqual(d.runs.map((r) => [r.diameter, r.gradient]), [[300, 2], [200, 2]]);
assert.deepEqual(d.marks, [{ x: 0, text: '배수지 R1' }]);
const info = { project: '시험', title: '수리 종단면도', number: 'W-1', client: '○○시', company: '(주)삼안', date: '2026.11' };
const prof = buildProfileDrawing({ sheet: 'A3', info, ...d });
assert.ok(prof.hScale >= 1500 * 1000 / 400 && prof.vScale > 0);
const svg = toSvg(prof.drawing);
assert.match(svg, /수리 종단면도/);
assert.match(svg, /D300mm\s+L=1,000\.0m/);
const dxf = toDxf(prof.drawing);
assert.ok(dxf.startsWith('0\r\nSECTION') && dxf.trimEnd().endsWith('EOF'));
assert.ok(dxf.includes('\\U+C218\\U+B9AC')); // 수리
assert.match(dxf, /\r\nHGL_2\r\n/);
assert.equal(niceScale(3080, 350), 10000);
// ends always; a priority station (run end) beats a plain one competing for the same space
assert.deepEqual(labelledStations([0, 4, 6, 10], [false, false, true, false], 3), [true, false, true, true]);
const plan = planInput(m, res, { mode: 'hydraulic', timeIndex: 1, minPressure: 50, maxPressure: 56, maxVelocity: 2 }, u);
// t=1: J1 43 m (< 50 red), J2 52 m (ok, blue)
const color = Object.fromEntries(plan.nodes.map((n) => [n.id, n.color]));
assert.deepEqual([color.J1, color.J2], ['#dc2626', '#2563eb']);
// t=1: P1 velocity 1.0 m/s -> 1.0~max band (amber)
assert.equal(plan.links.find((l) => l.id === 'P1')!.color, '#f59e0b');
const sheetPlan = buildNetworkDrawing({ sheet: 'A3', info, metric: true, ...plan, pipeLabels: 'DL', nodeLabels: 'sources' });
assert.ok(sheetPlan.scale >= 1500 * 1000 / 380);
assert.match(toSvg(sheetPlan.drawing), /D300 L=1,000m/);
// Equal-diameter pipes through a plain junction are labelled as one stretch.
const { pipeChains } = await import('../src/networkDrawing.ts');
const L = (id: string, a: [number, number], c: [number, number], d: number) => ({ id, kind: 'pipe' as const, pts: [a, c], diameter: d, length: Math.hypot(c[0] - a[0], c[1] - a[1]), color: '#000' });
const ch = pipeChains([L('a', [0, 0], [100, 0], 300), L('b', [100, 0], [250, 0], 300), L('c', [250, 0], [300, 0], 200)]);
assert.deepEqual(ch.map((c) => [c.links.map((l) => l.id).sort().join(','), c.length]), [['a,b', 250], ['c', 50]]);
// A reservoir/tank point stops the chain.
assert.equal(pipeChains([L('a', [0, 0], [100, 0], 300), L('b', [100, 0], [250, 0], 300)], new Set(['100.000,0.000'])).length, 2);
// Pressures below the design minimum are drawn red; reservoir gets ▽ H.W.L.
const red = buildProfileDrawing({ sheet: 'A3', info, ...d, minPressure: 50 });
const redTexts = red.drawing.items.filter((it) => it.kind === 'text' && it.color === '#dc2626').map((it) => (it as { text: string }).text);
assert.ok(redTexts.includes('48.00') && !redTexts.includes('57.00'));
assert.match(toSvg(red.drawing), /H\.W\.L 100\.00/);
assert.equal(d.stations[0].kind, 'reservoir');
});
test('DRAWING-2: split plan sheets (색인도 + 부분도), clipping, pressure tags, font', async () => {
const { buildNetworkDrawing, planLayout, tileNeighbours, clipPolyline } = await import('../src/networkDrawing.ts');
const { toSvg, mergeDrawings } = await import('../src/drawing.ts');
// clip: a line crossing the box keeps only the inside part
assert.deepEqual(clipPolyline([[-5, 5], [15, 5]], { x0: 0, y0: 0, x1: 10, y1: 10 }), [[[0, 5], [10, 5]]]);
assert.deepEqual(clipPolyline([[-5, -5], [-1, -1]], { x0: 0, y0: 0, x1: 10, y1: 10 }), []);
// A 6 km straight main with junctions every 100 m.
const nodes = Array.from({ length: 61 }, (_, i) => ({ id: `J${i}`, type: 'JUNCTION' as const, x: 200000 + i * 100, y: 500000, color: '#334155', head: 120 - i * 0.1, elevation: 60, pressure: 60 - i * 0.1, demand: i % 2 ? 0.5 : 0 }));
const links = nodes.slice(1).map((n, i) => ({ id: `P${i + 1}`, kind: 'pipe' as const, pts: [[nodes[i].x, nodes[i].y], [n.x, n.y]] as Array<[number, number]>, diameter: 200, length: 100, color: '#1d4ed8' }));
const info = { project: '시험', title: '관망도', number: 'W', client: '', company: '', date: '' };
const lay = planLayout({ sheet: 'A3', info, links, nodes }, { scale: 2000 });
// map window ~ 375 mm wide at 1:2000 = ~750 m: 6 km needs about 8-9 sheets, numbered left to right
assert.ok(lay.tiles.length >= 8 && lay.tiles.length <= 10, String(lay.tiles.length));
assert.deepEqual(lay.tiles.map((t) => t.no), lay.tiles.map((_, i) => i + 1));
assert.ok(lay.tiles.every((t, i) => i === 0 || t.minX > lay.tiles[i - 1].minX));
// every node is inside some sheet
assert.ok(nodes.every((n) => lay.tiles.some((t) => n.x >= t.minX && n.x <= t.maxX && n.y >= t.minY && n.y <= t.maxY)));
assert.deepEqual(tileNeighbours(lay.tiles, lay.tiles[0]).map((n) => [n.no, n.dx, n.dy]), [[2, 1, 0]]);
// regular grid: equal steps, neighbours overlap by the same strip
const stepX = lay.tiles[1].minX - lay.tiles[0].minX;
assert.ok(lay.tiles.every((t, i) => i === 0 || Math.abs(t.minX - lay.tiles[i - 1].minX - stepX) < 1e-6));
assert.ok(lay.tiles[0].maxX > lay.tiles[1].minX);
assert.ok(lay.tiles.every((t) => t.minY === lay.tiles[0].minY));
// grid names: one row -> A-1, A-2, ...
const g = planLayout({ sheet: 'A3', info, links, nodes }, { scale: 2000, naming: 'grid' });
assert.deepEqual(g.tiles.slice(0, 2).map((t) => t.name), ['A-1', 'A-2']);
// an L-shaped network: two rows, numbered from the north-west, corner neighbour reported
const lnodes = [...nodes.slice(0, 20), ...Array.from({ length: 15 }, (_, i) => ({ ...nodes[0], id: `K${i}`, x: 200000, y: 500000 - (i + 1) * 100 }))];
const llinks = [...links.slice(0, 19), ...lnodes.slice(20).map((n, i) => ({ ...links[0], id: `Q${i}`, pts: [[i ? lnodes[19 + i].x : 200000, i ? lnodes[19 + i].y : 500000], [n.x, n.y]] as Array<[number, number]> }))];
const L = planLayout({ sheet: 'A3', info, links: llinks, nodes: lnodes }, { scale: 2000, naming: 'grid' });
assert.equal(L.tiles[0].name, 'A-1');
assert.ok(L.tiles.some((t) => t.name.startsWith('B-')));
const a1 = L.tiles[0];
assert.ok(tileNeighbours(L.tiles, a1).some((n) => n.dy === -1));
const base = { sheet: 'A3' as const, info, metric: true, links, nodes, legend: [], legendTitle: '범 례', pipeLabels: 'DL' as const, nodeLabels: 'none' as const };
const t = lay.tiles[1];
const det = buildNetworkDrawing({ ...base, view: t, scale: lay.scale, sheetLabel: `(2/${lay.tiles.length})`, pressureTags: 'kgf', keyMap: { lines: links.map((l) => l.pts), tiles: lay.tiles, current: 2 }, neighbours: tileNeighbours(lay.tiles, t) });
const svg = toSvg(det.drawing);
assert.match(svg, /관망도 \(2\/\d+\)/);
assert.match(svg, /kg\/cm²/);
assert.match(svg, /위 치 도/);
assert.match(svg, /◀ 도면 1 연결/);
assert.match(svg, /▶ 도면 3 연결/);
assert.match(svg, /HY울릉도M/); // default font
// nothing drawn outside the map frame on the paper sheet
for (const it of det.drawing.items) if (it.kind === 'line' && it.layer === 'PIPE') assert.ok(it.pts.every(([x, y]) => x >= 0 && x <= 420 && y >= 0 && y <= 297));
// pressure 60 m -> 6.00 kg/cm²; head / elevation in the circle
assert.match(svg, /5\.\d\d kg\/cm²/);
// legend styles: table (default) has the ruled group cell, list has headings, none has no legend
const legBase = { ...base, legend: [{ label: '유속 0.3m/s 미만', color: '#123456', shape: 'line' as const }], legendTitle: '범 례 (수리 판정)' };
const tbl = toSvg(buildNetworkDrawing({ ...legBase, legendBand: '#d6e4f0' }).drawing);
assert.match(tbl, /#d6e4f0/);
assert.match(tbl, />유</);
assert.match(tbl, /#123456/);
const lst = toSvg(buildNetworkDrawing({ ...legBase, legendStyle: 'list' }).drawing);
assert.match(lst, />유속</);
const none = toSvg(buildNetworkDrawing({ ...legBase, legendStyle: 'none' }).drawing);
assert.doesNotMatch(none, /유속 0\.3m\/s 미만/);
const both = mergeDrawings([det.drawing, det.drawing]);
assert.equal(both.width, 420 * 2 + 30);
});