@pooder/kit 0.0.2 → 2.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,493 @@
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
+ Command,
3
+ Editor,
4
+ EditorState,
5
+ Extension,
6
+ OptionSchema,
7
+ Circle,
8
+ Group,
9
+ Point,
10
+ } from "@pooder/core";
11
+ import { DielineTool, DielineGeometry } from "./dieline";
12
+ import { getNearestPointOnDieline } from "./geometry";
13
+ import paper from "paper";
14
+
15
+ export interface HoleToolOptions {
16
+ innerRadius: number;
17
+ outerRadius: number;
18
+ style: "solid" | "dashed";
19
+ holes?: Array<{ x: number; y: number }>;
20
+ constraintTarget?: "original" | "bleed";
21
+ }
22
+
23
+ export class HoleTool implements Extension<HoleToolOptions> {
24
+ public name = "HoleTool";
25
+ public options: HoleToolOptions = {
26
+ innerRadius: 15,
27
+ outerRadius: 25,
28
+ style: "solid",
29
+ holes: [],
30
+ constraintTarget: "bleed",
31
+ };
32
+
33
+ public schema: Record<keyof HoleToolOptions, OptionSchema> = {
34
+ innerRadius: {
35
+ type: "number",
36
+ min: 1,
37
+ max: 100,
38
+ label: "Inner Radius",
39
+ },
40
+ outerRadius: {
41
+ type: "number",
42
+ min: 1,
43
+ max: 100,
44
+ label: "Outer Radius",
45
+ },
46
+ style: {
47
+ type: "select",
48
+ options: ["solid", "dashed"],
49
+ label: "Line Style",
50
+ },
51
+ constraintTarget: {
52
+ type: "select",
53
+ options: ["original", "bleed"],
54
+ label: "Constraint Target",
55
+ },
56
+ holes: {
57
+ type: "json",
58
+ label: "Holes",
59
+ } as any,
60
+ };
61
+
62
+ private handleMoving: ((e: any) => void) | null = null;
63
+ private handleModified: ((e: any) => void) | null = null;
64
+
65
+ onMount(editor: Editor) {
66
+ this.setup(editor);
67
+ }
68
+
69
+ onUnmount(editor: Editor) {
70
+ this.teardown(editor);
71
+ }
72
+
73
+ onDestroy(editor: Editor) {
74
+ this.teardown(editor);
75
+ }
76
+
77
+ private getDielineGeometry(editor: Editor): DielineGeometry | null {
78
+ const dielineTool = editor.getExtension("DielineTool") as DielineTool;
79
+ if (!dielineTool) return null;
80
+
81
+ const geometry = dielineTool.getGeometry(editor);
82
+ if (!geometry) return null;
83
+
84
+ const offset =
85
+ this.options.constraintTarget === "original"
86
+ ? 0
87
+ : dielineTool.options.offset || 0;
88
+
89
+ // Apply offset to geometry
90
+ return {
91
+ ...geometry,
92
+ width: Math.max(0, geometry.width + offset * 2),
93
+ height: Math.max(0, geometry.height + offset * 2),
94
+ radius: Math.max(0, geometry.radius + offset),
95
+ };
96
+ }
97
+
98
+ public enforceConstraints(editor: Editor) {
99
+ const geometry = this.getDielineGeometry(editor);
100
+ if (!geometry) return;
101
+
102
+ // Get all hole markers
103
+ const objects = editor.canvas
104
+ .getObjects()
105
+ .filter((obj: any) => obj.data?.type === "hole-marker");
106
+
107
+ let changed = false;
108
+ // Sort objects by index to maintain order in options.holes
109
+ objects.sort(
110
+ (a: any, b: any) => (a.data?.index ?? 0) - (b.data?.index ?? 0),
111
+ );
112
+
113
+ const newHoles: { x: number; y: number }[] = [];
114
+
115
+ objects.forEach((obj: any) => {
116
+ const currentPos = new Point(obj.left, obj.top);
117
+ const newPos = this.calculateConstrainedPosition(currentPos, geometry);
118
+
119
+ if (currentPos.distanceFrom(newPos) > 0.1) {
120
+ obj.set({
121
+ left: newPos.x,
122
+ top: newPos.y,
123
+ });
124
+ obj.setCoords();
125
+ changed = true;
126
+ }
127
+ newHoles.push({ x: obj.left, y: obj.top });
128
+ });
129
+
130
+ if (changed) {
131
+ this.options.holes = newHoles;
132
+ editor.canvas.requestRenderAll();
133
+ }
134
+ }
135
+
136
+ private setup(editor: Editor) {
137
+ if (!this.handleMoving) {
138
+ this.handleMoving = (e: any) => {
139
+ const target = e.target;
140
+ if (!target || target.data?.type !== "hole-marker") return;
141
+
142
+ const geometry = this.getDielineGeometry(editor);
143
+ if (!geometry) return;
144
+
145
+ const p = new Point(target.left, target.top);
146
+ const newPos = this.calculateConstrainedPosition(p, geometry);
147
+
148
+ target.set({
149
+ left: newPos.x,
150
+ top: newPos.y,
151
+ });
152
+ };
153
+ editor.canvas.on("object:moving", this.handleMoving);
154
+ }
155
+
156
+ if (!this.handleModified) {
157
+ this.handleModified = (e: any) => {
158
+ const target = e.target;
159
+ if (!target || target.data?.type !== "hole-marker") return;
160
+
161
+ // Update state when hole is moved
162
+ this.syncHolesFromCanvas(editor);
163
+ };
164
+ editor.canvas.on("object:modified", this.handleModified);
165
+ }
166
+
167
+ const opts = this.options;
168
+ // Default hole if none exist
169
+ if (!opts.holes || opts.holes.length === 0) {
170
+ let defaultPos = { x: editor.canvas.width! / 2, y: 50 };
171
+
172
+ const g = this.getDielineGeometry(editor);
173
+ if (g) {
174
+ // Default to Top-Center of Dieline shape
175
+ const topCenter = { x: g.x, y: g.y - g.height / 2 };
176
+ // Snap to exact shape edge
177
+ const snapped = getNearestPointOnDieline(topCenter, {
178
+ ...g,
179
+ holes: [],
180
+ } as any);
181
+ defaultPos = snapped;
182
+ }
183
+
184
+ opts.holes = [defaultPos];
185
+ }
186
+
187
+ this.options = { ...opts };
188
+ this.redraw(editor);
189
+
190
+ // Ensure Dieline updates to reflect current holes (fusion effect)
191
+ const dielineTool = editor.getExtension("DielineTool") as DielineTool;
192
+ if (dielineTool && dielineTool.updateDieline) {
193
+ dielineTool.updateDieline(editor);
194
+ }
195
+ }
196
+
197
+ private teardown(editor: Editor) {
198
+ if (this.handleMoving) {
199
+ editor.canvas.off("object:moving", this.handleMoving);
200
+ this.handleMoving = null;
201
+ }
202
+ if (this.handleModified) {
203
+ editor.canvas.off("object:modified", this.handleModified);
204
+ this.handleModified = null;
205
+ }
206
+
207
+ const objects = editor.canvas
208
+ .getObjects()
209
+ .filter((obj: any) => obj.data?.type === "hole-marker");
210
+ objects.forEach((obj) => editor.canvas.remove(obj));
211
+
212
+ editor.canvas.requestRenderAll();
213
+ }
214
+
215
+ onUpdate(editor: Editor, state: EditorState) {
216
+ this.enforceConstraints(editor);
217
+ this.redraw(editor);
218
+
219
+ // Trigger Dieline update
220
+ const dielineTool = editor.getExtension("DielineTool") as any;
221
+ if (dielineTool && dielineTool.updateDieline) {
222
+ dielineTool.updateDieline(editor);
223
+ }
224
+ }
225
+
226
+ commands: Record<string, Command> = {
227
+ reset: {
228
+ execute: (editor: Editor) => {
229
+ let defaultPos = { x: editor.canvas.width! / 2, y: 50 };
230
+
231
+ const g = this.getDielineGeometry(editor);
232
+ if (g) {
233
+ const topCenter = { x: g.x, y: g.y - g.height / 2 };
234
+ defaultPos = getNearestPointOnDieline(topCenter, {
235
+ ...g,
236
+ holes: [],
237
+ } as any);
238
+ }
239
+
240
+ this.options = {
241
+ innerRadius: 15,
242
+ outerRadius: 25,
243
+ style: "solid",
244
+ holes: [defaultPos],
245
+ };
246
+ this.redraw(editor);
247
+
248
+ // Trigger Dieline update
249
+ const dielineTool = editor.getExtension("DielineTool") as DielineTool;
250
+ if (dielineTool && dielineTool.updateDieline) {
251
+ dielineTool.updateDieline(editor);
252
+ }
253
+
254
+ return true;
255
+ },
256
+ },
257
+ addHole: {
258
+ execute: (editor: Editor, x: number, y: number) => {
259
+ if (!this.options.holes) this.options.holes = [];
260
+ this.options.holes.push({ x, y });
261
+ this.redraw(editor);
262
+
263
+ // Trigger Dieline update
264
+ const dielineTool = editor.getExtension("DielineTool") as any;
265
+ if (dielineTool && dielineTool.updateDieline) {
266
+ dielineTool.updateDieline(editor);
267
+ }
268
+
269
+ return true;
270
+ },
271
+ schema: {
272
+ x: {
273
+ type: "number",
274
+ label: "X Position",
275
+ required: true,
276
+ },
277
+ y: {
278
+ type: "number",
279
+ label: "Y Position",
280
+ required: true,
281
+ },
282
+ },
283
+ },
284
+ clearHoles: {
285
+ execute: (editor: Editor) => {
286
+ this.options.holes = [];
287
+ this.redraw(editor);
288
+
289
+ // Trigger Dieline update
290
+ const dielineTool = editor.getExtension("DielineTool") as any;
291
+ if (dielineTool && dielineTool.updateDieline) {
292
+ dielineTool.updateDieline(editor);
293
+ }
294
+
295
+ return true;
296
+ },
297
+ },
298
+ };
299
+
300
+ private syncHolesFromCanvas(editor: Editor) {
301
+ const objects = editor.canvas
302
+ .getObjects()
303
+ .filter((obj: any) => obj.data?.type === "hole-marker");
304
+ const holes = objects.map((obj) => ({ x: obj.left!, y: obj.top! }));
305
+ this.options.holes = holes;
306
+
307
+ // Trigger Dieline update for real-time fusion effect
308
+ const dielineTool = editor.getExtension("DielineTool") as any;
309
+ if (dielineTool && dielineTool.updateDieline) {
310
+ dielineTool.updateDieline(editor);
311
+ }
312
+ }
313
+
314
+ private redraw(editor: Editor) {
315
+ const canvas = editor.canvas;
316
+
317
+ // Remove existing holes
318
+ const existing = canvas
319
+ .getObjects()
320
+ .filter((obj: any) => obj.data?.type === "hole-marker");
321
+ existing.forEach((obj) => canvas.remove(obj));
322
+
323
+ const { innerRadius, outerRadius, style, holes } = this.options;
324
+
325
+ if (!holes || holes.length === 0) {
326
+ canvas.requestRenderAll();
327
+ return;
328
+ }
329
+
330
+ holes.forEach((hole, index) => {
331
+ const innerCircle = new Circle({
332
+ radius: innerRadius,
333
+ fill: "transparent",
334
+ stroke: "red",
335
+ strokeWidth: 2,
336
+ originX: "center",
337
+ originY: "center",
338
+ });
339
+
340
+ const outerCircle = new Circle({
341
+ radius: outerRadius,
342
+ fill: "transparent",
343
+ stroke: "#666",
344
+ strokeWidth: 1,
345
+ strokeDashArray: style === "dashed" ? [5, 5] : undefined,
346
+ originX: "center",
347
+ originY: "center",
348
+ });
349
+
350
+ const holeGroup = new Group([outerCircle, innerCircle], {
351
+ left: hole.x,
352
+ top: hole.y,
353
+ originX: "center",
354
+ originY: "center",
355
+ selectable: true,
356
+ hasControls: false, // Don't allow resizing/rotating
357
+ hasBorders: false,
358
+ subTargetCheck: false,
359
+ opacity: 0, // Default hidden
360
+ hoverCursor: "move",
361
+ data: { type: "hole-marker", index },
362
+ } as any);
363
+ (holeGroup as any).name = "hole-marker";
364
+
365
+ // Auto-show/hide logic
366
+ holeGroup.on("mouseover", () => {
367
+ holeGroup.set("opacity", 1);
368
+ canvas.requestRenderAll();
369
+ });
370
+ holeGroup.on("mouseout", () => {
371
+ if (canvas.getActiveObject() !== holeGroup) {
372
+ holeGroup.set("opacity", 0);
373
+ canvas.requestRenderAll();
374
+ }
375
+ });
376
+ holeGroup.on("selected", () => {
377
+ holeGroup.set("opacity", 1);
378
+ canvas.requestRenderAll();
379
+ });
380
+ holeGroup.on("deselected", () => {
381
+ holeGroup.set("opacity", 0);
382
+ canvas.requestRenderAll();
383
+ });
384
+
385
+ canvas.add(holeGroup);
386
+ canvas.bringObjectToFront(holeGroup);
387
+ });
388
+
389
+ canvas.requestRenderAll();
390
+ }
391
+
392
+ private calculateConstrainedPosition(p: Point, g: DielineGeometry): Point {
393
+ // Use Paper.js to get accurate nearest point
394
+ // This handles ellipses, rects, and rounded rects correctly
395
+
396
+ // Convert to holes format for geometry options
397
+ const options = {
398
+ ...g,
399
+ holes: [], // We don't need holes for boundary calculation
400
+ };
401
+
402
+ const nearest = getNearestPointOnDieline(
403
+ { x: p.x, y: p.y },
404
+ options as any,
405
+ );
406
+
407
+ // Now constrain distance
408
+ const nearestP = new Point(nearest.x, nearest.y);
409
+ const dist = p.distanceFrom(nearestP);
410
+
411
+ // Determine if point is inside or outside
412
+ // Simple heuristic: distance from center
413
+ // Or using paper.js contains() if we had the full path object
414
+ // For convex shapes, center distance works mostly, but let's use the vector direction
415
+
416
+ // Vector from nearest to current point
417
+ const v = p.subtract(nearestP);
418
+
419
+ // Vector from center to nearest point (approximate normal for convex shapes)
420
+ const center = new Point(g.x, g.y);
421
+ const centerToNearest = nearestP.subtract(center);
422
+
423
+ // Dot product to see if they align (outside) or oppose (inside)
424
+ // If point is exactly on line, dist is 0.
425
+
426
+ // However, we want to constrain the point to be within [innerRadius, -outerRadius] distance from the edge.
427
+ // Actually, usually users want to snap to the edge or stay within a reasonable margin.
428
+ // The previous logic clamped the distance.
429
+
430
+ // Let's implement a simple snap-to-edge if close, otherwise allow free movement but clamp max distance?
431
+ // Or reproduce the previous "slide along edge" behavior.
432
+ // Previous behavior: "clampedDist = Math.min(dist, innerRadius); ... Math.max(dist, -outerRadius)"
433
+ // This implies the hole center can be slightly inside or outside the main shape edge.
434
+
435
+ // Let's determine sign of distance
436
+ // We can use paper.js Shape.contains(point) to check if inside.
437
+ // But getNearestPointOnDieline returns just coordinates.
438
+
439
+ // Optimization: Let's assume for Dieline shapes (convex-ish),
440
+ // if distance from center > distance of nearest from center, it's outside.
441
+ const distToCenter = p.distanceFrom(center);
442
+ const nearestDistToCenter = nearestP.distanceFrom(center);
443
+
444
+ let signedDist = dist;
445
+ if (distToCenter < nearestDistToCenter) {
446
+ signedDist = -dist; // Inside
447
+ }
448
+
449
+ // Clamp distance
450
+ let clampedDist = signedDist;
451
+ if (signedDist > 0) {
452
+ clampedDist = Math.min(signedDist, this.options.innerRadius);
453
+ } else {
454
+ clampedDist = Math.max(signedDist, -this.options.outerRadius);
455
+ }
456
+
457
+ // Reconstruct point
458
+ // If dist is very small, just use nearestP
459
+ if (dist < 0.001) return nearestP;
460
+
461
+ // Direction vector normalized
462
+ const dir = v.scalarDivide(dist);
463
+
464
+ // New point = nearest + dir * clampedDist
465
+ // Note: if inside (signedDist < 0), v points towards center (roughly), dist is positive magnitude.
466
+ // Wait, v = p - nearest.
467
+ // If p is inside, p is closer to center. v points Inwards.
468
+ // If we want clampedDist to be negative, we should probably stick to normal vectors.
469
+
470
+ // Let's simplify:
471
+ // Just place it at nearest point + offset vector.
472
+ // Offset vector is 'v' scaled to clampedDist.
473
+
474
+ // If p is inside, v points in. length is 'dist'.
475
+ // We want length to be 'clampedDist' (magnitude).
476
+ // Since clampedDist is negative for inside, we need to be careful with signs.
477
+
478
+ // Actually simpler:
479
+ // We want the result to lie on the line connecting Center -> P -> Nearest? No.
480
+ // We want it on the line Nearest -> P.
481
+
482
+ // Current distance is 'dist'.
483
+ // Desired distance is abs(clampedDist).
484
+ // If clampedDist sign matches signedDist sign, we just scale v.
485
+
486
+ const scale = Math.abs(clampedDist) / (dist || 1);
487
+
488
+ // If we are clamping, we just scale the vector from nearest.
489
+ const offset = v.scalarMultiply(scale);
490
+
491
+ return nearestP.add(offset);
492
+ }
493
+ }