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