@pooder/kit 1.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hole.ts CHANGED
@@ -1,413 +1,629 @@
1
- import { Command, Editor, EditorState, Extension, OptionSchema, Circle, Group, Point } from '@pooder/core';
2
- import { DielineTool, DielineGeometry } from './dieline';
3
- import { getNearestPointOnDieline } from './geometry';
4
- import paper from 'paper';
5
-
6
- export interface HoleToolOptions {
7
- innerRadius: number;
8
- outerRadius: number;
9
- style: 'solid' | 'dashed';
10
- holes?: Array<{ x: number, y: number }>;
11
- }
12
-
13
- export class HoleTool implements Extension<HoleToolOptions> {
14
- public name = 'HoleTool';
15
- public options: HoleToolOptions = {
16
- innerRadius: 15,
17
- outerRadius: 25,
18
- style: 'solid',
19
- holes: []
20
- };
21
-
22
- public schema: Record<keyof HoleToolOptions, OptionSchema> = {
23
- innerRadius: {
24
- type: 'number',
25
- min: 1,
26
- max: 100,
27
- label: 'Inner Radius'
28
- },
29
- outerRadius: {
30
- type: 'number',
31
- min: 1,
32
- max: 100,
33
- label: 'Outer Radius'
34
- },
35
- style: {
36
- type: 'select',
37
- options: ['solid', 'dashed'],
38
- label: 'Line Style'
39
- },
40
- holes: {
41
- type: 'json',
42
- label: 'Holes'
43
- } as any
44
- };
45
-
46
- private handleMoving: ((e: any) => void) | null = null;
47
- private handleModified: ((e: any) => void) | null = null;
48
-
49
- onMount(editor: Editor) {
50
- this.setup(editor);
51
- }
52
-
53
- onUnmount(editor: Editor) {
54
- this.teardown(editor);
55
- }
56
-
57
- onDestroy(editor: Editor) {
58
- this.teardown(editor);
59
- }
60
-
61
- private getDielineGeometry(editor: Editor): DielineGeometry | null {
62
- const dielineTool = editor.getExtension('DielineTool') as DielineTool;
63
- if (!dielineTool) return null;
64
-
65
- const geometry = dielineTool.getGeometry(editor);
66
- if (!geometry) return null;
67
-
68
- const offset = dielineTool.options.offset || 0;
69
-
70
- // Apply offset to geometry
71
- return {
72
- ...geometry,
73
- width: Math.max(0, geometry.width + offset * 2),
74
- height: Math.max(0, geometry.height + offset * 2),
75
- radius: Math.max(0, geometry.radius + offset)
76
- };
77
- }
78
-
79
- private setup(editor: Editor) {
80
- if (!this.handleMoving) {
81
- this.handleMoving = (e: any) => {
82
- const target = e.target;
83
- if (!target || target.data?.type !== 'hole-marker') return;
84
-
85
- const geometry = this.getDielineGeometry(editor);
86
- if (!geometry) return;
87
-
88
- const p = new Point(target.left, target.top);
89
- const newPos = this.calculateConstrainedPosition(p, geometry);
90
-
91
- target.set({
92
- left: newPos.x,
93
- top: newPos.y
94
- });
95
- };
96
- editor.canvas.on('object:moving', this.handleMoving);
97
- }
98
-
99
- if (!this.handleModified) {
100
- this.handleModified = (e: any) => {
101
- const target = e.target;
102
- if (!target || target.data?.type !== 'hole-marker') return;
103
-
104
- // Update state when hole is moved
105
- this.syncHolesFromCanvas(editor);
106
- };
107
- editor.canvas.on('object:modified', this.handleModified);
108
- }
109
-
110
- const opts = this.options;
111
- // Default hole if none exist
112
- if (!opts.holes || opts.holes.length === 0) {
113
- let defaultPos = { x: editor.canvas.width! / 2, y: 50 };
114
-
115
- const g = this.getDielineGeometry(editor);
116
- if (g) {
117
- // Default to Top-Center of Dieline shape
118
- const topCenter = { x: g.x, y: g.y - g.height / 2 };
119
- // Snap to exact shape edge
120
- const snapped = getNearestPointOnDieline(topCenter, { ...g, holes: [] } as any);
121
- defaultPos = snapped;
122
- }
123
-
124
- opts.holes = [defaultPos];
125
- }
126
-
127
- this.options = { ...opts };
128
- this.redraw(editor);
129
-
130
- // Ensure Dieline updates to reflect current holes (fusion effect)
131
- const dielineTool = editor.getExtension('DielineTool') as DielineTool;
132
- if (dielineTool && dielineTool.updateDieline) {
133
- dielineTool.updateDieline(editor);
134
- }
135
- }
136
-
137
- private teardown(editor: Editor) {
138
- if (this.handleMoving) {
139
- editor.canvas.off('object:moving', this.handleMoving);
140
- this.handleMoving = null;
141
- }
142
- if (this.handleModified) {
143
- editor.canvas.off('object:modified', this.handleModified);
144
- this.handleModified = null;
145
- }
146
-
147
- const objects = editor.canvas.getObjects().filter((obj: any) => obj.data?.type === 'hole-marker');
148
- objects.forEach(obj => editor.canvas.remove(obj));
149
-
150
- editor.canvas.requestRenderAll();
151
- }
152
-
153
- onUpdate(editor: Editor, state: EditorState) {
154
- }
155
-
156
- commands: Record<string, Command> = {
157
- reset: {
158
- execute: (editor: Editor) => {
159
- let defaultPos = { x: editor.canvas.width! / 2, y: 50 };
160
-
161
- const g = this.getDielineGeometry(editor);
162
- if (g) {
163
- const topCenter = { x: g.x, y: g.y - g.height / 2 };
164
- defaultPos = getNearestPointOnDieline(topCenter, { ...g, holes: [] } as any);
165
- }
166
-
167
- this.options = {
168
- innerRadius: 15,
169
- outerRadius: 25,
170
- style: 'solid',
171
- holes: [defaultPos]
172
- };
173
- this.redraw(editor);
174
-
175
- // Trigger Dieline update
176
- const dielineTool = editor.getExtension('DielineTool') as DielineTool;
177
- if (dielineTool && dielineTool.updateDieline) {
178
- dielineTool.updateDieline(editor);
179
- }
180
-
181
- return true;
182
- }
183
- },
184
- addHole: {
185
- execute: (editor: Editor, x: number, y: number) => {
186
- if (!this.options.holes) this.options.holes = [];
187
- this.options.holes.push({ x, y });
188
- this.redraw(editor);
189
-
190
- // Trigger Dieline update
191
- const dielineTool = editor.getExtension('DielineTool') as any;
192
- if (dielineTool && dielineTool.updateDieline) {
193
- dielineTool.updateDieline(editor);
194
- }
195
-
196
- return true;
197
- },
198
- schema: {
199
- x: {
200
- type: 'number',
201
- label: 'X Position',
202
- required: true
203
- },
204
- y: {
205
- type: 'number',
206
- label: 'Y Position',
207
- required: true
208
- }
209
- }
210
- },
211
- clearHoles: {
212
- execute: (editor: Editor) => {
213
- this.options.holes = [];
214
- this.redraw(editor);
215
-
216
- // Trigger Dieline update
217
- const dielineTool = editor.getExtension('DielineTool') as any;
218
- if (dielineTool && dielineTool.updateDieline) {
219
- dielineTool.updateDieline(editor);
220
- }
221
-
222
- return true;
223
- }
224
- }
225
- };
226
-
227
- private syncHolesFromCanvas(editor: Editor) {
228
- const objects = editor.canvas.getObjects().filter((obj: any) => obj.data?.type === 'hole-marker');
229
- const holes = objects.map(obj => ({ x: obj.left!, y: obj.top! }));
230
- this.options.holes = holes;
231
-
232
- // Trigger Dieline update for real-time fusion effect
233
- const dielineTool = editor.getExtension('DielineTool') as any;
234
- if (dielineTool && dielineTool.updateDieline) {
235
- dielineTool.updateDieline(editor);
236
- }
237
- }
238
-
239
- private redraw(editor: Editor) {
240
- const canvas = editor.canvas;
241
-
242
- // Remove existing holes
243
- const existing = canvas.getObjects().filter((obj: any) => obj.data?.type === 'hole-marker');
244
- existing.forEach(obj => canvas.remove(obj));
245
-
246
- const { innerRadius, outerRadius, style, holes } = this.options;
247
-
248
- if (!holes || holes.length === 0) {
249
- canvas.requestRenderAll();
250
- return;
251
- }
252
-
253
- holes.forEach((hole, index) => {
254
- const innerCircle = new Circle({
255
- radius: innerRadius,
256
- fill: 'transparent',
257
- stroke: 'red',
258
- strokeWidth: 2,
259
- originX: 'center',
260
- originY: 'center'
261
- });
262
-
263
- const outerCircle = new Circle({
264
- radius: outerRadius,
265
- fill: 'transparent',
266
- stroke: '#666',
267
- strokeWidth: 1,
268
- strokeDashArray: style === 'dashed' ? [5, 5] : undefined,
269
- originX: 'center',
270
- originY: 'center'
271
- });
272
-
273
- const holeGroup = new Group([outerCircle, innerCircle], {
274
- left: hole.x,
275
- top: hole.y,
276
- originX: 'center',
277
- originY: 'center',
278
- selectable: true,
279
- hasControls: false, // Don't allow resizing/rotating
280
- hasBorders: false,
281
- subTargetCheck: false,
282
- opacity: 0, // Default hidden
283
- hoverCursor: 'move',
284
- data: { type: 'hole-marker', index }
285
- }as any);
286
- (holeGroup as any).name = 'hole-marker';
287
-
288
- // Auto-show/hide logic
289
- holeGroup.on('mouseover', () => {
290
- holeGroup.set('opacity', 1);
291
- canvas.requestRenderAll();
292
- });
293
- holeGroup.on('mouseout', () => {
294
- if (canvas.getActiveObject() !== holeGroup) {
295
- holeGroup.set('opacity', 0);
296
- canvas.requestRenderAll();
297
- }
298
- });
299
- holeGroup.on('selected', () => {
300
- holeGroup.set('opacity', 1);
301
- canvas.requestRenderAll();
302
- });
303
- holeGroup.on('deselected', () => {
304
- holeGroup.set('opacity', 0);
305
- canvas.requestRenderAll();
306
- });
307
-
308
- canvas.add(holeGroup);
309
- canvas.bringObjectToFront(holeGroup);
310
- });
311
-
312
- canvas.requestRenderAll();
313
- }
314
-
315
- private calculateConstrainedPosition(p: Point, g: DielineGeometry): Point {
316
- // Use Paper.js to get accurate nearest point
317
- // This handles ellipses, rects, and rounded rects correctly
318
-
319
- // Convert to holes format for geometry options
320
- const options = {
321
- ...g,
322
- holes: [] // We don't need holes for boundary calculation
323
- };
324
-
325
- const nearest = getNearestPointOnDieline({x: p.x, y: p.y}, options as any);
326
-
327
- // Now constrain distance
328
- const nearestP = new Point(nearest.x, nearest.y);
329
- const dist = p.distanceFrom(nearestP);
330
-
331
- // Determine if point is inside or outside
332
- // Simple heuristic: distance from center
333
- // Or using paper.js contains() if we had the full path object
334
- // For convex shapes, center distance works mostly, but let's use the vector direction
335
-
336
- // Vector from nearest to current point
337
- const v = p.subtract(nearestP);
338
-
339
- // Vector from center to nearest point (approximate normal for convex shapes)
340
- const center = new Point(g.x, g.y);
341
- const centerToNearest = nearestP.subtract(center);
342
-
343
- // Dot product to see if they align (outside) or oppose (inside)
344
- // If point is exactly on line, dist is 0.
345
-
346
- // However, we want to constrain the point to be within [innerRadius, -outerRadius] distance from the edge.
347
- // Actually, usually users want to snap to the edge or stay within a reasonable margin.
348
- // The previous logic clamped the distance.
349
-
350
- // Let's implement a simple snap-to-edge if close, otherwise allow free movement but clamp max distance?
351
- // Or reproduce the previous "slide along edge" behavior.
352
- // Previous behavior: "clampedDist = Math.min(dist, innerRadius); ... Math.max(dist, -outerRadius)"
353
- // This implies the hole center can be slightly inside or outside the main shape edge.
354
-
355
- // Let's determine sign of distance
356
- // We can use paper.js Shape.contains(point) to check if inside.
357
- // But getNearestPointOnDieline returns just coordinates.
358
-
359
- // Optimization: Let's assume for Dieline shapes (convex-ish),
360
- // if distance from center > distance of nearest from center, it's outside.
361
- const distToCenter = p.distanceFrom(center);
362
- const nearestDistToCenter = nearestP.distanceFrom(center);
363
-
364
- let signedDist = dist;
365
- if (distToCenter < nearestDistToCenter) {
366
- signedDist = -dist; // Inside
367
- }
368
-
369
- // Clamp distance
370
- let clampedDist = signedDist;
371
- if (signedDist > 0) {
372
- clampedDist = Math.min(signedDist, this.options.innerRadius);
373
- } else {
374
- clampedDist = Math.max(signedDist, -this.options.outerRadius);
375
- }
376
-
377
- // Reconstruct point
378
- // If dist is very small, just use nearestP
379
- if (dist < 0.001) return nearestP;
380
-
381
- // Direction vector normalized
382
- const dir = v.scalarDivide(dist);
383
-
384
- // New point = nearest + dir * clampedDist
385
- // Note: if inside (signedDist < 0), v points towards center (roughly), dist is positive magnitude.
386
- // Wait, v = p - nearest.
387
- // If p is inside, p is closer to center. v points Inwards.
388
- // If we want clampedDist to be negative, we should probably stick to normal vectors.
389
-
390
- // Let's simplify:
391
- // Just place it at nearest point + offset vector.
392
- // Offset vector is 'v' scaled to clampedDist.
393
-
394
- // If p is inside, v points in. length is 'dist'.
395
- // We want length to be 'clampedDist' (magnitude).
396
- // Since clampedDist is negative for inside, we need to be careful with signs.
397
-
398
- // Actually simpler:
399
- // We want the result to lie on the line connecting Center -> P -> Nearest? No.
400
- // We want it on the line Nearest -> P.
401
-
402
- // Current distance is 'dist'.
403
- // Desired distance is abs(clampedDist).
404
- // If clampedDist sign matches signedDist sign, we just scale v.
405
-
406
- const scale = Math.abs(clampedDist) / (dist || 1);
407
-
408
- // If we are clamping, we just scale the vector from nearest.
409
- const offset = v.scalarMultiply(scale);
410
-
411
- return nearestP.add(offset);
412
- }
413
- }
1
+ import {
2
+ Extension,
3
+ ExtensionContext,
4
+ ContributionPointIds,
5
+ CommandContribution,
6
+ ConfigurationContribution,
7
+ ConfigurationService,
8
+ } from "@pooder/core";
9
+ import { Circle, Group, Point } from "fabric";
10
+ import CanvasService from "./CanvasService";
11
+ import { DielineGeometry } from "./dieline";
12
+ import { getNearestPointOnDieline, HoleData } from "./geometry";
13
+ import { Coordinate } from "./coordinate";
14
+
15
+ export class HoleTool implements Extension {
16
+ id = "pooder.kit.hole";
17
+
18
+ public metadata = {
19
+ name: "HoleTool",
20
+ };
21
+
22
+ private innerRadius: number = 15;
23
+ private outerRadius: number = 25;
24
+ private style: "solid" | "dashed" = "solid";
25
+ private holes: Array<{ x: number; y: number }> = [];
26
+ private constraintTarget: "original" | "bleed" = "bleed";
27
+
28
+ private canvasService?: CanvasService;
29
+ private context?: ExtensionContext;
30
+ private isUpdatingConfig = false;
31
+
32
+ private handleMoving: ((e: any) => void) | null = null;
33
+ private handleModified: ((e: any) => void) | null = null;
34
+ private handleDielineChange: ((geometry: DielineGeometry) => void) | null =
35
+ null;
36
+
37
+ // Cache geometry to enforce constraints during drag
38
+ private currentGeometry: DielineGeometry | null = null;
39
+
40
+ constructor(
41
+ options?: Partial<{
42
+ innerRadius: number;
43
+ outerRadius: number;
44
+ style: "solid" | "dashed";
45
+ holes: Array<{ x: number; y: number }>;
46
+ constraintTarget: "original" | "bleed";
47
+ }>,
48
+ ) {
49
+ if (options) {
50
+ Object.assign(this, options);
51
+ }
52
+ }
53
+
54
+ activate(context: ExtensionContext) {
55
+ this.context = context;
56
+ this.canvasService = context.services.get<CanvasService>("CanvasService");
57
+
58
+ if (!this.canvasService) {
59
+ console.warn("CanvasService not found for HoleTool");
60
+ return;
61
+ }
62
+
63
+ const configService = context.services.get<ConfigurationService>(
64
+ "ConfigurationService",
65
+ );
66
+ if (configService) {
67
+ // Load initial config
68
+ this.innerRadius = configService.get(
69
+ "hole.innerRadius",
70
+ this.innerRadius,
71
+ );
72
+ this.outerRadius = configService.get(
73
+ "hole.outerRadius",
74
+ this.outerRadius,
75
+ );
76
+ this.style = configService.get("hole.style", this.style);
77
+ this.constraintTarget = configService.get(
78
+ "hole.constraintTarget",
79
+ this.constraintTarget,
80
+ );
81
+
82
+ // Load holes from dieline.holes (SSOT)
83
+ const dielineHoles = configService.get("dieline.holes", []);
84
+ if (this.canvasService) {
85
+ const { width, height } = this.canvasService.canvas;
86
+ this.holes = dielineHoles.map((h: any) => {
87
+ const p = Coordinate.denormalizePoint(h, {
88
+ width: width || 800,
89
+ height: height || 600,
90
+ });
91
+ return { x: p.x, y: p.y };
92
+ });
93
+ }
94
+
95
+ // Listen for changes
96
+ configService.onAnyChange((e: { key: string; value: any }) => {
97
+ if (this.isUpdatingConfig) return;
98
+
99
+ if (e.key.startsWith("hole.")) {
100
+ const prop = e.key.split(".")[1];
101
+ if (prop && prop in this) {
102
+ (this as any)[prop] = e.value;
103
+ this.redraw();
104
+ // Allow syncHolesToDieline to run to update dieline.holes
105
+ this.syncHolesToDieline();
106
+ }
107
+ }
108
+ // Listen for dieline.holes changes (e.g. from undo/redo or other sources)
109
+ if (e.key === "dieline.holes") {
110
+ const holes = e.value || [];
111
+ if (this.canvasService) {
112
+ const { width, height } = this.canvasService.canvas;
113
+ this.holes = holes.map((h: any) => {
114
+ const p = Coordinate.denormalizePoint(h, {
115
+ width: width || 800,
116
+ height: height || 600,
117
+ });
118
+ return { x: p.x, y: p.y };
119
+ });
120
+ this.redraw();
121
+ }
122
+ }
123
+ });
124
+ }
125
+
126
+ this.setup();
127
+ }
128
+
129
+ deactivate(context: ExtensionContext) {
130
+ this.teardown();
131
+ this.canvasService = undefined;
132
+ this.context = undefined;
133
+ }
134
+
135
+ contribute() {
136
+ return {
137
+ [ContributionPointIds.CONFIGURATIONS]: [
138
+ {
139
+ id: "hole.innerRadius",
140
+ type: "number",
141
+ label: "Inner Radius",
142
+ min: 1,
143
+ max: 100,
144
+ default: 15,
145
+ },
146
+ {
147
+ id: "hole.outerRadius",
148
+ type: "number",
149
+ label: "Outer Radius",
150
+ min: 1,
151
+ max: 100,
152
+ default: 25,
153
+ },
154
+ {
155
+ id: "hole.style",
156
+ type: "select",
157
+ label: "Line Style",
158
+ options: ["solid", "dashed"],
159
+ default: "solid",
160
+ },
161
+ {
162
+ id: "hole.constraintTarget",
163
+ type: "select",
164
+ label: "Constraint Target",
165
+ options: ["original", "bleed"],
166
+ default: "bleed",
167
+ },
168
+ ] as ConfigurationContribution[],
169
+ [ContributionPointIds.COMMANDS]: [
170
+ {
171
+ command: "resetHoles",
172
+ title: "Reset Holes",
173
+ handler: () => {
174
+ if (!this.canvasService) return false;
175
+ let defaultPos = { x: this.canvasService.canvas.width! / 2, y: 50 };
176
+
177
+ if (this.currentGeometry) {
178
+ const g = this.currentGeometry;
179
+ const topCenter = { x: g.x, y: g.y - g.height / 2 };
180
+ defaultPos = getNearestPointOnDieline(topCenter, {
181
+ ...g,
182
+ holes: [],
183
+ } as any);
184
+ }
185
+
186
+ this.innerRadius = 15;
187
+ this.outerRadius = 25;
188
+ this.style = "solid";
189
+ this.holes = [defaultPos];
190
+
191
+ this.redraw();
192
+ this.syncHolesToDieline();
193
+ return true;
194
+ },
195
+ },
196
+ {
197
+ command: "addHole",
198
+ title: "Add Hole",
199
+ handler: (x: number, y: number) => {
200
+ if (!this.holes) this.holes = [];
201
+ this.holes.push({ x, y });
202
+ this.redraw();
203
+ this.syncHolesToDieline();
204
+ return true;
205
+ },
206
+ },
207
+ {
208
+ command: "clearHoles",
209
+ title: "Clear Holes",
210
+ handler: () => {
211
+ this.holes = [];
212
+ this.redraw();
213
+ this.syncHolesToDieline();
214
+ return true;
215
+ },
216
+ },
217
+ ] as CommandContribution[],
218
+ };
219
+ }
220
+
221
+ private setup() {
222
+ if (!this.canvasService || !this.context) return;
223
+ const canvas = this.canvasService.canvas;
224
+
225
+ // 1. Listen for Dieline Geometry Changes
226
+ if (!this.handleDielineChange) {
227
+ this.handleDielineChange = (geometry: DielineGeometry) => {
228
+ this.currentGeometry = geometry;
229
+ const changed = this.enforceConstraints();
230
+ // Only sync if constraints actually moved something
231
+ if (changed) {
232
+ this.syncHolesToDieline();
233
+ }
234
+ };
235
+ this.context.eventBus.on(
236
+ "dieline:geometry:change",
237
+ this.handleDielineChange,
238
+ );
239
+ }
240
+
241
+ // 2. Initial Fetch of Geometry
242
+ // Assuming DielineTool registered 'getGeometry' command which is now available via CommandService
243
+ // Since we don't have direct access to CommandService here (it was in activate),
244
+ // we can get it from context.services
245
+ const commandService = this.context.services.get<any>("CommandService");
246
+ if (commandService) {
247
+ try {
248
+ const geometry = commandService.executeCommand("getGeometry");
249
+ if (geometry) {
250
+ // If executeCommand returns a promise, await it?
251
+ // CommandService.executeCommand is async in previous definition.
252
+ // But here we are in sync setup.
253
+ // Let's assume we can handle the promise if needed, or if it returns value directly (if not async).
254
+ // Checking CommandService implementation: executeCommand IS async.
255
+ Promise.resolve(geometry).then((g) => {
256
+ if (g) {
257
+ this.currentGeometry = g as DielineGeometry;
258
+ // Re-run setup logic dependent on geometry
259
+ this.enforceConstraints();
260
+ this.initializeHoles();
261
+ }
262
+ });
263
+ }
264
+ } catch (e) {
265
+ // Command might not be ready
266
+ }
267
+ }
268
+
269
+ // 3. Setup Canvas Interaction
270
+ if (!this.handleMoving) {
271
+ this.handleMoving = (e: any) => {
272
+ const target = e.target;
273
+ if (!target || target.data?.type !== "hole-marker") return;
274
+
275
+ if (!this.currentGeometry) return;
276
+
277
+ // Calculate effective geometry based on constraint target
278
+ const effectiveOffset =
279
+ this.constraintTarget === "original"
280
+ ? 0
281
+ : this.currentGeometry.offset;
282
+ const constraintGeometry = {
283
+ ...this.currentGeometry,
284
+ width: Math.max(0, this.currentGeometry.width + effectiveOffset * 2),
285
+ height: Math.max(
286
+ 0,
287
+ this.currentGeometry.height + effectiveOffset * 2,
288
+ ),
289
+ radius: Math.max(0, this.currentGeometry.radius + effectiveOffset),
290
+ };
291
+
292
+ const p = new Point(target.left, target.top);
293
+ const newPos = this.calculateConstrainedPosition(p, constraintGeometry);
294
+
295
+ target.set({
296
+ left: newPos.x,
297
+ top: newPos.y,
298
+ });
299
+ };
300
+ canvas.on("object:moving", this.handleMoving);
301
+ }
302
+
303
+ if (!this.handleModified) {
304
+ this.handleModified = (e: any) => {
305
+ const target = e.target;
306
+ if (!target || target.data?.type !== "hole-marker") return;
307
+
308
+ // Update state when hole is moved
309
+ this.syncHolesFromCanvas();
310
+ };
311
+ canvas.on("object:modified", this.handleModified);
312
+ }
313
+
314
+ this.initializeHoles();
315
+ }
316
+
317
+ private initializeHoles() {
318
+ if (!this.canvasService) return;
319
+ // Default hole if none exist
320
+ if (!this.holes || this.holes.length === 0) {
321
+ let defaultPos = { x: this.canvasService.canvas.width! / 2, y: 50 };
322
+
323
+ if (this.currentGeometry) {
324
+ const g = this.currentGeometry;
325
+ // Default to Top-Center of Dieline shape
326
+ const topCenter = { x: g.x, y: g.y - g.height / 2 };
327
+ // Snap to exact shape edge
328
+ const snapped = getNearestPointOnDieline(topCenter, {
329
+ ...g,
330
+ holes: [],
331
+ } as any);
332
+ defaultPos = snapped;
333
+ }
334
+
335
+ this.holes = [defaultPos];
336
+ }
337
+
338
+ this.redraw();
339
+ this.syncHolesToDieline();
340
+ }
341
+
342
+ private teardown() {
343
+ if (!this.canvasService) return;
344
+ const canvas = this.canvasService.canvas;
345
+
346
+ if (this.handleMoving) {
347
+ canvas.off("object:moving", this.handleMoving);
348
+ this.handleMoving = null;
349
+ }
350
+ if (this.handleModified) {
351
+ canvas.off("object:modified", this.handleModified);
352
+ this.handleModified = null;
353
+ }
354
+ if (this.handleDielineChange && this.context) {
355
+ this.context.eventBus.off(
356
+ "dieline:geometry:change",
357
+ this.handleDielineChange,
358
+ );
359
+ this.handleDielineChange = null;
360
+ }
361
+
362
+ const objects = canvas
363
+ .getObjects()
364
+ .filter((obj: any) => obj.data?.type === "hole-marker");
365
+ objects.forEach((obj) => canvas.remove(obj));
366
+
367
+ // Clear holes from Dieline (visual only, state preserved in HoleTool options)
368
+ if (this.context) {
369
+ const commandService = this.context.services.get<any>("CommandService");
370
+ if (commandService) {
371
+ try {
372
+ commandService.executeCommand("setHoles", []);
373
+ } catch (e) {}
374
+ }
375
+ }
376
+
377
+ this.canvasService.requestRenderAll();
378
+ }
379
+
380
+ private syncHolesFromCanvas() {
381
+ if (!this.canvasService) return;
382
+ const objects = this.canvasService.canvas
383
+ .getObjects()
384
+ .filter((obj: any) => obj.data?.type === "hole-marker");
385
+
386
+ const holes = objects.map((obj) => ({ x: obj.left!, y: obj.top! }));
387
+ this.holes = holes;
388
+
389
+ this.syncHolesToDieline();
390
+ }
391
+
392
+ private syncHolesToDieline() {
393
+ if (!this.context || !this.canvasService) return;
394
+
395
+ const { holes, innerRadius, outerRadius } = this;
396
+ const currentHoles = holes || [];
397
+ const width = this.canvasService.canvas.width || 800;
398
+ const height = this.canvasService.canvas.height || 600;
399
+
400
+ const configService = this.context.services.get<ConfigurationService>(
401
+ "ConfigurationService",
402
+ );
403
+
404
+ if (configService) {
405
+ this.isUpdatingConfig = true;
406
+ try {
407
+ // Update dieline.holes (Normalized coordinates)
408
+ const normalizedHoles = currentHoles.map((h) => {
409
+ const p = Coordinate.normalizePoint(h, { width, height });
410
+ return {
411
+ x: p.x,
412
+ y: p.y,
413
+ innerRadius,
414
+ outerRadius,
415
+ };
416
+ });
417
+ configService.update("dieline.holes", normalizedHoles);
418
+ } finally {
419
+ this.isUpdatingConfig = false;
420
+ }
421
+ }
422
+ }
423
+
424
+ private redraw() {
425
+ if (!this.canvasService) return;
426
+ const canvas = this.canvasService.canvas;
427
+
428
+ // Remove existing holes
429
+ const existing = canvas
430
+ .getObjects()
431
+ .filter((obj: any) => obj.data?.type === "hole-marker");
432
+ existing.forEach((obj) => canvas.remove(obj));
433
+
434
+ const { innerRadius, outerRadius, style, holes } = this;
435
+
436
+ if (!holes || holes.length === 0) {
437
+ this.canvasService.requestRenderAll();
438
+ return;
439
+ }
440
+
441
+ holes.forEach((hole, index) => {
442
+ const innerCircle = new Circle({
443
+ radius: innerRadius,
444
+ fill: "transparent",
445
+ stroke: "red",
446
+ strokeWidth: 2,
447
+ originX: "center",
448
+ originY: "center",
449
+ });
450
+
451
+ const outerCircle = new Circle({
452
+ radius: outerRadius,
453
+ fill: "transparent",
454
+ stroke: "#666",
455
+ strokeWidth: 1,
456
+ strokeDashArray: style === "dashed" ? [5, 5] : undefined,
457
+ originX: "center",
458
+ originY: "center",
459
+ });
460
+
461
+ const holeGroup = new Group([outerCircle, innerCircle], {
462
+ left: hole.x,
463
+ top: hole.y,
464
+ originX: "center",
465
+ originY: "center",
466
+ selectable: true,
467
+ hasControls: false, // Don't allow resizing/rotating
468
+ hasBorders: false,
469
+ subTargetCheck: false,
470
+ opacity: 0, // Default hidden
471
+ hoverCursor: "move",
472
+ data: { type: "hole-marker", index },
473
+ } as any);
474
+ (holeGroup as any).name = "hole-marker";
475
+
476
+ // Auto-show/hide logic
477
+ holeGroup.on("mouseover", () => {
478
+ holeGroup.set("opacity", 1);
479
+ canvas.requestRenderAll();
480
+ });
481
+ holeGroup.on("mouseout", () => {
482
+ if (canvas.getActiveObject() !== holeGroup) {
483
+ holeGroup.set("opacity", 0);
484
+ canvas.requestRenderAll();
485
+ }
486
+ });
487
+ holeGroup.on("selected", () => {
488
+ holeGroup.set("opacity", 1);
489
+ canvas.requestRenderAll();
490
+ });
491
+ holeGroup.on("deselected", () => {
492
+ holeGroup.set("opacity", 0);
493
+ canvas.requestRenderAll();
494
+ });
495
+
496
+ canvas.add(holeGroup);
497
+
498
+ // Ensure hole markers are always on top of Dieline layer
499
+ // Dieline layer uses bringObjectToFront, so we must be aggressive
500
+ // But we can't control when Dieline updates.
501
+ // Ideally, HoleTool should use a dedicated overlay layer above Dieline.
502
+ // For now, let's just bring to front.
503
+ canvas.bringObjectToFront(holeGroup);
504
+ });
505
+
506
+ // Also bring all existing markers to front to be safe
507
+ const markers = canvas.getObjects().filter((o: any) => o.data?.type === "hole-marker");
508
+ markers.forEach(m => canvas.bringObjectToFront(m));
509
+
510
+ this.canvasService.requestRenderAll();
511
+ }
512
+
513
+ public enforceConstraints(): boolean {
514
+ const geometry = this.currentGeometry;
515
+ if (!geometry || !this.canvasService) {
516
+ console.log(
517
+ "[HoleTool] Skipping enforceConstraints: No geometry or canvas service",
518
+ );
519
+ return false;
520
+ }
521
+
522
+ const effectiveOffset =
523
+ this.constraintTarget === "original" ? 0 : geometry.offset;
524
+ const constraintGeometry = {
525
+ ...geometry,
526
+ width: Math.max(0, geometry.width + effectiveOffset * 2),
527
+ height: Math.max(0, geometry.height + effectiveOffset * 2),
528
+ radius: Math.max(0, geometry.radius + effectiveOffset),
529
+ };
530
+
531
+ // Get all hole markers
532
+ const objects = this.canvasService.canvas
533
+ .getObjects()
534
+ .filter((obj: any) => obj.data?.type === "hole-marker");
535
+
536
+ console.log(
537
+ `[HoleTool] Enforcing constraints on ${objects.length} markers`,
538
+ );
539
+
540
+ let changed = false;
541
+ // Sort objects by index to maintain order in options.holes
542
+ objects.sort(
543
+ (a: any, b: any) => (a.data?.index ?? 0) - (b.data?.index ?? 0),
544
+ );
545
+
546
+ const newHoles: { x: number; y: number }[] = [];
547
+
548
+ objects.forEach((obj: any) => {
549
+ const currentPos = new Point(obj.left, obj.top);
550
+ const newPos = this.calculateConstrainedPosition(
551
+ currentPos,
552
+ constraintGeometry,
553
+ );
554
+
555
+ if (currentPos.distanceFrom(newPos) > 0.1) {
556
+ console.log(
557
+ `[HoleTool] Moving hole from (${currentPos.x}, ${currentPos.y}) to (${newPos.x}, ${newPos.y})`,
558
+ );
559
+ obj.set({
560
+ left: newPos.x,
561
+ top: newPos.y,
562
+ });
563
+ obj.setCoords();
564
+ changed = true;
565
+ }
566
+ newHoles.push({ x: obj.left, y: obj.top });
567
+ });
568
+
569
+ if (changed) {
570
+ this.holes = newHoles;
571
+ this.canvasService.requestRenderAll();
572
+ // We return true instead of syncing directly to avoid recursion
573
+ return true;
574
+ }
575
+ return false;
576
+ }
577
+
578
+ private calculateConstrainedPosition(p: Point, g: DielineGeometry): Point {
579
+ // Use Paper.js to get accurate nearest point
580
+ // This handles ellipses, rects, and rounded rects correctly
581
+
582
+ // Convert to holes format for geometry options
583
+ const options = {
584
+ ...g,
585
+ holes: [], // We don't need holes for boundary calculation
586
+ };
587
+
588
+ const nearest = getNearestPointOnDieline(
589
+ { x: p.x, y: p.y },
590
+ options as any,
591
+ );
592
+
593
+ // Now constrain distance
594
+ const nearestP = new Point(nearest.x, nearest.y);
595
+ const dist = p.distanceFrom(nearestP);
596
+
597
+ // Vector from nearest to current point
598
+ const v = p.subtract(nearestP);
599
+
600
+ // Vector from center to nearest point (approximate normal for convex shapes)
601
+ const center = new Point(g.x, g.y);
602
+ const centerToNearest = nearestP.subtract(center);
603
+
604
+ const distToCenter = p.distanceFrom(center);
605
+ const nearestDistToCenter = nearestP.distanceFrom(center);
606
+
607
+ let signedDist = dist;
608
+ if (distToCenter < nearestDistToCenter) {
609
+ signedDist = -dist; // Inside
610
+ }
611
+
612
+ // Clamp distance
613
+ let clampedDist = signedDist;
614
+ if (signedDist > 0) {
615
+ clampedDist = Math.min(signedDist, this.innerRadius);
616
+ } else {
617
+ clampedDist = Math.max(signedDist, -this.outerRadius);
618
+ }
619
+
620
+ // Reconstruct point
621
+ if (dist < 0.001) return nearestP;
622
+
623
+ // We want the result to lie on the line connecting Nearest -> P
624
+ const scale = Math.abs(clampedDist) / (dist || 1);
625
+ const offset = v.scalarMultiply(scale);
626
+
627
+ return nearestP.add(offset);
628
+ }
629
+ }