@compilr-dev/sdk 0.37.1 → 0.39.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.
@@ -13,7 +13,7 @@
13
13
  export declare const SCENE_VERSION = 1;
14
14
  export type Vec3 = [number, number, number];
15
15
  export type Vec2 = [number, number];
16
- export type SceneObjectType = 'box' | 'sphere' | 'cylinder' | 'cone' | 'torus' | 'extrude' | 'wedge' | 'group';
16
+ export type SceneObjectType = 'box' | 'sphere' | 'cylinder' | 'cone' | 'torus' | 'extrude' | 'wedge' | 'group' | 'label';
17
17
  export declare const SCENE_OBJECT_TYPES: readonly SceneObjectType[];
18
18
  /** The direction an extrude extends along (§17). */
19
19
  export type ExtrudeAxis = 'x' | 'y' | 'z';
@@ -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}$/ */
@@ -133,7 +149,32 @@ export interface GroupObject extends SceneObjectBase {
133
149
  color?: never;
134
150
  material?: never;
135
151
  }
136
- export type SceneObject = BoxObject | SphereObject | CylinderObject | ConeObject | TorusObject | ExtrudeObject | WedgeObject | GroupObject;
152
+ /** Which way a label turns (§19, D-L2). */
153
+ export type LabelFace = 'camera' | 'fixed';
154
+ export declare const SCENE_LABEL_FACES: readonly LabelFace[];
155
+ /**
156
+ * Visible text in the scene (§19). Drawn as an unlit textured plane, so it reads the same in
157
+ * shadow as in the sun and it survives into the GLB and into `scene_screenshot` — which a DOM
158
+ * overlay would not, and those are the two places the work leaves the app.
159
+ *
160
+ * `color` is the INK. `material` is rejected: roughness and metalness mean nothing for unlit
161
+ * text, and a half-faded label is a request nobody has.
162
+ *
163
+ * Parent it to name a thing — `group: "<id>"` — and it moves when that thing moves.
164
+ */
165
+ export interface LabelObject extends SceneObjectBase {
166
+ type: 'label';
167
+ /** One line, 1–120 chars. A newline is rejected: use two labels. */
168
+ text: string;
169
+ /** Cap height in metres (the width follows the text). */
170
+ size?: number;
171
+ /** '#RRGGBB' plate behind the text. Omitted = transparent. */
172
+ background?: string;
173
+ /** 'camera' (default) turns to face the viewer; 'fixed' obeys `rotation`. */
174
+ face?: LabelFace;
175
+ material?: never;
176
+ }
177
+ export type SceneObject = BoxObject | SphereObject | CylinderObject | ConeObject | TorusObject | ExtrudeObject | WedgeObject | GroupObject | LabelObject;
137
178
  export interface SceneCamera {
138
179
  position: Vec3;
139
180
  target: Vec3;
@@ -210,6 +251,24 @@ export declare const SCENE_MAX_SHADOW_LIGHTS = 2;
210
251
  export declare const SCENE_LIGHT_INTENSITY_MAX = 10;
211
252
  export declare const SCENE_MAX_GROUP_DEPTH = 8;
212
253
  export declare const SCENE_NAME_MAX = 80;
254
+ /** Label text (§19). One line — long enough for a room name or a dimension, not a paragraph. */
255
+ export declare const SCENE_LABEL_TEXT_MAX = 120;
256
+ /** Label cap height in metres. */
257
+ export declare const SCENE_LABEL_SIZE_MIN = 0.02;
258
+ export declare const SCENE_LABEL_SIZE_MAX = 20;
259
+ export declare const SCENE_LABEL_SIZE_DEFAULT = 0.25;
260
+ /**
261
+ * Estimated advance per character, as a fraction of the cap height — used for a label's BOUNDS.
262
+ *
263
+ * ⚠️ An estimate on purpose. The real width comes from measuring the text in a 2D canvas, which
264
+ * this module cannot do (it runs in Node, in the CLI, and in tests with no DOM). The renderer
265
+ * measures and builds its plane from that; this number only decides how much room a label is
266
+ * given when the camera is fitted to the scene, where being a few centimetres out is invisible.
267
+ * Anything that must agree exactly uses `liftFor`, which depends on the height alone.
268
+ */
269
+ export declare const SCENE_LABEL_ADVANCE = 0.62;
270
+ /** A label's plane in metres, [width, height] — the width estimated (see SCENE_LABEL_ADVANCE). */
271
+ export declare function labelPlaneSize(obj: LabelObject): Vec2;
213
272
  export declare const SCENE_OPACITY_MIN = 0.05;
214
273
  export declare const SCENE_ID_PATTERN: RegExp;
215
274
  /** Object colours never read as state (README) — a neutral palette, filled by insertion index. */
@@ -217,6 +276,8 @@ export declare const SCENE_NEUTRAL_PALETTE: readonly string[];
217
276
  /** Accessible names for the palette swatches, same order (Q-10). */
218
277
  export declare const SCENE_PALETTE_NAMES: readonly string[];
219
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;
220
281
  /** The reference camera, used for an empty scene. */
221
282
  export declare const SCENE_DEFAULT_CAMERA: Readonly<SceneCamera>;
222
283
  /** Vertical field of view of the viewer camera, degrees. */
@@ -23,8 +23,10 @@ export const SCENE_OBJECT_TYPES = [
23
23
  'extrude',
24
24
  'wedge',
25
25
  'group',
26
+ 'label',
26
27
  ];
27
28
  export const SCENE_EXTRUDE_AXES = ['x', 'y', 'z'];
29
+ export const SCENE_LABEL_FACES = ['camera', 'fixed'];
28
30
  // =============================================================================
29
31
  // Limits and defaults (§2.3, §2.4, Q-1, Q-14)
30
32
  // =============================================================================
@@ -56,6 +58,27 @@ export const SCENE_MAX_SHADOW_LIGHTS = 2;
56
58
  export const SCENE_LIGHT_INTENSITY_MAX = 10;
57
59
  export const SCENE_MAX_GROUP_DEPTH = 8;
58
60
  export const SCENE_NAME_MAX = 80;
61
+ /** Label text (§19). One line — long enough for a room name or a dimension, not a paragraph. */
62
+ export const SCENE_LABEL_TEXT_MAX = 120;
63
+ /** Label cap height in metres. */
64
+ export const SCENE_LABEL_SIZE_MIN = 0.02;
65
+ export const SCENE_LABEL_SIZE_MAX = 20;
66
+ export const SCENE_LABEL_SIZE_DEFAULT = 0.25;
67
+ /**
68
+ * Estimated advance per character, as a fraction of the cap height — used for a label's BOUNDS.
69
+ *
70
+ * ⚠️ An estimate on purpose. The real width comes from measuring the text in a 2D canvas, which
71
+ * this module cannot do (it runs in Node, in the CLI, and in tests with no DOM). The renderer
72
+ * measures and builds its plane from that; this number only decides how much room a label is
73
+ * given when the camera is fitted to the scene, where being a few centimetres out is invisible.
74
+ * Anything that must agree exactly uses `liftFor`, which depends on the height alone.
75
+ */
76
+ export const SCENE_LABEL_ADVANCE = 0.62;
77
+ /** A label's plane in metres, [width, height] — the width estimated (see SCENE_LABEL_ADVANCE). */
78
+ export function labelPlaneSize(obj) {
79
+ const h = obj.size ?? SCENE_LABEL_SIZE_DEFAULT;
80
+ return [Math.max(h * 0.5, obj.text.length * h * SCENE_LABEL_ADVANCE), h];
81
+ }
59
82
  export const SCENE_OPACITY_MIN = 0.05;
60
83
  export const SCENE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,47}$/;
61
84
  /** Object colours never read as state (README) — a neutral palette, filled by insertion index. */
@@ -82,7 +105,12 @@ export const SCENE_DEFAULT_MATERIAL = {
82
105
  roughness: 0.78,
83
106
  metalness: 0.02,
84
107
  opacity: 1,
108
+ emissive: '#000000',
109
+ emissiveIntensity: 1,
110
+ wireframe: false,
85
111
  };
112
+ /** `material.emissiveIntensity` range. Above ~5 everything clips to white. */
113
+ export const SCENE_EMISSIVE_INTENSITY_MAX = 5;
86
114
  /** The reference camera, used for an empty scene. */
87
115
  export const SCENE_DEFAULT_CAMERA = {
88
116
  position: [7, 5.5, 8],
@@ -132,6 +160,8 @@ export function defaultObjectFor(type, id) {
132
160
  return { ...base, type, size: [1, 1, 1] };
133
161
  case 'group':
134
162
  return { ...base, type };
163
+ case 'label':
164
+ return { ...base, type, text: 'Label' };
135
165
  }
136
166
  }
137
167
  /** The stored form: pretty-printed, because agents read and cite it (§4.1). */
@@ -177,6 +207,7 @@ const TYPE_KEYS = {
177
207
  extrude: ['points', 'height', 'axis'],
178
208
  wedge: ['size', 'ridge'],
179
209
  group: [],
210
+ label: ['text', 'size', 'background', 'face'],
180
211
  };
181
212
  /** How each type is described in errors (what fields it takes). */
182
213
  const TYPE_SHAPE = {
@@ -188,8 +219,16 @@ const TYPE_SHAPE = {
188
219
  extrude: 'extrude takes points [[a, b], …], height and optional axis',
189
220
  wedge: 'wedge takes size [w, h, d] and optional ridge',
190
221
  group: 'a group is a container: id, name, position, rotation, group, locked, tags only — colour its children',
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',
191
223
  };
192
- 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
+ ]);
193
232
  const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
194
233
  /** Short, stable number rendering for messages and summaries. */
195
234
  export function fmtNum(n) {
@@ -337,7 +376,8 @@ function validateObject(raw, i, errors) {
337
376
  // Unknown keys: rejected, because they would round-trip silently and read as honoured.
338
377
  const allowed = new Set([
339
378
  ...BASE_KEYS,
340
- ...(type === 'group' ? [] : SURFACE_KEYS),
379
+ // A group has no surface at all; a label has ink (`color`) but no material (§19.2).
380
+ ...(type === 'group' ? [] : type === 'label' ? ['color'] : SURFACE_KEYS),
341
381
  ...TYPE_KEYS[type],
342
382
  ]);
343
383
  for (const k of Object.keys(raw)) {
@@ -386,7 +426,7 @@ function validateObject(raw, i, errors) {
386
426
  }
387
427
  }
388
428
  }
389
- if (type !== 'group' && raw.material !== undefined) {
429
+ if (type !== 'group' && type !== 'label' && raw.material !== undefined) {
390
430
  if (!isRecord(raw.material)) {
391
431
  errors.push(`${label}.material = ${show(raw.material)} — { roughness?, metalness?, opacity? }.`);
392
432
  }
@@ -394,7 +434,7 @@ function validateObject(raw, i, errors) {
394
434
  const m = raw.material;
395
435
  for (const k of Object.keys(m)) {
396
436
  if (!MATERIAL_KEYS.has(k)) {
397
- 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.`);
398
438
  }
399
439
  }
400
440
  if (m.roughness !== undefined)
@@ -403,6 +443,15 @@ function validateObject(raw, i, errors) {
403
443
  checkNumber(m.metalness, `${label}.material.metalness`, 0, 1, 'metalness is 0–1', errors);
404
444
  if (m.opacity !== undefined)
405
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
+ }
406
455
  }
407
456
  }
408
457
  // Type-specific dimensions.
@@ -433,6 +482,29 @@ function validateObject(raw, i, errors) {
433
482
  break;
434
483
  case 'group':
435
484
  break;
485
+ case 'label': {
486
+ if (typeof raw.text !== 'string' || raw.text.length < 1) {
487
+ errors.push(`${label}.text = ${show(raw.text)} — the words to show, 1–${String(SCENE_LABEL_TEXT_MAX)} chars.`);
488
+ }
489
+ else if (raw.text.length > SCENE_LABEL_TEXT_MAX) {
490
+ errors.push(`${label}.text is ${String(raw.text.length)} chars (max ${String(SCENE_LABEL_TEXT_MAX)}) — a label is one short line, not a paragraph.`);
491
+ }
492
+ else if (/[\n\r]/.test(raw.text)) {
493
+ errors.push(`${label}.text has a line break — a label is one line; use two labels.`);
494
+ }
495
+ if (raw.size !== undefined) {
496
+ checkNumber(raw.size, `${label}.size`, SCENE_LABEL_SIZE_MIN, SCENE_LABEL_SIZE_MAX, `size is the cap height in metres, ${fmtNum(SCENE_LABEL_SIZE_MIN)}–${fmtNum(SCENE_LABEL_SIZE_MAX)} (the width follows the text)`, errors);
497
+ }
498
+ if (raw.background !== undefined &&
499
+ (typeof raw.background !== 'string' || !HEX_RE.test(raw.background))) {
500
+ errors.push(`${label}.background = ${show(raw.background)} — colours are "#RGB" or "#RRGGBB" hex.`);
501
+ }
502
+ if (raw.face !== undefined &&
503
+ !SCENE_LABEL_FACES.includes(raw.face)) {
504
+ errors.push(`${label}.face = ${show(raw.face)} — "camera" (turns to face the viewer) or "fixed" (obeys rotation).`);
505
+ }
506
+ break;
507
+ }
436
508
  case 'torus': {
437
509
  const rOk = checkNumber(raw.radius, `${label}.radius`, SCENE_RADIUS_MIN, SCENE_RADIUS_MAX, RADIUS_RULE, errors);
438
510
  const maxTube = rOk ? raw.radius : SCENE_RADIUS_MAX;
@@ -779,6 +851,9 @@ export function liftFor(obj) {
779
851
  }
780
852
  case 'group':
781
853
  return 0;
854
+ // Half the plane's height, so `y` is the bottom here too (D-L6).
855
+ case 'label':
856
+ return (obj.size ?? SCENE_LABEL_SIZE_DEFAULT) / 2;
782
857
  }
783
858
  }
784
859
  /**
@@ -822,6 +897,12 @@ function extrudeBox(obj) {
822
897
  /** Local AABB of the mesh around its own origin (the pivot). null for a group (no geometry). */
823
898
  function localBox(obj) {
824
899
  switch (obj.type) {
900
+ // A flat plane in x–y, centred on the pivot. Its depth is zero, and the width is the
901
+ // estimate (SCENE_LABEL_ADVANCE) — a label never decides a scene's extents on its own.
902
+ case 'label': {
903
+ const [w, h] = labelPlaneSize(obj);
904
+ return { min: [-w / 2, -h / 2, 0], max: [w / 2, h / 2, 0] };
905
+ }
825
906
  case 'box':
826
907
  case 'wedge': {
827
908
  // A wedge's triangle spans the full width at its base and the full height at its apex,
@@ -40,7 +40,12 @@ export const PROVIDER_METADATA = {
40
40
  displayName: 'Google AI (Gemini)',
41
41
  description: 'Gemini models from Google',
42
42
  endpoint: 'https://generativelanguage.googleapis.com/v1beta',
43
- envVar: 'GOOGLE_API_KEY',
43
+ // Must be a variable `detectProviderFromEnv` actually reads (see ENV_PROVIDER_MAP in
44
+ // provider.ts). It said `GOOGLE_API_KEY` for months — a variable nothing anywhere reads —
45
+ // and that string is what the CLI prints in "export <var>=..." when the key is missing and
46
+ // in the /model detail pane, so the one user who followed it set a variable that was then
47
+ // ignored. `GEMINI_API_KEY` is also accepted as a fallback.
48
+ envVar: 'GOOGLE_AI_API_KEY',
44
49
  keyUrl: 'https://aistudio.google.com/apikey',
45
50
  requiresKey: true,
46
51
  category: 'primary',
@@ -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, describeScene, serializeScene, } from '../../canvas/scene.js';
17
+ import { SCENE_MAX_BATCH, SCENE_MAX_LAYERS, SCENE_OBJECT_TYPES, 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 = [
@@ -200,7 +200,9 @@ const OBJECT_PROPERTIES = {
200
200
  description: 'box (size [w,h,d]) · sphere (radius) · cylinder / cone (radius, height, optional segments) · torus (radius, tube; lies flat) · ' +
201
201
  'extrude (points + height, optional axis) for walls, floors and profiles · wedge (size [w,h,d], optional ridge) — a ' +
202
202
  'triangular prism for roofs · group — a container with no geometry: give it your own id and put parts in it with ' +
203
- 'group: "<id>". There is no "wall" type — use extrude.',
203
+ 'group: "<id>" · label (text) — VISIBLE TEXT in the scene, for room names, part names and ' +
204
+ 'callouts; parent it with group: "<id>" to name a thing and it moves with it. ' +
205
+ 'There is no "wall" type — use extrude.',
204
206
  },
205
207
  position: {
206
208
  ...VEC3,
@@ -226,6 +228,25 @@ const OBJECT_PROPERTIES = {
226
228
  description: 'cylinder/cone: height in metres. extrude: how far it extends along its axis.',
227
229
  },
228
230
  tube: { type: 'number', description: 'torus only: tube radius (≤ radius).' },
231
+ text: {
232
+ type: 'string',
233
+ description: 'label only: the words to show (≤ 120, ONE line — no newline; use two labels). The scene is ' +
234
+ 'otherwise unlabelled solids, so name the rooms and the parts: a screenshot with no text in ' +
235
+ 'it cannot be read back by you or by anyone else.',
236
+ },
237
+ background: {
238
+ type: 'string',
239
+ description: 'label only: "#RRGGBB" plate behind the text. Omit for transparent — add one only where the ' +
240
+ 'label sits over busy geometry.',
241
+ },
242
+ face: {
243
+ type: 'string',
244
+ enum: SCENE_LABEL_FACES,
245
+ description: 'label only: "camera" (default) turns the text to the viewer, for a callout. "fixed" obeys ' +
246
+ 'rotation, for text that belongs to a surface — a room name lying on the floor needs ' +
247
+ 'face: "fixed" with rotation [-90, 0, 0]. ⚠️ A GLB export bakes whichever way a "camera" ' +
248
+ 'label happened to face.',
249
+ },
229
250
  segments: {
230
251
  type: 'integer',
231
252
  minimum: 3,
@@ -267,11 +288,30 @@ const OBJECT_PROPERTIES = {
267
288
  },
268
289
  material: {
269
290
  type: 'object',
270
- description: '{ roughness 0–1, metalness 0–1, opacity 0.05–1 }. Not on a group.',
291
+ description: 'Not on a group or a label. { roughness 0–1 (0 = mirror-smooth, 1 = matt) · metalness 0–1 ' +
292
+ '(metal is MIRROR, not colour: it shows the room, so it reads dark against a plain ' +
293
+ 'background — use it for a few accents, not everything) · opacity 0.05–1 (a pane of glass, ' +
294
+ 'a volume you want to see into) · emissive "#RRGGBB" + emissiveIntensity 0–5 · wireframe }.',
271
295
  properties: {
272
296
  roughness: { type: 'number' },
273
297
  metalness: { type: 'number' },
274
298
  opacity: { type: 'number' },
299
+ emissive: {
300
+ type: 'string',
301
+ description: 'A colour the object GIVES OFF, so it reads as live rather than lit: an indicator, an ' +
302
+ 'active path, the step being explained. ⚠️ It brightens the object only — it casts no ' +
303
+ "light on its neighbours and draws no halo. Pair it with the object's own colour, and " +
304
+ 'use it on a few things: everything glowing is nothing glowing.',
305
+ },
306
+ emissiveIntensity: {
307
+ type: 'number',
308
+ description: '0–5, default 1. Does nothing without emissive. Past ~3 the colour clips to white.',
309
+ },
310
+ wireframe: {
311
+ type: 'boolean',
312
+ description: 'Edges only. For the parts of a diagram that are LOGICAL rather than built — a boundary, ' +
313
+ 'a planned phase, a region — beside solid objects that are real.',
314
+ },
275
315
  },
276
316
  },
277
317
  locked: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compilr-dev/sdk",
3
- "version": "0.37.1",
3
+ "version": "0.39.0",
4
4
  "description": "Universal agent runtime for building AI-powered applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",