@nodaro/shared 2.26.0 → 3.0.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/index.cjs +1920 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2594 -2
- package/dist/index.d.ts +2594 -2
- package/dist/index.js +1803 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/llm-models.test.ts +4 -2
- package/src/__tests__/node-preset-extract.test.ts +11 -0
- package/src/__tests__/scene3d-camera-track.test.ts +235 -0
- package/src/__tests__/scene3d-v2-edit.test.ts +83 -0
- package/src/__tests__/scene3d-v2-fixtures.ts +232 -0
- package/src/__tests__/scene3d-v2-resources.test.ts +239 -0
- package/src/__tests__/scene3d-v2.test.ts +742 -0
- package/src/__tests__/scene3d.test.ts +482 -0
- package/src/index.ts +12 -0
- package/src/llm-models.ts +10 -0
- package/src/model-constants.ts +5 -0
- package/src/node-mappable-fields.ts +2 -0
- package/src/node-preset-extract.ts +8 -2
- package/src/scene3d-camera-track.ts +369 -0
- package/src/scene3d-edit.ts +311 -0
- package/src/scene3d-v2-edit.ts +156 -0
- package/src/scene3d-v2-plan.ts +694 -0
- package/src/scene3d-v2-resources.ts +382 -0
- package/src/scene3d-v2.ts +666 -0
- package/src/scene3d.ts +641 -0
|
@@ -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
|
+
}
|