dsh-cad 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -151,248 +151,248 @@ export const FUSION360_EXECUTOR = {
151
151
  * booleans + transforms; sketch-based extrusion; MeshManager tessellation;
152
152
  * STEP/STL export via the ExportManager.
153
153
  */
154
- const BRIDGE_ADDIN = String.raw `
155
- import json
156
- import math
157
- import os
158
- import traceback
159
-
160
- import adsk.core
161
- import adsk.fusion
162
-
163
- SPOOL = os.path.join(os.path.expanduser('~'), '.dsh-cad', 'fusion-spool')
164
- POLL_SECONDS = 0.5
165
-
166
-
167
- def log(message):
168
- try:
169
- with open(os.path.join(SPOOL, 'bridge.log'), 'a') as handle:
170
- handle.write(message + '\n')
171
- except Exception:
172
- pass
173
-
174
-
175
- def vector(values, default=(0.0, 0.0, 0.0)):
176
- if not values:
177
- return adsk.core.Vector3D.create(*default)
178
- return adsk.core.Vector3D.create(float(values[0]), float(values[1]), float(values[2]))
179
-
180
-
181
- class Runner:
182
- def __init__(self, app):
183
- self.app = app
184
- self.temp_brep = adsk.core.TemporaryBRepManager.get()
185
- self.design = None
186
-
187
- def document(self):
188
- doc = self.app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType)
189
- doc.activate()
190
- self.design = adsk.fusion.Design.cast(doc.products.itemByProductType('DesignProductType'))
191
- self.design.designType = adsk.fusion.DesignTypes.DirectDesignType
192
- return doc
193
-
194
- def create_prim(self, prim, params):
195
- params = params or {}
196
- at = params.get('at')
197
- base = vector(at)
198
- if prim == 'box':
199
- dx, dy, dz = float(params.get('dx', 10)), float(params.get('dy', 10)), float(params.get('dz', 10))
200
- # NOTE: verify the width/length/height argument order against the
201
- # local Fusion API docs on the first live run.
202
- return self.temp_brep.createBox(adsk.core.OrientedBoundingBox3D.create(base, adsk.core.Vector3D.create(1, 0, 0), adsk.core.Vector3D.create(0, 1, 0), dx, dy, dz))
203
- if prim == 'cylinder':
204
- height = float(params.get('height', 10))
205
- return self.temp_brep.createCylinder(adsk.core.InputCylinder.create(base, vector(params.get('axis'), (0, 0, 1)), float(params.get('radius', 5)), height))
206
- if prim == 'sphere':
207
- return self.temp_brep.createSphere(adsk.core.InputSphere.create(base, float(params.get('radius', 5))))
208
- if prim == 'cone':
209
- return self.temp_brep.createCone(adsk.core.InputCone.create(base, vector(params.get('axis'), (0, 0, 1)), float(params.get('radius1', 5)), float(params.get('radius2', 0)), float(params.get('height', 10))))
210
- if prim == 'torus':
211
- return self.temp_brep.createTorus(adsk.core.InputTorus.create(base, vector(params.get('axis'), (0, 0, 1)), float(params.get('majorRadius', 10)), float(params.get('minorRadius', 2))))
212
- raise ValueError('unknown primitive: %s' % prim)
213
-
214
- def boolean(self, op, target, tool):
215
- if op == 'cut':
216
- kind = adsk.core.BooleanTypes.DifferenceBooleanType
217
- elif op == 'fuse':
218
- kind = adsk.core.BooleanTypes.UnionBooleanType
219
- else:
220
- kind = adsk.core.BooleanTypes.IntersectionBooleanType
221
- self.temp_brep.booleanOperation(target, tool, kind)
222
-
223
- def transform(self, body, op):
224
- if op.get('translate'):
225
- t = vector(op['translate'])
226
- m = adsk.core.Matrix3D.create()
227
- m.translation = t
228
- self.temp_brep.transform(body, m)
229
- rotate = op.get('rotate')
230
- if rotate:
231
- m = adsk.core.Matrix3D.create()
232
- for axis, angle in ((adsk.core.Vector3D.create(1, 0, 0), rotate[0]), (adsk.core.Vector3D.create(0, 1, 0), rotate[1]), (adsk.core.Vector3D.create(0, 0, 1), rotate[2])):
233
- if abs(float(angle)) > 1e-9:
234
- m.setToRotation(math.radians(float(angle)), axis, adsk.core.Point3D.create(0, 0, 0))
235
- self.temp_brep.transform(body, m)
236
-
237
- def extrude(self, points, height):
238
- root = self.design.rootComponent
239
- sketch = root.sketches.add(root.xYConstructionPlane)
240
- pts = []
241
- for i in range(0, len(points) - 1, 2):
242
- pts.append(adsk.core.Point3D.create(float(points[i]), float(points[i + 1]), 0))
243
- if len(pts) < 3:
244
- raise ValueError('profile needs at least 3 points')
245
- if (pts[-1].distanceTo(pts[0])) > 1e-9:
246
- pts.append(pts[0])
247
- lines = sketch.sketchCurves.sketchLines
248
- for a, b in zip(pts, pts[1:]):
249
- lines.addByTwoPoints(a, b)
250
- profile = sketch.profiles.item(0)
251
- extrude_input = root.features.extrudeFeatures.createInput(profile, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)
252
- distance = adsk.fusion.DistanceExtentDefinition.create(adsk.core.ValueInput.createByReal(float(height or 10)))
253
- extrude_input.setOneSideExtent(distance, adsk.fusion.ExtentDirections.PositiveExtentDirection)
254
- feature = root.features.extrudeFeatures.add(extrude_input)
255
- body = feature.bodies.item(0)
256
- return self.temp_brep.copy(body)
257
-
258
- def show(self, bodies, names):
259
- root = self.design.rootComponent
260
- occurrence = root
261
- for body_id, body in bodies.items():
262
- feature = occurrence.features.baseFeatures.add()
263
- feature.startEdit()
264
- occurrence.bRepBodies.add(body, feature, names.get(body_id, body_id))
265
- feature.finishEdit()
266
-
267
- def tessellate(self, bodies, names):
268
- root = self.design.rootComponent
269
- meshes = []
270
- volumes = {}
271
- for body_id, body in bodies.items():
272
- feature = root.features.baseFeatures.add()
273
- feature.startEdit()
274
- shown = root.bRepBodies.add(body, feature, names.get(body_id, body_id))
275
- feature.finishEdit()
276
- mesh_manager = shown.meshManager
277
- mesh = mesh_manager.calculateMeshData(adsk.fusion.MeshCalculationOptions.create())
278
- coordinates = mesh.nodeCoordinatesAsArray
279
- triangles = mesh.triangleVerticesAsArray
280
- positions = []
281
- for point in coordinates:
282
- positions.extend([point.x, point.y, point.z])
283
- indices = [int(index) for index in triangles]
284
- normals = []
285
- for i in range(0, len(indices), 3):
286
- a, b, c = coordinates[indices[i]], coordinates[indices[i + 1]], coordinates[indices[i + 2]]
287
- ux, uy, uz = b.x - a.x, b.y - a.y, b.z - a.z
288
- vx, vy, vz = c.x - a.x, c.y - a.y, c.z - a.z
289
- nx, ny, nz = uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx
290
- length = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
291
- normals.extend([nx / length, ny / length, nz / length] * 3)
292
- volumes[body_id] = abs(sum(
293
- (coordinates[indices[i]].x * (coordinates[indices[i + 1]].y * coordinates[indices[i + 2]].z - coordinates[indices[i + 1]].z * coordinates[indices[i + 2]].y)
294
- - coordinates[indices[i]].y * (coordinates[indices[i + 1]].x * coordinates[indices[i + 2]].z - coordinates[indices[i + 1]].z * coordinates[indices[i + 2]].x)
295
- + coordinates[indices[i]].z * (coordinates[indices[i + 1]].x * coordinates[indices[i + 2]].y - coordinates[indices[i + 1]].y * coordinates[indices[i + 2]].x)) / 6.0
296
- for i in range(0, len(indices), 3)))
297
- meshes.append({
298
- 'bodyId': body_id,
299
- 'name': names.get(body_id, body_id),
300
- 'positions': positions,
301
- 'normals': normals,
302
- 'indices': indices,
303
- 'vertexCount': len(coordinates),
304
- 'triangleCount': len(indices) // 3,
305
- })
306
- return meshes, volumes
307
-
308
- def export(self, bodies, spec):
309
- doc = self.app.activeDocument
310
- if doc.isModified:
311
- doc.saveAs(spec['path'])
312
- return spec['path']
313
-
314
-
315
- def execute(app, program):
316
- runner = Runner(app)
317
- runner.document()
318
- bodies = {}
319
- volumes = {}
320
- for op in program.get('ops', []):
321
- kind = op['kind']
322
- if kind == 'reset':
323
- bodies = {}
324
- elif kind == 'create_prim':
325
- bodies[op['bodyId']] = runner.create_prim(op['prim'], op.get('params'))
326
- elif kind == 'extrude_profile':
327
- bodies[op['bodyId']] = runner.extrude(op['points'], op.get('height'))
328
- elif kind == 'boolean':
329
- target = bodies[op['target']]
330
- for tool_id in op['tools']:
331
- runner.boolean(op['op'], target, bodies[tool_id])
332
- del bodies[tool_id]
333
- bodies[op['target']] = target
334
- elif kind == 'transform':
335
- runner.transform(bodies[op['target']], op)
336
- elif kind == 'volume':
337
- pass # volumes derive from the tessellation below
338
- elif kind == 'delete':
339
- bodies.pop(op['target'], None)
340
- else:
341
- raise ValueError('unsupported op: %s' % kind)
342
- names = program.get('names') or {}
343
- meshes, volumes = runner.tessellate(bodies, names)
344
- result = {'ok': True, 'meshes': meshes, 'volumes': volumes}
345
- export = program.get('export')
346
- if export and meshes:
347
- result['exported'] = runner.export(bodies, export)
348
- return result
349
-
350
-
351
- def process_job(app, job_path):
352
- result_path = job_path.replace('.running', '').replace('.json', '') + '.result.json'
353
- try:
354
- with open(job_path) as handle:
355
- job = json.load(handle)
356
- # Claim semantics: remove the job file before executing so a spool
357
- # rename on the host side cannot re-trigger the same job.
358
- try:
359
- os.remove(job_path)
360
- except Exception:
361
- pass
362
- result = execute(app, job.get('program', {}))
363
- except Exception as error:
364
- result = {'ok': False, 'error': str(error), 'trace': traceback.format_exc()}
365
- try:
366
- with open(result_path, 'w') as handle:
367
- json.dump(result, handle)
368
- except Exception:
369
- pass
370
-
371
-
372
- def run(context):
373
- app = adsk.core.Application.get()
374
- ui = app.userInterface
375
- try:
376
- os.makedirs(SPOOL, exist_ok=True)
377
- log('bridge loaded')
378
-
379
- class PollHandler(adsk.core.CustomEventHandler):
380
- def notify(self, args):
381
- try:
382
- for name in sorted(os.listdir(SPOOL)):
383
- if name.endswith('.json') or name.endswith('.running'):
384
- process_job(app, os.path.join(SPOOL, name))
385
- break
386
- except Exception:
387
- log(traceback.format_exc())
388
-
389
- # One recurring timer: its firing CustomEvent drives the spool poll,
390
- # keeping the bridge resident without any UI.
391
- timer = adsk.core.Timer.create()
392
- timer.duration = POLL_SECONDS
393
- timer.firing.add(PollHandler())
394
- timer.isRunning = True
395
- except Exception:
396
- if ui:
397
- ui.messageBox('dsh-cad bridge failed to start:\n{}'.format(traceback.format_exc()))
154
+ const BRIDGE_ADDIN = String.raw `
155
+ import json
156
+ import math
157
+ import os
158
+ import traceback
159
+
160
+ import adsk.core
161
+ import adsk.fusion
162
+
163
+ SPOOL = os.path.join(os.path.expanduser('~'), '.dsh-cad', 'fusion-spool')
164
+ POLL_SECONDS = 0.5
165
+
166
+
167
+ def log(message):
168
+ try:
169
+ with open(os.path.join(SPOOL, 'bridge.log'), 'a') as handle:
170
+ handle.write(message + '\n')
171
+ except Exception:
172
+ pass
173
+
174
+
175
+ def vector(values, default=(0.0, 0.0, 0.0)):
176
+ if not values:
177
+ return adsk.core.Vector3D.create(*default)
178
+ return adsk.core.Vector3D.create(float(values[0]), float(values[1]), float(values[2]))
179
+
180
+
181
+ class Runner:
182
+ def __init__(self, app):
183
+ self.app = app
184
+ self.temp_brep = adsk.core.TemporaryBRepManager.get()
185
+ self.design = None
186
+
187
+ def document(self):
188
+ doc = self.app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType)
189
+ doc.activate()
190
+ self.design = adsk.fusion.Design.cast(doc.products.itemByProductType('DesignProductType'))
191
+ self.design.designType = adsk.fusion.DesignTypes.DirectDesignType
192
+ return doc
193
+
194
+ def create_prim(self, prim, params):
195
+ params = params or {}
196
+ at = params.get('at')
197
+ base = vector(at)
198
+ if prim == 'box':
199
+ dx, dy, dz = float(params.get('dx', 10)), float(params.get('dy', 10)), float(params.get('dz', 10))
200
+ # NOTE: verify the width/length/height argument order against the
201
+ # local Fusion API docs on the first live run.
202
+ return self.temp_brep.createBox(adsk.core.OrientedBoundingBox3D.create(base, adsk.core.Vector3D.create(1, 0, 0), adsk.core.Vector3D.create(0, 1, 0), dx, dy, dz))
203
+ if prim == 'cylinder':
204
+ height = float(params.get('height', 10))
205
+ return self.temp_brep.createCylinder(adsk.core.InputCylinder.create(base, vector(params.get('axis'), (0, 0, 1)), float(params.get('radius', 5)), height))
206
+ if prim == 'sphere':
207
+ return self.temp_brep.createSphere(adsk.core.InputSphere.create(base, float(params.get('radius', 5))))
208
+ if prim == 'cone':
209
+ return self.temp_brep.createCone(adsk.core.InputCone.create(base, vector(params.get('axis'), (0, 0, 1)), float(params.get('radius1', 5)), float(params.get('radius2', 0)), float(params.get('height', 10))))
210
+ if prim == 'torus':
211
+ return self.temp_brep.createTorus(adsk.core.InputTorus.create(base, vector(params.get('axis'), (0, 0, 1)), float(params.get('majorRadius', 10)), float(params.get('minorRadius', 2))))
212
+ raise ValueError('unknown primitive: %s' % prim)
213
+
214
+ def boolean(self, op, target, tool):
215
+ if op == 'cut':
216
+ kind = adsk.core.BooleanTypes.DifferenceBooleanType
217
+ elif op == 'fuse':
218
+ kind = adsk.core.BooleanTypes.UnionBooleanType
219
+ else:
220
+ kind = adsk.core.BooleanTypes.IntersectionBooleanType
221
+ self.temp_brep.booleanOperation(target, tool, kind)
222
+
223
+ def transform(self, body, op):
224
+ if op.get('translate'):
225
+ t = vector(op['translate'])
226
+ m = adsk.core.Matrix3D.create()
227
+ m.translation = t
228
+ self.temp_brep.transform(body, m)
229
+ rotate = op.get('rotate')
230
+ if rotate:
231
+ m = adsk.core.Matrix3D.create()
232
+ for axis, angle in ((adsk.core.Vector3D.create(1, 0, 0), rotate[0]), (adsk.core.Vector3D.create(0, 1, 0), rotate[1]), (adsk.core.Vector3D.create(0, 0, 1), rotate[2])):
233
+ if abs(float(angle)) > 1e-9:
234
+ m.setToRotation(math.radians(float(angle)), axis, adsk.core.Point3D.create(0, 0, 0))
235
+ self.temp_brep.transform(body, m)
236
+
237
+ def extrude(self, points, height):
238
+ root = self.design.rootComponent
239
+ sketch = root.sketches.add(root.xYConstructionPlane)
240
+ pts = []
241
+ for i in range(0, len(points) - 1, 2):
242
+ pts.append(adsk.core.Point3D.create(float(points[i]), float(points[i + 1]), 0))
243
+ if len(pts) < 3:
244
+ raise ValueError('profile needs at least 3 points')
245
+ if (pts[-1].distanceTo(pts[0])) > 1e-9:
246
+ pts.append(pts[0])
247
+ lines = sketch.sketchCurves.sketchLines
248
+ for a, b in zip(pts, pts[1:]):
249
+ lines.addByTwoPoints(a, b)
250
+ profile = sketch.profiles.item(0)
251
+ extrude_input = root.features.extrudeFeatures.createInput(profile, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)
252
+ distance = adsk.fusion.DistanceExtentDefinition.create(adsk.core.ValueInput.createByReal(float(height or 10)))
253
+ extrude_input.setOneSideExtent(distance, adsk.fusion.ExtentDirections.PositiveExtentDirection)
254
+ feature = root.features.extrudeFeatures.add(extrude_input)
255
+ body = feature.bodies.item(0)
256
+ return self.temp_brep.copy(body)
257
+
258
+ def show(self, bodies, names):
259
+ root = self.design.rootComponent
260
+ occurrence = root
261
+ for body_id, body in bodies.items():
262
+ feature = occurrence.features.baseFeatures.add()
263
+ feature.startEdit()
264
+ occurrence.bRepBodies.add(body, feature, names.get(body_id, body_id))
265
+ feature.finishEdit()
266
+
267
+ def tessellate(self, bodies, names):
268
+ root = self.design.rootComponent
269
+ meshes = []
270
+ volumes = {}
271
+ for body_id, body in bodies.items():
272
+ feature = root.features.baseFeatures.add()
273
+ feature.startEdit()
274
+ shown = root.bRepBodies.add(body, feature, names.get(body_id, body_id))
275
+ feature.finishEdit()
276
+ mesh_manager = shown.meshManager
277
+ mesh = mesh_manager.calculateMeshData(adsk.fusion.MeshCalculationOptions.create())
278
+ coordinates = mesh.nodeCoordinatesAsArray
279
+ triangles = mesh.triangleVerticesAsArray
280
+ positions = []
281
+ for point in coordinates:
282
+ positions.extend([point.x, point.y, point.z])
283
+ indices = [int(index) for index in triangles]
284
+ normals = []
285
+ for i in range(0, len(indices), 3):
286
+ a, b, c = coordinates[indices[i]], coordinates[indices[i + 1]], coordinates[indices[i + 2]]
287
+ ux, uy, uz = b.x - a.x, b.y - a.y, b.z - a.z
288
+ vx, vy, vz = c.x - a.x, c.y - a.y, c.z - a.z
289
+ nx, ny, nz = uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx
290
+ length = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
291
+ normals.extend([nx / length, ny / length, nz / length] * 3)
292
+ volumes[body_id] = abs(sum(
293
+ (coordinates[indices[i]].x * (coordinates[indices[i + 1]].y * coordinates[indices[i + 2]].z - coordinates[indices[i + 1]].z * coordinates[indices[i + 2]].y)
294
+ - coordinates[indices[i]].y * (coordinates[indices[i + 1]].x * coordinates[indices[i + 2]].z - coordinates[indices[i + 1]].z * coordinates[indices[i + 2]].x)
295
+ + coordinates[indices[i]].z * (coordinates[indices[i + 1]].x * coordinates[indices[i + 2]].y - coordinates[indices[i + 1]].y * coordinates[indices[i + 2]].x)) / 6.0
296
+ for i in range(0, len(indices), 3)))
297
+ meshes.append({
298
+ 'bodyId': body_id,
299
+ 'name': names.get(body_id, body_id),
300
+ 'positions': positions,
301
+ 'normals': normals,
302
+ 'indices': indices,
303
+ 'vertexCount': len(coordinates),
304
+ 'triangleCount': len(indices) // 3,
305
+ })
306
+ return meshes, volumes
307
+
308
+ def export(self, bodies, spec):
309
+ doc = self.app.activeDocument
310
+ if doc.isModified:
311
+ doc.saveAs(spec['path'])
312
+ return spec['path']
313
+
314
+
315
+ def execute(app, program):
316
+ runner = Runner(app)
317
+ runner.document()
318
+ bodies = {}
319
+ volumes = {}
320
+ for op in program.get('ops', []):
321
+ kind = op['kind']
322
+ if kind == 'reset':
323
+ bodies = {}
324
+ elif kind == 'create_prim':
325
+ bodies[op['bodyId']] = runner.create_prim(op['prim'], op.get('params'))
326
+ elif kind == 'extrude_profile':
327
+ bodies[op['bodyId']] = runner.extrude(op['points'], op.get('height'))
328
+ elif kind == 'boolean':
329
+ target = bodies[op['target']]
330
+ for tool_id in op['tools']:
331
+ runner.boolean(op['op'], target, bodies[tool_id])
332
+ del bodies[tool_id]
333
+ bodies[op['target']] = target
334
+ elif kind == 'transform':
335
+ runner.transform(bodies[op['target']], op)
336
+ elif kind == 'volume':
337
+ pass # volumes derive from the tessellation below
338
+ elif kind == 'delete':
339
+ bodies.pop(op['target'], None)
340
+ else:
341
+ raise ValueError('unsupported op: %s' % kind)
342
+ names = program.get('names') or {}
343
+ meshes, volumes = runner.tessellate(bodies, names)
344
+ result = {'ok': True, 'meshes': meshes, 'volumes': volumes}
345
+ export = program.get('export')
346
+ if export and meshes:
347
+ result['exported'] = runner.export(bodies, export)
348
+ return result
349
+
350
+
351
+ def process_job(app, job_path):
352
+ result_path = job_path.replace('.running', '').replace('.json', '') + '.result.json'
353
+ try:
354
+ with open(job_path) as handle:
355
+ job = json.load(handle)
356
+ # Claim semantics: remove the job file before executing so a spool
357
+ # rename on the host side cannot re-trigger the same job.
358
+ try:
359
+ os.remove(job_path)
360
+ except Exception:
361
+ pass
362
+ result = execute(app, job.get('program', {}))
363
+ except Exception as error:
364
+ result = {'ok': False, 'error': str(error), 'trace': traceback.format_exc()}
365
+ try:
366
+ with open(result_path, 'w') as handle:
367
+ json.dump(result, handle)
368
+ except Exception:
369
+ pass
370
+
371
+
372
+ def run(context):
373
+ app = adsk.core.Application.get()
374
+ ui = app.userInterface
375
+ try:
376
+ os.makedirs(SPOOL, exist_ok=True)
377
+ log('bridge loaded')
378
+
379
+ class PollHandler(adsk.core.CustomEventHandler):
380
+ def notify(self, args):
381
+ try:
382
+ for name in sorted(os.listdir(SPOOL)):
383
+ if name.endswith('.json') or name.endswith('.running'):
384
+ process_job(app, os.path.join(SPOOL, name))
385
+ break
386
+ except Exception:
387
+ log(traceback.format_exc())
388
+
389
+ # One recurring timer: its firing CustomEvent drives the spool poll,
390
+ # keeping the bridge resident without any UI.
391
+ timer = adsk.core.Timer.create()
392
+ timer.duration = POLL_SECONDS
393
+ timer.firing.add(PollHandler())
394
+ timer.isRunning = True
395
+ except Exception:
396
+ if ui:
397
+ ui.messageBox('dsh-cad bridge failed to start:\n{}'.format(traceback.format_exc()))
398
398
  `;