@deepblend/dsh-blender-contracts 0.2.20 → 0.3.1
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/lib/artistic-review.js +57 -0
- package/lib/asset-content.js +6 -3
- package/lib/handled-cup.js +55 -0
- package/lib/index.js +31 -14
- package/lib/recipe.js +253 -0
- package/lib/reference-images.js +28 -0
- package/lib/runtime-types.d.ts +208 -0
- package/lib/scene-patch.js +133 -1
- package/lib/scene-spec.js +249 -4
- package/lib/schema-types.d.ts +185 -0
- package/lib/schemas/job-result.schema.json +117 -0
- package/lib/schemas/recipe.schema.json +332 -0
- package/lib/schemas/scene-patch.schema.json +793 -57
- package/lib/schemas/scene-spec.schema.json +653 -76
- package/lib/sdk.d.ts +101 -0
- package/lib/sdk.js +41 -0
- package/lib/ui-api.js +68 -3
- package/lib/visual-composition.js +38 -24
- package/lib/visual-issue.js +26 -8
- package/lib/visual-loop.js +204 -26
- package/package.json +8 -1
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/** The current Host-facing execution seam; no process or service is created by this type module. */
|
|
2
|
+
import type { Buffer } from 'node:buffer';
|
|
3
|
+
import type { RenderProfile, Vec3 } from './schema-types.js';
|
|
4
|
+
import type { BlenderError } from './sdk.js';
|
|
5
|
+
|
|
6
|
+
export type EngineKey = RenderProfile['engine'];
|
|
7
|
+
export interface RuntimeWarning { code: string; message: string; detail?: unknown }
|
|
8
|
+
export interface BlenderExecutableReport { requested: string; resolved: string | null; found: boolean; advice?: string | null }
|
|
9
|
+
export interface BlenderEngineProbe { assignable: boolean; readback: string | null; error: string | null }
|
|
10
|
+
export interface BlenderGpuBackend { supported: boolean; gpuDevices: string[]; cpuDevices: string[]; error: string | null }
|
|
11
|
+
export interface BlenderGpuReport {
|
|
12
|
+
availableBackends: string[]; preferredBackend: string | null; gpuDeviceNames: string[]; cpuDeviceNames: string[];
|
|
13
|
+
backendSupport: Record<string, BlenderGpuBackend>;
|
|
14
|
+
}
|
|
15
|
+
export interface BlenderRenderSmokeTest { attempted: boolean; engine: string | null; ok: boolean; bytes: number; error: string | null }
|
|
16
|
+
export interface BlenderCapabilities {
|
|
17
|
+
protocolVersion: string; installed: boolean; executable: BlenderExecutableReport;
|
|
18
|
+
blenderVersion: string | null; blenderVersionTuple: number[] | null; pythonVersion: string | null;
|
|
19
|
+
buildHash: string | null; binaryPath: string | null; renderEngines: Record<string, BlenderEngineProbe>;
|
|
20
|
+
bestAvailableEngine: string | null; renderEngineEnumItems: string[]; gpu: BlenderGpuReport; gpuAvailable: boolean;
|
|
21
|
+
exportFormats: string[]; importFormats: string[]; unavailableFormats: string[];
|
|
22
|
+
renderSmokeTest: BlenderRenderSmokeTest | null; cyclesSmokeTest: BlenderRenderSmokeTest | null;
|
|
23
|
+
textBlockApi: boolean; frameApi: boolean; hostPlatform: string | null; warnings: RuntimeWarning[];
|
|
24
|
+
probedAt: number; durationMs: number; commandLine: string | null;
|
|
25
|
+
}
|
|
26
|
+
export interface BlenderBootstrapRequest { action: string; payload?: Record<string, unknown>; jobId?: string }
|
|
27
|
+
export interface BlenderBootstrapEnvelope<Result = Record<string, unknown>> {
|
|
28
|
+
protocolVersion: string; jobId: string | null; action: string; status: 'success' | 'error';
|
|
29
|
+
capabilities: Record<string, unknown> | null; result: Result | null;
|
|
30
|
+
error: { code: string; message: string; detail?: unknown } | null;
|
|
31
|
+
warnings: RuntimeWarning[]; notices: RuntimeWarning[];
|
|
32
|
+
}
|
|
33
|
+
export interface RuntimeOptions { signal?: AbortSignal }
|
|
34
|
+
export interface WorkingDirectory<Result = Record<string, unknown>> {
|
|
35
|
+
directory: string; envelope: BlenderBootstrapEnvelope<Result>; jobId: string | undefined;
|
|
36
|
+
}
|
|
37
|
+
export interface BootstrapWorkingDirectory extends WorkingDirectory { requestPath: string; resultPath: string }
|
|
38
|
+
export type WorkingDirectoryCallback<Result = Record<string, unknown>> = (info: WorkingDirectory<Result>) => void | Promise<void>;
|
|
39
|
+
export interface MaterialSlot { index: number; materialName: string | null; materialId?: string | null; usedPolygonCount?: number }
|
|
40
|
+
export interface ImageFacts { width: number; height: number; channels: number; isFloat: boolean; colorSpace: string }
|
|
41
|
+
export interface UvMapFacts<Source extends 'mesh-data' | 'evaluated-mesh'> {
|
|
42
|
+
name: string; activeRender: boolean; loopCount: number; finite: boolean; source: Source;
|
|
43
|
+
}
|
|
44
|
+
export interface CompiledObject {
|
|
45
|
+
name: string; type: string; deepblendId?: string | null; partId?: string; parentPartId?: string | null;
|
|
46
|
+
materialSlots?: MaterialSlot[]; sourceMaterialSlots?: Array<{ index: number; materialName: string | null }>;
|
|
47
|
+
worldBounds?: { min: Vec3; max: Vec3 } | null; boundsFrame?: number; renderVisible?: boolean;
|
|
48
|
+
uvMaps?: Array<UvMapFacts<'mesh-data'>>; evaluatedUvMaps?: Array<UvMapFacts<'evaluated-mesh'>> | null;
|
|
49
|
+
boundsUnavailable?: 'viewport-disabled' | 'empty-or-invalid-bounds'; evaluatedUvMapsUnavailable?: 'viewport-disabled';
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
}
|
|
52
|
+
/** Blender's measured configuration; additional measured fields may be added. */
|
|
53
|
+
export interface MeasuredRenderConfig {
|
|
54
|
+
engine: string; resolution: number[]; samples: number | null;
|
|
55
|
+
exposure?: number; viewTransform?: string; look?: string; gamma?: number;
|
|
56
|
+
[key: string]: unknown;
|
|
57
|
+
}
|
|
58
|
+
export interface CompileReport {
|
|
59
|
+
validation: { ok: boolean; errors: unknown[]; [key: string]: unknown };
|
|
60
|
+
sceneFingerprint: { totalPolygons: number; [key: string]: unknown };
|
|
61
|
+
objects?: CompiledObject[]; renderConfig?: MeasuredRenderConfig;
|
|
62
|
+
world?: { declared: boolean; color: number[]; strength: number;
|
|
63
|
+
environment?: { assetId: string; rotation?: number; image: ImageFacts; [key: string]: unknown }; [key: string]: unknown };
|
|
64
|
+
[key: string]: unknown;
|
|
65
|
+
}
|
|
66
|
+
export interface PreviewReport {
|
|
67
|
+
outputPath: string; bytes: number; width: number; height: number; frame: number; engine: string; cameraId: string;
|
|
68
|
+
fps?: number; frameStart?: number; frameEnd?: number; cameraName?: string; lens?: number;
|
|
69
|
+
renderConfig?: MeasuredRenderConfig; objects?: CompiledObject[]; [key: string]: unknown;
|
|
70
|
+
}
|
|
71
|
+
export interface RuntimeActionResult<Report> {
|
|
72
|
+
report: Report; envelope: BlenderBootstrapEnvelope<Report>; durationMs: number; stdout: string; stderr: string;
|
|
73
|
+
}
|
|
74
|
+
export interface CompileSceneRequest extends RuntimeOptions {
|
|
75
|
+
sceneSpecPath: string; projectRoot?: string; profile?: string; jobId?: string;
|
|
76
|
+
/** false runs a separate batch process, preserving a user's live Blender session. */
|
|
77
|
+
session?: boolean;
|
|
78
|
+
/** Copy or move result.blend before resolving; never retain a scratch path as a durable artifact. */
|
|
79
|
+
onWorkingDirectory?: WorkingDirectoryCallback<CompileReport>;
|
|
80
|
+
}
|
|
81
|
+
export interface RenderOverrides { engine?: EngineKey; width?: number; height?: number; samples?: number }
|
|
82
|
+
export interface RenderPreviewRequest extends RuntimeOptions, RenderOverrides {
|
|
83
|
+
checkpointPath: string; outputPath: string; cameraId?: string; frame?: number; jobId?: string;
|
|
84
|
+
session?: boolean;
|
|
85
|
+
onWorkingDirectory?: (info: BootstrapWorkingDirectory) => void | Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
export interface RenderView { id: string; role?: string | null; cameraId?: string; frame?: number }
|
|
88
|
+
export interface ObjectMeasurement {
|
|
89
|
+
id: string; viewId: string; visiblePixels: number; silhouettePixels: number; visibleFraction: number;
|
|
90
|
+
occludedFraction: number; frameCoverage: number; silhouetteCoverage: number; inFrame: boolean;
|
|
91
|
+
bbox: unknown; centroid: unknown; occludedBy: unknown[]; part: boolean; [key: string]: unknown;
|
|
92
|
+
}
|
|
93
|
+
export interface ViewReport {
|
|
94
|
+
viewId: string; role: string | null; outputPath: string; bytes: number; width: number; height: number;
|
|
95
|
+
frame: number; cameraId: string; cameraName: string; lens: number; engine: string;
|
|
96
|
+
metrics: { width: number; height: number; objects: ObjectMeasurement[]; luminance: Record<string, unknown> };
|
|
97
|
+
/** Measured for this frame before diagnostic mask renders. */
|
|
98
|
+
renderConfig?: MeasuredRenderConfig;
|
|
99
|
+
cameraFacts?: MeasuredCameraFacts;
|
|
100
|
+
}
|
|
101
|
+
export interface MeasuredCameraFacts {
|
|
102
|
+
frame: number; matrixWorld: number[][]; type: 'PERSP' | 'ORTHO' | 'PANO'; lens: number; orthoScale: number;
|
|
103
|
+
sensorWidth: number; sensorHeight: number; sensorFit: string; shift: number[]; clip: number[];
|
|
104
|
+
dof: { enabled: boolean; focusDistance: number; focusObject: { name: string; entityId: string | null; matrixWorld: number[][] } | null;
|
|
105
|
+
focusSubtarget: string | null; apertureFstop: number; apertureBlades: number; apertureRotation: number; apertureRatio: number };
|
|
106
|
+
}
|
|
107
|
+
export interface ViewsReport { views: ViewReport[]; renderConfig?: MeasuredRenderConfig; objects?: CompiledObject[]; [key: string]: unknown }
|
|
108
|
+
export interface RenderViewsRequest extends RuntimeOptions, RenderOverrides {
|
|
109
|
+
checkpointPath: string; views: RenderView[]; track?: string[]; parts?: string[]; jobId?: string;
|
|
110
|
+
/** false isolates both rendering and measurements from a user's live session. */
|
|
111
|
+
session?: boolean;
|
|
112
|
+
}
|
|
113
|
+
export interface RenderViewsResult {
|
|
114
|
+
report: ViewsReport; pngs: Record<string, Buffer>; envelope: BlenderBootstrapEnvelope<ViewsReport>; durationMs: number;
|
|
115
|
+
}
|
|
116
|
+
export interface CollectedOutputReader {
|
|
117
|
+
readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string };
|
|
118
|
+
}
|
|
119
|
+
/** Host-required subset of the DSH subprocess handle; implementations may expose additional fields. */
|
|
120
|
+
export interface FrameSequenceHandle {
|
|
121
|
+
readonly done: Promise<{ exitCode: number | null; signal: string | null }>;
|
|
122
|
+
readonly collected?: { readonly stdout?: CollectedOutputReader; readonly stderr?: CollectedOutputReader };
|
|
123
|
+
/** Synchronous, idempotent initiation of managed process termination. Await done afterwards. */
|
|
124
|
+
terminate(): void;
|
|
125
|
+
waitForExit?(signal?: AbortSignal): Promise<boolean>;
|
|
126
|
+
}
|
|
127
|
+
export interface FrameSequenceRequest extends RuntimeOptions {
|
|
128
|
+
checkpointPath: string; frames: number[]; jobDirectory: string; cameraId?: string; profileName?: string;
|
|
129
|
+
profile?: RenderProfile; frameRange?: [number, number]; jobId?: string; attemptToken?: string;
|
|
130
|
+
filePrefix?: string; padding?: number;
|
|
131
|
+
}
|
|
132
|
+
export interface FrameSequenceRun {
|
|
133
|
+
handle: FrameSequenceHandle; jobDirectory: string; framesDirectory: string; requestPath: string; planPath: string;
|
|
134
|
+
resultPath: string; eventsPath: string; processPath: string; argv: string[]; startedAt: number;
|
|
135
|
+
attemptToken: string | null; executable: string;
|
|
136
|
+
}
|
|
137
|
+
/** Optional fsynced journal extension for attribution; omitted evidence stays unknown. */
|
|
138
|
+
export interface FrameConfiguredEvent {
|
|
139
|
+
type: 'render_config'; attemptToken: string; renderConfig: MeasuredRenderConfig;
|
|
140
|
+
}
|
|
141
|
+
export interface FrameWrittenEvent {
|
|
142
|
+
type: 'frame'; frame: number; bytes: number; ms: number;
|
|
143
|
+
/** The matching configured event must precede this completion record. */
|
|
144
|
+
attemptToken: string;
|
|
145
|
+
/** SHA256 of the complete PNG bytes after structural verification. */
|
|
146
|
+
sha256: string;
|
|
147
|
+
}
|
|
148
|
+
export type FrameProvenanceEvent = FrameConfiguredEvent | FrameWrittenEvent;
|
|
149
|
+
/** Configuration read back by the renderer, with the launch identity that requested it. */
|
|
150
|
+
export interface FrameRenderSource {
|
|
151
|
+
id: string; attempt: number; revisionId: string; cameraId: string | null; checkpointSha256: string | null;
|
|
152
|
+
requestedSamples: number | null; effectiveProfile: RenderProfile; renderConfig: MeasuredRenderConfig;
|
|
153
|
+
}
|
|
154
|
+
export interface FrameSourceBinding { frame: number; sourceId: string; sha256: string }
|
|
155
|
+
/** Shared by canonical jobs and delivery manifests; known means configuration/PNG evidence, not per-pixel samples. */
|
|
156
|
+
export interface FrameProvenanceSummary {
|
|
157
|
+
schemaVersion: 'deepblend.frame-sources/v1';
|
|
158
|
+
coverage: 'complete' | 'partial' | 'unknown'; configuration: 'uniform' | 'mixed' | 'unknown';
|
|
159
|
+
groups: Array<{ renderConfig: MeasuredRenderConfig; frames: number[]; sourceIds: string[] }>;
|
|
160
|
+
sources: FrameRenderSource[]; frames: FrameSourceBinding[]; unknownFrames: number[]; missingFrames: number[];
|
|
161
|
+
knownFrameCount: number; unknownFrameCount: number; missingFrameCount: number;
|
|
162
|
+
/** A bounded read/metadata limit deferred attribution; omitted frames are not called missing or corrupt. */
|
|
163
|
+
limited?: true;
|
|
164
|
+
limitation?: 'status-budget' | 'frame-limit' | 'source-byte-limit' | 'journal-byte-limit' | 'journal-line-limit';
|
|
165
|
+
uncheckedFrameCount?: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface FrameSequenceOutcome {
|
|
169
|
+
/** Parsed but not yet validated. The Host checks protocol, completion and durable frame evidence. */
|
|
170
|
+
envelope: unknown; stdout: string; stderr: string; exitCode: number | null; signal: string | null;
|
|
171
|
+
durationMs: number; spawnFailure: unknown;
|
|
172
|
+
}
|
|
173
|
+
export interface EngineResolution { blenderEngine: string | null; requested: string | null; downgraded: boolean; warning: RuntimeWarning | null }
|
|
174
|
+
/** Required execution methods used by the current Host; see docs/public-api.md for lifecycle requirements. */
|
|
175
|
+
export interface BlenderRuntime {
|
|
176
|
+
getCapabilities(options?: RuntimeOptions & { refresh?: boolean }): Promise<BlenderCapabilities>;
|
|
177
|
+
invalidateCapabilities(): void;
|
|
178
|
+
resolveEngineKey(engineKey: EngineKey, options?: RuntimeOptions): Promise<EngineResolution>;
|
|
179
|
+
compileScene(request: CompileSceneRequest): Promise<RuntimeActionResult<CompileReport>>;
|
|
180
|
+
renderPreview(request: RenderPreviewRequest): Promise<RuntimeActionResult<PreviewReport>>;
|
|
181
|
+
renderViews(request: RenderViewsRequest): Promise<RenderViewsResult>;
|
|
182
|
+
startFrameSequence(request: FrameSequenceRequest): Promise<FrameSequenceRun>;
|
|
183
|
+
awaitFrameSequence(run: FrameSequenceRun): Promise<FrameSequenceOutcome>;
|
|
184
|
+
dispose(): void;
|
|
185
|
+
}
|
|
186
|
+
export interface BootstrapOptions extends RuntimeOptions {
|
|
187
|
+
session?: boolean; args?: string[]; cwd?: string; projectRoot?: string;
|
|
188
|
+
prepareDirectory?: (info: { directory: string; jobId: string }) => void | Promise<void>;
|
|
189
|
+
onWorkingDirectory?: (info: BootstrapWorkingDirectory) => void | Promise<void>;
|
|
190
|
+
}
|
|
191
|
+
export interface BootstrapRunOutcome {
|
|
192
|
+
envelope: BlenderBootstrapEnvelope; stdout: string; stderr: string; exitCode: number | null; durationMs: number;
|
|
193
|
+
/** Present for batch runs; it usually names an already removed directory. */
|
|
194
|
+
workingDirectory?: string | null;
|
|
195
|
+
}
|
|
196
|
+
export interface BlenderSession {
|
|
197
|
+
readonly pid: number | null; readonly workspace: string | null;
|
|
198
|
+
run(request: BlenderBootstrapRequest & { args?: string[] }, options?: RuntimeOptions): Promise<BlenderBootstrapEnvelope>;
|
|
199
|
+
close(): Promise<void>;
|
|
200
|
+
}
|
|
201
|
+
/** Optional local transport extensions. Remote runtimes need only implement BlenderRuntime. */
|
|
202
|
+
export interface LocalBlenderRuntime extends BlenderRuntime {
|
|
203
|
+
runBootstrap(request: BlenderBootstrapRequest, options?: BootstrapOptions): Promise<BootstrapRunOutcome>;
|
|
204
|
+
openSession(options?: RuntimeOptions & { cwd?: string; args?: string[] }): Promise<BlenderSession>;
|
|
205
|
+
attachSession(socketPath: string, options?: { timeoutMs?: number }): Promise<BlenderSession>;
|
|
206
|
+
closeSession(): Promise<void>;
|
|
207
|
+
resolveBlenderExecutable(options?: RuntimeOptions): Promise<{ requested: string; resolved: string | null; error: BlenderError | null; advice?: string }>;
|
|
208
|
+
}
|
package/lib/scene-patch.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { compileSchema, formatIssues } from './json-schema.js'
|
|
20
20
|
import { collectionForKind, sceneSpecDigest, specHash } from './scene-spec.js'
|
|
21
|
+
import { referenceImageIssues } from './reference-images.js'
|
|
21
22
|
import scenePatchSchema from './schemas/scene-patch.schema.json' with { type: 'json' }
|
|
22
23
|
|
|
23
24
|
const validatePatchStructure = compileSchema(scenePatchSchema, { id: 'scene-patch.schema.json' })
|
|
@@ -53,6 +54,13 @@ export const SCENE_OPERATION_NAMES = Object.freeze([
|
|
|
53
54
|
// accepted `material.texture.set` while this list (and therefore the manual and the tool's description) still
|
|
54
55
|
// had 23 names, which `scene-patch.test.mjs`'s schema-vs-constant check is the only check able to see.
|
|
55
56
|
'material.texture.set',
|
|
57
|
+
'entity.generator.set',
|
|
58
|
+
'entity.modifiers.set',
|
|
59
|
+
'material.images.set',
|
|
60
|
+
'entity.materialBindings.set',
|
|
61
|
+
'material.tangent.set',
|
|
62
|
+
'project.brief.set',
|
|
63
|
+
'project.reviewSubject.set',
|
|
56
64
|
])
|
|
57
65
|
|
|
58
66
|
/** The `id` grammar shared with SceneSpec. */const ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9._-]*$/
|
|
@@ -105,6 +113,12 @@ export function validateScenePatch(patch) {
|
|
|
105
113
|
const at = `operations[${position}]`
|
|
106
114
|
const op = operation.op
|
|
107
115
|
|
|
116
|
+
if (op === 'project.brief.set') {
|
|
117
|
+
for (const issue of referenceImageIssues(operation.referenceImages)) {
|
|
118
|
+
errors.push({ code: 'PATCH_OPERATION_INVALID', path: `${at}.referenceImages${issue.path}`, message: issue.message })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
108
122
|
if (op === 'entity.transform.update') {
|
|
109
123
|
const supplied = ['location', 'rotationEuler', 'scale'].filter(key => operation[key] !== undefined)
|
|
110
124
|
if (supplied.length === 0) {
|
|
@@ -149,6 +163,11 @@ export function validateScenePatch(patch) {
|
|
|
149
163
|
})
|
|
150
164
|
}
|
|
151
165
|
}
|
|
166
|
+
if (op === 'material.parameter.update' && ['anisotropic', 'anisotropicRotation'].includes(operation.parameter) &&
|
|
167
|
+
(!Number.isFinite(operation.value) || operation.value < 0 || operation.value > 1)) {
|
|
168
|
+
errors.push({ code: 'PATCH_OPERATION_INVALID', path: `${at}.value`,
|
|
169
|
+
message: `${operation.parameter} must be a finite scalar in [0, 1]` })
|
|
170
|
+
}
|
|
152
171
|
for (const key of ['entityId', 'materialId', 'lightId', 'cameraId', 'trackId', 'shotId']) {
|
|
153
172
|
const value = operation[key]
|
|
154
173
|
if (typeof value === 'string' && !ID_PATTERN.test(value)) {
|
|
@@ -402,6 +421,51 @@ export function applyPatchToSpec(spec, patch) {
|
|
|
402
421
|
break
|
|
403
422
|
}
|
|
404
423
|
|
|
424
|
+
case 'entity.materialBindings.set': {
|
|
425
|
+
const at = indexOfId(next.entities, operation.entityId)
|
|
426
|
+
if (at < 0) fail('PATCH_TARGET_MISSING', `no entity "${operation.entityId}" exists in this scene`)
|
|
427
|
+
const entity = next.entities[at]
|
|
428
|
+
if (entity.type !== 'asset-instance') fail('PATCH_OPERATION_INVALID', `${op} requires an asset-instance entity`)
|
|
429
|
+
const selectors = new Set()
|
|
430
|
+
for (const binding of operation.materialBindings) {
|
|
431
|
+
if (indexOfId(next.materials, binding.materialId) < 0) fail('PATCH_REFERENCE_MISSING', `no material "${binding.materialId}" exists`)
|
|
432
|
+
const key = JSON.stringify([binding.partId, binding.slotIndex ?? null])
|
|
433
|
+
if (selectors.has(key)) fail('PATCH_OPERATION_INVALID', 'duplicate part and slot material binding')
|
|
434
|
+
selectors.add(key)
|
|
435
|
+
}
|
|
436
|
+
const changed = JSON.stringify(entity.materialBindings ?? []) !== JSON.stringify(operation.materialBindings)
|
|
437
|
+
if (changed) {
|
|
438
|
+
const updated = { ...entity, materialBindings: structuredClone(operation.materialBindings) }
|
|
439
|
+
if (!updated.materialBindings.length) delete updated.materialBindings
|
|
440
|
+
next.entities[at] = updated
|
|
441
|
+
}
|
|
442
|
+
applied.push({ op, target: entity.id, summary: `set material bindings on entity "${entity.id}"`,
|
|
443
|
+
changedPaths: changed ? [`entities.${entity.id}.materialBindings`] : [] })
|
|
444
|
+
break
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
case 'entity.generator.set':
|
|
448
|
+
case 'entity.modifiers.set': {
|
|
449
|
+
const at = indexOfId(next.entities, operation.entityId)
|
|
450
|
+
if (at < 0) fail('PATCH_TARGET_MISSING', `no entity "${operation.entityId}" exists in this scene`)
|
|
451
|
+
const entity = next.entities[at]
|
|
452
|
+
const field = op === 'entity.generator.set' ? 'generator' : 'modifiers'
|
|
453
|
+
if ((field === 'generator' && entity.type !== 'generator') || entity.type === 'empty') {
|
|
454
|
+
fail('PATCH_OPERATION_INVALID', `${op} cannot edit a ${entity.type} entity`)
|
|
455
|
+
}
|
|
456
|
+
const before = entity[field] ?? (field === 'modifiers' ? [] : undefined)
|
|
457
|
+
const value = structuredClone(operation[field])
|
|
458
|
+
const changed = JSON.stringify(before) !== JSON.stringify(value)
|
|
459
|
+
if (changed) {
|
|
460
|
+
const updated = { ...entity, [field]: value }
|
|
461
|
+
if (field === 'modifiers' && value.length === 0) delete updated.modifiers
|
|
462
|
+
next.entities[at] = updated
|
|
463
|
+
}
|
|
464
|
+
applied.push({ op, target: entity.id, summary: `set ${field} on entity "${entity.id}"`,
|
|
465
|
+
changedPaths: changed ? [`entities.${entity.id}.${field}`] : [] })
|
|
466
|
+
break
|
|
467
|
+
}
|
|
468
|
+
|
|
405
469
|
case 'entity.add': {
|
|
406
470
|
if (indexOfId(next.entities, operation.entity.id) >= 0) {
|
|
407
471
|
fail('PATCH_TARGET_EXISTS', `entity "${operation.entity.id}" already exists; use entity.transform.update instead`)
|
|
@@ -428,6 +492,11 @@ export function applyPatchToSpec(spec, patch) {
|
|
|
428
492
|
fail('PATCH_TARGET_MISSING', `no entity "${operation.entityId}" exists in this scene`)
|
|
429
493
|
}
|
|
430
494
|
const dependents = []
|
|
495
|
+
if (next.project.reviewSubjectId === operation.entityId) dependents.push('review subject')
|
|
496
|
+
if (next.entities.some(entity => (entity.modifiers ?? []).some(modifier =>
|
|
497
|
+
modifier.type === 'boolean' && modifier.targetEntityId === operation.entityId))) {
|
|
498
|
+
dependents.push('boolean operand')
|
|
499
|
+
}
|
|
431
500
|
if (next.cameras.some(camera => camera.targetEntityId === operation.entityId)) {
|
|
432
501
|
dependents.push('camera target')
|
|
433
502
|
}
|
|
@@ -516,6 +585,34 @@ export function applyPatchToSpec(spec, patch) {
|
|
|
516
585
|
break
|
|
517
586
|
}
|
|
518
587
|
|
|
588
|
+
case 'material.tangent.set': {
|
|
589
|
+
const at = indexOfId(next.materials, operation.materialId)
|
|
590
|
+
if (at < 0) fail('PATCH_TARGET_MISSING', `no material "${operation.materialId}" exists in this scene`)
|
|
591
|
+
const material = { ...next.materials[at] }
|
|
592
|
+
if (operation.tangent === null) delete material.tangent
|
|
593
|
+
else material.tangent = structuredClone(operation.tangent)
|
|
594
|
+
next.materials = [...next.materials]
|
|
595
|
+
next.materials[at] = material
|
|
596
|
+
applied.push({ op, target: material.id, summary: `set anisotropic tangent on material "${material.id}"`,
|
|
597
|
+
changedPaths: [`materials.${material.id}.tangent`] })
|
|
598
|
+
break
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
case 'material.images.set': {
|
|
602
|
+
const at = indexOfId(next.materials, operation.materialId)
|
|
603
|
+
if (at < 0) fail('PATCH_TARGET_MISSING', `no material "${operation.materialId}" exists in this scene`)
|
|
604
|
+
const material = { ...next.materials[at] }
|
|
605
|
+
for (const binding of Object.values(operation.images ?? {})) {
|
|
606
|
+
if (indexOfId(next.assets, binding.assetId) < 0) fail('PATCH_REFERENCE_MISSING', `no image asset "${binding.assetId}" exists`)
|
|
607
|
+
}
|
|
608
|
+
if (operation.images === null) delete material.images
|
|
609
|
+
else material.images = structuredClone(operation.images)
|
|
610
|
+
next.materials[at] = material
|
|
611
|
+
applied.push({ op, target: material.id, summary: `set image maps on material "${material.id}"`,
|
|
612
|
+
changedPaths: [`materials.${material.id}.images`] })
|
|
613
|
+
break
|
|
614
|
+
}
|
|
615
|
+
|
|
519
616
|
case 'material.texture.set': {
|
|
520
617
|
const at = indexOfId(next.materials, operation.materialId)
|
|
521
618
|
if (at < 0) fail('PATCH_TARGET_MISSING', `no material "${operation.materialId}" exists in this scene`)
|
|
@@ -758,6 +855,29 @@ export function applyPatchToSpec(spec, patch) {
|
|
|
758
855
|
}
|
|
759
856
|
|
|
760
857
|
// ---- project and profiles -------------------------------------------
|
|
858
|
+
case 'project.reviewSubject.set': {
|
|
859
|
+
const entity = next.entities.find(entry => entry.id === operation.entityId)
|
|
860
|
+
if (operation.entityId !== null && !entity) fail('PATCH_REFERENCE_MISSING', `review subject "${operation.entityId}" does not exist`)
|
|
861
|
+
if (entity?.type === 'empty') fail('PATCH_OPERATION_INVALID', 'The review subject must be a renderable entity, not an empty.')
|
|
862
|
+
const project = { ...next.project }
|
|
863
|
+
if (operation.entityId === null) delete project.reviewSubjectId
|
|
864
|
+
else project.reviewSubjectId = operation.entityId
|
|
865
|
+
next = { ...next, project }
|
|
866
|
+
applied.push({ op, target: project.id,
|
|
867
|
+
summary: operation.entityId === null ? 'restored automatic review subject selection' : `set review subject to "${operation.entityId}"`,
|
|
868
|
+
changedPaths: ['project.reviewSubjectId'] })
|
|
869
|
+
break
|
|
870
|
+
}
|
|
871
|
+
case 'project.brief.set': {
|
|
872
|
+
const issues = referenceImageIssues(operation.referenceImages, next.assets ?? [])
|
|
873
|
+
if (issues.length > 0) fail(issues[0].code === 'SCENE_REFERENCE_MISSING'
|
|
874
|
+
? 'PATCH_REFERENCE_MISSING' : 'PATCH_OPERATION_INVALID', issues[0].message)
|
|
875
|
+
next = { ...next, project: { ...next.project, goal: operation.goal,
|
|
876
|
+
referenceImages: structuredClone(operation.referenceImages) } }
|
|
877
|
+
applied.push({ op, target: next.project.id, summary: 'replaced the authored goal and visual references',
|
|
878
|
+
changedPaths: ['project.goal', 'project.referenceImages'] })
|
|
879
|
+
break
|
|
880
|
+
}
|
|
761
881
|
case 'project.frameRange.set': {
|
|
762
882
|
if (operation.frameEnd <= operation.frameStart) {
|
|
763
883
|
fail('PATCH_FRAME_RANGE_INVALID', `frameEnd (${operation.frameEnd}) must be greater than frameStart (${operation.frameStart})`)
|
|
@@ -817,6 +937,9 @@ export function applyPatchToSpec(spec, patch) {
|
|
|
817
937
|
}
|
|
818
938
|
|
|
819
939
|
case 'world.set': {
|
|
940
|
+
if (operation.world.environment && indexOfId(next.assets, operation.world.environment.assetId) < 0) {
|
|
941
|
+
fail('PATCH_REFERENCE_MISSING', `no environment asset "${operation.world.environment.assetId}" exists`)
|
|
942
|
+
}
|
|
820
943
|
// The world REPLACES rather than merges, matching how a caller thinks about
|
|
821
944
|
// "make the background black": a half-updated world is how the hidden grey
|
|
822
945
|
// constant survived this long in the first place.
|
|
@@ -869,6 +992,12 @@ export function applyPatchToSpec(spec, patch) {
|
|
|
869
992
|
}
|
|
870
993
|
|
|
871
994
|
case 'asset.remove': {
|
|
995
|
+
if ((next.project.referenceImages ?? []).some(reference => reference.assetId === operation.assetId)) {
|
|
996
|
+
fail('PATCH_TARGET_IN_USE', `asset "${operation.assetId}" is still used by project.referenceImages`)
|
|
997
|
+
}
|
|
998
|
+
if (next.world?.environment?.assetId === operation.assetId) {
|
|
999
|
+
fail('PATCH_TARGET_IN_USE', `asset "${operation.assetId}" is still used by world.environment`)
|
|
1000
|
+
}
|
|
872
1001
|
if (indexOfId(next.assets, operation.assetId) < 0) {
|
|
873
1002
|
fail('PATCH_TARGET_MISSING', `no asset "${operation.assetId}" exists in this scene`)
|
|
874
1003
|
}
|
|
@@ -876,6 +1005,9 @@ export function applyPatchToSpec(spec, patch) {
|
|
|
876
1005
|
// instantiates this asset would be left pointing at nothing, and the compiler
|
|
877
1006
|
// would then refuse a scene the patch claimed to have produced.
|
|
878
1007
|
const dependents = next.entities.filter(entity => entity.assetId === operation.assetId)
|
|
1008
|
+
if ((next.materials ?? []).some(material => Object.values(material.images ?? {}).some(binding => binding.assetId === operation.assetId))) {
|
|
1009
|
+
fail('PATCH_TARGET_IN_USE', `asset "${operation.assetId}" is still referenced by material image maps`)
|
|
1010
|
+
}
|
|
879
1011
|
if (dependents.length > 0) {
|
|
880
1012
|
fail(
|
|
881
1013
|
'PATCH_TARGET_IN_USE',
|
|
@@ -929,7 +1061,7 @@ export function buildOperationManifest({ operations, request, revision, notices
|
|
|
929
1061
|
specHashBefore: revision.specHashBefore ?? null,
|
|
930
1062
|
specHashAfter: revision.specHashAfter ?? null,
|
|
931
1063
|
// Two different questions, two different answers. `sceneChanged` excludes
|
|
932
|
-
// project
|
|
1064
|
+
// project metadata and brief by design (see sceneSpecDigest); `specChanged` covers the
|
|
933
1065
|
// whole document, so the frame range counts.
|
|
934
1066
|
sceneChanged: revision.digestBefore !== revision.digestAfter,
|
|
935
1067
|
specChanged: (revision.specHashBefore ?? null) !== null
|