@pooder/kit 5.3.0 → 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 (73) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/index.d.mts +249 -36
  3. package/dist/index.d.ts +249 -36
  4. package/dist/index.js +2374 -1049
  5. package/dist/index.mjs +2375 -1050
  6. package/package.json +1 -1
  7. package/src/extensions/background.ts +178 -85
  8. package/src/extensions/dieline.ts +1149 -1030
  9. package/src/extensions/dielineShape.ts +109 -0
  10. package/src/extensions/feature.ts +482 -366
  11. package/src/extensions/film.ts +148 -76
  12. package/src/extensions/geometry.ts +210 -44
  13. package/src/extensions/image.ts +244 -114
  14. package/src/extensions/ruler.ts +471 -268
  15. package/src/extensions/sceneLayoutModel.ts +28 -6
  16. package/src/extensions/sceneVisibility.ts +3 -10
  17. package/src/extensions/tracer.ts +1019 -980
  18. package/src/extensions/white-ink.ts +284 -231
  19. package/src/services/CanvasService.ts +543 -11
  20. package/src/services/renderSpec.ts +37 -2
  21. package/.test-dist/src/CanvasService.js +0 -249
  22. package/.test-dist/src/ViewportSystem.js +0 -75
  23. package/.test-dist/src/background.js +0 -203
  24. package/.test-dist/src/bridgeSelection.js +0 -20
  25. package/.test-dist/src/constraints.js +0 -237
  26. package/.test-dist/src/coordinate.js +0 -74
  27. package/.test-dist/src/dieline.js +0 -818
  28. package/.test-dist/src/edgeScale.js +0 -12
  29. package/.test-dist/src/extensions/background.js +0 -203
  30. package/.test-dist/src/extensions/bridgeSelection.js +0 -20
  31. package/.test-dist/src/extensions/constraints.js +0 -237
  32. package/.test-dist/src/extensions/dieline.js +0 -828
  33. package/.test-dist/src/extensions/edgeScale.js +0 -12
  34. package/.test-dist/src/extensions/feature.js +0 -825
  35. package/.test-dist/src/extensions/featureComplete.js +0 -32
  36. package/.test-dist/src/extensions/film.js +0 -167
  37. package/.test-dist/src/extensions/geometry.js +0 -545
  38. package/.test-dist/src/extensions/image.js +0 -1529
  39. package/.test-dist/src/extensions/index.js +0 -30
  40. package/.test-dist/src/extensions/maskOps.js +0 -279
  41. package/.test-dist/src/extensions/mirror.js +0 -104
  42. package/.test-dist/src/extensions/ruler.js +0 -345
  43. package/.test-dist/src/extensions/sceneLayout.js +0 -96
  44. package/.test-dist/src/extensions/sceneLayoutModel.js +0 -196
  45. package/.test-dist/src/extensions/sceneVisibility.js +0 -62
  46. package/.test-dist/src/extensions/size.js +0 -331
  47. package/.test-dist/src/extensions/tracer.js +0 -538
  48. package/.test-dist/src/extensions/white-ink.js +0 -1190
  49. package/.test-dist/src/extensions/wrappedOffsets.js +0 -33
  50. package/.test-dist/src/feature.js +0 -826
  51. package/.test-dist/src/featureComplete.js +0 -32
  52. package/.test-dist/src/film.js +0 -167
  53. package/.test-dist/src/geometry.js +0 -506
  54. package/.test-dist/src/image.js +0 -1250
  55. package/.test-dist/src/index.js +0 -18
  56. package/.test-dist/src/maskOps.js +0 -270
  57. package/.test-dist/src/mirror.js +0 -104
  58. package/.test-dist/src/renderSpec.js +0 -2
  59. package/.test-dist/src/ruler.js +0 -343
  60. package/.test-dist/src/sceneLayout.js +0 -99
  61. package/.test-dist/src/sceneLayoutModel.js +0 -196
  62. package/.test-dist/src/sceneView.js +0 -40
  63. package/.test-dist/src/sceneVisibility.js +0 -42
  64. package/.test-dist/src/services/CanvasService.js +0 -249
  65. package/.test-dist/src/services/ViewportSystem.js +0 -76
  66. package/.test-dist/src/services/index.js +0 -24
  67. package/.test-dist/src/services/renderSpec.js +0 -2
  68. package/.test-dist/src/size.js +0 -332
  69. package/.test-dist/src/tracer.js +0 -544
  70. package/.test-dist/src/units.js +0 -30
  71. package/.test-dist/src/white-ink.js +0 -829
  72. package/.test-dist/src/wrappedOffsets.js +0 -33
  73. package/.test-dist/tests/run.js +0 -94
@@ -6,10 +6,35 @@ import {
6
6
  ConfigurationContribution,
7
7
  ConfigurationService,
8
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";
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 };
13
38
 
14
39
  export class RulerTool implements Extension {
15
40
  id = "pooder.kit.ruler";
@@ -18,12 +43,16 @@ export class RulerTool implements Extension {
18
43
  name: "RulerTool",
19
44
  };
20
45
 
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;
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 };
27
56
 
28
57
  private canvasService?: CanvasService;
29
58
  private context?: ExtensionContext;
@@ -38,6 +67,7 @@ export class RulerTool implements Extension {
38
67
  textColor: string;
39
68
  lineColor: string;
40
69
  fontSize: number;
70
+ gap: number;
41
71
  }>,
42
72
  ) {
43
73
  if (options) {
@@ -49,36 +79,49 @@ export class RulerTool implements Extension {
49
79
  this.context = context;
50
80
  this.canvasService = context.services.get<CanvasService>("CanvasService");
51
81
  if (!this.canvasService) {
52
- console.warn("CanvasService not found for RulerTool");
82
+ console.warn("[RulerTool] CanvasService not found.");
53
83
  return;
54
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
+ );
55
96
 
56
97
  const configService = context.services.get<ConfigurationService>(
57
98
  "ConfigurationService",
58
99
  );
59
100
  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
101
+ this.syncConfig(configService);
72
102
  configService.onAnyChange((e: { key: string; value: any }) => {
73
103
  let shouldUpdate = false;
74
104
  if (e.key.startsWith("ruler.")) {
75
105
  const prop = e.key.split(".")[1];
76
106
  if (prop && prop in this) {
77
- (this as any)[prop] = e.value;
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
+ }
78
115
  shouldUpdate = true;
116
+ this.log("config:update", {
117
+ key: e.key,
118
+ raw: e.value,
119
+ normalized: (this as any)[prop],
120
+ });
79
121
  }
80
122
  } else if (e.key.startsWith("size.")) {
81
123
  shouldUpdate = true;
124
+ this.log("size:update", { key: e.key, value: e.value });
82
125
  }
83
126
 
84
127
  if (shouldUpdate) {
@@ -87,16 +130,21 @@ export class RulerTool implements Extension {
87
130
  });
88
131
  }
89
132
 
90
- this.createLayer();
91
133
  context.eventBus.on("canvas:resized", this.onCanvasResized);
92
134
  this.updateRuler();
93
135
  }
94
136
 
95
137
  deactivate(context: ExtensionContext) {
96
138
  context.eventBus.off("canvas:resized", this.onCanvasResized);
97
- this.destroyLayer();
139
+ this.specs = [];
140
+ this.renderProducerDisposable?.dispose();
141
+ this.renderProducerDisposable = undefined;
142
+ if (this.canvasService) {
143
+ void this.canvasService.flushRenderFromProducers();
144
+ }
98
145
  this.canvasService = undefined;
99
146
  this.context = undefined;
147
+ this.renderSeq = 0;
100
148
  }
101
149
 
102
150
  contribute() {
@@ -106,43 +154,43 @@ export class RulerTool implements Extension {
106
154
  id: "ruler.thickness",
107
155
  type: "number",
108
156
  label: "Thickness",
109
- min: 10,
110
- max: 100,
111
- default: 20,
157
+ min: RULER_THICKNESS_MIN,
158
+ max: RULER_THICKNESS_MAX,
159
+ default: DEFAULT_THICKNESS,
112
160
  },
113
161
  {
114
162
  id: "ruler.gap",
115
163
  type: "number",
116
164
  label: "Gap",
117
- min: 0,
118
- max: 100,
119
- default: 15,
165
+ min: RULER_GAP_MIN,
166
+ max: RULER_GAP_MAX,
167
+ default: DEFAULT_GAP,
120
168
  },
121
169
  {
122
170
  id: "ruler.backgroundColor",
123
171
  type: "color",
124
172
  label: "Background Color",
125
- default: "#f0f0f0",
173
+ default: DEFAULT_BACKGROUND_COLOR,
126
174
  },
127
175
  {
128
176
  id: "ruler.textColor",
129
177
  type: "color",
130
178
  label: "Text Color",
131
- default: "#333333",
179
+ default: DEFAULT_TEXT_COLOR,
132
180
  },
133
181
  {
134
182
  id: "ruler.lineColor",
135
183
  type: "color",
136
184
  label: "Line Color",
137
- default: "#999999",
185
+ default: DEFAULT_LINE_COLOR,
138
186
  },
139
187
  {
140
188
  id: "ruler.fontSize",
141
189
  type: "number",
142
190
  label: "Font Size",
143
- min: 8,
144
- max: 24,
145
- default: 10,
191
+ min: RULER_FONT_SIZE_MIN,
192
+ max: RULER_FONT_SIZE_MAX,
193
+ default: DEFAULT_FONT_SIZE,
146
194
  },
147
195
  ] as ConfigurationContribution[],
148
196
  [ContributionPointIds.COMMANDS]: [
@@ -156,6 +204,7 @@ export class RulerTool implements Extension {
156
204
  lineColor: string;
157
205
  fontSize: number;
158
206
  thickness: number;
207
+ gap: number;
159
208
  }>,
160
209
  ) => {
161
210
  const oldState = {
@@ -164,12 +213,23 @@ export class RulerTool implements Extension {
164
213
  lineColor: this.lineColor,
165
214
  fontSize: this.fontSize,
166
215
  thickness: this.thickness,
216
+ gap: this.gap,
167
217
  };
168
218
  const newState = { ...oldState, ...theme };
169
- if (JSON.stringify(newState) === JSON.stringify(oldState))
219
+ if (JSON.stringify(newState) === JSON.stringify(oldState)) {
170
220
  return true;
221
+ }
171
222
 
172
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
+ );
173
233
  this.updateRuler();
174
234
  return true;
175
235
  },
@@ -178,274 +238,417 @@ export class RulerTool implements Extension {
178
238
  };
179
239
  }
180
240
 
181
- private getLayer() {
182
- return this.canvasService?.getLayer("ruler-overlay");
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}`);
183
247
  }
184
248
 
185
- private createLayer() {
186
- if (!this.canvasService) return;
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
+ );
187
271
 
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",
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,
201
279
  });
280
+ }
202
281
 
203
- canvas.bringObjectToFront(layer);
282
+ private toFiniteNumber(value: unknown, fallback: number): number {
283
+ const numeric = Number(value);
284
+ return Number.isFinite(numeric) ? numeric : fallback;
204
285
  }
205
286
 
206
- private destroyLayer() {
207
- if (!this.canvasService) return;
208
- const layer = this.getLayer();
209
- if (layer) {
210
- this.canvasService.canvas.remove(layer);
211
- }
287
+ private toSceneDisplayLength(value: number): number {
288
+ if (!this.canvasService) return value;
289
+ return this.canvasService.toSceneLength(value);
212
290
  }
213
291
 
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
- });
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
+ }
227
297
 
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,
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",
246
344
  selectable: false,
247
345
  evented: false,
346
+ excludeFromExport: true,
248
347
  },
249
- );
348
+ };
349
+ }
250
350
 
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",
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",
263
373
  originY: "center",
264
- angle: (angle * 180) / Math.PI,
374
+ backgroundColor: this.backgroundColor,
265
375
  selectable: false,
266
376
  evented: false,
377
+ excludeFromExport: true,
267
378
  },
268
- );
269
-
270
- return new Group([line, startArrow, endArrow], {
271
- selectable: false,
272
- evented: false,
273
- });
379
+ };
274
380
  }
275
381
 
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",
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)),
287
394
  );
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,
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
+ ),
333
406
  );
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
- ],
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 },
346
429
  {
347
- stroke: lineColor,
348
- strokeWidth: 1,
349
- selectable: false,
350
- evented: false,
430
+ stroke: this.lineColor,
431
+ strokeWidth,
432
+ strokeLineCap: "butt",
351
433
  },
352
434
  ),
353
- );
354
- layer.add(
355
- new Line(
356
- [topRulerXEnd, topRulerY - extLen, topRulerXEnd, topRulerY + extLen],
435
+ this.createPathSpec(
436
+ "ruler.top.arrow.start",
437
+ this.buildStartArrowPath(arrowSize),
438
+ { x: left, y: topY },
357
439
  {
358
- stroke: lineColor,
359
- strokeWidth: 1,
360
- selectable: false,
361
- evented: false,
440
+ fill: this.lineColor,
441
+ stroke: this.lineColor,
442
+ strokeWidth: this.toSceneDisplayLength(1),
443
+ originX: "left",
444
+ originY: "center",
445
+ angle: topLineAngleDeg,
362
446
  },
363
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
+ }),
364
489
  );
365
490
 
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
- ],
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 },
404
499
  {
405
- stroke: lineColor,
406
- strokeWidth: 1,
407
- selectable: false,
408
- evented: false,
500
+ stroke: this.lineColor,
501
+ strokeWidth,
502
+ strokeLineCap: "butt",
409
503
  },
410
504
  ),
411
- );
412
- layer.add(
413
- new Line(
414
- [
415
- leftRulerX - extLen,
416
- leftRulerYEnd,
417
- leftRulerX + extLen,
418
- leftRulerYEnd,
419
- ],
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,
420
558
  {
421
- stroke: lineColor,
422
- strokeWidth: 1,
423
- selectable: false,
424
- evented: false,
559
+ x: leftX,
560
+ y: top + (bottom - top) / 2,
425
561
  },
562
+ -90,
426
563
  ),
427
564
  );
428
565
 
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,
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,
444
642
  });
445
- layer.add(leftText);
446
643
 
447
- // Always bring ruler to front
448
- this.canvasService.canvas.bringObjectToFront(layer);
449
- this.canvasService.canvas.requestRenderAll();
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 });
450
653
  }
451
654
  }