@pooder/kit 5.3.1 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/index.d.mts +243 -36
  3. package/dist/index.d.ts +243 -36
  4. package/dist/index.js +2278 -1041
  5. package/dist/index.mjs +2278 -1041
  6. package/package.json +1 -1
  7. package/src/coordinate.ts +106 -106
  8. package/src/extensions/background.ts +323 -230
  9. package/src/extensions/bridgeSelection.ts +17 -17
  10. package/src/extensions/constraints.ts +322 -322
  11. package/src/extensions/dieline.ts +1149 -1076
  12. package/src/extensions/dielineShape.ts +109 -0
  13. package/src/extensions/edgeScale.ts +19 -19
  14. package/src/extensions/feature.ts +1137 -1021
  15. package/src/extensions/featureComplete.ts +46 -46
  16. package/src/extensions/film.ts +266 -194
  17. package/src/extensions/geometry.ts +885 -752
  18. package/src/extensions/image.ts +2054 -1926
  19. package/src/extensions/index.ts +11 -11
  20. package/src/extensions/maskOps.ts +283 -283
  21. package/src/extensions/mirror.ts +128 -128
  22. package/src/extensions/ruler.ts +654 -451
  23. package/src/extensions/sceneLayout.ts +140 -140
  24. package/src/extensions/sceneLayoutModel.ts +364 -352
  25. package/src/extensions/sceneVisibility.ts +64 -71
  26. package/src/extensions/size.ts +389 -389
  27. package/src/extensions/tracer.ts +1019 -1019
  28. package/src/extensions/white-ink.ts +1567 -1514
  29. package/src/extensions/wrappedOffsets.ts +33 -33
  30. package/src/index.ts +2 -2
  31. package/src/services/CanvasService.ts +832 -300
  32. package/src/services/ViewportSystem.ts +95 -95
  33. package/src/services/index.ts +3 -3
  34. package/src/services/renderSpec.ts +53 -18
  35. package/src/units.ts +27 -27
  36. package/tests/run.ts +118 -118
  37. package/tsconfig.test.json +15 -15
  38. package/.test-dist/src/CanvasService.js +0 -249
  39. package/.test-dist/src/ViewportSystem.js +0 -75
  40. package/.test-dist/src/background.js +0 -203
  41. package/.test-dist/src/bridgeSelection.js +0 -20
  42. package/.test-dist/src/constraints.js +0 -237
  43. package/.test-dist/src/coordinate.js +0 -74
  44. package/.test-dist/src/dieline.js +0 -818
  45. package/.test-dist/src/edgeScale.js +0 -12
  46. package/.test-dist/src/extensions/background.js +0 -203
  47. package/.test-dist/src/extensions/bridgeSelection.js +0 -20
  48. package/.test-dist/src/extensions/constraints.js +0 -237
  49. package/.test-dist/src/extensions/dieline.js +0 -828
  50. package/.test-dist/src/extensions/edgeScale.js +0 -12
  51. package/.test-dist/src/extensions/feature.js +0 -825
  52. package/.test-dist/src/extensions/featureComplete.js +0 -32
  53. package/.test-dist/src/extensions/film.js +0 -167
  54. package/.test-dist/src/extensions/geometry.js +0 -545
  55. package/.test-dist/src/extensions/image.js +0 -1529
  56. package/.test-dist/src/extensions/index.js +0 -30
  57. package/.test-dist/src/extensions/maskOps.js +0 -279
  58. package/.test-dist/src/extensions/mirror.js +0 -104
  59. package/.test-dist/src/extensions/ruler.js +0 -345
  60. package/.test-dist/src/extensions/sceneLayout.js +0 -96
  61. package/.test-dist/src/extensions/sceneLayoutModel.js +0 -196
  62. package/.test-dist/src/extensions/sceneVisibility.js +0 -62
  63. package/.test-dist/src/extensions/size.js +0 -331
  64. package/.test-dist/src/extensions/tracer.js +0 -538
  65. package/.test-dist/src/extensions/white-ink.js +0 -1190
  66. package/.test-dist/src/extensions/wrappedOffsets.js +0 -33
  67. package/.test-dist/src/feature.js +0 -826
  68. package/.test-dist/src/featureComplete.js +0 -32
  69. package/.test-dist/src/film.js +0 -167
  70. package/.test-dist/src/geometry.js +0 -506
  71. package/.test-dist/src/image.js +0 -1250
  72. package/.test-dist/src/index.js +0 -18
  73. package/.test-dist/src/maskOps.js +0 -270
  74. package/.test-dist/src/mirror.js +0 -104
  75. package/.test-dist/src/renderSpec.js +0 -2
  76. package/.test-dist/src/ruler.js +0 -343
  77. package/.test-dist/src/sceneLayout.js +0 -99
  78. package/.test-dist/src/sceneLayoutModel.js +0 -196
  79. package/.test-dist/src/sceneView.js +0 -40
  80. package/.test-dist/src/sceneVisibility.js +0 -42
  81. package/.test-dist/src/services/CanvasService.js +0 -249
  82. package/.test-dist/src/services/ViewportSystem.js +0 -76
  83. package/.test-dist/src/services/index.js +0 -24
  84. package/.test-dist/src/services/renderSpec.js +0 -2
  85. package/.test-dist/src/size.js +0 -332
  86. package/.test-dist/src/tracer.js +0 -544
  87. package/.test-dist/src/units.js +0 -30
  88. package/.test-dist/src/white-ink.js +0 -829
  89. package/.test-dist/src/wrappedOffsets.js +0 -33
  90. package/.test-dist/tests/run.js +0 -94
@@ -1,1021 +1,1137 @@
1
- import {
2
- Extension,
3
- ExtensionContext,
4
- ContributionPointIds,
5
- CommandContribution,
6
- ConfigurationService,
7
- ToolSessionService,
8
- } from "@pooder/core";
9
- import { Circle, Group, Point, Rect } from "fabric";
10
- import { CanvasService } from "../services";
11
- import {
12
- getNearestPointOnDieline,
13
- DielineFeature,
14
- resolveFeaturePosition,
15
- } from "./geometry";
16
- import { ConstraintRegistry, ConstraintFeature } from "./constraints";
17
- import { completeFeaturesStrict } from "./featureComplete";
18
- import {
19
- readSizeState,
20
- type SceneGeometrySnapshot as DielineGeometry,
21
- } from "./sceneLayoutModel";
22
-
23
- export class FeatureTool implements Extension {
24
- id = "pooder.kit.feature";
25
-
26
- public metadata = {
27
- name: "FeatureTool",
28
- };
29
-
30
- private workingFeatures: ConstraintFeature[] = [];
31
- private canvasService?: CanvasService;
32
- private context?: ExtensionContext;
33
- private isUpdatingConfig = false;
34
- private isToolActive = false;
35
- private isFeatureSessionActive = false;
36
- private sessionOriginalFeatures: ConstraintFeature[] | null = null;
37
- private hasWorkingChanges = false;
38
- private dirtyTrackerDisposable?: { dispose(): void };
39
-
40
- private handleMoving: ((e: any) => void) | null = null;
41
- private handleModified: ((e: any) => void) | null = null;
42
- private handleSceneGeometryChange:
43
- | ((geometry: DielineGeometry) => void)
44
- | null = null;
45
-
46
- private currentGeometry: DielineGeometry | null = null;
47
-
48
- constructor(
49
- options?: Partial<{
50
- features: ConstraintFeature[];
51
- }>,
52
- ) {
53
- if (options) {
54
- Object.assign(this, options);
55
- }
56
- }
57
-
58
- activate(context: ExtensionContext) {
59
- this.context = context;
60
- this.canvasService = context.services.get<CanvasService>("CanvasService");
61
-
62
- if (!this.canvasService) {
63
- console.warn("CanvasService not found for FeatureTool");
64
- return;
65
- }
66
-
67
- const configService = context.services.get<ConfigurationService>(
68
- "ConfigurationService",
69
- );
70
- if (configService) {
71
- const features = (configService.get("dieline.features", []) ||
72
- []) as ConstraintFeature[];
73
- this.workingFeatures = this.cloneFeatures(features);
74
- this.hasWorkingChanges = false;
75
-
76
- configService.onAnyChange((e: { key: string; value: any }) => {
77
- if (this.isUpdatingConfig) return;
78
-
79
- if (e.key === "dieline.features") {
80
- if (this.isFeatureSessionActive) return;
81
- const next = (e.value || []) as ConstraintFeature[];
82
- this.workingFeatures = this.cloneFeatures(next);
83
- this.hasWorkingChanges = false;
84
- this.redraw();
85
- this.emitWorkingChange();
86
- }
87
- });
88
- }
89
-
90
- const toolSessionService =
91
- context.services.get<ToolSessionService>("ToolSessionService");
92
- this.dirtyTrackerDisposable = toolSessionService?.registerDirtyTracker(
93
- this.id,
94
- () => this.hasWorkingChanges,
95
- );
96
-
97
- // Listen to tool activation
98
- context.eventBus.on("tool:activated", this.onToolActivated);
99
-
100
- this.setup();
101
- }
102
-
103
- deactivate(context: ExtensionContext) {
104
- context.eventBus.off("tool:activated", this.onToolActivated);
105
- this.restoreSessionFeaturesToConfig();
106
- this.dirtyTrackerDisposable?.dispose();
107
- this.dirtyTrackerDisposable = undefined;
108
- this.teardown();
109
- this.canvasService = undefined;
110
- this.context = undefined;
111
- }
112
-
113
- private onToolActivated = (event: { id: string | null }) => {
114
- this.isToolActive = event.id === this.id;
115
- if (!this.isToolActive) {
116
- this.restoreSessionFeaturesToConfig();
117
- }
118
- this.updateVisibility();
119
- };
120
-
121
- private updateVisibility() {
122
- if (!this.canvasService) return;
123
- const canvas = this.canvasService.canvas;
124
- const markers = canvas
125
- .getObjects()
126
- .filter((obj: any) => obj.data?.type === "feature-marker");
127
-
128
- markers.forEach((marker: any) => {
129
- // If tool active, allow selection. If not, disable selection.
130
- // Also might want to hide them entirely or just disable interaction.
131
- // Assuming we only want to see/edit holes when tool is active.
132
- marker.set({
133
- visible: this.isToolActive, // Or just selectable: false if we want them visible but locked
134
- selectable: this.isToolActive,
135
- evented: this.isToolActive,
136
- });
137
- });
138
- canvas.requestRenderAll();
139
- }
140
-
141
- contribute() {
142
- return {
143
- [ContributionPointIds.TOOLS]: [
144
- {
145
- id: this.id,
146
- name: "Feature",
147
- interaction: "session",
148
- commands: {
149
- begin: "beginFeatureSession",
150
- commit: "completeFeatures",
151
- rollback: "rollbackFeatureSession",
152
- },
153
- session: {
154
- autoBegin: false,
155
- leavePolicy: "block",
156
- },
157
- },
158
- ],
159
- [ContributionPointIds.COMMANDS]: [
160
- {
161
- command: "beginFeatureSession",
162
- title: "Begin Feature Session",
163
- handler: async () => {
164
- if (this.isFeatureSessionActive) {
165
- return { ok: true };
166
- }
167
- const original = this.getCommittedFeatures();
168
- this.sessionOriginalFeatures = this.cloneFeatures(original);
169
- this.isFeatureSessionActive = true;
170
- await this.refreshGeometry();
171
- this.setWorkingFeatures(this.cloneFeatures(original));
172
- this.hasWorkingChanges = false;
173
- this.redraw();
174
- this.emitWorkingChange();
175
- this.updateCommittedFeatures([]);
176
- return { ok: true };
177
- },
178
- },
179
- {
180
- command: "addFeature",
181
- title: "Add Edge Feature",
182
- handler: (type: "add" | "subtract" = "subtract") => {
183
- return this.addFeature(type);
184
- },
185
- },
186
- {
187
- command: "addHole",
188
- title: "Add Hole",
189
- handler: () => {
190
- return this.addFeature("subtract");
191
- },
192
- },
193
- {
194
- command: "addDoubleLayerHole",
195
- title: "Add Double Layer Hole",
196
- handler: () => {
197
- return this.addDoubleLayerHole();
198
- },
199
- },
200
- {
201
- command: "clearFeatures",
202
- title: "Clear Features",
203
- handler: () => {
204
- this.setWorkingFeatures([]);
205
- this.hasWorkingChanges = true;
206
- this.redraw();
207
- this.emitWorkingChange();
208
- return true;
209
- },
210
- },
211
- {
212
- command: "getWorkingFeatures",
213
- title: "Get Working Features",
214
- handler: () => {
215
- return this.cloneFeatures(this.workingFeatures);
216
- },
217
- },
218
- {
219
- command: "setWorkingFeatures",
220
- title: "Set Working Features",
221
- handler: async (features: ConstraintFeature[]) => {
222
- await this.refreshGeometry();
223
- this.setWorkingFeatures(this.cloneFeatures(features || []));
224
- this.hasWorkingChanges = true;
225
- this.redraw();
226
- this.emitWorkingChange();
227
- return { ok: true };
228
- },
229
- },
230
- {
231
- command: "rollbackFeatureSession",
232
- title: "Rollback Feature Session",
233
- handler: async () => {
234
- const original = this.cloneFeatures(
235
- this.sessionOriginalFeatures || this.getCommittedFeatures(),
236
- );
237
- await this.refreshGeometry();
238
- this.setWorkingFeatures(original);
239
- this.hasWorkingChanges = false;
240
- this.redraw();
241
- this.emitWorkingChange();
242
- this.updateCommittedFeatures(original);
243
- this.clearFeatureSessionState();
244
- return { ok: true };
245
- },
246
- },
247
- {
248
- command: "resetWorkingFeatures",
249
- title: "Reset Working Features",
250
- handler: async () => {
251
- await this.resetWorkingFeaturesFromSource();
252
- return { ok: true };
253
- },
254
- },
255
- {
256
- command: "updateWorkingGroupPosition",
257
- title: "Update Working Group Position",
258
- handler: (groupId: string, x: number, y: number) => {
259
- return this.updateWorkingGroupPosition(groupId, x, y);
260
- },
261
- },
262
- {
263
- command: "completeFeatures",
264
- title: "Complete Features",
265
- handler: () => {
266
- return this.completeFeatures();
267
- },
268
- },
269
- ] as CommandContribution[],
270
- };
271
- }
272
-
273
- private cloneFeatures(features: ConstraintFeature[]): ConstraintFeature[] {
274
- return JSON.parse(JSON.stringify(features || [])) as ConstraintFeature[];
275
- }
276
-
277
- private getConfigService(): ConfigurationService | undefined {
278
- return this.context?.services.get<ConfigurationService>(
279
- "ConfigurationService",
280
- );
281
- }
282
-
283
- private getCommittedFeatures(): ConstraintFeature[] {
284
- const configService = this.getConfigService();
285
- const committed = (configService?.get("dieline.features", []) ||
286
- []) as ConstraintFeature[];
287
- return this.cloneFeatures(committed);
288
- }
289
-
290
- private updateCommittedFeatures(next: ConstraintFeature[]) {
291
- const configService = this.getConfigService();
292
- if (!configService) return;
293
- this.isUpdatingConfig = true;
294
- try {
295
- configService.update("dieline.features", next);
296
- } finally {
297
- this.isUpdatingConfig = false;
298
- }
299
- }
300
-
301
- private clearFeatureSessionState() {
302
- this.isFeatureSessionActive = false;
303
- this.sessionOriginalFeatures = null;
304
- }
305
-
306
- private restoreSessionFeaturesToConfig() {
307
- if (!this.isFeatureSessionActive) return;
308
- const original = this.cloneFeatures(
309
- this.sessionOriginalFeatures || this.getCommittedFeatures(),
310
- );
311
- this.updateCommittedFeatures(original);
312
- this.clearFeatureSessionState();
313
- }
314
-
315
- private emitWorkingChange() {
316
- this.context?.eventBus.emit("feature:working:change", {
317
- features: this.cloneFeatures(this.workingFeatures),
318
- });
319
- }
320
-
321
- private async refreshGeometry() {
322
- if (!this.context) return;
323
- const commandService = this.context.services.get<any>("CommandService");
324
- if (!commandService) return;
325
- try {
326
- const g = await Promise.resolve(
327
- commandService.executeCommand("getSceneGeometry"),
328
- );
329
- if (g) this.currentGeometry = g as DielineGeometry;
330
- } catch (e) {}
331
- }
332
-
333
- private async resetWorkingFeaturesFromSource() {
334
- const next = this.cloneFeatures(
335
- this.isFeatureSessionActive && this.sessionOriginalFeatures
336
- ? this.sessionOriginalFeatures
337
- : this.getCommittedFeatures(),
338
- );
339
- await this.refreshGeometry();
340
- this.setWorkingFeatures(next);
341
- this.hasWorkingChanges = false;
342
- this.redraw();
343
- this.emitWorkingChange();
344
- }
345
-
346
- private setWorkingFeatures(next: ConstraintFeature[]) {
347
- this.workingFeatures = next;
348
- }
349
-
350
- private updateWorkingGroupPosition(groupId: string, x: number, y: number) {
351
- if (!groupId) return { ok: false };
352
-
353
- const configService = this.context?.services.get<ConfigurationService>(
354
- "ConfigurationService",
355
- );
356
- if (!configService) return { ok: false };
357
-
358
- const sizeState = readSizeState(configService);
359
- const dielineWidth = sizeState.actualWidthMm;
360
- const dielineHeight = sizeState.actualHeightMm;
361
-
362
- let changed = false;
363
- const next = this.workingFeatures.map((f) => {
364
- if (f.groupId !== groupId) return f;
365
- let nx = x;
366
- let ny = y;
367
- if (f.constraints && dielineWidth > 0 && dielineHeight > 0) {
368
- const constrained = ConstraintRegistry.apply(nx, ny, f, {
369
- dielineWidth,
370
- dielineHeight,
371
- });
372
- nx = constrained.x;
373
- ny = constrained.y;
374
- }
375
-
376
- if (f.x !== nx || f.y !== ny) {
377
- changed = true;
378
- return { ...f, x: nx, y: ny };
379
- }
380
- return f;
381
- });
382
-
383
- if (!changed) return { ok: true };
384
-
385
- this.setWorkingFeatures(next);
386
- this.hasWorkingChanges = true;
387
- this.redraw();
388
- this.enforceConstraints();
389
- this.emitWorkingChange();
390
-
391
- return { ok: true };
392
- }
393
-
394
- private completeFeatures(): {
395
- ok: boolean;
396
- issues?: Array<{
397
- featureId: string;
398
- groupId?: string;
399
- reason: string;
400
- }>;
401
- } {
402
- const configService = this.context?.services.get<ConfigurationService>(
403
- "ConfigurationService",
404
- );
405
- if (!configService) {
406
- return {
407
- ok: false,
408
- issues: [
409
- { featureId: "unknown", reason: "ConfigurationService not found" },
410
- ],
411
- };
412
- }
413
-
414
- const sizeState = readSizeState(configService);
415
- const dielineWidth = sizeState.actualWidthMm;
416
- const dielineHeight = sizeState.actualHeightMm;
417
-
418
- const result = completeFeaturesStrict(
419
- this.workingFeatures,
420
- { dielineWidth, dielineHeight },
421
- (next) => {
422
- this.updateCommittedFeatures(next as ConstraintFeature[]);
423
- this.workingFeatures = this.cloneFeatures(next as any);
424
- this.emitWorkingChange();
425
- },
426
- );
427
-
428
- if (!result.ok) {
429
- return {
430
- ok: false,
431
- issues: result.issues,
432
- };
433
- }
434
-
435
- this.hasWorkingChanges = false;
436
- this.clearFeatureSessionState();
437
- // Keep feature markers above dieline overlay after config-driven redraw.
438
- this.redraw();
439
- return { ok: true };
440
- }
441
-
442
- private addFeature(type: "add" | "subtract") {
443
- if (!this.canvasService) return false;
444
-
445
- // Default to top edge center
446
- const newFeature: ConstraintFeature = {
447
- id: Date.now().toString(),
448
- operation: type,
449
- shape: "rect",
450
- x: 0.5,
451
- y: 0, // Top edge
452
- width: 10,
453
- height: 10,
454
- rotation: 0,
455
- renderBehavior: "edge",
456
- // Default constraint: path (snap to edge)
457
- constraints: [{ type: "path" }],
458
- };
459
-
460
- this.setWorkingFeatures([...(this.workingFeatures || []), newFeature]);
461
- this.hasWorkingChanges = true;
462
- this.redraw();
463
- this.emitWorkingChange();
464
- return true;
465
- }
466
-
467
- private addDoubleLayerHole() {
468
- if (!this.canvasService) return false;
469
-
470
- const groupId = Date.now().toString();
471
- const timestamp = Date.now();
472
-
473
- // 1. Lug (Outer) - Add
474
- const lug: ConstraintFeature = {
475
- id: `${timestamp}-lug`,
476
- groupId,
477
- operation: "add",
478
- shape: "circle",
479
- x: 0.5,
480
- y: 0,
481
- radius: 20,
482
- rotation: 0,
483
- renderBehavior: "edge",
484
- constraints: [{ type: "path" }],
485
- };
486
-
487
- // 2. Hole (Inner) - Subtract
488
- const hole: ConstraintFeature = {
489
- id: `${timestamp}-hole`,
490
- groupId,
491
- operation: "subtract",
492
- shape: "circle",
493
- x: 0.5,
494
- y: 0,
495
- radius: 15,
496
- rotation: 0,
497
- renderBehavior: "edge",
498
- constraints: [{ type: "path" }],
499
- };
500
-
501
- this.setWorkingFeatures([...(this.workingFeatures || []), lug, hole]);
502
- this.hasWorkingChanges = true;
503
- this.redraw();
504
- this.emitWorkingChange();
505
- return true;
506
- }
507
-
508
- private getGeometryForFeature(
509
- geometry: DielineGeometry,
510
- feature?: ConstraintFeature,
511
- ): DielineGeometry {
512
- // Legacy support or specialized scaling can go here if needed
513
- // Currently all features operate on the base geometry (or scaled version of it)
514
- return geometry;
515
- }
516
-
517
- private setup() {
518
- if (!this.canvasService || !this.context) return;
519
- const canvas = this.canvasService.canvas;
520
-
521
- // 1. Listen for Scene Geometry Changes
522
- if (!this.handleSceneGeometryChange) {
523
- this.handleSceneGeometryChange = (geometry: DielineGeometry) => {
524
- this.currentGeometry = geometry;
525
- this.redraw();
526
- this.enforceConstraints();
527
- };
528
- this.context.eventBus.on(
529
- "scene:geometry:change",
530
- this.handleSceneGeometryChange,
531
- );
532
- }
533
-
534
- // 2. Initial Fetch of Geometry
535
- const commandService = this.context.services.get<any>("CommandService");
536
- if (commandService) {
537
- try {
538
- Promise.resolve(commandService.executeCommand("getSceneGeometry")).then(
539
- (g) => {
540
- if (g) {
541
- this.currentGeometry = g as DielineGeometry;
542
- this.redraw();
543
- }
544
- },
545
- );
546
- } catch (e) {}
547
- }
548
-
549
- // 3. Setup Canvas Interaction
550
- if (!this.handleMoving) {
551
- this.handleMoving = (e: any) => {
552
- const target = e.target;
553
- if (!target || target.data?.type !== "feature-marker") return;
554
- if (!this.currentGeometry) return;
555
-
556
- // Determine feature to use for snapping context
557
- let feature: ConstraintFeature | undefined;
558
- if (target.data?.isGroup) {
559
- const indices = target.data?.indices as number[];
560
- if (indices && indices.length > 0) {
561
- feature = this.workingFeatures[indices[0]];
562
- }
563
- } else {
564
- const index = target.data?.index;
565
- if (index !== undefined) {
566
- feature = this.workingFeatures[index];
567
- }
568
- }
569
-
570
- const geometry = this.getGeometryForFeature(
571
- this.currentGeometry,
572
- feature,
573
- );
574
-
575
- // Snap to edge during move
576
- // For Group, target.left/top is group center (or top-left depending on origin)
577
- // We snap the target position itself.
578
- const p = new Point(target.left, target.top);
579
-
580
- // Calculate limit based on target size (min dimension / 2 ensures overlap)
581
- // Also subtract stroke width to ensure visual overlap (not just tangent)
582
- // target.strokeWidth for group is usually 0, need a safe default (e.g. 2 for markers)
583
- const markerStrokeWidth =
584
- (target.strokeWidth || 2) * (target.scaleX || 1);
585
- const minDim = Math.min(
586
- target.getScaledWidth(),
587
- target.getScaledHeight(),
588
- );
589
- const limit = Math.max(0, minDim / 2 - markerStrokeWidth);
590
-
591
- const snapped = this.constrainPosition(p, geometry, limit, feature);
592
-
593
- target.set({
594
- left: snapped.x,
595
- top: snapped.y,
596
- });
597
- };
598
- canvas.on("object:moving", this.handleMoving);
599
- }
600
-
601
- if (!this.handleModified) {
602
- this.handleModified = (e: any) => {
603
- const target = e.target;
604
- if (!target || target.data?.type !== "feature-marker") return;
605
-
606
- if (target.data?.isGroup) {
607
- // It's a Group object
608
- const groupObj = target as Group;
609
- // @ts-ignore
610
- const indices = groupObj.data?.indices as number[];
611
- if (!indices) return;
612
-
613
- // We need to update all features in the group based on their new absolute positions.
614
- // Fabric Group children positions are relative to group center.
615
- // We need to calculate absolute position for each child.
616
- // Note: groupObj has already been moved to new position (target.left, target.top)
617
-
618
- const groupCenter = new Point(groupObj.left, groupObj.top);
619
- // Get group matrix to transform children
620
- // Simplified: just add relative coordinates if no rotation/scaling on group
621
- // We locked rotation/scaling, so it's safe.
622
-
623
- const newFeatures = [...this.workingFeatures];
624
- const { x, y } = this.currentGeometry!; // Center is same
625
-
626
- // Fabric Group objects have .getObjects() which returns children
627
- // But children inside group have coordinates relative to group center.
628
- // center is (0,0) inside the group local coordinate system.
629
-
630
- groupObj.getObjects().forEach((child, i) => {
631
- const originalIndex = indices[i];
632
- const feature = this.workingFeatures[originalIndex];
633
- const geometry = this.getGeometryForFeature(
634
- this.currentGeometry!,
635
- feature,
636
- );
637
- const { width, height } = geometry;
638
- const layoutLeft = x - width / 2;
639
- const layoutTop = y - height / 2;
640
-
641
- // Calculate absolute position
642
- // child.left/top are relative to group center
643
- const absX = groupCenter.x + (child.left || 0);
644
- const absY = groupCenter.y + (child.top || 0);
645
-
646
- // Normalize
647
- const normalizedX = width > 0 ? (absX - layoutLeft) / width : 0.5;
648
- const normalizedY = height > 0 ? (absY - layoutTop) / height : 0.5;
649
-
650
- newFeatures[originalIndex] = {
651
- ...newFeatures[originalIndex],
652
- x: normalizedX,
653
- y: normalizedY,
654
- };
655
- });
656
-
657
- this.setWorkingFeatures(newFeatures);
658
- this.hasWorkingChanges = true;
659
- this.emitWorkingChange();
660
- } else {
661
- // Single object
662
- this.syncFeatureFromCanvas(target);
663
- }
664
- };
665
- canvas.on("object:modified", this.handleModified);
666
- }
667
- }
668
-
669
- private teardown() {
670
- if (!this.canvasService) return;
671
- const canvas = this.canvasService.canvas;
672
-
673
- if (this.handleMoving) {
674
- canvas.off("object:moving", this.handleMoving);
675
- this.handleMoving = null;
676
- }
677
- if (this.handleModified) {
678
- canvas.off("object:modified", this.handleModified);
679
- this.handleModified = null;
680
- }
681
- if (this.handleSceneGeometryChange && this.context) {
682
- this.context.eventBus.off(
683
- "scene:geometry:change",
684
- this.handleSceneGeometryChange,
685
- );
686
- this.handleSceneGeometryChange = null;
687
- }
688
-
689
- const objects = canvas
690
- .getObjects()
691
- .filter((obj: any) => obj.data?.type === "feature-marker");
692
- objects.forEach((obj) => canvas.remove(obj));
693
-
694
- this.canvasService.requestRenderAll();
695
- }
696
-
697
- private constrainPosition(
698
- p: Point,
699
- geometry: DielineGeometry,
700
- limit: number,
701
- feature?: ConstraintFeature,
702
- ): { x: number; y: number } {
703
- if (!feature) {
704
- return { x: p.x, y: p.y };
705
- }
706
-
707
- const minX = geometry.x - geometry.width / 2;
708
- const minY = geometry.y - geometry.height / 2;
709
-
710
- // Normalize
711
- const nx = geometry.width > 0 ? (p.x - minX) / geometry.width : 0.5;
712
- const ny = geometry.height > 0 ? (p.y - minY) / geometry.height : 0.5;
713
-
714
- const scale = geometry.scale || 1;
715
- const dielineWidth = geometry.width / scale;
716
- const dielineHeight = geometry.height / scale;
717
-
718
- // Filter constraints: only apply those that are NOT validateOnly
719
- const activeConstraints = feature.constraints?.filter(
720
- (c) => !c.validateOnly,
721
- );
722
-
723
- const constrained = ConstraintRegistry.apply(
724
- nx,
725
- ny,
726
- feature,
727
- {
728
- dielineWidth,
729
- dielineHeight,
730
- geometry,
731
- },
732
- activeConstraints,
733
- );
734
-
735
- // Denormalize
736
- return {
737
- x: minX + constrained.x * geometry.width,
738
- y: minY + constrained.y * geometry.height,
739
- };
740
- }
741
-
742
- private syncFeatureFromCanvas(target: any) {
743
- if (!this.currentGeometry || !this.context) return;
744
-
745
- const index = target.data?.index;
746
- if (
747
- index === undefined ||
748
- index < 0 ||
749
- index >= this.workingFeatures.length
750
- )
751
- return;
752
-
753
- const feature = this.workingFeatures[index];
754
- const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
755
- const { width, height, x, y } = geometry;
756
-
757
- // Calculate Normalized Position
758
- // The geometry x/y is the CENTER.
759
- const left = x - width / 2;
760
- const top = y - height / 2;
761
-
762
- const normalizedX = width > 0 ? (target.left - left) / width : 0.5;
763
- const normalizedY = height > 0 ? (target.top - top) / height : 0.5;
764
-
765
- // Update feature
766
- const updatedFeature = {
767
- ...feature,
768
- x: normalizedX,
769
- y: normalizedY,
770
- // Could also update rotation if we allowed rotating markers
771
- };
772
-
773
- const newFeatures = [...this.workingFeatures];
774
- newFeatures[index] = updatedFeature;
775
- this.setWorkingFeatures(newFeatures);
776
- this.hasWorkingChanges = true;
777
- this.emitWorkingChange();
778
- }
779
-
780
- private redraw() {
781
- if (!this.canvasService || !this.currentGeometry) return;
782
- const canvas = this.canvasService.canvas;
783
- const geometry = this.currentGeometry;
784
-
785
- // Remove existing markers
786
- const existing = canvas
787
- .getObjects()
788
- .filter((obj: any) => obj.data?.type === "feature-marker");
789
- existing.forEach((obj) => canvas.remove(obj));
790
-
791
- if (!this.workingFeatures || this.workingFeatures.length === 0) {
792
- this.canvasService.requestRenderAll();
793
- return;
794
- }
795
-
796
- const scale = geometry.scale || 1;
797
- const finalScale = scale;
798
-
799
- // Group features by groupId
800
- const groups: {
801
- [key: string]: { feature: ConstraintFeature; index: number }[];
802
- } = {};
803
- const singles: { feature: ConstraintFeature; index: number }[] = [];
804
-
805
- this.workingFeatures.forEach((f: ConstraintFeature, i: number) => {
806
- if (f.groupId) {
807
- if (!groups[f.groupId]) groups[f.groupId] = [];
808
- groups[f.groupId].push({ feature: f, index: i });
809
- } else {
810
- singles.push({ feature: f, index: i });
811
- }
812
- });
813
-
814
- // Helper to create marker shape
815
- const createMarkerShape = (
816
- feature: ConstraintFeature,
817
- pos: { x: number; y: number },
818
- ) => {
819
- const featureScale = scale;
820
-
821
- const visualWidth = (feature.width || 10) * featureScale;
822
- const visualHeight = (feature.height || 10) * featureScale;
823
- const visualRadius = (feature.radius || 0) * featureScale;
824
- const color =
825
- feature.color || (feature.operation === "add" ? "#00FF00" : "#FF0000");
826
- const strokeDash =
827
- feature.strokeDash ||
828
- (feature.operation === "subtract" ? [4, 4] : undefined);
829
-
830
- let shape: any;
831
- if (feature.shape === "rect") {
832
- shape = new Rect({
833
- width: visualWidth,
834
- height: visualHeight,
835
- rx: visualRadius,
836
- ry: visualRadius,
837
- fill: "transparent",
838
- stroke: color,
839
- strokeWidth: 2,
840
- strokeDashArray: strokeDash,
841
- originX: "center",
842
- originY: "center",
843
- left: pos.x,
844
- top: pos.y,
845
- });
846
- } else {
847
- shape = new Circle({
848
- radius: visualRadius || 5 * finalScale,
849
- fill: "transparent",
850
- stroke: color,
851
- strokeWidth: 2,
852
- strokeDashArray: strokeDash,
853
- originX: "center",
854
- originY: "center",
855
- left: pos.x,
856
- top: pos.y,
857
- });
858
- }
859
- if (feature.rotation) {
860
- shape.rotate(feature.rotation);
861
- }
862
-
863
- // Handle Indicator for Bridge
864
- if (feature.bridge && feature.bridge.type === "vertical") {
865
- // Create a visual indicator for the bridge
866
- // A dashed rectangle extending upwards
867
- const bridgeIndicator = new Rect({
868
- width: visualWidth,
869
- height: 100 * featureScale, // Arbitrary long length to show direction
870
- fill: "transparent",
871
- stroke: "#888",
872
- strokeWidth: 1,
873
- strokeDashArray: [2, 2],
874
- originX: "center",
875
- originY: "bottom", // Anchor at bottom so it extends up
876
- left: pos.x,
877
- top: pos.y - visualHeight / 2, // Start from top of feature
878
- opacity: 0.5,
879
- selectable: false,
880
- evented: false,
881
- });
882
-
883
- // We need to return a group containing both shape and indicator
884
- // But createMarkerShape is expected to return one object.
885
- // If we return a Group, Fabric handles it.
886
- // But the caller might wrap this in another Group if it's part of a feature group.
887
- // Fabric supports nested groups.
888
-
889
- const group = new Group([bridgeIndicator, shape], {
890
- originX: "center",
891
- originY: "center",
892
- left: pos.x,
893
- top: pos.y,
894
- });
895
- return group;
896
- }
897
-
898
- return shape;
899
- };
900
-
901
- // Render Singles
902
- singles.forEach(({ feature, index }) => {
903
- const geometry = this.getGeometryForFeature(
904
- this.currentGeometry!,
905
- feature,
906
- );
907
- const pos = resolveFeaturePosition(feature, geometry);
908
- const marker = createMarkerShape(feature, pos);
909
-
910
- marker.set({
911
- visible: this.isToolActive,
912
- selectable: this.isToolActive,
913
- evented: this.isToolActive,
914
- hasControls: false,
915
- hasBorders: false,
916
- hoverCursor: "move",
917
- lockRotation: true,
918
- lockScalingX: true,
919
- lockScalingY: true,
920
- data: { type: "feature-marker", index, isGroup: false },
921
- });
922
-
923
- canvas.add(marker);
924
- canvas.bringObjectToFront(marker);
925
- });
926
-
927
- // Render Groups
928
- Object.keys(groups).forEach((groupId) => {
929
- const members = groups[groupId];
930
- if (members.length === 0) return;
931
-
932
- // Calculate group center (average position) to position the group correctly
933
- // But Fabric Group uses relative coordinates.
934
- // Easiest way: Create shapes at absolute positions, then Group them.
935
- // Fabric will auto-calculate group center and adjust children.
936
-
937
- const shapes = members.map(({ feature }) => {
938
- const geometry = this.getGeometryForFeature(
939
- this.currentGeometry!,
940
- feature,
941
- );
942
- const pos = resolveFeaturePosition(feature, geometry);
943
- return createMarkerShape(feature, pos);
944
- });
945
-
946
- const groupObj = new Group(shapes, {
947
- visible: this.isToolActive,
948
- selectable: this.isToolActive,
949
- evented: this.isToolActive,
950
- hasControls: false,
951
- hasBorders: false,
952
- hoverCursor: "move",
953
- lockRotation: true,
954
- lockScalingX: true,
955
- lockScalingY: true,
956
- subTargetCheck: true, // Allow events to pass through if needed, but we treat as one
957
- interactive: false, // Children not interactive
958
- // @ts-ignore
959
- data: {
960
- type: "feature-marker",
961
- isGroup: true,
962
- groupId,
963
- indices: members.map((m) => m.index),
964
- },
965
- });
966
-
967
- canvas.add(groupObj);
968
- canvas.bringObjectToFront(groupObj);
969
- });
970
-
971
- this.canvasService.requestRenderAll();
972
- }
973
-
974
- private enforceConstraints() {
975
- if (!this.canvasService || !this.currentGeometry) return;
976
- // Iterate markers and snap them if geometry changed
977
- const canvas = this.canvasService.canvas;
978
- const markers = canvas
979
- .getObjects()
980
- .filter((obj: any) => obj.data?.type === "feature-marker");
981
-
982
- markers.forEach((marker: any) => {
983
- // Find associated feature
984
- let feature: ConstraintFeature | undefined;
985
- if (marker.data?.isGroup) {
986
- const indices = marker.data?.indices as number[];
987
- if (indices && indices.length > 0) {
988
- feature = this.workingFeatures[indices[0]];
989
- }
990
- } else {
991
- const index = marker.data?.index;
992
- if (index !== undefined) {
993
- feature = this.workingFeatures[index];
994
- }
995
- }
996
-
997
- const geometry = this.getGeometryForFeature(
998
- this.currentGeometry!,
999
- feature,
1000
- );
1001
-
1002
- const markerStrokeWidth =
1003
- (marker.strokeWidth || 2) * (marker.scaleX || 1);
1004
- const minDim = Math.min(
1005
- marker.getScaledWidth(),
1006
- marker.getScaledHeight(),
1007
- );
1008
- const limit = Math.max(0, minDim / 2 - markerStrokeWidth);
1009
-
1010
- const snapped = this.constrainPosition(
1011
- new Point(marker.left, marker.top),
1012
- geometry,
1013
- limit,
1014
- feature,
1015
- );
1016
- marker.set({ left: snapped.x, top: snapped.y });
1017
- marker.setCoords();
1018
- });
1019
- canvas.requestRenderAll();
1020
- }
1021
- }
1
+ import {
2
+ Extension,
3
+ ExtensionContext,
4
+ ContributionPointIds,
5
+ CommandContribution,
6
+ ConfigurationService,
7
+ ToolSessionService,
8
+ } from "@pooder/core";
9
+ import { CanvasService, RenderObjectSpec } from "../services";
10
+ import { resolveFeaturePosition } from "./geometry";
11
+ import { ConstraintRegistry, ConstraintFeature } from "./constraints";
12
+ import { completeFeaturesStrict } from "./featureComplete";
13
+ import {
14
+ readSizeState,
15
+ type SceneGeometrySnapshot as DielineGeometry,
16
+ } from "./sceneLayoutModel";
17
+
18
+ const FEATURE_OVERLAY_LAYER_ID = "feature-overlay";
19
+ const FEATURE_STROKE_WIDTH = 2;
20
+ const DEFAULT_RECT_SIZE = 10;
21
+ const DEFAULT_CIRCLE_RADIUS = 5;
22
+
23
+ type MarkerPoint = { x: number; y: number };
24
+
25
+ interface GroupMemberOffset {
26
+ index: number;
27
+ dx: number;
28
+ dy: number;
29
+ }
30
+
31
+ interface MarkerRenderState {
32
+ feature: ConstraintFeature;
33
+ index: number;
34
+ position: MarkerPoint;
35
+ geometry: DielineGeometry;
36
+ scale: number;
37
+ }
38
+
39
+ interface MarkerData {
40
+ type: "feature-marker";
41
+ index: number;
42
+ featureId: string;
43
+ groupId?: string;
44
+ markerRole: "handle" | "member" | "indicator";
45
+ markerOffsetX: number;
46
+ markerOffsetY: number;
47
+ isGroup: boolean;
48
+ indices?: number[];
49
+ anchorIndex?: number;
50
+ memberOffsets?: GroupMemberOffset[];
51
+ }
52
+
53
+ export class FeatureTool implements Extension {
54
+ id = "pooder.kit.feature";
55
+
56
+ public metadata = {
57
+ name: "FeatureTool",
58
+ };
59
+
60
+ private workingFeatures: ConstraintFeature[] = [];
61
+ private canvasService?: CanvasService;
62
+ private context?: ExtensionContext;
63
+ private isUpdatingConfig = false;
64
+ private isToolActive = false;
65
+ private isFeatureSessionActive = false;
66
+ private sessionOriginalFeatures: ConstraintFeature[] | null = null;
67
+ private hasWorkingChanges = false;
68
+ private dirtyTrackerDisposable?: { dispose(): void };
69
+ private renderProducerDisposable?: { dispose: () => void };
70
+ private specs: RenderObjectSpec[] = [];
71
+ private renderSeq = 0;
72
+
73
+ private handleMoving: ((e: any) => void) | null = null;
74
+ private handleModified: ((e: any) => void) | null = null;
75
+ private handleSceneGeometryChange:
76
+ | ((geometry: DielineGeometry) => void)
77
+ | null = null;
78
+
79
+ private currentGeometry: DielineGeometry | null = null;
80
+
81
+ constructor(
82
+ options?: Partial<{
83
+ features: ConstraintFeature[];
84
+ }>,
85
+ ) {
86
+ if (options) {
87
+ Object.assign(this, options);
88
+ }
89
+ }
90
+
91
+ activate(context: ExtensionContext) {
92
+ this.context = context;
93
+ this.canvasService = context.services.get<CanvasService>("CanvasService");
94
+
95
+ if (!this.canvasService) {
96
+ console.warn("CanvasService not found for FeatureTool");
97
+ return;
98
+ }
99
+
100
+ this.renderProducerDisposable?.dispose();
101
+ this.renderProducerDisposable = this.canvasService.registerRenderProducer(
102
+ this.id,
103
+ () => ({
104
+ rootLayerSpecs: {
105
+ [FEATURE_OVERLAY_LAYER_ID]: this.specs,
106
+ },
107
+ }),
108
+ { priority: 350 },
109
+ );
110
+
111
+ const configService = context.services.get<ConfigurationService>(
112
+ "ConfigurationService",
113
+ );
114
+ if (configService) {
115
+ const features = (configService.get("dieline.features", []) ||
116
+ []) as ConstraintFeature[];
117
+ this.workingFeatures = this.cloneFeatures(features);
118
+ this.hasWorkingChanges = false;
119
+
120
+ configService.onAnyChange((e: { key: string; value: any }) => {
121
+ if (this.isUpdatingConfig) return;
122
+
123
+ if (e.key === "dieline.features") {
124
+ if (this.isFeatureSessionActive) return;
125
+ const next = (e.value || []) as ConstraintFeature[];
126
+ this.workingFeatures = this.cloneFeatures(next);
127
+ this.hasWorkingChanges = false;
128
+ this.redraw();
129
+ this.emitWorkingChange();
130
+ }
131
+ });
132
+ }
133
+
134
+ const toolSessionService =
135
+ context.services.get<ToolSessionService>("ToolSessionService");
136
+ this.dirtyTrackerDisposable = toolSessionService?.registerDirtyTracker(
137
+ this.id,
138
+ () => this.hasWorkingChanges,
139
+ );
140
+
141
+ context.eventBus.on("tool:activated", this.onToolActivated);
142
+
143
+ this.setup();
144
+ }
145
+
146
+ deactivate(context: ExtensionContext) {
147
+ context.eventBus.off("tool:activated", this.onToolActivated);
148
+ this.restoreSessionFeaturesToConfig();
149
+ this.dirtyTrackerDisposable?.dispose();
150
+ this.dirtyTrackerDisposable = undefined;
151
+ this.teardown();
152
+ this.canvasService = undefined;
153
+ this.context = undefined;
154
+ }
155
+
156
+ private onToolActivated = (event: { id: string | null }) => {
157
+ this.isToolActive = event.id === this.id;
158
+ if (!this.isToolActive) {
159
+ this.restoreSessionFeaturesToConfig();
160
+ }
161
+ this.updateVisibility();
162
+ };
163
+
164
+ private updateVisibility() {
165
+ this.redraw();
166
+ }
167
+
168
+ contribute() {
169
+ return {
170
+ [ContributionPointIds.TOOLS]: [
171
+ {
172
+ id: this.id,
173
+ name: "Feature",
174
+ interaction: "session",
175
+ commands: {
176
+ begin: "beginFeatureSession",
177
+ commit: "completeFeatures",
178
+ rollback: "rollbackFeatureSession",
179
+ },
180
+ session: {
181
+ autoBegin: false,
182
+ leavePolicy: "block",
183
+ },
184
+ },
185
+ ],
186
+ [ContributionPointIds.COMMANDS]: [
187
+ {
188
+ command: "beginFeatureSession",
189
+ title: "Begin Feature Session",
190
+ handler: async () => {
191
+ if (this.isFeatureSessionActive) {
192
+ return { ok: true };
193
+ }
194
+ const original = this.getCommittedFeatures();
195
+ this.sessionOriginalFeatures = this.cloneFeatures(original);
196
+ this.isFeatureSessionActive = true;
197
+ await this.refreshGeometry();
198
+ this.setWorkingFeatures(this.cloneFeatures(original));
199
+ this.hasWorkingChanges = false;
200
+ this.redraw();
201
+ this.emitWorkingChange();
202
+ this.updateCommittedFeatures([]);
203
+ return { ok: true };
204
+ },
205
+ },
206
+ {
207
+ command: "addFeature",
208
+ title: "Add Edge Feature",
209
+ handler: (type: "add" | "subtract" = "subtract") => {
210
+ return this.addFeature(type);
211
+ },
212
+ },
213
+ {
214
+ command: "addHole",
215
+ title: "Add Hole",
216
+ handler: () => {
217
+ return this.addFeature("subtract");
218
+ },
219
+ },
220
+ {
221
+ command: "addDoubleLayerHole",
222
+ title: "Add Double Layer Hole",
223
+ handler: () => {
224
+ return this.addDoubleLayerHole();
225
+ },
226
+ },
227
+ {
228
+ command: "clearFeatures",
229
+ title: "Clear Features",
230
+ handler: () => {
231
+ this.setWorkingFeatures([]);
232
+ this.hasWorkingChanges = true;
233
+ this.redraw();
234
+ this.emitWorkingChange();
235
+ return true;
236
+ },
237
+ },
238
+ {
239
+ command: "getWorkingFeatures",
240
+ title: "Get Working Features",
241
+ handler: () => {
242
+ return this.cloneFeatures(this.workingFeatures);
243
+ },
244
+ },
245
+ {
246
+ command: "setWorkingFeatures",
247
+ title: "Set Working Features",
248
+ handler: async (features: ConstraintFeature[]) => {
249
+ await this.refreshGeometry();
250
+ this.setWorkingFeatures(this.cloneFeatures(features || []));
251
+ this.hasWorkingChanges = true;
252
+ this.redraw();
253
+ this.emitWorkingChange();
254
+ return { ok: true };
255
+ },
256
+ },
257
+ {
258
+ command: "rollbackFeatureSession",
259
+ title: "Rollback Feature Session",
260
+ handler: async () => {
261
+ const original = this.cloneFeatures(
262
+ this.sessionOriginalFeatures || this.getCommittedFeatures(),
263
+ );
264
+ await this.refreshGeometry();
265
+ this.setWorkingFeatures(original);
266
+ this.hasWorkingChanges = false;
267
+ this.redraw();
268
+ this.emitWorkingChange();
269
+ this.updateCommittedFeatures(original);
270
+ this.clearFeatureSessionState();
271
+ return { ok: true };
272
+ },
273
+ },
274
+ {
275
+ command: "resetWorkingFeatures",
276
+ title: "Reset Working Features",
277
+ handler: async () => {
278
+ await this.resetWorkingFeaturesFromSource();
279
+ return { ok: true };
280
+ },
281
+ },
282
+ {
283
+ command: "updateWorkingGroupPosition",
284
+ title: "Update Working Group Position",
285
+ handler: (groupId: string, x: number, y: number) => {
286
+ return this.updateWorkingGroupPosition(groupId, x, y);
287
+ },
288
+ },
289
+ {
290
+ command: "completeFeatures",
291
+ title: "Complete Features",
292
+ handler: () => {
293
+ return this.completeFeatures();
294
+ },
295
+ },
296
+ ] as CommandContribution[],
297
+ };
298
+ }
299
+
300
+ private cloneFeatures(features: ConstraintFeature[]): ConstraintFeature[] {
301
+ return JSON.parse(JSON.stringify(features || [])) as ConstraintFeature[];
302
+ }
303
+
304
+ private getConfigService(): ConfigurationService | undefined {
305
+ return this.context?.services.get<ConfigurationService>(
306
+ "ConfigurationService",
307
+ );
308
+ }
309
+
310
+ private getCommittedFeatures(): ConstraintFeature[] {
311
+ const configService = this.getConfigService();
312
+ const committed = (configService?.get("dieline.features", []) ||
313
+ []) as ConstraintFeature[];
314
+ return this.cloneFeatures(committed);
315
+ }
316
+
317
+ private updateCommittedFeatures(next: ConstraintFeature[]) {
318
+ const configService = this.getConfigService();
319
+ if (!configService) return;
320
+ this.isUpdatingConfig = true;
321
+ try {
322
+ configService.update("dieline.features", next);
323
+ } finally {
324
+ this.isUpdatingConfig = false;
325
+ }
326
+ }
327
+
328
+ private clearFeatureSessionState() {
329
+ this.isFeatureSessionActive = false;
330
+ this.sessionOriginalFeatures = null;
331
+ }
332
+
333
+ private restoreSessionFeaturesToConfig() {
334
+ if (!this.isFeatureSessionActive) return;
335
+ const original = this.cloneFeatures(
336
+ this.sessionOriginalFeatures || this.getCommittedFeatures(),
337
+ );
338
+ this.updateCommittedFeatures(original);
339
+ this.clearFeatureSessionState();
340
+ }
341
+
342
+ private emitWorkingChange() {
343
+ this.context?.eventBus.emit("feature:working:change", {
344
+ features: this.cloneFeatures(this.workingFeatures),
345
+ });
346
+ }
347
+
348
+ private async refreshGeometry() {
349
+ if (!this.context) return;
350
+ const commandService = this.context.services.get<any>("CommandService");
351
+ if (!commandService) return;
352
+ try {
353
+ const g = await Promise.resolve(
354
+ commandService.executeCommand("getSceneGeometry"),
355
+ );
356
+ if (g) this.currentGeometry = g as DielineGeometry;
357
+ } catch (e) {}
358
+ }
359
+
360
+ private async resetWorkingFeaturesFromSource() {
361
+ const next = this.cloneFeatures(
362
+ this.isFeatureSessionActive && this.sessionOriginalFeatures
363
+ ? this.sessionOriginalFeatures
364
+ : this.getCommittedFeatures(),
365
+ );
366
+ await this.refreshGeometry();
367
+ this.setWorkingFeatures(next);
368
+ this.hasWorkingChanges = false;
369
+ this.redraw();
370
+ this.emitWorkingChange();
371
+ }
372
+
373
+ private setWorkingFeatures(next: ConstraintFeature[]) {
374
+ this.workingFeatures = next;
375
+ }
376
+
377
+ private updateWorkingGroupPosition(groupId: string, x: number, y: number) {
378
+ if (!groupId) return { ok: false };
379
+
380
+ const configService = this.context?.services.get<ConfigurationService>(
381
+ "ConfigurationService",
382
+ );
383
+ if (!configService) return { ok: false };
384
+
385
+ const sizeState = readSizeState(configService);
386
+ const dielineWidth = sizeState.actualWidthMm;
387
+ const dielineHeight = sizeState.actualHeightMm;
388
+
389
+ let changed = false;
390
+ const next = this.workingFeatures.map((f) => {
391
+ if (f.groupId !== groupId) return f;
392
+ let nx = x;
393
+ let ny = y;
394
+ if (f.constraints && dielineWidth > 0 && dielineHeight > 0) {
395
+ const constrained = ConstraintRegistry.apply(nx, ny, f, {
396
+ dielineWidth,
397
+ dielineHeight,
398
+ });
399
+ nx = constrained.x;
400
+ ny = constrained.y;
401
+ }
402
+
403
+ if (f.x !== nx || f.y !== ny) {
404
+ changed = true;
405
+ return { ...f, x: nx, y: ny };
406
+ }
407
+ return f;
408
+ });
409
+
410
+ if (!changed) return { ok: true };
411
+
412
+ this.setWorkingFeatures(next);
413
+ this.hasWorkingChanges = true;
414
+ this.redraw({ enforceConstraints: true });
415
+ this.emitWorkingChange();
416
+
417
+ return { ok: true };
418
+ }
419
+
420
+ private completeFeatures(): {
421
+ ok: boolean;
422
+ issues?: Array<{
423
+ featureId: string;
424
+ groupId?: string;
425
+ reason: string;
426
+ }>;
427
+ } {
428
+ const configService = this.context?.services.get<ConfigurationService>(
429
+ "ConfigurationService",
430
+ );
431
+ if (!configService) {
432
+ return {
433
+ ok: false,
434
+ issues: [
435
+ { featureId: "unknown", reason: "ConfigurationService not found" },
436
+ ],
437
+ };
438
+ }
439
+
440
+ const sizeState = readSizeState(configService);
441
+ const dielineWidth = sizeState.actualWidthMm;
442
+ const dielineHeight = sizeState.actualHeightMm;
443
+
444
+ const result = completeFeaturesStrict(
445
+ this.workingFeatures,
446
+ { dielineWidth, dielineHeight },
447
+ (next) => {
448
+ this.updateCommittedFeatures(next as ConstraintFeature[]);
449
+ this.workingFeatures = this.cloneFeatures(next as ConstraintFeature[]);
450
+ this.emitWorkingChange();
451
+ },
452
+ );
453
+
454
+ if (!result.ok) {
455
+ return {
456
+ ok: false,
457
+ issues: result.issues,
458
+ };
459
+ }
460
+
461
+ this.hasWorkingChanges = false;
462
+ this.clearFeatureSessionState();
463
+ this.redraw();
464
+ return { ok: true };
465
+ }
466
+
467
+ private addFeature(type: "add" | "subtract") {
468
+ if (!this.canvasService) return false;
469
+
470
+ const newFeature: ConstraintFeature = {
471
+ id: Date.now().toString(),
472
+ operation: type,
473
+ shape: "rect",
474
+ x: 0.5,
475
+ y: 0,
476
+ width: 10,
477
+ height: 10,
478
+ rotation: 0,
479
+ renderBehavior: "edge",
480
+ constraints: [{ type: "path" }],
481
+ };
482
+
483
+ this.setWorkingFeatures([...(this.workingFeatures || []), newFeature]);
484
+ this.hasWorkingChanges = true;
485
+ this.redraw();
486
+ this.emitWorkingChange();
487
+ return true;
488
+ }
489
+
490
+ private addDoubleLayerHole() {
491
+ if (!this.canvasService) return false;
492
+
493
+ const groupId = Date.now().toString();
494
+ const timestamp = Date.now();
495
+
496
+ const lug: ConstraintFeature = {
497
+ id: `${timestamp}-lug`,
498
+ groupId,
499
+ operation: "add",
500
+ shape: "circle",
501
+ x: 0.5,
502
+ y: 0,
503
+ radius: 20,
504
+ rotation: 0,
505
+ renderBehavior: "edge",
506
+ constraints: [{ type: "path" }],
507
+ };
508
+
509
+ const hole: ConstraintFeature = {
510
+ id: `${timestamp}-hole`,
511
+ groupId,
512
+ operation: "subtract",
513
+ shape: "circle",
514
+ x: 0.5,
515
+ y: 0,
516
+ radius: 15,
517
+ rotation: 0,
518
+ renderBehavior: "edge",
519
+ constraints: [{ type: "path" }],
520
+ };
521
+
522
+ this.setWorkingFeatures([...(this.workingFeatures || []), lug, hole]);
523
+ this.hasWorkingChanges = true;
524
+ this.redraw();
525
+ this.emitWorkingChange();
526
+ return true;
527
+ }
528
+
529
+ private getGeometryForFeature(
530
+ geometry: DielineGeometry,
531
+ _feature?: ConstraintFeature,
532
+ ): DielineGeometry {
533
+ return geometry;
534
+ }
535
+
536
+ private setup() {
537
+ if (!this.canvasService || !this.context) return;
538
+ const canvas = this.canvasService.canvas;
539
+
540
+ if (!this.handleSceneGeometryChange) {
541
+ this.handleSceneGeometryChange = (geometry: DielineGeometry) => {
542
+ this.currentGeometry = geometry;
543
+ this.redraw({ enforceConstraints: true });
544
+ };
545
+ this.context.eventBus.on(
546
+ "scene:geometry:change",
547
+ this.handleSceneGeometryChange,
548
+ );
549
+ }
550
+
551
+ const commandService = this.context.services.get<any>("CommandService");
552
+ if (commandService) {
553
+ try {
554
+ Promise.resolve(commandService.executeCommand("getSceneGeometry")).then(
555
+ (g) => {
556
+ if (g) {
557
+ this.currentGeometry = g as DielineGeometry;
558
+ this.redraw();
559
+ }
560
+ },
561
+ );
562
+ } catch (e) {}
563
+ }
564
+
565
+ if (!this.handleMoving) {
566
+ this.handleMoving = (e: any) => {
567
+ const target = this.getDraggableMarkerTarget(e?.target);
568
+ if (!target || !this.currentGeometry) return;
569
+
570
+ const feature = this.getFeatureForMarker(target);
571
+ const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
572
+ const snapped = this.constrainPosition(
573
+ {
574
+ x: Number(target.left || 0),
575
+ y: Number(target.top || 0),
576
+ },
577
+ geometry,
578
+ feature,
579
+ );
580
+
581
+ target.set({
582
+ left: snapped.x,
583
+ top: snapped.y,
584
+ });
585
+ target.setCoords();
586
+
587
+ this.syncMarkerVisualsByTarget(target, snapped);
588
+ };
589
+ canvas.on("object:moving", this.handleMoving);
590
+ }
591
+
592
+ if (!this.handleModified) {
593
+ this.handleModified = (e: any) => {
594
+ const target = this.getDraggableMarkerTarget(e?.target);
595
+ if (!target) return;
596
+
597
+ if (target.data?.isGroup) {
598
+ this.syncGroupFromCanvas(target);
599
+ } else {
600
+ this.syncFeatureFromCanvas(target);
601
+ }
602
+ };
603
+ canvas.on("object:modified", this.handleModified);
604
+ }
605
+ }
606
+
607
+ private teardown() {
608
+ if (!this.canvasService) return;
609
+ const canvas = this.canvasService.canvas;
610
+
611
+ if (this.handleMoving) {
612
+ canvas.off("object:moving", this.handleMoving);
613
+ this.handleMoving = null;
614
+ }
615
+ if (this.handleModified) {
616
+ canvas.off("object:modified", this.handleModified);
617
+ this.handleModified = null;
618
+ }
619
+ if (this.handleSceneGeometryChange && this.context) {
620
+ this.context.eventBus.off(
621
+ "scene:geometry:change",
622
+ this.handleSceneGeometryChange,
623
+ );
624
+ this.handleSceneGeometryChange = null;
625
+ }
626
+
627
+ this.renderSeq += 1;
628
+ this.specs = [];
629
+ this.renderProducerDisposable?.dispose();
630
+ this.renderProducerDisposable = undefined;
631
+ void this.canvasService.flushRenderFromProducers();
632
+ }
633
+
634
+ private getDraggableMarkerTarget(target: any): any | null {
635
+ if (!target || target.data?.type !== "feature-marker") return null;
636
+ if (target.data?.markerRole !== "handle") return null;
637
+ return target;
638
+ }
639
+
640
+ private getFeatureForMarker(target: any): ConstraintFeature | undefined {
641
+ const data = target?.data || {};
642
+ const index = data.isGroup
643
+ ? this.toFeatureIndex(data.anchorIndex)
644
+ : this.toFeatureIndex(data.index);
645
+ if (index === null) return undefined;
646
+ return this.workingFeatures[index];
647
+ }
648
+
649
+ private constrainPosition(
650
+ p: MarkerPoint,
651
+ geometry: DielineGeometry,
652
+ feature?: ConstraintFeature,
653
+ ): MarkerPoint {
654
+ if (!feature) {
655
+ return { x: p.x, y: p.y };
656
+ }
657
+
658
+ const minX = geometry.x - geometry.width / 2;
659
+ const minY = geometry.y - geometry.height / 2;
660
+
661
+ const nx = geometry.width > 0 ? (p.x - minX) / geometry.width : 0.5;
662
+ const ny = geometry.height > 0 ? (p.y - minY) / geometry.height : 0.5;
663
+
664
+ const scale = geometry.scale || 1;
665
+ const dielineWidth = geometry.width / scale;
666
+ const dielineHeight = geometry.height / scale;
667
+
668
+ const activeConstraints = feature.constraints?.filter(
669
+ (c) => !c.validateOnly,
670
+ );
671
+
672
+ const constrained = ConstraintRegistry.apply(
673
+ nx,
674
+ ny,
675
+ feature,
676
+ {
677
+ dielineWidth,
678
+ dielineHeight,
679
+ geometry,
680
+ },
681
+ activeConstraints,
682
+ );
683
+
684
+ return {
685
+ x: minX + constrained.x * geometry.width,
686
+ y: minY + constrained.y * geometry.height,
687
+ };
688
+ }
689
+
690
+ private toNormalizedPoint(
691
+ point: MarkerPoint,
692
+ geometry: DielineGeometry,
693
+ ): MarkerPoint {
694
+ const left = geometry.x - geometry.width / 2;
695
+ const top = geometry.y - geometry.height / 2;
696
+ return {
697
+ x: geometry.width > 0 ? (point.x - left) / geometry.width : 0.5,
698
+ y: geometry.height > 0 ? (point.y - top) / geometry.height : 0.5,
699
+ };
700
+ }
701
+
702
+ private syncFeatureFromCanvas(target: any) {
703
+ if (!this.currentGeometry) return;
704
+
705
+ const index = this.toFeatureIndex(target.data?.index);
706
+ if (index === null || index >= this.workingFeatures.length) return;
707
+
708
+ const feature = this.workingFeatures[index];
709
+ const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
710
+ const normalized = this.toNormalizedPoint(
711
+ {
712
+ x: Number(target.left || 0),
713
+ y: Number(target.top || 0),
714
+ },
715
+ geometry,
716
+ );
717
+
718
+ const updatedFeature = {
719
+ ...feature,
720
+ x: normalized.x,
721
+ y: normalized.y,
722
+ };
723
+
724
+ const next = [...this.workingFeatures];
725
+ next[index] = updatedFeature;
726
+ this.setWorkingFeatures(next);
727
+ this.hasWorkingChanges = true;
728
+ this.emitWorkingChange();
729
+ }
730
+
731
+ private syncGroupFromCanvas(target: any) {
732
+ if (!this.currentGeometry) return;
733
+
734
+ const indices = this.readGroupIndices(target.data?.indices);
735
+ if (indices.length === 0) return;
736
+ const offsets = this.readGroupMemberOffsets(target.data?.memberOffsets, indices);
737
+
738
+ const anchorCenter = {
739
+ x: Number(target.left || 0),
740
+ y: Number(target.top || 0),
741
+ };
742
+
743
+ const next = [...this.workingFeatures];
744
+ let changed = false;
745
+ offsets.forEach((entry) => {
746
+ const index = entry.index;
747
+ if (index < 0 || index >= next.length) return;
748
+ const feature = next[index];
749
+ const geometry = this.getGeometryForFeature(this.currentGeometry!, feature);
750
+ const normalized = this.toNormalizedPoint(
751
+ {
752
+ x: anchorCenter.x + entry.dx,
753
+ y: anchorCenter.y + entry.dy,
754
+ },
755
+ geometry,
756
+ );
757
+
758
+ if (feature.x !== normalized.x || feature.y !== normalized.y) {
759
+ next[index] = {
760
+ ...feature,
761
+ x: normalized.x,
762
+ y: normalized.y,
763
+ };
764
+ changed = true;
765
+ }
766
+ });
767
+
768
+ if (!changed) return;
769
+ this.setWorkingFeatures(next);
770
+ this.hasWorkingChanges = true;
771
+ this.emitWorkingChange();
772
+ }
773
+
774
+ private redraw(options: { enforceConstraints?: boolean } = {}) {
775
+ void this.redrawAsync(options);
776
+ }
777
+
778
+ private async redrawAsync(options: { enforceConstraints?: boolean } = {}) {
779
+ if (!this.canvasService) return;
780
+
781
+ const seq = ++this.renderSeq;
782
+ this.specs = this.buildFeatureSpecs();
783
+ if (seq !== this.renderSeq) return;
784
+
785
+ await this.canvasService.flushRenderFromProducers();
786
+ if (seq !== this.renderSeq) return;
787
+
788
+ this.syncOverlayOrder();
789
+ if (options.enforceConstraints) {
790
+ this.enforceConstraints();
791
+ }
792
+ }
793
+
794
+ private syncOverlayOrder() {
795
+ if (!this.canvasService) return;
796
+ this.canvasService.bringLayerToFront(FEATURE_OVERLAY_LAYER_ID);
797
+ this.canvasService.bringLayerToFront("ruler-overlay");
798
+ }
799
+
800
+ private buildFeatureSpecs(): RenderObjectSpec[] {
801
+ if (!this.currentGeometry || this.workingFeatures.length === 0) {
802
+ return [];
803
+ }
804
+
805
+ const groups = new Map<string, MarkerRenderState[]>();
806
+ const singles: MarkerRenderState[] = [];
807
+
808
+ this.workingFeatures.forEach((feature, index) => {
809
+ const geometry = this.getGeometryForFeature(this.currentGeometry!, feature);
810
+ const position = resolveFeaturePosition(feature, geometry);
811
+ const scale = geometry.scale || 1;
812
+ const marker: MarkerRenderState = {
813
+ feature,
814
+ index,
815
+ position,
816
+ geometry,
817
+ scale,
818
+ };
819
+
820
+ if (feature.groupId) {
821
+ const list = groups.get(feature.groupId) || [];
822
+ list.push(marker);
823
+ groups.set(feature.groupId, list);
824
+ return;
825
+ }
826
+ singles.push(marker);
827
+ });
828
+
829
+ const specs: RenderObjectSpec[] = [];
830
+
831
+ singles.forEach((marker) => {
832
+ this.appendMarkerSpecs(specs, marker, {
833
+ markerRole: "handle",
834
+ isGroup: false,
835
+ });
836
+ });
837
+
838
+ groups.forEach((members, groupId) => {
839
+ if (!members.length) return;
840
+ const anchor = members[0];
841
+ const memberOffsets: GroupMemberOffset[] = members.map((member) => ({
842
+ index: member.index,
843
+ dx: member.position.x - anchor.position.x,
844
+ dy: member.position.y - anchor.position.y,
845
+ }));
846
+ const indices = members.map((member) => member.index);
847
+
848
+ members
849
+ .filter((member) => member.index !== anchor.index)
850
+ .forEach((member) => {
851
+ this.appendMarkerSpecs(specs, member, {
852
+ markerRole: "member",
853
+ isGroup: false,
854
+ groupId,
855
+ });
856
+ });
857
+
858
+ this.appendMarkerSpecs(specs, anchor, {
859
+ markerRole: "handle",
860
+ isGroup: true,
861
+ groupId,
862
+ indices,
863
+ anchorIndex: anchor.index,
864
+ memberOffsets,
865
+ });
866
+ });
867
+
868
+ return specs;
869
+ }
870
+
871
+ private appendMarkerSpecs(
872
+ specs: RenderObjectSpec[],
873
+ marker: MarkerRenderState,
874
+ options: {
875
+ markerRole: "handle" | "member";
876
+ isGroup: boolean;
877
+ groupId?: string;
878
+ indices?: number[];
879
+ anchorIndex?: number;
880
+ memberOffsets?: GroupMemberOffset[];
881
+ },
882
+ ) {
883
+ const { feature, index, position, scale, geometry } = marker;
884
+ const baseRadius =
885
+ feature.shape === "circle"
886
+ ? (feature.radius ?? DEFAULT_CIRCLE_RADIUS)
887
+ : (feature.radius ?? 0);
888
+ const baseWidth =
889
+ feature.shape === "circle"
890
+ ? baseRadius * 2
891
+ : (feature.width ?? DEFAULT_RECT_SIZE);
892
+ const baseHeight =
893
+ feature.shape === "circle"
894
+ ? baseRadius * 2
895
+ : (feature.height ?? DEFAULT_RECT_SIZE);
896
+ const visualWidth = baseWidth * scale;
897
+ const visualHeight = baseHeight * scale;
898
+ const visualRadius = baseRadius * scale;
899
+ const color =
900
+ feature.color || (feature.operation === "add" ? "#00FF00" : "#FF0000");
901
+ const strokeDash =
902
+ feature.strokeDash ||
903
+ (feature.operation === "subtract" ? [4, 4] : undefined);
904
+
905
+ const interactive = options.markerRole === "handle";
906
+ const baseData = this.buildMarkerData(marker, options);
907
+ const commonProps = {
908
+ visible: this.isToolActive,
909
+ selectable: interactive && this.isToolActive,
910
+ evented: interactive && this.isToolActive,
911
+ hasControls: false,
912
+ hasBorders: false,
913
+ hoverCursor: interactive ? "move" : "default",
914
+ lockRotation: true,
915
+ lockScalingX: true,
916
+ lockScalingY: true,
917
+ fill: "transparent",
918
+ stroke: color,
919
+ strokeWidth: FEATURE_STROKE_WIDTH,
920
+ strokeDashArray: strokeDash,
921
+ originX: "center" as const,
922
+ originY: "center" as const,
923
+ left: position.x,
924
+ top: position.y,
925
+ angle: feature.rotation || 0,
926
+ };
927
+
928
+ const markerId = this.markerId(index);
929
+ if (feature.shape === "rect") {
930
+ specs.push({
931
+ id: markerId,
932
+ type: "rect",
933
+ space: "screen",
934
+ data: baseData,
935
+ props: {
936
+ ...commonProps,
937
+ width: visualWidth,
938
+ height: visualHeight,
939
+ rx: visualRadius,
940
+ ry: visualRadius,
941
+ },
942
+ });
943
+ } else {
944
+ specs.push({
945
+ id: markerId,
946
+ type: "rect",
947
+ space: "screen",
948
+ data: baseData,
949
+ props: {
950
+ ...commonProps,
951
+ width: visualWidth,
952
+ height: visualHeight,
953
+ rx: visualRadius,
954
+ ry: visualRadius,
955
+ },
956
+ });
957
+ }
958
+
959
+ if (feature.bridge?.type === "vertical") {
960
+ const featureTopY = position.y - visualHeight / 2;
961
+ const dielineTopY = geometry.y - geometry.height / 2;
962
+ const bridgeHeight = Math.max(0, featureTopY - dielineTopY);
963
+ if (bridgeHeight <= 0.001) {
964
+ return;
965
+ }
966
+ specs.push({
967
+ id: this.bridgeIndicatorId(index),
968
+ type: "rect",
969
+ space: "screen",
970
+ data: {
971
+ ...baseData,
972
+ markerRole: "indicator",
973
+ markerOffsetX: 0,
974
+ markerOffsetY: -visualHeight / 2,
975
+ } as MarkerData,
976
+ props: {
977
+ visible: this.isToolActive,
978
+ selectable: false,
979
+ evented: false,
980
+ width: visualWidth,
981
+ height: bridgeHeight,
982
+ fill: "transparent",
983
+ stroke: "#888",
984
+ strokeWidth: 1,
985
+ strokeDashArray: [2, 2],
986
+ opacity: 0.5,
987
+ originX: "center",
988
+ originY: "bottom",
989
+ left: position.x,
990
+ top: position.y - visualHeight / 2,
991
+ },
992
+ });
993
+ }
994
+ }
995
+
996
+ private buildMarkerData(
997
+ marker: MarkerRenderState,
998
+ options: {
999
+ markerRole: "handle" | "member";
1000
+ isGroup: boolean;
1001
+ groupId?: string;
1002
+ indices?: number[];
1003
+ anchorIndex?: number;
1004
+ memberOffsets?: GroupMemberOffset[];
1005
+ },
1006
+ ): MarkerData {
1007
+ const data: MarkerData = {
1008
+ type: "feature-marker",
1009
+ index: marker.index,
1010
+ featureId: marker.feature.id,
1011
+ markerRole: options.markerRole,
1012
+ markerOffsetX: 0,
1013
+ markerOffsetY: 0,
1014
+ isGroup: options.isGroup,
1015
+ };
1016
+ if (options.groupId) data.groupId = options.groupId;
1017
+ if (options.indices) data.indices = options.indices;
1018
+ if (options.anchorIndex !== undefined) data.anchorIndex = options.anchorIndex;
1019
+ if (options.memberOffsets) data.memberOffsets = options.memberOffsets;
1020
+ return data;
1021
+ }
1022
+
1023
+ private markerId(index: number): string {
1024
+ return `feature.marker.${index}`;
1025
+ }
1026
+
1027
+ private bridgeIndicatorId(index: number): string {
1028
+ return `feature.marker.${index}.bridge`;
1029
+ }
1030
+
1031
+ private toFeatureIndex(value: unknown): number | null {
1032
+ const numeric = Number(value);
1033
+ if (!Number.isInteger(numeric) || numeric < 0) return null;
1034
+ return numeric;
1035
+ }
1036
+
1037
+ private readGroupIndices(raw: unknown): number[] {
1038
+ if (!Array.isArray(raw)) return [];
1039
+ return raw
1040
+ .map((value) => this.toFeatureIndex(value))
1041
+ .filter((value): value is number => value !== null);
1042
+ }
1043
+
1044
+ private readGroupMemberOffsets(
1045
+ raw: unknown,
1046
+ fallbackIndices: number[] = [],
1047
+ ): GroupMemberOffset[] {
1048
+ if (Array.isArray(raw)) {
1049
+ const parsed = raw
1050
+ .map((entry) => {
1051
+ const index = this.toFeatureIndex((entry as any)?.index);
1052
+ const dx = Number((entry as any)?.dx);
1053
+ const dy = Number((entry as any)?.dy);
1054
+ if (index === null || !Number.isFinite(dx) || !Number.isFinite(dy)) {
1055
+ return null;
1056
+ }
1057
+ return { index, dx, dy };
1058
+ })
1059
+ .filter((value): value is GroupMemberOffset => !!value);
1060
+ if (parsed.length > 0) return parsed;
1061
+ }
1062
+
1063
+ return fallbackIndices.map((index) => ({ index, dx: 0, dy: 0 }));
1064
+ }
1065
+
1066
+ private syncMarkerVisualsByTarget(target: any, center: MarkerPoint) {
1067
+ if (target.data?.isGroup) {
1068
+ const indices = this.readGroupIndices(target.data?.indices);
1069
+ const offsets = this.readGroupMemberOffsets(target.data?.memberOffsets, indices);
1070
+ offsets.forEach((entry) => {
1071
+ this.syncMarkerVisualObjectsToCenter(entry.index, {
1072
+ x: center.x + entry.dx,
1073
+ y: center.y + entry.dy,
1074
+ });
1075
+ });
1076
+ this.canvasService?.requestRenderAll();
1077
+ return;
1078
+ }
1079
+
1080
+ const index = this.toFeatureIndex(target.data?.index);
1081
+ if (index === null) return;
1082
+ this.syncMarkerVisualObjectsToCenter(index, center);
1083
+ this.canvasService?.requestRenderAll();
1084
+ }
1085
+
1086
+ private syncMarkerVisualObjectsToCenter(index: number, center: MarkerPoint) {
1087
+ if (!this.canvasService) return;
1088
+ const markers = this.canvasService.canvas
1089
+ .getObjects()
1090
+ .filter(
1091
+ (obj: any) =>
1092
+ obj?.data?.type === "feature-marker" &&
1093
+ this.toFeatureIndex(obj?.data?.index) === index,
1094
+ );
1095
+
1096
+ markers.forEach((marker: any) => {
1097
+ const offsetX = Number(marker?.data?.markerOffsetX || 0);
1098
+ const offsetY = Number(marker?.data?.markerOffsetY || 0);
1099
+ marker.set({
1100
+ left: center.x + offsetX,
1101
+ top: center.y + offsetY,
1102
+ });
1103
+ marker.setCoords();
1104
+ });
1105
+ }
1106
+
1107
+ private enforceConstraints() {
1108
+ if (!this.canvasService || !this.currentGeometry) return;
1109
+
1110
+ const handles = this.canvasService.canvas
1111
+ .getObjects()
1112
+ .filter(
1113
+ (obj: any) =>
1114
+ obj?.data?.type === "feature-marker" &&
1115
+ obj?.data?.markerRole === "handle",
1116
+ );
1117
+
1118
+ handles.forEach((marker: any) => {
1119
+ const feature = this.getFeatureForMarker(marker);
1120
+ if (!feature) return;
1121
+ const geometry = this.getGeometryForFeature(this.currentGeometry!, feature);
1122
+ const snapped = this.constrainPosition(
1123
+ {
1124
+ x: Number(marker.left || 0),
1125
+ y: Number(marker.top || 0),
1126
+ },
1127
+ geometry,
1128
+ feature,
1129
+ );
1130
+ marker.set({ left: snapped.x, top: snapped.y });
1131
+ marker.setCoords();
1132
+ this.syncMarkerVisualsByTarget(marker, snapped);
1133
+ });
1134
+
1135
+ this.canvasService.canvas.requestRenderAll();
1136
+ }
1137
+ }