@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
@@ -0,0 +1,40 @@
1
+ /** One queue per native renderer, shared by every standalone run in this process. */
2
+ const renderers = new Map();
3
+ export function standaloneSceneAccess(url) {
4
+ const key = new URL(url).href;
5
+ let access = renderers.get(key);
6
+ if (!access) {
7
+ access = createSceneAccess();
8
+ renderers.set(key, access);
9
+ }
10
+ return access;
11
+ }
12
+ function createSceneAccess() {
13
+ let tail = Promise.resolve();
14
+ let uncertain = false;
15
+ return {
16
+ release(synchronized) {
17
+ if (!synchronized)
18
+ uncertain = true;
19
+ },
20
+ run(signal, action) {
21
+ const result = tail.then(async () => {
22
+ signal.throwIfAborted();
23
+ if (uncertain)
24
+ throw new Error("The renderer has an uncertain scene commit. Inspect it before restarting standalone scene access; do not repeat the edit.");
25
+ const active = new AbortController();
26
+ const abort = () => active.abort(signal.reason);
27
+ signal.addEventListener("abort", abort, { once: true });
28
+ const beginSettlement = () => signal.removeEventListener("abort", abort);
29
+ try {
30
+ return await action(active.signal, beginSettlement);
31
+ }
32
+ finally {
33
+ beginSettlement();
34
+ }
35
+ });
36
+ tail = result.then(() => { }, () => { });
37
+ return result;
38
+ },
39
+ };
40
+ }
@@ -3,7 +3,7 @@ import { createOpenAI } from "@ai-sdk/openai";
3
3
  import { createGoogleGenerativeAI } from "@ai-sdk/google";
4
4
  import express from "express";
5
5
  import { getSparkModel, SPARK_MODELS } from "../models.js";
6
- import { KakapoAPI, KakapoRpcError } from "@womp/kakapo-sdk";
6
+ import { KakapoAPI, KakapoRpcError, KakapoSceneCommitError } from "@womp/kakapo-sdk";
7
7
  import { captureSdkSceneScreenshot } from "../sdk-scene-screenshot.js";
8
8
  import { composeCapabilities, createAccountCapability, createGeometryCapability, createOpenScadCapability, createSceneCapability, } from "../capabilities.js";
9
9
  import { loadDemoSceneMaterials } from "./demo-scene-materials.js";
@@ -13,7 +13,7 @@ import { createSdkGeometryHost } from "../sdk-geometry-host.js";
13
13
  import { createSdkOpenScadHost } from "../sdk-openscad-host.js";
14
14
  import { createSdkSceneHealthHost } from "../sdk-scene-health-host.js";
15
15
  import { geometryReference } from "../geometry.js";
16
- import { getSdkSceneAreaIds, hasSceneResourcePressure, readStableSdkSceneHealth, } from "../scene-health.js";
16
+ import { hasSceneResourcePressure, } from "../scene-health.js";
17
17
  import { QuickJSJavaScriptExecutor } from "../quickjs-executor.js";
18
18
  import { sparkModelOptions } from "../model-options.js";
19
19
  import { createSparkRunExecutor } from "./executor.js";
@@ -21,7 +21,7 @@ import { createSparkAgentRouter } from "./express.js";
21
21
  import { SparkAgentService } from "./service.js";
22
22
  import { SqliteSparkStore } from "./sqlite-store.js";
23
23
  import { clearSceneContents } from "./scene-actions.js";
24
- import { initializeStandaloneScene } from "./scene-initialization.js";
24
+ import { standaloneSceneAccess } from "./scene-access.js";
25
25
  export async function checkStandaloneServices(api) {
26
26
  const [fonts, openScad] = await Promise.allSettled([
27
27
  api.listFonts(),
@@ -57,6 +57,7 @@ export async function createStandaloneSparkAgentApp(options) {
57
57
  throw error;
58
58
  }
59
59
  const artifactStore = store;
60
+ const sceneAccess = standaloneSceneAccess(options.kakapoUrl);
60
61
  const executeRun = createSparkRunExecutor({
61
62
  createHost: async (context) => {
62
63
  const api = new KakapoAPI({
@@ -65,9 +66,7 @@ export async function createStandaloneSparkAgentApp(options) {
65
66
  });
66
67
  try {
67
68
  context.signal.throwIfAborted();
68
- await api.connect();
69
- context.signal.throwIfAborted();
70
- await initializeStandaloneScene(api, context.signal);
69
+ await api.connect({ refreshScene: false });
71
70
  context.signal.throwIfAborted();
72
71
  const support = await checkStandaloneServices(api);
73
72
  const services = {
@@ -81,12 +80,12 @@ export async function createStandaloneSparkAgentApp(options) {
81
80
  targetKey,
82
81
  operations: store,
83
82
  saveToRenderer: true,
84
- withConnection: async (invocation, action) => {
85
- invocation.signal.throwIfAborted();
83
+ withConnection: (invocation, action) => sceneAccess.run(invocation.signal, async (signal, beginSettlement) => {
86
84
  if (!api.isConnected)
87
85
  throw new Error("The Kakapo connection was interrupted; reconnect before continuing");
88
- return action(api, () => { }, invocation);
89
- },
86
+ return action(api, beginSettlement, { ...invocation, signal });
87
+ }),
88
+ acquireAccess: async () => sceneAccess.release,
90
89
  assertEditable: () => {
91
90
  if (!api.isConnected)
92
91
  throw new Error("The Kakapo connection is unavailable");
@@ -103,16 +102,11 @@ export async function createStandaloneSparkAgentApp(options) {
103
102
  const sceneHost = createSdkSceneHost({
104
103
  ...editorOptions,
105
104
  editor,
106
- health: createSdkSceneHealthHost(editor, onHealth),
107
- onSceneCommitted: async (_operation, api) => {
108
- const sceneHealth = await readStableSdkSceneHealth(api, getSdkSceneAreaIds(api), context.signal);
109
- onHealth(sceneHealth);
110
- return { sceneHealth };
111
- },
105
+ health: createSdkSceneHealthHost(editor, onHealth, repairs),
112
106
  services: async () => services,
113
107
  screenshot: async (settings, invocation) => {
114
108
  invocation.signal.throwIfAborted();
115
- const image = await captureSdkSceneScreenshot(api, settings);
109
+ const image = await editor.withConnection(invocation, activeApi => captureSdkSceneScreenshot(activeApi, settings));
116
110
  options.onScreenshot?.(context.runId);
117
111
  invocation.signal.throwIfAborted();
118
112
  return artifactStore.write(context.ownerId, image);
@@ -254,8 +248,19 @@ export async function createStandaloneSparkAgentApp(options) {
254
248
  clearingScene = true;
255
249
  const api = new KakapoAPI({ url: options.kakapoUrl, requestTimeoutMs: 30_000 });
256
250
  try {
257
- await api.connect();
258
- const { save } = await api.editSceneAndSave(clearSceneContents);
251
+ await api.connect({ refreshScene: false });
252
+ const { save } = await sceneAccess.run(new AbortController().signal, async () => {
253
+ try {
254
+ const result = await api.accessScene(clearSceneContents, { save: true });
255
+ sceneAccess.release(result.save.status !== "unknown");
256
+ return result;
257
+ }
258
+ catch (error) {
259
+ if (error instanceof KakapoSceneCommitError && error.outcome === "unknown")
260
+ sceneAccess.release(false);
261
+ throw error;
262
+ }
263
+ });
259
264
  const warning = save.status === "failed" || save.status === "unknown"
260
265
  ? `Scene cleared, but saving ${save.status === "failed" ? "failed" : "could not be confirmed"}: ${save.error} Do not repeat the clear operation.`
261
266
  : undefined;
@@ -1,28 +1,5 @@
1
- export declare const BENCH_PROMPTS: readonly [{
2
- readonly id: "armchair";
3
- readonly prompt: "Make a mustard-yellow upholstered armchair, 85 cm wide, 90 cm deep, and 85 cm tall. Give it a thick square seat cushion, a slightly reclined backrest, rounded armrests, and four short tapered wooden legs.";
4
- }, {
5
- readonly id: "desk-lamp";
6
- readonly prompt: "Make a matte-black desk lamp, 45 cm tall, with a circular base 18 cm in diameter, two slender articulated arms, and a conical shade 15 cm wide. Give the shade a white interior and a warm-white bulb.";
7
- }, {
8
- readonly id: "coffee-mug";
9
- readonly prompt: "Make a teal ceramic mug, 10 cm tall and 9 cm in diameter, with a gently rounded base, an open top, a thick rim, and a large C-shaped handle. Give it a cream-colored interior.";
10
- }, {
11
- readonly id: "bookshelf";
12
- readonly prompt: "Make a light-oak bookshelf, 180 cm tall, 90 cm wide, and 30 cm deep. Give it five evenly spaced open compartments, thick rectangular side panels, a solid back panel, and a recessed base.";
13
- }, {
14
- readonly id: "backpack";
15
- readonly prompt: "Make a navy-blue backpack, 45 cm tall, 30 cm wide, and 18 cm deep. Give it a rounded rectangular body, a large front zipper pocket, two padded shoulder straps, a top carry handle, and light-gray zipper pulls.";
16
- }, {
17
- readonly id: "toaster";
18
- readonly prompt: "Make a cream-colored two-slot toaster, 28 cm wide, 18 cm deep, and 20 cm tall. Give it rounded corners, a brushed-metal top, two long bread slots, a silver side lever, and a circular browning dial.";
19
- }, {
20
- readonly id: "plant-pot";
21
- readonly prompt: "Make a terracotta plant pot, 25 cm tall, tapering from a 30 cm opening to a 20 cm base. Give it a thick rolled rim and dark soil containing a small plant with six broad green leaves on short stems.";
22
- }, {
23
- readonly id: "suitcase";
24
- readonly prompt: "Make a burgundy hard-shell suitcase, 65 cm tall, 42 cm wide, and 26 cm deep. Give it rounded corners, five vertical ribs on each broad face, four black spinner wheels, a silver telescoping handle, and a black side carry handle.";
25
- }, {
26
- readonly id: "dining-table";
27
- readonly prompt: "Make a rectangular walnut dining table, 160 cm long, 90 cm wide, and 75 cm tall. Give it a 4 cm thick tabletop with softly rounded corners, four square tapered legs, and a recessed wooden apron beneath the top.";
28
- }];
1
+ export declare const BENCH_ORIENTATION = "Orient the object upright (+Y), with its front facing +Z and its base centered at the origin.";
2
+ export declare const BENCH_PROMPTS: {
3
+ prompt: string;
4
+ id: string;
5
+ }[];
@@ -1,3 +1,4 @@
1
+ export const BENCH_ORIENTATION = "Orient the object upright (+Y), with its front facing +Z and its base centered at the origin.";
1
2
  export const BENCH_PROMPTS = [
2
3
  { id: "armchair", prompt: "Make a mustard-yellow upholstered armchair, 85 cm wide, 90 cm deep, and 85 cm tall. Give it a thick square seat cushion, a slightly reclined backrest, rounded armrests, and four short tapered wooden legs." },
3
4
  { id: "desk-lamp", prompt: "Make a matte-black desk lamp, 45 cm tall, with a circular base 18 cm in diameter, two slender articulated arms, and a conical shade 15 cm wide. Give the shade a white interior and a warm-white bulb." },
@@ -8,4 +9,4 @@ export const BENCH_PROMPTS = [
8
9
  { id: "plant-pot", prompt: "Make a terracotta plant pot, 25 cm tall, tapering from a 30 cm opening to a 20 cm base. Give it a thick rolled rim and dark soil containing a small plant with six broad green leaves on short stems." },
9
10
  { id: "suitcase", prompt: "Make a burgundy hard-shell suitcase, 65 cm tall, 42 cm wide, and 26 cm deep. Give it rounded corners, five vertical ribs on each broad face, four black spinner wheels, a silver telescoping handle, and a black side carry handle." },
10
11
  { id: "dining-table", prompt: "Make a rectangular walnut dining table, 160 cm long, 90 cm wide, and 75 cm tall. Give it a 4 cm thick tabletop with softly rounded corners, four square tapered legs, and a recessed wooden apron beneath the top." },
11
- ];
12
+ ].map(item => ({ ...item, prompt: `${item.prompt} ${BENCH_ORIENTATION}` }));
@@ -2,7 +2,7 @@ import { once } from "node:events";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { KakapoAPI } from "@womp/kakapo-sdk";
5
- import { z } from "zod";
5
+ import { benchCamera, benchGeometryIds } from "./bench-camera.js";
6
6
  import { SparkChatTransport } from "./client.js";
7
7
  import { createStandaloneSparkAgentApp } from "./server/standalone.js";
8
8
  import { BENCH_PROMPTS } from "./simple-bench-prompts.js";
@@ -40,7 +40,7 @@ export async function runSimpleBench(options) {
40
40
  const report = async () => {
41
41
  await writeFile(path.join(options.output, "results.json"), JSON.stringify({
42
42
  version: 1, startedAt, nodeVersion: process.version, settings: options.settings,
43
- timeoutMs: options.timeoutMs, camera: "fitted-perspective-preserving-direction", plannedPrompts: prompts.map((item) => item.id),
43
+ timeoutMs: options.timeoutMs, camera: "fixed-three-quarter-v3", plannedPrompts: prompts.map((item) => item.id),
44
44
  totalCost: totalBenchCost(results), results,
45
45
  }, null, 2));
46
46
  const columns = ["id", "status", "steps", "readSkill", "executeJavaScript", "screenshots", "durationMs", "costUsd", "costStatus", "image", "error"];
@@ -100,30 +100,18 @@ export async function runSimpleBench(options) {
100
100
  const api = new KakapoAPI({ url: options.kakapoUrl, requestTimeoutMs: 30_000 });
101
101
  try {
102
102
  await api.connect();
103
- const ids = api.listNodes({ parentId: 0 })
104
- .filter((node) => node.kind !== "light" && (!("hidden" in node) || !node.hidden))
105
- .map((node) => node.id);
103
+ const snapshot = await api.rpc("scene_state_get");
104
+ await writeFile(path.join(options.output, `${item.id}.scene.json`), typeof snapshot === "string" ? snapshot : JSON.stringify(snapshot));
105
+ const ids = benchGeometryIds(api);
106
106
  const bounds = await api.getNodesBoundingBox(ids);
107
107
  const viewport = await api.captureScreenshot({});
108
- const { camera } = await api.refreshScene();
109
- const direction = z.object({ x: z.number().finite(), y: z.number().finite(), z: z.number().finite() }).parse(camera.dir);
110
- const directionLength = Math.hypot(direction.x, direction.y, direction.z);
111
- if (directionLength === 0)
112
- throw new Error("Cannot frame a scene with a zero camera direction.");
113
- const target = {
114
- x: (bounds.min.x + bounds.max.x) / 2,
115
- y: (bounds.min.y + bounds.max.y) / 2,
116
- z: (bounds.min.z + bounds.max.z) / 2,
117
- };
118
- const radius = Math.hypot(bounds.max.x - bounds.min.x, bounds.max.y - bounds.min.y, bounds.max.z - bounds.min.z) / 2;
119
- const halfAngle = Math.atan(Math.tan(Math.PI / 6) * Math.min(1, viewport.height / viewport.width));
120
- const offset = Math.max(0.1, radius * 1.1 / Math.sin(halfAngle)) / directionLength;
121
- const image = await api.captureScreenshot({ camera: {
122
- position: { x: target.x - direction.x * offset, y: target.y - direction.y * offset, z: target.z - direction.z * offset },
123
- target, projection: "perspective", horizontalFovDegrees: 60,
124
- }, timeoutMs: 30_000 });
108
+ const camera = benchCamera(bounds, viewport.width / viewport.height);
109
+ await writeFile(path.join(options.output, `${item.id}.framing.json`), JSON.stringify({ ids, bounds, width: viewport.width, height: viewport.height, cameras: { "three-quarter": camera } }, null, 2));
110
+ const image = await api.captureScreenshot({ camera, timeoutMs: 30_000 });
111
+ if (image.width !== viewport.width || image.height !== viewport.height)
112
+ throw new Error("Viewport changed during benchmark capture.");
113
+ await writeFile(path.join(options.output, `${item.id}.jpg`), image.data);
125
114
  result.image = `${item.id}.jpg`;
126
- await writeFile(path.join(options.output, result.image), image.data);
127
115
  }
128
116
  finally {
129
117
  api.disconnect();
@@ -164,8 +164,20 @@ export declare function createWompSceneApi(context: WompSceneApiContext): {
164
164
  nodeGetLightType: (id: number) => number;
165
165
  nodeSetLightType: (id: number, lightType: number) => void;
166
166
  nodeGetPower: (id: number) => number;
167
+ /** Linear emitted intensity before collimation; not a percentage or physical unit. */
167
168
  nodeSetPower: (id: number, power: number) => void;
168
169
  nodeGetText: (id: number) => string;
170
+ /** Aim light emission or the readable text/decal face using target minus node position in parent-local coordinates. Use instead of generic lookAt for these faces. Y-up, Z-up when vertical. */
171
+ nodeSetFacing: (id: number, direction: Vec3) => void;
172
+ /** Font em size and extrusion depth in project units, not text bounding-box dimensions. */
173
+ nodeSetTextSize: (id: number, size: {
174
+ fontSize: number;
175
+ depth: number;
176
+ }) => void;
177
+ nodeGetTextSize: (id: number) => {
178
+ fontSize: number;
179
+ depth: number;
180
+ };
169
181
  nodeSetText: (id: number, text: string) => void;
170
182
  nodeGetFont: (id: number) => string;
171
183
  nodeSetFont: (id: number, font: string) => void;
@@ -177,8 +189,10 @@ export declare function createWompSceneApi(context: WompSceneApiContext): {
177
189
  nodeSetAlign: (id: number, align: number) => void;
178
190
  nodeGetWrap: (id: number) => number;
179
191
  nodeSetWrap: (id: number, wrap: number) => void;
192
+ /** Text layout width in project units, including owning Area scale. */
180
193
  nodeGetWidth: (id: number) => number;
181
194
  nodeSetWidth: (id: number, width: number) => void;
195
+ /** Additional letter spacing in project units, including owning Area scale. */
182
196
  nodeGetSpacing: (id: number) => number;
183
197
  nodeSetSpacing: (id: number, spacing: number) => void;
184
198
  nodeGetLineHeight: (id: number) => number;
@@ -272,6 +286,7 @@ export declare function createWompSceneApi(context: WompSceneApiContext): {
272
286
  nodeResize: (id: number, dimensions: Partial<Vec3>) => Promise<void>;
273
287
  aabbCenter: (box: AABB) => Vec3;
274
288
  aabbRadius: (box: AABB) => number;
289
+ /** Align local +Z from origin toward target, not a light's emission or a text/decal face; use nodeSetFacing for those. */
275
290
  lookAt: typeof lookAt;
276
291
  alignAxis: typeof alignAxis;
277
292
  scenePropertiesRead: () => {
@@ -291,5 +306,20 @@ export declare function createWompSceneApi(context: WompSceneApiContext): {
291
306
  g: number;
292
307
  b: number;
293
308
  }) => void;
309
+ unitMultiplier: number;
310
+ /** Current draft summaries. Filters precede pagination; defaults to 100 results, maximum 1000. */
311
+ nodes(options?: {
312
+ ids?: number[];
313
+ type?: number;
314
+ parentId?: number;
315
+ name?: string;
316
+ offset?: number;
317
+ limit?: number;
318
+ }): {
319
+ id: number;
320
+ name: string;
321
+ type: number;
322
+ parentId: number;
323
+ }[];
294
324
  };
295
325
  export {};
@@ -1,8 +1,8 @@
1
- import { xyzRotationFromMatrix, MAX_BLEND_RADIUS_MM, MAX_MIRROR_BLEND_RADIUS_MM } from "@womp/kakapo-sdk";
1
+ import { xyzRotationFromMatrix, MAX_BLEND_RADIUS_MM, MAX_MIRROR_BLEND_RADIUS_MM, maxLightIntensity } from "@womp/kakapo-sdk";
2
2
  import { metadataObject, readMaterialMetadata, removeMaterialMetadata, shiftCurvePointMetadata, writeMaterialMetadata, } from "./scene-metadata.js";
3
3
  import { createSceneEnvironment } from "./scene-environment.js";
4
4
  import { assertColor255 } from "./color.js";
5
- import { convertRotationBasis, sceneUnitMultiplier, fromEngineDimensions, fromEngineMirrorRotation, fromEnginePosition, toEngineDimensions, toEngineMirrorRotation, toEnginePosition, } from "./coordinates.js";
5
+ import { INTERNAL_UNITS_PER_MM, convertRotationBasis, sceneUnitMultiplier, fromEngineDimensions, fromEngineMirrorRotation, fromEnginePosition, toEngineDimensions, toEngineMirrorRotation, toEnginePosition, } from "./coordinates.js";
6
6
  export const NodeType = {
7
7
  Primitive: 0,
8
8
  Union: 1,
@@ -395,6 +395,8 @@ export function createWompSceneApi(context) {
395
395
  const y = finite(sizeValue?.y, "size.y");
396
396
  if (x <= 0 || y <= 0)
397
397
  throw new Error("Light size.x and size.y must be positive");
398
+ if ("z" in sizeValue)
399
+ finite(sizeValue.z, "size.z");
398
400
  const converted = toEngineDimensions({ x, y, z: 1 }, unitMultiplier);
399
401
  update(id, { size: { x: converted.z, y: converted.y } });
400
402
  return;
@@ -434,6 +436,23 @@ export function createWompSceneApi(context) {
434
436
  return converted;
435
437
  };
436
438
  const api = {
439
+ get unitMultiplier() {
440
+ return sceneUnitMultiplier(scene.getSceneProperties().unit_multiplier);
441
+ },
442
+ /** Current draft summaries. Filters precede pagination; defaults to 100 results, maximum 1000. */
443
+ nodes(options = {}) {
444
+ const { offset = 0, limit = 100 } = options;
445
+ if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 1000)
446
+ throw new Error("nodes() requires a nonnegative integer offset and a limit from 1 to 1000");
447
+ const ids = options.ids === undefined ? undefined : new Set(options.ids);
448
+ const query = options.name?.toLowerCase();
449
+ return scene.listNodeSummaries().filter(({ id, name, kind, parentId }) => (ids === undefined || ids.has(id)) &&
450
+ (options.type === undefined || sceneNodeType(kind) === options.type) &&
451
+ (options.parentId === undefined || (parentId ?? 0) === options.parentId) &&
452
+ (query === undefined || name.toLowerCase().includes(query))).slice(offset, offset + limit).map(({ id, name, kind, parentId }) => ({
453
+ id, name, type: sceneNodeType(kind), parentId: parentId ?? 0,
454
+ }));
455
+ },
437
456
  ...createSceneEnvironment(scene, context.hdrs, context.print),
438
457
  listSuperMaterials: () => {
439
458
  if (!context.superMaterialNames)
@@ -609,7 +628,7 @@ export function createWompSceneApi(context) {
609
628
  nodeSetLock: (id, locked) => {
610
629
  requireNode(id);
611
630
  const current = metadataObject(scene.getSceneExt([String(id)])[String(id)]);
612
- scene.patchSceneExt([{ op: "add", path: `/${id}`, value: { ...current, locked: Boolean(locked) } }]);
631
+ scene.patchSceneExt([{ op: "add", path: `/${id}`, value: { ...current, locked: Boolean(locked) } }], { returnScene: false });
613
632
  },
614
633
  nodeSetPos: (id, position) => {
615
634
  requireEditableTransform(id);
@@ -643,7 +662,7 @@ export function createWompSceneApi(context) {
643
662
  return "operation" in node ? OPERATIONS.indexOf(node.operation) : OperationType.Union;
644
663
  },
645
664
  nodeSetOp: (id, operation) => {
646
- const value = OPERATIONS[Math.round(finite(operation, "operation"))];
665
+ const value = OPERATIONS[finite(operation, "operation")];
647
666
  if (!value)
648
667
  throw new Error("Operation must be from 0 to 3");
649
668
  const node = requireNode(id);
@@ -701,51 +720,113 @@ export function createWompSceneApi(context) {
701
720
  },
702
721
  nodeGetLightType: (id) => LIGHT_TYPES.indexOf(requireLight(id).lightType),
703
722
  nodeSetLightType: (id, lightType) => {
704
- const value = LIGHT_TYPES[Math.round(finite(lightType, "lightType"))];
723
+ requireLight(id);
724
+ const value = LIGHT_TYPES[finite(lightType, "lightType")];
705
725
  if (!value)
706
- throw new Error("LightType must be from 0 to 2");
726
+ throw new Error("LightType must be an integer from 0 to 2");
707
727
  update(id, { lightType: value });
708
728
  },
709
729
  nodeGetPower: (id) => requireLight(id).power,
730
+ /** Linear emitted intensity before collimation; not a percentage or physical unit. */
710
731
  nodeSetPower: (id, power) => {
711
- requireLight(id);
712
- if (!Number.isFinite(power) || power < 0 || power > 100)
713
- throw new Error(`Light ${id}: power must be a finite number from 0 through 100`);
732
+ const light = requireLight(id);
733
+ const max = maxLightIntensity(light.lightType);
734
+ if (!Number.isFinite(power) || power < 0 || power > max)
735
+ throw new Error(`Light ${id} (${light.lightType}): linear intensity must be a finite number from 0 through ${max}`);
714
736
  update(id, { power });
715
737
  },
716
738
  nodeGetText: (id) => requireText(id).text,
717
- nodeSetText: (id, text) => update(id, { text: String(text) }),
739
+ /** Aim light emission or the readable text/decal face using target minus node position in parent-local coordinates. Use instead of generic lookAt for these faces. Y-up, Z-up when vertical. */
740
+ nodeSetFacing: (id, direction) => {
741
+ requireNode(id);
742
+ const value = vector(direction, "direction");
743
+ const nativeDirection = { x: -value.z, y: value.y, z: -value.x };
744
+ const up = Math.abs(value.y) / Math.hypot(value.x, value.y, value.z) > 1 - 1e-8
745
+ ? { x: -1, y: 0, z: 0 } : { x: 0, y: 1, z: 0 };
746
+ context.call("setNodeFacing", [id, nativeDirection, up], () => scene.setNodeFacing(id, nativeDirection, up));
747
+ },
748
+ /** Font em size and extrusion depth in project units, not text bounding-box dimensions. */
749
+ nodeSetTextSize: (id, size) => {
750
+ requireText(id);
751
+ const fontSize = finite(size?.fontSize, "fontSize");
752
+ const depth = finite(size?.depth, "depth");
753
+ if (fontSize <= 0 || depth <= 0)
754
+ throw new Error("Text fontSize and depth must be positive");
755
+ const factor = INTERNAL_UNITS_PER_MM / ancestorScale(id);
756
+ const nativeSize = {
757
+ fontSize: clamp(fontSize / unitMultiplier, 0.01, 1000) * factor,
758
+ depth: clamp(depth / unitMultiplier, 0.01, 1000) * factor,
759
+ };
760
+ context.call("setTextSize", [id, nativeSize], () => scene.setTextSize(id, nativeSize));
761
+ },
762
+ nodeGetTextSize: (id) => {
763
+ const node = requireText(id);
764
+ const factor = ancestorScale(id) * unitMultiplier / INTERNAL_UNITS_PER_MM;
765
+ return { fontSize: node.transform.scale.x * factor, depth: node.transform.scale.z * factor };
766
+ },
767
+ nodeSetText: (id, text) => {
768
+ requireText(id);
769
+ if (typeof text !== "string")
770
+ throw new Error("text must be a string; use an empty string to clear it");
771
+ update(id, { text });
772
+ },
718
773
  nodeGetFont: (id) => requireText(id).fontFamily,
719
774
  nodeSetFont: (id, font) => {
775
+ requireText(id);
776
+ if (typeof font !== "string" || !font.trim())
777
+ throw new Error("font must be a non-empty font family name");
778
+ if (!context.fonts.length)
779
+ throw new Error("Font catalog is unavailable; this host cannot validate a new font selection");
720
780
  const match = context.fonts.find((family) => family.name.toLowerCase() === font.toLowerCase());
721
- if (!match && context.fonts.length)
722
- throw new Error(`Unknown font "${font}"`);
781
+ if (!match)
782
+ throw new Error(`Unknown font "${font}"; choose a family from the font catalog`);
723
783
  update(id, { fontFamily: match?.name ?? font });
724
784
  },
725
785
  nodeGetWeight: (id) => requireText(id).weight,
726
- nodeSetWeight: (id, weight) => update(id, { weight: clamp(finite(weight, "weight"), 100, 900) }),
786
+ nodeSetWeight: (id, weight) => {
787
+ requireText(id);
788
+ update(id, { weight: clamp(finite(weight, "weight"), 100, 900) });
789
+ },
727
790
  nodeGetItalic: (id) => requireText(id).italic,
728
- nodeSetItalic: (id, italic) => update(id, { italic: Boolean(italic) }),
791
+ nodeSetItalic: (id, italic) => {
792
+ requireText(id);
793
+ if (typeof italic !== "boolean")
794
+ throw new Error("italic must be a boolean");
795
+ update(id, { italic });
796
+ },
729
797
  nodeGetAlign: (id) => ALIGNS.indexOf(requireText(id).align),
730
798
  nodeSetAlign: (id, align) => {
731
- const value = ALIGNS[Math.round(finite(align, "align"))];
799
+ requireText(id);
800
+ const value = ALIGNS[finite(align, "align")];
732
801
  if (!value)
733
- throw new Error("Align must be from 0 to 2");
802
+ throw new Error("Align must be an integer from 0 to 2");
734
803
  update(id, { align: value });
735
804
  },
736
805
  nodeGetWrap: (id) => WRAPS.indexOf(requireText(id).wrap),
737
806
  nodeSetWrap: (id, wrap) => {
738
- const value = WRAPS[Math.round(finite(wrap, "wrap"))];
807
+ requireText(id);
808
+ const value = WRAPS[finite(wrap, "wrap")];
739
809
  if (!value)
740
- throw new Error("Wrap must be from 0 to 2");
810
+ throw new Error("Wrap must be an integer from 0 to 2");
741
811
  update(id, { wrap: value });
742
812
  },
743
- nodeGetWidth: (id) => requireText(id).width,
744
- nodeSetWidth: (id, width) => update(id, { width: finite(width, "width") }),
745
- nodeGetSpacing: (id) => requireText(id).spacing,
746
- nodeSetSpacing: (id, spacing) => update(id, { spacing: clamp(finite(spacing, "spacing"), -1, 1) }),
813
+ /** Text layout width in project units, including owning Area scale. */
814
+ nodeGetWidth: (id) => requireText(id).width * ancestorScale(id) * unitMultiplier / INTERNAL_UNITS_PER_MM,
815
+ nodeSetWidth: (id, width) => {
816
+ requireText(id);
817
+ update(id, { width: finite(width, "width") * INTERNAL_UNITS_PER_MM / unitMultiplier / ancestorScale(id) });
818
+ },
819
+ /** Additional letter spacing in project units, including owning Area scale. */
820
+ nodeGetSpacing: (id) => requireText(id).spacing * ancestorScale(id) * unitMultiplier / INTERNAL_UNITS_PER_MM,
821
+ nodeSetSpacing: (id, spacing) => {
822
+ requireText(id);
823
+ update(id, { spacing: clamp(finite(spacing, "spacing") / unitMultiplier, -25, 25) * INTERNAL_UNITS_PER_MM / ancestorScale(id) });
824
+ },
747
825
  nodeGetLineHeight: (id) => requireText(id).lineHeight,
748
- nodeSetLineHeight: (id, lineHeight) => update(id, { lineHeight: clamp(finite(lineHeight, "lineHeight"), 0.5, 3) }),
826
+ nodeSetLineHeight: (id, lineHeight) => {
827
+ requireText(id);
828
+ update(id, { lineHeight: clamp(finite(lineHeight, "lineHeight"), 0.5, 3) });
829
+ },
749
830
  nodeGetDensity: (id) => requireCurve(id).density,
750
831
  nodeSetDensity: (id, density) => update(id, { density: clamp(Math.round(finite(density, "density")), 0, 250) }),
751
832
  nodeGetRoundness: (id) => requireCurve(id).roundness,
@@ -762,7 +843,7 @@ export function createWompSceneApi(context) {
762
843
  return value;
763
844
  },
764
845
  nodeSetPrimType: (id, primitive) => {
765
- const value = PUBLIC_PRIMITIVES.get(Math.round(finite(primitive, "primitive")));
846
+ const value = PUBLIC_PRIMITIVES.get(finite(primitive, "primitive"));
766
847
  if (!value)
767
848
  throw new Error("PrimitiveType must be Sphere, Cylinder, Cube, Cone, Torus, or Pyramid");
768
849
  const node = requireNode(id);
@@ -991,6 +1072,7 @@ export function createWompSceneApi(context) {
991
1072
  z: (box.min.z + box.max.z) / 2,
992
1073
  }),
993
1074
  aabbRadius: (box) => Math.hypot(box.max.x - box.min.x, box.max.y - box.min.y, box.max.z - box.min.z) / 2,
1075
+ /** Align local +Z from origin toward target, not a light's emission or a text/decal face; use nodeSetFacing for those. */
994
1076
  lookAt,
995
1077
  alignAxis,
996
1078
  };