@pooder/kit 5.3.1 → 5.4.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.
Files changed (90) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/index.d.mts +243 -36
  3. package/dist/index.d.ts +243 -36
  4. package/dist/index.js +2278 -1041
  5. package/dist/index.mjs +2278 -1041
  6. package/package.json +1 -1
  7. package/src/coordinate.ts +106 -106
  8. package/src/extensions/background.ts +323 -230
  9. package/src/extensions/bridgeSelection.ts +17 -17
  10. package/src/extensions/constraints.ts +322 -322
  11. package/src/extensions/dieline.ts +1149 -1076
  12. package/src/extensions/dielineShape.ts +109 -0
  13. package/src/extensions/edgeScale.ts +19 -19
  14. package/src/extensions/feature.ts +1137 -1021
  15. package/src/extensions/featureComplete.ts +46 -46
  16. package/src/extensions/film.ts +266 -194
  17. package/src/extensions/geometry.ts +885 -752
  18. package/src/extensions/image.ts +2054 -1926
  19. package/src/extensions/index.ts +11 -11
  20. package/src/extensions/maskOps.ts +283 -283
  21. package/src/extensions/mirror.ts +128 -128
  22. package/src/extensions/ruler.ts +654 -451
  23. package/src/extensions/sceneLayout.ts +140 -140
  24. package/src/extensions/sceneLayoutModel.ts +364 -352
  25. package/src/extensions/sceneVisibility.ts +64 -71
  26. package/src/extensions/size.ts +389 -389
  27. package/src/extensions/tracer.ts +1019 -1019
  28. package/src/extensions/white-ink.ts +1567 -1514
  29. package/src/extensions/wrappedOffsets.ts +33 -33
  30. package/src/index.ts +2 -2
  31. package/src/services/CanvasService.ts +832 -300
  32. package/src/services/ViewportSystem.ts +95 -95
  33. package/src/services/index.ts +3 -3
  34. package/src/services/renderSpec.ts +53 -18
  35. package/src/units.ts +27 -27
  36. package/tests/run.ts +118 -118
  37. package/tsconfig.test.json +15 -15
  38. package/.test-dist/src/CanvasService.js +0 -249
  39. package/.test-dist/src/ViewportSystem.js +0 -75
  40. package/.test-dist/src/background.js +0 -203
  41. package/.test-dist/src/bridgeSelection.js +0 -20
  42. package/.test-dist/src/constraints.js +0 -237
  43. package/.test-dist/src/coordinate.js +0 -74
  44. package/.test-dist/src/dieline.js +0 -818
  45. package/.test-dist/src/edgeScale.js +0 -12
  46. package/.test-dist/src/extensions/background.js +0 -203
  47. package/.test-dist/src/extensions/bridgeSelection.js +0 -20
  48. package/.test-dist/src/extensions/constraints.js +0 -237
  49. package/.test-dist/src/extensions/dieline.js +0 -828
  50. package/.test-dist/src/extensions/edgeScale.js +0 -12
  51. package/.test-dist/src/extensions/feature.js +0 -825
  52. package/.test-dist/src/extensions/featureComplete.js +0 -32
  53. package/.test-dist/src/extensions/film.js +0 -167
  54. package/.test-dist/src/extensions/geometry.js +0 -545
  55. package/.test-dist/src/extensions/image.js +0 -1529
  56. package/.test-dist/src/extensions/index.js +0 -30
  57. package/.test-dist/src/extensions/maskOps.js +0 -279
  58. package/.test-dist/src/extensions/mirror.js +0 -104
  59. package/.test-dist/src/extensions/ruler.js +0 -345
  60. package/.test-dist/src/extensions/sceneLayout.js +0 -96
  61. package/.test-dist/src/extensions/sceneLayoutModel.js +0 -196
  62. package/.test-dist/src/extensions/sceneVisibility.js +0 -62
  63. package/.test-dist/src/extensions/size.js +0 -331
  64. package/.test-dist/src/extensions/tracer.js +0 -538
  65. package/.test-dist/src/extensions/white-ink.js +0 -1190
  66. package/.test-dist/src/extensions/wrappedOffsets.js +0 -33
  67. package/.test-dist/src/feature.js +0 -826
  68. package/.test-dist/src/featureComplete.js +0 -32
  69. package/.test-dist/src/film.js +0 -167
  70. package/.test-dist/src/geometry.js +0 -506
  71. package/.test-dist/src/image.js +0 -1250
  72. package/.test-dist/src/index.js +0 -18
  73. package/.test-dist/src/maskOps.js +0 -270
  74. package/.test-dist/src/mirror.js +0 -104
  75. package/.test-dist/src/renderSpec.js +0 -2
  76. package/.test-dist/src/ruler.js +0 -343
  77. package/.test-dist/src/sceneLayout.js +0 -99
  78. package/.test-dist/src/sceneLayoutModel.js +0 -196
  79. package/.test-dist/src/sceneView.js +0 -40
  80. package/.test-dist/src/sceneVisibility.js +0 -42
  81. package/.test-dist/src/services/CanvasService.js +0 -249
  82. package/.test-dist/src/services/ViewportSystem.js +0 -76
  83. package/.test-dist/src/services/index.js +0 -24
  84. package/.test-dist/src/services/renderSpec.js +0 -2
  85. package/.test-dist/src/size.js +0 -332
  86. package/.test-dist/src/tracer.js +0 -544
  87. package/.test-dist/src/units.js +0 -30
  88. package/.test-dist/src/white-ink.js +0 -829
  89. package/.test-dist/src/wrappedOffsets.js +0 -33
  90. package/.test-dist/tests/run.js +0 -94
@@ -1,451 +1,654 @@
1
- import {
2
- Extension,
3
- ExtensionContext,
4
- ContributionPointIds,
5
- CommandContribution,
6
- ConfigurationContribution,
7
- ConfigurationService,
8
- } from "@pooder/core";
9
- import { Rect, Line, Text, Group, Polygon } from "fabric";
10
- import { CanvasService } from "../services";
11
- import { formatMm } from "../units";
12
- import { computeSceneLayout, readSizeState } from "./sceneLayoutModel";
13
-
14
- export class RulerTool implements Extension {
15
- id = "pooder.kit.ruler";
16
-
17
- public metadata = {
18
- name: "RulerTool",
19
- };
20
-
21
- private thickness: number = 20;
22
- private gap: number = 15;
23
- private backgroundColor: string = "#f0f0f0";
24
- private textColor: string = "#333333";
25
- private lineColor: string = "#999999";
26
- private fontSize: number = 10;
27
-
28
- private canvasService?: CanvasService;
29
- private context?: ExtensionContext;
30
- private onCanvasResized = () => {
31
- this.updateRuler();
32
- };
33
-
34
- constructor(
35
- options?: Partial<{
36
- thickness: number;
37
- backgroundColor: string;
38
- textColor: string;
39
- lineColor: string;
40
- fontSize: number;
41
- }>,
42
- ) {
43
- if (options) {
44
- Object.assign(this, options);
45
- }
46
- }
47
-
48
- activate(context: ExtensionContext) {
49
- this.context = context;
50
- this.canvasService = context.services.get<CanvasService>("CanvasService");
51
- if (!this.canvasService) {
52
- console.warn("CanvasService not found for RulerTool");
53
- return;
54
- }
55
-
56
- const configService = context.services.get<ConfigurationService>(
57
- "ConfigurationService",
58
- );
59
- if (configService) {
60
- // Load initial config
61
- this.thickness = configService.get("ruler.thickness", this.thickness);
62
- this.gap = configService.get("ruler.gap", this.gap);
63
- this.backgroundColor = configService.get(
64
- "ruler.backgroundColor",
65
- this.backgroundColor,
66
- );
67
- this.textColor = configService.get("ruler.textColor", this.textColor);
68
- this.lineColor = configService.get("ruler.lineColor", this.lineColor);
69
- this.fontSize = configService.get("ruler.fontSize", this.fontSize);
70
-
71
- // Listen for changes
72
- configService.onAnyChange((e: { key: string; value: any }) => {
73
- let shouldUpdate = false;
74
- if (e.key.startsWith("ruler.")) {
75
- const prop = e.key.split(".")[1];
76
- if (prop && prop in this) {
77
- (this as any)[prop] = e.value;
78
- shouldUpdate = true;
79
- }
80
- } else if (e.key.startsWith("size.")) {
81
- shouldUpdate = true;
82
- }
83
-
84
- if (shouldUpdate) {
85
- this.updateRuler();
86
- }
87
- });
88
- }
89
-
90
- this.createLayer();
91
- context.eventBus.on("canvas:resized", this.onCanvasResized);
92
- this.updateRuler();
93
- }
94
-
95
- deactivate(context: ExtensionContext) {
96
- context.eventBus.off("canvas:resized", this.onCanvasResized);
97
- this.destroyLayer();
98
- this.canvasService = undefined;
99
- this.context = undefined;
100
- }
101
-
102
- contribute() {
103
- return {
104
- [ContributionPointIds.CONFIGURATIONS]: [
105
- {
106
- id: "ruler.thickness",
107
- type: "number",
108
- label: "Thickness",
109
- min: 10,
110
- max: 100,
111
- default: 20,
112
- },
113
- {
114
- id: "ruler.gap",
115
- type: "number",
116
- label: "Gap",
117
- min: 0,
118
- max: 100,
119
- default: 15,
120
- },
121
- {
122
- id: "ruler.backgroundColor",
123
- type: "color",
124
- label: "Background Color",
125
- default: "#f0f0f0",
126
- },
127
- {
128
- id: "ruler.textColor",
129
- type: "color",
130
- label: "Text Color",
131
- default: "#333333",
132
- },
133
- {
134
- id: "ruler.lineColor",
135
- type: "color",
136
- label: "Line Color",
137
- default: "#999999",
138
- },
139
- {
140
- id: "ruler.fontSize",
141
- type: "number",
142
- label: "Font Size",
143
- min: 8,
144
- max: 24,
145
- default: 10,
146
- },
147
- ] as ConfigurationContribution[],
148
- [ContributionPointIds.COMMANDS]: [
149
- {
150
- command: "setTheme",
151
- title: "Set Ruler Theme",
152
- handler: (
153
- theme: Partial<{
154
- backgroundColor: string;
155
- textColor: string;
156
- lineColor: string;
157
- fontSize: number;
158
- thickness: number;
159
- }>,
160
- ) => {
161
- const oldState = {
162
- backgroundColor: this.backgroundColor,
163
- textColor: this.textColor,
164
- lineColor: this.lineColor,
165
- fontSize: this.fontSize,
166
- thickness: this.thickness,
167
- };
168
- const newState = { ...oldState, ...theme };
169
- if (JSON.stringify(newState) === JSON.stringify(oldState))
170
- return true;
171
-
172
- Object.assign(this, newState);
173
- this.updateRuler();
174
- return true;
175
- },
176
- },
177
- ] as CommandContribution[],
178
- };
179
- }
180
-
181
- private getLayer() {
182
- return this.canvasService?.getLayer("ruler-overlay");
183
- }
184
-
185
- private createLayer() {
186
- if (!this.canvasService) return;
187
-
188
- const canvas = this.canvasService.canvas;
189
- const width = canvas.width || 800;
190
- const height = canvas.height || 600;
191
-
192
- const layer = this.canvasService.createLayer("ruler-overlay", {
193
- width,
194
- height,
195
- selectable: false,
196
- evented: false,
197
- left: 0,
198
- top: 0,
199
- originX: "left",
200
- originY: "top",
201
- });
202
-
203
- canvas.bringObjectToFront(layer);
204
- }
205
-
206
- private destroyLayer() {
207
- if (!this.canvasService) return;
208
- const layer = this.getLayer();
209
- if (layer) {
210
- this.canvasService.canvas.remove(layer);
211
- }
212
- }
213
-
214
- private createArrowLine(
215
- x1: number,
216
- y1: number,
217
- x2: number,
218
- y2: number,
219
- color: string,
220
- ): Group {
221
- const line = new Line([x1, y1, x2, y2], {
222
- stroke: color,
223
- strokeWidth: this.thickness / 20, // Scale stroke width relative to thickness (default 1)
224
- selectable: false,
225
- evented: false,
226
- });
227
-
228
- // Arrow size proportional to thickness
229
- const arrowSize = Math.max(4, this.thickness * 0.3);
230
- const angle = Math.atan2(y2 - y1, x2 - x1);
231
-
232
- // End Arrow (at x2, y2)
233
- const endArrow = new Polygon(
234
- [
235
- { x: 0, y: 0 },
236
- { x: -arrowSize, y: -arrowSize / 2 },
237
- { x: -arrowSize, y: arrowSize / 2 },
238
- ],
239
- {
240
- fill: color,
241
- left: x2,
242
- top: y2,
243
- originX: "right",
244
- originY: "center",
245
- angle: (angle * 180) / Math.PI,
246
- selectable: false,
247
- evented: false,
248
- },
249
- );
250
-
251
- // Start Arrow (at x1, y1)
252
- const startArrow = new Polygon(
253
- [
254
- { x: 0, y: 0 },
255
- { x: arrowSize, y: -arrowSize / 2 },
256
- { x: arrowSize, y: arrowSize / 2 },
257
- ],
258
- {
259
- fill: color,
260
- left: x1,
261
- top: y1,
262
- originX: "left",
263
- originY: "center",
264
- angle: (angle * 180) / Math.PI,
265
- selectable: false,
266
- evented: false,
267
- },
268
- );
269
-
270
- return new Group([line, startArrow, endArrow], {
271
- selectable: false,
272
- evented: false,
273
- });
274
- }
275
-
276
- private updateRuler() {
277
- if (!this.canvasService) return;
278
- const layer = this.getLayer();
279
- if (!layer) return;
280
-
281
- layer.remove(...layer.getObjects());
282
-
283
- const { backgroundColor, lineColor, textColor, fontSize } = this;
284
-
285
- const configService = this.context?.services.get<ConfigurationService>(
286
- "ConfigurationService",
287
- );
288
- if (!configService) return;
289
- const sizeState = readSizeState(configService);
290
- const layout = computeSceneLayout(this.canvasService, sizeState);
291
- if (!layout) return;
292
-
293
- const trimRect = layout.trimRect;
294
- const cutRect = layout.cutRect;
295
- const useCutAsRuler = layout.cutMode === "outset";
296
- const rulerRect = useCutAsRuler ? cutRect : trimRect;
297
- // Use gap configuration
298
- const gap = this.gap || 15;
299
-
300
- // New Bounding Box for Ruler
301
- const rulerLeft = rulerRect.left;
302
- const rulerTop = rulerRect.top;
303
- const rulerRight = rulerRect.left + rulerRect.width;
304
- const rulerBottom = rulerRect.top + rulerRect.height;
305
-
306
- // Display Dimensions (Physical)
307
- const displayWidthMm = useCutAsRuler
308
- ? layout.cutWidthMm
309
- : layout.trimWidthMm;
310
- const displayHeightMm = useCutAsRuler
311
- ? layout.cutHeightMm
312
- : layout.trimHeightMm;
313
- const displayUnit = sizeState.unit;
314
-
315
- // Ruler Placement Coordinates
316
- // Top Ruler: Above the top boundary
317
- const topRulerY = rulerTop - gap;
318
- const topRulerXStart = rulerLeft;
319
- const topRulerXEnd = rulerRight;
320
-
321
- // Left Ruler: Left of the left boundary
322
- const leftRulerX = rulerLeft - gap;
323
- const leftRulerYStart = rulerTop;
324
- const leftRulerYEnd = rulerBottom;
325
-
326
- // 1. Top Dimension Line (X-Axis)
327
- const topDimLine = this.createArrowLine(
328
- topRulerXStart,
329
- topRulerY,
330
- topRulerXEnd,
331
- topRulerY,
332
- lineColor,
333
- );
334
- layer.add(topDimLine);
335
-
336
- // Top Extension Lines
337
- const extLen = 5;
338
- layer.add(
339
- new Line(
340
- [
341
- topRulerXStart,
342
- topRulerY - extLen,
343
- topRulerXStart,
344
- topRulerY + extLen,
345
- ],
346
- {
347
- stroke: lineColor,
348
- strokeWidth: 1,
349
- selectable: false,
350
- evented: false,
351
- },
352
- ),
353
- );
354
- layer.add(
355
- new Line(
356
- [topRulerXEnd, topRulerY - extLen, topRulerXEnd, topRulerY + extLen],
357
- {
358
- stroke: lineColor,
359
- strokeWidth: 1,
360
- selectable: false,
361
- evented: false,
362
- },
363
- ),
364
- );
365
-
366
- // Top Text (Centered)
367
- const widthStr = formatMm(displayWidthMm, displayUnit);
368
- const topTextContent = `${widthStr} ${displayUnit}`;
369
- const topText = new Text(topTextContent, {
370
- left: topRulerXStart + (rulerRight - rulerLeft) / 2,
371
- top: topRulerY,
372
- fontSize: fontSize,
373
- fill: textColor,
374
- fontFamily: "Arial",
375
- originX: "center",
376
- originY: "center",
377
- backgroundColor: backgroundColor, // Background mask for readability
378
- selectable: false,
379
- evented: false,
380
- });
381
- // Add small padding to text background if Fabric supports it directly or via separate rect
382
- // Fabric Text backgroundColor is tight.
383
- layer.add(topText);
384
-
385
- // 2. Left Dimension Line (Y-Axis)
386
- const leftDimLine = this.createArrowLine(
387
- leftRulerX,
388
- leftRulerYStart,
389
- leftRulerX,
390
- leftRulerYEnd,
391
- lineColor,
392
- );
393
- layer.add(leftDimLine);
394
-
395
- // Left Extension Lines
396
- layer.add(
397
- new Line(
398
- [
399
- leftRulerX - extLen,
400
- leftRulerYStart,
401
- leftRulerX + extLen,
402
- leftRulerYStart,
403
- ],
404
- {
405
- stroke: lineColor,
406
- strokeWidth: 1,
407
- selectable: false,
408
- evented: false,
409
- },
410
- ),
411
- );
412
- layer.add(
413
- new Line(
414
- [
415
- leftRulerX - extLen,
416
- leftRulerYEnd,
417
- leftRulerX + extLen,
418
- leftRulerYEnd,
419
- ],
420
- {
421
- stroke: lineColor,
422
- strokeWidth: 1,
423
- selectable: false,
424
- evented: false,
425
- },
426
- ),
427
- );
428
-
429
- // Left Text (Centered, Rotated)
430
- const heightStr = formatMm(displayHeightMm, displayUnit);
431
- const leftTextContent = `${heightStr} ${displayUnit}`;
432
- const leftText = new Text(leftTextContent, {
433
- left: leftRulerX,
434
- top: leftRulerYStart + (rulerBottom - rulerTop) / 2,
435
- angle: -90,
436
- fontSize: fontSize,
437
- fill: textColor,
438
- fontFamily: "Arial",
439
- originX: "center",
440
- originY: "center",
441
- backgroundColor: backgroundColor,
442
- selectable: false,
443
- evented: false,
444
- });
445
- layer.add(leftText);
446
-
447
- // Always bring ruler to front
448
- this.canvasService.canvas.bringObjectToFront(layer);
449
- this.canvasService.canvas.requestRenderAll();
450
- }
451
- }
1
+ import {
2
+ Extension,
3
+ ExtensionContext,
4
+ ContributionPointIds,
5
+ CommandContribution,
6
+ ConfigurationContribution,
7
+ ConfigurationService,
8
+ } from "@pooder/core";
9
+ import { CanvasService, RenderObjectSpec } from "../services";
10
+ import {
11
+ buildSceneGeometry,
12
+ computeSceneLayout,
13
+ fromMm,
14
+ readSizeState,
15
+ } from "./sceneLayoutModel";
16
+ import type { Unit } from "../coordinate";
17
+
18
+ const RULER_LAYER_ID = "ruler-overlay";
19
+ const EXTENSION_LINE_LENGTH = 5;
20
+ const MIN_ARROW_SIZE = 4;
21
+ const THICKNESS_TO_STROKE_WIDTH_RATIO = 20;
22
+
23
+ const DEFAULT_THICKNESS = 20;
24
+ const DEFAULT_GAP = 45;
25
+ const DEFAULT_FONT_SIZE = 10;
26
+ const DEFAULT_BACKGROUND_COLOR = "#f0f0f0";
27
+ const DEFAULT_TEXT_COLOR = "#333333";
28
+ const DEFAULT_LINE_COLOR = "#999999";
29
+
30
+ const RULER_THICKNESS_MIN = 10;
31
+ const RULER_THICKNESS_MAX = 100;
32
+ const RULER_GAP_MIN = 0;
33
+ const RULER_GAP_MAX = 100;
34
+ const RULER_FONT_SIZE_MIN = 8;
35
+ const RULER_FONT_SIZE_MAX = 24;
36
+
37
+ type Point = { x: number; y: number };
38
+
39
+ export class RulerTool implements Extension {
40
+ id = "pooder.kit.ruler";
41
+
42
+ public metadata = {
43
+ name: "RulerTool",
44
+ };
45
+
46
+ private thickness = DEFAULT_THICKNESS;
47
+ private gap = DEFAULT_GAP;
48
+ private backgroundColor = DEFAULT_BACKGROUND_COLOR;
49
+ private textColor = DEFAULT_TEXT_COLOR;
50
+ private lineColor = DEFAULT_LINE_COLOR;
51
+ private fontSize = DEFAULT_FONT_SIZE;
52
+ private renderSeq = 0;
53
+ private readonly numericProps = new Set(["thickness", "gap", "fontSize"]);
54
+ private specs: RenderObjectSpec[] = [];
55
+ private renderProducerDisposable?: { dispose: () => void };
56
+
57
+ private canvasService?: CanvasService;
58
+ private context?: ExtensionContext;
59
+ private onCanvasResized = () => {
60
+ this.updateRuler();
61
+ };
62
+
63
+ constructor(
64
+ options?: Partial<{
65
+ thickness: number;
66
+ backgroundColor: string;
67
+ textColor: string;
68
+ lineColor: string;
69
+ fontSize: number;
70
+ gap: number;
71
+ }>,
72
+ ) {
73
+ if (options) {
74
+ Object.assign(this, options);
75
+ }
76
+ }
77
+
78
+ activate(context: ExtensionContext) {
79
+ this.context = context;
80
+ this.canvasService = context.services.get<CanvasService>("CanvasService");
81
+ if (!this.canvasService) {
82
+ console.warn("[RulerTool] CanvasService not found.");
83
+ return;
84
+ }
85
+ this.renderProducerDisposable?.dispose();
86
+ this.renderProducerDisposable = this.canvasService.registerRenderProducer(
87
+ this.id,
88
+ () => ({
89
+ rootLayerSpecs: {
90
+ [RULER_LAYER_ID]: this.specs,
91
+ },
92
+ replaceRootLayerIds: [RULER_LAYER_ID],
93
+ }),
94
+ { priority: 400 },
95
+ );
96
+
97
+ const configService = context.services.get<ConfigurationService>(
98
+ "ConfigurationService",
99
+ );
100
+ if (configService) {
101
+ this.syncConfig(configService);
102
+ configService.onAnyChange((e: { key: string; value: any }) => {
103
+ let shouldUpdate = false;
104
+ if (e.key.startsWith("ruler.")) {
105
+ const prop = e.key.split(".")[1];
106
+ if (prop && prop in this) {
107
+ if (this.numericProps.has(prop)) {
108
+ (this as any)[prop] = this.toFiniteNumber(
109
+ e.value,
110
+ (this as any)[prop],
111
+ );
112
+ } else {
113
+ (this as any)[prop] = e.value;
114
+ }
115
+ shouldUpdate = true;
116
+ this.log("config:update", {
117
+ key: e.key,
118
+ raw: e.value,
119
+ normalized: (this as any)[prop],
120
+ });
121
+ }
122
+ } else if (e.key.startsWith("size.")) {
123
+ shouldUpdate = true;
124
+ this.log("size:update", { key: e.key, value: e.value });
125
+ }
126
+
127
+ if (shouldUpdate) {
128
+ this.updateRuler();
129
+ }
130
+ });
131
+ }
132
+
133
+ context.eventBus.on("canvas:resized", this.onCanvasResized);
134
+ this.updateRuler();
135
+ }
136
+
137
+ deactivate(context: ExtensionContext) {
138
+ context.eventBus.off("canvas:resized", this.onCanvasResized);
139
+ this.specs = [];
140
+ this.renderProducerDisposable?.dispose();
141
+ this.renderProducerDisposable = undefined;
142
+ if (this.canvasService) {
143
+ void this.canvasService.flushRenderFromProducers();
144
+ }
145
+ this.canvasService = undefined;
146
+ this.context = undefined;
147
+ this.renderSeq = 0;
148
+ }
149
+
150
+ contribute() {
151
+ return {
152
+ [ContributionPointIds.CONFIGURATIONS]: [
153
+ {
154
+ id: "ruler.thickness",
155
+ type: "number",
156
+ label: "Thickness",
157
+ min: RULER_THICKNESS_MIN,
158
+ max: RULER_THICKNESS_MAX,
159
+ default: DEFAULT_THICKNESS,
160
+ },
161
+ {
162
+ id: "ruler.gap",
163
+ type: "number",
164
+ label: "Gap",
165
+ min: RULER_GAP_MIN,
166
+ max: RULER_GAP_MAX,
167
+ default: DEFAULT_GAP,
168
+ },
169
+ {
170
+ id: "ruler.backgroundColor",
171
+ type: "color",
172
+ label: "Background Color",
173
+ default: DEFAULT_BACKGROUND_COLOR,
174
+ },
175
+ {
176
+ id: "ruler.textColor",
177
+ type: "color",
178
+ label: "Text Color",
179
+ default: DEFAULT_TEXT_COLOR,
180
+ },
181
+ {
182
+ id: "ruler.lineColor",
183
+ type: "color",
184
+ label: "Line Color",
185
+ default: DEFAULT_LINE_COLOR,
186
+ },
187
+ {
188
+ id: "ruler.fontSize",
189
+ type: "number",
190
+ label: "Font Size",
191
+ min: RULER_FONT_SIZE_MIN,
192
+ max: RULER_FONT_SIZE_MAX,
193
+ default: DEFAULT_FONT_SIZE,
194
+ },
195
+ ] as ConfigurationContribution[],
196
+ [ContributionPointIds.COMMANDS]: [
197
+ {
198
+ command: "setTheme",
199
+ title: "Set Ruler Theme",
200
+ handler: (
201
+ theme: Partial<{
202
+ backgroundColor: string;
203
+ textColor: string;
204
+ lineColor: string;
205
+ fontSize: number;
206
+ thickness: number;
207
+ gap: number;
208
+ }>,
209
+ ) => {
210
+ const oldState = {
211
+ backgroundColor: this.backgroundColor,
212
+ textColor: this.textColor,
213
+ lineColor: this.lineColor,
214
+ fontSize: this.fontSize,
215
+ thickness: this.thickness,
216
+ gap: this.gap,
217
+ };
218
+ const newState = { ...oldState, ...theme };
219
+ if (JSON.stringify(newState) === JSON.stringify(oldState)) {
220
+ return true;
221
+ }
222
+
223
+ Object.assign(this, newState);
224
+ this.thickness = this.toFiniteNumber(
225
+ this.thickness,
226
+ DEFAULT_THICKNESS,
227
+ );
228
+ this.gap = this.toFiniteNumber(this.gap, DEFAULT_GAP);
229
+ this.fontSize = this.toFiniteNumber(
230
+ this.fontSize,
231
+ DEFAULT_FONT_SIZE,
232
+ );
233
+ this.updateRuler();
234
+ return true;
235
+ },
236
+ },
237
+ ] as CommandContribution[],
238
+ };
239
+ }
240
+
241
+ private log(step: string, payload?: Record<string, unknown>) {
242
+ if (payload) {
243
+ console.debug(`[RulerTool] ${step}`, payload);
244
+ return;
245
+ }
246
+ console.debug(`[RulerTool] ${step}`);
247
+ }
248
+
249
+ private syncConfig(configService: ConfigurationService) {
250
+ this.thickness = this.toFiniteNumber(
251
+ configService.get("ruler.thickness", this.thickness),
252
+ DEFAULT_THICKNESS,
253
+ );
254
+ this.gap = Math.max(
255
+ 0,
256
+ this.toFiniteNumber(
257
+ configService.get("ruler.gap", this.gap),
258
+ DEFAULT_GAP,
259
+ ),
260
+ );
261
+ this.backgroundColor = configService.get(
262
+ "ruler.backgroundColor",
263
+ this.backgroundColor,
264
+ );
265
+ this.textColor = configService.get("ruler.textColor", this.textColor);
266
+ this.lineColor = configService.get("ruler.lineColor", this.lineColor);
267
+ this.fontSize = this.toFiniteNumber(
268
+ configService.get("ruler.fontSize", this.fontSize),
269
+ DEFAULT_FONT_SIZE,
270
+ );
271
+
272
+ this.log("config:loaded", {
273
+ thickness: this.thickness,
274
+ gap: this.gap,
275
+ fontSize: this.fontSize,
276
+ backgroundColor: this.backgroundColor,
277
+ textColor: this.textColor,
278
+ lineColor: this.lineColor,
279
+ });
280
+ }
281
+
282
+ private toFiniteNumber(value: unknown, fallback: number): number {
283
+ const numeric = Number(value);
284
+ return Number.isFinite(numeric) ? numeric : fallback;
285
+ }
286
+
287
+ private toSceneDisplayLength(value: number): number {
288
+ if (!this.canvasService) return value;
289
+ return this.canvasService.toSceneLength(value);
290
+ }
291
+
292
+ private formatLengthMm(valueMm: number, unit: Unit): string {
293
+ const converted = fromMm(valueMm, unit);
294
+ const fractionDigits = unit === "in" ? 3 : 2;
295
+ return Number(converted.toFixed(fractionDigits)).toString();
296
+ }
297
+
298
+ private buildLinePath(start: Point, end: Point): string {
299
+ const dx = end.x - start.x;
300
+ const dy = end.y - start.y;
301
+ return `M 0 0 L ${dx} ${dy}`;
302
+ }
303
+
304
+ private buildStartArrowPath(size: number): string {
305
+ return `M 0 0 L ${size} ${-size / 2} L ${size} ${size / 2} Z`;
306
+ }
307
+
308
+ private buildEndArrowPath(size: number): string {
309
+ return `M 0 0 L ${-size} ${-size / 2} L ${-size} ${size / 2} Z`;
310
+ }
311
+
312
+ private createPathSpec(
313
+ id: string,
314
+ pathData: string,
315
+ position: Point,
316
+ options: {
317
+ stroke?: string | null;
318
+ fill?: string | null;
319
+ strokeWidth?: number;
320
+ originX?: "left" | "center" | "right";
321
+ originY?: "top" | "center" | "bottom";
322
+ angle?: number;
323
+ strokeLineCap?: "butt" | "round" | "square";
324
+ },
325
+ ): RenderObjectSpec {
326
+ return {
327
+ id,
328
+ type: "path",
329
+ data: {
330
+ id,
331
+ type: "ruler",
332
+ },
333
+ props: {
334
+ pathData,
335
+ left: position.x,
336
+ top: position.y,
337
+ originX: options.originX ?? "left",
338
+ originY: options.originY ?? "top",
339
+ angle: options.angle ?? 0,
340
+ stroke: options.stroke ?? null,
341
+ fill: options.fill ?? null,
342
+ strokeWidth: options.strokeWidth ?? 1,
343
+ strokeLineCap: options.strokeLineCap ?? "butt",
344
+ selectable: false,
345
+ evented: false,
346
+ excludeFromExport: true,
347
+ },
348
+ };
349
+ }
350
+
351
+ private createTextSpec(
352
+ id: string,
353
+ text: string,
354
+ position: Point,
355
+ angle: number = 0,
356
+ ): RenderObjectSpec {
357
+ return {
358
+ id,
359
+ type: "text",
360
+ data: {
361
+ id,
362
+ type: "ruler",
363
+ },
364
+ props: {
365
+ text,
366
+ left: position.x,
367
+ top: position.y,
368
+ angle,
369
+ fontSize: this.toSceneDisplayLength(this.fontSize),
370
+ fill: this.textColor,
371
+ fontFamily: "Arial",
372
+ originX: "center",
373
+ originY: "center",
374
+ backgroundColor: this.backgroundColor,
375
+ selectable: false,
376
+ evented: false,
377
+ excludeFromExport: true,
378
+ },
379
+ };
380
+ }
381
+
382
+ private buildRulerSpecs(input: {
383
+ left: number;
384
+ top: number;
385
+ right: number;
386
+ bottom: number;
387
+ widthLabel: string;
388
+ heightLabel: string;
389
+ }): RenderObjectSpec[] {
390
+ const { left, top, right, bottom, widthLabel, heightLabel } = input;
391
+ const gap = Math.max(
392
+ 0,
393
+ this.toSceneDisplayLength(this.toFiniteNumber(this.gap, DEFAULT_GAP)),
394
+ );
395
+ const topY = top - gap;
396
+ const leftX = left - gap;
397
+ const arrowSize = Math.max(
398
+ this.toSceneDisplayLength(MIN_ARROW_SIZE),
399
+ this.toSceneDisplayLength(this.thickness * 0.3),
400
+ );
401
+ const strokeWidth = Math.max(
402
+ this.toSceneDisplayLength(1),
403
+ this.toSceneDisplayLength(
404
+ this.thickness / THICKNESS_TO_STROKE_WIDTH_RATIO,
405
+ ),
406
+ );
407
+ const extensionLength = this.toSceneDisplayLength(EXTENSION_LINE_LENGTH);
408
+ const topLineAngleDeg = 0;
409
+ const leftLineAngleDeg = 90;
410
+
411
+ // Keep dimension line inside the arrow heads so it doesn't visually overflow.
412
+ const topMidX = left + (right - left) / 2;
413
+ const leftMidY = top + (bottom - top) / 2;
414
+ const topLineStartX = Math.min(left + arrowSize, topMidX);
415
+ const topLineEndX = Math.max(right - arrowSize, topMidX);
416
+ const leftLineStartY = Math.min(top + arrowSize, leftMidY);
417
+ const leftLineEndY = Math.max(bottom - arrowSize, leftMidY);
418
+
419
+ const specs: RenderObjectSpec[] = [];
420
+
421
+ specs.push(
422
+ this.createPathSpec(
423
+ "ruler.top.line",
424
+ this.buildLinePath(
425
+ { x: topLineStartX, y: topY },
426
+ { x: topLineEndX, y: topY },
427
+ ),
428
+ { x: topLineStartX, y: topY },
429
+ {
430
+ stroke: this.lineColor,
431
+ strokeWidth,
432
+ strokeLineCap: "butt",
433
+ },
434
+ ),
435
+ this.createPathSpec(
436
+ "ruler.top.arrow.start",
437
+ this.buildStartArrowPath(arrowSize),
438
+ { x: left, y: topY },
439
+ {
440
+ fill: this.lineColor,
441
+ stroke: this.lineColor,
442
+ strokeWidth: this.toSceneDisplayLength(1),
443
+ originX: "left",
444
+ originY: "center",
445
+ angle: topLineAngleDeg,
446
+ },
447
+ ),
448
+ this.createPathSpec(
449
+ "ruler.top.arrow.end",
450
+ this.buildEndArrowPath(arrowSize),
451
+ { x: right, y: topY },
452
+ {
453
+ fill: this.lineColor,
454
+ stroke: this.lineColor,
455
+ strokeWidth: this.toSceneDisplayLength(1),
456
+ originX: "right",
457
+ originY: "center",
458
+ angle: topLineAngleDeg,
459
+ },
460
+ ),
461
+ this.createPathSpec(
462
+ "ruler.top.ext.start",
463
+ this.buildLinePath(
464
+ { x: left, y: topY - extensionLength },
465
+ { x: left, y: topY + extensionLength },
466
+ ),
467
+ { x: left, y: topY - extensionLength },
468
+ {
469
+ stroke: this.lineColor,
470
+ strokeWidth: this.toSceneDisplayLength(1),
471
+ },
472
+ ),
473
+ this.createPathSpec(
474
+ "ruler.top.ext.end",
475
+ this.buildLinePath(
476
+ { x: right, y: topY - extensionLength },
477
+ { x: right, y: topY + extensionLength },
478
+ ),
479
+ { x: right, y: topY - extensionLength },
480
+ {
481
+ stroke: this.lineColor,
482
+ strokeWidth: this.toSceneDisplayLength(1),
483
+ },
484
+ ),
485
+ this.createTextSpec("ruler.top.label", widthLabel, {
486
+ x: left + (right - left) / 2,
487
+ y: topY,
488
+ }),
489
+ );
490
+
491
+ specs.push(
492
+ this.createPathSpec(
493
+ "ruler.left.line",
494
+ this.buildLinePath(
495
+ { x: leftX, y: leftLineStartY },
496
+ { x: leftX, y: leftLineEndY },
497
+ ),
498
+ { x: leftX, y: leftLineStartY },
499
+ {
500
+ stroke: this.lineColor,
501
+ strokeWidth,
502
+ strokeLineCap: "butt",
503
+ },
504
+ ),
505
+ this.createPathSpec(
506
+ "ruler.left.arrow.start",
507
+ this.buildStartArrowPath(arrowSize),
508
+ { x: leftX, y: top },
509
+ {
510
+ fill: this.lineColor,
511
+ stroke: this.lineColor,
512
+ strokeWidth: this.toSceneDisplayLength(1),
513
+ originX: "left",
514
+ originY: "center",
515
+ angle: leftLineAngleDeg,
516
+ },
517
+ ),
518
+ this.createPathSpec(
519
+ "ruler.left.arrow.end",
520
+ this.buildEndArrowPath(arrowSize),
521
+ { x: leftX, y: bottom },
522
+ {
523
+ fill: this.lineColor,
524
+ stroke: this.lineColor,
525
+ strokeWidth: this.toSceneDisplayLength(1),
526
+ originX: "right",
527
+ originY: "center",
528
+ angle: leftLineAngleDeg,
529
+ },
530
+ ),
531
+ this.createPathSpec(
532
+ "ruler.left.ext.start",
533
+ this.buildLinePath(
534
+ { x: leftX - extensionLength, y: top },
535
+ { x: leftX + extensionLength, y: top },
536
+ ),
537
+ { x: leftX - extensionLength, y: top },
538
+ {
539
+ stroke: this.lineColor,
540
+ strokeWidth: this.toSceneDisplayLength(1),
541
+ },
542
+ ),
543
+ this.createPathSpec(
544
+ "ruler.left.ext.end",
545
+ this.buildLinePath(
546
+ { x: leftX - extensionLength, y: bottom },
547
+ { x: leftX + extensionLength, y: bottom },
548
+ ),
549
+ { x: leftX - extensionLength, y: bottom },
550
+ {
551
+ stroke: this.lineColor,
552
+ strokeWidth: this.toSceneDisplayLength(1),
553
+ },
554
+ ),
555
+ this.createTextSpec(
556
+ "ruler.left.label",
557
+ heightLabel,
558
+ {
559
+ x: leftX,
560
+ y: top + (bottom - top) / 2,
561
+ },
562
+ -90,
563
+ ),
564
+ );
565
+
566
+ return specs;
567
+ }
568
+
569
+ private updateRuler() {
570
+ void this.updateRulerAsync();
571
+ }
572
+
573
+ private async updateRulerAsync() {
574
+ if (!this.canvasService) return;
575
+ const configService = this.context?.services.get<ConfigurationService>(
576
+ "ConfigurationService",
577
+ );
578
+ if (!configService) return;
579
+
580
+ const seq = ++this.renderSeq;
581
+ const sizeState = readSizeState(configService);
582
+ const layout = computeSceneLayout(this.canvasService, sizeState);
583
+
584
+ this.log("render:start", {
585
+ seq,
586
+ unit: sizeState.unit,
587
+ gap: this.gap,
588
+ thickness: this.thickness,
589
+ fontSize: this.fontSize,
590
+ hasLayout: !!layout,
591
+ scale: layout?.scale ?? null,
592
+ });
593
+
594
+ if (!layout || layout.scale <= 0) {
595
+ if (seq !== this.renderSeq) return;
596
+ this.log("render:skip", { seq, reason: "invalid-layout" });
597
+ this.specs = [];
598
+ await this.canvasService.flushRenderFromProducers();
599
+ return;
600
+ }
601
+
602
+ const geometry = buildSceneGeometry(configService, layout);
603
+ if (geometry.unit !== "px") {
604
+ console.warn("[RulerTool] Unexpected geometry unit.", geometry.unit);
605
+ }
606
+ const centerScene = this.canvasService.toScenePoint({
607
+ x: geometry.x,
608
+ y: geometry.y,
609
+ });
610
+ const widthScene = this.canvasService.toSceneLength(geometry.width);
611
+ const heightScene = this.canvasService.toSceneLength(geometry.height);
612
+ const rulerLeft = centerScene.x - widthScene / 2;
613
+ const rulerTop = centerScene.y - heightScene / 2;
614
+ const rulerRight = rulerLeft + widthScene;
615
+ const rulerBottom = rulerTop + heightScene;
616
+
617
+ const widthMm = widthScene;
618
+ const heightMm = heightScene;
619
+ const unit = sizeState.unit;
620
+ const widthLabel = `${this.formatLengthMm(widthMm, unit)} ${unit}`;
621
+ const heightLabel = `${this.formatLengthMm(heightMm, unit)} ${unit}`;
622
+ const specs = this.buildRulerSpecs({
623
+ left: rulerLeft,
624
+ top: rulerTop,
625
+ right: rulerRight,
626
+ bottom: rulerBottom,
627
+ widthLabel,
628
+ heightLabel,
629
+ });
630
+
631
+ this.log("render:geometry", {
632
+ seq,
633
+ left: rulerLeft,
634
+ top: rulerTop,
635
+ right: rulerRight,
636
+ bottom: rulerBottom,
637
+ widthScene,
638
+ heightScene,
639
+ widthMm,
640
+ heightMm,
641
+ specCount: specs.length,
642
+ });
643
+
644
+ if (seq !== this.renderSeq) return;
645
+
646
+ this.specs = specs;
647
+ await this.canvasService.flushRenderFromProducers();
648
+ if (seq !== this.renderSeq) return;
649
+
650
+ this.canvasService.bringLayerToFront(RULER_LAYER_ID);
651
+ this.canvasService.requestRenderAll();
652
+ this.log("render:done", { seq });
653
+ }
654
+ }