@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,1137 +1,1140 @@
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
- }
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
+ passes: [
105
+ {
106
+ id: FEATURE_OVERLAY_LAYER_ID,
107
+ stack: 880,
108
+ order: 0,
109
+ objects: this.specs,
110
+ },
111
+ ],
112
+ }),
113
+ { priority: 350 },
114
+ );
115
+
116
+ const configService = context.services.get<ConfigurationService>(
117
+ "ConfigurationService",
118
+ );
119
+ if (configService) {
120
+ const features = (configService.get("dieline.features", []) ||
121
+ []) as ConstraintFeature[];
122
+ this.workingFeatures = this.cloneFeatures(features);
123
+ this.hasWorkingChanges = false;
124
+
125
+ configService.onAnyChange((e: { key: string; value: any }) => {
126
+ if (this.isUpdatingConfig) return;
127
+
128
+ if (e.key === "dieline.features") {
129
+ if (this.isFeatureSessionActive) return;
130
+ const next = (e.value || []) as ConstraintFeature[];
131
+ this.workingFeatures = this.cloneFeatures(next);
132
+ this.hasWorkingChanges = false;
133
+ this.redraw();
134
+ this.emitWorkingChange();
135
+ }
136
+ });
137
+ }
138
+
139
+ const toolSessionService =
140
+ context.services.get<ToolSessionService>("ToolSessionService");
141
+ this.dirtyTrackerDisposable = toolSessionService?.registerDirtyTracker(
142
+ this.id,
143
+ () => this.hasWorkingChanges,
144
+ );
145
+
146
+ context.eventBus.on("tool:activated", this.onToolActivated);
147
+
148
+ this.setup();
149
+ }
150
+
151
+ deactivate(context: ExtensionContext) {
152
+ context.eventBus.off("tool:activated", this.onToolActivated);
153
+ this.restoreSessionFeaturesToConfig();
154
+ this.dirtyTrackerDisposable?.dispose();
155
+ this.dirtyTrackerDisposable = undefined;
156
+ this.teardown();
157
+ this.canvasService = undefined;
158
+ this.context = undefined;
159
+ }
160
+
161
+ private onToolActivated = (event: { id: string | null }) => {
162
+ this.isToolActive = event.id === this.id;
163
+ if (!this.isToolActive) {
164
+ this.restoreSessionFeaturesToConfig();
165
+ }
166
+ this.updateVisibility();
167
+ };
168
+
169
+ private updateVisibility() {
170
+ this.redraw();
171
+ }
172
+
173
+ contribute() {
174
+ return {
175
+ [ContributionPointIds.TOOLS]: [
176
+ {
177
+ id: this.id,
178
+ name: "Feature",
179
+ interaction: "session",
180
+ commands: {
181
+ begin: "beginFeatureSession",
182
+ commit: "completeFeatures",
183
+ rollback: "rollbackFeatureSession",
184
+ },
185
+ session: {
186
+ autoBegin: false,
187
+ leavePolicy: "block",
188
+ },
189
+ },
190
+ ],
191
+ [ContributionPointIds.COMMANDS]: [
192
+ {
193
+ command: "beginFeatureSession",
194
+ title: "Begin Feature Session",
195
+ handler: async () => {
196
+ if (this.isFeatureSessionActive) {
197
+ return { ok: true };
198
+ }
199
+ const original = this.getCommittedFeatures();
200
+ this.sessionOriginalFeatures = this.cloneFeatures(original);
201
+ this.isFeatureSessionActive = true;
202
+ await this.refreshGeometry();
203
+ this.setWorkingFeatures(this.cloneFeatures(original));
204
+ this.hasWorkingChanges = false;
205
+ this.redraw();
206
+ this.emitWorkingChange();
207
+ this.updateCommittedFeatures([]);
208
+ return { ok: true };
209
+ },
210
+ },
211
+ {
212
+ command: "addFeature",
213
+ title: "Add Edge Feature",
214
+ handler: (type: "add" | "subtract" = "subtract") => {
215
+ return this.addFeature(type);
216
+ },
217
+ },
218
+ {
219
+ command: "addHole",
220
+ title: "Add Hole",
221
+ handler: () => {
222
+ return this.addFeature("subtract");
223
+ },
224
+ },
225
+ {
226
+ command: "addDoubleLayerHole",
227
+ title: "Add Double Layer Hole",
228
+ handler: () => {
229
+ return this.addDoubleLayerHole();
230
+ },
231
+ },
232
+ {
233
+ command: "clearFeatures",
234
+ title: "Clear Features",
235
+ handler: () => {
236
+ this.setWorkingFeatures([]);
237
+ this.hasWorkingChanges = true;
238
+ this.redraw();
239
+ this.emitWorkingChange();
240
+ return true;
241
+ },
242
+ },
243
+ {
244
+ command: "getWorkingFeatures",
245
+ title: "Get Working Features",
246
+ handler: () => {
247
+ return this.cloneFeatures(this.workingFeatures);
248
+ },
249
+ },
250
+ {
251
+ command: "setWorkingFeatures",
252
+ title: "Set Working Features",
253
+ handler: async (features: ConstraintFeature[]) => {
254
+ await this.refreshGeometry();
255
+ this.setWorkingFeatures(this.cloneFeatures(features || []));
256
+ this.hasWorkingChanges = true;
257
+ this.redraw();
258
+ this.emitWorkingChange();
259
+ return { ok: true };
260
+ },
261
+ },
262
+ {
263
+ command: "rollbackFeatureSession",
264
+ title: "Rollback Feature Session",
265
+ handler: async () => {
266
+ const original = this.cloneFeatures(
267
+ this.sessionOriginalFeatures || this.getCommittedFeatures(),
268
+ );
269
+ await this.refreshGeometry();
270
+ this.setWorkingFeatures(original);
271
+ this.hasWorkingChanges = false;
272
+ this.clearFeatureSessionState();
273
+ this.redraw();
274
+ this.emitWorkingChange();
275
+ this.updateCommittedFeatures(original);
276
+ return { ok: true };
277
+ },
278
+ },
279
+ {
280
+ command: "resetWorkingFeatures",
281
+ title: "Reset Working Features",
282
+ handler: async () => {
283
+ await this.resetWorkingFeaturesFromSource();
284
+ return { ok: true };
285
+ },
286
+ },
287
+ {
288
+ command: "updateWorkingGroupPosition",
289
+ title: "Update Working Group Position",
290
+ handler: (groupId: string, x: number, y: number) => {
291
+ return this.updateWorkingGroupPosition(groupId, x, y);
292
+ },
293
+ },
294
+ {
295
+ command: "completeFeatures",
296
+ title: "Complete Features",
297
+ handler: () => {
298
+ return this.completeFeatures();
299
+ },
300
+ },
301
+ ] as CommandContribution[],
302
+ };
303
+ }
304
+
305
+ private cloneFeatures(features: ConstraintFeature[]): ConstraintFeature[] {
306
+ return JSON.parse(JSON.stringify(features || [])) as ConstraintFeature[];
307
+ }
308
+
309
+ private getConfigService(): ConfigurationService | undefined {
310
+ return this.context?.services.get<ConfigurationService>(
311
+ "ConfigurationService",
312
+ );
313
+ }
314
+
315
+ private getCommittedFeatures(): ConstraintFeature[] {
316
+ const configService = this.getConfigService();
317
+ const committed = (configService?.get("dieline.features", []) ||
318
+ []) as ConstraintFeature[];
319
+ return this.cloneFeatures(committed);
320
+ }
321
+
322
+ private updateCommittedFeatures(next: ConstraintFeature[]) {
323
+ const configService = this.getConfigService();
324
+ if (!configService) return;
325
+ this.isUpdatingConfig = true;
326
+ try {
327
+ configService.update("dieline.features", next);
328
+ } finally {
329
+ this.isUpdatingConfig = false;
330
+ }
331
+ }
332
+
333
+ private clearFeatureSessionState() {
334
+ this.isFeatureSessionActive = false;
335
+ this.sessionOriginalFeatures = null;
336
+ }
337
+
338
+ private restoreSessionFeaturesToConfig() {
339
+ if (!this.isFeatureSessionActive) return;
340
+ const original = this.cloneFeatures(
341
+ this.sessionOriginalFeatures || this.getCommittedFeatures(),
342
+ );
343
+ this.updateCommittedFeatures(original);
344
+ this.clearFeatureSessionState();
345
+ }
346
+
347
+ private emitWorkingChange() {
348
+ this.context?.eventBus.emit("feature:working:change", {
349
+ features: this.cloneFeatures(this.workingFeatures),
350
+ });
351
+ }
352
+
353
+ private async refreshGeometry() {
354
+ if (!this.context) return;
355
+ const commandService = this.context.services.get<any>("CommandService");
356
+ if (!commandService) return;
357
+ try {
358
+ const g = await Promise.resolve(
359
+ commandService.executeCommand("getSceneGeometry"),
360
+ );
361
+ if (g) this.currentGeometry = g as DielineGeometry;
362
+ } catch (e) {}
363
+ }
364
+
365
+ private async resetWorkingFeaturesFromSource() {
366
+ const next = this.cloneFeatures(
367
+ this.isFeatureSessionActive && this.sessionOriginalFeatures
368
+ ? this.sessionOriginalFeatures
369
+ : this.getCommittedFeatures(),
370
+ );
371
+ await this.refreshGeometry();
372
+ this.setWorkingFeatures(next);
373
+ this.hasWorkingChanges = false;
374
+ this.redraw();
375
+ this.emitWorkingChange();
376
+ }
377
+
378
+ private setWorkingFeatures(next: ConstraintFeature[]) {
379
+ this.workingFeatures = next;
380
+ }
381
+
382
+ private updateWorkingGroupPosition(groupId: string, x: number, y: number) {
383
+ if (!groupId) return { ok: false };
384
+
385
+ const configService = this.context?.services.get<ConfigurationService>(
386
+ "ConfigurationService",
387
+ );
388
+ if (!configService) return { ok: false };
389
+
390
+ const sizeState = readSizeState(configService);
391
+ const dielineWidth = sizeState.actualWidthMm;
392
+ const dielineHeight = sizeState.actualHeightMm;
393
+
394
+ let changed = false;
395
+ const next = this.workingFeatures.map((f) => {
396
+ if (f.groupId !== groupId) return f;
397
+ let nx = x;
398
+ let ny = y;
399
+ if (f.constraints && dielineWidth > 0 && dielineHeight > 0) {
400
+ const constrained = ConstraintRegistry.apply(nx, ny, f, {
401
+ dielineWidth,
402
+ dielineHeight,
403
+ });
404
+ nx = constrained.x;
405
+ ny = constrained.y;
406
+ }
407
+
408
+ if (f.x !== nx || f.y !== ny) {
409
+ changed = true;
410
+ return { ...f, x: nx, y: ny };
411
+ }
412
+ return f;
413
+ });
414
+
415
+ if (!changed) return { ok: true };
416
+
417
+ this.setWorkingFeatures(next);
418
+ this.hasWorkingChanges = true;
419
+ this.redraw({ enforceConstraints: true });
420
+ this.emitWorkingChange();
421
+
422
+ return { ok: true };
423
+ }
424
+
425
+ private completeFeatures(): {
426
+ ok: boolean;
427
+ issues?: Array<{
428
+ featureId: string;
429
+ groupId?: string;
430
+ reason: string;
431
+ }>;
432
+ } {
433
+ const configService = this.context?.services.get<ConfigurationService>(
434
+ "ConfigurationService",
435
+ );
436
+ if (!configService) {
437
+ return {
438
+ ok: false,
439
+ issues: [
440
+ { featureId: "unknown", reason: "ConfigurationService not found" },
441
+ ],
442
+ };
443
+ }
444
+
445
+ const sizeState = readSizeState(configService);
446
+ const dielineWidth = sizeState.actualWidthMm;
447
+ const dielineHeight = sizeState.actualHeightMm;
448
+
449
+ const result = completeFeaturesStrict(
450
+ this.workingFeatures,
451
+ { dielineWidth, dielineHeight },
452
+ (next) => {
453
+ this.updateCommittedFeatures(next as ConstraintFeature[]);
454
+ this.workingFeatures = this.cloneFeatures(next as ConstraintFeature[]);
455
+ this.emitWorkingChange();
456
+ },
457
+ );
458
+
459
+ if (!result.ok) {
460
+ return {
461
+ ok: false,
462
+ issues: result.issues,
463
+ };
464
+ }
465
+
466
+ this.hasWorkingChanges = false;
467
+ this.clearFeatureSessionState();
468
+ this.redraw();
469
+ return { ok: true };
470
+ }
471
+
472
+ private addFeature(type: "add" | "subtract") {
473
+ if (!this.canvasService) return false;
474
+
475
+ const newFeature: ConstraintFeature = {
476
+ id: Date.now().toString(),
477
+ operation: type,
478
+ shape: "rect",
479
+ x: 0.5,
480
+ y: 0,
481
+ width: 10,
482
+ height: 10,
483
+ rotation: 0,
484
+ renderBehavior: "edge",
485
+ constraints: [{ type: "path" }],
486
+ };
487
+
488
+ this.setWorkingFeatures([...(this.workingFeatures || []), newFeature]);
489
+ this.hasWorkingChanges = true;
490
+ this.redraw();
491
+ this.emitWorkingChange();
492
+ return true;
493
+ }
494
+
495
+ private addDoubleLayerHole() {
496
+ if (!this.canvasService) return false;
497
+
498
+ const groupId = Date.now().toString();
499
+ const timestamp = Date.now();
500
+
501
+ const lug: ConstraintFeature = {
502
+ id: `${timestamp}-lug`,
503
+ groupId,
504
+ operation: "add",
505
+ shape: "circle",
506
+ x: 0.5,
507
+ y: 0,
508
+ radius: 20,
509
+ rotation: 0,
510
+ renderBehavior: "edge",
511
+ constraints: [{ type: "path" }],
512
+ };
513
+
514
+ const hole: ConstraintFeature = {
515
+ id: `${timestamp}-hole`,
516
+ groupId,
517
+ operation: "subtract",
518
+ shape: "circle",
519
+ x: 0.5,
520
+ y: 0,
521
+ radius: 15,
522
+ rotation: 0,
523
+ renderBehavior: "edge",
524
+ constraints: [{ type: "path" }],
525
+ };
526
+
527
+ this.setWorkingFeatures([...(this.workingFeatures || []), lug, hole]);
528
+ this.hasWorkingChanges = true;
529
+ this.redraw();
530
+ this.emitWorkingChange();
531
+ return true;
532
+ }
533
+
534
+ private getGeometryForFeature(
535
+ geometry: DielineGeometry,
536
+ _feature?: ConstraintFeature,
537
+ ): DielineGeometry {
538
+ return geometry;
539
+ }
540
+
541
+ private setup() {
542
+ if (!this.canvasService || !this.context) return;
543
+ const canvas = this.canvasService.canvas;
544
+
545
+ if (!this.handleSceneGeometryChange) {
546
+ this.handleSceneGeometryChange = (geometry: DielineGeometry) => {
547
+ this.currentGeometry = geometry;
548
+ this.redraw({ enforceConstraints: true });
549
+ };
550
+ this.context.eventBus.on(
551
+ "scene:geometry:change",
552
+ this.handleSceneGeometryChange,
553
+ );
554
+ }
555
+
556
+ const commandService = this.context.services.get<any>("CommandService");
557
+ if (commandService) {
558
+ try {
559
+ Promise.resolve(commandService.executeCommand("getSceneGeometry")).then(
560
+ (g) => {
561
+ if (g) {
562
+ this.currentGeometry = g as DielineGeometry;
563
+ this.redraw();
564
+ }
565
+ },
566
+ );
567
+ } catch (e) {}
568
+ }
569
+
570
+ if (!this.handleMoving) {
571
+ this.handleMoving = (e: any) => {
572
+ const target = this.getDraggableMarkerTarget(e?.target);
573
+ if (!target || !this.currentGeometry) return;
574
+
575
+ const feature = this.getFeatureForMarker(target);
576
+ const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
577
+ const snapped = this.constrainPosition(
578
+ {
579
+ x: Number(target.left || 0),
580
+ y: Number(target.top || 0),
581
+ },
582
+ geometry,
583
+ feature,
584
+ );
585
+
586
+ target.set({
587
+ left: snapped.x,
588
+ top: snapped.y,
589
+ });
590
+ target.setCoords();
591
+
592
+ this.syncMarkerVisualsByTarget(target, snapped);
593
+ };
594
+ canvas.on("object:moving", this.handleMoving);
595
+ }
596
+
597
+ if (!this.handleModified) {
598
+ this.handleModified = (e: any) => {
599
+ const target = this.getDraggableMarkerTarget(e?.target);
600
+ if (!target) return;
601
+
602
+ if (target.data?.isGroup) {
603
+ this.syncGroupFromCanvas(target);
604
+ } else {
605
+ this.syncFeatureFromCanvas(target);
606
+ }
607
+ };
608
+ canvas.on("object:modified", this.handleModified);
609
+ }
610
+ }
611
+
612
+ private teardown() {
613
+ if (!this.canvasService) return;
614
+ const canvas = this.canvasService.canvas;
615
+
616
+ if (this.handleMoving) {
617
+ canvas.off("object:moving", this.handleMoving);
618
+ this.handleMoving = null;
619
+ }
620
+ if (this.handleModified) {
621
+ canvas.off("object:modified", this.handleModified);
622
+ this.handleModified = null;
623
+ }
624
+ if (this.handleSceneGeometryChange && this.context) {
625
+ this.context.eventBus.off(
626
+ "scene:geometry:change",
627
+ this.handleSceneGeometryChange,
628
+ );
629
+ this.handleSceneGeometryChange = null;
630
+ }
631
+
632
+ this.renderSeq += 1;
633
+ this.specs = [];
634
+ this.renderProducerDisposable?.dispose();
635
+ this.renderProducerDisposable = undefined;
636
+ void this.canvasService.flushRenderFromProducers();
637
+ }
638
+
639
+ private getDraggableMarkerTarget(target: any): any | null {
640
+ if (!this.isFeatureSessionActive || !this.isToolActive) return null;
641
+ if (!target || target.data?.type !== "feature-marker") return null;
642
+ if (target.data?.markerRole !== "handle") return null;
643
+ return target;
644
+ }
645
+
646
+ private getFeatureForMarker(target: any): ConstraintFeature | undefined {
647
+ const data = target?.data || {};
648
+ const index = data.isGroup
649
+ ? this.toFeatureIndex(data.anchorIndex)
650
+ : this.toFeatureIndex(data.index);
651
+ if (index === null) return undefined;
652
+ return this.workingFeatures[index];
653
+ }
654
+
655
+ private constrainPosition(
656
+ p: MarkerPoint,
657
+ geometry: DielineGeometry,
658
+ feature?: ConstraintFeature,
659
+ ): MarkerPoint {
660
+ if (!feature) {
661
+ return { x: p.x, y: p.y };
662
+ }
663
+
664
+ const minX = geometry.x - geometry.width / 2;
665
+ const minY = geometry.y - geometry.height / 2;
666
+
667
+ const nx = geometry.width > 0 ? (p.x - minX) / geometry.width : 0.5;
668
+ const ny = geometry.height > 0 ? (p.y - minY) / geometry.height : 0.5;
669
+
670
+ const scale = geometry.scale || 1;
671
+ const dielineWidth = geometry.width / scale;
672
+ const dielineHeight = geometry.height / scale;
673
+
674
+ const activeConstraints = feature.constraints?.filter(
675
+ (c) => !c.validateOnly,
676
+ );
677
+
678
+ const constrained = ConstraintRegistry.apply(
679
+ nx,
680
+ ny,
681
+ feature,
682
+ {
683
+ dielineWidth,
684
+ dielineHeight,
685
+ geometry,
686
+ },
687
+ activeConstraints,
688
+ );
689
+
690
+ return {
691
+ x: minX + constrained.x * geometry.width,
692
+ y: minY + constrained.y * geometry.height,
693
+ };
694
+ }
695
+
696
+ private toNormalizedPoint(
697
+ point: MarkerPoint,
698
+ geometry: DielineGeometry,
699
+ ): MarkerPoint {
700
+ const left = geometry.x - geometry.width / 2;
701
+ const top = geometry.y - geometry.height / 2;
702
+ return {
703
+ x: geometry.width > 0 ? (point.x - left) / geometry.width : 0.5,
704
+ y: geometry.height > 0 ? (point.y - top) / geometry.height : 0.5,
705
+ };
706
+ }
707
+
708
+ private syncFeatureFromCanvas(target: any) {
709
+ if (!this.currentGeometry) return;
710
+
711
+ const index = this.toFeatureIndex(target.data?.index);
712
+ if (index === null || index >= this.workingFeatures.length) return;
713
+
714
+ const feature = this.workingFeatures[index];
715
+ const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
716
+ const normalized = this.toNormalizedPoint(
717
+ {
718
+ x: Number(target.left || 0),
719
+ y: Number(target.top || 0),
720
+ },
721
+ geometry,
722
+ );
723
+
724
+ const updatedFeature = {
725
+ ...feature,
726
+ x: normalized.x,
727
+ y: normalized.y,
728
+ };
729
+
730
+ const next = [...this.workingFeatures];
731
+ next[index] = updatedFeature;
732
+ this.setWorkingFeatures(next);
733
+ this.hasWorkingChanges = true;
734
+ this.emitWorkingChange();
735
+ }
736
+
737
+ private syncGroupFromCanvas(target: any) {
738
+ if (!this.currentGeometry) return;
739
+
740
+ const indices = this.readGroupIndices(target.data?.indices);
741
+ if (indices.length === 0) return;
742
+ const offsets = this.readGroupMemberOffsets(target.data?.memberOffsets, indices);
743
+
744
+ const anchorCenter = {
745
+ x: Number(target.left || 0),
746
+ y: Number(target.top || 0),
747
+ };
748
+
749
+ const next = [...this.workingFeatures];
750
+ let changed = false;
751
+ offsets.forEach((entry) => {
752
+ const index = entry.index;
753
+ if (index < 0 || index >= next.length) return;
754
+ const feature = next[index];
755
+ const geometry = this.getGeometryForFeature(this.currentGeometry!, feature);
756
+ const normalized = this.toNormalizedPoint(
757
+ {
758
+ x: anchorCenter.x + entry.dx,
759
+ y: anchorCenter.y + entry.dy,
760
+ },
761
+ geometry,
762
+ );
763
+
764
+ if (feature.x !== normalized.x || feature.y !== normalized.y) {
765
+ next[index] = {
766
+ ...feature,
767
+ x: normalized.x,
768
+ y: normalized.y,
769
+ };
770
+ changed = true;
771
+ }
772
+ });
773
+
774
+ if (!changed) return;
775
+ this.setWorkingFeatures(next);
776
+ this.hasWorkingChanges = true;
777
+ this.emitWorkingChange();
778
+ }
779
+
780
+ private redraw(options: { enforceConstraints?: boolean } = {}) {
781
+ void this.redrawAsync(options);
782
+ }
783
+
784
+ private async redrawAsync(options: { enforceConstraints?: boolean } = {}) {
785
+ if (!this.canvasService) return;
786
+
787
+ const seq = ++this.renderSeq;
788
+ this.specs = this.buildFeatureSpecs();
789
+ if (seq !== this.renderSeq) return;
790
+
791
+ await this.canvasService.flushRenderFromProducers();
792
+ if (seq !== this.renderSeq) return;
793
+ if (options.enforceConstraints) {
794
+ this.enforceConstraints();
795
+ }
796
+ }
797
+
798
+ private buildFeatureSpecs(): RenderObjectSpec[] {
799
+ if (
800
+ !this.isFeatureSessionActive ||
801
+ !this.currentGeometry ||
802
+ this.workingFeatures.length === 0
803
+ ) {
804
+ return [];
805
+ }
806
+
807
+ const groups = new Map<string, MarkerRenderState[]>();
808
+ const singles: MarkerRenderState[] = [];
809
+
810
+ this.workingFeatures.forEach((feature, index) => {
811
+ const geometry = this.getGeometryForFeature(this.currentGeometry!, feature);
812
+ const position = resolveFeaturePosition(feature, geometry);
813
+ const scale = geometry.scale || 1;
814
+ const marker: MarkerRenderState = {
815
+ feature,
816
+ index,
817
+ position,
818
+ geometry,
819
+ scale,
820
+ };
821
+
822
+ if (feature.groupId) {
823
+ const list = groups.get(feature.groupId) || [];
824
+ list.push(marker);
825
+ groups.set(feature.groupId, list);
826
+ return;
827
+ }
828
+ singles.push(marker);
829
+ });
830
+
831
+ const specs: RenderObjectSpec[] = [];
832
+
833
+ singles.forEach((marker) => {
834
+ this.appendMarkerSpecs(specs, marker, {
835
+ markerRole: "handle",
836
+ isGroup: false,
837
+ });
838
+ });
839
+
840
+ groups.forEach((members, groupId) => {
841
+ if (!members.length) return;
842
+ const anchor = members[0];
843
+ const memberOffsets: GroupMemberOffset[] = members.map((member) => ({
844
+ index: member.index,
845
+ dx: member.position.x - anchor.position.x,
846
+ dy: member.position.y - anchor.position.y,
847
+ }));
848
+ const indices = members.map((member) => member.index);
849
+
850
+ members
851
+ .filter((member) => member.index !== anchor.index)
852
+ .forEach((member) => {
853
+ this.appendMarkerSpecs(specs, member, {
854
+ markerRole: "member",
855
+ isGroup: false,
856
+ groupId,
857
+ });
858
+ });
859
+
860
+ this.appendMarkerSpecs(specs, anchor, {
861
+ markerRole: "handle",
862
+ isGroup: true,
863
+ groupId,
864
+ indices,
865
+ anchorIndex: anchor.index,
866
+ memberOffsets,
867
+ });
868
+ });
869
+
870
+ return specs;
871
+ }
872
+
873
+ private appendMarkerSpecs(
874
+ specs: RenderObjectSpec[],
875
+ marker: MarkerRenderState,
876
+ options: {
877
+ markerRole: "handle" | "member";
878
+ isGroup: boolean;
879
+ groupId?: string;
880
+ indices?: number[];
881
+ anchorIndex?: number;
882
+ memberOffsets?: GroupMemberOffset[];
883
+ },
884
+ ) {
885
+ const { feature, index, position, scale, geometry } = marker;
886
+ const baseRadius =
887
+ feature.shape === "circle"
888
+ ? (feature.radius ?? DEFAULT_CIRCLE_RADIUS)
889
+ : (feature.radius ?? 0);
890
+ const baseWidth =
891
+ feature.shape === "circle"
892
+ ? baseRadius * 2
893
+ : (feature.width ?? DEFAULT_RECT_SIZE);
894
+ const baseHeight =
895
+ feature.shape === "circle"
896
+ ? baseRadius * 2
897
+ : (feature.height ?? DEFAULT_RECT_SIZE);
898
+ const visualWidth = baseWidth * scale;
899
+ const visualHeight = baseHeight * scale;
900
+ const visualRadius = baseRadius * scale;
901
+ const color =
902
+ feature.color || (feature.operation === "add" ? "#00FF00" : "#FF0000");
903
+ const strokeDash =
904
+ feature.strokeDash ||
905
+ (feature.operation === "subtract" ? [4, 4] : undefined);
906
+
907
+ const interactive = options.markerRole === "handle";
908
+ const sessionVisible = this.isToolActive && this.isFeatureSessionActive;
909
+ const baseData = this.buildMarkerData(marker, options);
910
+ const commonProps = {
911
+ visible: sessionVisible,
912
+ selectable: interactive && sessionVisible,
913
+ evented: interactive && sessionVisible,
914
+ hasControls: false,
915
+ hasBorders: false,
916
+ hoverCursor: interactive ? "move" : "default",
917
+ lockRotation: true,
918
+ lockScalingX: true,
919
+ lockScalingY: true,
920
+ fill: "transparent",
921
+ stroke: color,
922
+ strokeWidth: FEATURE_STROKE_WIDTH,
923
+ strokeDashArray: strokeDash,
924
+ originX: "center" as const,
925
+ originY: "center" as const,
926
+ left: position.x,
927
+ top: position.y,
928
+ angle: feature.rotation || 0,
929
+ };
930
+
931
+ const markerId = this.markerId(index);
932
+ if (feature.shape === "rect") {
933
+ specs.push({
934
+ id: markerId,
935
+ type: "rect",
936
+ space: "screen",
937
+ data: baseData,
938
+ props: {
939
+ ...commonProps,
940
+ width: visualWidth,
941
+ height: visualHeight,
942
+ rx: visualRadius,
943
+ ry: visualRadius,
944
+ },
945
+ });
946
+ } else {
947
+ specs.push({
948
+ id: markerId,
949
+ type: "rect",
950
+ space: "screen",
951
+ data: baseData,
952
+ props: {
953
+ ...commonProps,
954
+ width: visualWidth,
955
+ height: visualHeight,
956
+ rx: visualRadius,
957
+ ry: visualRadius,
958
+ },
959
+ });
960
+ }
961
+
962
+ if (feature.bridge?.type === "vertical") {
963
+ const featureTopY = position.y - visualHeight / 2;
964
+ const dielineTopY = geometry.y - geometry.height / 2;
965
+ const bridgeHeight = Math.max(0, featureTopY - dielineTopY);
966
+ if (bridgeHeight <= 0.001) {
967
+ return;
968
+ }
969
+ specs.push({
970
+ id: this.bridgeIndicatorId(index),
971
+ type: "rect",
972
+ space: "screen",
973
+ data: {
974
+ ...baseData,
975
+ markerRole: "indicator",
976
+ markerOffsetX: 0,
977
+ markerOffsetY: -visualHeight / 2,
978
+ } as MarkerData,
979
+ props: {
980
+ visible: sessionVisible,
981
+ selectable: false,
982
+ evented: false,
983
+ width: visualWidth,
984
+ height: bridgeHeight,
985
+ fill: "transparent",
986
+ stroke: "#888",
987
+ strokeWidth: 1,
988
+ strokeDashArray: [2, 2],
989
+ opacity: 0.5,
990
+ originX: "center",
991
+ originY: "bottom",
992
+ left: position.x,
993
+ top: position.y - visualHeight / 2,
994
+ },
995
+ });
996
+ }
997
+ }
998
+
999
+ private buildMarkerData(
1000
+ marker: MarkerRenderState,
1001
+ options: {
1002
+ markerRole: "handle" | "member";
1003
+ isGroup: boolean;
1004
+ groupId?: string;
1005
+ indices?: number[];
1006
+ anchorIndex?: number;
1007
+ memberOffsets?: GroupMemberOffset[];
1008
+ },
1009
+ ): MarkerData {
1010
+ const data: MarkerData = {
1011
+ type: "feature-marker",
1012
+ index: marker.index,
1013
+ featureId: marker.feature.id,
1014
+ markerRole: options.markerRole,
1015
+ markerOffsetX: 0,
1016
+ markerOffsetY: 0,
1017
+ isGroup: options.isGroup,
1018
+ };
1019
+ if (options.groupId) data.groupId = options.groupId;
1020
+ if (options.indices) data.indices = options.indices;
1021
+ if (options.anchorIndex !== undefined) data.anchorIndex = options.anchorIndex;
1022
+ if (options.memberOffsets) data.memberOffsets = options.memberOffsets;
1023
+ return data;
1024
+ }
1025
+
1026
+ private markerId(index: number): string {
1027
+ return `feature.marker.${index}`;
1028
+ }
1029
+
1030
+ private bridgeIndicatorId(index: number): string {
1031
+ return `feature.marker.${index}.bridge`;
1032
+ }
1033
+
1034
+ private toFeatureIndex(value: unknown): number | null {
1035
+ const numeric = Number(value);
1036
+ if (!Number.isInteger(numeric) || numeric < 0) return null;
1037
+ return numeric;
1038
+ }
1039
+
1040
+ private readGroupIndices(raw: unknown): number[] {
1041
+ if (!Array.isArray(raw)) return [];
1042
+ return raw
1043
+ .map((value) => this.toFeatureIndex(value))
1044
+ .filter((value): value is number => value !== null);
1045
+ }
1046
+
1047
+ private readGroupMemberOffsets(
1048
+ raw: unknown,
1049
+ fallbackIndices: number[] = [],
1050
+ ): GroupMemberOffset[] {
1051
+ if (Array.isArray(raw)) {
1052
+ const parsed = raw
1053
+ .map((entry) => {
1054
+ const index = this.toFeatureIndex((entry as any)?.index);
1055
+ const dx = Number((entry as any)?.dx);
1056
+ const dy = Number((entry as any)?.dy);
1057
+ if (index === null || !Number.isFinite(dx) || !Number.isFinite(dy)) {
1058
+ return null;
1059
+ }
1060
+ return { index, dx, dy };
1061
+ })
1062
+ .filter((value): value is GroupMemberOffset => !!value);
1063
+ if (parsed.length > 0) return parsed;
1064
+ }
1065
+
1066
+ return fallbackIndices.map((index) => ({ index, dx: 0, dy: 0 }));
1067
+ }
1068
+
1069
+ private syncMarkerVisualsByTarget(target: any, center: MarkerPoint) {
1070
+ if (target.data?.isGroup) {
1071
+ const indices = this.readGroupIndices(target.data?.indices);
1072
+ const offsets = this.readGroupMemberOffsets(target.data?.memberOffsets, indices);
1073
+ offsets.forEach((entry) => {
1074
+ this.syncMarkerVisualObjectsToCenter(entry.index, {
1075
+ x: center.x + entry.dx,
1076
+ y: center.y + entry.dy,
1077
+ });
1078
+ });
1079
+ this.canvasService?.requestRenderAll();
1080
+ return;
1081
+ }
1082
+
1083
+ const index = this.toFeatureIndex(target.data?.index);
1084
+ if (index === null) return;
1085
+ this.syncMarkerVisualObjectsToCenter(index, center);
1086
+ this.canvasService?.requestRenderAll();
1087
+ }
1088
+
1089
+ private syncMarkerVisualObjectsToCenter(index: number, center: MarkerPoint) {
1090
+ if (!this.canvasService) return;
1091
+ const markers = this.canvasService.canvas
1092
+ .getObjects()
1093
+ .filter(
1094
+ (obj: any) =>
1095
+ obj?.data?.type === "feature-marker" &&
1096
+ this.toFeatureIndex(obj?.data?.index) === index,
1097
+ );
1098
+
1099
+ markers.forEach((marker: any) => {
1100
+ const offsetX = Number(marker?.data?.markerOffsetX || 0);
1101
+ const offsetY = Number(marker?.data?.markerOffsetY || 0);
1102
+ marker.set({
1103
+ left: center.x + offsetX,
1104
+ top: center.y + offsetY,
1105
+ });
1106
+ marker.setCoords();
1107
+ });
1108
+ }
1109
+
1110
+ private enforceConstraints() {
1111
+ if (!this.canvasService || !this.currentGeometry) return;
1112
+
1113
+ const handles = this.canvasService.canvas
1114
+ .getObjects()
1115
+ .filter(
1116
+ (obj: any) =>
1117
+ obj?.data?.type === "feature-marker" &&
1118
+ obj?.data?.markerRole === "handle",
1119
+ );
1120
+
1121
+ handles.forEach((marker: any) => {
1122
+ const feature = this.getFeatureForMarker(marker);
1123
+ if (!feature) return;
1124
+ const geometry = this.getGeometryForFeature(this.currentGeometry!, feature);
1125
+ const snapped = this.constrainPosition(
1126
+ {
1127
+ x: Number(marker.left || 0),
1128
+ y: Number(marker.top || 0),
1129
+ },
1130
+ geometry,
1131
+ feature,
1132
+ );
1133
+ marker.set({ left: snapped.x, top: snapped.y });
1134
+ marker.setCoords();
1135
+ this.syncMarkerVisualsByTarget(marker, snapped);
1136
+ });
1137
+
1138
+ this.canvasService.canvas.requestRenderAll();
1139
+ }
1140
+ }