@womp/spark-agent 4.4.0 → 5.0.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 (53) hide show
  1. package/.env.example +6 -0
  2. package/README.md +58 -97
  3. package/dist/bench-camera.d.ts +9 -0
  4. package/dist/bench-camera.js +32 -0
  5. package/dist/bounded-output.js +8 -19
  6. package/dist/capabilities/geometry.d.ts +0 -3
  7. package/dist/capabilities/geometry.js +7 -8
  8. package/dist/capabilities/openscad-instructions.d.ts +1 -1
  9. package/dist/capabilities/openscad-instructions.js +80 -81
  10. package/dist/capabilities/openscad.d.ts +0 -3
  11. package/dist/capabilities/openscad.js +1 -2
  12. package/dist/capabilities/scene.d.ts +4 -11
  13. package/dist/capabilities/scene.js +39 -52
  14. package/dist/generated/geometry-declarations.d.ts +1 -1
  15. package/dist/generated/geometry-declarations.js +1 -1
  16. package/dist/generated/openscad-declarations.d.ts +1 -1
  17. package/dist/generated/openscad-declarations.js +1 -1
  18. package/dist/generated/scene-declarations.d.ts +1 -1
  19. package/dist/generated/scene-declarations.js +1 -1
  20. package/dist/native-executor.js +2 -2
  21. package/dist/openscad.d.ts +1 -2
  22. package/dist/openscad.js +1 -19
  23. package/dist/quickjs-executor.js +1 -1
  24. package/dist/repair-state.d.ts +2 -0
  25. package/dist/repair-state.js +15 -0
  26. package/dist/run-metrics.js +21 -7
  27. package/dist/runtime.d.ts +1 -5
  28. package/dist/runtime.js +8 -14
  29. package/dist/scene-environment.js +1 -1
  30. package/dist/scene-health.d.ts +1 -2
  31. package/dist/scene-health.js +13 -22
  32. package/dist/scene-operations.d.ts +5 -5
  33. package/dist/scene-operations.js +24 -43
  34. package/dist/sdk-geometry-host.d.ts +2 -2
  35. package/dist/sdk-geometry-host.js +44 -61
  36. package/dist/sdk-openscad-host.js +63 -102
  37. package/dist/sdk-scene-health-host.d.ts +2 -1
  38. package/dist/sdk-scene-health-host.js +54 -4
  39. package/dist/sdk-scene-host.d.ts +29 -5
  40. package/dist/sdk-scene-host.js +34 -31
  41. package/dist/sdk-scene-screenshot.d.ts +2 -2
  42. package/dist/sdk-scene-screenshot.js +3 -15
  43. package/dist/server/scene-access.d.ts +4 -0
  44. package/dist/server/scene-access.js +40 -0
  45. package/dist/server/standalone.js +24 -19
  46. package/dist/simple-bench-prompts.d.ts +5 -28
  47. package/dist/simple-bench-prompts.js +2 -1
  48. package/dist/simple-bench.js +11 -23
  49. package/dist/womp-scene-api.d.ts +30 -0
  50. package/dist/womp-scene-api.js +106 -24
  51. package/package.json +139 -139
  52. package/dist/server/scene-initialization.d.ts +0 -3
  53. package/dist/server/scene-initialization.js +0 -44
package/.env.example ADDED
@@ -0,0 +1,6 @@
1
+ OPENAI_API_KEY=
2
+ ANTHROPIC_API_KEY=
3
+ GEMINI_API_KEY=
4
+ KAKAPO_URL=ws://127.0.0.1:5502
5
+ PORT=8787
6
+ SPARK_STORAGE_PATH=.spark/spark.sqlite
package/README.md CHANGED
@@ -6,7 +6,23 @@ Spark Agent uses AI SDK 7 `UIMessage` and `UIMessageChunk` as its public chat pr
6
6
  JavaScript uses the host's executor and capabilities. Native browser execution and isolated QuickJS
7
7
  execution use the same model loop. The host owns conversations, artifacts, authorization, and connections.
8
8
 
9
- ## Requirements
9
+ ## Requirements
10
+
11
+ ### Migrating to 5.0
12
+
13
+ - Replace scene capability `read`/`edit` with `access(callback)`. Inspect the current draft
14
+ through `scene.nodes({ ids, name, type, parentId, offset, limit })`, not a `nodes` array.
15
+ Results default to 100, with a maximum page size of 1,000.
16
+ - Update custom scene hosts to `SceneHost.access` and SDK adapters to `acquireAccess`.
17
+ `onCommitted` receives the acknowledged scene; keep host access suspended until synchronization succeeds.
18
+ - Remove scene revision arguments from Geometry/OpenSCAD writes. Current parameters and overrides
19
+ are prepared inside protected access. Hosts must still enforce connection/project ownership.
20
+ - Screenshot calls capture the current native viewport; remove target, view and camera options.
21
+ - Scene health is explicitly requested through `scene.health()`, not automatically after each edit.
22
+ - This release requires Kakapo SDK 0.7.0. Native and QuickJS executors now allow 100,000 host calls
23
+ per execution. Receipts, cancellation, timeout limits and uncertain-outcome handling remain enforced.
24
+
25
+ ### Runtime
10
26
 
11
27
  - Node.js 22.13 or newer
12
28
  - AI SDK 7
@@ -102,11 +118,12 @@ A failed barrier aborts the loop. The default model-step limit is 100; a limit r
102
118
 
103
119
  Spark does not estimate or limit input tokens or automatically shorten conversation history.
104
120
  The provider enforces its context limit; rejections follow the normal provider-error path.
105
- Provider-reported usage and cost tracking are unchanged.
106
121
 
107
- Screenshot and file APIs return serializable artifact references. `emitImage` and `emitFile` resolve
108
- them through the host's artifact resolver. Keep references resolvable when reloading history, and
109
- pass `createSparkTools(...)` to AI SDK's `convertToModelMessages` to reconstruct multimodal results.
122
+ Screenshot and file APIs return serializable artifact references. `emitImage` and `emitFile` resolve
123
+ them through the host's artifact resolver. Keep references resolvable when reloading history, and
124
+ pass `createSparkTools(...)` to AI SDK's `convertToModelMessages` to reconstruct multimodal results.
125
+ `emitImage(artifact, { inspect: false })` displays an image without adding its bytes to model input,
126
+ including restored history. Inspection is enabled by default.
110
127
  Kakapo skill declarations are generated from its actual typed API during the build and packaged as
111
128
  JavaScript strings; they do not need filesystem or Markdown loading in the browser.
112
129
 
@@ -142,9 +159,11 @@ const chat = useChat<SparkUIMessage>({
142
159
  ```
143
160
 
144
161
  Render `message.parts`. Standard AI SDK parts represent text, reasoning, files, tool inputs, tool
145
- outputs, approvals, and step boundaries. Spark adds typed `data-console`, `data-artifact`,
146
- `data-run-status`, `data-step-metrics`, and `data-execution-receipt` parts. Receipts are emitted before
147
- their storage barrier, keeping known effects visible even when saving the tool result fails.
162
+ outputs, approvals, and step boundaries. Spark adds typed `data-console`, `data-artifact`,
163
+ `data-run-status`, `data-step-metrics`, and `data-execution-receipt` parts. Receipts are emitted before
164
+ their storage barrier, keeping known effects visible even when saving the tool result fails.
165
+ Hosts can consume `data-tool-input-label` once the tool's label field is complete, while the
166
+ remaining input is still streaming.
148
167
 
149
168
  An explicit Stop action must cancel the durable run before stopping local observation:
150
169
 
@@ -174,7 +193,7 @@ that turn, independently of the selector.
174
193
 
175
194
  Custom stores must implement `saveConversationSettings` as a settings-only update, and preserve
176
195
  stored settings during `saveConversation` so streaming history saves cannot overwrite preferences.
177
- The built-in memory and SQLite stores do this without a database migration.
196
+ The built-in memory and SQLite stores implement this contract.
178
197
 
179
198
  The SSE response contains ordered persistence envelopes around standard AI SDK chunks:
180
199
 
@@ -219,9 +238,17 @@ app.use(
219
238
  );
220
239
  ```
221
240
 
222
- `MemorySparkAgentStore` and `MemorySparkArtifactStore` are development implementations. Production
223
- hosts must supply durable implementations backed by their database/object storage and must preserve
224
- the ordering contract of `appendRunChunk()`.
241
+ `MemorySparkAgentStore` and `MemorySparkArtifactStore` are development implementations. Production
242
+ hosts must supply durable implementations backed by their database/object storage and must preserve
243
+ the ordering contract of `appendRunChunk()`.
244
+
245
+ `SparkAgentServiceOptions.endOrphanedRuns` defaults to `false`. Enable it only when one process
246
+ owns the store: a message or Stop then ends unfinished runs not held by that process. On a shared
247
+ store, enabling it could end another process's live run.
248
+
249
+ Custom stores can implement `releaseActiveRun(conversationId, runId, updatedAt)` to clear the active
250
+ run only if its ID still matches. Memory and SQLite stores implement it. Without it, the service
251
+ serializes read-and-save release against its own submissions.
225
252
 
226
253
  ## Standalone development server
227
254
 
@@ -257,8 +284,8 @@ acknowledgement proves only renderer-local history, not durable backend storage.
257
284
  The standalone server persists conversations, run streams, and screenshot bytes in SQLite. Only
258
285
  one local process can execute against a database. After a terminated process is replaced, startup
259
286
  preserves recorded output and marks abandoned queued/running runs incomplete without replaying
260
- scene edits. Pending approvals remain pending. Full operation reconciliation and run continuation
261
- are still being integrated. Node 22 reports an experimental warning for its built-in SQLite module.
287
+ scene edits. Pending approvals remain pending; startup does not resume abandoned runs.
288
+ Node 22 reports an experimental warning for its built-in SQLite module.
262
289
 
263
290
  Set `SPARK_SCENE_IDENTITY` only when the host can verify that the value names the same durable
264
291
  scene across renderer reconnects. Without it, Spark uses a process-scoped identity and does not
@@ -267,9 +294,8 @@ authorize interrupted scene mutations to continue after a server restart.
267
294
  `createStandaloneSparkAgentApp(options)` is asynchronous. Await it before listening, and call
268
295
  `await app.dispose()` on shutdown to settle active runs and release storage ownership.
269
296
 
270
- This development branch currently installs `kakapo-sdk` from the sibling checkout. Run `yarn build`
271
- in `../kakapo-sdk` before installing Spark dependencies. A registry version must replace this local
272
- dependency before release; no new package has been published by this migration.
297
+ When using a sibling `kakapo-sdk` dependency, run `yarn build` in `../kakapo-sdk` before installing
298
+ Spark dependencies. Replace local dependencies with registry versions before publishing.
273
299
 
274
300
  For the explicit fictional account profile, use `yarn dev:demo-account` or
275
301
  `spark-agent --profile demo-account`. It adds the `account` skill and `womp.account.read`
@@ -329,7 +355,10 @@ The standalone host uses a fresh QuickJS runtime for each JavaScript execution.
329
355
  or browser globals and receives only the composed capability APIs. Imports, filesystem access,
330
356
  network APIs, and host environment variables are not exposed. QuickJS limits memory, stack size,
331
357
  execution time, source size, host calls, and data crossing the host bridge. Browser hosts can instead
332
- choose native page execution, with the page-global access and interruption limitations described above.
358
+ choose native page execution, with the page-global access and interruption limitations described above.
359
+ Both executors allow 100,000 host calls per execution by default; the counter resets for each
360
+ execution. QuickJS supports overriding this through `maxHostCalls`. Cancellation, timeouts,
361
+ and the other execution limits still apply.
333
362
 
334
363
  QuickJS isolates generated code from the JavaScript host API; it is not an operating-system security
335
364
  boundary. Multi-tenant deployments must additionally isolate workers, authorize every Kakapo
@@ -345,85 +374,17 @@ yarn test
345
374
  yarn web:build
346
375
  ```
347
376
 
348
- ## Release procedure
349
-
350
- Releases use a manual package publication; no separate release service is required.
351
-
352
- ### Migration to 0.3
353
-
354
- - The package root is browser-safe. Import `KakapoRuntime`, `createWompSceneApi`, and its enums
355
- from `/kakapo`, and `QuickJSJavaScriptExecutor` from `/quickjs`.
356
- - `runSparkAgent` takes the host's selected model, executor, capability composition, artifact
357
- resolver, and persistence hooks. The host owns connection setup and disposal. The model sees
358
- only `read_skill` and `execute_javascript`; domain methods live under the supplied `womp` APIs.
359
- - Replace fixed scene/screenshot tool history with the host's history adapter. Use
360
- `convertSparkMessages` when rebuilding model input from current Spark UI messages so artifact
361
- references become model-visible content. Preserve old records; do not replay historical tools.
362
- - Custom server executors receive `uiMessages` and convert them using their actual capability
363
- tools. `SparkRunExecutionContext.modelMessages` has been removed.
364
- - Custom JavaScript executors must honor cancellation and host-owned user waits. Callback-based
365
- scene edits and artifact references work with both the native and QuickJS executors.
366
-
367
- ### 4.1.0
368
-
369
- - `emitImage(artifact, { inspect: false })` displays an image without adding its bytes to model input, including restored history. Inspection remains the default.
370
- - Generation instructions use completed outputs directly and avoid redundant listing or emission; explicit inspection and follow-up imports remain supported.
371
- - Streamed tool labels are emitted only after their label field is complete. Hosts can consume `data-tool-input-label` while the rest of the input streams.
372
- - Server cancellation waits for active execution to settle before reporting its terminal state.
373
- - Includes the latest geometry material, scalar-port, and primitive improvements from main.
374
-
375
- ### 4.2.2
376
-
377
- The published 4.2.1 was packed from the 4.2.0 sources and differs from 4.2.0 only in its version
378
- number, so none of the changes below are in it. Use 4.2.2.
379
-
380
- Compatible: both additions are optional, and a host that sets neither behaves as before except that a
381
- run whose progress cannot be stored now ends instead of staying `running`.
382
-
383
- - A run whose stream chunk, `running` status or ending cannot be stored ends `failed` with a sentence
384
- instead of staying `running` until a restart. The final status and the conversation release are
385
- retried in the background for about five minutes; a message or a Stop for that conversation stores
386
- the ending at once.
387
- - `SparkAgentServiceOptions.endOrphanedRuns` (default `false`): a message or a Stop ends an unfinished
388
- active run that no execution in this process holds, with "This run stopped unexpectedly and could
389
- not be finished." Turn it on only when one process owns the store; on a shared store it would end
390
- another process's live runs.
391
- - `SparkAgentStore.releaseActiveRun(conversationId, runId, updatedAt)` (optional): clears the active
392
- run only while it still names that run. The memory and SQLite stores implement it. A store without
393
- it has the read-and-save release serialised against the service's own submits, so a late retry
394
- cannot overwrite a newer message.
395
-
396
- ### 4.2.0
397
-
398
- Incompatible: a color that can only be a 0-1 one is now refused where it used to be stored as
399
- near-black, and the `scene-editing` skill payload changed. Hosts that pin the skill surface in a
400
- fixture must regenerate it.
401
-
402
- - Model-facing colors state their domain in the declared parameter name: `nodeSetColor`,
403
- `materialSetColor`, `materialSetSecondaryColor`, `materialSetSubsurfaceColor` and
404
- `setBackgroundColor` take `rgb255`, and the internal type is `Color255`. The `scene-editing` skill
405
- version is 30.
406
- - A color whose every component is strictly between 0 and 1 and none an integer throws, naming the
407
- 0-255 conversion (`{r:0.2, g:0.4, b:0.6}` → "use `{r:51, g:102, b:153}` … or `{r:0, g:0, b:0}` for
408
- black"). Nothing is auto-scaled: `{r:1, g:0, b:0}`, `{r:0, g:0, b:0}`, `{r:0.5, g:0, b:0}` and
409
- every 0-255 color write exactly as before.
410
- - `scene.health` accepts the object ids `scene.read()` publishes, including `0`; a value that is not
411
- an id is refused with a sentence naming it instead of a serialized schema error. Ids belonging to
412
- no Area (the root, a root-level Group, a Light) are dropped and reported rather than making the
413
- whole reading `unverified`.
414
- - A tool result can no longer cost a turn. Geometry diagnostics quote a source line once per line
415
- and cap at 5 diagnostics and 4 KiB; the `execute_javascript` envelope bounds the error to 8 KiB and
416
- the receipts to a 1 MiB model input, always as valid JSON. Only model input is bounded —
417
- `persistence.afterToolCall`, the UI stream and the host's own receipts keep the original object.
418
- - A scene-health receipt keeps its totals, limits and overflow flags whole and bounds the per-Area
419
- rows to 8, affected and overflowing Areas first, naming the omission in `reason`. Health read for a
420
- decision (`hasGeometryOverflow`, `hasSceneResourcePressure`, `options.onHealth`) still sees every
421
- row, so no repair decision changes. A consumer fixture that recorded a receipt with more than 8
422
- Areas will differ.
423
- - `operations.generate` keeps a host's own cancellation message on a stopped generation instead of
424
- replacing it with the bare abort reason.
425
-
426
- ### Publishing
377
+ ## Release procedure
378
+
379
+ Releases use a manual package publication; no separate release service is required.
380
+
381
+ Versions follow **MAJOR.MINOR.PATCH** (for example, `4.4.0`):
382
+
383
+ - **Major:** breaking changes.
384
+ - **Minor:** backward-compatible new features.
385
+ - **Patch:** backward-compatible bug fixes.
386
+
387
+ ### Publishing
427
388
 
428
389
  1. Choose an unused package version after checking `npm view @womp/spark-agent versions --json`.
429
390
  Update `package.json` and record public API changes and migration notes. Use a new minor version
@@ -0,0 +1,9 @@
1
+ import type { KakapoAPI, Vec3, CameraConfiguration } from "@womp/kakapo-sdk";
2
+ export type BenchBounds = {
3
+ min: Vec3;
4
+ max: Vec3;
5
+ };
6
+ /** Groups organize geometry; query their visible renderable children, not Group bounds. */
7
+ export declare function benchGeometryIds(api: Pick<KakapoAPI, "listNodes">, parentId?: number): number[];
8
+ /** Fixed Spark-space three-quarter view, fitted against all eight native-world bounding-box corners. */
9
+ export declare function benchCamera(bounds: BenchBounds, aspect: number): CameraConfiguration;
@@ -0,0 +1,32 @@
1
+ import { toEnginePosition } from "./coordinates.js";
2
+ /** Groups organize geometry; query their visible renderable children, not Group bounds. */
3
+ export function benchGeometryIds(api, parentId = 0) {
4
+ return api.listNodes({ parentId }).flatMap(node => {
5
+ if (("hidden" in node && node.hidden) || node.kind === "light")
6
+ return [];
7
+ return node.kind === "group" ? benchGeometryIds(api, node.id) : [node.id];
8
+ });
9
+ }
10
+ /** Fixed Spark-space three-quarter view, fitted against all eight native-world bounding-box corners. */
11
+ export function benchCamera(bounds, aspect) {
12
+ const axes = ["x", "y", "z"];
13
+ if (!Number.isFinite(aspect) || aspect <= 0 || axes.some(a => !Number.isFinite(bounds.min[a]) || !Number.isFinite(bounds.max[a]) || bounds.min[a] > bounds.max[a]))
14
+ throw new Error("Benchmark framing requires finite ordered bounds and a positive aspect ratio.");
15
+ const target = { x: (bounds.min.x + bounds.max.x) / 2, y: (bounds.min.y + bounds.max.y) / 2, z: (bounds.min.z + bounds.max.z) / 2 };
16
+ const offset = toEnginePosition({ x: 1, y: 0.65, z: 1 });
17
+ const length = Math.hypot(offset.x, offset.y, offset.z);
18
+ const back = { x: offset.x / length, y: offset.y / length, z: offset.z / length };
19
+ const horizontal = Math.hypot(back.x, back.z);
20
+ const right = { x: back.z / horizontal, y: 0, z: -back.x / horizontal };
21
+ const up = { x: -back.x * back.y / horizontal, y: horizontal, z: -back.z * back.y / horizontal };
22
+ const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
23
+ const tanH = Math.tan(Math.PI / 6);
24
+ let distance = 0.1;
25
+ for (const x of [bounds.min.x, bounds.max.x])
26
+ for (const y of [bounds.min.y, bounds.max.y])
27
+ for (const z of [bounds.min.z, bounds.max.z]) {
28
+ const point = { x: x - target.x, y: y - target.y, z: z - target.z };
29
+ distance = Math.max(distance, dot(point, back) + 1.1 * Math.max(Math.abs(dot(point, right)) / tanH, Math.abs(dot(point, up)) * aspect / tanH));
30
+ }
31
+ return { position: { x: target.x + back.x * distance, y: target.y + back.y * distance, z: target.z + back.z * distance }, target, projection: "perspective", horizontalFovDegrees: 60 };
32
+ }
@@ -1,18 +1,5 @@
1
- // Byte budgets for everything a tool call hands back to the model.
2
- //
3
- // WHY THIS FILE EXISTS. `execute_javascript` already refuses a printed payload
4
- // or a returned value over 1 MiB (agent-tools.ts `jsonValue` and `print`), but
5
- // nothing bounded the rest of the envelope: the `error` string and the receipts.
6
- // One rejected `geometry.write` over a one-character enum value produced a
7
- // compiler diagnostic that repeated the offending source line once per
8
- // diagnostic, and the resulting tool result cost a whole turn -- 65,136 output
9
- // tokens, a run that ended `incomplete`, ~235 KB rendered into the panel and an
10
- // unresponsive tab. A bad argument must cost one tool result, not a turn, so the
11
- // same budget that guards `print` now guards the whole serialized result.
12
- //
13
- // These helpers truncate and say so. They never throw: an over-long diagnostic
14
- // is still the most useful thing the model can read, so it is cut and labelled
15
- // rather than replaced.
1
+ // Bound the full serialized tool result, including errors and receipts, before
2
+ // handing it to the model. Oversized diagnostics are cut and labelled.
16
3
  const encoder = new TextEncoder();
17
4
  const decoder = new TextDecoder();
18
5
  export const MODEL_RESULT_BYTE_LIMIT = 1024 * 1024;
@@ -37,10 +24,11 @@ export function truncateUtf8(text, limit) {
37
24
  * incomplete rather than wrong.
38
25
  */
39
26
  export function boundedText(text, limit, label) {
40
- const total = byteLength(text);
27
+ const bytes = encoder.encode(text);
28
+ const total = bytes.byteLength;
41
29
  if (total <= limit)
42
30
  return text;
43
- const kept = truncateUtf8(text, limit);
31
+ const kept = decoder.decode(bytes.subarray(0, limit)).replace(/�$/u, "");
44
32
  return `${kept}\n[${label} truncated at ${limit} bytes; ${total - byteLength(kept)} bytes omitted.]`;
45
33
  }
46
34
  /**
@@ -67,12 +55,13 @@ export function boundedResultText(output, limit = MODEL_RESULT_BYTE_LIMIT) {
67
55
  if (receipt.details === undefined)
68
56
  return receipt;
69
57
  const details = asText(receipt.details);
70
- if (byteLength(details) <= detailsLimit)
58
+ const detailsBytes = byteLength(details);
59
+ if (detailsBytes <= detailsLimit)
71
60
  return receipt;
72
61
  return {
73
62
  ...receipt,
74
63
  details: {
75
- omitted: `This receipt's details were ${byteLength(details)} bytes, over the ${detailsLimit} byte limit for one receipt in a tool result. Read the receipt from the host if the outcome matters.`,
64
+ omitted: `This receipt's details were ${detailsBytes} bytes, over the ${detailsLimit} byte limit for one receipt in a tool result. Read the receipt from the host if the outcome matters.`,
76
65
  },
77
66
  };
78
67
  });
@@ -17,13 +17,11 @@ export type GeometryTarget = {
17
17
  instanceId?: number;
18
18
  source: string | null;
19
19
  instances: GeometryInstance[];
20
- revision: string;
21
20
  diagnostics: string[];
22
21
  };
23
22
  export type GeometryWrite = {
24
23
  objectId: number;
25
24
  source: string;
26
- expectedRevision: string;
27
25
  };
28
26
  export type GeometryWriteResult = {
29
27
  status: "applied" | "rejected";
@@ -31,7 +29,6 @@ export type GeometryWriteResult = {
31
29
  graphId?: number;
32
30
  instanceId?: number;
33
31
  affectedObjectIds: number[];
34
- revision: string;
35
32
  diagnostics: string[];
36
33
  };
37
34
  export type WompGeometryApi = {
@@ -5,7 +5,6 @@ const write = z
5
5
  .object({
6
6
  objectId,
7
7
  source: z.string().trim().min(1),
8
- expectedRevision: z.string().min(1),
9
8
  })
10
9
  .strict();
11
10
  export function createGeometryCapability(host, reference) {
@@ -15,26 +14,26 @@ export function createGeometryCapability(host, reference) {
15
14
  name: "geometry-nodes",
16
15
  version: "5",
17
16
  description: "Create and edit visual Geometry Nodes graphs with adjustable parameters and animation.",
18
- instructions: `Use womp.geometry for requested Geometry Nodes, parametric organic/procedural geometry, or animation.
17
+ instructions: `Use womp.geometry for requested Geometry Nodes, parametric organic/procedural geometry, or animation.
19
18
  Keep follow-up edits to an existing Geometry Nodes object in its graph, preserving its adjustable parameters and instances.
20
19
  Read the target after any scene edit this build needs. read(0) reads the scene root as a creation container.
21
20
  write updates an existing shared graph, attaches a new graph to an Area without one, or creates a new Area beneath the root/Group target. Use the returned objectId for follow-up edits.
22
- Read geometry-nodes-reference before writing source. Supply the complete FlowIR text and the exact revision returned by the latest read. JavaScript calls execute JavaScript; source is a FlowIR string, not JavaScript or JSON.
23
- After a stale revision rejection, read the latest graph and adapt the edit while preserving intervening user changes. Ask only when the edits conflict or remain ambiguous; never retry the old replacement with a refreshed revision token.
21
+ Read geometry-nodes-reference before writing source. Supply the complete FlowIR text after inspecting the target. JavaScript calls execute JavaScript; source is a FlowIR string, not JavaScript or JSON.
22
+ Inspect the current target before replacing its graph; preserve unrelated geometry and parameter overrides.
24
23
  If source is null and diagnostics are present, the existing graph cannot be edited without loss. Explain the limitation; never treat it as empty or create a replacement to bypass the refusal.
25
24
  Preserve unrelated statements, parameter names, and FlowIR node IDs from the read. Shared graph edits affect all listed instances; report the affected objects. Valid overrides migrate by parameter name and type. Removed or type-changed parameters return to graph defaults and are reported.
26
25
  Use min/max/step for naturally bounded parameters and omit ranges for free-form positions/rotations. Meaningful parameter names become editor labels. Do not invent label fields.
27
26
  SetMaterial assigns one scene material to every primitive it receives. Its Material input is a scene material ID, not a color; 0 selects the scene default material.
28
- Obtain that ID from the scene-editing skill inside one scene.edit, where those lookups live: materialList with materialGetName and materialGetColor to reuse an existing material, materialCreate with materialSetColor and materialSetName for a new one, or listSuperMaterials with materialImport for a library material. Prefer reusing a material that already matches the request over creating another, and do not recolor a shared material unless every object using it should change.
29
- A scene edit changes the scene revision, so create or import the material before reading the geometry target, then write, all within one JavaScript call. Each call has a fresh scope and cannot read an earlier call's variables.
27
+ Obtain that ID from the scene-editing skill inside one scene.access, where those lookups live: materialList with materialGetName and materialGetColor to reuse an existing material, materialCreate with materialSetColor and materialSetName for a new one, or listSuperMaterials with materialImport for a library material. Prefer reusing a material that already matches the request over creating another, and do not recolor a shared material unless every object using it should change.
28
+ Create or import the material inside scene.access before writing the geometry target, all within one JavaScript call. Each call has a fresh scope and cannot read an earlier call's variables.
30
29
  Positions and sizes are millimetres; Boolean Blend uses the conversion in geometry-nodes-reference. Coordinates are right-handed, +X right, +Y up, +Z forward. Rotations are degrees, intrinsic X-Z-Y.
31
30
  On compiler errors, repair the source in this same conversation and retry with a fresh read. A rejected write made no change. Committed/unknown effects remain in execution receipts even if a later call fails; inspect before retrying.
32
31
  After applying, read the returned object and use the scene screenshot API with emitImage to inspect the applied result.`,
33
32
  declarations: WOMP_GEOMETRY_DECLARATIONS,
34
33
  examples: [
35
34
  "const target = await womp.geometry.read(objectId); print(target);",
36
- "const target = await womp.geometry.read(objectId); print(await womp.geometry.write({ objectId: target.objectId, source: editedFlowIR, expectedRevision: target.revision }));",
37
- "const materialId = await womp.scene.edit(scene => { const id = scene.materialCreate(); scene.materialSetName(id, 'Red'); scene.materialSetColor(id, { r: 255, g: 0, b: 0 }); return id; }, (await womp.scene.read()).revision); const target = await womp.geometry.read(0); const source = ['param Float size = 20.0 min=1 max=100 step=1', 'node box Primitive Type=Cube Position=Vec3(0.0,0.0,0.0) Rotation=Vec3(0.0,0.0,0.0) Size=size Roundness=0.0', `node painted SetMaterial Material=${materialId} Geometry=box.Geometry`, 'out Geometry=painted.Geometry'].join('\\n'); print(await womp.geometry.write({ objectId: target.objectId, source, expectedRevision: target.revision }));",
35
+ "const target = await womp.geometry.read(objectId); print(await womp.geometry.write({ objectId: target.objectId, source: editedFlowIR }));",
36
+ "const materialId = await womp.scene.access(scene => { const id = scene.materialCreate(); scene.materialSetName(id, 'Red'); scene.materialSetColor(id, { r: 255, g: 0, b: 0 }); return id; }); const target = await womp.geometry.read(0); const source = ['param Float size = 20.0 min=1 max=100 step=1', 'node box Primitive Type=Cube Position=Vec3(0.0,0.0,0.0) Rotation=Vec3(0.0,0.0,0.0) Size=size Roundness=0.0', `node painted SetMaterial Material=${materialId} Geometry=box.Geometry`, 'out Geometry=painted.Geometry'].join('\\n'); print(await womp.geometry.write({ objectId: target.objectId, source }));",
38
37
  ],
39
38
  },
40
39
  {
@@ -1 +1 @@
1
- export declare const OPENSCAD_INSTRUCTIONS = "Use womp.openscad to create or update one adjustable CAD part. Read an existing part before editing it.\nKeep follow-up edits to an existing CAD part in that part unless the user asks to change representation.\nread(0) returns the root container and a scene revision for creation. read(id) returns an existing\npart or container. Use the scene-editing skill when available to discover other scene objects.\ncreate requires a parentId (0 means the scene root); update requires the existing objectId.\nBoth methods compile the effective source and parameters, apply one undoable change, and check wall thickness.\nA stale revision requires reading again and adapting the edit while preserving intervening user changes.\nAsk the user only when the intended edits conflict or remain ambiguous; never reuse the old replacement\nwith a refreshed revision token. Existing overrides are preserved for parameters still present;\nparams supplies overrides to merge into the current values. Keep top-level parameter names stable.\nReturn complete source on every update and preserve unrelated geometry. Never create a replacement to repair a part.\nCompile errors return status rejected without applying. Creation requires validated placement bounds.\nAn update may apply with validation unavailable; that is a diagnostic, not proof that it compiles.\nAn applied part remains in the scene even if thickness fails or is unavailable. Use the returned objectId for repairs.\nWhen thickness fails, increase thin walls and fragile connections while preserving requested dimensions and openings.\nAt most four applied printability repairs are allowed per logical build/edit in this run. Respect the returned limit;\ndo not bypass it by recreating the part. Unavailable thickness is not a pass. Report unresolved issues honestly.\nBuild connected geometry and avoid features below 1.4 mm. OpenSCAD source dimensions use millimetres,\nindependently of the scene display unit. New parts are centered and grounded from compiled bounds.\nOpenSCAD is Z-up; the scene-editing API is Y-up. For an unrotated part at its creation-time scale and an identity parent transform,\na source point [x,y,z] mm maps to scene {x:y, y:z, z:-x} times the unitMultiplier returned by scene.read(), plus nodeGetPos(partId).\nAccount for the part and parent transforms when they are not identity; source coordinates are not scene coordinates.\nAfter a major change, use the scene screenshot API and emitImage to inspect the applied result.\nA committed effect can survive later failure. Inspect receipts and the existing part before retrying.\nDo not print source in the final answer unless requested; describe the result and any unresolved diagnostics.\n\nRUNTIME\n- Produce a single OpenSCAD file.\n- Do not use imports, includes, external libraries, text, hull, minkowski, polyhedron, or extrudes.\n- Prefer cube, sphere, cylinder, translate, rotate, color, union, difference, and intersection.\n- Do not use $fn, $fa, or $fs; tessellation controls are ignored.\n- Build faceted geometry by combining supported primitives with CSG operations and transforms.\n- Do not use scale(); size primitives directly instead.\n- Use color(\"#rrggbb\") around distinct visible parts.\n- Keep the model centered near the origin and use millimeter-like units consistently.\n- For organic blending, use goop(radius_mm) { ... }. The radius is in millimetres before parent scaling, matching scene-editing; do not multiply by project unitMultiplier. It is ordered: the first child seeds with blend 0, and each later child uses radius_mm.\n- Put core primitives first. Avoid wrapping goop children in union() when they should blend individually.\n\nSCREWS AND THREADS\n- Copy helical_ridge below for rounded screw threads, necks, caps, and coils. It joins spheres with cylinders; never use stacked rings or disconnected spheres.\n- Dimensions are mm: radius = ridge centerline radius; pitch = axial travel/turn; tube_radius = half ridge thickness; handedness = 1 (right) or -1 (left).\n- For a hand control use thread_hand = 1; // [-1:Left, 1:Right] and pass it directly. Ternary expressions (?:) are unsupported.\n- Embed the ridge in the shaft/neck (e.g. radius = shaft_radius). Hollow fittings: subtract the bore from body + thread. Internal threads: subtract the helix from the socket wall with mating clearance. Mating parts need matching pitch and handedness.\n- Expose diameter, pitch, thread length, and thickness in Customizer; derive sampling. Keep 2 * tube_radius < pitch and allow tube_radius beyond both path endpoints for rounded ends. This is a rounded profile, not ISO/metric V-thread; never claim standards compliance.\n- Build heads/grips separately using cylinders, tapered cylinders, and radial ribs/cutters, not thread samples.\n\nmodule helical_ridge(radius, pitch, turns, tube_radius, handedness) {\n if (radius > 0 && pitch > 0 && turns > 0 && tube_radius > 0) {\n // Limit chord error to 0.03 mm or 3% of the ridge radius.\n chord_error = min(0.03, tube_radius * 0.03);\n steps_per_turn = max(48, ceil(180 / acos(1 - min(1, chord_error / radius))));\n steps = ceil(turns * steps_per_turn);\n for (i = [0:steps]) {\n angle = handedness * 360 * turns * i / steps;\n p = [radius * cos(angle), radius * sin(angle), pitch * turns * i / steps];\n translate(p) sphere(r = tube_radius);\n if (i < steps) {\n next_angle = handedness * 360 * turns * (i + 1) / steps;\n dx = radius * cos(next_angle) - p[0];\n dy = radius * sin(next_angle) - p[1];\n dz = pitch * turns / steps;\n segment_length = sqrt(dx * dx + dy * dy + dz * dz);\n translate(p)\n rotate([0, acos(dz / segment_length), atan2(dy, dx)])\n cylinder(h = segment_length, r = tube_radius);\n }\n }\n }\n}\n\nPARAMETERS\n- Make important top-level constants user-adjustable with OpenSCAD Customizer comments:\n - Numbers with ranges: body_radius = 12; // [4:0.5:40]\n - Counts: leg_count = 4; // [2:8]\n - Booleans: show_base = true;\n - Options: style = \"round\"; // [round, sharp, chunky]\n- Prefer stable size/count/ratio/style controls; keep fragile dependent dimensions as local expressions.\n\nSUPPORTED SUBSET\n- Export is best-effort from a supported CSG subset: cube, sphere, cylinder, translate, rotate, color, union, difference, intersection, and goop.\n- difference() cutters become subtractive primitives, so use clean cutters that fully pass through the target solid.\n- Unsupported OpenSCAD may validate but can warn or skip during export; prefer the supported subset above.";
1
+ export declare const OPENSCAD_INSTRUCTIONS = "Use womp.openscad to create or update one adjustable CAD part. Read an existing part before editing it.\nKeep follow-up edits to an existing CAD part in that part unless the user asks to change representation.\nread(0) returns the root container for creation. read(id) returns an existing\npart or container. Use the scene-editing skill when available to discover other scene objects.\ncreate requires a parentId (0 means the scene root); update requires the existing objectId.\nBoth methods compile the effective source and parameters, apply one undoable change, and check wall thickness.\nPreserve intervening user changes. Ask the user when the intended edits conflict or remain ambiguous.\nExisting overrides are preserved for parameters still present;\nparams supplies overrides to merge into the current values. Keep top-level parameter names stable.\nReturn complete source on every update and preserve unrelated geometry. Never create a replacement to repair a part.\nCompile errors return status rejected without applying. Creation requires validated placement bounds.\nAn update may apply with validation unavailable; that is a diagnostic, not proof that it compiles.\nAn applied part remains in the scene even if thickness fails or is unavailable. Use the returned objectId for repairs.\nWhen thickness fails, increase thin walls and fragile connections while preserving requested dimensions and openings.\nAt most four applied printability repairs are allowed per logical build/edit in this run. Respect the returned limit;\ndo not bypass it by recreating the part. Unavailable thickness is not a pass. Report unresolved issues honestly.\nBuild connected geometry and avoid features below 1.4 mm. OpenSCAD source dimensions use millimetres,\nindependently of the scene display unit. New parts are centered and grounded from compiled bounds.\nOpenSCAD is Z-up; the scene-editing API is Y-up. For an unrotated part at its creation-time scale and an identity parent transform,\na source point [x,y,z] mm maps to scene {x:y, y:z, z:-x} times the scene.unitMultiplier inside the access callback, plus nodeGetPos(partId).\nAccount for the part and parent transforms when they are not identity; source coordinates are not scene coordinates.\nAfter a major change, use the scene screenshot API and emitImage to inspect the applied result.\nA committed effect can survive later failure. Inspect receipts and the existing part before retrying.\nDo not print source in the final answer unless requested; describe the result and any unresolved diagnostics.\n\nRUNTIME\n- Produce a single OpenSCAD file.\n- Do not use imports, includes, external libraries, text, hull, minkowski, polyhedron, or extrudes.\n- Prefer cube, sphere, cylinder, translate, rotate, color, union, difference, and intersection.\n- Do not use $fn, $fa, or $fs; tessellation controls are ignored.\n- Build faceted geometry by combining supported primitives with CSG operations and transforms.\n- Do not use scale(); size primitives directly instead.\n- Use color(\"#rrggbb\") around distinct visible parts.\n- Keep the model centered near the origin and use millimeter-like units consistently.\n- For organic blending, use goop(radius_mm) { ... }. The radius is in millimetres before parent scaling, matching scene-editing; do not multiply by project unitMultiplier. It is ordered: the first child seeds with blend 0, and each later child uses radius_mm.\n- Put core primitives first. Avoid wrapping goop children in union() when they should blend individually.\n\nSCREWS AND THREADS\n- Copy helical_ridge below for rounded screw threads, necks, caps, and coils. It joins spheres with cylinders; never use stacked rings or disconnected spheres.\n- Dimensions are mm: radius = ridge centerline radius; pitch = axial travel/turn; tube_radius = half ridge thickness; handedness = 1 (right) or -1 (left).\n- For a hand control use thread_hand = 1; // [-1:Left, 1:Right] and pass it directly. Ternary expressions (?:) are unsupported.\n- Embed the ridge in the shaft/neck (e.g. radius = shaft_radius). Hollow fittings: subtract the bore from body + thread. Internal threads: subtract the helix from the socket wall with mating clearance. Mating parts need matching pitch and handedness.\n- Expose diameter, pitch, thread length, and thickness in Customizer; derive sampling. Keep 2 * tube_radius < pitch and allow tube_radius beyond both path endpoints for rounded ends. This is a rounded profile, not ISO/metric V-thread; never claim standards compliance.\n- Build heads/grips separately using cylinders, tapered cylinders, and radial ribs/cutters, not thread samples.\n\nmodule helical_ridge(radius, pitch, turns, tube_radius, handedness) {\n if (radius > 0 && pitch > 0 && turns > 0 && tube_radius > 0) {\n // Limit chord error to 0.03 mm or 3% of the ridge radius.\n chord_error = min(0.03, tube_radius * 0.03);\n steps_per_turn = max(48, ceil(180 / acos(1 - min(1, chord_error / radius))));\n steps = ceil(turns * steps_per_turn);\n for (i = [0:steps]) {\n angle = handedness * 360 * turns * i / steps;\n p = [radius * cos(angle), radius * sin(angle), pitch * turns * i / steps];\n translate(p) sphere(r = tube_radius);\n if (i < steps) {\n next_angle = handedness * 360 * turns * (i + 1) / steps;\n dx = radius * cos(next_angle) - p[0];\n dy = radius * sin(next_angle) - p[1];\n dz = pitch * turns / steps;\n segment_length = sqrt(dx * dx + dy * dy + dz * dz);\n translate(p)\n rotate([0, acos(dz / segment_length), atan2(dy, dx)])\n cylinder(h = segment_length, r = tube_radius);\n }\n }\n }\n}\n\nPARAMETERS\n- Make important top-level constants user-adjustable with OpenSCAD Customizer comments:\n - Numbers with ranges: body_radius = 12; // [4:0.5:40]\n - Counts: leg_count = 4; // [2:8]\n - Booleans: show_base = true;\n - Options: style = \"round\"; // [round, sharp, chunky]\n- Prefer stable size/count/ratio/style controls; keep fragile dependent dimensions as local expressions.\n\nSUPPORTED SUBSET\n- Export is best-effort from a supported CSG subset: cube, sphere, cylinder, translate, rotate, color, union, difference, intersection, and goop.\n- difference() cutters become subtractive primitives, so use clean cutters that fully pass through the target solid.\n- Unsupported OpenSCAD may validate but can warn or skip during export; prefer the supported subset above.";
@@ -1,82 +1,81 @@
1
- export const OPENSCAD_INSTRUCTIONS = `Use womp.openscad to create or update one adjustable CAD part. Read an existing part before editing it.
2
- Keep follow-up edits to an existing CAD part in that part unless the user asks to change representation.
3
- read(0) returns the root container and a scene revision for creation. read(id) returns an existing
4
- part or container. Use the scene-editing skill when available to discover other scene objects.
5
- create requires a parentId (0 means the scene root); update requires the existing objectId.
6
- Both methods compile the effective source and parameters, apply one undoable change, and check wall thickness.
7
- A stale revision requires reading again and adapting the edit while preserving intervening user changes.
8
- Ask the user only when the intended edits conflict or remain ambiguous; never reuse the old replacement
9
- with a refreshed revision token. Existing overrides are preserved for parameters still present;
10
- params supplies overrides to merge into the current values. Keep top-level parameter names stable.
11
- Return complete source on every update and preserve unrelated geometry. Never create a replacement to repair a part.
12
- Compile errors return status rejected without applying. Creation requires validated placement bounds.
13
- An update may apply with validation unavailable; that is a diagnostic, not proof that it compiles.
14
- An applied part remains in the scene even if thickness fails or is unavailable. Use the returned objectId for repairs.
15
- When thickness fails, increase thin walls and fragile connections while preserving requested dimensions and openings.
16
- At most four applied printability repairs are allowed per logical build/edit in this run. Respect the returned limit;
17
- do not bypass it by recreating the part. Unavailable thickness is not a pass. Report unresolved issues honestly.
18
- Build connected geometry and avoid features below 1.4 mm. OpenSCAD source dimensions use millimetres,
19
- independently of the scene display unit. New parts are centered and grounded from compiled bounds.
20
- OpenSCAD is Z-up; the scene-editing API is Y-up. For an unrotated part at its creation-time scale and an identity parent transform,
21
- a source point [x,y,z] mm maps to scene {x:y, y:z, z:-x} times the unitMultiplier returned by scene.read(), plus nodeGetPos(partId).
22
- Account for the part and parent transforms when they are not identity; source coordinates are not scene coordinates.
23
- After a major change, use the scene screenshot API and emitImage to inspect the applied result.
24
- A committed effect can survive later failure. Inspect receipts and the existing part before retrying.
25
- Do not print source in the final answer unless requested; describe the result and any unresolved diagnostics.
26
-
27
- RUNTIME
28
- - Produce a single OpenSCAD file.
29
- - Do not use imports, includes, external libraries, text, hull, minkowski, polyhedron, or extrudes.
30
- - Prefer cube, sphere, cylinder, translate, rotate, color, union, difference, and intersection.
31
- - Do not use $fn, $fa, or $fs; tessellation controls are ignored.
32
- - Build faceted geometry by combining supported primitives with CSG operations and transforms.
33
- - Do not use scale(); size primitives directly instead.
34
- - Use color("#rrggbb") around distinct visible parts.
35
- - Keep the model centered near the origin and use millimeter-like units consistently.
36
- - For organic blending, use goop(radius_mm) { ... }. The radius is in millimetres before parent scaling, matching scene-editing; do not multiply by project unitMultiplier. It is ordered: the first child seeds with blend 0, and each later child uses radius_mm.
37
- - Put core primitives first. Avoid wrapping goop children in union() when they should blend individually.
38
-
39
- SCREWS AND THREADS
40
- - Copy helical_ridge below for rounded screw threads, necks, caps, and coils. It joins spheres with cylinders; never use stacked rings or disconnected spheres.
41
- - Dimensions are mm: radius = ridge centerline radius; pitch = axial travel/turn; tube_radius = half ridge thickness; handedness = 1 (right) or -1 (left).
42
- - For a hand control use thread_hand = 1; // [-1:Left, 1:Right] and pass it directly. Ternary expressions (?:) are unsupported.
43
- - Embed the ridge in the shaft/neck (e.g. radius = shaft_radius). Hollow fittings: subtract the bore from body + thread. Internal threads: subtract the helix from the socket wall with mating clearance. Mating parts need matching pitch and handedness.
44
- - Expose diameter, pitch, thread length, and thickness in Customizer; derive sampling. Keep 2 * tube_radius < pitch and allow tube_radius beyond both path endpoints for rounded ends. This is a rounded profile, not ISO/metric V-thread; never claim standards compliance.
45
- - Build heads/grips separately using cylinders, tapered cylinders, and radial ribs/cutters, not thread samples.
46
-
47
- module helical_ridge(radius, pitch, turns, tube_radius, handedness) {
48
- if (radius > 0 && pitch > 0 && turns > 0 && tube_radius > 0) {
49
- // Limit chord error to 0.03 mm or 3% of the ridge radius.
50
- chord_error = min(0.03, tube_radius * 0.03);
51
- steps_per_turn = max(48, ceil(180 / acos(1 - min(1, chord_error / radius))));
52
- steps = ceil(turns * steps_per_turn);
53
- for (i = [0:steps]) {
54
- angle = handedness * 360 * turns * i / steps;
55
- p = [radius * cos(angle), radius * sin(angle), pitch * turns * i / steps];
56
- translate(p) sphere(r = tube_radius);
57
- if (i < steps) {
58
- next_angle = handedness * 360 * turns * (i + 1) / steps;
59
- dx = radius * cos(next_angle) - p[0];
60
- dy = radius * sin(next_angle) - p[1];
61
- dz = pitch * turns / steps;
62
- segment_length = sqrt(dx * dx + dy * dy + dz * dz);
63
- translate(p)
64
- rotate([0, acos(dz / segment_length), atan2(dy, dx)])
65
- cylinder(h = segment_length, r = tube_radius);
66
- }
67
- }
68
- }
69
- }
70
-
71
- PARAMETERS
72
- - Make important top-level constants user-adjustable with OpenSCAD Customizer comments:
73
- - Numbers with ranges: body_radius = 12; // [4:0.5:40]
74
- - Counts: leg_count = 4; // [2:8]
75
- - Booleans: show_base = true;
76
- - Options: style = "round"; // [round, sharp, chunky]
77
- - Prefer stable size/count/ratio/style controls; keep fragile dependent dimensions as local expressions.
78
-
79
- SUPPORTED SUBSET
80
- - Export is best-effort from a supported CSG subset: cube, sphere, cylinder, translate, rotate, color, union, difference, intersection, and goop.
81
- - difference() cutters become subtractive primitives, so use clean cutters that fully pass through the target solid.
1
+ export const OPENSCAD_INSTRUCTIONS = `Use womp.openscad to create or update one adjustable CAD part. Read an existing part before editing it.
2
+ Keep follow-up edits to an existing CAD part in that part unless the user asks to change representation.
3
+ read(0) returns the root container for creation. read(id) returns an existing
4
+ part or container. Use the scene-editing skill when available to discover other scene objects.
5
+ create requires a parentId (0 means the scene root); update requires the existing objectId.
6
+ Both methods compile the effective source and parameters, apply one undoable change, and check wall thickness.
7
+ Preserve intervening user changes. Ask the user when the intended edits conflict or remain ambiguous.
8
+ Existing overrides are preserved for parameters still present;
9
+ params supplies overrides to merge into the current values. Keep top-level parameter names stable.
10
+ Return complete source on every update and preserve unrelated geometry. Never create a replacement to repair a part.
11
+ Compile errors return status rejected without applying. Creation requires validated placement bounds.
12
+ An update may apply with validation unavailable; that is a diagnostic, not proof that it compiles.
13
+ An applied part remains in the scene even if thickness fails or is unavailable. Use the returned objectId for repairs.
14
+ When thickness fails, increase thin walls and fragile connections while preserving requested dimensions and openings.
15
+ At most four applied printability repairs are allowed per logical build/edit in this run. Respect the returned limit;
16
+ do not bypass it by recreating the part. Unavailable thickness is not a pass. Report unresolved issues honestly.
17
+ Build connected geometry and avoid features below 1.4 mm. OpenSCAD source dimensions use millimetres,
18
+ independently of the scene display unit. New parts are centered and grounded from compiled bounds.
19
+ OpenSCAD is Z-up; the scene-editing API is Y-up. For an unrotated part at its creation-time scale and an identity parent transform,
20
+ a source point [x,y,z] mm maps to scene {x:y, y:z, z:-x} times the scene.unitMultiplier inside the access callback, plus nodeGetPos(partId).
21
+ Account for the part and parent transforms when they are not identity; source coordinates are not scene coordinates.
22
+ After a major change, use the scene screenshot API and emitImage to inspect the applied result.
23
+ A committed effect can survive later failure. Inspect receipts and the existing part before retrying.
24
+ Do not print source in the final answer unless requested; describe the result and any unresolved diagnostics.
25
+
26
+ RUNTIME
27
+ - Produce a single OpenSCAD file.
28
+ - Do not use imports, includes, external libraries, text, hull, minkowski, polyhedron, or extrudes.
29
+ - Prefer cube, sphere, cylinder, translate, rotate, color, union, difference, and intersection.
30
+ - Do not use $fn, $fa, or $fs; tessellation controls are ignored.
31
+ - Build faceted geometry by combining supported primitives with CSG operations and transforms.
32
+ - Do not use scale(); size primitives directly instead.
33
+ - Use color("#rrggbb") around distinct visible parts.
34
+ - Keep the model centered near the origin and use millimeter-like units consistently.
35
+ - For organic blending, use goop(radius_mm) { ... }. The radius is in millimetres before parent scaling, matching scene-editing; do not multiply by project unitMultiplier. It is ordered: the first child seeds with blend 0, and each later child uses radius_mm.
36
+ - Put core primitives first. Avoid wrapping goop children in union() when they should blend individually.
37
+
38
+ SCREWS AND THREADS
39
+ - Copy helical_ridge below for rounded screw threads, necks, caps, and coils. It joins spheres with cylinders; never use stacked rings or disconnected spheres.
40
+ - Dimensions are mm: radius = ridge centerline radius; pitch = axial travel/turn; tube_radius = half ridge thickness; handedness = 1 (right) or -1 (left).
41
+ - For a hand control use thread_hand = 1; // [-1:Left, 1:Right] and pass it directly. Ternary expressions (?:) are unsupported.
42
+ - Embed the ridge in the shaft/neck (e.g. radius = shaft_radius). Hollow fittings: subtract the bore from body + thread. Internal threads: subtract the helix from the socket wall with mating clearance. Mating parts need matching pitch and handedness.
43
+ - Expose diameter, pitch, thread length, and thickness in Customizer; derive sampling. Keep 2 * tube_radius < pitch and allow tube_radius beyond both path endpoints for rounded ends. This is a rounded profile, not ISO/metric V-thread; never claim standards compliance.
44
+ - Build heads/grips separately using cylinders, tapered cylinders, and radial ribs/cutters, not thread samples.
45
+
46
+ module helical_ridge(radius, pitch, turns, tube_radius, handedness) {
47
+ if (radius > 0 && pitch > 0 && turns > 0 && tube_radius > 0) {
48
+ // Limit chord error to 0.03 mm or 3% of the ridge radius.
49
+ chord_error = min(0.03, tube_radius * 0.03);
50
+ steps_per_turn = max(48, ceil(180 / acos(1 - min(1, chord_error / radius))));
51
+ steps = ceil(turns * steps_per_turn);
52
+ for (i = [0:steps]) {
53
+ angle = handedness * 360 * turns * i / steps;
54
+ p = [radius * cos(angle), radius * sin(angle), pitch * turns * i / steps];
55
+ translate(p) sphere(r = tube_radius);
56
+ if (i < steps) {
57
+ next_angle = handedness * 360 * turns * (i + 1) / steps;
58
+ dx = radius * cos(next_angle) - p[0];
59
+ dy = radius * sin(next_angle) - p[1];
60
+ dz = pitch * turns / steps;
61
+ segment_length = sqrt(dx * dx + dy * dy + dz * dz);
62
+ translate(p)
63
+ rotate([0, acos(dz / segment_length), atan2(dy, dx)])
64
+ cylinder(h = segment_length, r = tube_radius);
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ PARAMETERS
71
+ - Make important top-level constants user-adjustable with OpenSCAD Customizer comments:
72
+ - Numbers with ranges: body_radius = 12; // [4:0.5:40]
73
+ - Counts: leg_count = 4; // [2:8]
74
+ - Booleans: show_base = true;
75
+ - Options: style = "round"; // [round, sharp, chunky]
76
+ - Prefer stable size/count/ratio/style controls; keep fragile dependent dimensions as local expressions.
77
+
78
+ SUPPORTED SUBSET
79
+ - Export is best-effort from a supported CSG subset: cube, sphere, cylinder, translate, rotate, color, union, difference, intersection, and goop.
80
+ - difference() cutters become subtractive primitives, so use clean cutters that fully pass through the target solid.
82
81
  - Unsupported OpenSCAD may validate but can warn or skip during export; prefer the supported subset above.`;