@compilr-dev/sdk 0.38.0 → 0.40.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.
@@ -48,6 +48,11 @@ export type SceneOp =
48
48
  op: 'layers';
49
49
  layers: unknown;
50
50
  }
51
+ /** Replace the lights (§20.5); `[]` restores the default studio light. */
52
+ | {
53
+ op: 'lights';
54
+ lights: unknown;
55
+ }
51
56
  /** Whole-scene replace (scene_create, JSON import). */
52
57
  | {
53
58
  op: 'replace';
@@ -59,7 +64,7 @@ export type SceneEditSource = {
59
64
  } | {
60
65
  kind: 'user';
61
66
  };
62
- export type SceneChangeKind = 'moved' | 'resized' | 'rotated' | 'recoloured' | 'renamed' | 'removed' | 'added' | 'edited' | 'camera' | 'layers' | 'replaced';
67
+ export type SceneChangeKind = 'moved' | 'resized' | 'rotated' | 'recoloured' | 'renamed' | 'removed' | 'added' | 'edited' | 'camera' | 'layers' | 'lights' | 'replaced';
63
68
  /** One journalled change (the writer stamps `rev`). */
64
69
  export interface SceneChange {
65
70
  rev: number;
@@ -291,6 +291,43 @@ function setLayers(scene, layers) {
291
291
  warnings: [],
292
292
  };
293
293
  }
294
+ /**
295
+ * Replace the lights.
296
+ *
297
+ * `[]` DELETES the field rather than storing an empty list, which is the difference between
298
+ * "the default studio light" and "no light at all" — an empty array would render a black scene
299
+ * and read in the JSON as if lighting had been configured.
300
+ */
301
+ function setLights(scene, lights) {
302
+ if (!Array.isArray(lights)) {
303
+ return {
304
+ error: 'lights must be an array of { type: "sun" | "ambient", … } — or [] to go back to the default studio light.',
305
+ };
306
+ }
307
+ const probe = validateScene({ ...scene, lights: lights.length === 0 ? undefined : lights });
308
+ if (!probe.ok)
309
+ return { error: formatValidationErrors(probe.errors) };
310
+ const before = scene.lights ?? [];
311
+ const next = { ...scene };
312
+ if (lights.length === 0)
313
+ delete next.lights;
314
+ else
315
+ next.lights = probe.scene.lights;
316
+ const summary = lights.length === 0
317
+ ? 'Lights cleared — back to the default studio light.'
318
+ : `Lights set: ${lights
319
+ .map((l) => isRecord(l) && typeof l.type === 'string'
320
+ ? `${l.type} ${typeof l.intensity === 'number' ? String(l.intensity) : 'default'}`
321
+ : '?')
322
+ .join(', ')}.`;
323
+ return {
324
+ scene: next,
325
+ summary,
326
+ ids: [],
327
+ changes: [{ id: '', what: 'lights', from: before, to: lights }],
328
+ warnings: [],
329
+ };
330
+ }
294
331
  function setCamera(scene, camera) {
295
332
  if (isRecord(camera) && camera.fit === true) {
296
333
  const rest = { ...scene };
@@ -335,6 +372,8 @@ export function applySceneOp(scene, op, opts) {
335
372
  return setCamera(scene, op.camera);
336
373
  case 'layers':
337
374
  return setLayers(scene, op.layers);
375
+ case 'lights':
376
+ return setLights(scene, op.lights);
338
377
  case 'replace': {
339
378
  const v = validateScene(op.scene);
340
379
  if (!v.ok)
@@ -431,6 +470,8 @@ function describeChange(c) {
431
470
  return 'changed the camera';
432
471
  case 'layers':
433
472
  return 'changed which layers can be hidden';
473
+ case 'lights':
474
+ return 'changed the lighting';
434
475
  case 'replaced':
435
476
  return 'replaced the whole scene';
436
477
  case 'edited':
@@ -21,10 +21,26 @@ export declare const SCENE_EXTRUDE_AXES: readonly ExtrudeAxis[];
21
21
  export interface SceneMaterial {
22
22
  /** 0..1, default 0.78 */
23
23
  roughness?: number;
24
- /** 0..1, default 0.02 */
24
+ /**
25
+ * 0..1, default 0.02.
26
+ *
27
+ * ⚠️ Metal is MIRROR, not colour: a metal with nothing to reflect renders near-black. The
28
+ * viewer lights the scene with a neutral room environment so this means something; without
29
+ * one, raising it made objects darker, which is why it had looked like a dead knob.
30
+ */
25
31
  metalness?: number;
26
32
  /** 0.05..1, default 1 (transparent when < 1) */
27
33
  opacity?: number;
34
+ /**
35
+ * '#RRGGBB' the object gives off, default '#000000' (none). Emission is NOT light: it
36
+ * brightens the object itself and casts nothing on its neighbours, so it marks a thing as
37
+ * live or active — an indicator, a hot path, a selected step — rather than lighting a room.
38
+ */
39
+ emissive?: string;
40
+ /** How strong the emission is. 0..5, default 1. Only means anything with `emissive` set. */
41
+ emissiveIntensity?: number;
42
+ /** Draw the edges only. Default false. For the parts of a diagram that are logical, not built. */
43
+ wireframe?: boolean;
28
44
  }
29
45
  export interface SceneObjectBase {
30
46
  /** Unique in the scene; /^[a-z0-9][a-z0-9_-]{0,47}$/ */
@@ -260,6 +276,8 @@ export declare const SCENE_NEUTRAL_PALETTE: readonly string[];
260
276
  /** Accessible names for the palette swatches, same order (Q-10). */
261
277
  export declare const SCENE_PALETTE_NAMES: readonly string[];
262
278
  export declare const SCENE_DEFAULT_MATERIAL: Readonly<Required<SceneMaterial>>;
279
+ /** `material.emissiveIntensity` range. Above ~5 everything clips to white. */
280
+ export declare const SCENE_EMISSIVE_INTENSITY_MAX = 5;
263
281
  /** The reference camera, used for an empty scene. */
264
282
  export declare const SCENE_DEFAULT_CAMERA: Readonly<SceneCamera>;
265
283
  /** Vertical field of view of the viewer camera, degrees. */
@@ -105,7 +105,12 @@ export const SCENE_DEFAULT_MATERIAL = {
105
105
  roughness: 0.78,
106
106
  metalness: 0.02,
107
107
  opacity: 1,
108
+ emissive: '#000000',
109
+ emissiveIntensity: 1,
110
+ wireframe: false,
108
111
  };
112
+ /** `material.emissiveIntensity` range. Above ~5 everything clips to white. */
113
+ export const SCENE_EMISSIVE_INTENSITY_MAX = 5;
109
114
  /** The reference camera, used for an empty scene. */
110
115
  export const SCENE_DEFAULT_CAMERA = {
111
116
  position: [7, 5.5, 8],
@@ -216,7 +221,14 @@ const TYPE_SHAPE = {
216
221
  group: 'a group is a container: id, name, position, rotation, group, locked, tags only — colour its children',
217
222
  label: 'label takes text and optional size (cap height in metres), background, face ("camera" or "fixed"); color is the ink and material is not allowed',
218
223
  };
219
- const MATERIAL_KEYS = new Set(['roughness', 'metalness', 'opacity']);
224
+ const MATERIAL_KEYS = new Set([
225
+ 'roughness',
226
+ 'metalness',
227
+ 'opacity',
228
+ 'emissive',
229
+ 'emissiveIntensity',
230
+ 'wireframe',
231
+ ]);
220
232
  const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
221
233
  /** Short, stable number rendering for messages and summaries. */
222
234
  export function fmtNum(n) {
@@ -422,7 +434,7 @@ function validateObject(raw, i, errors) {
422
434
  const m = raw.material;
423
435
  for (const k of Object.keys(m)) {
424
436
  if (!MATERIAL_KEYS.has(k)) {
425
- errors.push(`${label}.material.${k} is not a material field — roughness, metalness, opacity.`);
437
+ errors.push(`${label}.material.${k} is not a material field — roughness, metalness, opacity, emissive, emissiveIntensity, wireframe.`);
426
438
  }
427
439
  }
428
440
  if (m.roughness !== undefined)
@@ -431,6 +443,15 @@ function validateObject(raw, i, errors) {
431
443
  checkNumber(m.metalness, `${label}.material.metalness`, 0, 1, 'metalness is 0–1', errors);
432
444
  if (m.opacity !== undefined)
433
445
  checkNumber(m.opacity, `${label}.material.opacity`, SCENE_OPACITY_MIN, 1, `opacity is ${fmtNum(SCENE_OPACITY_MIN)}–1`, errors);
446
+ if (m.emissive !== undefined &&
447
+ (typeof m.emissive !== 'string' || !HEX_RE.test(m.emissive))) {
448
+ errors.push(`${label}.material.emissive = ${show(m.emissive)} — a colour the object gives off, "#RGB" or "#RRGGBB" ("#000000" for none).`);
449
+ }
450
+ if (m.emissiveIntensity !== undefined)
451
+ checkNumber(m.emissiveIntensity, `${label}.material.emissiveIntensity`, 0, SCENE_EMISSIVE_INTENSITY_MAX, `emissiveIntensity is 0–${String(SCENE_EMISSIVE_INTENSITY_MAX)}, and only does anything with emissive set`, errors);
452
+ if (m.wireframe !== undefined && typeof m.wireframe !== 'boolean') {
453
+ errors.push(`${label}.material.wireframe = ${show(m.wireframe)} — true or false.`);
454
+ }
434
455
  }
435
456
  }
436
457
  // Type-specific dimensions.
@@ -280,6 +280,7 @@ export const CAPABILITY_PACKS = {
280
280
  'scene_remove_object',
281
281
  'scene_set_camera',
282
282
  'scene_set_layers',
283
+ 'scene_set_lights',
283
284
  'scene_screenshot',
284
285
  ],
285
286
  readOnly: false,
@@ -15,7 +15,7 @@
15
15
  import type { PlatformContext, PlatformToolsConfig } from '../context.js';
16
16
  import { type ISceneWriter } from '../scene-writer.js';
17
17
  import { type SceneFile } from '../../canvas/scene.js';
18
- export declare const SCENE_TOOL_NAMES: readonly ["scene_create", "scene_get", "scene_add_object", "scene_update_object", "scene_remove_object", "scene_set_camera", "scene_set_layers", "scene_screenshot"];
18
+ export declare const SCENE_TOOL_NAMES: readonly ["scene_create", "scene_get", "scene_add_object", "scene_update_object", "scene_remove_object", "scene_set_camera", "scene_set_layers", "scene_set_lights", "scene_screenshot"];
19
19
  /**
20
20
  * "sofa · box · Sofa" rows, indented under their group parent; `#tag` suffixes when `withTags`.
21
21
  *
@@ -103,6 +103,9 @@ export declare function createSceneTools(config: PlatformToolsConfig, options?:
103
103
  }> | import("@compilr-dev/agents").Tool<{
104
104
  canvas_id: number;
105
105
  layers?: unknown;
106
+ }> | import("@compilr-dev/agents").Tool<{
107
+ canvas_id: number;
108
+ lights?: unknown;
106
109
  }> | import("@compilr-dev/agents").Tool<{
107
110
  canvas_id: number;
108
111
  width?: number;
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { defineTool, createErrorResult } from '@compilr-dev/agents';
16
16
  import { createSceneWriter } from '../scene-writer.js';
17
- import { SCENE_MAX_BATCH, SCENE_MAX_LAYERS, SCENE_OBJECT_TYPES, SCENE_LABEL_FACES, describeScene, serializeScene, } from '../../canvas/scene.js';
17
+ import { SCENE_MAX_BATCH, SCENE_MAX_LAYERS, SCENE_OBJECT_TYPES, SCENE_MAX_LIGHTS, SCENE_MAX_SHADOW_LIGHTS, SCENE_LIGHT_INTENSITY_MAX, SCENE_LABEL_FACES, describeScene, serializeScene, } from '../../canvas/scene.js';
18
18
  import { describeLayers, emptyLayers, undeclaredTags } from '../../canvas/scene-layers.js';
19
19
  import { formatUserEditNotice, } from '../../canvas/scene-ops.js';
20
20
  export const SCENE_TOOL_NAMES = [
@@ -25,6 +25,7 @@ export const SCENE_TOOL_NAMES = [
25
25
  'scene_remove_object',
26
26
  'scene_set_camera',
27
27
  'scene_set_layers',
28
+ 'scene_set_lights',
28
29
  'scene_screenshot',
29
30
  ];
30
31
  const DEFAULT_SHOT = { width: 1280, height: 800 };
@@ -288,11 +289,36 @@ const OBJECT_PROPERTIES = {
288
289
  },
289
290
  material: {
290
291
  type: 'object',
291
- description: '{ roughness 0–1, metalness 0–1, opacity 0.05–1 }. Not on a group.',
292
+ description: 'Not on a group or a label. { roughness 0–1 (0 = mirror-smooth, 1 = matt) · metalness 0–1 ' +
293
+ '(metal is MIRROR, not colour: it shows the room, so it reads dark against a plain ' +
294
+ 'background — use it for a few accents, not everything) · opacity 0.05–1 (a pane of glass, ' +
295
+ 'a volume you want to see into) · emissive "#RRGGBB" + emissiveIntensity 0–5 · wireframe }.',
292
296
  properties: {
293
297
  roughness: { type: 'number' },
294
298
  metalness: { type: 'number' },
295
299
  opacity: { type: 'number' },
300
+ emissive: {
301
+ type: 'string',
302
+ description: 'A colour the object GIVES OFF, so it reads as live rather than lit: an indicator, an ' +
303
+ 'active path, the step being explained. ⚠️ It brightens the object only — it casts no ' +
304
+ "light on its neighbours and draws no halo. Pair it with the object's own colour, and " +
305
+ 'use it on a few things: everything glowing is nothing glowing.',
306
+ },
307
+ emissiveIntensity: {
308
+ type: 'number',
309
+ description: '0–5, default 1. Does nothing without emissive. ⚠️ MEASURED on a real scene: a sphere at ' +
310
+ '1.5 loses its shading and reads as a flat disc, and a ring at 2 washes to near-white and ' +
311
+ 'loses its hue. Emission adds a flat colour OVER the shading, so the bigger or paler the ' +
312
+ 'object, the sooner it stops looking like a solid. Stay at or below ~0.3 for anything ' +
313
+ 'larger than a token and save the high end for small accents. Against a bright scene a ' +
314
+ 'glow reads poorly whatever the number — dim the scene with scene_set_lights rather than ' +
315
+ 'turning this up.',
316
+ },
317
+ wireframe: {
318
+ type: 'boolean',
319
+ description: 'Edges only. For the parts of a diagram that are LOGICAL rather than built — a boundary, ' +
320
+ 'a planned phase, a region — beside solid objects that are real.',
321
+ },
296
322
  },
297
323
  },
298
324
  locked: {
@@ -664,6 +690,56 @@ export function createSceneTools(config, options) {
664
690
  },
665
691
  });
666
692
  // ---------------------------------------------------------------------------
693
+ const sceneSetLightsTool = defineTool({
694
+ name: 'scene_set_lights',
695
+ description: "Replace a scene's lighting. Until this existed, lights could only be set at creation, so " +
696
+ 'dimming a finished scene meant rebuilding every object in it. ' +
697
+ 'Two kinds: { type: "ambient", intensity } is the overall fill — it also sets how much of ' +
698
+ 'the room glossy and metallic surfaces reflect, so dropping it to 0 makes a genuinely dark ' +
699
+ 'scene — and { type: "sun", position: [x, y, z], intensity, castShadow } is a directional ' +
700
+ 'light that can cast shadows. [] goes back to the default studio light (ambient fill + one ' +
701
+ 'sun from above-right), which is what most scenes want. ' +
702
+ `Up to ${String(SCENE_MAX_LIGHTS)} lights, ${String(SCENE_MAX_SHADOW_LIGHTS)} of them casting shadows ` +
703
+ '(each shadow costs a render pass). ⚠️ Lighting a scene darker makes emissive objects stand ' +
704
+ 'out; it also makes everything else harder to read, so change it for a reason.',
705
+ inputSchema: {
706
+ type: 'object',
707
+ properties: {
708
+ canvas_id: { type: 'number', description: 'The scene canvas id.' },
709
+ lights: {
710
+ type: 'array',
711
+ maxItems: SCENE_MAX_LIGHTS,
712
+ description: '[] restores the default studio light.',
713
+ items: {
714
+ type: 'object',
715
+ properties: {
716
+ type: { type: 'string', enum: ['sun', 'ambient'] },
717
+ position: { ...VEC3, description: 'sun only: where it shines FROM, in metres.' },
718
+ intensity: {
719
+ type: 'number',
720
+ description: `0–${String(SCENE_LIGHT_INTENSITY_MAX)}. The default studio light is ambient ~1 and sun ~1.6.`,
721
+ },
722
+ castShadow: { type: 'boolean', description: 'sun only. Default false.' },
723
+ },
724
+ required: ['type'],
725
+ },
726
+ },
727
+ },
728
+ required: ['canvas_id', 'lights'],
729
+ },
730
+ execute: async (input) => {
731
+ try {
732
+ if (!Array.isArray(input.lights)) {
733
+ return createErrorResult('lights must be an array of { type: "sun" | "ambient", … } — or [] to go back to the default studio light.');
734
+ }
735
+ return await applyAsAgent(input.canvas_id, [{ op: 'lights', lights: input.lights }], () => null);
736
+ }
737
+ catch (error) {
738
+ return createErrorResult(errorMessage('set lights', error));
739
+ }
740
+ },
741
+ });
742
+ // ---------------------------------------------------------------------------
667
743
  const sceneScreenshotTool = defineTool({
668
744
  name: 'scene_screenshot',
669
745
  description: 'RENDER a 3D scene and SEE it — check proportions, overlaps, gaps and floating objects after ' +
@@ -717,6 +793,7 @@ export function createSceneTools(config, options) {
717
793
  sceneRemoveObjectTool,
718
794
  sceneSetCameraTool,
719
795
  sceneSetLayersTool,
796
+ sceneSetLightsTool,
720
797
  sceneScreenshotTool,
721
798
  ];
722
799
  }
@@ -315,6 +315,7 @@ export const TOOL_GROUPS = {
315
315
  'scene_remove_object',
316
316
  'scene_set_camera',
317
317
  'scene_set_layers',
318
+ 'scene_set_lights',
318
319
  'scene_screenshot',
319
320
  ],
320
321
  readOnly: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compilr-dev/sdk",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "Universal agent runtime for building AI-powered applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",