@toolpath/tool-drawing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,846 @@
1
+ import {
2
+ AWAY_FROM_TIP,
3
+ DrawingProvider,
4
+ TOWARD_MINUS,
5
+ TOWARD_PLUS,
6
+ TOWARD_TIP,
7
+ arrowhead,
8
+ useDrawingContext
9
+ } from "./chunk-7ZID4QRO.js";
10
+ import {
11
+ assemblyOutline,
12
+ hasNeck
13
+ } from "./chunk-OUMNS4RI.js";
14
+
15
+ // src/model/frame.ts
16
+ var DEFAULT_PADDING = 16;
17
+ var DEFAULT_BOX = { width: 640, height: 240 };
18
+ var TYPE_FRACTION = 0.045;
19
+ var TYPE_MIN_PX = 9;
20
+ var TYPE_MAX_PX = 14;
21
+ var MOST_OF_A_PANEL = 0.6;
22
+ var clamp = (value, low, high) => Math.min(Math.max(value, low), high);
23
+ var fitted = (low, high, axis) => {
24
+ const total = low + high;
25
+ const most = axis * MOST_OF_A_PANEL;
26
+ return total <= most || total <= 0 ? [low, high] : [low * most / total, high * most / total];
27
+ };
28
+ var round = (value) => Math.round(value * 1e4) / 1e4;
29
+ var boxFor = (box, options) => box.width > 0 && box.height > 0 ? box : options.defaultBox ?? DEFAULT_BOX;
30
+ var typeSizeFor = (box, options = {}) => {
31
+ const measured = boxFor(box, options);
32
+ return clamp(Math.min(measured.width, measured.height) * TYPE_FRACTION, TYPE_MIN_PX, TYPE_MAX_PX);
33
+ };
34
+ var orientationFor = (box, options = {}) => {
35
+ const measured = boxFor(box, options);
36
+ return measured.width >= measured.height ? "horizontal" : "vertical";
37
+ };
38
+ var frameFor = (outline, box, options = {}) => {
39
+ const given = options.padding ?? DEFAULT_PADDING;
40
+ const pad = typeof given === "number" ? { minus: given, plus: given, along: given } : {
41
+ minus: given.minus ?? DEFAULT_PADDING,
42
+ plus: given.plus ?? DEFAULT_PADDING,
43
+ along: given.along ?? DEFAULT_PADDING
44
+ };
45
+ const measured = boxFor(box, options);
46
+ const orientation = orientationFor(box, options);
47
+ const alongPx = orientation === "horizontal" ? measured.width : measured.height;
48
+ const acrossPx = orientation === "horizontal" ? measured.height : measured.width;
49
+ const alongMm = outline.height;
50
+ const acrossMm = outline.radius * 2;
51
+ const [minus, plus] = fitted(pad.minus, pad.plus, acrossPx);
52
+ const [ends] = fitted(pad.along, pad.along, alongPx);
53
+ const padding = { minus, plus, along: ends };
54
+ const room = (px, chrome) => Math.max(1, px - chrome);
55
+ const ratios = [
56
+ room(alongPx, padding.along * 2) / alongMm,
57
+ room(acrossPx, padding.minus + padding.plus) / acrossMm
58
+ ].filter((ratio) => Number.isFinite(ratio) && ratio > 0);
59
+ const scale = ratios.length > 0 ? Math.min(...ratios) : 1;
60
+ const endMargin = padding.along / scale;
61
+ const minusMargin = padding.minus / scale;
62
+ const plusMargin = padding.plus / scale;
63
+ const along = alongMm + endMargin * 2;
64
+ const across = acrossMm + minusMargin + plusMargin;
65
+ const viewBox = orientation === "horizontal" ? [-endMargin, -outline.radius - minusMargin, along, across] : [-outline.radius - minusMargin, -endMargin, across, along];
66
+ const fontSize = typeSizeFor(box, options) / scale;
67
+ return {
68
+ orientation,
69
+ scale,
70
+ viewBox: viewBox.map(round).join(" "),
71
+ padding,
72
+ // The tip sits at the origin. Horizontal runs it left to right, so the
73
+ // business end is where reading starts; vertical runs it bottom to top, so
74
+ // the tool hangs from its holder the way it does in the spindle.
75
+ toX: orientation === "horizontal" ? (_r, z) => z : (r) => r,
76
+ toY: orientation === "horizontal" ? (r) => r : (_r, z) => outline.height - z,
77
+ fontSize
78
+ };
79
+ };
80
+
81
+ // src/model/dimensions.ts
82
+ var EMPTY = { lengths: [], widths: [], angles: [], cornerRadius: null };
83
+ var dimensionsFor = (assembly) => {
84
+ const { tool, holder, stickout } = assembly;
85
+ const { DC, LCF, OAL, SFDM, RE } = tool.geometry;
86
+ if (DC === void 0 || LCF === void 0) {
87
+ return EMPTY;
88
+ }
89
+ const shoulderLength = tool.geometry["shoulder-length"];
90
+ const shoulderDiameter = tool.geometry["shoulder-diameter"];
91
+ const necked = hasNeck(tool) && shoulderLength !== void 0 && shoulderDiameter !== void 0;
92
+ const widths = [{ code: "DC", radius: DC / 2, at: 0 }];
93
+ if (necked && shoulderDiameter !== void 0 && shoulderLength !== void 0) {
94
+ widths.push({
95
+ code: "shoulder-diameter",
96
+ radius: shoulderDiameter / 2,
97
+ // At the top of the relief, where the shank steps down to it: measured
98
+ // in the middle it sat among the flute length and the corner radius,
99
+ // which is the busiest inch of the drawing (Paul, 2026-09-01).
100
+ at: shoulderLength
101
+ });
102
+ }
103
+ if (SFDM !== void 0) {
104
+ const shankFrom = necked && shoulderLength !== void 0 ? shoulderLength : LCF;
105
+ const shankTo = stickout ?? OAL ?? shankFrom;
106
+ widths.push({
107
+ code: "SFDM",
108
+ radius: SFDM / 2,
109
+ at: (shankFrom + Math.max(shankFrom, shankTo)) / 2
110
+ });
111
+ }
112
+ const spans = [
113
+ { code: "LCF", from: 0, to: LCF }
114
+ ];
115
+ if (necked && shoulderLength !== void 0) {
116
+ spans.push({ code: "shoulder-length", from: 0, to: shoulderLength });
117
+ }
118
+ const below = tool.geometry.LBH;
119
+ if (below !== void 0 && below > 0) {
120
+ spans.push({ code: "LBH", from: 0, to: below });
121
+ }
122
+ if (holder === null) {
123
+ if (OAL !== void 0) {
124
+ spans.push({ code: "OAL", from: 0, to: OAL });
125
+ }
126
+ } else if (stickout !== null) {
127
+ spans.push({ code: "stickout", from: 0, to: stickout });
128
+ }
129
+ const lengths = spans.slice().sort((a, b) => a.to - a.from - (b.to - b.from)).map((span, index) => ({ ...span, lane: index }));
130
+ const SIG = tool.geometry.SIG;
131
+ const pointed = tool.form === "drill" || tool.form === "spot drill" || tool.form === "center drill";
132
+ const angles = pointed && SIG !== void 0 && SIG > 0 ? [
133
+ {
134
+ code: "SIG",
135
+ degrees: SIG,
136
+ at: { r: DC / 4, z: DC / 2 / Math.tan(SIG / 2 * Math.PI / 180) / 2 }
137
+ }
138
+ ] : [];
139
+ return {
140
+ lengths,
141
+ widths,
142
+ angles,
143
+ cornerRadius: RE !== void 0 && RE > 0 ? RE : null
144
+ };
145
+ };
146
+ var DIMENSION_LABEL = {
147
+ LBH: "below holder",
148
+ "shoulder-length": "shoulder",
149
+ "shoulder-diameter": "shoulder \u2300",
150
+ stickout: "stickout",
151
+ // A drill's included angle. "Tip angle" is what the table beside it calls
152
+ // the same number (Paul, 2026-09-01).
153
+ SIG: "tip angle"
154
+ };
155
+ var dimensionLabel = (code) => DIMENSION_LABEL[code] ?? code;
156
+ var formatMillimetres = (millimetres) => `${String(Math.round(millimetres * 100) / 100)} mm`;
157
+ var stackLabels = (boxes, gap = 0, fixed = []) => {
158
+ const placed = [...fixed];
159
+ const moved = /* @__PURE__ */ new Map();
160
+ const clashes = (one, two) => one.across < two.across + two.width && two.across < one.across + one.width && one.along < two.along + two.height + gap && two.along < one.along + one.height + gap;
161
+ for (const box of [...boxes].sort((a, b) => a.along - b.along)) {
162
+ let along = box.along;
163
+ let clash = true;
164
+ while (clash) {
165
+ clash = false;
166
+ for (const other of placed) {
167
+ if (clashes({ ...box, along }, other)) {
168
+ along = other.along + other.height + gap;
169
+ clash = true;
170
+ break;
171
+ }
172
+ }
173
+ }
174
+ placed.push({ ...box, along });
175
+ moved.set(box.key, along);
176
+ }
177
+ return moved;
178
+ };
179
+ var figureType = (fontSize) => fontSize * 0.85;
180
+ var figureWidth = (lines, type) => lines.length === 0 ? 0 : (Math.max(...lines.map((each) => each.length)) + 1) * type * 0.56;
181
+ var figureHeight = (lines, type) => type * 0.45 * 2 + type * 1.15 * lines;
182
+ var bandOffset = (bands, band, room) => {
183
+ let at2 = room.arrow + room.gap;
184
+ for (let index = 0; index < band; index += 1) {
185
+ at2 += (bands[index] ?? 0) + room.gap * 2;
186
+ }
187
+ return at2;
188
+ };
189
+ var laneOffset = (bands, lane, room) => bandOffset(bands, lane, room) + (bands[lane] ?? 0) + room.gap;
190
+ var bandRoom = (bands, room) => bands.length === 0 ? 0 : bandOffset(bands, bands.length - 1, room) + (bands[bands.length - 1] ?? 0);
191
+ var linesOf = (model, format) => [
192
+ ...model.lengths.map((each) => ({
193
+ code: each.code,
194
+ lines: [dimensionLabel(each.code), format(each.to - each.from)]
195
+ })),
196
+ ...model.angles.map((each) => ({
197
+ code: each.code,
198
+ lines: [dimensionLabel(each.code), `${String(each.degrees)}\xB0`]
199
+ })),
200
+ ...model.widths.map((each) => ({
201
+ code: each.code,
202
+ // Some labels carry the diameter sign already; none carries it twice.
203
+ lines: [
204
+ dimensionLabel(each.code).includes("\u2300") ? dimensionLabel(each.code) : `${dimensionLabel(each.code)} \u2300`,
205
+ format(each.radius * 2),
206
+ // The corner radius belongs to the tip's diameter, and reads under it
207
+ // rather than beside it.
208
+ ...each.at === 0 && model.cornerRadius !== null ? [`RE ${format(model.cornerRadius)}`] : []
209
+ ]
210
+ }))
211
+ ];
212
+ var dimensionLayout = (model, format, fontSize, sides = "one", textAlongAxis = false) => {
213
+ const type = figureType(fontSize);
214
+ const said = new Map(linesOf(model, format).map((each) => [each.code, each.lines]));
215
+ const extents = (lines) => {
216
+ const wide = figureWidth(lines, type);
217
+ const deep = figureHeight(lines.length, type);
218
+ return textAlongAxis ? { across: deep, along: wide } : { across: wide, along: deep };
219
+ };
220
+ const across = [...model.widths, ...model.angles];
221
+ const figures = [
222
+ ...across.map((each, index) => {
223
+ const side = sides === "both" && index % 2 === 1 ? "plus" : "minus";
224
+ const lines = said.get(each.code) ?? [];
225
+ return { code: each.code, side, band: 0, lane: null, lines, ...extents(lines) };
226
+ }),
227
+ ...model.lengths.map((each) => {
228
+ const side = sides === "both" && each.lane % 2 === 1 ? "plus" : "minus";
229
+ const lane = sides === "both" ? Math.floor(each.lane / 2) : each.lane;
230
+ const lines = said.get(each.code) ?? [];
231
+ return { code: each.code, side, band: lane + 1, lane, lines, ...extents(lines) };
232
+ })
233
+ ];
234
+ const bandsOn = (side) => {
235
+ const mine = figures.filter((each) => each.side === side);
236
+ const count = Math.max(0, ...mine.map((each) => each.band)) + 1;
237
+ return Array.from(
238
+ { length: mine.length === 0 ? 0 : count },
239
+ (_, band) => Math.max(0, ...mine.filter((each) => each.band === band).map((each) => each.across))
240
+ );
241
+ };
242
+ return { figures, bands: { minus: bandsOn("minus"), plus: bandsOn("plus") } };
243
+ };
244
+
245
+ // src/render/sheet.ts
246
+ var SHEETS = {
247
+ light: {
248
+ ground: "#ffffff",
249
+ ink: "#3f4650",
250
+ centre: "#15181c",
251
+ dimension: "#606a76",
252
+ body: "#c4c8ce",
253
+ flutes: "#e6bf59",
254
+ holder: "#9aa2ad",
255
+ connection: "#78818d"
256
+ },
257
+ dark: {
258
+ // A step above `zinc-900`, which is the card, and above the wash on it.
259
+ ground: "#22252b",
260
+ ink: "#c7cdd6",
261
+ centre: "#e8ebef",
262
+ dimension: "#8d97a4",
263
+ body: "#5b626c",
264
+ // The gold reads as gold on either ground, a shade deeper here so it does
265
+ // not glare against the greys around it.
266
+ flutes: "#c9a44b",
267
+ holder: "#474d57",
268
+ connection: "#3a4048"
269
+ }
270
+ };
271
+ var isConnection = (segment) => segment.part === "flange" || segment.part === "body" && segment.provenance === "assumed";
272
+ var sectionFill = (segment, sheet) => {
273
+ if (segment.part === "flutes" || segment.part === "tip") {
274
+ return sheet.flutes;
275
+ }
276
+ if (segment.part === "shank" || segment.part === "neck") {
277
+ return sheet.body;
278
+ }
279
+ return isConnection(segment) ? sheet.connection : sheet.holder;
280
+ };
281
+ var ASSUMED = {
282
+ tip: "tip angle",
283
+ nose: "nose length",
284
+ body: "body cone",
285
+ flange: "flange thickness"
286
+ };
287
+ var assumedNames = (segments) => [
288
+ ...new Set(
289
+ segments.filter((segment) => segment.provenance === "assumed").map((segment) => ASSUMED[segment.part] ?? segment.part)
290
+ )
291
+ ];
292
+
293
+ // src/render/tool-drawing.tsx
294
+ import { useEffect, useRef, useState } from "react";
295
+
296
+ // src/render/silhouette.ts
297
+ var at = (point, frame) => `${frame.toX(point.r, point.z).toFixed(2)},${frame.toY(point.r, point.z).toFixed(2)}`;
298
+ var silhouettePath = (segments, frame) => {
299
+ const up = segments.flatMap((segment) => segment.points);
300
+ if (up.length === 0) {
301
+ return "";
302
+ }
303
+ const down = [...segments].reverse().flatMap((segment) => [...segment.points].reverse());
304
+ const right = up.map((point) => at(point, frame)).join(" L");
305
+ const left = down.map((point) => at({ r: -point.r, z: point.z }, frame)).join(" L");
306
+ return `M${right} L${left} Z`;
307
+ };
308
+ var sectionPoints = (segment, frame) => {
309
+ const right = segment.points.map((point) => at(point, frame));
310
+ const left = [...segment.points].reverse().map((point) => at({ r: -point.r, z: point.z }, frame));
311
+ return [...right, ...left].join(" ");
312
+ };
313
+ var STEP = 0.01;
314
+ var joins = (segments) => {
315
+ const found = [];
316
+ for (let index = 0; index + 1 < segments.length; index += 1) {
317
+ const lower = segments[index];
318
+ const upper = segments[index + 1];
319
+ const below = lower.points[lower.points.length - 1];
320
+ const above = upper.points[0];
321
+ if (below === void 0 || above === void 0) {
322
+ continue;
323
+ }
324
+ found.push({
325
+ part: lower.part,
326
+ radius: Math.min(below.r, above.r),
327
+ z: below.z,
328
+ stepped: Math.abs(below.r - above.r) > STEP
329
+ });
330
+ }
331
+ return found;
332
+ };
333
+
334
+ // src/render/dimension-lines.tsx
335
+ import { jsx, jsxs } from "react/jsx-runtime";
336
+ var DimensionLines = ({
337
+ model,
338
+ layout,
339
+ frame,
340
+ outline,
341
+ room,
342
+ requested,
343
+ ink,
344
+ ground
345
+ }) => {
346
+ const { fontSize, scale } = frame;
347
+ const head = fontSize * 0.9;
348
+ const type = figureType(fontSize);
349
+ const lineHeight = type * 1.15;
350
+ const inset = type * 0.45;
351
+ const mm = (pixels) => pixels / scale;
352
+ const sign = (side) => side === "minus" ? -1 : 1;
353
+ const granted = (side) => {
354
+ const asked = side === "minus" ? requested.minus : requested.plus;
355
+ const got = side === "minus" ? frame.padding.minus : frame.padding.plus;
356
+ return asked > 0 ? got / asked : 1;
357
+ };
358
+ const at2 = (r, z) => `${frame.toX(r, z).toFixed(2)},${frame.toY(r, z).toFixed(2)}`;
359
+ const edgeAt = (side) => sign(side) * outline.radius;
360
+ const laneAt = (lane, side) => sign(side) * (outline.radius + mm(laneOffset(layout.bands[side], lane, room) * granted(side)));
361
+ const bandAt = (band, side) => sign(side) * (outline.radius + mm(bandOffset(layout.bands[side], band, room) * granted(side)));
362
+ const placeOf = new Map(layout.figures.map((each) => [each.code, each]));
363
+ const wants = (figure) => {
364
+ if (figure.lane === null) {
365
+ const angle = model.angles.find((each) => each.code === figure.code);
366
+ if (angle) {
367
+ return { r: angle.at.r * sign(figure.side), z: angle.at.z };
368
+ }
369
+ const width = model.widths.find((each) => each.code === figure.code);
370
+ return { r: (width?.radius ?? 0) * sign(figure.side), z: width?.at ?? 0 };
371
+ }
372
+ const length = model.lengths.find((each) => each.code === figure.code);
373
+ return {
374
+ r: laneAt(figure.lane, figure.side),
375
+ /**
376
+ * **At the top of its dimension, not the middle of it** (Paul,
377
+ * 2026-09-01). A figure halfway down a 50 mm dimension is level with
378
+ * nothing on the tool; at the top it is level with the arrow it belongs
379
+ * to.
380
+ */
381
+ z: Math.max(length?.from ?? 0, length?.to ?? 0)
382
+ };
383
+ };
384
+ const boxOf = (figure) => {
385
+ const inboard = bandAt(figure.band, figure.side);
386
+ const across = mm(figure.across * granted(figure.side));
387
+ const along = mm(figure.along);
388
+ return {
389
+ // Outward from the band's inboard edge, on this flank.
390
+ from: inboard,
391
+ to: inboard + sign(figure.side) * across,
392
+ across,
393
+ along
394
+ };
395
+ };
396
+ const obstacles = model.lengths.flatMap((each) => {
397
+ const place = placeOf.get(each.code);
398
+ if (place === void 0 || place.lane === null) {
399
+ return [];
400
+ }
401
+ const lane = laneAt(place.lane, place.side);
402
+ const edge = edgeAt(place.side);
403
+ const thickness = mm(figureType(fontSize) * 0.7 * scale);
404
+ return [each.from, each.to].map((height, index) => ({
405
+ key: `line-${each.code}-${String(index)}`,
406
+ across: Math.min(lane, edge),
407
+ width: Math.abs(lane - edge),
408
+ along: height - thickness / 2,
409
+ height: thickness
410
+ }));
411
+ });
412
+ const placed = stackLabels(
413
+ layout.figures.map((figure) => {
414
+ const box = boxOf(figure);
415
+ const start = wants(figure);
416
+ return {
417
+ key: figure.code,
418
+ across: Math.min(box.from, box.to),
419
+ width: box.across,
420
+ along: start.z,
421
+ height: box.along
422
+ };
423
+ }),
424
+ mm(type * 0.5 * scale),
425
+ obstacles
426
+ );
427
+ return /* @__PURE__ */ jsxs("g", { "data-dimensions": true, children: [
428
+ model.lengths.map((each) => {
429
+ const place = placeOf.get(each.code);
430
+ if (place === void 0 || place.lane === null) {
431
+ return null;
432
+ }
433
+ const lane = laneAt(place.lane, place.side);
434
+ const edge = edgeAt(place.side);
435
+ const inside = Math.abs(each.to - each.from) >= head * 2.6;
436
+ const reach = head * 2.2;
437
+ return /* @__PURE__ */ jsxs("g", { "data-dimension": each.code, children: [
438
+ /* @__PURE__ */ jsx(
439
+ "line",
440
+ {
441
+ x1: frame.toX(lane, each.from),
442
+ y1: frame.toY(lane, each.from),
443
+ x2: frame.toX(edge, each.from),
444
+ y2: frame.toY(edge, each.from),
445
+ stroke: ink,
446
+ strokeOpacity: 0.4,
447
+ strokeWidth: fontSize * 0.05
448
+ }
449
+ ),
450
+ /* @__PURE__ */ jsx(
451
+ "line",
452
+ {
453
+ x1: frame.toX(lane, each.to),
454
+ y1: frame.toY(lane, each.to),
455
+ x2: frame.toX(edge, each.to),
456
+ y2: frame.toY(edge, each.to),
457
+ stroke: ink,
458
+ strokeOpacity: 0.4,
459
+ strokeWidth: fontSize * 0.05
460
+ }
461
+ ),
462
+ /* @__PURE__ */ jsx(
463
+ "line",
464
+ {
465
+ x1: frame.toX(lane, inside ? each.from : each.from - reach),
466
+ y1: frame.toY(lane, inside ? each.from : each.from - reach),
467
+ x2: frame.toX(lane, inside ? each.to : each.to + reach),
468
+ y2: frame.toY(lane, inside ? each.to : each.to + reach),
469
+ stroke: ink,
470
+ strokeWidth: fontSize * 0.07
471
+ }
472
+ ),
473
+ /* @__PURE__ */ jsx(
474
+ "polygon",
475
+ {
476
+ points: arrowhead(lane, each.from, inside ? TOWARD_TIP : AWAY_FROM_TIP, head, frame),
477
+ fill: ink
478
+ }
479
+ ),
480
+ /* @__PURE__ */ jsx(
481
+ "polygon",
482
+ {
483
+ points: arrowhead(lane, each.to, inside ? AWAY_FROM_TIP : TOWARD_TIP, head, frame),
484
+ fill: ink
485
+ }
486
+ )
487
+ ] }, each.code);
488
+ }),
489
+ model.widths.map((each) => {
490
+ const stand = head * 2.4;
491
+ return /* @__PURE__ */ jsxs("g", { "data-dimension": each.code, children: [
492
+ /* @__PURE__ */ jsx(
493
+ "line",
494
+ {
495
+ x1: frame.toX(-each.radius - stand, each.at),
496
+ y1: frame.toY(-each.radius - stand, each.at),
497
+ x2: frame.toX(-each.radius, each.at),
498
+ y2: frame.toY(-each.radius, each.at),
499
+ stroke: ink,
500
+ strokeWidth: fontSize * 0.07
501
+ }
502
+ ),
503
+ /* @__PURE__ */ jsx(
504
+ "polygon",
505
+ {
506
+ points: arrowhead(-each.radius, each.at, TOWARD_PLUS, head, frame),
507
+ fill: ink
508
+ }
509
+ ),
510
+ /* @__PURE__ */ jsx(
511
+ "line",
512
+ {
513
+ x1: frame.toX(each.radius, each.at),
514
+ y1: frame.toY(each.radius, each.at),
515
+ x2: frame.toX(each.radius + stand, each.at),
516
+ y2: frame.toY(each.radius + stand, each.at),
517
+ stroke: ink,
518
+ strokeWidth: fontSize * 0.07
519
+ }
520
+ ),
521
+ /* @__PURE__ */ jsx(
522
+ "polygon",
523
+ {
524
+ points: arrowhead(each.radius, each.at, TOWARD_MINUS, head, frame),
525
+ fill: ink
526
+ }
527
+ )
528
+ ] }, each.code);
529
+ }),
530
+ layout.figures.map((figure) => {
531
+ const box = boxOf(figure);
532
+ const start = wants(figure);
533
+ const low = placed.get(figure.code) ?? start.z;
534
+ const one = { x: frame.toX(box.from, low), y: frame.toY(box.from, low) };
535
+ const two = {
536
+ x: frame.toX(box.to, low + box.along),
537
+ y: frame.toY(box.to, low + box.along)
538
+ };
539
+ const rect = {
540
+ x: Math.min(one.x, two.x),
541
+ y: Math.min(one.y, two.y),
542
+ width: Math.abs(one.x - two.x),
543
+ height: Math.abs(one.y - two.y)
544
+ };
545
+ const turn = box.from - sign(figure.side) * mm(type * 0.5 * scale);
546
+ const middleZ = low + box.along / 2;
547
+ const anchorX = frame.toX(box.from, middleZ);
548
+ const axisX = frame.toX(0, middleZ);
549
+ const apart = Math.abs(anchorX - axisX) > mm(1);
550
+ const textAnchor = apart ? anchorX < axisX ? "end" : "start" : "middle";
551
+ const textX = apart ? anchorX : rect.x + rect.width / 2;
552
+ return /* @__PURE__ */ jsxs("g", { "data-figure": figure.code, children: [
553
+ /* @__PURE__ */ jsx(
554
+ "polyline",
555
+ {
556
+ points: `${at2(start.r, start.z)} ${at2(turn, start.z)} ${at2(turn, middleZ)}`,
557
+ fill: "none",
558
+ stroke: ink,
559
+ strokeOpacity: 0.35,
560
+ strokeWidth: fontSize * 0.05
561
+ }
562
+ ),
563
+ /* @__PURE__ */ jsx(
564
+ "rect",
565
+ {
566
+ x: rect.x,
567
+ y: rect.y,
568
+ width: rect.width,
569
+ height: rect.height,
570
+ fill: ground,
571
+ rx: type * 0.2
572
+ }
573
+ ),
574
+ figure.lines.map((line, index) => /* @__PURE__ */ jsx(
575
+ "text",
576
+ {
577
+ x: textX,
578
+ y: rect.y + inset + type * 0.85 + lineHeight * index,
579
+ fontSize: type,
580
+ textAnchor,
581
+ fill: ink,
582
+ fontFamily: "ui-monospace, monospace",
583
+ children: line
584
+ },
585
+ line
586
+ ))
587
+ ] }, `figure-${figure.code}`);
588
+ })
589
+ ] });
590
+ };
591
+
592
+ // src/render/tool-drawing.tsx
593
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
594
+ var STROKE = { silhouette: 0.09, edge: 0.09, chord: 0.06, centre: 0.05 };
595
+ var DEFAULT_PADDING2 = 16;
596
+ var STRUCK = "#f87171";
597
+ var ToolDrawing = ({
598
+ assembly,
599
+ theme = "dark",
600
+ caption,
601
+ dimensions = false,
602
+ dimensionSides = "one",
603
+ formatLength = formatMillimetres,
604
+ padding = DEFAULT_PADDING2,
605
+ collisions,
606
+ verdict,
607
+ children,
608
+ className
609
+ }) => {
610
+ const sheet = SHEETS[theme];
611
+ const element = useRef(null);
612
+ const [box, setBox] = useState({ width: 0, height: 0 });
613
+ useEffect(() => {
614
+ const svg = element.current;
615
+ if (!svg || typeof ResizeObserver === "undefined") {
616
+ return;
617
+ }
618
+ const watch = new ResizeObserver((entries) => {
619
+ const measured = entries[0]?.contentRect;
620
+ if (measured && measured.width > 0 && measured.height > 0) {
621
+ setBox({ width: measured.width, height: measured.height });
622
+ }
623
+ });
624
+ watch.observe(svg);
625
+ return () => {
626
+ watch.disconnect();
627
+ };
628
+ }, []);
629
+ const { tool, holder } = assembly;
630
+ const name = caption ?? tool.label ?? tool.form;
631
+ const outline = assemblyOutline(assembly);
632
+ if (outline === null) {
633
+ const missing = tool.geometry.DC === void 0 || tool.geometry.LCF === void 0;
634
+ return /* @__PURE__ */ jsx2(
635
+ "p",
636
+ {
637
+ "data-undrawable": tool.form,
638
+ className,
639
+ style: { background: sheet.ground, color: sheet.dimension, padding: "1rem", margin: 0 },
640
+ children: missing ? `${name} states no cutting diameter or flute length, so there is nothing to draw.` : `${name} is a ${tool.form}, and this drawing has no shape for that form \u2014 so it is not drawn, rather than drawn wrong.`
641
+ }
642
+ );
643
+ }
644
+ const typePx = typeSizeFor(box);
645
+ const model = dimensions ? dimensionsFor(assembly) : null;
646
+ const room = { arrow: typePx * 0.9 * 2.4, gap: typePx * 0.5 };
647
+ const layout = model === null ? null : dimensionLayout(
648
+ model,
649
+ formatLength,
650
+ typePx,
651
+ dimensionSides,
652
+ orientationFor(box) === "horizontal"
653
+ );
654
+ const asked = typeof padding === "number" ? { minus: padding, plus: padding, along: padding } : {
655
+ minus: padding.minus ?? DEFAULT_PADDING2,
656
+ plus: padding.plus ?? DEFAULT_PADDING2,
657
+ along: padding.along ?? DEFAULT_PADDING2
658
+ };
659
+ const chrome = layout === null ? asked : {
660
+ minus: asked.minus + bandRoom(layout.bands.minus, room) + room.gap,
661
+ plus: asked.plus + bandRoom(layout.bands.plus, room) + room.gap,
662
+ // Headroom for one figure's depth past each end: the stacker moves a
663
+ // clash away from the tip, so the outermost figure of a crowded tool
664
+ // ends up past the top of what it measures.
665
+ along: asked.along + Math.max(0, ...layout.figures.map((each) => each.along))
666
+ };
667
+ const frame = frameFor(outline, box, { padding: chrome });
668
+ const { fontSize } = frame;
669
+ const assumed = assumedNames(outline.segments);
670
+ const line = (r, z) => ({ x: frame.toX(r, z), y: frame.toY(r, z) });
671
+ const overhang = fontSize * 1.2;
672
+ const from = line(0, -overhang);
673
+ const to = line(0, outline.height + overhang);
674
+ return /* @__PURE__ */ jsxs2(
675
+ "figure",
676
+ {
677
+ className,
678
+ style: { display: "flex", flexDirection: "column", minHeight: 0, height: "100%", margin: 0 },
679
+ children: [
680
+ /* @__PURE__ */ jsxs2(
681
+ "figcaption",
682
+ {
683
+ style: {
684
+ display: "flex",
685
+ padding: "0.5rem 0.75rem",
686
+ fontSize: "0.75rem",
687
+ fontFamily: "ui-monospace, monospace",
688
+ color: sheet.dimension
689
+ },
690
+ children: [
691
+ name,
692
+ verdict ? /* @__PURE__ */ jsx2(
693
+ "span",
694
+ {
695
+ "data-verdict": verdict.clears ? "clears" : "collides",
696
+ style: { marginLeft: "auto", color: verdict.clears ? sheet.dimension : STRUCK },
697
+ children: verdict.clears ? "clears the part" : "collides with the part"
698
+ }
699
+ ) : null
700
+ ]
701
+ }
702
+ ),
703
+ verdict?.note ? /* @__PURE__ */ jsx2(
704
+ "p",
705
+ {
706
+ "data-verdict-note": true,
707
+ style: {
708
+ padding: "0 0.75rem 0.25rem",
709
+ fontSize: "0.625rem",
710
+ color: sheet.dimension,
711
+ margin: 0
712
+ },
713
+ children: verdict.note
714
+ }
715
+ ) : null,
716
+ /* @__PURE__ */ jsxs2(
717
+ "svg",
718
+ {
719
+ ref: element,
720
+ role: "img",
721
+ "aria-label": `${name}, drawn from its stated dimensions`,
722
+ viewBox: frame.viewBox,
723
+ style: { background: sheet.ground, flex: 1, minHeight: 0, borderRadius: "0.25rem" },
724
+ preserveAspectRatio: "xMidYMid meet",
725
+ children: [
726
+ outline.segments.map((segment, index) => {
727
+ const struck = (collisions ?? []).some(
728
+ (each) => each.part === segment.part && each.height >= Math.min(...segment.points.map((point) => point.z)) - 1e-6 && each.height <= Math.max(...segment.points.map((point) => point.z)) + 1e-6
729
+ );
730
+ return /* @__PURE__ */ jsx2(
731
+ "polygon",
732
+ {
733
+ "data-part": segment.part,
734
+ "data-provenance": segment.provenance,
735
+ ...struck ? { "data-struck": "true" } : {},
736
+ points: sectionPoints(segment, frame),
737
+ fill: struck ? STRUCK : sectionFill(segment, sheet),
738
+ fillOpacity: struck ? 0.75 : 1,
739
+ stroke: "none"
740
+ },
741
+ `${segment.part}-${String(index)}`
742
+ );
743
+ }),
744
+ joins(outline.segments).map((join, index) => {
745
+ const start = line(-join.radius, join.z);
746
+ const end = line(join.radius, join.z);
747
+ return /* @__PURE__ */ jsx2(
748
+ "line",
749
+ {
750
+ "data-join": join.part,
751
+ "data-stepped": join.stepped ? "true" : "false",
752
+ x1: start.x,
753
+ y1: start.y,
754
+ x2: end.x,
755
+ y2: end.y,
756
+ stroke: sheet.ink,
757
+ strokeOpacity: join.stepped ? 1 : 0.35,
758
+ strokeWidth: fontSize * (join.stepped ? STROKE.edge : STROKE.chord),
759
+ ...join.stepped ? {} : {
760
+ strokeDasharray: `${(fontSize * 0.5).toFixed(2)} ${(fontSize * 0.4).toFixed(2)}`
761
+ }
762
+ },
763
+ `join-${join.part}-${String(index)}`
764
+ );
765
+ }),
766
+ /* @__PURE__ */ jsx2(
767
+ "path",
768
+ {
769
+ "data-silhouette": true,
770
+ d: silhouettePath(outline.segments, frame),
771
+ fill: "none",
772
+ stroke: sheet.ink,
773
+ strokeWidth: fontSize * STROKE.silhouette,
774
+ strokeLinejoin: "round"
775
+ }
776
+ ),
777
+ /* @__PURE__ */ jsx2(DrawingProvider, { value: { frame, outline, sheet }, children }),
778
+ model !== null && layout !== null ? /* @__PURE__ */ jsx2(
779
+ DimensionLines,
780
+ {
781
+ model,
782
+ layout,
783
+ frame,
784
+ outline,
785
+ room,
786
+ requested: chrome,
787
+ ink: sheet.dimension,
788
+ ground: sheet.ground
789
+ }
790
+ ) : null,
791
+ /* @__PURE__ */ jsx2(
792
+ "line",
793
+ {
794
+ "data-centreline": true,
795
+ x1: from.x,
796
+ y1: from.y,
797
+ x2: to.x,
798
+ y2: to.y,
799
+ stroke: sheet.centre,
800
+ strokeWidth: fontSize * STROKE.centre,
801
+ strokeDasharray: `${(fontSize * 1.6).toFixed(2)} ${(fontSize * 0.5).toFixed(2)} ${(fontSize * 0.3).toFixed(2)} ${(fontSize * 0.5).toFixed(2)}`
802
+ }
803
+ )
804
+ ]
805
+ }
806
+ ),
807
+ /* @__PURE__ */ jsxs2(
808
+ "p",
809
+ {
810
+ "data-provenance-note": true,
811
+ style: {
812
+ padding: "0 0.75rem 0.5rem",
813
+ fontSize: "0.625rem",
814
+ color: sheet.dimension,
815
+ margin: 0
816
+ },
817
+ children: [
818
+ "Drawn from stated dimensions",
819
+ assumed.length > 0 ? `; ${assumed.join(", ")} assumed` : "",
820
+ ".",
821
+ holder !== null && holder.gaugeLength === null ? " The holder length is not stated." : ""
822
+ ]
823
+ }
824
+ )
825
+ ]
826
+ }
827
+ );
828
+ };
829
+ export {
830
+ SHEETS,
831
+ ToolDrawing,
832
+ bandOffset,
833
+ bandRoom,
834
+ dimensionLabel,
835
+ dimensionLayout,
836
+ dimensionsFor,
837
+ figureHeight,
838
+ figureType,
839
+ formatMillimetres,
840
+ frameFor,
841
+ laneOffset,
842
+ orientationFor,
843
+ stackLabels,
844
+ typeSizeFor,
845
+ useDrawingContext
846
+ };