@nodaro/shared 2.27.0 → 3.1.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.
@@ -0,0 +1,156 @@
1
+ /** Immutable, deterministic edits over a baked scene. No asset bytes are mutated. */
2
+ import { z } from "zod"
3
+ import {
4
+ newScene3DRevisionId, rotationVec3Schema, scaleVec3Schema,
5
+ scene3DColorSchema, scene3DIdSchema, vec3Schema,
6
+ } from "./scene3d.js"
7
+ import {
8
+ SCENE3D_V2_OVERRIDE_OPERATION_VERSION, scene3DMaterialRoleSchema,
9
+ type Scene3DEntityCapability, type Scene3DOverride, type Scene3DPlanV2,
10
+ } from "./scene3d-v2.js"
11
+ import { scene3DPlanV2Schema } from "./scene3d-v2-plan.js"
12
+ import { computeScene3DPlanV2ContentHash, verifyScene3DPlanV2ContentHash } from "./scene3d-v2-resources.js"
13
+
14
+ /** Callers describe values. Revision identity and provenance are assigned here. */
15
+ export const scene3DV2OverrideInputSchema = z.discriminatedUnion("kind", [
16
+ z.object({ kind: z.literal("entity-transform"), entityId: scene3DIdSchema,
17
+ space: z.enum(["local", "world"]), position: vec3Schema.optional(),
18
+ rotation: rotationVec3Schema.optional(), scale: scaleVec3Schema.optional(),
19
+ }).strict(),
20
+ z.object({ kind: z.literal("entity-color"), entityId: scene3DIdSchema,
21
+ materialRole: scene3DMaterialRoleSchema, color: scene3DColorSchema,
22
+ }).strict(),
23
+ z.object({ kind: z.literal("entity-visibility"), entityId: scene3DIdSchema, visible: z.boolean() }).strict(),
24
+ z.object({ kind: z.literal("camera-shot-offset"), shotId: scene3DIdSchema,
25
+ positionOffset: vec3Schema.optional(), targetOffset: vec3Schema.optional(),
26
+ }).strict(),
27
+ ])
28
+ export const scene3DV2EditOperationSchema = z.discriminatedUnion("op", [
29
+ z.object({ op: z.literal("set-override"), override: scene3DV2OverrideInputSchema }).strict(),
30
+ z.object({ op: z.literal("remove-override"), overrideId: scene3DIdSchema }).strict(),
31
+ ])
32
+ export const scene3DV2EditOperationsSchema = z.array(scene3DV2EditOperationSchema).min(1).max(100)
33
+ export type Scene3DV2OverrideInput = z.infer<typeof scene3DV2OverrideInputSchema>
34
+ export type Scene3DV2EditOperation = z.infer<typeof scene3DV2EditOperationSchema>
35
+
36
+ export interface Scene3DV2EditOptions {
37
+ expectedRevisionId: string
38
+ expectedContentHash?: string
39
+ lockedObjectIds?: readonly string[]
40
+ /** Hosts may allocate identity at admission for idempotent job replay. */
41
+ newRevisionId?: string
42
+ }
43
+ export type Scene3DV2EditResult =
44
+ | { ok: true; plan: Scene3DPlanV2; changeSummary: string }
45
+ | { ok: false; code: "invalid_plan" | "invalid_operations" | "stale_revision" | "locked"; message: string }
46
+
47
+ function channel(override: Scene3DV2OverrideInput): string {
48
+ switch (override.kind) {
49
+ case "entity-transform": return `transform:${override.entityId}`
50
+ case "entity-color": return `color:${override.entityId}:${override.materialRole}`
51
+ case "entity-visibility": return `visibility:${override.entityId}`
52
+ case "camera-shot-offset": return `camera:${override.shotId}`
53
+ }
54
+ }
55
+ function capability(override: Scene3DV2OverrideInput): Scene3DEntityCapability | null {
56
+ switch (override.kind) {
57
+ case "entity-transform": return "transform"
58
+ case "entity-color": return "color"
59
+ case "entity-visibility": return "visibility"
60
+ case "camera-shot-offset": return null
61
+ }
62
+ }
63
+
64
+ /** A parent's transform or visibility changes its descendants too. */
65
+ function lockIssue(plan: Scene3DPlanV2, override: Scene3DV2OverrideInput, externalLocks: ReadonlySet<string>): string | null {
66
+ if (override.kind === "camera-shot-offset") return null
67
+ const target = plan.objects.find((o) => o.id === override.entityId)
68
+ if (!target) return `Unknown entity: ${override.entityId}`
69
+ const cap = capability(override)!
70
+ if (target.capabilities && !target.capabilities.includes(cap)) return `Entity ${target.id} does not allow ${cap} edits`
71
+ if (externalLocks.has(target.id) || target.locks?.includes(cap)) return `Entity ${target.id} is locked for ${cap}`
72
+ if (cap !== "transform" && cap !== "visibility") return null
73
+ const byId = new Map(plan.objects.map((entity) => [entity.id, entity]))
74
+ for (const entity of plan.objects) {
75
+ if (!externalLocks.has(entity.id) && !entity.locks?.includes(cap)) continue
76
+ let parent = entity.parentId
77
+ while (parent) {
78
+ if (parent === target.id) return `Changing ${target.id} would change locked descendant ${entity.id}`
79
+ parent = byId.get(parent)?.parentId
80
+ }
81
+ }
82
+ return null
83
+ }
84
+
85
+ /**
86
+ * Edits are all-or-nothing. The expected revision and content hash are checked
87
+ * before writing, and the result is validated and hashed before acceptance.
88
+ * A source file belongs to its exact base revision: until the host materializes
89
+ * these edits, the new revision must not advertise the old native download.
90
+ */
91
+ export async function applyScene3DV2EditOperations(
92
+ input: Scene3DPlanV2,
93
+ operations: readonly Scene3DV2EditOperation[],
94
+ options: Scene3DV2EditOptions,
95
+ ): Promise<Scene3DV2EditResult> {
96
+ const parsed = scene3DPlanV2Schema.safeParse(input)
97
+ if (!parsed.success) return { ok: false, code: "invalid_plan", message: parsed.error.issues[0]?.message ?? "Invalid scene" }
98
+ const base = parsed.data as Scene3DPlanV2
99
+ if (base.revisionId !== options.expectedRevisionId ||
100
+ (options.expectedContentHash !== undefined && base.provenance.contentHash !== options.expectedContentHash)) {
101
+ return { ok: false, code: "stale_revision", message: "The scene changed since this edit was prepared" }
102
+ }
103
+ if (!await verifyScene3DPlanV2ContentHash(base)) {
104
+ return { ok: false, code: "invalid_plan", message: "The scene content does not match its digest" }
105
+ }
106
+ const ops = scene3DV2EditOperationsSchema.safeParse(operations)
107
+ if (!ops.success) return { ok: false, code: "invalid_operations", message: ops.error.issues[0]?.message ?? "Invalid edit" }
108
+ const revisionId = options.newRevisionId ?? newScene3DRevisionId()
109
+ if (revisionId === base.revisionId) return { ok: false, code: "invalid_operations", message: "An edit requires a new revision identity" }
110
+ const externalLocks = new Set(options.lockedObjectIds ?? [])
111
+ for (const id of externalLocks) {
112
+ if (!base.objects.some((entity) => entity.id === id)) return { ok: false, code: "invalid_operations", message: `Unknown locked entity: ${id}` }
113
+ }
114
+ let overrides = [...(base.overrides ?? [])]
115
+ for (const [index, operation] of ops.data.entries()) {
116
+ if (operation.op === "remove-override") {
117
+ const existing = overrides.find((override) => override.id === operation.overrideId)
118
+ if (!existing) return { ok: false, code: "invalid_operations", message: `Unknown override: ${operation.overrideId}` }
119
+ const issue = lockIssue(base, existing, externalLocks)
120
+ if (issue) return { ok: false, code: "locked", message: issue }
121
+ overrides = overrides.filter((override) => override.id !== existing.id)
122
+ continue
123
+ }
124
+ const issue = lockIssue(base, operation.override, externalLocks)
125
+ if (issue) return { ok: false, code: "locked", message: issue }
126
+ const previous = overrides.find((override) => channel(override) === channel(operation.override))
127
+ const compatible = previous && (previous.kind !== "entity-transform" ||
128
+ (operation.override.kind === "entity-transform" && previous.space === operation.override.space))
129
+ const next: Scene3DOverride = {
130
+ ...(compatible ? previous : {}),
131
+ ...operation.override,
132
+ id: `edit-${revisionId}-${index}`,
133
+ sourceRevisionId: base.revisionId,
134
+ sourceContentHash: base.provenance.contentHash,
135
+ operationVersion: SCENE3D_V2_OVERRIDE_OPERATION_VERSION,
136
+ }
137
+ const key = channel(next)
138
+ overrides = [...overrides.filter((override) => channel(override) !== key), next]
139
+ }
140
+ const { sourceArtifactId: _sourceArtifactId, ...provenance } = base.provenance
141
+ const next: Scene3DPlanV2 = {
142
+ ...base, revisionId, parentRevisionId: base.revisionId,
143
+ // Geometry and cameras are reused; derived images, validation and native
144
+ // exports describe the old revision until regenerated for these overlays.
145
+ assets: base.assets.filter((asset) => asset.kind === "glb" || asset.kind === "camera-track-json").map((asset) => ({
146
+ ...asset, originRevisionId: asset.originRevisionId ?? base.revisionId,
147
+ })),
148
+ overrides,
149
+ provenance: { ...provenance, sourceRevisionId: base.revisionId },
150
+ }
151
+ const validated = scene3DPlanV2Schema.safeParse(next)
152
+ if (!validated.success) return { ok: false, code: "invalid_operations", message: validated.error.issues[0]?.message ?? "Invalid edited scene" }
153
+ const plan = validated.data as Scene3DPlanV2
154
+ const contentHash = await computeScene3DPlanV2ContentHash(plan)
155
+ return { ok: true, plan: { ...plan, provenance: { ...plan.provenance, contentHash } }, changeSummary: `Applied ${ops.data.length} scene edit${ops.data.length === 1 ? "" : "s"}` }
156
+ }