cyclecad 0.1.5 → 0.1.8

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.
@@ -0,0 +1,1048 @@
1
+ /**
2
+ * agent-api.js — The Agent-First API for cycleCAD
3
+ *
4
+ * This is the core differentiator: cycleCAD is not a GUI app with an API bolted on.
5
+ * The API IS the product. AI agents are the only interface.
6
+ *
7
+ * Any LLM (GPT, Claude, Gemini, Llama, Mistral) can design, validate, and
8
+ * manufacture through this API. Every operation is a JSON command.
9
+ * Every command returns a structured result.
10
+ *
11
+ * Architecture:
12
+ * Agent sends JSON command → agent-api.js dispatches → cycleCAD modules execute
13
+ * → structured JSON result returned → agent decides next action
14
+ *
15
+ * Protocol: JSON-RPC 2.0 style
16
+ * { method: "sketch.rect", params: { width: 50, height: 30 } }
17
+ * → { ok: true, result: { entityId: "rect_1", ... } }
18
+ */
19
+
20
+ import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js';
21
+
22
+ // ============================================================================
23
+ // Module references — set during init
24
+ // ============================================================================
25
+ let _viewport = null;
26
+ let _sketch = null;
27
+ let _ops = null;
28
+ let _advancedOps = null;
29
+ let _exportMod = null;
30
+ let _appState = null;
31
+
32
+ // ============================================================================
33
+ // Session state
34
+ // ============================================================================
35
+ let sessionId = null;
36
+ let commandLog = [];
37
+ let featureIndex = 0;
38
+
39
+ /**
40
+ * Initialize the Agent API with references to all cycleCAD modules
41
+ */
42
+ export function initAgentAPI({ viewport, sketch, operations, advancedOps, exportModule, appState }) {
43
+ _viewport = viewport;
44
+ _sketch = sketch;
45
+ _ops = operations;
46
+ _advancedOps = advancedOps;
47
+ _exportMod = exportModule;
48
+ _appState = appState;
49
+ sessionId = crypto.randomUUID();
50
+ commandLog = [];
51
+ featureIndex = 0;
52
+ console.log(`[Agent API] Initialized. Session: ${sessionId}`);
53
+
54
+ // Expose globally for external agent access
55
+ window.cycleCAD = { execute, executeMany, getSchema, getState, getSession };
56
+
57
+ return { sessionId };
58
+ }
59
+
60
+ // ============================================================================
61
+ // COMMAND DISPATCH
62
+ // ============================================================================
63
+
64
+ /**
65
+ * Execute a single agent command
66
+ * @param {Object} cmd - { method: string, params: object }
67
+ * @returns {Object} - { ok: boolean, result?: any, error?: string }
68
+ */
69
+ export function execute(cmd) {
70
+ const start = performance.now();
71
+ try {
72
+ if (!cmd || !cmd.method) {
73
+ return err('Missing "method" field');
74
+ }
75
+ const handler = COMMANDS[cmd.method];
76
+ if (!handler) {
77
+ return err(`Unknown method: "${cmd.method}". Use getSchema() to see available commands.`);
78
+ }
79
+ const result = handler(cmd.params || {});
80
+ const elapsed = Math.round(performance.now() - start);
81
+ const entry = { method: cmd.method, params: cmd.params, elapsed, ok: true };
82
+ commandLog.push(entry);
83
+ return { ok: true, result, elapsed };
84
+ } catch (e) {
85
+ const elapsed = Math.round(performance.now() - start);
86
+ commandLog.push({ method: cmd.method, params: cmd.params, elapsed, ok: false, error: e.message });
87
+ return err(e.message);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Execute a sequence of commands (pipeline)
93
+ * Stops on first error unless continueOnError is set
94
+ * @param {Array<Object>} cmds - Array of { method, params }
95
+ * @param {Object} opts - { continueOnError: false }
96
+ * @returns {Object} - { ok, results: Array, errors: Array }
97
+ */
98
+ export function executeMany(cmds, opts = {}) {
99
+ const results = [];
100
+ const errors = [];
101
+ for (let i = 0; i < cmds.length; i++) {
102
+ const r = execute(cmds[i]);
103
+ results.push(r);
104
+ if (!r.ok) {
105
+ errors.push({ index: i, method: cmds[i].method, error: r.error });
106
+ if (!opts.continueOnError) break;
107
+ }
108
+ }
109
+ return { ok: errors.length === 0, results, errors };
110
+ }
111
+
112
+ function err(msg) { return { ok: false, error: msg }; }
113
+ function nextId(prefix) { return `${prefix}_${++featureIndex}`; }
114
+
115
+ // ============================================================================
116
+ // HELPER: get mesh from features
117
+ // ============================================================================
118
+ function getFeatureMesh(id) {
119
+ if (!_appState) return null;
120
+ const features = _appState.getFeatures ? _appState.getFeatures() : (_appState.features || []);
121
+ const f = features.find(f => f.id === id || f.name === id);
122
+ return f ? f.mesh : null;
123
+ }
124
+
125
+ function getAllFeatures() {
126
+ if (!_appState) return [];
127
+ return _appState.getFeatures ? _appState.getFeatures() : (_appState.features || []);
128
+ }
129
+
130
+ // ============================================================================
131
+ // COMMAND REGISTRY
132
+ // ============================================================================
133
+
134
+ const COMMANDS = {
135
+
136
+ // ==========================================================================
137
+ // SKETCH — 2D drawing on a plane
138
+ // ==========================================================================
139
+
140
+ 'sketch.start': ({ plane = 'XY' }) => {
141
+ const cam = _viewport.getCamera();
142
+ const ctrl = _viewport.getControls();
143
+ _sketch.startSketch(plane, cam, ctrl);
144
+ return { plane, status: 'active' };
145
+ },
146
+
147
+ 'sketch.end': () => {
148
+ const entities = _sketch.getEntities();
149
+ _sketch.endSketch();
150
+ return { entityCount: entities.length, entities: entities.map(summarizeEntity) };
151
+ },
152
+
153
+ 'sketch.line': ({ x1, y1, x2, y2 }) => {
154
+ requireAll({ x1, y1, x2, y2 });
155
+ const entities = _sketch.getEntities();
156
+ entities.push({ type: 'line', x1, y1, x2, y2, id: nextId('line') });
157
+ return { id: entities[entities.length - 1].id, type: 'line', from: [x1, y1], to: [x2, y2] };
158
+ },
159
+
160
+ 'sketch.rect': ({ x = 0, y = 0, width, height }) => {
161
+ requireAll({ width, height });
162
+ const entities = _sketch.getEntities();
163
+ const id = nextId('rect');
164
+ // Rectangle = 4 lines
165
+ entities.push({ type: 'line', x1: x, y1: y, x2: x + width, y2: y, id: id + '_t' });
166
+ entities.push({ type: 'line', x1: x + width, y1: y, x2: x + width, y2: y + height, id: id + '_r' });
167
+ entities.push({ type: 'line', x1: x + width, y1: y + height, x2: x, y2: y + height, id: id + '_b' });
168
+ entities.push({ type: 'line', x1: x, y1: y + height, x2: x, y2: y, id: id + '_l' });
169
+ return { id, type: 'rect', origin: [x, y], width, height, edges: 4 };
170
+ },
171
+
172
+ 'sketch.circle': ({ cx = 0, cy = 0, radius }) => {
173
+ requireAll({ radius });
174
+ const entities = _sketch.getEntities();
175
+ const id = nextId('circle');
176
+ entities.push({ type: 'circle', cx, cy, radius, id });
177
+ return { id, type: 'circle', center: [cx, cy], radius };
178
+ },
179
+
180
+ 'sketch.arc': ({ cx = 0, cy = 0, radius, startAngle = 0, endAngle = Math.PI }) => {
181
+ requireAll({ radius });
182
+ const entities = _sketch.getEntities();
183
+ const id = nextId('arc');
184
+ entities.push({ type: 'arc', cx, cy, radius, startAngle, endAngle, id });
185
+ return { id, type: 'arc', center: [cx, cy], radius, startAngle, endAngle };
186
+ },
187
+
188
+ 'sketch.clear': () => {
189
+ _sketch.clearSketch();
190
+ return { cleared: true };
191
+ },
192
+
193
+ 'sketch.entities': () => {
194
+ return { entities: _sketch.getEntities().map(summarizeEntity) };
195
+ },
196
+
197
+ // ==========================================================================
198
+ // OPERATIONS — 3D modeling
199
+ // ==========================================================================
200
+
201
+ 'ops.extrude': ({ height, symmetric = false, material = 'steel' }) => {
202
+ requireAll({ height });
203
+ const entities = _sketch.getEntities();
204
+ if (entities.length === 0) throw new Error('No sketch entities to extrude. Use sketch.* commands first.');
205
+ const mesh = _ops.extrudeProfile(entities, height, { symmetric, material: _ops.createMaterial(material) });
206
+ const id = nextId('extrude');
207
+ mesh.name = id;
208
+ _viewport.addToScene(mesh);
209
+ addFeature(id, 'extrude', mesh, { height, symmetric, material });
210
+ return { id, type: 'extrude', height, material, bbox: getBBox(mesh) };
211
+ },
212
+
213
+ 'ops.revolve': ({ axis = 'Y', angle = 360, material = 'steel' }) => {
214
+ const entities = _sketch.getEntities();
215
+ if (entities.length === 0) throw new Error('No sketch entities to revolve.');
216
+ const mesh = _ops.revolveProfile(entities, { type: axis }, { angle: THREE.MathUtils.degToRad(angle), material: _ops.createMaterial(material) });
217
+ const id = nextId('revolve');
218
+ mesh.name = id;
219
+ _viewport.addToScene(mesh);
220
+ addFeature(id, 'revolve', mesh, { axis, angle, material });
221
+ return { id, type: 'revolve', axis, angle, material, bbox: getBBox(mesh) };
222
+ },
223
+
224
+ 'ops.primitive': ({ shape, width, height, depth, radius, segments, material = 'steel' }) => {
225
+ requireAll({ shape });
226
+ const mesh = _ops.createPrimitive(shape, { width, height, depth, radius, segments }, { material: _ops.createMaterial(material) });
227
+ const id = nextId(shape);
228
+ mesh.name = id;
229
+ _viewport.addToScene(mesh);
230
+ addFeature(id, 'primitive', mesh, { shape, width, height, depth, radius, material });
231
+ return { id, type: 'primitive', shape, material, bbox: getBBox(mesh) };
232
+ },
233
+
234
+ 'ops.fillet': ({ target, radius = 0.1 }) => {
235
+ requireAll({ target });
236
+ const mesh = getFeatureMesh(target);
237
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
238
+ const result = _ops.fillet(mesh, 'all', radius);
239
+ return { target, radius, applied: true };
240
+ },
241
+
242
+ 'ops.chamfer': ({ target, distance = 0.1 }) => {
243
+ requireAll({ target });
244
+ const mesh = getFeatureMesh(target);
245
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
246
+ _ops.chamfer(mesh, 'all', distance);
247
+ return { target, distance, applied: true };
248
+ },
249
+
250
+ 'ops.boolean': ({ operation, targetA, targetB }) => {
251
+ requireAll({ operation, targetA, targetB });
252
+ const meshA = getFeatureMesh(targetA);
253
+ const meshB = getFeatureMesh(targetB);
254
+ if (!meshA) throw new Error(`Feature "${targetA}" not found`);
255
+ if (!meshB) throw new Error(`Feature "${targetB}" not found`);
256
+ let result;
257
+ if (operation === 'union') result = _ops.booleanUnion(meshA, meshB);
258
+ else if (operation === 'cut') result = _ops.booleanCut(meshA, meshB);
259
+ else if (operation === 'intersect') result = _ops.booleanIntersect(meshA, meshB);
260
+ else throw new Error(`Unknown boolean op: "${operation}". Use union|cut|intersect.`);
261
+ const id = nextId('bool');
262
+ result.name = id;
263
+ addFeature(id, 'boolean', result, { operation, targetA, targetB });
264
+ return { id, operation, bbox: getBBox(result) };
265
+ },
266
+
267
+ 'ops.shell': ({ target, thickness = 0.1 }) => {
268
+ requireAll({ target });
269
+ const mesh = getFeatureMesh(target);
270
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
271
+ _ops.createShell(mesh, thickness);
272
+ return { target, thickness, applied: true };
273
+ },
274
+
275
+ 'ops.pattern': ({ target, type = 'rect', count = 3, spacing = 1 }) => {
276
+ requireAll({ target });
277
+ const mesh = getFeatureMesh(target);
278
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
279
+ const clones = _ops.createPattern(mesh, type, count, spacing);
280
+ return { target, type, count, spacing, created: clones.length };
281
+ },
282
+
283
+ 'ops.material': ({ target, material }) => {
284
+ requireAll({ target, material });
285
+ const mesh = getFeatureMesh(target);
286
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
287
+ mesh.material = _ops.createMaterial(material);
288
+ return { target, material, applied: true };
289
+ },
290
+
291
+ // ==========================================================================
292
+ // ADVANCED OPS — sweep, loft, sheet metal, spring, thread
293
+ // ==========================================================================
294
+
295
+ 'ops.sweep': ({ profile, path, twist = 0, scale = 1 }) => {
296
+ requireAll({ profile, path });
297
+ const mesh = _advancedOps.createSweep(profile, path, { twist, scale });
298
+ const id = nextId('sweep');
299
+ mesh.name = id;
300
+ _viewport.addToScene(mesh);
301
+ addFeature(id, 'sweep', mesh, { profile, path, twist, scale });
302
+ return { id, type: 'sweep', bbox: getBBox(mesh) };
303
+ },
304
+
305
+ 'ops.loft': ({ profiles }) => {
306
+ requireAll({ profiles });
307
+ const mesh = _advancedOps.createLoft(profiles);
308
+ const id = nextId('loft');
309
+ mesh.name = id;
310
+ _viewport.addToScene(mesh);
311
+ addFeature(id, 'loft', mesh, { profiles });
312
+ return { id, type: 'loft', bbox: getBBox(mesh) };
313
+ },
314
+
315
+ 'ops.spring': ({ radius = 5, wireRadius = 0.5, height = 20, turns = 5, material = 'steel' }) => {
316
+ const mesh = _advancedOps.createSpring(radius, wireRadius, height, turns, { material: _ops.createMaterial(material) });
317
+ const id = nextId('spring');
318
+ mesh.name = id;
319
+ _viewport.addToScene(mesh);
320
+ addFeature(id, 'spring', mesh, { radius, wireRadius, height, turns, material });
321
+ return { id, type: 'spring', bbox: getBBox(mesh) };
322
+ },
323
+
324
+ 'ops.thread': ({ outerRadius = 5, innerRadius = 4.2, pitch = 1, length = 10, material = 'steel' }) => {
325
+ const mesh = _advancedOps.createThread(outerRadius, innerRadius, pitch, length, { material: _ops.createMaterial(material) });
326
+ const id = nextId('thread');
327
+ mesh.name = id;
328
+ _viewport.addToScene(mesh);
329
+ addFeature(id, 'thread', mesh, { outerRadius, innerRadius, pitch, length, material });
330
+ return { id, type: 'thread', bbox: getBBox(mesh) };
331
+ },
332
+
333
+ 'ops.bend': ({ target, angle = 90, radius = 2 }) => {
334
+ requireAll({ target });
335
+ const mesh = getFeatureMesh(target);
336
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
337
+ _advancedOps.createBend(mesh, null, THREE.MathUtils.degToRad(angle), radius);
338
+ return { target, angle, radius, applied: true };
339
+ },
340
+
341
+ // ==========================================================================
342
+ // TRANSFORM — move, rotate, scale
343
+ // ==========================================================================
344
+
345
+ 'transform.move': ({ target, x = 0, y = 0, z = 0 }) => {
346
+ requireAll({ target });
347
+ const mesh = getFeatureMesh(target);
348
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
349
+ mesh.position.set(
350
+ mesh.position.x + x,
351
+ mesh.position.y + y,
352
+ mesh.position.z + z
353
+ );
354
+ return { target, position: [mesh.position.x, mesh.position.y, mesh.position.z] };
355
+ },
356
+
357
+ 'transform.rotate': ({ target, x = 0, y = 0, z = 0 }) => {
358
+ requireAll({ target });
359
+ const mesh = getFeatureMesh(target);
360
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
361
+ mesh.rotation.x += THREE.MathUtils.degToRad(x);
362
+ mesh.rotation.y += THREE.MathUtils.degToRad(y);
363
+ mesh.rotation.z += THREE.MathUtils.degToRad(z);
364
+ return { target, rotation: [
365
+ THREE.MathUtils.radToDeg(mesh.rotation.x),
366
+ THREE.MathUtils.radToDeg(mesh.rotation.y),
367
+ THREE.MathUtils.radToDeg(mesh.rotation.z)
368
+ ] };
369
+ },
370
+
371
+ 'transform.scale': ({ target, x = 1, y = 1, z = 1 }) => {
372
+ requireAll({ target });
373
+ const mesh = getFeatureMesh(target);
374
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
375
+ mesh.scale.set(mesh.scale.x * x, mesh.scale.y * y, mesh.scale.z * z);
376
+ return { target, scale: [mesh.scale.x, mesh.scale.y, mesh.scale.z] };
377
+ },
378
+
379
+ // ==========================================================================
380
+ // VIEWPORT — camera, view, display
381
+ // ==========================================================================
382
+
383
+ 'view.set': ({ view = 'isometric' }) => {
384
+ _viewport.setView(view);
385
+ return { view };
386
+ },
387
+
388
+ 'view.fit': ({ target }) => {
389
+ if (target) {
390
+ const mesh = getFeatureMesh(target);
391
+ if (mesh) _viewport.fitToObject(mesh);
392
+ } else {
393
+ _viewport.setView('isometric');
394
+ }
395
+ return { fitted: true };
396
+ },
397
+
398
+ 'view.wireframe': ({ enabled = true }) => {
399
+ _viewport.toggleWireframe(enabled);
400
+ return { wireframe: enabled };
401
+ },
402
+
403
+ 'view.grid': ({ visible = true }) => {
404
+ _viewport.toggleGrid(visible);
405
+ return { grid: visible };
406
+ },
407
+
408
+ // ==========================================================================
409
+ // EXPORT — STL, OBJ, glTF, JSON
410
+ // ==========================================================================
411
+
412
+ 'export.stl': ({ filename = 'agent-output.stl', binary = true }) => {
413
+ const features = getAllFeatures();
414
+ if (features.length === 0) throw new Error('No features to export');
415
+ if (binary) {
416
+ _exportMod.exportSTLBinary(features, filename);
417
+ } else {
418
+ _exportMod.exportSTL(features, filename);
419
+ }
420
+ return { format: 'stl', binary, filename, featureCount: features.length };
421
+ },
422
+
423
+ 'export.obj': ({ filename = 'agent-output.obj' }) => {
424
+ const features = getAllFeatures();
425
+ if (features.length === 0) throw new Error('No features to export');
426
+ _exportMod.exportOBJ(features, filename);
427
+ return { format: 'obj', filename, featureCount: features.length };
428
+ },
429
+
430
+ 'export.gltf': ({ filename = 'agent-output.gltf' }) => {
431
+ const features = getAllFeatures();
432
+ if (features.length === 0) throw new Error('No features to export');
433
+ _exportMod.exportGLTF(features, filename);
434
+ return { format: 'gltf', filename, featureCount: features.length };
435
+ },
436
+
437
+ 'export.json': ({ filename = 'agent-output.cyclecad.json' }) => {
438
+ const features = getAllFeatures();
439
+ if (features.length === 0) throw new Error('No features to export');
440
+ _exportMod.exportJSON(features, filename);
441
+ return { format: 'json', filename, featureCount: features.length };
442
+ },
443
+
444
+ // ==========================================================================
445
+ // QUERY — inspect the model, features, dimensions
446
+ // ==========================================================================
447
+
448
+ 'query.features': () => {
449
+ const features = getAllFeatures();
450
+ return {
451
+ count: features.length,
452
+ features: features.map(f => ({
453
+ id: f.id,
454
+ type: f.type,
455
+ name: f.name || f.id,
456
+ bbox: f.mesh ? getBBox(f.mesh) : null,
457
+ params: f.params || {}
458
+ }))
459
+ };
460
+ },
461
+
462
+ 'query.bbox': ({ target }) => {
463
+ requireAll({ target });
464
+ const mesh = getFeatureMesh(target);
465
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
466
+ return getBBox(mesh);
467
+ },
468
+
469
+ 'query.materials': () => {
470
+ return { materials: Object.keys(_ops.getMaterialPresets()) };
471
+ },
472
+
473
+ 'query.session': () => {
474
+ return getSession();
475
+ },
476
+
477
+ 'query.log': ({ last = 20 }) => {
478
+ return { commands: commandLog.slice(-last) };
479
+ },
480
+
481
+ // ==========================================================================
482
+ // VALIDATE — DFM checks, measurements, analysis
483
+ // ==========================================================================
484
+
485
+ 'validate.dimensions': ({ target }) => {
486
+ requireAll({ target });
487
+ const mesh = getFeatureMesh(target);
488
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
489
+ const bbox = getBBox(mesh);
490
+ const volume = bbox.width * bbox.height * bbox.depth;
491
+ return {
492
+ target,
493
+ width: bbox.width,
494
+ height: bbox.height,
495
+ depth: bbox.depth,
496
+ volume: round(volume),
497
+ diagonal: round(Math.sqrt(bbox.width ** 2 + bbox.height ** 2 + bbox.depth ** 2)),
498
+ fitsInPrintBed: bbox.width <= 250 && bbox.height <= 250 && bbox.depth <= 250,
499
+ printBedSize: [250, 250, 250]
500
+ };
501
+ },
502
+
503
+ 'validate.wallThickness': ({ target, minWall = 0.8 }) => {
504
+ requireAll({ target });
505
+ const mesh = getFeatureMesh(target);
506
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
507
+ const bbox = getBBox(mesh);
508
+ const minDim = Math.min(bbox.width, bbox.height, bbox.depth);
509
+ return {
510
+ target,
511
+ minDimension: round(minDim),
512
+ minWallRequired: minWall,
513
+ passes: minDim >= minWall,
514
+ recommendation: minDim < minWall ? `Minimum dimension ${round(minDim)}mm is below ${minWall}mm wall threshold` : 'OK'
515
+ };
516
+ },
517
+
518
+ 'validate.printability': ({ target, process = 'FDM' }) => {
519
+ requireAll({ target });
520
+ const mesh = getFeatureMesh(target);
521
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
522
+ const bbox = getBBox(mesh);
523
+ const volume = bbox.width * bbox.height * bbox.depth;
524
+ const issues = [];
525
+
526
+ // Size checks
527
+ const maxPrint = process === 'FDM' ? 250 : process === 'SLA' ? 150 : 300;
528
+ if (bbox.width > maxPrint || bbox.height > maxPrint || bbox.depth > maxPrint) {
529
+ issues.push(`Exceeds ${process} build volume (${maxPrint}mm)`);
530
+ }
531
+
532
+ // Wall thickness
533
+ const minDim = Math.min(bbox.width, bbox.height, bbox.depth);
534
+ const minWall = process === 'FDM' ? 0.8 : process === 'SLA' ? 0.3 : 1.0;
535
+ if (minDim < minWall) {
536
+ issues.push(`Min dimension ${round(minDim)}mm below ${minWall}mm ${process} minimum`);
537
+ }
538
+
539
+ // Aspect ratio (stability)
540
+ const maxDim = Math.max(bbox.width, bbox.height, bbox.depth);
541
+ if (maxDim / Math.max(minDim, 0.1) > 15) {
542
+ issues.push('High aspect ratio — may need supports or orientation change');
543
+ }
544
+
545
+ return {
546
+ target, process,
547
+ printable: issues.length === 0,
548
+ issues,
549
+ estimatedTime: `${Math.ceil(volume / 1000)}min`,
550
+ estimatedMaterial: `${round(volume * 0.0012)}g`,
551
+ orientation: bbox.height > bbox.width ? 'Consider rotating 90° for better bed adhesion' : 'OK'
552
+ };
553
+ },
554
+
555
+ 'validate.cost': ({ target, process = 'FDM', material = 'PLA' }) => {
556
+ requireAll({ target });
557
+ const mesh = getFeatureMesh(target);
558
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
559
+ const bbox = getBBox(mesh);
560
+ const volume = bbox.width * bbox.height * bbox.depth;
561
+
562
+ // Cost estimation per process
563
+ const rates = {
564
+ FDM: { perCm3: 0.05, setup: 2, perHour: 3 },
565
+ SLA: { perCm3: 0.15, setup: 5, perHour: 8 },
566
+ CNC: { perCm3: 0.30, setup: 25, perHour: 45 },
567
+ injection: { perCm3: 0.02, setup: 5000, perHour: 100 }
568
+ };
569
+ const rate = rates[process] || rates.FDM;
570
+ const volumeCm3 = volume / 1000;
571
+ const hours = volumeCm3 / 10;
572
+ const materialCost = round(volumeCm3 * rate.perCm3);
573
+ const machineCost = round(hours * rate.perHour);
574
+ const totalUnit = round(materialCost + machineCost + rate.setup);
575
+
576
+ return {
577
+ target, process, material,
578
+ volumeCm3: round(volumeCm3),
579
+ materialCost,
580
+ machineCost,
581
+ setupCost: rate.setup,
582
+ unitCost: totalUnit,
583
+ batchOf100: round((materialCost + machineCost) * 100 + rate.setup),
584
+ recommendation: process === 'injection' && volumeCm3 < 50
585
+ ? 'Volume too low for injection molding — consider FDM or CNC'
586
+ : 'OK'
587
+ };
588
+ },
589
+
590
+ // ==========================================================================
591
+ // SCENE — full scene management
592
+ // ==========================================================================
593
+
594
+ 'scene.clear': () => {
595
+ const features = getAllFeatures();
596
+ features.forEach(f => {
597
+ if (f.mesh) _viewport.removeFromScene(f.mesh);
598
+ });
599
+ if (_appState.clearFeatures) _appState.clearFeatures();
600
+ else if (_appState.features) _appState.features.length = 0;
601
+ _sketch.clearSketch();
602
+ featureIndex = 0;
603
+ return { cleared: true, removed: features.length };
604
+ },
605
+
606
+ 'scene.snapshot': () => {
607
+ const renderer = _viewport.getRenderer();
608
+ if (!renderer) throw new Error('Renderer not available');
609
+ renderer.render(_viewport.getScene(), _viewport.getCamera());
610
+ const dataUrl = renderer.domElement.toDataURL('image/png');
611
+ return { format: 'png', dataUrl, width: renderer.domElement.width, height: renderer.domElement.height };
612
+ },
613
+
614
+ // ==========================================================================
615
+ // RENDER — Visual feedback loop (CAD Agent pattern)
616
+ // Agent sends commands → gets PNG renders back → evaluates → iterates
617
+ // ==========================================================================
618
+
619
+ 'render.snapshot': ({ width = 800, height = 600 }) => {
620
+ const renderer = _viewport.getRenderer();
621
+ const scene = _viewport.getScene();
622
+ const cam = _viewport.getCamera();
623
+ if (!renderer) throw new Error('Renderer not available');
624
+ // Render at requested resolution
625
+ const origW = renderer.domElement.width;
626
+ const origH = renderer.domElement.height;
627
+ renderer.setSize(width, height);
628
+ cam.aspect = width / height;
629
+ cam.updateProjectionMatrix();
630
+ renderer.render(scene, cam);
631
+ const dataUrl = renderer.domElement.toDataURL('image/png');
632
+ // Restore
633
+ renderer.setSize(origW, origH);
634
+ cam.aspect = origW / origH;
635
+ cam.updateProjectionMatrix();
636
+ return { format: 'png', width, height, dataUrl };
637
+ },
638
+
639
+ 'render.multiview': ({ width = 400, height = 300 }) => {
640
+ const renderer = _viewport.getRenderer();
641
+ const scene = _viewport.getScene();
642
+ const cam = _viewport.getCamera();
643
+ if (!renderer) throw new Error('Renderer not available');
644
+ const origPos = cam.position.clone();
645
+ const origTarget = new THREE.Vector3();
646
+ // Capture 6 standard views
647
+ const views = {
648
+ front: [0, 0, 3000], back: [0, 0, -3000],
649
+ right: [3000, 0, 0], left: [-3000, 0, 0],
650
+ top: [0, 3000, 0], isometric: [2000, 1500, 2000]
651
+ };
652
+ const renders = {};
653
+ const origW = renderer.domElement.width;
654
+ const origH = renderer.domElement.height;
655
+ renderer.setSize(width, height);
656
+ cam.aspect = width / height;
657
+ cam.updateProjectionMatrix();
658
+ for (const [name, pos] of Object.entries(views)) {
659
+ cam.position.set(...pos);
660
+ cam.lookAt(0, 0, 0);
661
+ renderer.render(scene, cam);
662
+ renders[name] = renderer.domElement.toDataURL('image/png');
663
+ }
664
+ // Restore camera
665
+ cam.position.copy(origPos);
666
+ renderer.setSize(origW, origH);
667
+ cam.aspect = origW / origH;
668
+ cam.updateProjectionMatrix();
669
+ return { viewCount: 6, width, height, views: Object.keys(renders), renders };
670
+ },
671
+
672
+ // ==========================================================================
673
+ // ENGINEERING CALCS — mass, surface area, center of mass
674
+ // ==========================================================================
675
+
676
+ 'validate.mass': ({ target, material = 'steel' }) => {
677
+ requireAll({ target });
678
+ const mesh = getFeatureMesh(target);
679
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
680
+ const bbox = getBBox(mesh);
681
+ const volumeCm3 = (bbox.width * bbox.height * bbox.depth) / 1000;
682
+ // Material densities (g/cm³)
683
+ const densities = { steel: 7.85, aluminum: 2.70, plastic: 1.04, brass: 8.50, titanium: 4.51, nylon: 1.14, copper: 8.96, wood: 0.6, pla: 1.24, abs: 1.05 };
684
+ const density = densities[material] || densities.steel;
685
+ // Approximate fill factor (solid shapes ~0.65 of bbox, complex shapes less)
686
+ const fillFactor = 0.65;
687
+ const massG = round(volumeCm3 * density * fillFactor);
688
+ return {
689
+ target, material,
690
+ density: density + ' g/cm³',
691
+ bboxVolumeCm3: round(volumeCm3),
692
+ estimatedFill: fillFactor,
693
+ massGrams: massG,
694
+ massKg: round(massG / 1000),
695
+ massLbs: round(massG / 453.592)
696
+ };
697
+ },
698
+
699
+ 'validate.surfaceArea': ({ target }) => {
700
+ requireAll({ target });
701
+ const mesh = getFeatureMesh(target);
702
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
703
+ // Calculate surface area from mesh triangles
704
+ const geo = mesh.geometry;
705
+ if (!geo) throw new Error('No geometry on mesh');
706
+ const pos = geo.getAttribute('position');
707
+ const idx = geo.getIndex();
708
+ let area = 0;
709
+ const vA = new THREE.Vector3(), vB = new THREE.Vector3(), vC = new THREE.Vector3();
710
+ const triCount = idx ? idx.count / 3 : pos.count / 3;
711
+ for (let i = 0; i < triCount; i++) {
712
+ if (idx) {
713
+ vA.fromBufferAttribute(pos, idx.getX(i * 3));
714
+ vB.fromBufferAttribute(pos, idx.getX(i * 3 + 1));
715
+ vC.fromBufferAttribute(pos, idx.getX(i * 3 + 2));
716
+ } else {
717
+ vA.fromBufferAttribute(pos, i * 3);
718
+ vB.fromBufferAttribute(pos, i * 3 + 1);
719
+ vC.fromBufferAttribute(pos, i * 3 + 2);
720
+ }
721
+ const edge1 = new THREE.Vector3().subVectors(vB, vA);
722
+ const edge2 = new THREE.Vector3().subVectors(vC, vA);
723
+ area += edge1.cross(edge2).length() * 0.5;
724
+ }
725
+ // Apply world transforms
726
+ const scale = mesh.scale;
727
+ area *= scale.x * scale.y; // approximate for uniform scale
728
+ return {
729
+ target,
730
+ surfaceAreaMm2: round(area),
731
+ surfaceAreaCm2: round(area / 100),
732
+ surfaceAreaM2: round(area / 1000000),
733
+ triangles: triCount
734
+ };
735
+ },
736
+
737
+ 'validate.centerOfMass': ({ target }) => {
738
+ requireAll({ target });
739
+ const mesh = getFeatureMesh(target);
740
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
741
+ const geo = mesh.geometry;
742
+ if (!geo) throw new Error('No geometry on mesh');
743
+ geo.computeBoundingBox();
744
+ const center = new THREE.Vector3();
745
+ geo.boundingBox.getCenter(center);
746
+ // Apply mesh world transform
747
+ center.applyMatrix4(mesh.matrixWorld);
748
+ return {
749
+ target,
750
+ centerOfMass: [round(center.x), round(center.y), round(center.z)],
751
+ note: 'Assumes uniform density — geometric centroid of mesh'
752
+ };
753
+ },
754
+
755
+ // ==========================================================================
756
+ // DESIGN REVIEW — auto-analyze CAD for issues (Matsuo Lab pattern)
757
+ // ==========================================================================
758
+
759
+ 'validate.designReview': ({ target }) => {
760
+ requireAll({ target });
761
+ const mesh = getFeatureMesh(target);
762
+ if (!mesh) throw new Error(`Feature "${target}" not found`);
763
+ const bbox = getBBox(mesh);
764
+ const issues = [];
765
+ const warnings = [];
766
+ const passed = [];
767
+ const volume = bbox.width * bbox.height * bbox.depth;
768
+ const minDim = Math.min(bbox.width, bbox.height, bbox.depth);
769
+ const maxDim = Math.max(bbox.width, bbox.height, bbox.depth);
770
+ const aspectRatio = maxDim / Math.max(minDim, 0.01);
771
+
772
+ // 1. Geometry sanity
773
+ const geo = mesh.geometry;
774
+ const triCount = geo.getIndex() ? geo.getIndex().count / 3 : geo.getAttribute('position').count / 3;
775
+ if (triCount < 4) issues.push({ severity: 'error', check: 'geometry', msg: 'Too few triangles — degenerate geometry' });
776
+ else passed.push('Geometry: valid mesh');
777
+
778
+ if (triCount > 500000) warnings.push({ severity: 'warn', check: 'complexity', msg: `High triangle count (${triCount}) — may impact performance` });
779
+ else passed.push(`Complexity: ${triCount} triangles OK`);
780
+
781
+ // 2. Dimensions
782
+ if (minDim < 0.1) issues.push({ severity: 'error', check: 'dimensions', msg: `Minimum dimension ${round(minDim)}mm — likely too thin to manufacture` });
783
+ else if (minDim < 0.8) warnings.push({ severity: 'warn', check: 'wallThickness', msg: `Min dimension ${round(minDim)}mm — below FDM minimum (0.8mm)` });
784
+ else passed.push(`Wall thickness: ${round(minDim)}mm OK`);
785
+
786
+ // 3. Aspect ratio
787
+ if (aspectRatio > 20) issues.push({ severity: 'error', check: 'aspectRatio', msg: `Extreme aspect ratio ${round(aspectRatio)}:1 — part will be fragile or warp` });
788
+ else if (aspectRatio > 10) warnings.push({ severity: 'warn', check: 'aspectRatio', msg: `High aspect ratio ${round(aspectRatio)}:1 — consider supports` });
789
+ else passed.push(`Aspect ratio: ${round(aspectRatio)}:1 OK`);
790
+
791
+ // 4. Size
792
+ if (maxDim > 500) warnings.push({ severity: 'warn', check: 'size', msg: `Part exceeds 500mm (${round(maxDim)}mm) — check build volume for your process` });
793
+ else passed.push(`Size: ${round(maxDim)}mm max dimension OK`);
794
+
795
+ // 5. Volume check
796
+ if (volume < 1) issues.push({ severity: 'error', check: 'volume', msg: 'Near-zero volume — possibly a surface, not a solid' });
797
+ else passed.push(`Volume: ${round(volume / 1000)}cm³ OK`);
798
+
799
+ // 6. Origin check (center of mass far from origin)
800
+ geo.computeBoundingBox();
801
+ const center = new THREE.Vector3();
802
+ geo.boundingBox.getCenter(center);
803
+ const distFromOrigin = center.length();
804
+ if (distFromOrigin > maxDim * 5) warnings.push({ severity: 'warn', check: 'origin', msg: `Part center is ${round(distFromOrigin)}mm from origin — may cause precision issues` });
805
+ else passed.push('Origin: part near world origin');
806
+
807
+ // Score
808
+ const score = issues.length === 0 && warnings.length === 0 ? 'A'
809
+ : issues.length === 0 && warnings.length <= 2 ? 'B'
810
+ : issues.length === 0 ? 'C'
811
+ : 'F';
812
+
813
+ return {
814
+ target,
815
+ score,
816
+ summary: `${passed.length} passed, ${warnings.length} warnings, ${issues.length} errors`,
817
+ passed,
818
+ warnings,
819
+ issues,
820
+ recommendation: issues.length > 0
821
+ ? 'Fix errors before manufacturing'
822
+ : warnings.length > 0
823
+ ? 'Review warnings — part may need adjustments'
824
+ : 'Design review passed — ready for manufacturing'
825
+ };
826
+ },
827
+
828
+ // ==========================================================================
829
+ // META — API info, health, schema
830
+ // ==========================================================================
831
+
832
+ 'meta.ping': () => {
833
+ return { pong: true, timestamp: Date.now(), session: sessionId };
834
+ },
835
+
836
+ 'meta.version': () => {
837
+ return {
838
+ product: 'cycleCAD',
839
+ tagline: 'The Agent-First OS for Manufacturing',
840
+ apiVersion: '1.0.0',
841
+ modules: ['sketch', 'operations', 'advanced-ops', 'export', 'viewport', 'validate'],
842
+ commandCount: Object.keys(COMMANDS).length
843
+ };
844
+ },
845
+
846
+ 'meta.schema': () => {
847
+ return getSchema();
848
+ },
849
+ };
850
+
851
+ // ============================================================================
852
+ // SCHEMA — self-describing API for agent discovery
853
+ // ============================================================================
854
+
855
+ export function getSchema() {
856
+ return {
857
+ description: 'cycleCAD Agent API — The Agent-First OS for Manufacturing',
858
+ version: '1.0.0',
859
+ protocol: 'JSON commands via window.cycleCAD.execute({ method, params })',
860
+ namespaces: {
861
+ sketch: {
862
+ description: '2D drawing on a construction plane',
863
+ methods: {
864
+ 'sketch.start': { params: { plane: 'XY|XZ|YZ' }, description: 'Start sketch mode' },
865
+ 'sketch.end': { params: {}, description: 'End sketch, return entities' },
866
+ 'sketch.line': { params: { x1: 'number', y1: 'number', x2: 'number', y2: 'number' }, description: 'Draw a line' },
867
+ 'sketch.rect': { params: { x: 'number?', y: 'number?', width: 'number', height: 'number' }, description: 'Draw a rectangle' },
868
+ 'sketch.circle': { params: { cx: 'number?', cy: 'number?', radius: 'number' }, description: 'Draw a circle' },
869
+ 'sketch.arc': { params: { cx: 'number?', cy: 'number?', radius: 'number', startAngle: 'number?', endAngle: 'number?' }, description: 'Draw an arc' },
870
+ 'sketch.clear': { params: {}, description: 'Clear all sketch entities' },
871
+ 'sketch.entities': { params: {}, description: 'List current sketch entities' },
872
+ }
873
+ },
874
+ ops: {
875
+ description: '3D modeling operations',
876
+ methods: {
877
+ 'ops.extrude': { params: { height: 'number', symmetric: 'bool?', material: 'string?' }, description: 'Extrude sketch into 3D solid' },
878
+ 'ops.revolve': { params: { axis: 'X|Y|Z?', angle: 'degrees?', material: 'string?' }, description: 'Revolve sketch around axis' },
879
+ 'ops.primitive': { params: { shape: 'box|sphere|cylinder|cone|torus', width: 'n?', height: 'n?', depth: 'n?', radius: 'n?', material: 'string?' }, description: 'Create a primitive shape' },
880
+ 'ops.fillet': { params: { target: 'featureId', radius: 'number?' }, description: 'Fillet edges' },
881
+ 'ops.chamfer': { params: { target: 'featureId', distance: 'number?' }, description: 'Chamfer edges' },
882
+ 'ops.boolean': { params: { operation: 'union|cut|intersect', targetA: 'featureId', targetB: 'featureId' }, description: 'Boolean operation' },
883
+ 'ops.shell': { params: { target: 'featureId', thickness: 'number?' }, description: 'Shell (hollow) a solid' },
884
+ 'ops.pattern': { params: { target: 'featureId', type: 'rect|circular?', count: 'number?', spacing: 'number?' }, description: 'Pattern repeat' },
885
+ 'ops.material': { params: { target: 'featureId', material: 'string' }, description: 'Change material' },
886
+ 'ops.sweep': { params: { profile: 'object', path: 'object', twist: 'number?', scale: 'number?' }, description: 'Sweep profile along path' },
887
+ 'ops.loft': { params: { profiles: 'array' }, description: 'Loft between profiles' },
888
+ 'ops.spring': { params: { radius: 'n?', wireRadius: 'n?', height: 'n?', turns: 'n?', material: 'string?' }, description: 'Generate a spring' },
889
+ 'ops.thread': { params: { outerRadius: 'n?', innerRadius: 'n?', pitch: 'n?', length: 'n?', material: 'string?' }, description: 'Generate a thread' },
890
+ 'ops.bend': { params: { target: 'featureId', angle: 'degrees?', radius: 'number?' }, description: 'Sheet metal bend' },
891
+ }
892
+ },
893
+ transform: {
894
+ description: 'Move, rotate, scale features',
895
+ methods: {
896
+ 'transform.move': { params: { target: 'featureId', x: 'n?', y: 'n?', z: 'n?' }, description: 'Translate' },
897
+ 'transform.rotate': { params: { target: 'featureId', x: 'deg?', y: 'deg?', z: 'deg?' }, description: 'Rotate' },
898
+ 'transform.scale': { params: { target: 'featureId', x: 'n?', y: 'n?', z: 'n?' }, description: 'Scale' },
899
+ }
900
+ },
901
+ view: {
902
+ description: 'Camera and display',
903
+ methods: {
904
+ 'view.set': { params: { view: 'front|back|left|right|top|bottom|isometric' }, description: 'Set camera view' },
905
+ 'view.fit': { params: { target: 'featureId?' }, description: 'Fit view to object' },
906
+ 'view.wireframe': { params: { enabled: 'bool?' }, description: 'Toggle wireframe' },
907
+ 'view.grid': { params: { visible: 'bool?' }, description: 'Toggle grid' },
908
+ }
909
+ },
910
+ export: {
911
+ description: 'Export model files',
912
+ methods: {
913
+ 'export.stl': { params: { filename: 'string?', binary: 'bool?' }, description: 'Export STL' },
914
+ 'export.obj': { params: { filename: 'string?' }, description: 'Export OBJ' },
915
+ 'export.gltf': { params: { filename: 'string?' }, description: 'Export glTF' },
916
+ 'export.json': { params: { filename: 'string?' }, description: 'Export cycleCAD JSON' },
917
+ }
918
+ },
919
+ validate: {
920
+ description: 'DFM checks, cost estimation, engineering analysis, design review',
921
+ methods: {
922
+ 'validate.dimensions': { params: { target: 'featureId' }, description: 'Get part dimensions' },
923
+ 'validate.wallThickness': { params: { target: 'featureId', minWall: 'mm?' }, description: 'Check wall thickness' },
924
+ 'validate.printability': { params: { target: 'featureId', process: 'FDM|SLA|CNC?' }, description: 'Check printability' },
925
+ 'validate.cost': { params: { target: 'featureId', process: 'FDM|SLA|CNC|injection?', material: 'string?' }, description: 'Estimate manufacturing cost' },
926
+ 'validate.mass': { params: { target: 'featureId', material: 'string?' }, description: 'Estimate part mass (weight)' },
927
+ 'validate.surfaceArea': { params: { target: 'featureId' }, description: 'Calculate surface area from mesh' },
928
+ 'validate.centerOfMass': { params: { target: 'featureId' }, description: 'Get center of mass (geometric centroid)' },
929
+ 'validate.designReview': { params: { target: 'featureId' }, description: 'Auto-analyze for manufacturing issues — scored A/B/C/F' },
930
+ }
931
+ },
932
+ render: {
933
+ description: 'Visual feedback loop — agents see what they built',
934
+ methods: {
935
+ 'render.snapshot': { params: { width: 'number?', height: 'number?' }, description: 'Render current view as PNG at specified resolution' },
936
+ 'render.multiview': { params: { width: 'number?', height: 'number?' }, description: 'Render 6 standard views (front/back/left/right/top/isometric) as PNGs' },
937
+ }
938
+ },
939
+ query: {
940
+ description: 'Inspect model state',
941
+ methods: {
942
+ 'query.features': { params: {}, description: 'List all features' },
943
+ 'query.bbox': { params: { target: 'featureId' }, description: 'Get bounding box' },
944
+ 'query.materials': { params: {}, description: 'List available materials' },
945
+ 'query.session': { params: {}, description: 'Session info' },
946
+ 'query.log': { params: { last: 'number?' }, description: 'Recent command log' },
947
+ }
948
+ },
949
+ scene: {
950
+ description: 'Scene management',
951
+ methods: {
952
+ 'scene.clear': { params: {}, description: 'Clear all features' },
953
+ 'scene.snapshot': { params: {}, description: 'Capture viewport as PNG (legacy — use render.snapshot)' },
954
+ }
955
+ },
956
+ meta: {
957
+ description: 'API info',
958
+ methods: {
959
+ 'meta.ping': { params: {}, description: 'Health check' },
960
+ 'meta.version': { params: {}, description: 'Version info' },
961
+ 'meta.schema': { params: {}, description: 'Full API schema' },
962
+ }
963
+ }
964
+ },
965
+ example: {
966
+ description: 'Design a bracket with 4 bolt holes',
967
+ commands: [
968
+ { method: 'sketch.start', params: { plane: 'XY' } },
969
+ { method: 'sketch.rect', params: { width: 80, height: 40 } },
970
+ { method: 'ops.extrude', params: { height: 5, material: 'aluminum' } },
971
+ { method: 'validate.printability', params: { target: 'extrude_1', process: 'CNC' } },
972
+ { method: 'validate.cost', params: { target: 'extrude_1', process: 'CNC', material: 'aluminum' } },
973
+ { method: 'export.stl', params: { filename: 'bracket.stl' } },
974
+ ]
975
+ }
976
+ };
977
+ }
978
+
979
+ // ============================================================================
980
+ // SESSION STATE
981
+ // ============================================================================
982
+
983
+ export function getState() {
984
+ return {
985
+ session: sessionId,
986
+ featureCount: getAllFeatures().length,
987
+ commandCount: commandLog.length,
988
+ features: getAllFeatures().map(f => ({
989
+ id: f.id, type: f.type, bbox: f.mesh ? getBBox(f.mesh) : null
990
+ }))
991
+ };
992
+ }
993
+
994
+ export function getSession() {
995
+ return {
996
+ sessionId,
997
+ commandsExecuted: commandLog.length,
998
+ featureCount: getAllFeatures().length,
999
+ uptime: Math.round(performance.now() / 1000),
1000
+ api: `window.cycleCAD.execute({ method: "meta.ping" })`
1001
+ };
1002
+ }
1003
+
1004
+ // ============================================================================
1005
+ // UTILS
1006
+ // ============================================================================
1007
+
1008
+ function requireAll(params) {
1009
+ for (const [key, val] of Object.entries(params)) {
1010
+ if (val === undefined || val === null) {
1011
+ throw new Error(`Required parameter "${key}" is missing`);
1012
+ }
1013
+ }
1014
+ }
1015
+
1016
+ function getBBox(mesh) {
1017
+ const box = new THREE.Box3().setFromObject(mesh);
1018
+ const size = new THREE.Vector3();
1019
+ box.getSize(size);
1020
+ const center = new THREE.Vector3();
1021
+ box.getCenter(center);
1022
+ return {
1023
+ width: round(size.x),
1024
+ height: round(size.y),
1025
+ depth: round(size.z),
1026
+ center: [round(center.x), round(center.y), round(center.z)],
1027
+ min: [round(box.min.x), round(box.min.y), round(box.min.z)],
1028
+ max: [round(box.max.x), round(box.max.y), round(box.max.z)]
1029
+ };
1030
+ }
1031
+
1032
+ function round(n) { return Math.round(n * 100) / 100; }
1033
+
1034
+ function summarizeEntity(e) {
1035
+ const s = { type: e.type, id: e.id };
1036
+ if (e.type === 'line') { s.from = [e.x1, e.y1]; s.to = [e.x2, e.y2]; }
1037
+ if (e.type === 'circle') { s.center = [e.cx, e.cy]; s.radius = e.radius; }
1038
+ if (e.type === 'arc') { s.center = [e.cx, e.cy]; s.radius = e.radius; }
1039
+ return s;
1040
+ }
1041
+
1042
+ function addFeature(id, type, mesh, params) {
1043
+ if (_appState.addFeature) {
1044
+ _appState.addFeature({ id, type, name: id, mesh, params });
1045
+ } else if (_appState.features) {
1046
+ _appState.features.push({ id, type, name: id, mesh, params });
1047
+ }
1048
+ }