@compilr-dev/sdk 0.33.1 → 0.34.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.
@@ -6,6 +6,7 @@ export * from './types.js';
6
6
  export * from './validate.js';
7
7
  export * from './scene.js';
8
8
  export * from './scene-ops.js';
9
+ export * from './scene-layers.js';
9
10
  export { parseColor, relativeLuminance, contrastRatio, oklabLightness, lightnessDeltaPct, isNeutral, hueAngle, AA_TEXT, SURFACE_DELTA_MIN, SURFACE_DELTA_TARGET, SURFACE_DELTA_MAX, HAIRLINE_MIN_PCT, HAIRLINE_MAX_PCT, } from './color.js';
10
11
  export type { Rgb } from './color.js';
11
12
  export { runQualityChecks, checkSurfaceTokens, checkTextContrast, checkHueCount, checkDeadTweaks, primaryVarNames, } from './quality-checks.js';
@@ -11,6 +11,7 @@ export * from './validate.js';
11
11
  */
12
12
  export * from './scene.js';
13
13
  export * from './scene-ops.js';
14
+ export * from './scene-layers.js';
14
15
  /*
15
16
  ⚠️ Renderer-safe on purpose. Both modules are pure arithmetic with no imports beyond
16
17
  each other, so Desktop's renderer can use them for the surface ramp without pulling the
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Scene layers → the Tweaks panel (3d-canvas-spec §18).
3
+ *
4
+ * A scene's control manifest is DERIVED from `scene.layers`, never stored on the canvas row:
5
+ * one source of truth, so a toggle cannot drift from the scene that declares it. What IS stored
6
+ * is the row's `values` — `layer_exterior: false` — because which layers are showing is the
7
+ * user's view state, not a change to the model. That split is the whole point of §18.2: hiding a
8
+ * wall to look inside must not bump the rev, enter the undo journal, or tell the agent the user
9
+ * edited the scene.
10
+ */
11
+ import type { ControlManifest, ParamValue } from './types.js';
12
+ import type { SceneFile, SceneObject } from './scene.js';
13
+ /** The panel group every layer toggle sits in. */
14
+ export declare const LAYER_GROUP = "Layers";
15
+ /**
16
+ * `exterior` → `layer_exterior`, `load-bearing` → `layer_load_bearing`.
17
+ *
18
+ * ⚠️ `PARAM_RE` (canvas/validate.ts) is `/^[a-zA-Z_][a-zA-Z0-9_]*$/`, so the obvious
19
+ * `layer:exterior` fails manifest validation — hence the prefix and the `-` → `_` mapping. Tags
20
+ * forbid underscores (`SCENE_TAG_PATTERN`) precisely so this stays reversible: without that,
21
+ * `load-bearing` and `load_bearing` would both land on `layer_load_bearing`.
22
+ */
23
+ export declare function layerParam(tag: string): string;
24
+ /** The inverse of `layerParam`; null for any other param. Unambiguous — see the note there. */
25
+ export declare function layerTagFromParam(param: string): string | null;
26
+ /** One toggle per declared layer, in declaration order. Empty when the scene declares none. */
27
+ export declare function sceneControlManifest(scene: SceneFile): ControlManifest;
28
+ /** Does this scene have anything to tweak? Drives whether the host shows the panel at all. */
29
+ export declare function sceneHasLayers(scene: SceneFile): boolean;
30
+ /** Every tag actually carried by an object, in first-seen order. */
31
+ export declare function sceneTags(scene: SceneFile): string[];
32
+ /**
33
+ * Tags objects carry that no layer declares — metadata, not controls.
34
+ *
35
+ * Reported by `scene_get` so the agent can see that a label it has been applying is not yet
36
+ * togglable, rather than wondering why the user has no switch for it.
37
+ */
38
+ export declare function undeclaredTags(scene: SceneFile): string[];
39
+ /** Layers declared with no object carrying the tag — a control that governs nothing, yet. */
40
+ export declare function emptyLayers(scene: SceneFile): string[];
41
+ /**
42
+ * The tags currently switched OFF, from the row's values.
43
+ *
44
+ * A layer with no entry in `values` falls back to its declared default, so a scene read before
45
+ * the user has touched anything hides exactly what the agent asked to be hidden.
46
+ */
47
+ export declare function hiddenTags(scene: SceneFile, values: Record<string, ParamValue> | undefined): Set<string>;
48
+ /**
49
+ * Is this object hidden in its own right?
50
+ *
51
+ * Only its OWN tags: a hidden ancestor hides its subtree through the renderer's node tree
52
+ * (three.js propagates `visible`), so walking the ancestry here would double-count and would
53
+ * disagree with what is drawn.
54
+ */
55
+ export declare function isTagHidden(object: SceneObject, hidden: ReadonlySet<string>): boolean;
56
+ /** One line for `scene_get`'s header: `exterior (on), glazing (off)`, or null when there are none. */
57
+ export declare function describeLayers(scene: SceneFile, values?: Record<string, ParamValue>): string | null;
@@ -0,0 +1,94 @@
1
+ /** The panel group every layer toggle sits in. */
2
+ export const LAYER_GROUP = 'Layers';
3
+ /**
4
+ * `exterior` → `layer_exterior`, `load-bearing` → `layer_load_bearing`.
5
+ *
6
+ * ⚠️ `PARAM_RE` (canvas/validate.ts) is `/^[a-zA-Z_][a-zA-Z0-9_]*$/`, so the obvious
7
+ * `layer:exterior` fails manifest validation — hence the prefix and the `-` → `_` mapping. Tags
8
+ * forbid underscores (`SCENE_TAG_PATTERN`) precisely so this stays reversible: without that,
9
+ * `load-bearing` and `load_bearing` would both land on `layer_load_bearing`.
10
+ */
11
+ export function layerParam(tag) {
12
+ return `layer_${tag.replace(/-/g, '_')}`;
13
+ }
14
+ /** The inverse of `layerParam`; null for any other param. Unambiguous — see the note there. */
15
+ export function layerTagFromParam(param) {
16
+ if (!param.startsWith('layer_'))
17
+ return null;
18
+ const tag = param.slice('layer_'.length).replace(/_/g, '-');
19
+ return tag.length > 0 ? tag : null;
20
+ }
21
+ /** One toggle per declared layer, in declaration order. Empty when the scene declares none. */
22
+ export function sceneControlManifest(scene) {
23
+ const controls = (scene.layers ?? []).map((l) => ({
24
+ type: 'toggle',
25
+ param: layerParam(l.tag),
26
+ label: l.label,
27
+ default: l.visible ?? true,
28
+ group: LAYER_GROUP,
29
+ }));
30
+ return { controls };
31
+ }
32
+ /** Does this scene have anything to tweak? Drives whether the host shows the panel at all. */
33
+ export function sceneHasLayers(scene) {
34
+ return (scene.layers ?? []).length > 0;
35
+ }
36
+ /** Every tag actually carried by an object, in first-seen order. */
37
+ export function sceneTags(scene) {
38
+ const out = [];
39
+ for (const o of scene.objects) {
40
+ for (const t of o.tags ?? [])
41
+ if (!out.includes(t))
42
+ out.push(t);
43
+ }
44
+ return out;
45
+ }
46
+ /**
47
+ * Tags objects carry that no layer declares — metadata, not controls.
48
+ *
49
+ * Reported by `scene_get` so the agent can see that a label it has been applying is not yet
50
+ * togglable, rather than wondering why the user has no switch for it.
51
+ */
52
+ export function undeclaredTags(scene) {
53
+ const declared = new Set((scene.layers ?? []).map((l) => l.tag));
54
+ return sceneTags(scene).filter((t) => !declared.has(t));
55
+ }
56
+ /** Layers declared with no object carrying the tag — a control that governs nothing, yet. */
57
+ export function emptyLayers(scene) {
58
+ const used = new Set(sceneTags(scene));
59
+ return (scene.layers ?? []).map((l) => l.tag).filter((t) => !used.has(t));
60
+ }
61
+ /**
62
+ * The tags currently switched OFF, from the row's values.
63
+ *
64
+ * A layer with no entry in `values` falls back to its declared default, so a scene read before
65
+ * the user has touched anything hides exactly what the agent asked to be hidden.
66
+ */
67
+ export function hiddenTags(scene, values) {
68
+ const hidden = new Set();
69
+ for (const layer of scene.layers ?? []) {
70
+ const raw = values?.[layerParam(layer.tag)];
71
+ const on = raw === undefined ? (layer.visible ?? true) : raw === true || raw === 'true' || raw === 1;
72
+ if (!on)
73
+ hidden.add(layer.tag);
74
+ }
75
+ return hidden;
76
+ }
77
+ /**
78
+ * Is this object hidden in its own right?
79
+ *
80
+ * Only its OWN tags: a hidden ancestor hides its subtree through the renderer's node tree
81
+ * (three.js propagates `visible`), so walking the ancestry here would double-count and would
82
+ * disagree with what is drawn.
83
+ */
84
+ export function isTagHidden(object, hidden) {
85
+ return (object.tags ?? []).some((t) => hidden.has(t));
86
+ }
87
+ /** One line for `scene_get`'s header: `exterior (on), glazing (off)`, or null when there are none. */
88
+ export function describeLayers(scene, values) {
89
+ const layers = scene.layers ?? [];
90
+ if (layers.length === 0)
91
+ return null;
92
+ const hidden = hiddenTags(scene, values);
93
+ return layers.map((l) => `${l.tag} (${hidden.has(l.tag) ? 'off' : 'on'})`).join(', ');
94
+ }
@@ -43,6 +43,11 @@ export type SceneOp =
43
43
  fit: true;
44
44
  };
45
45
  }
46
+ /** Replace the whole layer list (§18); `[]` clears it, so the scene has no Tweaks panel. */
47
+ | {
48
+ op: 'layers';
49
+ layers: unknown;
50
+ }
46
51
  /** Whole-scene replace (scene_create, JSON import). */
47
52
  | {
48
53
  op: 'replace';
@@ -54,7 +59,7 @@ export type SceneEditSource = {
54
59
  } | {
55
60
  kind: 'user';
56
61
  };
57
- export type SceneChangeKind = 'moved' | 'resized' | 'rotated' | 'recoloured' | 'renamed' | 'removed' | 'added' | 'edited' | 'camera' | 'replaced';
62
+ export type SceneChangeKind = 'moved' | 'resized' | 'rotated' | 'recoloured' | 'renamed' | 'removed' | 'added' | 'edited' | 'camera' | 'layers' | 'replaced';
58
63
  /** One journalled change (the writer stamps `rev`). */
59
64
  export interface SceneChange {
60
65
  rev: number;
@@ -259,6 +259,38 @@ function removeObject(scene, id, opts) {
259
259
  removed,
260
260
  };
261
261
  }
262
+ /**
263
+ * Replace `scene.layers` (§18). The shape is checked by the final `validateScene`, like every
264
+ * other op; this only rejects what would otherwise be stored as a non-list.
265
+ *
266
+ * ⚠️ This changes which toggles EXIST, not which are on — that is the canvas row's `values` and
267
+ * is not a scene edit at all.
268
+ */
269
+ function setLayers(scene, layers) {
270
+ if (!Array.isArray(layers)) {
271
+ return {
272
+ error: 'layers must be an array of { tag, label, visible? } — or [] to remove every toggle.',
273
+ };
274
+ }
275
+ const before = scene.layers ?? [];
276
+ const next = { ...scene };
277
+ if (layers.length === 0)
278
+ delete next.layers;
279
+ else
280
+ next.layers = layers;
281
+ const summary = layers.length === 0
282
+ ? `Layers cleared${before.length > 0 ? ` (was ${String(before.length)})` : ''}.`
283
+ : `Layers set: ${layers
284
+ .map((l) => (isRecord(l) && typeof l.tag === 'string' ? l.tag : '?'))
285
+ .join(', ')}.`;
286
+ return {
287
+ scene: next,
288
+ summary,
289
+ ids: [],
290
+ changes: [{ id: '', what: 'layers', from: before, to: layers }],
291
+ warnings: [],
292
+ };
293
+ }
262
294
  function setCamera(scene, camera) {
263
295
  if (isRecord(camera) && camera.fit === true) {
264
296
  const rest = { ...scene };
@@ -301,6 +333,8 @@ export function applySceneOp(scene, op, opts) {
301
333
  return removeObject(scene, op.id, opts);
302
334
  case 'camera':
303
335
  return setCamera(scene, op.camera);
336
+ case 'layers':
337
+ return setLayers(scene, op.layers);
304
338
  case 'replace': {
305
339
  const v = validateScene(op.scene);
306
340
  if (!v.ok)
@@ -395,6 +429,8 @@ function describeChange(c) {
395
429
  return `added ${typeof c.to === 'string' ? `${c.to} ` : ''}${q}`;
396
430
  case 'camera':
397
431
  return 'changed the camera';
432
+ case 'layers':
433
+ return 'changed which layers can be hidden';
398
434
  case 'replaced':
399
435
  return 'replaced the whole scene';
400
436
  case 'edited':
@@ -47,6 +47,14 @@ export interface SceneObjectBase {
47
47
  material?: SceneMaterial;
48
48
  /** User-only lock: the inspector cannot move/remove it; agents still can (and are warned). */
49
49
  locked?: boolean;
50
+ /**
51
+ * Cross-cutting labels (§18): `exterior`, `glazing`, `structure`. A tag on a GROUP applies to
52
+ * its whole subtree. Tags declared as `layers` become show/hide toggles for the user.
53
+ *
54
+ * ⚠️ Lowercase, hyphens, NO underscore — `layerParam` maps `-` to `_`, so allowing both would
55
+ * let `load-bearing` and `load_bearing` collide on one toggle.
56
+ */
57
+ tags?: string[];
50
58
  }
51
59
  export interface BoxObject extends SceneObjectBase {
52
60
  type: 'box';
@@ -139,6 +147,21 @@ export type SceneLight = {
139
147
  type: 'ambient';
140
148
  intensity?: number;
141
149
  };
150
+ /**
151
+ * A user-togglable layer (§18): one tag, one toggle in the Tweaks panel.
152
+ *
153
+ * Which layers are currently SHOWING is not here — it lives in the canvas record's `values`, so
154
+ * hiding a wall to look inside is not an edit to the scene (no rev, no undo entry, no user-edit
155
+ * notice to the agent). `visible` is only the default.
156
+ */
157
+ export interface SceneLayer {
158
+ /** The tag this layer governs. */
159
+ tag: string;
160
+ /** Panel label — "Exterior walls", not "exterior". */
161
+ label: string;
162
+ /** Showing by default. Default true. */
163
+ visible?: boolean;
164
+ }
142
165
  export interface SceneFile {
143
166
  version: 1;
144
167
  name: string;
@@ -149,12 +172,23 @@ export interface SceneFile {
149
172
  /** Missing → the default studio light. */
150
173
  lights?: SceneLight[];
151
174
  objects: SceneObject[];
175
+ /**
176
+ * Tags the user may show/hide (§18). A tag no layer declares is metadata, not a control.
177
+ * Missing or empty → the scene has no Tweaks panel.
178
+ */
179
+ layers?: SceneLayer[];
152
180
  }
153
181
  export interface SceneBounds {
154
182
  min: Vec3;
155
183
  max: Vec3;
156
184
  }
157
185
  export declare const SCENE_MAX_OBJECTS = 500;
186
+ /** Tag format (§18.3): no underscore, so `layerParam` cannot produce a collision. */
187
+ export declare const SCENE_TAG_PATTERN: RegExp;
188
+ export declare const SCENE_MAX_TAGS_PER_OBJECT = 8;
189
+ /** Distinct tags in one scene, and layers — beyond this a panel of toggles is a tree. */
190
+ export declare const SCENE_MAX_TAGS = 24;
191
+ export declare const SCENE_MAX_LAYERS = 24;
158
192
  export declare const SCENE_MAX_BYTES: number;
159
193
  /** Max objects in one scene_add_object batch (Q-1). */
160
194
  export declare const SCENE_MAX_BATCH = 50;
@@ -29,6 +29,12 @@ export const SCENE_EXTRUDE_AXES = ['x', 'y', 'z'];
29
29
  // Limits and defaults (§2.3, §2.4, Q-1, Q-14)
30
30
  // =============================================================================
31
31
  export const SCENE_MAX_OBJECTS = 500;
32
+ /** Tag format (§18.3): no underscore, so `layerParam` cannot produce a collision. */
33
+ export const SCENE_TAG_PATTERN = /^[a-z0-9][a-z0-9-]{0,23}$/;
34
+ export const SCENE_MAX_TAGS_PER_OBJECT = 8;
35
+ /** Distinct tags in one scene, and layers — beyond this a panel of toggles is a tree. */
36
+ export const SCENE_MAX_TAGS = 24;
37
+ export const SCENE_MAX_LAYERS = 24;
32
38
  export const SCENE_MAX_BYTES = 512 * 1024;
33
39
  /** Max objects in one scene_add_object batch (Q-1). */
34
40
  export const SCENE_MAX_BATCH = 50;
@@ -158,8 +164,8 @@ export function utf8Bytes(s) {
158
164
  }
159
165
  return n;
160
166
  }
161
- const TOP_KEYS = new Set(['version', 'name', 'units', 'camera', 'lights', 'objects']);
162
- const BASE_KEYS = ['id', 'name', 'type', 'position', 'rotation', 'group', 'locked'];
167
+ const TOP_KEYS = new Set(['version', 'name', 'units', 'camera', 'lights', 'objects', 'layers']);
168
+ const BASE_KEYS = ['id', 'name', 'type', 'position', 'rotation', 'group', 'locked', 'tags'];
163
169
  /** Surface fields: every type but `group` (a group has no surface of its own). */
164
170
  const SURFACE_KEYS = ['color', 'material'];
165
171
  const TYPE_KEYS = {
@@ -181,7 +187,7 @@ const TYPE_SHAPE = {
181
187
  torus: 'torus takes radius and tube',
182
188
  extrude: 'extrude takes points [[a, b], …], height and optional axis',
183
189
  wedge: 'wedge takes size [w, h, d] and optional ridge',
184
- group: 'a group is a container: id, name, position, rotation, group, locked only — colour its children',
190
+ group: 'a group is a container: id, name, position, rotation, group, locked, tags only — colour its children',
185
191
  };
186
192
  const MATERIAL_KEYS = new Set(['roughness', 'metalness', 'opacity']);
187
193
  const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
@@ -362,6 +368,24 @@ function validateObject(raw, i, errors) {
362
368
  if (raw.locked !== undefined && typeof raw.locked !== 'boolean') {
363
369
  errors.push(`${label}.locked = ${show(raw.locked)} — true or false.`);
364
370
  }
371
+ if (raw.tags !== undefined) {
372
+ if (!Array.isArray(raw.tags)) {
373
+ errors.push(`${label}.tags = ${show(raw.tags)} — an array of labels, e.g. ["exterior"].`);
374
+ }
375
+ else if (raw.tags.length > SCENE_MAX_TAGS_PER_OBJECT) {
376
+ errors.push(`${label}.tags has ${String(raw.tags.length)} labels (max ${String(SCENE_MAX_TAGS_PER_OBJECT)}).`);
377
+ }
378
+ else {
379
+ for (const t of raw.tags) {
380
+ if (typeof t !== 'string' || !SCENE_TAG_PATTERN.test(t)) {
381
+ errors.push(`${label}.tags has ${show(t)} — a tag is 1–24 chars of lowercase letters, digits or "-" (no underscore: "load-bearing", not "load_bearing").`);
382
+ }
383
+ }
384
+ if (new Set(raw.tags).size !== raw.tags.length) {
385
+ errors.push(`${label}.tags repeats a label.`);
386
+ }
387
+ }
388
+ }
365
389
  if (type !== 'group' && raw.material !== undefined) {
366
390
  if (!isRecord(raw.material)) {
367
391
  errors.push(`${label}.material = ${show(raw.material)} — { roughness?, metalness?, opacity? }.`);
@@ -501,6 +525,44 @@ function validateGroups(objects, errors) {
501
525
  }
502
526
  });
503
527
  }
528
+ /** `layers` (§18): one toggle per tag, with a label the panel can show. */
529
+ function validateLayers(v, errors) {
530
+ if (!Array.isArray(v)) {
531
+ errors.push(`layers = ${show(v)} — an array of { tag, label, visible? }.`);
532
+ return;
533
+ }
534
+ if (v.length > SCENE_MAX_LAYERS) {
535
+ errors.push(`layers has ${String(v.length)} entries (max ${String(SCENE_MAX_LAYERS)}).`);
536
+ return;
537
+ }
538
+ const seen = new Set();
539
+ v.forEach((raw, i) => {
540
+ const at = `layers[${String(i)}]`;
541
+ if (!isRecord(raw)) {
542
+ errors.push(`${at} = ${show(raw)} — { tag, label, visible? }.`);
543
+ return;
544
+ }
545
+ for (const k of Object.keys(raw)) {
546
+ if (k !== 'tag' && k !== 'label' && k !== 'visible') {
547
+ errors.push(`${at}.${k} is not a layer field — tag, label, visible.`);
548
+ }
549
+ }
550
+ if (typeof raw.tag !== 'string' || !SCENE_TAG_PATTERN.test(raw.tag)) {
551
+ errors.push(`${at}.tag = ${show(raw.tag)} — 1–24 chars of lowercase letters, digits or "-" (no underscore).`);
552
+ }
553
+ else if (seen.has(raw.tag)) {
554
+ errors.push(`${at}.tag = "${raw.tag}" — already declared; one layer per tag.`);
555
+ }
556
+ else
557
+ seen.add(raw.tag);
558
+ if (typeof raw.label !== 'string' || raw.label.trim().length < 1 || raw.label.length > 48) {
559
+ errors.push(`${at}.label = ${show(raw.label)} — a string of 1–48 chars, e.g. "Exterior walls".`);
560
+ }
561
+ if (raw.visible !== undefined && typeof raw.visible !== 'boolean') {
562
+ errors.push(`${at}.visible = ${show(raw.visible)} — true or false.`);
563
+ }
564
+ });
565
+ }
504
566
  function validateLights(v, errors) {
505
567
  if (!Array.isArray(v)) {
506
568
  errors.push(`lights = ${show(v)} — an array of { type: "sun" | "ambient", … }.`);
@@ -561,7 +623,7 @@ export function validateScene(input) {
561
623
  }
562
624
  for (const k of Object.keys(input)) {
563
625
  if (!TOP_KEYS.has(k)) {
564
- errors.push(`${k} is not a scene field — version, name, units, camera, lights, objects.`);
626
+ errors.push(`${k} is not a scene field — version, name, units, camera, lights, objects, layers.`);
565
627
  }
566
628
  }
567
629
  if (input.version !== SCENE_VERSION) {
@@ -590,6 +652,8 @@ export function validateScene(input) {
590
652
  }
591
653
  if (input.lights !== undefined)
592
654
  validateLights(input.lights, errors);
655
+ if (input.layers !== undefined)
656
+ validateLayers(input.layers, errors);
593
657
  if (!Array.isArray(input.objects)) {
594
658
  errors.push(`objects = ${show(input.objects)} — must be an array.`);
595
659
  return { ok: false, errors };
@@ -612,6 +676,17 @@ export function validateScene(input) {
612
676
  }
613
677
  });
614
678
  validateGroups(objects.filter(isRecord), errors);
679
+ const distinct = new Set();
680
+ for (const o of objects) {
681
+ if (isRecord(o) && Array.isArray(o.tags)) {
682
+ for (const t of o.tags)
683
+ if (typeof t === 'string')
684
+ distinct.add(t);
685
+ }
686
+ }
687
+ if (distinct.size > SCENE_MAX_TAGS) {
688
+ errors.push(`The scene uses ${String(distinct.size)} different tags (max ${String(SCENE_MAX_TAGS)}). Reuse the labels you have — a tag is a layer the user toggles, not a note on one object.`);
689
+ }
615
690
  if (errors.length === 0) {
616
691
  const text = JSON.stringify(input, null, 2);
617
692
  const bytes = utf8Bytes(text);
@@ -279,6 +279,7 @@ export const CAPABILITY_PACKS = {
279
279
  'scene_update_object',
280
280
  'scene_remove_object',
281
281
  'scene_set_camera',
282
+ 'scene_set_layers',
282
283
  'scene_screenshot',
283
284
  ],
284
285
  readOnly: false,
@@ -291,10 +292,13 @@ export const CAPABILITY_PACKS = {
291
292
  '(objects: [...], ≤ 50), the group first, its parts with group: "<id>"; children are positioned in the ' +
292
293
  "group's frame. Roofs: wedge (gable; ridge along z, rotate 90° for x), cone with segments 4 (pyramid/hip), " +
293
294
  'or extrude with axis "x"/"z" (a side profile pushed horizontally). Read with scene_get before editing — ' +
294
- 'the user edits too. Keep neutral colours unless asked. After building, scene_screenshot to check.',
295
- // ≈ chars / 4: the snippet is ~910 chars; the seven schemas serialise to ~12 KB (§17 added
296
- // group/wedge/segments/axis/ridge and the axis mapping, ~3.7 KB).
297
- estimatedPromptTokens: 230,
295
+ "the user edits too (it returns an outline; pass ids: [...] for an object's numbers). TAG AS YOU BUILD: " +
296
+ 'tags: ["exterior"] etc. on each object, then scene_set_layers to give the user show/hide toggles — a tag ' +
297
+ 'spans groups, so "exterior" covers walls on every floor, and tagging afterwards means a pass over every ' +
298
+ 'object. Keep neutral colours unless asked. After building, scene_screenshot to check.',
299
+ // ≈ chars / 4: the snippet is ~1,250 chars; the eight schemas serialise to ~13 KB (§17 added
300
+ // group/wedge/segments/axis/ridge and the axis mapping, ~3.7 KB; §18 tags + scene_set_layers).
301
+ estimatedPromptTokens: 315,
298
302
  estimatedToolTokens: 3000,
299
303
  },
300
304
  plans: {
@@ -15,9 +15,16 @@
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_screenshot"];
19
- /** "sofa · box · Sofa" rows, indented under their group parent. */
20
- export declare function sceneOutline(scene: SceneFile): string;
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"];
19
+ /**
20
+ * "sofa · box · Sofa" rows, indented under their group parent; `#tag` suffixes when `withTags`.
21
+ *
22
+ * ⚠️ `withTags` exists because tags are DECORATION and addressing is the job. 400 objects each
23
+ * carrying two tags pushes the outline past `SCENE_READ_MAX_CHARS` (measured: 20,723 chars), and
24
+ * refusing the cheap read — the one every edit depends on — to make room for labels would be the
25
+ * wrong trade. `sceneReadBody` drops them, with a count, rather than failing.
26
+ */
27
+ export declare function sceneOutline(scene: SceneFile, withTags?: boolean): string;
21
28
  /**
22
29
  * The most JSON one `scene_get` may return.
23
30
  *
@@ -93,6 +100,9 @@ export declare function createSceneTools(config: PlatformToolsConfig, options?:
93
100
  position?: unknown;
94
101
  target?: unknown;
95
102
  fit?: boolean;
103
+ }> | import("@compilr-dev/agents").Tool<{
104
+ canvas_id: number;
105
+ layers?: unknown;
96
106
  }> | import("@compilr-dev/agents").Tool<{
97
107
  canvas_id: number;
98
108
  width?: number;
@@ -14,7 +14,8 @@
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_OBJECT_TYPES, describeScene, serializeScene, } from '../../canvas/scene.js';
17
+ import { SCENE_MAX_BATCH, SCENE_MAX_LAYERS, SCENE_OBJECT_TYPES, describeScene, serializeScene, } from '../../canvas/scene.js';
18
+ import { describeLayers, emptyLayers, undeclaredTags } from '../../canvas/scene-layers.js';
18
19
  import { formatUserEditNotice, } from '../../canvas/scene-ops.js';
19
20
  export const SCENE_TOOL_NAMES = [
20
21
  'scene_create',
@@ -23,13 +24,21 @@ export const SCENE_TOOL_NAMES = [
23
24
  'scene_update_object',
24
25
  'scene_remove_object',
25
26
  'scene_set_camera',
27
+ 'scene_set_layers',
26
28
  'scene_screenshot',
27
29
  ];
28
30
  const DEFAULT_SHOT = { width: 1280, height: 800 };
29
31
  const SHOT_MIN = 320;
30
32
  const SHOT_MAX = 2048;
31
- /** "sofa · box · Sofa" rows, indented under their group parent. */
32
- export function sceneOutline(scene) {
33
+ /**
34
+ * "sofa · box · Sofa" rows, indented under their group parent; `#tag` suffixes when `withTags`.
35
+ *
36
+ * ⚠️ `withTags` exists because tags are DECORATION and addressing is the job. 400 objects each
37
+ * carrying two tags pushes the outline past `SCENE_READ_MAX_CHARS` (measured: 20,723 chars), and
38
+ * refusing the cheap read — the one every edit depends on — to make room for labels would be the
39
+ * wrong trade. `sceneReadBody` drops them, with a count, rather than failing.
40
+ */
41
+ export function sceneOutline(scene, withTags = true) {
33
42
  if (scene.objects.length === 0)
34
43
  return '(no objects yet)';
35
44
  const byParent = new Map();
@@ -64,7 +73,9 @@ export function sceneOutline(scene) {
64
73
  const walk = (parent, depth) => {
65
74
  for (const o of byParent.get(parent) ?? []) {
66
75
  const lock = o.locked ? ' · locked' : '';
67
- rows.push(`${' '.repeat(depth)}${o.id} · ${typeOf(o)}${o.name ? ` · ${o.name}` : ''}${lock}`);
76
+ // §18: only tagged rows pay for this, and §6.4's budget still has to hold.
77
+ const tags = withTags && o.tags && o.tags.length > 0 ? ` · ${o.tags.map((t) => `#${t}`).join(' ')}` : '';
78
+ rows.push(`${' '.repeat(depth)}${o.id} · ${typeOf(o)}${o.name ? ` · ${o.name}` : ''}${lock}${tags}`);
68
79
  if (depth < 8)
69
80
  walk(o.id, depth + 1);
70
81
  }
@@ -81,6 +92,7 @@ export function sceneOutline(scene) {
81
92
  * for. So an oversized read must be refused with instructions, never silently shortened.
82
93
  */
83
94
  export const SCENE_READ_MAX_CHARS = 20_000;
95
+ const OUTLINE_HEAD = '--- objects (id · type · name) ---';
84
96
  /** "roof, walls" — the first few of a list, for an error that has to name what is available. */
85
97
  function someIds(ids, limit = 12) {
86
98
  return ids.length <= limit
@@ -136,10 +148,18 @@ export function sceneReadBody(scene, head, ids, full) {
136
148
  if (full === true) {
137
149
  return guard(serializeScene(scene), `This scene's JSON is over ${String(SCENE_READ_MAX_CHARS)} characters, which would be summarised before you saw it and you would lose the ids. Call scene_get without full for the outline, then with ids: [...] for the objects you need.`);
138
150
  }
139
- return {
140
- ok: true,
141
- text: `${head}\n\n--- objects (id · type · name) ---\n${sceneOutline(scene)}`,
142
- };
151
+ /*
152
+ The outline must always be readable: it is the cheap path every edit depends on, and the
153
+ object count is what bounds it. So when tags would push it over the ceiling they come OFF,
154
+ with a count — an explicit "tags omitted" is nothing like a silently shortened JSON dump,
155
+ which is what the guard exists to prevent.
156
+ */
157
+ const outlineWithTags = `${head}\n\n${OUTLINE_HEAD}\n${sceneOutline(scene)}`;
158
+ if (outlineWithTags.length <= SCENE_READ_MAX_CHARS)
159
+ return { ok: true, text: outlineWithTags };
160
+ const tagged = scene.objects.filter((o) => (o.tags ?? []).length > 0).length;
161
+ const note = `(tags omitted on ${String(tagged)} objects — this outline is near the read limit; the layers line above lists them, and scene_get with ids: [...] shows an object's own tags)`;
162
+ return guard(`${OUTLINE_HEAD}\n${sceneOutline(scene, false)}\n\n${note}`, `This scene's outline is over ${String(SCENE_READ_MAX_CHARS)} characters even without tags (${String(scene.objects.length)} objects). Read a part of it instead: scene_get with ids: [...] for the objects you are working on.`);
143
163
  }
144
164
  function clampShot(v, fallback) {
145
165
  if (typeof v !== 'number' || !Number.isFinite(v))
@@ -258,6 +278,14 @@ const OBJECT_PROPERTIES = {
258
278
  type: 'boolean',
259
279
  description: 'Lock against USER edits in the inspector (agents can still edit it).',
260
280
  },
281
+ tags: {
282
+ type: 'array',
283
+ items: { type: 'string' },
284
+ description: 'Cross-cutting labels, e.g. ["exterior"], ["glazing"]. Lowercase, hyphens, no underscore; ' +
285
+ 'up to 8 per object. Declare a tag with scene_set_layers and the user gets a show/hide ' +
286
+ 'toggle for it. A tag on a GROUP covers everything inside it. Tag as you build: labelling ' +
287
+ 'afterwards means a pass over every object.',
288
+ },
261
289
  };
262
290
  const OBJECT_FIELD_NAMES = Object.keys(OBJECT_PROPERTIES);
263
291
  // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
@@ -382,7 +410,17 @@ export function createSceneTools(config, options) {
382
410
  const r = await writer.read(input.canvas_id);
383
411
  if (!r.ok)
384
412
  return createErrorResult(r.error);
385
- const head = `Scene "${r.title}" (canvas ${String(r.canvasId)}): ${describeScene(r.scene)} · rev ${String(r.rev)}`;
413
+ /*
414
+ §18. The layer line goes in the HEADER, so it is there whichever read shape the agent
415
+ asked for — it needs to know a layer is hidden before it decides the scene is wrong.
416
+ `undefined` values: the defaults the scene declares, not the user's current toggles,
417
+ which the tools never see.
418
+ */
419
+ const layerLine = describeLayers(r.scene);
420
+ const untagged = undeclaredTags(r.scene);
421
+ const head = `Scene "${r.title}" (canvas ${String(r.canvasId)}): ${describeScene(r.scene)} · rev ${String(r.rev)}` +
422
+ (layerLine === null ? '' : `\nlayers: ${layerLine}`) +
423
+ (untagged.length > 0 ? `\ntags with no toggle: ${untagged.join(', ')}` : '');
386
424
  const body = sceneReadBody(r.scene, head, input.ids, input.full);
387
425
  if (!body.ok)
388
426
  return createErrorResult(body.error);
@@ -544,6 +582,67 @@ export function createSceneTools(config, options) {
544
582
  },
545
583
  });
546
584
  // ---------------------------------------------------------------------------
585
+ const sceneSetLayersTool = defineTool({
586
+ name: 'scene_set_layers',
587
+ description: 'Declare which tags the USER can show and hide, giving each a label for the Tweaks panel — ' +
588
+ 'e.g. [{ tag: "exterior", label: "Exterior walls" }]. Replaces the whole list; [] removes ' +
589
+ 'every toggle. Tags themselves go on objects (see tags on scene_add_object), and one tag can ' +
590
+ 'span groups, which is the point: "exterior" covers walls on every floor. Pass visible: false ' +
591
+ "to start a layer hidden (a cutaway). Toggling is the user's, not yours: it changes their " +
592
+ 'view, never the scene.',
593
+ inputSchema: {
594
+ type: 'object',
595
+ properties: {
596
+ canvas_id: { type: 'number', description: 'The scene canvas id.' },
597
+ layers: {
598
+ type: 'array',
599
+ maxItems: SCENE_MAX_LAYERS,
600
+ description: `Up to ${String(SCENE_MAX_LAYERS)} layers, in panel order. [] clears them.`,
601
+ items: {
602
+ type: 'object',
603
+ properties: {
604
+ tag: { type: 'string', description: 'The tag this toggle governs.' },
605
+ label: { type: 'string', description: 'Panel label, e.g. "Exterior walls".' },
606
+ visible: { type: 'boolean', description: 'Showing by default. Default true.' },
607
+ },
608
+ required: ['tag', 'label'],
609
+ },
610
+ },
611
+ },
612
+ required: ['canvas_id', 'layers'],
613
+ },
614
+ execute: async (input) => {
615
+ try {
616
+ if (!Array.isArray(input.layers)) {
617
+ return createErrorResult('layers must be an array of { tag, label, visible? } — or [] to remove every toggle.');
618
+ }
619
+ const r = await applyAsAgent(input.canvas_id, [{ op: 'layers', layers: input.layers }], () => null);
620
+ if (!r.success)
621
+ return r;
622
+ // A toggle that governs nothing is not an error — the agent may label the objects next —
623
+ // but it is invisible in the panel, so say so rather than let it look applied.
624
+ const read = await writer.read(input.canvas_id);
625
+ if (read.ok) {
626
+ const empty = emptyLayers(read.scene);
627
+ const undeclared = undeclaredTags(read.scene);
628
+ const notes = [];
629
+ if (empty.length > 0) {
630
+ notes.push(`No object carries ${empty.length === 1 ? 'the tag' : 'the tags'} ${empty.join(', ')} yet — add tags with scene_update_object and the toggle starts working.`);
631
+ }
632
+ if (undeclared.length > 0) {
633
+ notes.push(`Tags in use with no toggle: ${undeclared.join(', ')}.`);
634
+ }
635
+ if (notes.length > 0)
636
+ return { ...r, result: `${String(r.result)}\n\n${notes.join('\n')}` };
637
+ }
638
+ return r;
639
+ }
640
+ catch (error) {
641
+ return createErrorResult(errorMessage('set layers', error));
642
+ }
643
+ },
644
+ });
645
+ // ---------------------------------------------------------------------------
547
646
  const sceneScreenshotTool = defineTool({
548
647
  name: 'scene_screenshot',
549
648
  description: 'RENDER a 3D scene and SEE it — check proportions, overlaps, gaps and floating objects after ' +
@@ -596,6 +695,7 @@ export function createSceneTools(config, options) {
596
695
  sceneUpdateObjectTool,
597
696
  sceneRemoveObjectTool,
598
697
  sceneSetCameraTool,
698
+ sceneSetLayersTool,
599
699
  sceneScreenshotTool,
600
700
  ];
601
701
  }
@@ -314,6 +314,7 @@ export const TOOL_GROUPS = {
314
314
  'scene_update_object',
315
315
  'scene_remove_object',
316
316
  'scene_set_camera',
317
+ 'scene_set_layers',
317
318
  'scene_screenshot',
318
319
  ],
319
320
  readOnly: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compilr-dev/sdk",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
4
4
  "description": "Universal agent runtime for building AI-powered applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",