@compilr-dev/sdk 0.29.8 → 0.30.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 +2 -0
- package/dist/canvas/index.js +7 -0
- package/dist/canvas/scene-ops.d.ts +122 -0
- package/dist/canvas/scene-ops.js +375 -0
- package/dist/canvas/scene.d.ts +207 -0
- package/dist/canvas/scene.js +843 -0
- package/dist/canvas/types.d.ts +9 -1
- package/dist/canvas/types.js +1 -0
- package/dist/capabilities/packs.d.ts +1 -1
- package/dist/capabilities/packs.js +30 -3
- package/dist/index.d.ts +5 -2
- package/dist/index.js +3 -1
- package/dist/platform/context.d.ts +18 -1
- package/dist/platform/index.d.ts +4 -2
- package/dist/platform/index.js +4 -1
- package/dist/platform/scene-writer.d.ts +95 -0
- package/dist/platform/scene-writer.js +184 -0
- package/dist/platform/services.d.ts +20 -2
- package/dist/platform/tools/canvas-tools.js +101 -5
- package/dist/platform/tools/index.d.ts +1 -0
- package/dist/platform/tools/index.js +18 -2
- package/dist/platform/tools/scene-tools.d.ts +65 -0
- package/dist/platform/tools/scene-tools.js +452 -0
- package/dist/team/tool-config.js +21 -0
- package/package.json +1 -1
package/dist/canvas/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export * from './types.js';
|
|
6
6
|
export * from './validate.js';
|
|
7
|
+
export * from './scene.js';
|
|
8
|
+
export * from './scene-ops.js';
|
|
7
9
|
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';
|
|
8
10
|
export type { Rgb } from './color.js';
|
|
9
11
|
export { runQualityChecks, checkSurfaceTokens, checkTextContrast, checkHueCount, checkDeadTweaks, primaryVarNames, } from './quality-checks.js';
|
package/dist/canvas/index.js
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export * from './types.js';
|
|
6
6
|
export * from './validate.js';
|
|
7
|
+
/*
|
|
8
|
+
3D scenes — the `scene` canvas type. Pure (no imports beyond each other) so Desktop's
|
|
9
|
+
renderer can validate, preview an edit (applySceneOps) and compute lift/bounds/fit with the
|
|
10
|
+
SAME code the writer uses. The node-side writer lives in platform/scene-writer.ts.
|
|
11
|
+
*/
|
|
12
|
+
export * from './scene.js';
|
|
13
|
+
export * from './scene-ops.js';
|
|
7
14
|
/*
|
|
8
15
|
⚠️ Renderer-safe on purpose. Both modules are pure arithmetic with no imports beyond
|
|
9
16
|
each other, so Desktop's renderer can use them for the surface ramp without pulling the
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene ops — the PURE half of the scene writer (3d-canvas-spec §4.2).
|
|
3
|
+
*
|
|
4
|
+
* `applySceneOps(scene, ops)` is the whole edit pipeline minus storage: apply every op, validate
|
|
5
|
+
* the FINAL scene, normalise it. All or nothing — a batch whose result is invalid returns an
|
|
6
|
+
* error and no scene. The node-side writer (`platform/scene-writer.ts`) wraps it with a
|
|
7
|
+
* per-canvas lock, revisions and the user-edit journal; Desktop's renderer can use it directly
|
|
8
|
+
* for an optimistic preview of an inspector edit (the writer's reply stays authoritative).
|
|
9
|
+
*
|
|
10
|
+
* Renderer-safe: imports only `scene.ts`.
|
|
11
|
+
*/
|
|
12
|
+
import type { SceneCamera, SceneFile, SceneObject, SceneObjectType } from './scene.js';
|
|
13
|
+
/** An object as a caller supplies it — untrusted until the final validation. */
|
|
14
|
+
export type SceneObjectInput = Record<string, unknown>;
|
|
15
|
+
export type SceneOp =
|
|
16
|
+
/** Add one object. `index` inserts at a position (undo of a remove re-adds at its index). */
|
|
17
|
+
{
|
|
18
|
+
op: 'add';
|
|
19
|
+
object: SceneObjectInput;
|
|
20
|
+
index?: number;
|
|
21
|
+
}
|
|
22
|
+
/** Add up to SCENE_MAX_BATCH objects atomically (Q-1). */
|
|
23
|
+
| {
|
|
24
|
+
op: 'add';
|
|
25
|
+
objects: SceneObjectInput[];
|
|
26
|
+
index?: number;
|
|
27
|
+
}
|
|
28
|
+
/** Shallow merge; arrays replaced whole; `null` clears an optional field; id/type rejected. */
|
|
29
|
+
| {
|
|
30
|
+
op: 'update';
|
|
31
|
+
id: string;
|
|
32
|
+
patch: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
/** Removes descendants too (reported). */
|
|
35
|
+
| {
|
|
36
|
+
op: 'remove';
|
|
37
|
+
id: string;
|
|
38
|
+
}
|
|
39
|
+
/** `{ fit: true }` removes the stored camera — the viewer then fits the objects. */
|
|
40
|
+
| {
|
|
41
|
+
op: 'camera';
|
|
42
|
+
camera: SceneCamera | {
|
|
43
|
+
fit: true;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Whole-scene replace (scene_create, JSON import). */
|
|
47
|
+
| {
|
|
48
|
+
op: 'replace';
|
|
49
|
+
scene: unknown;
|
|
50
|
+
};
|
|
51
|
+
export type SceneEditSource = {
|
|
52
|
+
kind: 'agent';
|
|
53
|
+
agentId?: string;
|
|
54
|
+
} | {
|
|
55
|
+
kind: 'user';
|
|
56
|
+
};
|
|
57
|
+
export type SceneChangeKind = 'moved' | 'resized' | 'rotated' | 'recoloured' | 'renamed' | 'removed' | 'added' | 'edited' | 'camera' | 'replaced';
|
|
58
|
+
/** One journalled change (the writer stamps `rev`). */
|
|
59
|
+
export interface SceneChange {
|
|
60
|
+
rev: number;
|
|
61
|
+
id: string;
|
|
62
|
+
what: SceneChangeKind;
|
|
63
|
+
from?: unknown;
|
|
64
|
+
to?: unknown;
|
|
65
|
+
}
|
|
66
|
+
export interface SceneOpOptions {
|
|
67
|
+
/** Who is editing. Locked objects refuse USER edits and warn on AGENT edits (Q-12). */
|
|
68
|
+
source?: SceneEditSource;
|
|
69
|
+
/** For messages ("No object "sofa" in scene 12"). */
|
|
70
|
+
canvasId?: number;
|
|
71
|
+
}
|
|
72
|
+
export interface SceneOpResult {
|
|
73
|
+
scene: SceneFile;
|
|
74
|
+
summary: string;
|
|
75
|
+
/** Ids added / changed / removed by this op. */
|
|
76
|
+
ids: string[];
|
|
77
|
+
changes: Omit<SceneChange, 'rev'>[];
|
|
78
|
+
warnings: string[];
|
|
79
|
+
/** Objects a remove took out, with their former index — enough to undo it (Q-4). */
|
|
80
|
+
removed?: {
|
|
81
|
+
index: number;
|
|
82
|
+
object: SceneObject;
|
|
83
|
+
}[];
|
|
84
|
+
}
|
|
85
|
+
export type SceneOpsResult = {
|
|
86
|
+
ok: true;
|
|
87
|
+
scene: SceneFile;
|
|
88
|
+
summaries: string[];
|
|
89
|
+
ids: string[];
|
|
90
|
+
changes: Omit<SceneChange, 'rev'>[];
|
|
91
|
+
warnings: string[];
|
|
92
|
+
removed: {
|
|
93
|
+
index: number;
|
|
94
|
+
object: SceneObject;
|
|
95
|
+
}[];
|
|
96
|
+
} | {
|
|
97
|
+
ok: false;
|
|
98
|
+
error: string;
|
|
99
|
+
};
|
|
100
|
+
/** A fresh id from the name (or type): slug, suffixed -2, -3… on collision. */
|
|
101
|
+
export declare function generateSceneId(base: string, taken: Set<string>): string;
|
|
102
|
+
/**
|
|
103
|
+
* Apply ONE op to a scene. Pure. Does not validate the resulting scene as a whole — use
|
|
104
|
+
* `applySceneOps` for the full pipeline (apply all → validate → normalise).
|
|
105
|
+
*/
|
|
106
|
+
export declare function applySceneOp(scene: SceneFile, op: SceneOp, opts?: SceneOpOptions): SceneOpResult | {
|
|
107
|
+
error: string;
|
|
108
|
+
};
|
|
109
|
+
/** Cap and bullet validation errors for a tool result. */
|
|
110
|
+
export declare function formatValidationErrors(errors: string[], max?: number): string;
|
|
111
|
+
/**
|
|
112
|
+
* The whole edit pipeline, pure: apply every op in order, then validate the FINAL scene and
|
|
113
|
+
* normalise it. All or nothing. `scene` may be null only when the first op is `replace`.
|
|
114
|
+
*/
|
|
115
|
+
export declare function applySceneOps(scene: SceneFile | null, ops: SceneOp[], opts?: SceneOpOptions): SceneOpsResult;
|
|
116
|
+
/**
|
|
117
|
+
* "The user edited this scene since your last call: moved "sofa" to [1.2, 0, -0.4]; removed
|
|
118
|
+
* "lamp". Re-read with scene_get if you depend on other fields." — null when no changes.
|
|
119
|
+
*/
|
|
120
|
+
export declare function formatUserEditNotice(changes: SceneChange[], max?: number): string | null;
|
|
121
|
+
/** Type guard used by hosts on untrusted IPC payloads. */
|
|
122
|
+
export declare function isSceneObjectType(v: unknown): v is SceneObjectType;
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scene ops — the PURE half of the scene writer (3d-canvas-spec §4.2).
|
|
3
|
+
*
|
|
4
|
+
* `applySceneOps(scene, ops)` is the whole edit pipeline minus storage: apply every op, validate
|
|
5
|
+
* the FINAL scene, normalise it. All or nothing — a batch whose result is invalid returns an
|
|
6
|
+
* error and no scene. The node-side writer (`platform/scene-writer.ts`) wraps it with a
|
|
7
|
+
* per-canvas lock, revisions and the user-edit journal; Desktop's renderer can use it directly
|
|
8
|
+
* for an optimistic preview of an inspector edit (the writer's reply stays authoritative).
|
|
9
|
+
*
|
|
10
|
+
* Renderer-safe: imports only `scene.ts`.
|
|
11
|
+
*/
|
|
12
|
+
import { SCENE_ID_PATTERN, SCENE_MAX_BATCH, SCENE_OBJECT_TYPES, descendantsOf, fmtVec, normalizeScene, slugifySceneId, validateScene, } from './scene.js';
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// Helpers
|
|
15
|
+
// =============================================================================
|
|
16
|
+
const SIZE_KEYS = new Set(['size', 'radius', 'height', 'tube', 'points']);
|
|
17
|
+
function sceneRef(opts) {
|
|
18
|
+
return opts?.canvasId !== undefined ? `scene ${String(opts.canvasId)}` : 'this scene';
|
|
19
|
+
}
|
|
20
|
+
function isRecord(v) {
|
|
21
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
22
|
+
}
|
|
23
|
+
function clone(v) {
|
|
24
|
+
return JSON.parse(JSON.stringify(v));
|
|
25
|
+
}
|
|
26
|
+
function unknownId(id, opts) {
|
|
27
|
+
return `No object "${id}" in ${sceneRef(opts)}. List ids with scene_get.`;
|
|
28
|
+
}
|
|
29
|
+
/** A fresh id from the name (or type): slug, suffixed -2, -3… on collision. */
|
|
30
|
+
export function generateSceneId(base, taken) {
|
|
31
|
+
const stem = slugifySceneId(base);
|
|
32
|
+
if (!taken.has(stem))
|
|
33
|
+
return stem;
|
|
34
|
+
for (let n = 2;; n++) {
|
|
35
|
+
const candidate = `${stem}-${String(n)}`;
|
|
36
|
+
if (!taken.has(candidate))
|
|
37
|
+
return candidate;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function lockedMessage(id) {
|
|
41
|
+
return `"${id}" is locked — unlock it before moving, resizing or removing it.`;
|
|
42
|
+
}
|
|
43
|
+
function lockedWarning(id) {
|
|
44
|
+
return `⚠ "${id}" is locked by the user; you changed it anyway. Tell the user, or leave locked objects alone unless asked.`;
|
|
45
|
+
}
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// Single op
|
|
48
|
+
// =============================================================================
|
|
49
|
+
function addObjects(scene, inputs, index, opts) {
|
|
50
|
+
if (inputs.length === 0)
|
|
51
|
+
return { error: 'Nothing to add — pass at least one object.' };
|
|
52
|
+
if (inputs.length > SCENE_MAX_BATCH) {
|
|
53
|
+
return {
|
|
54
|
+
error: `A batch adds at most ${String(SCENE_MAX_BATCH)} objects (got ${String(inputs.length)}). Split it over several scene_add_object calls.`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const taken = new Set(scene.objects.map((o) => o.id));
|
|
58
|
+
const added = [];
|
|
59
|
+
for (let i = 0; i < inputs.length; i++) {
|
|
60
|
+
const raw = inputs[i];
|
|
61
|
+
const at = inputs.length > 1 ? `objects[${String(i)}]` : 'The object';
|
|
62
|
+
if (!isRecord(raw))
|
|
63
|
+
return { error: `${at} must be a JSON object.` };
|
|
64
|
+
// Explicit nulls are a caller's "not set" — dropped.
|
|
65
|
+
const obj = Object.fromEntries(Object.entries(clone(raw)).filter(([, v]) => v !== null));
|
|
66
|
+
if (obj.type === 'wall') {
|
|
67
|
+
return {
|
|
68
|
+
error: `${at}: use type "extrude" with points [[x,z],…] and height for walls.`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (typeof obj.type !== 'string' ||
|
|
72
|
+
!SCENE_OBJECT_TYPES.includes(obj.type)) {
|
|
73
|
+
return {
|
|
74
|
+
error: `${at}.type = ${obj.type === undefined ? 'missing' : JSON.stringify(obj.type)} — must be one of ${SCENE_OBJECT_TYPES.join(', ')}.`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (obj.id === undefined) {
|
|
78
|
+
const base = typeof obj.name === 'string' && obj.name.trim() ? obj.name : obj.type;
|
|
79
|
+
obj.id = generateSceneId(base, taken);
|
|
80
|
+
}
|
|
81
|
+
else if (typeof obj.id !== 'string' || !SCENE_ID_PATTERN.test(obj.id)) {
|
|
82
|
+
return {
|
|
83
|
+
error: `${at}.id = ${JSON.stringify(obj.id)} — ids are 1–48 chars of lowercase letters, digits, "-" or "_". Omit id to have one generated from the name.`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
else if (taken.has(obj.id)) {
|
|
87
|
+
return {
|
|
88
|
+
error: `An object "${obj.id}" already exists in ${sceneRef(opts)}. Choose another id, or omit id to have one generated (scene_get lists ids).`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
taken.add(obj.id);
|
|
92
|
+
added.push(obj);
|
|
93
|
+
}
|
|
94
|
+
const objects = [...scene.objects];
|
|
95
|
+
const at = index === undefined ? objects.length : Math.max(0, Math.min(objects.length, Math.floor(index)));
|
|
96
|
+
objects.splice(at, 0, ...added);
|
|
97
|
+
const ids = added.map((o) => o.id);
|
|
98
|
+
const summary = added.length === 1
|
|
99
|
+
? `Added ${added[0].type} "${added[0].id}" at ${showVal(added[0].position)}.`
|
|
100
|
+
: `Added ${String(added.length)} objects: ${ids.map((id) => `"${id}"`).join(', ')}.`;
|
|
101
|
+
return {
|
|
102
|
+
scene: { ...scene, objects },
|
|
103
|
+
summary,
|
|
104
|
+
ids,
|
|
105
|
+
changes: added.map((o) => ({ id: o.id, what: 'added', to: o.type })),
|
|
106
|
+
warnings: [],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function changeKindFor(key) {
|
|
110
|
+
if (key === 'position')
|
|
111
|
+
return 'moved';
|
|
112
|
+
if (key === 'rotation')
|
|
113
|
+
return 'rotated';
|
|
114
|
+
if (key === 'color')
|
|
115
|
+
return 'recoloured';
|
|
116
|
+
if (key === 'name')
|
|
117
|
+
return 'renamed';
|
|
118
|
+
if (SIZE_KEYS.has(key))
|
|
119
|
+
return 'resized';
|
|
120
|
+
return 'edited';
|
|
121
|
+
}
|
|
122
|
+
function showVal(v) {
|
|
123
|
+
if (v === undefined)
|
|
124
|
+
return '(unset)';
|
|
125
|
+
if (Array.isArray(v) && v.every((n) => typeof n === 'number'))
|
|
126
|
+
return fmtVec(v);
|
|
127
|
+
return JSON.stringify(v);
|
|
128
|
+
}
|
|
129
|
+
function updateObject(scene, id, patch, opts) {
|
|
130
|
+
const idx = scene.objects.findIndex((o) => o.id === id);
|
|
131
|
+
if (idx < 0)
|
|
132
|
+
return { error: unknownId(id, opts) };
|
|
133
|
+
if (!isRecord(patch) || Object.keys(patch).length === 0) {
|
|
134
|
+
return {
|
|
135
|
+
error: 'patch must be a non-empty object of fields to change, e.g. { "position": [1, 0, 2] }.',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if ('id' in patch || 'type' in patch) {
|
|
139
|
+
const which = 'id' in patch ? 'id' : 'type';
|
|
140
|
+
return {
|
|
141
|
+
error: `patch.${which} cannot change — remove the object (scene_remove_object) and add it again (scene_add_object).`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const before = scene.objects[idx];
|
|
145
|
+
const warnings = [];
|
|
146
|
+
if (before.locked === true) {
|
|
147
|
+
const onlyLock = Object.keys(patch).every((k) => k === 'locked');
|
|
148
|
+
if (opts?.source?.kind === 'user') {
|
|
149
|
+
if (!onlyLock)
|
|
150
|
+
return { error: lockedMessage(id) };
|
|
151
|
+
}
|
|
152
|
+
else if (!onlyLock) {
|
|
153
|
+
warnings.push(lockedWarning(id));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const next = clone(before);
|
|
157
|
+
const changes = [];
|
|
158
|
+
const parts = [];
|
|
159
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
160
|
+
const from = before[k];
|
|
161
|
+
if (v === null)
|
|
162
|
+
Reflect.deleteProperty(next, k);
|
|
163
|
+
else
|
|
164
|
+
next[k] = clone(v); // shallow merge: arrays and nested objects replaced whole
|
|
165
|
+
const to = next[k];
|
|
166
|
+
if (JSON.stringify(from) === JSON.stringify(to))
|
|
167
|
+
continue;
|
|
168
|
+
changes.push({ id, what: changeKindFor(k), from, to });
|
|
169
|
+
parts.push(`${k} ${showVal(from)} → ${showVal(to)}`);
|
|
170
|
+
}
|
|
171
|
+
const objects = [...scene.objects];
|
|
172
|
+
objects[idx] = next;
|
|
173
|
+
return {
|
|
174
|
+
scene: { ...scene, objects },
|
|
175
|
+
summary: parts.length
|
|
176
|
+
? `Updated "${id}": ${parts.join('; ')}.`
|
|
177
|
+
: `"${id}" unchanged (same values).`,
|
|
178
|
+
ids: [id],
|
|
179
|
+
changes,
|
|
180
|
+
warnings,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function removeObject(scene, id, opts) {
|
|
184
|
+
const idx = scene.objects.findIndex((o) => o.id === id);
|
|
185
|
+
if (idx < 0)
|
|
186
|
+
return { error: unknownId(id, opts) };
|
|
187
|
+
const children = descendantsOf(scene, id);
|
|
188
|
+
const all = new Set([id, ...children]);
|
|
189
|
+
const lockedIds = scene.objects
|
|
190
|
+
.filter((o) => all.has(o.id) && o.locked === true)
|
|
191
|
+
.map((o) => o.id);
|
|
192
|
+
const warnings = [];
|
|
193
|
+
if (lockedIds.length > 0) {
|
|
194
|
+
if (opts?.source?.kind === 'user')
|
|
195
|
+
return { error: lockedMessage(lockedIds[0]) };
|
|
196
|
+
for (const l of lockedIds)
|
|
197
|
+
warnings.push(lockedWarning(l));
|
|
198
|
+
}
|
|
199
|
+
const removed = [];
|
|
200
|
+
scene.objects.forEach((o, i) => {
|
|
201
|
+
if (all.has(o.id))
|
|
202
|
+
removed.push({ index: i, object: clone(o) });
|
|
203
|
+
});
|
|
204
|
+
const objects = scene.objects.filter((o) => !all.has(o.id));
|
|
205
|
+
const childNote = children.length
|
|
206
|
+
? ` and ${String(children.length)} ${children.length === 1 ? 'child' : 'children'} (${children.map((c) => `"${c}"`).join(', ')})`
|
|
207
|
+
: '';
|
|
208
|
+
return {
|
|
209
|
+
scene: { ...scene, objects },
|
|
210
|
+
summary: `Removed "${id}"${childNote}.`,
|
|
211
|
+
ids: [id, ...children],
|
|
212
|
+
changes: [id, ...children].map((rid) => ({ id: rid, what: 'removed' })),
|
|
213
|
+
warnings,
|
|
214
|
+
removed,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function setCamera(scene, camera) {
|
|
218
|
+
if (isRecord(camera) && camera.fit === true) {
|
|
219
|
+
const rest = { ...scene };
|
|
220
|
+
delete rest.camera;
|
|
221
|
+
return {
|
|
222
|
+
scene: rest,
|
|
223
|
+
summary: 'Camera cleared — the view now fits the objects.',
|
|
224
|
+
ids: [],
|
|
225
|
+
changes: [{ id: '', what: 'camera', from: scene.camera, to: 'fit' }],
|
|
226
|
+
warnings: [],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (!isRecord(camera)) {
|
|
230
|
+
return { error: 'camera must be { position: [x, y, z], target: [x, y, z] } or { fit: true }.' };
|
|
231
|
+
}
|
|
232
|
+
const next = { position: camera.position, target: camera.target };
|
|
233
|
+
return {
|
|
234
|
+
scene: { ...scene, camera: next },
|
|
235
|
+
summary: `Camera set: position ${showVal(camera.position)}, target ${showVal(camera.target)}.`,
|
|
236
|
+
ids: [],
|
|
237
|
+
changes: [{ id: '', what: 'camera', from: scene.camera, to: next }],
|
|
238
|
+
warnings: [],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Apply ONE op to a scene. Pure. Does not validate the resulting scene as a whole — use
|
|
243
|
+
* `applySceneOps` for the full pipeline (apply all → validate → normalise).
|
|
244
|
+
*/
|
|
245
|
+
export function applySceneOp(scene, op, opts) {
|
|
246
|
+
switch (op.op) {
|
|
247
|
+
case 'add':
|
|
248
|
+
return 'objects' in op
|
|
249
|
+
? Array.isArray(op.objects)
|
|
250
|
+
? addObjects(scene, op.objects, op.index, opts)
|
|
251
|
+
: { error: 'objects must be an array of objects.' }
|
|
252
|
+
: addObjects(scene, [op.object], op.index, opts);
|
|
253
|
+
case 'update':
|
|
254
|
+
return updateObject(scene, op.id, op.patch, opts);
|
|
255
|
+
case 'remove':
|
|
256
|
+
return removeObject(scene, op.id, opts);
|
|
257
|
+
case 'camera':
|
|
258
|
+
return setCamera(scene, op.camera);
|
|
259
|
+
case 'replace': {
|
|
260
|
+
const v = validateScene(op.scene);
|
|
261
|
+
if (!v.ok)
|
|
262
|
+
return { error: formatValidationErrors(v.errors) };
|
|
263
|
+
return {
|
|
264
|
+
scene: v.scene,
|
|
265
|
+
summary: `Replaced the scene (${String(v.scene.objects.length)} objects).`,
|
|
266
|
+
ids: v.scene.objects.map((o) => o.id),
|
|
267
|
+
changes: [{ id: '', what: 'replaced' }],
|
|
268
|
+
warnings: [],
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
default: {
|
|
272
|
+
const unknownOp = op.op;
|
|
273
|
+
return { error: `Unknown scene op ${JSON.stringify(unknownOp)}.` };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** Cap and bullet validation errors for a tool result. */
|
|
278
|
+
export function formatValidationErrors(errors, max = 8) {
|
|
279
|
+
const shown = errors.slice(0, max).map((e) => `- ${e}`);
|
|
280
|
+
if (errors.length > max)
|
|
281
|
+
shown.push(`- …and ${String(errors.length - max)} more.`);
|
|
282
|
+
return `The scene is invalid; nothing was stored:\n${shown.join('\n')}`;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The whole edit pipeline, pure: apply every op in order, then validate the FINAL scene and
|
|
286
|
+
* normalise it. All or nothing. `scene` may be null only when the first op is `replace`.
|
|
287
|
+
*/
|
|
288
|
+
export function applySceneOps(scene, ops, opts) {
|
|
289
|
+
if (ops.length === 0)
|
|
290
|
+
return { ok: false, error: 'No edits given.' };
|
|
291
|
+
let cur = scene;
|
|
292
|
+
const summaries = [];
|
|
293
|
+
const ids = [];
|
|
294
|
+
const changes = [];
|
|
295
|
+
const warnings = [];
|
|
296
|
+
const removed = [];
|
|
297
|
+
for (const op of ops) {
|
|
298
|
+
if (!cur && op.op !== 'replace') {
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
error: 'The stored scene could not be read; only a full replace can fix it.',
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const base = cur ?? { version: 1, name: 'Scene', units: 'm', objects: [] };
|
|
305
|
+
const r = applySceneOp(base, op, opts);
|
|
306
|
+
if ('error' in r)
|
|
307
|
+
return { ok: false, error: r.error };
|
|
308
|
+
cur = r.scene;
|
|
309
|
+
summaries.push(r.summary);
|
|
310
|
+
for (const id of r.ids)
|
|
311
|
+
if (!ids.includes(id))
|
|
312
|
+
ids.push(id);
|
|
313
|
+
changes.push(...r.changes);
|
|
314
|
+
warnings.push(...r.warnings);
|
|
315
|
+
if (r.removed)
|
|
316
|
+
removed.push(...r.removed);
|
|
317
|
+
}
|
|
318
|
+
const v = validateScene(cur);
|
|
319
|
+
if (!v.ok)
|
|
320
|
+
return { ok: false, error: formatValidationErrors(v.errors) };
|
|
321
|
+
return {
|
|
322
|
+
ok: true,
|
|
323
|
+
scene: normalizeScene(v.scene),
|
|
324
|
+
summaries,
|
|
325
|
+
ids,
|
|
326
|
+
changes,
|
|
327
|
+
warnings,
|
|
328
|
+
removed,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
// =============================================================================
|
|
332
|
+
// The user-edit notice (§4.3)
|
|
333
|
+
// =============================================================================
|
|
334
|
+
function describeChange(c) {
|
|
335
|
+
const q = `"${c.id}"`;
|
|
336
|
+
switch (c.what) {
|
|
337
|
+
case 'moved':
|
|
338
|
+
return `moved ${q} to ${showVal(c.to)}`;
|
|
339
|
+
case 'rotated':
|
|
340
|
+
return `rotated ${q} to ${showVal(c.to)}`;
|
|
341
|
+
case 'recoloured':
|
|
342
|
+
return `recoloured ${q} to ${typeof c.to === 'string' ? c.to : showVal(c.to)}`;
|
|
343
|
+
case 'renamed':
|
|
344
|
+
return `renamed ${q} to ${showVal(c.to)}`;
|
|
345
|
+
case 'resized':
|
|
346
|
+
return `resized ${q}`;
|
|
347
|
+
case 'removed':
|
|
348
|
+
return `removed ${q}`;
|
|
349
|
+
case 'added':
|
|
350
|
+
return `added ${typeof c.to === 'string' ? `${c.to} ` : ''}${q}`;
|
|
351
|
+
case 'camera':
|
|
352
|
+
return 'changed the camera';
|
|
353
|
+
case 'replaced':
|
|
354
|
+
return 'replaced the whole scene';
|
|
355
|
+
case 'edited':
|
|
356
|
+
return `edited ${q}`;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* "The user edited this scene since your last call: moved "sofa" to [1.2, 0, -0.4]; removed
|
|
361
|
+
* "lamp". Re-read with scene_get if you depend on other fields." — null when no changes.
|
|
362
|
+
*/
|
|
363
|
+
export function formatUserEditNotice(changes, max = 12) {
|
|
364
|
+
if (changes.length === 0)
|
|
365
|
+
return null;
|
|
366
|
+
const parts = changes.slice(0, max).map(describeChange);
|
|
367
|
+
if (changes.length > max)
|
|
368
|
+
parts.push(`and ${String(changes.length - max)} more`);
|
|
369
|
+
return (`The user edited this scene since your last call: ${parts.join('; ')}. ` +
|
|
370
|
+
'Re-read with scene_get if you depend on other fields.');
|
|
371
|
+
}
|
|
372
|
+
/** Type guard used by hosts on untrusted IPC payloads. */
|
|
373
|
+
export function isSceneObjectType(v) {
|
|
374
|
+
return typeof v === 'string' && SCENE_OBJECT_TYPES.includes(v);
|
|
375
|
+
}
|