No entry matches.
01Execution model
Whatever you paste into the modal is handed to new Function('return (async()=>{…})()') and run on the main thread. Three consequences account for most surprises.
Global read, local write
- Every NASSCAD global is visible
- Your
const/letdie with the run - To persist:
window.x = …
Top-level await works
- The body is already an async IIFE
- 26 shipped functions are async
- A missed
awaitinterleaves steps
No preemption
- Stop is cooperative, not a kill switch
- Call
scriptCheckStop()per iteration - Rendering freezes inside a sync block
JSON.stringify, so handing it a three.js mesh no longer breaks the display.⏹ Stopped by user if Stop was pressed. Nothing calls it for you — a loop without it cannot be interrupted, because synchronous JS never yields the main thread.setTimeout(stopScript, 30000)).Return value, output, history
Whatever the script returns is printed last, prefixed →; with no return you get ✓ OK. A thrown error prints ✗ message in red and is mirrored to the log at ERROR level. The last 20 scripts live in sessionStorage under nasscad_script_hist — they survive a reload, not a tab close.
02Scope & reachability
NASSCAD's globals live in two different places, and the difference is visible from a script. This is verified behaviour, not theory.
// function declarations land on the global OBJECT typeof window.addPrimitive // "function" // top-level const / let land in the global LEXICAL environment only typeof window.objs // "undefined" ← not a bug typeof objs // "object" ← bare name works typeof window.NASSCAD_MATERIALS // "undefined" NASSCAD_MATERIALS.length // 12
Both kinds are reachable from NassScript, because the script body is evaluated in global scope. The distinction matters in exactly two situations: feature-detecting with typeof window.X (use the bare name instead), and assigning a value you want to survive the run (window.myCache = … works; a bare const does not).
What is not reachable
The 2D sketcher is an IIFE-isolated module: 102 of its functions never touch the global scope. It publishes exactly three names — openSketcher, closeSketcher, and the SK object described in its own section. Likewise every _qf* helper of the fillet module and every _p21* STEP parser helper is a plain global, while the OCCT kernel handle itself is created on demand and only handed out by _occtLoad().
A leading underscore means internal. Those functions work, several are genuinely the best tool for a scripting job, and this page documents the useful ones — but they are not a contract. Anything without an underscore is part of the app's own surface and far less likely to move.
03Data model
A NASSCAD object is a small record wrapping a three.js mesh. Every other API works on this shape.
// one entry of objs[] { id: 42, // unique integer — the key used by _csgTree name: 'Cylinder_42', // shown in the object list type: 'cylinder', // source primitive, or 'csg' after a boolean/generator mesh: THREE.Mesh, // geometry + material + transform color: '#5b8fc9', // list swatch (independent of an applied material) isHole: false, // true ⇒ subtracted by the next boolean // situational: genType: 'gear', // gear|screw|nut|pipe|lathe|cylind|cubic|tore|arcsphere|circtext|sketch genParams: {…}, // the generator's exact inputs — replayable tubeRo, tubeRi, // outer / inner radius of a tube sphereRes, // sphere segment count matId: 'inox', // applied NASSCAD material isManifold, // false ⇒ CSG will warn isOcctResult, // came out of a fillet/chamfer _poolSlot // GeometryPool slot, when pooled }
The CSG construction tree
_csgTree is a Map keyed by object id. Each node stores the operation, the result centre of gravity, and a full snapshot of every source — enough to rebuild them without the original objects, which is what makes Re-run and Explode non-destructive.
_csgTree.get(id) = {
op: 'union' | 'subtract' | 'intersect',
cg: [x, y, z],
children: [{
id, name, type, color, isHole, tubeRo, tubeRi, genType, genParams,
p: [x,y,z], r: [rx,ry,rz], s: [sx,sy,sz], // position / rotation / scale
opacity, wireframe,
_csgTree: {…} | null, // nested node — the tree is recursive
_csgGeo: {…} // serialised geometry, only when not rebuildable
}]
}
delSel() itself does objs = objs.filter(…). Never hold a reference to the array across an operation; re-read objs.setTr, setDim and renameObj can see.Box3, Vector3, Matrix4, BufferGeometry and the rest are all available.PS is the reference for every scale computation: a primitive at scale 1 measures 20 mm on each axis._segViewLive() derives the object actually passed to makeGeo.04Creating geometry
Eleven mesh primitives, plus a documented door for any geometry a script computes itself.
| type | Shape | Triangles at 32 seg | Notes |
|---|---|---|---|
| cube | Box 20³ | 12 | — |
| sphere | UV sphere Ø20 | 2 520 | stores sphereRes; smooth normals |
| cylinder | Ø20 × 20 | 272 | centred on its own origin |
| cone | Ø20 × 20 | 204 | — |
| pyramid | Square base, apex | 6 | flat-shaded |
| roof | Gable prism | 8 | ridge height 10 |
| roofarc | Barrel roof | 192 | segments ×1.5 |
| halfsphere | Dome | 1 152 | segments ×1.5 |
| tube | Hollow cylinder | 384 | sets tubeRo = 10, tubeRi = 5 |
| hollowbox | Open box | 32 | outer / inner wall via its dialog |
| wedge | Ramp | 8 | — |
S is the edge size in mm; res is the segment table — pass _segViewLive() to match the current setting.THREE.BufferGeometry and gives it a material, a free position, an id, a list entry and an undo step, then returns the created object. name may be a string or a function of the new id. Pass hideDialogFn: ()=>{} outside a dialog._finalizeGenToScene.create3DText() reads the dialog fields, so the dialog has to be open and filled.PS on X, named _Cp01, _Cp02…) or through an internal clipboard. CSG geometry is cloned independently, and genParams plus CSG history are carried over.RES_MAX) — with a selection it retessellates it live, without one it changes the default for new primitives. Adaptive tessellation scales segments with object size.05Selection
Selection drives most of the rest of the API. From a script, assigning it directly beats chaining calls.
add=false replaces the selection; add=true appends — or removes if the object was already selected. selObj(null, false) clears._csgTree entry, and reassigns objs.selObjs yourself, call at least the first three.// selection by predicate — the single most useful pattern selObjs = objs.filter(o => o.genType === 'gear' || /^Cylinder_/.test(o.name)); updProps(); updOList(true); updCSG(); scriptLog(selObjs.length + ' object(s) selected');
06Transforms
Two routes: the panel functions, or the mesh directly. In a script the second is almost always right.
setTr('position','x',1) moves 25.4 mm. And on Y the value addresses the bottom of the bounding box, not the mesh origin.scaleSelUniform(1) is an absolute reset, not a no-op.In a script prefer the mesh: o.mesh.position.set(x,y,z), o.mesh.rotation.y = a, o.mesh.scale.set(…) — always millimetres, always the object you mean, followed by _invalidateBbox(o.mesh). setTr and setDim are panel functions: one object, and they read the display unit.
07Appearance & materials
o.matId. The step triplet of each entry is what gets written into a STEP file.{id, name, step:[r,g,b], hex, family, shininess, opacity}; specular is derived from the family rather than stored.| id | Material | Hex | Family | Shininess | Opacity |
|---|---|---|---|---|---|
| alu | Aluminium | BABABA | metal | 60 | 1 |
| inox | Stainless steel | A6A6AB | metal | 90 | 1 |
| acier | Plain steel | 737373 | metal | 25 | 1 |
| laiton | Brass | D4AD36 | metal | 85 | 1 |
| cuivre | Copper | B87333 | metal | 80 | 1 |
| or | Gold | FFD600 | metal | 110 | 1 |
| titane | Titanium | 9999A6 | metal | 55 | 1 |
| abs-noir | Black plastic | 1A1A1A | plastic | 30 | 1 |
| abs-blanc | White plastic | F2F2F2 | plastic | 35 | 1 |
| caoutchouc | Rubber | 333333 | rubber | 6 | 1 |
| carbone | Carbon / CFRP | 262626 | composite | 45 | 1 |
| verre | Glass | B2D9E6 | glass | 100 | 0.35 |
_csgTree nodes. For bulk renaming, writing o.name then calling updOList() is more direct.toggleTheme() swaps the app's light/dark theme and rebuilds the grid and axis colours.08Boolean CSG
All three booleans go to MEDUSA over local HTTP. There is no in-browser fallback: with the engine stopped, the operation fails loudly instead of degrading.
selObjs, two objects minimum. If the selection mixes solids and holes the operation is forced to solids − holes, whatever you asked for, with solids ordered first. Unions of non-overlapping objects bypass the engine entirely, and a union of union-trees is flattened to its leaves before being sent._workerReady caches the last answer. Check it before a batch rather than after the first failure. _medusaRequire() throws a descriptive error when the engine is missing.doCSG: POST /csg for a flat operation, POST /csgtree for a whole tree. Useful when you want the result mesh without the scene bookkeeping._csgTree entry, which imported objects never have.Every doCSG is a local request plus a retessellation. Order matters: union all the holes into one object and subtract once, rather than subtracting N times. It is faster and it leaves a single node in the tree instead of N nested ones.
09Fillet & chamfer — OpenCASCADE
The one place NASSCAD handles real B-Rep locally: the mesh is sewn into a solid, coplanar facets are merged back into faces, and BRepFilletAPI works on that. Entirely in the browser — no MEDUSA involved.
oc handle, i.e. the full OCCT binding: BRepBuilderAPI_*, BRepFilletAPI_MakeFillet, TopExp_Explorer_2, BRepGProp, ShapeUpgrade_UnifySameDomain_2, BRepCheck_Analyzer…BRepGProp, topological validity via BRepCheck_Analyzer, chord length of an edge, and the disposer that calls .delete() on anything you pass it.The solid handed to OCCT is built from the mesh: one triangle becomes one planar face, then coplanar faces are merged. A cylinder stays N planar strips — it never becomes an analytic cylindrical surface. The fillets and the recovered edges are real; the original surfaces of a part that was never a B-Rep are not recoverable.
Every OCCT instance a script creates must be released by hand with .delete(). Embind has no garbage collector and the WASM heap tops out at 2 GB — this is the cause of "after N fillets nothing works until I reload".
10Sketch.Gen — window.SK
The 2D sketcher is an isolated module, and it is the only part of NASSCAD that ships a purpose-built scripting object. Its geometry engine stays private; these eighteen entries are the whole public surface.
genType is 'sketch' reloads its stored entities for editing.getEntities(), write back coordinate by coordinate.genType: 'sketch', its entities stored in genParams.can* pair returns the stack depth, so they double as counters.11Generators
Nine generators read their inputs from their dialog fields, so a script cannot call them like functions. What a script can do is read and replay their parameters, which every generated object carries.
genParams restored. The matching hide*Dialog(cancel) closes them.genType. One call instead of ten branches.genParams and they return raw arrays, no DOM involved. Feed the result to _finalizeGenToScene to place it.genParams by generator
| genType | Generator | Parameters |
|---|---|---|
| gear | Gear.Gen | gearMode, gearType, gearModule, gearTeeth, gearHeight, gearHoleR, gearTwist, gearHand, gearRes, gearBacklash, gearPair, gearTeeth2, pulleyType… |
| screw | Screw.Gen | system, specIdx, thread, pitchCustom, length, head, nRad, chamferAbout |
| nut | Nut.Gen | system, specIdx, thread, pitchCustom, style, mCustom, chamfer, nRad |
| pipe | Pipe.Gen | re, ep, rb, sc, sr, l1, v1y, v1z, l2, v2y, v2z, l3 |
| tore | Tore.Gen | R, r, N, M |
| cylind | Cylind.Gen | H, Rt, Rb, ct, cb, segs, mode, N |
| cubic | Cubic.Gen | W, H, D, c, segs, mode |
| arcsphere | ArcSphere.Gen | R, Wphi, Htheta, arc |
| lathe | RevSolid.Gen | R, H, N, arc |
| circtext | CircularText.Gen | text, fontKey, size, depth, curve |
| sketch | Sketch.Gen | entities[], depth |
Build the part once by hand, then read objs.at(-1).genParams from a script. You now have the exact, valid parameter object — vary it in a loop and hand each variant to the matching builder, or store it and rebuild the part later without touching the dialog.
12Import
Importers take a File object, so a script can feed them from anywhere — a fetch, generated text, the clipboard.
.gltf (embedded buffers) with Draco decoding when present — glTF metres are converted to millimetres, one object per node, material colours kept. 3MF with its unit, components, production-extension parts and base/colour-group colours. Both keep the file's layout as an assembly. opts.noUndo skips the undo snapshot (the batch importer already took one).geometry.userData: parts (OBJ o/g, multi-solid STL, as triangle ranges), faceRGB (per-triangle colour) and color (STL header colour). parsePLY returns an indexed geometry.doImp opens the picker, importMesh processes the batch strictly in sequence with a watchdog sized from the largest file.styleAlpha, the colour → opacity table. No reader (MEDUSA, occt-import-js, the NSPG cache) carries an alpha channel, so that table is what makes a translucent body come in translucent. A hue seen both opaque and translucent is ambiguous and stays out of the table; nasStepDeclaredAlpha tolerates ±1 per channel on lookup, the rounding the sRGB round-trip can cost.// import a STEP file from a URL without touching the file picker const r = await fetch('https://example.local/flange.stp'); const f = new File([await r.blob()], 'flange.stp', {type:'application/step'}); await importSTEP_XCAF(f); return objs.length + ' objects after import';
13Export
Every mesh exporter — STL, OBJ, 3MF, GLB, PLY — walks objs, meaning the entire scene, never the selection. STEP is the one exception: its internal entry point takes an explicit object list.
setExpQuality resolution. STL (binary and ASCII) and 3MF run a watertightness gate that can prompt. GLB is written in metres, as glTF requires, with one material per colour; 3MF is deflate-compressed with object and per-face colours; PLY is binary with vertex colours. They open the native save picker where the browser supports it, otherwise fall back to a download.NEXT_ASSEMBLY_USAGE_OCCURRENCE — a single body stays a single product. Colours travel as a STYLED_ITEM per body plus an OVER_RIDING_STYLED_ITEM per face that differs from it, and opacity below 1 as SURFACE_STYLE_RENDERING_WITH_PROPERTIES.returnText: true, resolves with the text instead of downloading; with silent: true, without the spinner. Settings come from globalThis._stepExportConfig. Passing no list defaults to the whole scene.{fusionMode, apVersion, customTolerance, logStats}. The two tables below list the accepted values._sliceMesh is the underlying triangle/plane intersector and returns plain segments; _chainSegments joins them.newProject() asks for confirmation only when the scene is non-empty, then disposes everything.| apVersion | Schema | Colours | Use |
|---|---|---|---|
| AP203 | Config Controlled Design | yes (+ Shape Appearance Layer MIM) | widest reader compatibility |
| AP214 | Automotive Design | yes | the industry default |
| AP242 | Managed Model Based 3D Eng. | yes | current standard, MBD / PMI capable |
| fusionMode | Tolerance | Decimals | Effect |
|---|---|---|---|
| EXACT | 1e-6 | 6 | strictest coplanar merge |
| ROBUST | 1e-5 | 5 | default; survives noisy meshes |
| FACETED | — | — | no merge — faceted B-Rep, for organic shapes |
Decimals quantise the vertex and plane keys of the merge; customTolerance overrides both. The tolerance is also written as the file's UNCERTAINTY_MEASURE_WITH_UNIT. Coordinates themselves are always written to six decimals. | |||
14Scene, undo, camera, units
await undoPush('my script') at the top guarantees the restore point is written before you change anything. Snapshots are captured synchronously then persisted to IndexedDB.camA, then call updCam(). persp also resets the target to the origin at distance 240.setTr and setDim interpret their arguments in this unit, and the choice is persisted.togSnap() cycles through SNAP_SIZES and only turns snapping off after the last one.hideSpinner() in a finally._camDirty = true is what asks for a redraw.15Memory & storage
ArrayBuffer split into a slot header, a geometry zone (80 %) and a snapshot zone, with bump allocation and mark-and-sweep collection. stats() returns {totalMo, geoUsedMo, geoFreeMo, snapUsedMo, snapFreeMo, slots, maxSlots}.NASSCAD_DB, 2 GB quota target, persistent storage requested at startup.16Logging & diagnostics
ERROR WARN OK INFO CSG IDB MEDUSA DBG. All are persisted to IndexedDB except DBG, deliberately, as too noisy — so a script message you want to keep must not use that level. console.log/warn/error, uncaught errors and unhandled rejections are all funnelled here automatically.MEDUSA filter is the one cross-cutting filter — it also catches engine lines logged at other levels.GET /log) and renders it beside the browser log. MEDUSA keeps 20 000 lines in memory and writes no file unless started with --logfile.selObjs
MEDUSA native engine required
OCCT WASM kernel
DOM dialog must be open
undo pushes a restore point
17Bundled libraries
Loaded as plain globals alongside the app, and therefore available to any script.
BufferGeometry, Box3, Matrix4, Raycaster and the loaders are all fair game._occtLoad() instantiates.18Recipes
Ten complete scripts. Each was executed against a running NASSCAD 4.7.0 instance while writing this page; the numbers quoted in the comments are the values that came back.
1 — Scene inventory
What the file actually contains: type, origin, triangle count, footprint.
const rows = objs.map(o => { const g = o.mesh.geometry; const tri = g.index ? g.index.count / 3 : g.attributes.position.count / 3; o.mesh.updateMatrixWorld(true); const s = new THREE.Box3().setFromObject(o.mesh).getSize(new THREE.Vector3()); return [ String(o.id).padStart(4), o.name.padEnd(26).slice(0, 26), (o.genType || o.type).padEnd(10), (Math.round(tri) + ' tri').padStart(12), `${s.x.toFixed(1)}×${s.y.toFixed(1)}×${s.z.toFixed(1)} mm`, o.isHole ? ' hole' : '' ].join(' '); }); scriptLog(rows.join('\n')); const byKind = {}; objs.forEach(o => { const k = o.genType || o.type; byKind[k] = (byKind[k] || 0) + 1; }); scriptLog('\n— breakdown —', byKind); return objs.length + ' objects';
2 — Real volume and mass
Signed mesh volume including the world transform, converted to mass via the applied material. Verified: a default cube returns exactly 8 000 mm³.
// densities in g/cm³, keyed on NASSCAD material ids const RHO = {alu:2.70, inox:7.90, acier:7.85, laiton:8.50, cuivre:8.96, or:19.30, titane:4.51, 'abs-noir':1.04, 'abs-blanc':1.04, caoutchouc:1.20, carbone:1.60, verre:2.50}; function volumeMm3(mesh){ mesh.updateMatrixWorld(true); const g = mesh.geometry, p = g.attributes.position.array; const ix = g.index ? g.index.array : null; const n = ix ? ix.length / 3 : p.length / 9; const m = mesh.matrixWorld; const a = new THREE.Vector3(), b = new THREE.Vector3(), c = new THREE.Vector3(); let v = 0; for(let i = 0; i < n; i++){ const i0 = (ix ? ix[i*3] : i*3) * 3; const i1 = (ix ? ix[i*3+1] : i*3+1) * 3; const i2 = (ix ? ix[i*3+2] : i*3+2) * 3; a.set(p[i0], p[i0+1], p[i0+2]).applyMatrix4(m); b.set(p[i1], p[i1+1], p[i1+2]).applyMatrix4(m); c.set(p[i2], p[i2+1], p[i2+2]).applyMatrix4(m); v += a.dot(b.clone().cross(c)) / 6; // signed tetrahedron (O,a,b,c) } return Math.abs(v); } let total = 0; (selObjs.length ? selObjs : objs).forEach(o => { const mm3 = volumeMm3(o.mesh); const rho = RHO[o.matId] ?? 1.0; const g = mm3 / 1000 * rho; // mm³ → cm³ → g total += g; scriptLog(`${o.name.padEnd(24)} ${(mm3/1000).toFixed(2).padStart(10)} cm³ ` + `${g.toFixed(1).padStart(9)} g ${o.matId || '(no material, ρ=1)'}`); }); return 'total mass: ' + (total/1000).toFixed(3) + ' kg';
3 — Parametric hole grid
Select the plate, run: holes are created, aligned to its bounding box, and subtracted in one operation.
const D = 6, PITCH = 20, MARGIN = 12; // hole Ø, spacing, edge margin (mm) const plate = selObjs[selObjs.length - 1]; if(!plate) throw new Error('Select the plate first'); if(!await _medusaProbe(2000)) throw new Error('MEDUSA offline — the subtraction would fail'); await undoPush('hole grid'); plate.mesh.updateMatrixWorld(true); const bb = new THREE.Box3().setFromObject(plate.mesh); const thk = bb.max.y - bb.min.y; const cy = (bb.max.y + bb.min.y) / 2; setHoleMode(true); const holes = []; for(let x = bb.min.x + MARGIN; x <= bb.max.x - MARGIN + 1e-6; x += PITCH){ for(let z = bb.min.z + MARGIN; z <= bb.max.z - MARGIN + 1e-6; z += PITCH){ scriptCheckStop(); addPrimitive('cylinder'); const t = objs[objs.length - 1]; t.mesh.scale.set(D / PS, (thk + 4) / PS, D / PS); // base is Ø20 × h20 = PS t.mesh.position.set(x, cy, z); _invalidateBbox(t.mesh); holes.push(t); } } setHoleMode(false); selObjs = [plate, ...holes]; updProps(); updOList(); updCSG(); await doCSG('subtract'); return holes.length + ' holes Ø' + D + ' mm';
4 — Bolt circle
The base pattern of every flange: N holes on a given circle, left as holes ready to subtract.
const N = 8, PCD = 80, D = 8, H = 30; // count, pitch circle Ø, hole Ø, height await undoPush('bolt circle'); setHoleMode(true); const made = []; for(let i = 0; i < N; i++){ scriptCheckStop(); const a = i * 2 * Math.PI / N; addPrimitive('cylinder'); const t = objs[objs.length - 1]; t.mesh.scale.set(D / PS, H / PS, D / PS); t.mesh.position.set(PCD/2 * Math.cos(a), 0, PCD/2 * Math.sin(a)); _invalidateBbox(t.mesh); made.push(t); } setHoleMode(false); selObjs = made; updProps(); updOList(); updStats(); return `${N} holes Ø${D} on Ø${PCD}`;
5 — Your own geometry into the scene
A hexagonal prism computed by the script and injected as a first-class object. Verified: 30 × 12 × 34.64 mm for 30 mm across flats.
// extrude a convex polygon [[x,z],…] to height h, base at Y=0 function extrude(poly, h){ const v = []; const tri = (...p) => v.push(...p); const n = poly.length; for(let i = 0; i < n; i++){ // side walls const a = poly[i], b = poly[(i+1) % n]; tri(a[0],0,a[1], b[0],0,b[1], b[0],h,b[1]); tri(a[0],0,a[1], b[0],h,b[1], a[0],h,a[1]); } for(let i = 1; i < n - 1; i++){ // caps (triangle fan) tri(poly[0][0],0,poly[0][1], poly[i+1][0],0,poly[i+1][1], poly[i][0],0,poly[i][1]); tri(poly[0][0],h,poly[0][1], poly[i][0],h,poly[i][1], poly[i+1][0],h,poly[i+1][1]); } const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(v, 3)); g.computeVertexNormals(); g.computeBoundingBox(); return g; } const AF = 30, TH = 12; // across flats, thickness const R = AF / 2 / Math.cos(Math.PI / 6); const hex = Array.from({length: 6}, (_, i) => { const a = i * Math.PI / 3 + Math.PI / 6; return [R * Math.cos(a), R * Math.sin(a)]; }); const obj = _finalizeGenToScene({ label: 'NassScript prism', geo: await postProcessCSGGeo(extrude(hex, TH)), // weld + crease normals name: n => `hex${AF}_${n}`, genType: 'script', genParams: {acrossFlats: AF, thickness: TH}, hideDialogFn: () => {} }); return obj.name;
6 — One STEP file per part, no dialog
Verified: a single default cube exports to 7 694 bytes of AP242 with a MANIFOLD_SOLID_BREP body.
globalThis._stepExportConfig = {fusionMode:'ROBUST', apVersion:'AP242', logStats:false};
const targets = selObjs.length ? [...selObjs] : [...objs];
const written = [];
for(const o of targets){
scriptCheckStop();
const text = await _expSTEPRun([o], {returnText:true, silent:true});
written.push({name: o.name, bytes: text.length});
// comment this block out for a dry-run inventory
const url = URL.createObjectURL(new Blob([text], {type:'application/step'}));
const a = Object.assign(document.createElement('a'),
{href:url, download: o.name.replace(/[^\w.-]/g, '_') + '.stp'});
a.click(); URL.revokeObjectURL(url);
await new Promise(r => setTimeout(r, 350)); // let the browser breathe
}
scriptLog(written);
return written.length + ' STEP files';
7 — One STL per part
Mesh exporters only see objs. Swap in a one-object scene, and restore it whatever happens.
const saved = [...objs]; const targets = selObjs.length ? [...selObjs] : [...objs]; try { for(const o of targets){ scriptCheckStop(); objs = [o]; // ← objs is a reassignable global scriptLog('exporting ' + o.name); await expSTL(); // opens the save picker } } finally { objs = saved; // ← non-negotiable updOList(); updStats(); updProps(); } return targets.length + ' parts exported';
8 — Pre-flight check
Five seconds that stop you launching a batch of booleans that cannot succeed.
const report = []; const medusa = await _medusaProbe(2500); report.push((medusa ? '✓' : '✗') + ' MEDUSA ' + (medusa ? 'reachable' : 'OFFLINE — no boolean will run')); const nonManifold = objs.filter(o => o.isManifold === false); report.push((nonManifold.length ? '⚠' : '✓') + ' non-manifold: ' + (nonManifold.map(o => o.name).join(', ') || 'none')); const heavy = objs.map(o => { const g = o.mesh.geometry; return {n: o.name, t: g.index ? g.index.count/3 : g.attributes.position.count/3}; }).filter(x => x.t > 50000); report.push((heavy.length ? '⚠' : '✓') + ' over 50k triangles: ' + (heavy.map(x => `${x.n} (${Math.round(x.t)})`).join(', ') || 'none')); const s = GeometryPool.initialized ? GeometryPool.stats() : null; if(s) report.push(`· pool ${Math.round(s.geoUsedMo + s.snapUsedMo)} / ${Math.round(s.totalMo)} MB` + `, ${s.slots}/${s.maxSlots} slots`); report.push(`· ${objs.length} objects, ${objs.filter(o => o.isHole).length} hole(s)`); scriptLog(report.join('\n')); return medusa ? 'ready' : 'blocked';
9 — Read and replay generator parameters
Every generated part carries its exact inputs. Dump them, edit them, rebuild from them.
// 1. dump every generated part's parameters as JSON you can keep const sheet = objs.filter(o => o.genType).map(o => ({ name: o.name, genType: o.genType, params: o.genParams, at: [+o.mesh.position.x.toFixed(3), +o.mesh.position.y.toFixed(3), +o.mesh.position.z.toFixed(3)], rot: [+o.mesh.rotation.x.toFixed(4), +o.mesh.rotation.y.toFixed(4), +o.mesh.rotation.z.toFixed(4)] })); scriptLog(JSON.stringify(sheet, null, 1)); // 2. rebuild a family of gears from one existing gear's parameters const ref = objs.find(o => o.genType === 'gear'); if(ref){ await undoPush('gear family'); for(const z of [12, 18, 24, 36]){ scriptCheckStop(); const p = {...ref.genParams, gearTeeth: z, gearPair: false}; const {vPos, tris} = _gearBuild(p); const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(new Float32Array(vPos), 3)); if(tris) g.setIndex(Array.from(tris)); g.computeVertexNormals(); g.computeBoundingBox(); _finalizeGenToScene({label:'Gear.Gen (script)', geo:g, name: n => `gear_z${z}_${n}`, genType:'gear', genParams:p, hideDialogFn: () => {}, shininess: 12, specular: 0x2a2a2a}); } } return sheet.length + ' generated parts catalogued';
10 — Drive the 2D sketcher
Read the contour, transform it numerically, extrude — the only fully scriptable modelling loop in the app.
openSketcher(); // draw a closed contour first, then run this const ents = SK.getEntities(); if(!ents.length) throw new Error('Empty sketch — draw a closed contour first'); scriptLog(`${ents.length} entities`); const kinds = {}; ents.forEach(e => kinds[e.type] = (kinds[e.type] || 0) + 1); scriptLog(kinds); // bounding box of the sketch, in sketch units const xs = [], ys = []; ents.forEach(e => { [[e.x1, e.y1], [e.x2, e.y2], [e.cx, e.cy]].forEach(([x, y]) => { if(typeof x === 'number') xs.push(x); if(typeof y === 'number') ys.push(y); }); }); scriptLog(`extent ${(Math.max(...xs)-Math.min(...xs)).toFixed(2)}` + ` × ${(Math.max(...ys)-Math.min(...ys)).toFixed(2)}`); // nudge one entity numerically, then extrude // SK.updateProp(ents[0].id, 'x2', 45); SK.extrude(); return objs.at(-1).name;
19Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Scene is right, object list is wrong | objs / selObjs mutated without a refresh | updOList(); updStats(); updProps(); |
| Clicking selects the wrong thing | Mesh moved by hand, bounding-box cache stale | _invalidateBbox(o.mesh) |
typeof window.objs is undefined | Top-level const/let never reach the global object | Use the bare name objs |
| A variable vanishes between runs | Your const/let are local to the run | window.myVar = … |
setTr moves 25.4 mm instead of 1 | Display unit is inches | setDispUnit('mm') · or mesh.position |
setDim only affects one object | It sees selObjs.at(-1) only | Loop and reassign the selection |
| Asked for union, got a subtraction | A hole object was in the selection | setHoleMode(false) on the sources |
| Stop button does nothing | No scriptCheckStop() in the loop | One per iteration |
| Ctrl+Z undoes the previous action | undoPush not awaited | await undoPush('…') |
| The 200-step undo journal fills instantly | addPrimitive pushes one undo per call | Build geometry and use _finalizeGenToScene |
| Export contains the whole scene | Mesh exporters walk objs | Recipe 7, or _expSTEPRun |
| After N fillets nothing works | WASM heap full of embind instances | .delete() every instance |
| The browser freezes mid-script | Long synchronous block on the main thread | await new Promise(r=>setTimeout(r,0)) |
SK is undefined | Sketcher module not reached yet | It exists from load; check for a script error at startup |
| Custom geometry renders inside-out | Triangle winding reversed | Swap two vertices per triangle, recompute normals |
20How this was verified
Static reading finds what is declared. It does not tell you what is actually reachable, which is the only thing a script cares about.
Source pass
- 10 shipped files parsed
- 678 top-level function declarations
- 243 without a leading underscore
Live enumeration
- App served over HTTP, headless browser
- Global scope diffed against a blank page
- 102 sketcher functions found absent
Execution
- All 11 primitives created
- Recipes 1, 2, 5, 6, 8 run for real
- Async flags read off the functions
That second step is what corrected the picture: the sketcher's internals never reach the global scope, top-level const bindings never land on window, and toggleXRay turned out to be async while _expSTEPRun and _occtLoad return promises without being declared async. The figure of 576 reachable global functions, 189 of them public, is the source count minus the isolated module.
Numbers quoted elsewhere on this page — a 12-triangle cube, a 2 520-triangle sphere at 32 segments, 8 000 mm³ for a default cube, 7 694 bytes of AP242 for one cube, a 30 × 12 × 34.64 mm hexagonal prism — came back from that running instance, not from reading the code.