@pooder/kit 5.4.0 → 6.0.1

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 (69) hide show
  1. package/.test-dist/src/coordinate.js +74 -0
  2. package/.test-dist/src/extensions/background.js +547 -0
  3. package/.test-dist/src/extensions/bridgeSelection.js +20 -0
  4. package/.test-dist/src/extensions/constraints.js +237 -0
  5. package/.test-dist/src/extensions/dieline.js +935 -0
  6. package/.test-dist/src/extensions/dielineShape.js +66 -0
  7. package/.test-dist/src/extensions/edgeScale.js +12 -0
  8. package/.test-dist/src/extensions/feature.js +910 -0
  9. package/.test-dist/src/extensions/featureComplete.js +32 -0
  10. package/.test-dist/src/extensions/film.js +226 -0
  11. package/.test-dist/src/extensions/geometry.js +609 -0
  12. package/.test-dist/src/extensions/image.js +1788 -0
  13. package/.test-dist/src/extensions/index.js +28 -0
  14. package/.test-dist/src/extensions/maskOps.js +334 -0
  15. package/.test-dist/src/extensions/mirror.js +104 -0
  16. package/.test-dist/src/extensions/ruler.js +442 -0
  17. package/.test-dist/src/extensions/sceneLayout.js +96 -0
  18. package/.test-dist/src/extensions/sceneLayoutModel.js +202 -0
  19. package/.test-dist/src/extensions/sceneVisibility.js +55 -0
  20. package/.test-dist/src/extensions/size.js +331 -0
  21. package/.test-dist/src/extensions/tracer.js +709 -0
  22. package/.test-dist/src/extensions/white-ink.js +1200 -0
  23. package/.test-dist/src/extensions/wrappedOffsets.js +33 -0
  24. package/.test-dist/src/index.js +18 -0
  25. package/.test-dist/src/services/CanvasService.js +1032 -0
  26. package/.test-dist/src/services/ViewportSystem.js +76 -0
  27. package/.test-dist/src/services/index.js +25 -0
  28. package/.test-dist/src/services/renderSpec.js +2 -0
  29. package/.test-dist/src/services/visibility.js +57 -0
  30. package/.test-dist/src/units.js +30 -0
  31. package/.test-dist/tests/run.js +150 -0
  32. package/CHANGELOG.md +12 -0
  33. package/dist/index.d.mts +164 -62
  34. package/dist/index.d.ts +164 -62
  35. package/dist/index.js +2433 -1719
  36. package/dist/index.mjs +2442 -1723
  37. package/package.json +1 -1
  38. package/src/coordinate.ts +106 -106
  39. package/src/extensions/background.ts +716 -323
  40. package/src/extensions/bridgeSelection.ts +17 -17
  41. package/src/extensions/constraints.ts +322 -322
  42. package/src/extensions/dieline.ts +1173 -1149
  43. package/src/extensions/dielineShape.ts +109 -109
  44. package/src/extensions/edgeScale.ts +19 -19
  45. package/src/extensions/feature.ts +1140 -1137
  46. package/src/extensions/featureComplete.ts +46 -46
  47. package/src/extensions/film.ts +270 -266
  48. package/src/extensions/geometry.ts +851 -885
  49. package/src/extensions/image.ts +2240 -2054
  50. package/src/extensions/index.ts +10 -11
  51. package/src/extensions/maskOps.ts +283 -283
  52. package/src/extensions/mirror.ts +128 -128
  53. package/src/extensions/ruler.ts +664 -654
  54. package/src/extensions/sceneLayout.ts +140 -140
  55. package/src/extensions/sceneLayoutModel.ts +364 -364
  56. package/src/extensions/size.ts +389 -389
  57. package/src/extensions/tracer.ts +1019 -1019
  58. package/src/extensions/white-ink.ts +1508 -1575
  59. package/src/extensions/wrappedOffsets.ts +33 -33
  60. package/src/index.ts +2 -2
  61. package/src/services/CanvasService.ts +1317 -832
  62. package/src/services/ViewportSystem.ts +95 -95
  63. package/src/services/index.ts +4 -3
  64. package/src/services/renderSpec.ts +85 -53
  65. package/src/services/visibility.ts +83 -0
  66. package/src/units.ts +27 -27
  67. package/tests/run.ts +258 -118
  68. package/tsconfig.test.json +15 -15
  69. package/src/extensions/sceneVisibility.ts +0 -64
@@ -1,654 +1,664 @@
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
- }
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
+ 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
+ );
108
+
109
+ const configService = context.services.get<ConfigurationService>(
110
+ "ConfigurationService",
111
+ );
112
+ if (configService) {
113
+ this.syncConfig(configService);
114
+ configService.onAnyChange((e: { key: string; value: any }) => {
115
+ let shouldUpdate = false;
116
+ if (e.key.startsWith("ruler.")) {
117
+ const prop = e.key.split(".")[1];
118
+ if (prop && prop in this) {
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
+ }
127
+ shouldUpdate = true;
128
+ this.log("config:update", {
129
+ key: e.key,
130
+ raw: e.value,
131
+ normalized: (this as any)[prop],
132
+ });
133
+ }
134
+ } else if (e.key.startsWith("size.")) {
135
+ shouldUpdate = true;
136
+ this.log("size:update", { key: e.key, value: e.value });
137
+ }
138
+
139
+ if (shouldUpdate) {
140
+ this.updateRuler();
141
+ }
142
+ });
143
+ }
144
+
145
+ context.eventBus.on("canvas:resized", this.onCanvasResized);
146
+ this.updateRuler();
147
+ }
148
+
149
+ deactivate(context: ExtensionContext) {
150
+ context.eventBus.off("canvas:resized", this.onCanvasResized);
151
+ this.specs = [];
152
+ this.renderProducerDisposable?.dispose();
153
+ this.renderProducerDisposable = undefined;
154
+ if (this.canvasService) {
155
+ void this.canvasService.flushRenderFromProducers();
156
+ }
157
+ this.canvasService = undefined;
158
+ this.context = undefined;
159
+ this.renderSeq = 0;
160
+ }
161
+
162
+ contribute() {
163
+ return {
164
+ [ContributionPointIds.CONFIGURATIONS]: [
165
+ {
166
+ id: "ruler.thickness",
167
+ type: "number",
168
+ label: "Thickness",
169
+ min: RULER_THICKNESS_MIN,
170
+ max: RULER_THICKNESS_MAX,
171
+ default: DEFAULT_THICKNESS,
172
+ },
173
+ {
174
+ id: "ruler.gap",
175
+ type: "number",
176
+ label: "Gap",
177
+ min: RULER_GAP_MIN,
178
+ max: RULER_GAP_MAX,
179
+ default: DEFAULT_GAP,
180
+ },
181
+ {
182
+ id: "ruler.backgroundColor",
183
+ type: "color",
184
+ label: "Background Color",
185
+ default: DEFAULT_BACKGROUND_COLOR,
186
+ },
187
+ {
188
+ id: "ruler.textColor",
189
+ type: "color",
190
+ label: "Text Color",
191
+ default: DEFAULT_TEXT_COLOR,
192
+ },
193
+ {
194
+ id: "ruler.lineColor",
195
+ type: "color",
196
+ label: "Line Color",
197
+ default: DEFAULT_LINE_COLOR,
198
+ },
199
+ {
200
+ id: "ruler.fontSize",
201
+ type: "number",
202
+ label: "Font Size",
203
+ min: RULER_FONT_SIZE_MIN,
204
+ max: RULER_FONT_SIZE_MAX,
205
+ default: DEFAULT_FONT_SIZE,
206
+ },
207
+ ] as ConfigurationContribution[],
208
+ [ContributionPointIds.COMMANDS]: [
209
+ {
210
+ command: "setTheme",
211
+ title: "Set Ruler Theme",
212
+ handler: (
213
+ theme: Partial<{
214
+ backgroundColor: string;
215
+ textColor: string;
216
+ lineColor: string;
217
+ fontSize: number;
218
+ thickness: number;
219
+ gap: number;
220
+ }>,
221
+ ) => {
222
+ const oldState = {
223
+ backgroundColor: this.backgroundColor,
224
+ textColor: this.textColor,
225
+ lineColor: this.lineColor,
226
+ fontSize: this.fontSize,
227
+ thickness: this.thickness,
228
+ gap: this.gap,
229
+ };
230
+ const newState = { ...oldState, ...theme };
231
+ if (JSON.stringify(newState) === JSON.stringify(oldState)) {
232
+ return true;
233
+ }
234
+
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
+ );
245
+ this.updateRuler();
246
+ return true;
247
+ },
248
+ },
249
+ ] as CommandContribution[],
250
+ };
251
+ }
252
+
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}`);
259
+ }
260
+
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
+ );
283
+
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,
291
+ });
292
+ }
293
+
294
+ private toFiniteNumber(value: unknown, fallback: number): number {
295
+ const numeric = Number(value);
296
+ return Number.isFinite(numeric) ? numeric : fallback;
297
+ }
298
+
299
+ private toSceneDisplayLength(value: number): number {
300
+ if (!this.canvasService) return value;
301
+ return this.canvasService.toSceneLength(value);
302
+ }
303
+
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
+ }
309
+
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",
356
+ selectable: false,
357
+ evented: false,
358
+ excludeFromExport: true,
359
+ },
360
+ };
361
+ }
362
+
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",
385
+ originY: "center",
386
+ backgroundColor: this.backgroundColor,
387
+ selectable: false,
388
+ evented: false,
389
+ excludeFromExport: true,
390
+ },
391
+ };
392
+ }
393
+
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)),
406
+ );
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),
412
+ );
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 },
441
+ {
442
+ stroke: this.lineColor,
443
+ strokeWidth,
444
+ strokeLineCap: "butt",
445
+ },
446
+ ),
447
+ this.createPathSpec(
448
+ "ruler.top.arrow.start",
449
+ this.buildStartArrowPath(arrowSize),
450
+ { x: left, y: topY },
451
+ {
452
+ fill: this.lineColor,
453
+ stroke: this.lineColor,
454
+ strokeWidth: this.toSceneDisplayLength(1),
455
+ originX: "left",
456
+ originY: "center",
457
+ angle: topLineAngleDeg,
458
+ },
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
+ }),
501
+ );
502
+
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 },
511
+ {
512
+ stroke: this.lineColor,
513
+ strokeWidth,
514
+ strokeLineCap: "butt",
515
+ },
516
+ ),
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,
570
+ {
571
+ x: leftX,
572
+ y: top + (bottom - top) / 2,
573
+ },
574
+ -90,
575
+ ),
576
+ );
577
+
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,
641
+ });
642
+
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 });
663
+ }
664
+ }