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