dsh-cad 0.2.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.
Files changed (113) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +190 -0
  3. package/README.zh-CN.md +173 -0
  4. package/cordis.patch.yml +3 -0
  5. package/lib/b64.js +12 -0
  6. package/lib/cad_connector/builtin-executor.js +55 -0
  7. package/lib/cad_connector/builtin.js +8 -0
  8. package/lib/cad_connector/executor.js +76 -0
  9. package/lib/cad_connector/freecad-executor.js +398 -0
  10. package/lib/cad_connector/freecad.js +8 -0
  11. package/lib/cad_connector/fusion360-executor.js +398 -0
  12. package/lib/cad_connector/fusion360.js +8 -0
  13. package/lib/cad_connector/gstarcad3d.js +8 -0
  14. package/lib/cad_connector/index.js +20 -0
  15. package/lib/cad_connector/onshape.js +8 -0
  16. package/lib/cad_connector/solidworks.js +8 -0
  17. package/lib/cad_connector/types.js +6 -0
  18. package/lib/cad_connector/zw3d.js +8 -0
  19. package/lib/client.js +4117 -0
  20. package/lib/client.js.map +7 -0
  21. package/lib/convert/dcprt.js +38 -0
  22. package/lib/convert/detect.js +22 -0
  23. package/lib/convert/dxf.js +124 -0
  24. package/lib/convert/index.js +70 -0
  25. package/lib/convert/obj.js +106 -0
  26. package/lib/convert/png.js +114 -0
  27. package/lib/convert/step-worker.mjs +61 -0
  28. package/lib/convert/step.js +66 -0
  29. package/lib/convert/stl.js +110 -0
  30. package/lib/convert/svg.js +101 -0
  31. package/lib/demo-bracket.brep +668 -0
  32. package/lib/demo-flange.brep +709 -0
  33. package/lib/demo-shaft.brep +516 -0
  34. package/lib/feature_script/dc_asm.js +17 -0
  35. package/lib/feature_script/dc_engineering.js +19 -0
  36. package/lib/feature_script/dc_prt.js +21 -0
  37. package/lib/feature_script/index.js +7 -0
  38. package/lib/image/profile.js +218 -0
  39. package/lib/index.js +77 -0
  40. package/lib/modeling/assembly.js +63 -0
  41. package/lib/modeling/bin-format.js +80 -0
  42. package/lib/modeling/bin-store.js +67 -0
  43. package/lib/modeling/client.js +117 -0
  44. package/lib/modeling/document.js +55 -0
  45. package/lib/modeling/drawing.js +324 -0
  46. package/lib/modeling/hlr.cjs +328 -0
  47. package/lib/modeling/modeling-worker.cjs +235 -0
  48. package/lib/modeling/occt-adapter.cjs +271 -0
  49. package/lib/routes.js +142 -0
  50. package/lib/store.js +68 -0
  51. package/lib/tools/cad-freecad.js +160 -0
  52. package/lib/tools/cad-fusion.js +138 -0
  53. package/lib/tools/cad-image.js +121 -0
  54. package/lib/tools/cad-info.js +92 -0
  55. package/lib/tools/cad-model.js +816 -0
  56. package/lib/tools/cad-view.js +112 -0
  57. package/lib/tools/util.js +25 -0
  58. package/lib/types/b64.d.ts +8 -0
  59. package/lib/types/cad/builtin.d.ts +3 -0
  60. package/lib/types/cad/freecad.d.ts +3 -0
  61. package/lib/types/cad/fusion360.d.ts +3 -0
  62. package/lib/types/cad/gstarcad3d.d.ts +3 -0
  63. package/lib/types/cad/index.d.ts +8 -0
  64. package/lib/types/cad/onshape.d.ts +3 -0
  65. package/lib/types/cad/solidworks.d.ts +3 -0
  66. package/lib/types/cad/types.d.ts +21 -0
  67. package/lib/types/cad/zw3d.d.ts +3 -0
  68. package/lib/types/cad_connector/builtin-executor.d.ts +9 -0
  69. package/lib/types/cad_connector/builtin.d.ts +3 -0
  70. package/lib/types/cad_connector/executor.d.ts +69 -0
  71. package/lib/types/cad_connector/freecad-executor.d.ts +55 -0
  72. package/lib/types/cad_connector/freecad.d.ts +3 -0
  73. package/lib/types/cad_connector/fusion360-executor.d.ts +11 -0
  74. package/lib/types/cad_connector/fusion360.d.ts +3 -0
  75. package/lib/types/cad_connector/gstarcad3d.d.ts +3 -0
  76. package/lib/types/cad_connector/index.d.ts +8 -0
  77. package/lib/types/cad_connector/onshape.d.ts +3 -0
  78. package/lib/types/cad_connector/solidworks.d.ts +3 -0
  79. package/lib/types/cad_connector/types.d.ts +21 -0
  80. package/lib/types/cad_connector/zw3d.d.ts +3 -0
  81. package/lib/types/convert/dcprt.d.ts +14 -0
  82. package/lib/types/convert/detect.d.ts +7 -0
  83. package/lib/types/convert/dxf.d.ts +3 -0
  84. package/lib/types/convert/index.d.ts +9 -0
  85. package/lib/types/convert/obj.d.ts +7 -0
  86. package/lib/types/convert/png.d.ts +8 -0
  87. package/lib/types/convert/step.d.ts +4 -0
  88. package/lib/types/convert/stl.d.ts +7 -0
  89. package/lib/types/convert/svg.d.ts +8 -0
  90. package/lib/types/feature_script/dc_asm.d.ts +40 -0
  91. package/lib/types/feature_script/dc_engineering.d.ts +55 -0
  92. package/lib/types/feature_script/dc_prt.d.ts +37 -0
  93. package/lib/types/feature_script/index.d.ts +7 -0
  94. package/lib/types/image/profile.d.ts +28 -0
  95. package/lib/types/index.d.ts +18 -0
  96. package/lib/types/modeling/assembly.d.ts +17 -0
  97. package/lib/types/modeling/bin-format.d.ts +42 -0
  98. package/lib/types/modeling/bin-store.d.ts +20 -0
  99. package/lib/types/modeling/client.d.ts +125 -0
  100. package/lib/types/modeling/document.d.ts +26 -0
  101. package/lib/types/modeling/drawing.d.ts +38 -0
  102. package/lib/types/routes.d.ts +27 -0
  103. package/lib/types/store.d.ts +18 -0
  104. package/lib/types/tools/cad-freecad.d.ts +10 -0
  105. package/lib/types/tools/cad-fusion.d.ts +8 -0
  106. package/lib/types/tools/cad-image.d.ts +8 -0
  107. package/lib/types/tools/cad-info.d.ts +6 -0
  108. package/lib/types/tools/cad-model.d.ts +13 -0
  109. package/lib/types/tools/cad-view.d.ts +10 -0
  110. package/lib/types/tools/util.d.ts +4 -0
  111. package/lib/types/types.d.ts +142 -0
  112. package/lib/types.js +25 -0
  113. package/package.json +91 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * GeometryExecutor — the unified backend access contract.
3
+ *
4
+ * The display layer is one WebGL pipeline and never changes; what varies is
5
+ * HOW geometry is produced. Every backend (the built-in OCCT/WASM kernel,
6
+ * FreeCAD, Fusion 360, a future SolidWorks bridge…) implements this one
7
+ * interface over the same op program shape, so swapping executors changes
8
+ * only the quality of the produced geometry — never the frontend.
9
+ */
10
+ // ── LLM op normalization (executor-agnostic) ────────────────────────────────
11
+ const OP_KINDS = new Set(['create_prim', 'extrude_profile', 'boolean', 'fillet', 'transform', 'volume', 'delete', 'reset']);
12
+ const BOOLEANS = new Set(['cut', 'fuse', 'common']);
13
+ const PRIMITIVES = new Set(['box', 'cylinder', 'sphere', 'cone', 'torus']);
14
+ const PRIM_FIELDS = ['dx', 'dy', 'dz', 'radius', 'radius1', 'radius2', 'height', 'majorRadius', 'minorRadius', 'at', 'axis'];
15
+ /**
16
+ * LLM callers emit op shapes liberally. Normalize the common variants onto
17
+ * the canonical op shape before validation/execution:
18
+ * - {op:"cut", target, tools} → {kind:"boolean", op:"cut", ...}
19
+ * - {op:"create_prim", kind:"box", dx:...} → {kind:"create_prim", prim:"box", params:{dx:...}}
20
+ * - {kind:"create_prim", prim, dx:...} → params folded in
21
+ */
22
+ export function normalizeOps(raw) {
23
+ return raw.map((step) => {
24
+ if (typeof step !== 'object' || step === null)
25
+ return step;
26
+ const source = { ...step };
27
+ // {op:"cut"|"fuse"|"common", ...} without a kind
28
+ if (typeof source.op === 'string' && BOOLEANS.has(source.op) && source.kind === undefined) {
29
+ return { kind: 'boolean', op: source.op, target: source.target, tools: source.tools };
30
+ }
31
+ // {op:"<opKind>", ...} — op as the discriminator (kind may name the primitive)
32
+ if (typeof source.op === 'string' && OP_KINDS.has(source.op) && !OP_KINDS.has(source.kind)) {
33
+ const kind = source.op;
34
+ delete source.op;
35
+ if (kind === 'create_prim') {
36
+ const prim = typeof source.prim === 'string' ? source.prim : PRIMITIVES.has(source.kind) ? source.kind : undefined;
37
+ if (source.kind !== undefined && !OP_KINDS.has(source.kind))
38
+ delete source.kind;
39
+ if (prim !== undefined)
40
+ source.prim = prim;
41
+ }
42
+ source.kind = kind;
43
+ }
44
+ // fold inline primitive fields into params
45
+ if (source.kind === 'create_prim') {
46
+ const params = typeof source.params === 'object' && source.params !== null ? { ...source.params } : {};
47
+ let folded = false;
48
+ for (const field of PRIM_FIELDS) {
49
+ if (source[field] !== undefined) {
50
+ params[field] = source[field];
51
+ delete source[field];
52
+ folded = true;
53
+ }
54
+ }
55
+ if (folded)
56
+ source.params = params;
57
+ }
58
+ return source;
59
+ });
60
+ }
61
+ /** Op discriminators the canonical program accepts. */
62
+ export function isKnownOpKind(kind) {
63
+ return typeof kind === 'string' && OP_KINDS.has(kind);
64
+ }
65
+ /** Registry of the executors compiled into this build (display order). */
66
+ import { FREECAD_EXECUTOR } from './freecad-executor.js';
67
+ import { FUSION360_EXECUTOR } from './fusion360-executor.js';
68
+ import { BUILTIN_EXECUTOR } from './builtin-executor.js';
69
+ export const EXECUTORS = [
70
+ BUILTIN_EXECUTOR,
71
+ FREECAD_EXECUTOR,
72
+ FUSION360_EXECUTOR,
73
+ ];
74
+ export function executorById(id) {
75
+ return EXECUTORS.find((executor) => executor.id === id);
76
+ }
@@ -0,0 +1,398 @@
1
+ /**
2
+ * FreeCAD external executor: probe a local FreeCAD install, run a feature
3
+ * program (the same op shapes as the built-in kernel) through a Python
4
+ * bridge in FreeCAD's console binary, and read back tessellated meshes +
5
+ * volumes. Each run is an isolated temp-dir process — no shared state with
6
+ * the WASM session worker.
7
+ */
8
+ import { execFile, spawn } from 'node:child_process';
9
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
10
+ import { readdirSync, existsSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import path from 'node:path';
13
+ let probed;
14
+ let probedGui;
15
+ function firstExisting(candidates) {
16
+ for (const candidate of candidates) {
17
+ if (existsSync(candidate))
18
+ return candidate;
19
+ }
20
+ return null;
21
+ }
22
+ function windowsInstallDirs() {
23
+ const dirs = [];
24
+ const roots = [process.env.ProgramFiles ?? 'C:\\Program Files', process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'];
25
+ if (process.env.LOCALAPPDATA !== undefined)
26
+ roots.push(path.join(process.env.LOCALAPPDATA, 'Programs'));
27
+ for (const root of roots) {
28
+ try {
29
+ for (const entry of readdirSync(root)) {
30
+ if (/^freecad/i.test(entry)) {
31
+ const bin = path.join(root, entry, 'bin');
32
+ for (const exe of ['freecadcmd.exe', 'FreeCADCmd.exe', 'freecad.exe', 'FreeCAD.exe']) {
33
+ dirs.push(path.join(bin, exe));
34
+ }
35
+ }
36
+ }
37
+ }
38
+ catch {
39
+ // No such root — skip.
40
+ }
41
+ }
42
+ return dirs;
43
+ }
44
+ function findFreeCadIn(gui) {
45
+ // FREECAD_BIN points at the console binary; its GUI sibling sits next to it.
46
+ if (process.env.FREECAD_BIN !== undefined && process.env.FREECAD_BIN !== '') {
47
+ if (gui) {
48
+ const sibling = path.join(path.dirname(process.env.FREECAD_BIN), 'FreeCAD.exe');
49
+ if (existsSync(sibling))
50
+ return sibling;
51
+ }
52
+ else {
53
+ return process.env.FREECAD_BIN;
54
+ }
55
+ }
56
+ const candidates = gui
57
+ ? [
58
+ '/usr/bin/freecad', '/usr/local/bin/freecad',
59
+ '/Applications/FreeCAD.app/Contents/MacOS/FreeCAD',
60
+ ...windowsInstallDirs().reverse(),
61
+ ]
62
+ : [
63
+ '/usr/bin/freecadcmd', '/usr/local/bin/freecadcmd',
64
+ '/usr/bin/FreeCADCmd', '/opt/freecad/bin/freecadcmd',
65
+ '/Applications/FreeCAD.app/Contents/MacOS/FreeCADCmd',
66
+ '/usr/bin/freecad', '/usr/local/bin/freecad',
67
+ ...windowsInstallDirs(),
68
+ ];
69
+ return firstExisting(candidates);
70
+ }
71
+ /**
72
+ * Locate a FreeCAD console executable: FREECAD_BIN env → PATH candidates →
73
+ * common install directories. Cached after the first probe. Pass
74
+ * `{ gui: true }` for the GUI binary (windowed) variant.
75
+ */
76
+ export function findFreeCad(options = {}) {
77
+ if (options.gui === true) {
78
+ if (probedGui === undefined)
79
+ probedGui = findFreeCadIn(true);
80
+ return probedGui;
81
+ }
82
+ if (probed === undefined)
83
+ probed = findFreeCadIn(false);
84
+ return probed;
85
+ }
86
+ /** Whether an external FreeCAD executor is usable on this machine. */
87
+ export function freecadAvailable() {
88
+ return findFreeCad() !== null;
89
+ }
90
+ /** The Python bridge executed inside FreeCAD's console binary. */
91
+ const BRIDGE = String.raw `
92
+ import json, math, os, struct, traceback
93
+
94
+ import FreeCAD
95
+ import Part
96
+
97
+ BASE = os.path.dirname(os.path.abspath(__file__))
98
+ OPS_PATH = os.path.join(BASE, 'ops.json')
99
+ RESULT_PATH = os.path.join(BASE, 'result.json')
100
+
101
+
102
+ def write(result):
103
+ with open(RESULT_PATH, 'w') as handle:
104
+ json.dump(result, handle)
105
+
106
+
107
+ def vec(value, default=(0.0, 0.0, 0.0)):
108
+ if not value:
109
+ return FreeCAD.Vector(*default)
110
+ return FreeCAD.Vector(float(value[0]), float(value[1]), float(value[2]))
111
+
112
+
113
+ def orient(shape, at, axis):
114
+ if at:
115
+ shape.translate(vec(at))
116
+ if axis:
117
+ target = vec(axis, (0.0, 0.0, 1.0))
118
+ if target.Length < 1e-9:
119
+ return shape
120
+ target.normalize()
121
+ if abs(target.z - 1.0) > 1e-9:
122
+ rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), target)
123
+ shape.transformShape(rotation.toMatrix(), True)
124
+ return shape
125
+
126
+
127
+ def make_prim(prim, params):
128
+ params = params or {}
129
+ if prim == 'box':
130
+ shape = Part.makeBox(float(params.get('dx', 10)), float(params.get('dy', 10)), float(params.get('dz', 10)))
131
+ elif prim == 'cylinder':
132
+ shape = Part.makeCylinder(float(params.get('radius', 5)), float(params.get('height', 10)))
133
+ elif prim == 'sphere':
134
+ shape = Part.makeSphere(float(params.get('radius', 5)))
135
+ elif prim == 'cone':
136
+ shape = Part.makeCone(float(params.get('radius1', 5)), float(params.get('radius2', 0)), float(params.get('height', 10)))
137
+ elif prim == 'torus':
138
+ shape = Part.makeTorus(float(params.get('majorRadius', 10)), float(params.get('minorRadius', 2)))
139
+ else:
140
+ raise ValueError('unknown primitive: %s' % prim)
141
+ return orient(shape, params.get('at'), params.get('axis'))
142
+
143
+
144
+ def extrude_profile(points, height, base):
145
+ pts = []
146
+ for i in range(0, len(points) - 1, 2):
147
+ pts.append(FreeCAD.Vector(float(points[i]), float(points[i + 1]), float(base or 0.0)))
148
+ if len(pts) < 3:
149
+ raise ValueError('profile needs at least 3 points')
150
+ if (pts[-1] - pts[0]).Length > 1e-9:
151
+ pts.append(pts[0])
152
+ return Part.Face(Part.makePolygon(pts)).extrude(FreeCAD.Vector(0, 0, float(height or 10)))
153
+
154
+
155
+ def mesh_to_shape(path):
156
+ import Mesh, MeshPart
157
+ return MeshPart.meshToShape(Mesh.Mesh(str(path)))
158
+
159
+
160
+ def flat_normal(a, b, c):
161
+ n = (b - a).cross(c - a)
162
+ length = n.Length
163
+ if length > 1e-12:
164
+ n = FreeCAD.Vector(n.x / length, n.y / length, n.z / length)
165
+ else:
166
+ n = FreeCAD.Vector(0, 0, 1)
167
+ return [n.x, n.y, n.z]
168
+
169
+
170
+ def tessellate(body_id, shape, name):
171
+ verts, facets = shape.tessellate(0.4)
172
+ positions = []
173
+ normals = []
174
+ indices = []
175
+ for v in verts:
176
+ positions.extend([v.x, v.y, v.z])
177
+ for f in facets:
178
+ a, b, c = verts[f[0]], verts[f[1]], verts[f[2]]
179
+ n = flat_normal(a, b, c)
180
+ normals.extend(n + n + n)
181
+ indices.extend([f[0], f[1], f[2]])
182
+ return {
183
+ 'bodyId': body_id,
184
+ 'name': name or body_id,
185
+ 'positions': positions,
186
+ 'normals': normals,
187
+ 'indices': indices,
188
+ 'vertexCount': len(verts),
189
+ 'triangleCount': len(facets),
190
+ }
191
+
192
+
193
+ def write_stl(shape, path):
194
+ verts, facets = shape.tessellate(0.2)
195
+ with open(path, 'wb') as handle:
196
+ handle.write(b'\0' * 80)
197
+ handle.write(struct.pack('<I', len(facets)))
198
+ for f in facets:
199
+ a, b, c = verts[f[0]], verts[f[1]], verts[f[2]]
200
+ n = flat_normal(a, b, c)
201
+ handle.write(struct.pack('<3f', n[0], n[1], n[2]))
202
+ for v in (a, b, c):
203
+ handle.write(struct.pack('<3f', v.x, v.y, v.z))
204
+ handle.write(struct.pack('<H', 0))
205
+
206
+
207
+ def run(spec):
208
+ bodies = {}
209
+ volumes = {}
210
+ source = spec.get('input')
211
+ if source:
212
+ fmt = str(source.get('format', 'step')).lower()
213
+ body_id = source.get('bodyId', 'input')
214
+ if fmt in ('step', 'stp', 'brep'):
215
+ bodies[body_id] = Part.read(str(source['path']))
216
+ elif fmt == 'stl':
217
+ bodies[body_id] = mesh_to_shape(source['path'])
218
+ else:
219
+ raise ValueError('unsupported input format: %s' % fmt)
220
+ for op in spec.get('ops', []):
221
+ kind = op['kind']
222
+ if kind == 'reset':
223
+ bodies = {}
224
+ elif kind == 'create_prim':
225
+ bodies[op['bodyId']] = make_prim(op['prim'], op.get('params'))
226
+ elif kind == 'extrude_profile':
227
+ bodies[op['bodyId']] = extrude_profile(op['points'], op.get('height'), op.get('base'))
228
+ elif kind == 'boolean':
229
+ target = bodies[op['target']]
230
+ operation = op['op']
231
+ for tool_id in op['tools']:
232
+ tool = bodies[tool_id]
233
+ if operation == 'cut':
234
+ target = target.cut(tool)
235
+ elif operation == 'fuse':
236
+ target = target.fuse(tool)
237
+ elif operation == 'common':
238
+ target = target.common(tool)
239
+ else:
240
+ raise ValueError('unknown boolean: %s' % operation)
241
+ del bodies[tool_id]
242
+ bodies[op['target']] = target
243
+ elif kind == 'fillet':
244
+ shape = bodies[op['target']]
245
+ bodies[op['target']] = shape.makeFillet(float(op['radius']), shape.Edges)
246
+ elif kind == 'transform':
247
+ shape = bodies[op['target']]
248
+ if op.get('translate'):
249
+ shape.translate(vec(op['translate']))
250
+ rotate = op.get('rotate')
251
+ if rotate:
252
+ for axis, angle in ((FreeCAD.Vector(1, 0, 0), rotate[0]), (FreeCAD.Vector(0, 1, 0), rotate[1]), (FreeCAD.Vector(0, 0, 1), rotate[2])):
253
+ if abs(float(angle)) > 1e-9:
254
+ shape.rotate(FreeCAD.Vector(0, 0, 0), axis, float(angle))
255
+ mirror = op.get('mirror')
256
+ if mirror:
257
+ shape = shape.mirror(FreeCAD.Vector(0, 0, 0), vec(mirror, (0.0, 0.0, 1.0)))
258
+ bodies[op['target']] = shape
259
+ elif kind == 'volume':
260
+ volumes[op['target']] = bodies[op['target']].Volume
261
+ elif kind == 'delete':
262
+ bodies.pop(op['target'], None)
263
+ else:
264
+ raise ValueError('unsupported op: %s' % kind)
265
+ names = spec.get('names') or {}
266
+ result = {'ok': True, 'meshes': [tessellate(i, s, names.get(i)) for i, s in bodies.items()], 'volumes': volumes}
267
+ export = spec.get('export')
268
+ if export:
269
+ fmt = str(export.get('format', 'step')).lower()
270
+ target_path = str(export['path'])
271
+ shapes = list(bodies.values())
272
+ if len(shapes) == 0:
273
+ raise ValueError('nothing to export — the program produced no bodies')
274
+ if fmt in ('step', 'stp'):
275
+ # Part.export() writes files Part.read() loads as null shapes in
276
+ # FreeCAD 1.1 — a compound through exportStep() round-trips.
277
+ Part.Compound(shapes).exportStep(target_path)
278
+ elif fmt == 'stl':
279
+ write_stl(shapes[0], target_path)
280
+ else:
281
+ raise ValueError('unsupported export format: %s' % fmt)
282
+ result['exported'] = target_path
283
+ if spec.get('display'):
284
+ document = FreeCAD.newDocument('dsh-cad')
285
+ for body_id, shape in bodies.items():
286
+ obj = document.addObject('Part::Feature', body_id)
287
+ obj.Label = names.get(body_id, body_id)
288
+ obj.Shape = shape
289
+ document.recompute()
290
+ return result
291
+
292
+
293
+ try:
294
+ with open(OPS_PATH) as handle:
295
+ write(run(json.load(handle)))
296
+ print('###DSH-OK###')
297
+ except Exception as error:
298
+ write({'ok': False, 'error': str(error), 'trace': traceback.format_exc()})
299
+ print('###DSH-ERR###')
300
+ `;
301
+ /**
302
+ * Run a feature program in an external FreeCAD process and return tessellated
303
+ * meshes + volumes (+ exported file path). Console mode waits for exit; GUI
304
+ * mode detaches the windowed process and polls for the result file.
305
+ */
306
+ export async function runFreeCadProgram(program, options = {}) {
307
+ const timeoutMs = options.timeoutMs ?? 300_000;
308
+ const gui = options.gui === true;
309
+ const executable = findFreeCad(gui ? { gui: true } : {});
310
+ if (executable === null) {
311
+ throw new Error(gui
312
+ ? 'FreeCAD GUI was not found — install FreeCAD or point FREECAD_BIN at its console binary'
313
+ : 'FreeCAD was not found — install FreeCAD or point FREECAD_BIN at its console binary (freecadcmd)');
314
+ }
315
+ const dir = await mkdtemp(path.join(tmpdir(), 'dsh-cad-freecad-'));
316
+ const resultPath = path.join(dir, 'result.json');
317
+ if (gui) {
318
+ // Detached GUI window: the bridge writes result.json while the document
319
+ // stays open on screen; the temp dir is left for the running process.
320
+ await writeFile(path.join(dir, 'bridge.py'), BRIDGE);
321
+ await writeFile(path.join(dir, 'ops.json'), JSON.stringify(program));
322
+ const child = spawn(executable, [path.join(dir, 'bridge.py')], {
323
+ detached: true,
324
+ stdio: 'ignore',
325
+ windowsHide: false,
326
+ });
327
+ child.unref();
328
+ const deadline = Date.now() + timeoutMs;
329
+ while (Date.now() < deadline) {
330
+ await new Promise((resolve) => setTimeout(resolve, 800));
331
+ if (existsSync(resultPath)) {
332
+ const parsed = JSON.parse(await readFile(resultPath, 'utf8'));
333
+ const mapped = mapBridgeResult(parsed);
334
+ // The script has finished executing; the temp files are no longer
335
+ // needed by the still-open GUI window.
336
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
337
+ return mapped;
338
+ }
339
+ }
340
+ throw new Error('the FreeCAD GUI produced no result before the timeout');
341
+ }
342
+ try {
343
+ const script = path.join(dir, 'bridge.py');
344
+ const specPath = path.join(dir, 'ops.json');
345
+ await writeFile(script, BRIDGE);
346
+ await writeFile(specPath, JSON.stringify(program));
347
+ const stdout = await new Promise((resolve, reject) => {
348
+ execFile(executable, [script], { timeout: timeoutMs, windowsHide: true, maxBuffer: 64 * 1024 * 1024 }, (error, out) => {
349
+ if (error !== null && error.killed !== true) {
350
+ reject(new Error(`FreeCAD exited with an error: ${error.message}`));
351
+ return;
352
+ }
353
+ resolve(String(out));
354
+ });
355
+ });
356
+ let parsed;
357
+ try {
358
+ parsed = JSON.parse(await readFile(resultPath, 'utf8'));
359
+ }
360
+ catch {
361
+ throw new Error(`the FreeCAD bridge produced no result (stdout: ${stdout.slice(-400)})`);
362
+ }
363
+ return mapBridgeResult(parsed);
364
+ }
365
+ finally {
366
+ await rm(dir, { recursive: true, force: true });
367
+ }
368
+ }
369
+ function mapBridgeResult(parsed) {
370
+ if (!parsed.ok) {
371
+ throw new Error(`FreeCAD bridge failed: ${parsed.error ?? 'unknown error'}`);
372
+ }
373
+ return {
374
+ meshes: (parsed.meshes ?? []).map((mesh) => ({
375
+ bodyId: mesh.bodyId,
376
+ name: mesh.name === '' ? mesh.bodyId : mesh.name,
377
+ positions: Float32Array.from(mesh.positions),
378
+ normals: Float32Array.from(mesh.normals),
379
+ indices: Uint32Array.from(mesh.indices),
380
+ vertexCount: mesh.vertexCount,
381
+ triangleCount: mesh.triangleCount,
382
+ })),
383
+ volumes: parsed.volumes ?? {},
384
+ exported: parsed.exported,
385
+ };
386
+ }
387
+ /** The GeometryExecutor contract over the FreeCAD console/GUI bridge. */
388
+ export const FREECAD_EXECUTOR = {
389
+ id: 'freecad',
390
+ label: 'FreeCAD',
391
+ available: freecadAvailable,
392
+ unavailableReason: () => 'FreeCAD was not found — install FreeCAD or point FREECAD_BIN at its console binary (freecadcmd)',
393
+ run(program, options = {}) {
394
+ // `display` rides the detached-GUI path: the FreeCAD window opens with
395
+ // the bodies loaded and stays open for inspection.
396
+ return runFreeCadProgram(program, { ...options, gui: program.display === true });
397
+ },
398
+ };
@@ -0,0 +1,8 @@
1
+ export const FREECAD_CONNECTOR = {
2
+ id: 'freecad',
3
+ label: 'FreeCAD',
4
+ vendor: 'Open-source community',
5
+ language: 'python',
6
+ status: 'available',
7
+ binding: 'Python bridge over a local FreeCAD console (freecadcmd) — requires FreeCAD installed (or FREECAD_BIN set)',
8
+ };