@compilr-dev/sdk 0.33.1 → 0.35.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.
- package/dist/canvas/index.d.ts +1 -0
- package/dist/canvas/index.js +1 -0
- package/dist/canvas/scene-layers.d.ts +57 -0
- package/dist/canvas/scene-layers.js +94 -0
- package/dist/canvas/scene-ops.d.ts +6 -1
- package/dist/canvas/scene-ops.js +36 -0
- package/dist/canvas/scene.d.ts +34 -0
- package/dist/canvas/scene.js +79 -4
- package/dist/capabilities/packs.js +8 -4
- package/dist/errors/classify.js +42 -5
- package/dist/models/model-registry.js +28 -4
- package/dist/models.d.ts +14 -0
- package/dist/models.js +26 -23
- package/dist/platform/tools/scene-tools.d.ts +13 -3
- package/dist/platform/tools/scene-tools.js +109 -9
- package/dist/team/tool-config.js +1 -0
- package/package.json +2 -2
package/dist/canvas/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/canvas/index.js
CHANGED
|
@@ -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;
|
package/dist/canvas/scene-ops.js
CHANGED
|
@@ -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':
|
package/dist/canvas/scene.d.ts
CHANGED
|
@@ -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;
|
package/dist/canvas/scene.js
CHANGED
|
@@ -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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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: {
|
package/dist/errors/classify.js
CHANGED
|
@@ -33,13 +33,50 @@ function statusFromText(text) {
|
|
|
33
33
|
const m = text.match(/\b(400|401|403|404|408|409|413|422|429|500|502|503|504|529)\b/);
|
|
34
34
|
return m ? Number(m[1]) : undefined;
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Textual markers for "the key is wrong", measured against the real wire text on 2026-10-03.
|
|
38
|
+
*
|
|
39
|
+
* ⚠️ EVERY MARKER BUT ONE USED TO BE AN ANTHROPIC STRING, so a bad key classified as `auth` for
|
|
40
|
+
* exactly one of three first-party providers. What the others actually send:
|
|
41
|
+
*
|
|
42
|
+
* Anthropic 401 {"error":{"type":"authentication_error","message":"API key is invalid."}}
|
|
43
|
+
* OpenAI 401 {"error":{"message":"Incorrect API key provided: sk-…","code":"invalid_api_key"}}
|
|
44
|
+
* Gemini 400 {"error":{"message":"API key not valid. Please pass a valid API key.",
|
|
45
|
+
* "details":[{"reason":"API_KEY_INVALID"}]}}
|
|
46
|
+
*
|
|
47
|
+
* Two independent reasons the old set missed them:
|
|
48
|
+
*
|
|
49
|
+
* 1. **Gemini answers 400, not 401**, so no status test can catch it — the text is the only
|
|
50
|
+
* signal, and "API key not valid" is not the substring "invalid api key".
|
|
51
|
+
* 2. **Our own providers rewrite the message and interpolate their name into the middle of the
|
|
52
|
+
* phrase**: `gemini-native` produces "Invalid Gemini API key. …" and `openai.ts` produces
|
|
53
|
+
* "Invalid OpenAI API key. …". Neither contains "invalid api key". In-process the thrown
|
|
54
|
+
* `ProviderError.statusCode` saved OpenAI; across an IPC boundary — Desktop, the path this
|
|
55
|
+
* module exists for — only the string crosses, and both classified as `unknown`.
|
|
56
|
+
*
|
|
57
|
+
* Hence `invalid(?: \w+)? api key`: it matches the bare phrase and the branded one, so a
|
|
58
|
+
* provider adding its name no longer silently breaks the classification.
|
|
59
|
+
*/
|
|
60
|
+
const AUTH_MARKERS = [
|
|
61
|
+
/authentication_error/,
|
|
62
|
+
/permission_error/,
|
|
63
|
+
/unauthorized/,
|
|
64
|
+
/x-api-key/,
|
|
65
|
+
// Gemini: the message, and the machine-readable reason in `details`.
|
|
66
|
+
/api key not valid/,
|
|
67
|
+
/api[_ -]?key[_ -]?invalid/,
|
|
68
|
+
// OpenAI: the message, and its `error.code`.
|
|
69
|
+
/incorrect api key/,
|
|
70
|
+
/invalid_api_key/,
|
|
71
|
+
// Ours and anyone else's, with or without a provider name in the middle.
|
|
72
|
+
/invalid(?: \w+)? api key/,
|
|
73
|
+
/\b(?:no|missing) api key/,
|
|
74
|
+
];
|
|
36
75
|
function categorize(statusCode, text) {
|
|
37
76
|
const lower = text.toLowerCase();
|
|
38
|
-
// Auth: key issues. Match on status OR
|
|
39
|
-
//
|
|
40
|
-
if (statusCode === 401 ||
|
|
41
|
-
statusCode === 403 ||
|
|
42
|
-
/authentication_error|invalid x-api-key|invalid api key|no api key|x-api-key|permission_error|unauthorized/.test(lower)) {
|
|
77
|
+
// Auth: key issues. Match on status OR a textual marker, since a stringified error may not
|
|
78
|
+
// preserve the numeric code — and Gemini's bad-key status (400) is not an auth code at all.
|
|
79
|
+
if (statusCode === 401 || statusCode === 403 || AUTH_MARKERS.some((re) => re.test(lower))) {
|
|
43
80
|
return 'auth';
|
|
44
81
|
}
|
|
45
82
|
if (statusCode === 429 || /rate.?limit|too many requests/.test(lower))
|
|
@@ -739,8 +739,24 @@ export function shouldClearHistoryOnModelChange(_fromModelId, _toModelId) {
|
|
|
739
739
|
// Context Window Resolution
|
|
740
740
|
// =============================================================================
|
|
741
741
|
/**
|
|
742
|
-
*
|
|
743
|
-
*
|
|
742
|
+
* What to assume when the endpoint is unknown.
|
|
743
|
+
*
|
|
744
|
+
* ⚠️ UNDER-REPORTING IS THE SAFE DIRECTION. Too small wastes context and compacts early; too
|
|
745
|
+
* large means compaction fires after the real limit, so the request 400s and reads as a provider
|
|
746
|
+
* fault. `custom` points at whatever the user is running — LM Studio, vLLM, a proxy — so it gets
|
|
747
|
+
* the same local-model assumption as Ollama rather than a number borrowed from a frontier model.
|
|
748
|
+
*/
|
|
749
|
+
const UNKNOWN_ENDPOINT_CONTEXT = 32000;
|
|
750
|
+
/**
|
|
751
|
+
* Provider fallback context limits, used when a model is not in the registry or declares no
|
|
752
|
+
* `contextWindow` (every Groq / Together / Fireworks / Perplexity / OpenRouter / Ollama entry —
|
|
753
|
+
* those hosts define the window by deployment, so the registry does not guess per model).
|
|
754
|
+
*
|
|
755
|
+
* ⚠️ EXHAUSTIVE ON PURPOSE. This was a `default: return 200000`, which handed out ANTHROPIC's
|
|
756
|
+
* window to any provider not listed — so `getModelContextWindow('some-local-model', 'custom')`
|
|
757
|
+
* answered 200000 and a local 8k model would compact long after it had already overflowed. The
|
|
758
|
+
* `never` assignment below makes a new `ProviderType` a compile error here instead of a silent
|
|
759
|
+
* inheritance of someone else's limit.
|
|
744
760
|
*/
|
|
745
761
|
function getProviderContextLimitFallback(provider) {
|
|
746
762
|
switch (provider) {
|
|
@@ -763,8 +779,16 @@ function getProviderContextLimitFallback(provider) {
|
|
|
763
779
|
return 131072;
|
|
764
780
|
case 'openrouter':
|
|
765
781
|
return 128000;
|
|
766
|
-
|
|
767
|
-
|
|
782
|
+
case 'custom':
|
|
783
|
+
case undefined:
|
|
784
|
+
return UNKNOWN_ENDPOINT_CONTEXT;
|
|
785
|
+
default: {
|
|
786
|
+
// Unreachable while every ProviderType is handled above — and a COMPILE ERROR the moment
|
|
787
|
+
// one is added without a limit, which is the whole point of writing it this way.
|
|
788
|
+
const unhandled = provider;
|
|
789
|
+
void unhandled;
|
|
790
|
+
return UNKNOWN_ENDPOINT_CONTEXT;
|
|
791
|
+
}
|
|
768
792
|
}
|
|
769
793
|
}
|
|
770
794
|
/**
|
package/dist/models.d.ts
CHANGED
|
@@ -9,6 +9,20 @@ import type { ProviderType } from './config.js';
|
|
|
9
9
|
* Default model for each provider.
|
|
10
10
|
* Derived from MODEL_REGISTRY — uses the 'balanced' tier default for each provider.
|
|
11
11
|
*/
|
|
12
|
+
/**
|
|
13
|
+
* Default model for each provider — the 'balanced' tier entry from MODEL_REGISTRY.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ THE KEYS COME FROM `PROVIDER_METADATA`, NOT A HAND-WRITTEN LIST. The list used to be typed
|
|
16
|
+
* out here and was missing `ollama-anthropic`, so `DEFAULT_MODELS['ollama-anthropic']` was
|
|
17
|
+
* `undefined` behind a `Record<ProviderType, string>` — the `{} as Record<…>` cast is what let
|
|
18
|
+
* that compile. `PROVIDER_METADATA` is a true complete Record, so adding a provider to the union
|
|
19
|
+
* now forces an entry here too.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ `custom` IS DELIBERATELY EMPTY. It used to fall through to `'gpt-6-sol'`, so pointing
|
|
22
|
+
* `custom` at LM Studio or vLLM without naming a model requested one that endpoint has never
|
|
23
|
+
* heard of. A custom endpoint's model is the user's to name; there is no default to invent, and
|
|
24
|
+
* an empty string fails immediately and obviously instead of looking like an intent.
|
|
25
|
+
*/
|
|
12
26
|
export declare const DEFAULT_MODELS: Record<ProviderType, string>;
|
|
13
27
|
/**
|
|
14
28
|
* Default context window when model is unknown
|
package/dist/models.js
CHANGED
|
@@ -4,36 +4,39 @@
|
|
|
4
4
|
* This file maintains backward compatibility with the original SDK API.
|
|
5
5
|
* Internally, it delegates to the full model registry in ./models/.
|
|
6
6
|
*/
|
|
7
|
-
import { getDefaultModelForTier, getModelContextWindow, MODEL_REGISTRY } from './models/index.js';
|
|
7
|
+
import { getDefaultModelForTier, getModelContextWindow, MODEL_REGISTRY, PROVIDER_METADATA, } from './models/index.js';
|
|
8
8
|
/**
|
|
9
9
|
* Default model for each provider.
|
|
10
10
|
* Derived from MODEL_REGISTRY — uses the 'balanced' tier default for each provider.
|
|
11
11
|
*/
|
|
12
|
+
/**
|
|
13
|
+
* Default model for each provider — the 'balanced' tier entry from MODEL_REGISTRY.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ THE KEYS COME FROM `PROVIDER_METADATA`, NOT A HAND-WRITTEN LIST. The list used to be typed
|
|
16
|
+
* out here and was missing `ollama-anthropic`, so `DEFAULT_MODELS['ollama-anthropic']` was
|
|
17
|
+
* `undefined` behind a `Record<ProviderType, string>` — the `{} as Record<…>` cast is what let
|
|
18
|
+
* that compile. `PROVIDER_METADATA` is a true complete Record, so adding a provider to the union
|
|
19
|
+
* now forces an entry here too.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ `custom` IS DELIBERATELY EMPTY. It used to fall through to `'gpt-6-sol'`, so pointing
|
|
22
|
+
* `custom` at LM Studio or vLLM without naming a model requested one that endpoint has never
|
|
23
|
+
* heard of. A custom endpoint's model is the user's to name; there is no default to invent, and
|
|
24
|
+
* an empty string fails immediately and obviously instead of looking like an intent.
|
|
25
|
+
*/
|
|
12
26
|
export const DEFAULT_MODELS = (() => {
|
|
13
|
-
const providers = [
|
|
14
|
-
'claude',
|
|
15
|
-
'openai',
|
|
16
|
-
'gemini',
|
|
17
|
-
'ollama',
|
|
18
|
-
'together',
|
|
19
|
-
'groq',
|
|
20
|
-
'fireworks',
|
|
21
|
-
'perplexity',
|
|
22
|
-
'openrouter',
|
|
23
|
-
'custom',
|
|
24
|
-
];
|
|
25
27
|
const result = {};
|
|
26
|
-
for (const provider of
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
else {
|
|
32
|
-
// Fallback: first supported model for this provider, or a sensible default
|
|
33
|
-
const firstModel = MODEL_REGISTRY.find((m) => m.provider === provider && m.status === 'supported');
|
|
34
|
-
// Fallback only when the registry has no model at all for the provider; gpt-4o is gone.
|
|
35
|
-
result[provider] = firstModel?.id ?? 'gpt-6-sol';
|
|
28
|
+
for (const provider of Object.keys(PROVIDER_METADATA)) {
|
|
29
|
+
if (provider === 'custom') {
|
|
30
|
+
result[provider] = '';
|
|
31
|
+
continue;
|
|
36
32
|
}
|
|
33
|
+
// `ollama-anthropic` is Ollama behind an Anthropic-shaped shim: same models.
|
|
34
|
+
const lookup = provider === 'ollama-anthropic' ? 'ollama' : provider;
|
|
35
|
+
const balanced = getDefaultModelForTier(lookup, 'balanced');
|
|
36
|
+
result[provider] =
|
|
37
|
+
balanced?.id ??
|
|
38
|
+
MODEL_REGISTRY.find((m) => m.provider === lookup && m.status === 'supported')?.id ??
|
|
39
|
+
'';
|
|
37
40
|
}
|
|
38
41
|
return result;
|
|
39
42
|
})();
|
|
@@ -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
|
-
/**
|
|
20
|
-
|
|
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
|
-
/**
|
|
32
|
-
|
|
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.
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/team/tool-config.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compilr-dev/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Universal agent runtime for building AI-powered applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"node": ">=20.0.0"
|
|
82
82
|
},
|
|
83
83
|
"dependencies": {
|
|
84
|
-
"@compilr-dev/agents": "^0.
|
|
84
|
+
"@compilr-dev/agents": "^0.8.0",
|
|
85
85
|
"@compilr-dev/logger": "^0.1.0",
|
|
86
86
|
"ajv": "^6.14.0",
|
|
87
87
|
"yaml": "^2.8.4"
|