Files
SPipe/src/profileDrawing.ts
T
SPipe EngineeringandClaude Opus 5.5 e3f386addf 도면 출력 추가: 수리 종단면도, 관망도(색인도·부분도·수압표), 범례·색상 선택, 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>
2026-11-06 15:03:21 +09:00

377 lines
20 KiB
TypeScript

// 수리 종단면도 (hydraulic profile sheet) in the style of a Korean design drawing
// (e.g. 국사봉배수지 C1-003, 마전배수지 동수두도): ground / pipe / hydraulic grade
// lines over a station table (측점·절점번호·거리·추가거리·지반고·관저고·동수두·수압)
// with pipe runs (D, L) in the bottom row, facility symbols (배수지 ▽H.W.L,
// 감압밸브, 가압펌프) and pressures below the design minimum in red.
import { DrawingBuilder, legendTable, niceScale, panelHeader, sheetFrame, sheetHeading, SHEETS, type Drawing, type LegendStyle, type TitleInfo } from './drawing.ts';
export interface ProfileStation {
id: string;
/** Cumulative distance from the start (m). */
x: number;
ground: number;
invert: number;
/** Hydraulic head per series, same order as `series`. */
heads: number[];
pressure?: number;
kind?: 'reservoir' | 'tank' | 'junction';
/** Water levels for reservoirs / tanks (m). */
hwl?: number;
lwl?: number;
}
export interface ProfileRun {
fromX: number;
toX: number;
diameter: number; // mm
length: number; // m
roughness?: number;
flow?: number;
flowUnit?: string;
/** Hydraulic gradient (‰) along the run. */
gradient?: number;
}
export interface ProfileSeries {
name: string;
color: string;
dash?: 'dashed' | 'dotted';
}
/** Valves and pumps on the path (zero-length links between two stations). */
export interface ProfileFitting {
x: number;
kind: 'valve' | 'pump';
subtype?: string;
label: string;
}
export interface ProfileSheetInput {
sheet: keyof typeof SHEETS;
info: Omit<TitleInfo, 'scale'>;
stations: ProfileStation[];
runs: ProfileRun[];
series: ProfileSeries[];
/** Optional named points above the HGL (e.g. reservoirs). */
marks?: Array<{ x: number; text: string }>;
fittings?: ProfileFitting[];
/** Design minimum pressure (m): lower values are drawn red. */
minPressure?: number;
font?: string;
legendStyle?: LegendStyle;
legendBand?: string;
}
const fx = (v: number, d = 2) => (Number.isFinite(v) ? v.toFixed(d) : '-');
const fxc = (v: number, d = 2) => (Number.isFinite(v) ? v.toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d }) : '-');
const RED = '#dc2626';
function niceStep(range: number, target = 10): number {
const raw = range / target;
const mag = Math.pow(10, Math.floor(Math.log10(raw)));
const n = raw / mag;
return (n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10) * mag;
}
/** Stations whose labels fit side by side (first, last and run ends win). */
export function labelledStations(xs: number[], priority: boolean[], minGap: number): boolean[] {
const keep = xs.map(() => false);
const taken: number[] = [];
const fits = (x: number) => taken.every((t) => Math.abs(t - x) >= minGap);
const order = xs.map((x, i) => i).sort((a, b) => Number(priority[b]) - Number(priority[a]) || a - b);
for (const i of [0, xs.length - 1, ...order]) {
if (keep[i] || !fits(xs[i])) continue;
keep[i] = true;
taken.push(xs[i]);
}
return keep;
}
/** Linear interpolation of a station value at distance x. */
function at(st: ProfileStation[], x: number, f: (s: ProfileStation) => number): number {
for (let i = 0; i < st.length - 1; i++) {
const a = st[i], c = st[i + 1];
if (x >= a.x && x <= c.x) {
const t = c.x > a.x ? (x - a.x) / (c.x - a.x) : 0;
return f(a) + (f(c) - f(a)) * t;
}
}
return f(st[x <= st[0].x ? 0 : st.length - 1]);
}
export function buildProfileDrawing(input: ProfileSheetInput): { drawing: Drawing; hScale: number; vScale: number } {
const sheet = SHEETS[input.sheet];
const st = input.stations;
if (st.length < 2) throw new Error('종단 경로에 절점이 2개 이상 필요합니다.');
const b = new DrawingBuilder(sheet.w, sheet.h, input.info.title);
const k = sheet.w / 420; // text/row sizes grow with the sheet
const total = st[st.length - 1].x - st[0].x;
const zs = st.flatMap((s) => [s.ground, s.invert, ...s.heads, s.hwl ?? NaN]).filter(Number.isFinite);
const zMin = Math.min(...zs), zMax = Math.max(...zs);
// Frame first so the scales use the real space.
const tmp = new DrawingBuilder(sheet.w, sheet.h, '');
const area = sheetFrame(tmp, { ...input.info, scale: '' });
const headW = 25 * k;
const rows: Array<{ label: string; h: number }> = [
{ label: '측 점', h: 8 },
{ label: '절 점 번 호', h: 11 },
{ label: '거 리 (m)', h: 10 },
{ label: '추가거리 (m)', h: 11 },
{ label: '지 반 고 (m)', h: 10 },
{ label: '관 저 고 (m)', h: 10 },
{ label: '동 수 두 (m)', h: 10 },
{ label: '수 압 (m)', h: 9 },
{ label: '관경 · 연장', h: 7 },
].map((r) => ({ ...r, h: r.h * k }));
const tableH = rows.reduce((a, r) => a + r.h, 0);
const gx0 = area.x0 + headW + 5 * k, gx1 = area.x1 - 14 * k;
const ty0 = area.y0, ty1 = ty0 + tableH;
const headingY = area.y1 - 16 * k;
const gy0 = ty1 + 6 * k, gy1 = headingY - 4 * k;
const hScale = niceScale(Math.max(total, 1), gx1 - gx0);
// Headroom above the highest line for facility labels.
const vScale = niceScale(Math.max(zMax - zMin, 1) * 1.35, gy1 - gy0, [10, 20, 25, 50, 100, 200, 250, 300, 500, 700, 1000, 2000, 2500, 5000, 10000]);
const tick = niceStep((gy1 - gy0) * vScale / 1000, 10);
// Do not drop below 0 m when everything is above sea level (wasted space).
const zBase0 = Math.floor((zMin - 0.05 * (zMax - zMin)) / tick) * tick;
const zBase = zMin >= 0 ? Math.max(0, zBase0) : zBase0;
const X = (x: number) => gx0 + ((x - st[0].x) * 1000) / hScale;
const Y = (z: number) => gy0 + ((z - zBase) * 1000) / vScale;
const zTop = zBase + ((gy1 - gy0) * vScale) / 1000;
const scaleTxt = `V = 1:${vScale.toLocaleString()} H = 1:${hScale.toLocaleString()}`;
sheetFrame(b, { ...input.info, scale: `V=1:${vScale.toLocaleString()} H=1:${hScale.toLocaleString()}` });
sheetHeading(b, area, input.info.title, scaleTxt);
// Elevation axes (both sides) + horizontal grid + datum.
for (const [ax, dir] of [[gx0, -1], [gx1, 1]] as const) {
b.line('AXIS', [[ax, gy0], [ax, gy1]], { width: 0.35 });
for (let z = zBase; z <= zTop + 1e-9; z += tick) {
const y = Y(z);
b.line('AXIS', [[ax, y], [ax + dir * 1.5 * k, y]], { width: 0.25 });
b.text('AXIS', ax + dir * 2.5 * k, y, fx(z, tick < 1 ? 1 : 0), 2 * k, { anchor: dir < 0 ? 'end' : 'start', vAnchor: 'middle' });
}
b.text('AXIS', ax + dir * 2.5 * k, gy1 + 3 * k, 'EL.(m)', 2 * k, { anchor: dir < 0 ? 'end' : 'start' });
}
for (let z = zBase + tick; z < zTop - 1e-9; z += tick) b.line('GRID', [[gx0, Y(z)], [gx1, Y(z)]], { width: 0.08, color: '#cbd5e1' });
b.line('AXIS', [[gx0, gy0], [gx1, gy0]], { width: 0.35 });
b.text('AXIS', gx0 + 1.5 * k, gy0 + 1.2 * k, `D.L = ${fx(zBase, tick < 1 ? 1 : 2)} m`, 2 * k, { bold: true });
// Station table.
// Priority stations: ends, facilities, diameter changes, fittings.
const dChange = (x: number) => {
const into = input.runs.find((r) => Math.abs(r.toX - x) < 1e-6), out = input.runs.find((r) => Math.abs(r.fromX - x) < 1e-6);
return !!into && !!out && into.diameter !== out.diameter;
};
const nearFitting = (x: number) => (input.fittings ?? []).some((f) => Math.abs(f.x - x) < 1e-3 + total * 1e-4);
const isRunEnd = st.map((s, i) => i === 0 || i === st.length - 1 || (s.kind !== undefined && s.kind !== 'junction') || dChange(s.x) || nearFitting(s.x));
const show = labelledStations(st.map((s) => X(s.x)), isRunEnd, 3.2 * k);
const rowTop: number[] = [];
{
let y = ty1;
for (const r of rows) { rowTop.push(y); y -= r.h; }
}
b.rect('TABLE', area.x0, ty0, gx1 - area.x0, tableH, { width: 0.45 });
rows.forEach((r, i) => {
if (i > 0) b.line('TABLE', [[area.x0, rowTop[i]], [gx1, rowTop[i]]], { width: 0.15 });
b.text('TABLE', area.x0 + headW / 2, rowTop[i] - r.h / 2, r.label, 2.2 * k, { anchor: 'middle', vAnchor: 'middle', bold: true });
});
b.line('TABLE', [[area.x0 + headW, ty0], [area.x0 + headW, ty1]], { width: 0.35 });
const minP = input.minPressure;
let prevShown = -1;
st.forEach((s, i) => {
if (!show[i]) return;
const x = X(s.x);
const topZ = Math.max(s.ground, s.invert);
b.line('GUIDE', [[x, rowTop[rows.length - 1]], [x, ty1]], { width: 0.08, color: '#94a3b8' });
b.line('GUIDE', [[x, gy0], [x, Y(Math.min(topZ, zTop))]], { width: 0.08, color: '#94a3b8', dash: 'dotted' });
const prev = prevShown >= 0 ? st[prevShown] : undefined;
prevShown = i;
const low = minP !== undefined && s.pressure !== undefined && s.kind !== 'reservoir' && s.kind !== 'tank' && s.pressure < minP;
const id = s.id.length > 11 ? `${s.id.slice(0, 10)}…` : s.id;
const vals: Array<[string, string?]> = [
[`No.${i}`],
[id],
[fxc(prev ? s.x - prev.x : 0, 1)],
[fxc(s.x - st[0].x, 1)],
[fx(s.ground)],
[fx(s.invert)],
[s.heads.length ? fx(s.heads[0]) : '-'],
[s.pressure === undefined ? '-' : fx(s.pressure), low ? RED : undefined],
];
vals.forEach(([v, color], r) => {
b.text('TABLE', x, rowTop[r] - rows[r].h / 2, v, (r <= 1 ? 1.8 : 1.65) * k, { anchor: 'middle', vAnchor: 'middle', rotate: 90, color, bold: r === 0 || !!color });
});
});
// Bottom row: one bracket per run of equal diameter.
const merged: ProfileRun[] = [];
for (const r of input.runs) {
const last = merged[merged.length - 1];
if (last && last.diameter === r.diameter && Math.abs(last.toX - r.fromX) < 1e-6) merged[merged.length - 1] = { ...last, toX: r.toX, length: last.length + r.length };
else merged.push({ ...r });
}
const rr = rows.length - 1;
const remY = rowTop[rr] - rows[rr].h / 2;
for (const r of merged) {
const a = X(r.fromX), c = X(r.toX);
const h = rows[rr].h;
b.line('RUN', [[a, remY - h * 0.28], [a, remY + h * 0.28]], { width: 0.25 });
b.line('RUN', [[c, remY - h * 0.28], [c, remY + h * 0.28]], { width: 0.25 });
const t = `D${r.diameter.toFixed(0)}mm L=${fxc(r.length, 1)}m`;
const tw = t.length * 0.62 * 1.8 * k;
if (c - a > tw + 4 * k) {
b.line('RUN', [[a, remY], [(a + c) / 2 - tw / 2 - 1 * k, remY]], { width: 0.15 });
b.line('RUN', [[(a + c) / 2 + tw / 2 + 1 * k, remY], [c, remY]], { width: 0.15 });
b.text('RUN', (a + c) / 2, remY, t, 1.8 * k, { anchor: 'middle', vAnchor: 'middle' });
} else {
b.line('RUN', [[a, remY], [c, remY]], { width: 0.15 });
if (c - a > 7 * k) b.text('RUN', (a + c) / 2, remY, `D${r.diameter.toFixed(0)}`, 1.5 * k, { anchor: 'middle', vAnchor: 'middle' });
}
}
// Lines: ground, pipe invert (+ crown when the bore is visible), HGL series.
const P = (f: (s: ProfileStation) => number) => st.map((s) => [X(s.x), Y(f(s))] as [number, number]).filter(([, y]) => Number.isFinite(y));
b.line('GROUND', P((s) => s.ground), { width: 0.4, color: '#7c4a1e' });
// Ground hatch ticks under the ground line (every ~4 mm on paper).
{
const step = 4 * k;
for (let px = gx0 + step / 2; px < X(st[st.length - 1].x); px += step) {
const xm = st[0].x + ((px - gx0) * hScale) / 1000;
const yg = Y(at(st, xm, (s) => s.ground));
if (!Number.isFinite(yg) || yg - 1.6 * k < gy0) continue;
b.line('GROUND', [[px, yg], [px - 1.1 * k, yg - 1.6 * k]], { width: 0.1, color: '#a16207' });
}
}
const showCrown = merged.some((r) => (r.diameter / 1000) * 1000 / vScale >= 0.45);
b.line('PIPE', P((s) => s.invert), { width: 0.5, color: '#111111' });
if (showCrown) {
for (const r of input.runs) {
const d = r.diameter / 1000;
b.line('PIPE', [[X(r.fromX), Y(at(st, r.fromX, (s) => s.invert) + d)], [X(r.toX), Y(at(st, r.toX, (s) => s.invert) + d)]], { width: 0.3, color: '#111111' });
}
}
input.series.forEach((sr, i) => {
b.line(`HGL_${i + 1}`, P((s) => s.heads[i]), { width: 0.45, color: sr.color, dash: sr.dash });
});
// Inline name on the first HGL, along its longest early segment.
if (input.series.length) {
let best = -1, bi = -1;
for (let i = 0; i < st.length - 1; i++) {
const L = X(st[i + 1].x) - X(st[i].x);
if (Number.isFinite(st[i].heads[0]) && Number.isFinite(st[i + 1].heads[0]) && L > best) { best = L; bi = i; }
if (X(st[i + 1].x) > (gx0 + gx1) / 2 && best > 25 * k) break;
}
if (bi >= 0 && best > 22 * k) {
const ax = X(st[bi].x), ay = Y(st[bi].heads[0]), cx = X(st[bi + 1].x), cy = Y(st[bi + 1].heads[0]);
const ang = (Math.atan2(cy - ay, cx - ax) * 180) / Math.PI;
const t = 0.3;
b.text('NOTE', ax + (cx - ax) * t, ay + (cy - ay) * t + 1.2 * k, `동수경사선 (${input.series[0].name})`, 2 * k, { rotate: ang, color: input.series[0].color, bold: true });
}
}
// Run annotations above the first HGL: C, Q, I.
for (const r of merged) {
const a = X(r.fromX), c = X(r.toX);
if (c - a < 26 * k) continue;
const mid = (r.fromX + r.toX) / 2;
const h = at(st, mid, (s) => s.heads[0]);
if (!Number.isFinite(h)) continue;
const y = Math.min(Y(h) + 3.5 * k, gy1 - 2 * k);
const parts = [r.roughness !== undefined ? `C=${r.roughness.toFixed(0)}` : '', r.flow !== undefined ? `Q=${fxc(r.flow, 1)}${r.flowUnit ?? ''}` : '', r.gradient !== undefined ? `I=${r.gradient.toFixed(2)}‰` : ''].filter(Boolean);
if (parts.length) b.text('NOTE', (a + c) / 2 + 6 * k, y, parts.join(' '), 1.8 * k, { anchor: 'middle', color: '#1e3a8a' });
}
// Leader labels (60°) above the highest line at x.
const labelTops: number[] = [];
const leader = (x: number, fromY: number, text: string, color = '#0f172a') => {
const px = X(x);
const topZ = Math.max(at(st, x, (s) => Math.max(s.ground, ...s.heads.filter(Number.isFinite), s.hwl ?? -Infinity)));
const size = 2.1 * k;
const rise = text.length * size * 0.8 * Math.sin(Math.PI / 3);
let base = Math.min(Y(topZ) + 7 * k, gy1 - rise);
base = Math.max(base, fromY + 3 * k);
// nudge up when an earlier label sits right next to this one
for (const t of labelTops) if (Math.abs(t - px) < size * 1.6) base = Math.min(base + size * 1.4, gy1 - 2 * k);
labelTops.push(px);
b.line('NOTE', [[px, fromY], [px, base]], { width: 0.15, color });
b.line('NOTE', [[px, base], [px + size * 0.5, base + size * 0.87]], { width: 0.15, color });
b.text('NOTE', px + size * 0.4, base + size * 0.5, text, size, { rotate: 60, color, bold: true });
};
// Reservoirs / tanks: ▽ at the water level and a label.
const tri = (layer: string, cx: number, y: number, s: number, color: string) => {
b.fill(layer, [[cx - s, y + s * 1.15], [cx + s, y + s * 1.15], [cx, y]], color);
};
for (const s of st) {
if (s.kind !== 'reservoir' && s.kind !== 'tank') continue;
const x = X(s.x);
const hwl = s.hwl ?? s.ground;
const y = Y(hwl);
if (s.kind === 'tank' && s.lwl !== undefined) {
const w = 5 * k;
b.rect('FACILITY', x - w / 2, Y(at(st, s.x, (q) => q.invert)), w, y - Y(at(st, s.x, (q) => q.invert)) + 1.5 * k, { width: 0.35 });
b.line('FACILITY', [[x - w / 2 - 1.5 * k, Y(s.lwl)], [x + w / 2 + 1.5 * k, Y(s.lwl)]], { width: 0.2, color: '#0369a1', dash: 'dashed' });
}
b.line('FACILITY', [[x - 4 * k, y], [x + 4 * k, y]], { width: 0.35, color: '#0369a1' });
b.line('FACILITY', [[x - 2.6 * k, y - 0.8 * k], [x + 2.6 * k, y - 0.8 * k]], { width: 0.2, color: '#0369a1' });
tri('FACILITY', x, y, 1.3 * k, '#0369a1');
b.text('FACILITY', x + 4.5 * k, y - 1.2 * k, `H.W.L ${fx(hwl)}`, 1.9 * k, { vAnchor: 'top', color: '#0369a1', bold: true });
const name = (input.marks ?? []).find((m) => Math.abs(m.x - s.x) < 1e-6)?.text ?? `${s.kind === 'reservoir' ? '배수지' : '탱크'} ${s.id}`;
leader(s.x, y + 2 * k, name);
}
for (const m of input.marks ?? []) {
if (st.some((s) => (s.kind === 'reservoir' || s.kind === 'tank') && Math.abs(s.x - m.x) < 1e-6)) continue;
leader(m.x, Y(at(st, m.x, (s) => Math.max(s.ground, ...s.heads.filter(Number.isFinite)))) + 1 * k, m.text);
}
// Valves (bow-tie on the pipe, PRV filled) and pumps (circle + triangle).
for (const f of input.fittings ?? []) {
const x = X(f.x), y = Y(at(st, f.x, (s) => s.invert));
const s = 1.5 * k;
if (f.kind === 'valve') {
const filled = f.subtype === 'PRV' || f.subtype === 'PSV' || f.subtype === 'PBV';
const L: Array<[number, number]> = [[x - s, y - s * 0.75], [x - s, y + s * 0.75], [x, y]];
const R: Array<[number, number]> = [[x + s, y - s * 0.75], [x + s, y + s * 0.75], [x, y]];
if (filled) { b.fill('VALVE', L, '#111111'); b.fill('VALVE', R, '#111111'); }
b.line('VALVE', L, { width: 0.25, closed: true });
b.line('VALVE', R, { width: 0.25, closed: true });
} else {
b.circle('PUMP', x, y, s * 1.1, { fill: '#ffffff', color: '#111111', width: 0.3 });
b.fill('PUMP', [[x - s * 0.45, y - s * 0.6], [x - s * 0.45, y + s * 0.6], [x + s * 0.65, y]], '#111111');
}
const hgl = at(st, f.x, (q) => q.heads[0]);
leader(f.x, Math.max(y, Number.isFinite(hgl) ? Y(hgl) : y) + 2 * k, f.label, f.kind === 'valve' ? '#7c2d12' : '#14532d');
}
// Legend (top-right of the graph): plain framed panel.
const entries: Array<{ name: string; color: string; dash?: 'dashed' | 'dotted'; w: number; sym?: 'tri' | 'valve' | 'pump' | 'red' }> = [
{ name: '지반선', color: '#7c4a1e', w: 0.4 },
{ name: showCrown ? '관로 (관저·관정)' : '관저고', color: '#111111', w: 0.5 },
...input.series.map((s) => ({ name: `동수경사선 (${s.name})`, color: s.color, dash: s.dash, w: 0.45 })),
...(st.some((s) => s.kind === 'reservoir' || s.kind === 'tank') ? [{ name: '배수지 수위 (H.W.L)', color: '#0369a1', w: 0, sym: 'tri' as const }] : []),
...((input.fittings ?? []).some((f) => f.kind === 'valve') ? [{ name: '밸브 (감압 등: 채움)', color: '#111111', w: 0, sym: 'valve' as const }] : []),
...((input.fittings ?? []).some((f) => f.kind === 'pump') ? [{ name: '가압펌프', color: '#111111', w: 0, sym: 'pump' as const }] : []),
...(minP !== undefined && st.some((s) => s.pressure !== undefined) ? [{ name: `수압 ${minP}m 미만 (붉은 글씨)`, color: RED, w: 0, sym: 'red' as const }] : []),
];
const lw = 62 * k;
type E = (typeof entries)[number];
const sym = (en: E) => (sx: number, y: number) => {
const ex = sx + 10 * k;
if (en.sym === 'tri') { b.line('LEGEND', [[sx + 1 * k, y], [ex - 1 * k, y]], { width: 0.3, color: en.color }); tri('LEGEND', (sx + ex) / 2, y, 1.1 * k, en.color); }
else if (en.sym === 'valve') { const c = (sx + ex) / 2, s2 = 1.3 * k; b.fill('LEGEND', [[c - s2, y - s2 * 0.75], [c - s2, y + s2 * 0.75], [c, y]], '#111111'); b.fill('LEGEND', [[c + s2, y - s2 * 0.75], [c + s2, y + s2 * 0.75], [c, y]], '#111111'); }
else if (en.sym === 'pump') { const c = (sx + ex) / 2; b.circle('LEGEND', c, y, 1.4 * k, { fill: '#ffffff', color: '#111111', width: 0.3 }); b.fill('LEGEND', [[c - 0.6 * k, y - 0.8 * k], [c - 0.6 * k, y + 0.8 * k], [c + 0.9 * k, y]], '#111111'); }
else if (en.sym === 'red') b.text('LEGEND', (sx + ex) / 2, y, '12.34', 1.8 * k, { anchor: 'middle', vAnchor: 'middle', color: RED, bold: true });
else b.line('LEGEND', [[sx, y], [ex, y]], { width: en.w, color: en.color, dash: en.dash });
};
const leg = legendTable(b, 'LEGEND', [
{ name: '선형', rows: entries.filter((en) => !en.sym).map((en) => ({ label: en.name, draw: sym(en) })) },
{ name: '기호', rows: entries.filter((en) => en.sym).map((en) => ({ label: en.name, draw: sym(en) })) },
], { w: lw, rowH: 4.2 * k, style: input.legendStyle ?? 'table', band: input.legendBand });
if (leg.h) leg.draw(gx1 - lw - 3 * k, gy1 - 6 * k - leg.h);
return { drawing: b.build(input.font), hScale, vScale };
}