@pooder/kit 4.1.0 → 4.2.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.
package/src/feature.ts CHANGED
@@ -3,7 +3,6 @@ import {
3
3
  ExtensionContext,
4
4
  ContributionPointIds,
5
5
  CommandContribution,
6
- ConfigurationContribution,
7
6
  ConfigurationService,
8
7
  } from "@pooder/core";
9
8
  import { Circle, Group, Point, Rect } from "fabric";
@@ -14,8 +13,11 @@ import {
14
13
  DielineFeature,
15
14
  resolveFeaturePosition,
16
15
  } from "./geometry";
17
- import { Coordinate } from "./coordinate";
18
16
  import { ConstraintRegistry } from "./constraints";
17
+ import {
18
+ completeFeaturesStrict,
19
+ } from "./featureComplete";
20
+ import { parseLengthToMm } from "./units";
19
21
 
20
22
  export class FeatureTool implements Extension {
21
23
  id = "pooder.kit.feature";
@@ -24,7 +26,7 @@ export class FeatureTool implements Extension {
24
26
  name: "FeatureTool",
25
27
  };
26
28
 
27
- private features: DielineFeature[] = [];
29
+ private workingFeatures: DielineFeature[] = [];
28
30
  private canvasService?: CanvasService;
29
31
  private context?: ExtensionContext;
30
32
  private isUpdatingConfig = false;
@@ -60,14 +62,18 @@ export class FeatureTool implements Extension {
60
62
  "ConfigurationService",
61
63
  );
62
64
  if (configService) {
63
- this.features = configService.get("dieline.features", []);
65
+ const features = (configService.get("dieline.features", []) ||
66
+ []) as DielineFeature[];
67
+ this.workingFeatures = this.cloneFeatures(features);
64
68
 
65
69
  configService.onAnyChange((e: { key: string; value: any }) => {
66
70
  if (this.isUpdatingConfig) return;
67
71
 
68
72
  if (e.key === "dieline.features") {
69
- this.features = e.value || [];
73
+ const next = (e.value || []) as DielineFeature[];
74
+ this.workingFeatures = this.cloneFeatures(next);
70
75
  this.redraw();
76
+ this.emitWorkingChange();
71
77
  }
72
78
  });
73
79
  }
@@ -138,28 +144,175 @@ export class FeatureTool implements Extension {
138
144
  command: "clearFeatures",
139
145
  title: "Clear Features",
140
146
  handler: () => {
141
- const configService =
142
- this.context?.services.get<ConfigurationService>(
143
- "ConfigurationService",
144
- );
145
- if (configService) {
146
- configService.update("dieline.features", []);
147
- }
147
+ this.setWorkingFeatures([]);
148
+ this.redraw();
149
+ this.emitWorkingChange();
148
150
  return true;
149
151
  },
150
152
  },
153
+ {
154
+ command: "getWorkingFeatures",
155
+ title: "Get Working Features",
156
+ handler: () => {
157
+ return this.cloneFeatures(this.workingFeatures);
158
+ },
159
+ },
160
+ {
161
+ command: "setWorkingFeatures",
162
+ title: "Set Working Features",
163
+ handler: async (features: DielineFeature[]) => {
164
+ await this.refreshGeometry();
165
+ this.setWorkingFeatures(this.cloneFeatures(features || []));
166
+ this.redraw();
167
+ this.emitWorkingChange();
168
+ return { ok: true };
169
+ },
170
+ },
171
+ {
172
+ command: "updateWorkingGroupPosition",
173
+ title: "Update Working Group Position",
174
+ handler: (groupId: string, x: number, y: number) => {
175
+ return this.updateWorkingGroupPosition(groupId, x, y);
176
+ },
177
+ },
178
+ {
179
+ command: "completeFeatures",
180
+ title: "Complete Features",
181
+ handler: () => {
182
+ return this.completeFeatures();
183
+ },
184
+ },
151
185
  ] as CommandContribution[],
152
186
  };
153
187
  }
154
188
 
155
- private addFeature(type: "add" | "subtract") {
156
- if (!this.canvasService) return false;
189
+ private cloneFeatures(features: DielineFeature[]): DielineFeature[] {
190
+ return JSON.parse(JSON.stringify(features || [])) as DielineFeature[];
191
+ }
157
192
 
158
- const configService = this.context?.services.get<ConfigurationService>(
159
- "ConfigurationService",
193
+ private emitWorkingChange() {
194
+ this.context?.eventBus.emit("feature:working:change", {
195
+ features: this.cloneFeatures(this.workingFeatures),
196
+ });
197
+ }
198
+
199
+ private async refreshGeometry() {
200
+ if (!this.context) return;
201
+ const commandService = this.context.services.get<any>("CommandService");
202
+ if (!commandService) return;
203
+ try {
204
+ const g = await Promise.resolve(commandService.executeCommand("getGeometry"));
205
+ if (g) this.currentGeometry = g as DielineGeometry;
206
+ } catch (e) {}
207
+ }
208
+
209
+ private setWorkingFeatures(next: DielineFeature[]) {
210
+ this.workingFeatures = next;
211
+ }
212
+
213
+ private updateWorkingGroupPosition(groupId: string, x: number, y: number) {
214
+ if (!groupId) return { ok: false };
215
+
216
+ const configService =
217
+ this.context?.services.get<ConfigurationService>("ConfigurationService");
218
+ if (!configService) return { ok: false };
219
+
220
+ const dielineWidth = parseLengthToMm(
221
+ configService.get("dieline.width") ?? 500,
222
+ "mm",
223
+ );
224
+ const dielineHeight = parseLengthToMm(
225
+ configService.get("dieline.height") ?? 500,
226
+ "mm",
160
227
  );
161
- const unit = configService?.get("dieline.unit", "mm") || "mm";
162
- const defaultSize = Coordinate.convertUnit(10, "mm", unit);
228
+
229
+ let changed = false;
230
+ const next = this.workingFeatures.map((f) => {
231
+ if (f.groupId !== groupId) return f;
232
+ let nx = x;
233
+ let ny = y;
234
+ if (f.constraints && dielineWidth > 0 && dielineHeight > 0) {
235
+ const constrained = ConstraintRegistry.apply(nx, ny, f, {
236
+ dielineWidth,
237
+ dielineHeight,
238
+ });
239
+ nx = constrained.x;
240
+ ny = constrained.y;
241
+ }
242
+
243
+ if (f.x !== nx || f.y !== ny) {
244
+ changed = true;
245
+ return { ...f, x: nx, y: ny };
246
+ }
247
+ return f;
248
+ });
249
+
250
+ if (!changed) return { ok: true };
251
+
252
+ this.setWorkingFeatures(next);
253
+ this.redraw();
254
+ this.enforceConstraints();
255
+ this.emitWorkingChange();
256
+
257
+ return { ok: true };
258
+ }
259
+
260
+ private completeFeatures(): {
261
+ ok: boolean;
262
+ issues?: Array<{
263
+ featureId: string;
264
+ groupId?: string;
265
+ reason: string;
266
+ }>;
267
+ } {
268
+ const configService =
269
+ this.context?.services.get<ConfigurationService>("ConfigurationService");
270
+ if (!configService) {
271
+ return {
272
+ ok: false,
273
+ issues: [
274
+ { featureId: "unknown", reason: "ConfigurationService not found" },
275
+ ],
276
+ };
277
+ }
278
+
279
+ const dielineWidth = parseLengthToMm(
280
+ configService.get("dieline.width") ?? 500,
281
+ "mm",
282
+ );
283
+ const dielineHeight = parseLengthToMm(
284
+ configService.get("dieline.height") ?? 500,
285
+ "mm",
286
+ );
287
+
288
+ const result = completeFeaturesStrict(
289
+ this.workingFeatures,
290
+ { dielineWidth, dielineHeight },
291
+ (next) => {
292
+ this.isUpdatingConfig = true;
293
+ try {
294
+ configService.update("dieline.features", next);
295
+ } finally {
296
+ this.isUpdatingConfig = false;
297
+ }
298
+
299
+ this.workingFeatures = this.cloneFeatures(next as any);
300
+ this.emitWorkingChange();
301
+ },
302
+ );
303
+
304
+ if (!result.ok) {
305
+ return {
306
+ ok: false,
307
+ issues: result.issues,
308
+ };
309
+ }
310
+
311
+ return { ok: true };
312
+ }
313
+
314
+ private addFeature(type: "add" | "subtract") {
315
+ if (!this.canvasService) return false;
163
316
 
164
317
  // Default to top edge center
165
318
  const newFeature: DielineFeature = {
@@ -169,31 +322,20 @@ export class FeatureTool implements Extension {
169
322
  shape: "rect",
170
323
  x: 0.5,
171
324
  y: 0, // Top edge
172
- width: defaultSize,
173
- height: defaultSize,
325
+ width: 10,
326
+ height: 10,
174
327
  rotation: 0,
175
328
  };
176
329
 
177
- if (configService) {
178
- const current = configService.get(
179
- "dieline.features",
180
- [],
181
- ) as DielineFeature[];
182
- configService.update("dieline.features", [...current, newFeature]);
183
- }
330
+ this.setWorkingFeatures([...(this.workingFeatures || []), newFeature]);
331
+ this.redraw();
332
+ this.emitWorkingChange();
184
333
  return true;
185
334
  }
186
335
 
187
336
  private addDoubleLayerHole() {
188
337
  if (!this.canvasService) return false;
189
338
 
190
- const configService = this.context?.services.get<ConfigurationService>(
191
- "ConfigurationService",
192
- );
193
- const unit = configService?.get("dieline.unit", "mm") || "mm";
194
- const lugRadius = Coordinate.convertUnit(20, "mm", unit);
195
- const holeRadius = Coordinate.convertUnit(15, "mm", unit);
196
-
197
339
  const groupId = Date.now().toString();
198
340
  const timestamp = Date.now();
199
341
 
@@ -206,7 +348,7 @@ export class FeatureTool implements Extension {
206
348
  placement: "edge",
207
349
  x: 0.5,
208
350
  y: 0,
209
- radius: lugRadius, // 20mm
351
+ radius: 20,
210
352
  rotation: 0,
211
353
  };
212
354
 
@@ -219,17 +361,13 @@ export class FeatureTool implements Extension {
219
361
  placement: "edge",
220
362
  x: 0.5,
221
363
  y: 0,
222
- radius: holeRadius, // 15mm
364
+ radius: 15,
223
365
  rotation: 0,
224
366
  };
225
367
 
226
- if (configService) {
227
- const current = configService.get(
228
- "dieline.features",
229
- [],
230
- ) as DielineFeature[];
231
- configService.update("dieline.features", [...current, lug, hole]);
232
- }
368
+ this.setWorkingFeatures([...(this.workingFeatures || []), lug, hole]);
369
+ this.redraw();
370
+ this.emitWorkingChange();
233
371
  return true;
234
372
  }
235
373
 
@@ -286,12 +424,12 @@ export class FeatureTool implements Extension {
286
424
  if (target.data?.isGroup) {
287
425
  const indices = target.data?.indices as number[];
288
426
  if (indices && indices.length > 0) {
289
- feature = this.features[indices[0]];
427
+ feature = this.workingFeatures[indices[0]];
290
428
  }
291
429
  } else {
292
430
  const index = target.data?.index;
293
431
  if (index !== undefined) {
294
- feature = this.features[index];
432
+ feature = this.workingFeatures[index];
295
433
  }
296
434
  }
297
435
 
@@ -327,7 +465,6 @@ export class FeatureTool implements Extension {
327
465
  const target = e.target;
328
466
  if (!target || target.data?.type !== "feature-marker") return;
329
467
 
330
- // Sync changes back to config
331
468
  if (target.data?.isGroup) {
332
469
  // It's a Group object
333
470
  const groupObj = target as Group;
@@ -345,7 +482,7 @@ export class FeatureTool implements Extension {
345
482
  // Simplified: just add relative coordinates if no rotation/scaling on group
346
483
  // We locked rotation/scaling, so it's safe.
347
484
 
348
- const newFeatures = [...this.features];
485
+ const newFeatures = [...this.workingFeatures];
349
486
  const { x, y } = this.currentGeometry!; // Center is same
350
487
 
351
488
  // Fabric Group objects have .getObjects() which returns children
@@ -354,7 +491,7 @@ export class FeatureTool implements Extension {
354
491
 
355
492
  groupObj.getObjects().forEach((child, i) => {
356
493
  const originalIndex = indices[i];
357
- const feature = this.features[originalIndex];
494
+ const feature = this.workingFeatures[originalIndex];
358
495
  const geometry = this.getGeometryForFeature(
359
496
  this.currentGeometry!,
360
497
  feature,
@@ -379,20 +516,8 @@ export class FeatureTool implements Extension {
379
516
  };
380
517
  });
381
518
 
382
- this.features = newFeatures;
383
-
384
- const configService =
385
- this.context?.services.get<ConfigurationService>(
386
- "ConfigurationService",
387
- );
388
- if (configService) {
389
- this.isUpdatingConfig = true;
390
- try {
391
- configService.update("dieline.features", this.features);
392
- } finally {
393
- this.isUpdatingConfig = false;
394
- }
395
- }
519
+ this.setWorkingFeatures(newFeatures);
520
+ this.emitWorkingChange();
396
521
  } else {
397
522
  // Single object
398
523
  this.syncFeatureFromCanvas(target);
@@ -437,11 +562,9 @@ export class FeatureTool implements Extension {
437
562
  feature?: DielineFeature
438
563
  ): { x: number; y: number } {
439
564
  if (feature && feature.constraints) {
440
- // Use Constraint Registry
441
- // Convert to normalized coordinates
442
565
  const minX = geometry.x - geometry.width / 2;
443
566
  const minY = geometry.y - geometry.height / 2;
444
-
567
+
445
568
  const nx = geometry.width > 0 ? (p.x - minX) / geometry.width : 0.5;
446
569
  const ny = geometry.height > 0 ? (p.y - minY) / geometry.height : 0.5;
447
570
 
@@ -461,38 +584,33 @@ export class FeatureTool implements Extension {
461
584
  }
462
585
 
463
586
  if (feature && feature.placement === "internal") {
464
- // Constrain to bounds
465
- // geometry.x/y is center
466
- const minX = geometry.x - geometry.width / 2;
467
- const maxX = geometry.x + geometry.width / 2;
468
- const minY = geometry.y - geometry.height / 2;
469
- const maxY = geometry.y + geometry.height / 2;
470
-
471
- return {
472
- x: Math.max(minX, Math.min(maxX, p.x)),
473
- y: Math.max(minY, Math.min(maxY, p.y))
474
- };
587
+ const minX = geometry.x - geometry.width / 2;
588
+ const maxX = geometry.x + geometry.width / 2;
589
+ const minY = geometry.y - geometry.height / 2;
590
+ const maxY = geometry.y + geometry.height / 2;
591
+
592
+ return {
593
+ x: Math.max(minX, Math.min(maxX, p.x)),
594
+ y: Math.max(minY, Math.min(maxY, p.y)),
595
+ };
475
596
  }
476
597
 
477
- // Use geometry helper to find nearest point on Base Shape
478
- // geometry object matches GeometryOptions structure required by getNearestPointOnDieline
479
- // except for 'features' which we don't need for base shape snapping
480
- const nearest = getNearestPointOnDieline({ x: p.x, y: p.y }, {
481
- ...geometry,
482
- features: [],
483
- } as any);
598
+ const nearest = getNearestPointOnDieline(
599
+ { x: p.x, y: p.y },
600
+ {
601
+ ...geometry,
602
+ features: [],
603
+ } as any,
604
+ );
484
605
 
485
- // Calculate vector from nearest point to current point
486
606
  const dx = p.x - nearest.x;
487
607
  const dy = p.y - nearest.y;
488
608
  const dist = Math.sqrt(dx * dx + dy * dy);
489
609
 
490
- // If within limit, allow current position (offset from edge)
491
610
  if (dist <= limit) {
492
611
  return { x: p.x, y: p.y };
493
612
  }
494
613
 
495
- // Otherwise, clamp to limit
496
614
  const scale = limit / dist;
497
615
  return {
498
616
  x: nearest.x + dx * scale,
@@ -504,10 +622,14 @@ export class FeatureTool implements Extension {
504
622
  if (!this.currentGeometry || !this.context) return;
505
623
 
506
624
  const index = target.data?.index;
507
- if (index === undefined || index < 0 || index >= this.features.length)
625
+ if (
626
+ index === undefined ||
627
+ index < 0 ||
628
+ index >= this.workingFeatures.length
629
+ )
508
630
  return;
509
631
 
510
- const feature = this.features[index];
632
+ const feature = this.workingFeatures[index];
511
633
  const geometry = this.getGeometryForFeature(this.currentGeometry, feature);
512
634
  const { width, height, x, y } = geometry;
513
635
 
@@ -527,22 +649,10 @@ export class FeatureTool implements Extension {
527
649
  // Could also update rotation if we allowed rotating markers
528
650
  };
529
651
 
530
- const newFeatures = [...this.features];
652
+ const newFeatures = [...this.workingFeatures];
531
653
  newFeatures[index] = updatedFeature;
532
- this.features = newFeatures;
533
-
534
- // Save to config
535
- const configService = this.context.services.get<ConfigurationService>(
536
- "ConfigurationService",
537
- );
538
- if (configService) {
539
- this.isUpdatingConfig = true;
540
- try {
541
- configService.update("dieline.features", this.features);
542
- } finally {
543
- this.isUpdatingConfig = false;
544
- }
545
- }
654
+ this.setWorkingFeatures(newFeatures);
655
+ this.emitWorkingChange();
546
656
  }
547
657
 
548
658
  private redraw() {
@@ -556,7 +666,7 @@ export class FeatureTool implements Extension {
556
666
  .filter((obj: any) => obj.data?.type === "feature-marker");
557
667
  existing.forEach((obj) => canvas.remove(obj));
558
668
 
559
- if (!this.features || this.features.length === 0) {
669
+ if (!this.workingFeatures || this.workingFeatures.length === 0) {
560
670
  this.canvasService.requestRenderAll();
561
671
  return;
562
672
  }
@@ -569,7 +679,7 @@ export class FeatureTool implements Extension {
569
679
  {};
570
680
  const singles: { feature: DielineFeature; index: number }[] = [];
571
681
 
572
- this.features.forEach((f, i) => {
682
+ this.workingFeatures.forEach((f: DielineFeature, i: number) => {
573
683
  if (f.groupId) {
574
684
  if (!groups[f.groupId]) groups[f.groupId] = [];
575
685
  groups[f.groupId].push({ feature: f, index: i });
@@ -583,7 +693,6 @@ export class FeatureTool implements Extension {
583
693
  feature: DielineFeature,
584
694
  pos: { x: number; y: number },
585
695
  ) => {
586
- // Features are in the same unit as geometry.unit
587
696
  const featureScale = scale;
588
697
 
589
698
  const visualWidth = (feature.width || 10) * featureScale;
@@ -653,27 +762,6 @@ export class FeatureTool implements Extension {
653
762
  data: { type: "feature-marker", index, isGroup: false },
654
763
  });
655
764
 
656
- // Auto-hide logic
657
- marker.set("opacity", 0);
658
- marker.on("mouseover", () => {
659
- marker.set("opacity", 1);
660
- canvas.requestRenderAll();
661
- });
662
- marker.on("mouseout", () => {
663
- if (canvas.getActiveObject() !== marker) {
664
- marker.set("opacity", 0);
665
- canvas.requestRenderAll();
666
- }
667
- });
668
- marker.on("selected", () => {
669
- marker.set("opacity", 1);
670
- canvas.requestRenderAll();
671
- });
672
- marker.on("deselected", () => {
673
- marker.set("opacity", 0);
674
- canvas.requestRenderAll();
675
- });
676
-
677
765
  canvas.add(marker);
678
766
  canvas.bringObjectToFront(marker);
679
767
  });
@@ -718,27 +806,6 @@ export class FeatureTool implements Extension {
718
806
  },
719
807
  });
720
808
 
721
- // Auto-hide logic for group
722
- groupObj.set("opacity", 0);
723
- groupObj.on("mouseover", () => {
724
- groupObj.set("opacity", 1);
725
- canvas.requestRenderAll();
726
- });
727
- groupObj.on("mouseout", () => {
728
- if (canvas.getActiveObject() !== groupObj) {
729
- groupObj.set("opacity", 0);
730
- canvas.requestRenderAll();
731
- }
732
- });
733
- groupObj.on("selected", () => {
734
- groupObj.set("opacity", 1);
735
- canvas.requestRenderAll();
736
- });
737
- groupObj.on("deselected", () => {
738
- groupObj.set("opacity", 0);
739
- canvas.requestRenderAll();
740
- });
741
-
742
809
  canvas.add(groupObj);
743
810
  canvas.bringObjectToFront(groupObj);
744
811
  });
@@ -760,12 +827,12 @@ export class FeatureTool implements Extension {
760
827
  if (marker.data?.isGroup) {
761
828
  const indices = marker.data?.indices as number[];
762
829
  if (indices && indices.length > 0) {
763
- feature = this.features[indices[0]];
830
+ feature = this.workingFeatures[indices[0]];
764
831
  }
765
832
  } else {
766
833
  const index = marker.data?.index;
767
834
  if (index !== undefined) {
768
- feature = this.features[index];
835
+ feature = this.workingFeatures[index];
769
836
  }
770
837
  }
771
838
 
@@ -0,0 +1,45 @@
1
+ import { ConstraintContext, ConstraintFeature, ConstraintRegistry } from "./constraints";
2
+
3
+ export type FeatureCompleteIssue = {
4
+ featureId: string;
5
+ groupId?: string;
6
+ reason: string;
7
+ };
8
+
9
+ export function validateFeaturesStrict(
10
+ features: ConstraintFeature[],
11
+ context: ConstraintContext,
12
+ ): { ok: boolean; issues?: FeatureCompleteIssue[] } {
13
+ const eps = 1e-6;
14
+ const issues: FeatureCompleteIssue[] = [];
15
+
16
+ for (const f of features) {
17
+ if (!f.constraints?.type) continue;
18
+ const constrained = ConstraintRegistry.apply(f.x, f.y, f, context);
19
+ if (
20
+ Math.abs(constrained.x - f.x) > eps ||
21
+ Math.abs(constrained.y - f.y) > eps
22
+ ) {
23
+ issues.push({
24
+ featureId: f.id,
25
+ groupId: f.groupId,
26
+ reason: "Position violates constraint strategy",
27
+ });
28
+ }
29
+ }
30
+
31
+ return { ok: issues.length === 0, issues: issues.length ? issues : undefined };
32
+ }
33
+
34
+ export function completeFeaturesStrict(
35
+ features: ConstraintFeature[],
36
+ context: ConstraintContext,
37
+ update: (nextFeatures: ConstraintFeature[]) => void,
38
+ ): { ok: boolean; issues?: FeatureCompleteIssue[] } {
39
+ const validation = validateFeaturesStrict(features, context);
40
+ if (!validation.ok) return validation;
41
+ const next = JSON.parse(JSON.stringify(features || [])) as ConstraintFeature[];
42
+ update(next);
43
+ return { ok: true };
44
+ }
45
+
package/src/index.ts CHANGED
@@ -6,4 +6,5 @@ export * from "./image";
6
6
  export * from "./white-ink";
7
7
  export * from "./ruler";
8
8
  export * from "./mirror";
9
+ export * from "./units";
9
10
  export { default as CanvasService } from "./CanvasService";