@hraness/slopcamera 3.2.8 → 3.3.2
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/README.md +29 -9
- package/apps/desktop/application/context.ts +6 -0
- package/apps/desktop/application/default-registry.ts +26 -0
- package/apps/desktop/application/operation.ts +14 -1
- package/apps/desktop/application/operations/index.ts +4 -0
- package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
- package/apps/desktop/application/operations/spatial-direction.ts +222 -0
- package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
- package/apps/desktop/application/operations/spatial-review.ts +194 -0
- package/apps/desktop/application/operations/spatial-scene.ts +25 -1
- package/apps/desktop/application/registry.ts +2 -2
- package/apps/desktop/application/spatial-asset-admission.ts +201 -0
- package/apps/desktop/application/spatial-assets.ts +160 -31
- package/apps/desktop/application/spatial-render.ts +217 -20
- package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
- package/apps/desktop/application/spatial-review.ts +361 -0
- package/apps/desktop/application/spatial-spz.ts +48 -3
- package/apps/desktop/application/spatial-world-import.ts +26 -5
- package/apps/desktop/application/spatial-world-metadata.ts +105 -0
- package/apps/desktop/cli/args.ts +514 -11
- package/apps/desktop/cli/capability-manifest.ts +374 -0
- package/apps/desktop/cli/cinema-renderer.ts +608 -0
- package/apps/desktop/cli/command-host-resources.ts +29 -1
- package/apps/desktop/cli/commands.ts +1188 -4
- package/apps/desktop/cli/gateway-review-provider.ts +334 -0
- package/apps/desktop/cli/help.ts +172 -6
- package/apps/desktop/cli/main.ts +3 -3
- package/apps/desktop/cli/portable-surface.ts +6 -1
- package/apps/desktop/cli/spatial-asset-service.ts +124 -0
- package/apps/desktop/cli/spatial-design-service.ts +237 -0
- package/apps/desktop/cli/spatial-generate-service.ts +370 -0
- package/apps/desktop/cli/spatial-scene-service.ts +271 -4
- package/apps/desktop/cli/workflow-code.ts +1 -1
- package/apps/desktop/code/semantic-builder.ts +24 -0
- package/apps/desktop/contracts/cinema.ts +736 -0
- package/apps/desktop/contracts/index.ts +1 -0
- package/apps/desktop/contracts/spatial-asset.ts +16 -0
- package/apps/desktop/contracts/spatial-world.ts +26 -3
- package/apps/desktop/core/cinema-plan.ts +1320 -0
- package/apps/desktop/core/index.ts +1 -0
- package/apps/desktop/core/storage.ts +75 -0
- package/apps/desktop/dist/cli/main.js +725 -273
- package/apps/desktop/html-overlay/spatial.ts +619 -57
- package/apps/desktop/workflows/cinematic-world.ts +127 -0
- package/apps/desktop/workflows/index.ts +6 -0
- package/dist/cli.js +25 -429
- package/dist/code/advanced.js +1 -269
- package/dist/code/index.js +16 -2239
- package/dist/generate.js +1 -23
- package/dist/host-resources.js +1 -25
- package/dist/index-0bbesn4m.js +35 -0
- package/dist/index-296qhpf2.js +3 -0
- package/dist/index-3cxf2c58.js +3 -0
- package/dist/index-77fjfg9f.js +3 -0
- package/dist/index-80a36bc0.js +3 -0
- package/dist/index-a3vvs3rf.js +3 -0
- package/dist/index-b3geqczn.js +5 -0
- package/dist/index-grk47tgs.js +3 -0
- package/dist/index-gs48afpt.js +7 -0
- package/dist/index-qry58nj2.js +10 -0
- package/dist/index-zhg36tf9.js +3 -0
- package/dist/index.js +1 -226
- package/dist/operations.js +1 -28
- package/dist/skill-install-gx7afxsd.js +2 -0
- package/dist/vectorize/worker.js +1 -141
- package/dist/workflow.js +1 -15
- package/docs/README.md +5 -1
- package/docs/studio.md +12 -0
- package/package.json +7 -5
- package/skills/slopcamera/SKILL.md +7 -1
- package/skills/slopcamera/references/directed-scenes.md +48 -2
- package/skills/slopcamera/references/gateway-media.md +15 -1
- package/skills/slopcamera/references/image-galleries.md +166 -0
- package/skills/slopcamera/references/install.md +1 -1
- package/skills/slopcamera/references/music-video.md +4 -0
- package/skills/slopcamera/references/native-studio.md +19 -0
- package/skills/slopcamera/references/parametric-design.md +14 -0
- package/skills/slopcamera/references/scene-building.md +172 -0
- package/skills/slopcamera/references/social-collage-banners.md +204 -0
- package/skills/slopcamera/references/workflows-sdk.md +3 -3
- package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
- package/src/capability-manifest.ts +361 -0
- package/src/cli.ts +222 -4
- package/src/code/canonical-json.ts +9 -0
- package/src/generate.ts +2 -2
- package/src/icon.ts +1111 -0
- package/src/image-gallery.ts +1049 -0
- package/src/index.ts +11 -0
- package/src/mcp/boundary.ts +11 -11
- package/src/mcp/index.ts +3 -0
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools.ts +1649 -132
- package/src/mcp/types.ts +19 -0
- package/src/operations.ts +412 -0
- package/src/portable-capability-manifest.ts +67 -0
- package/src/scene-gallery.ts +184 -0
- package/src/spatial-scene/asset-admission.ts +183 -0
- package/src/spatial-scene/audit-rendered.ts +692 -0
- package/src/spatial-scene/audit.ts +617 -0
- package/src/spatial-scene/behavior-audit.ts +252 -0
- package/src/spatial-scene/behavior-bake.ts +232 -0
- package/src/spatial-scene/behavior-fns.ts +585 -0
- package/src/spatial-scene/behavior-gallery.ts +108 -0
- package/src/spatial-scene/behavior-stdlib.ts +323 -0
- package/src/spatial-scene/behavior-trace.ts +325 -0
- package/src/spatial-scene/behavior.ts +617 -0
- package/src/spatial-scene/build.ts +632 -0
- package/src/spatial-scene/camera-rig.ts +125 -0
- package/src/spatial-scene/camera-track.ts +4 -2
- package/src/spatial-scene/character.ts +124 -0
- package/src/spatial-scene/contracts.ts +75 -7
- package/src/spatial-scene/design-templates.ts +351 -0
- package/src/spatial-scene/design.ts +327 -0
- package/src/spatial-scene/direction-compile.ts +584 -0
- package/src/spatial-scene/direction.ts +122 -0
- package/src/spatial-scene/effects.ts +149 -0
- package/src/spatial-scene/evaluate.ts +109 -29
- package/src/spatial-scene/gallery.ts +277 -0
- package/src/spatial-scene/generate.ts +239 -0
- package/src/spatial-scene/geometry-native.ts +165 -0
- package/src/spatial-scene/geometry.ts +1216 -0
- package/src/spatial-scene/gltf.ts +600 -77
- package/src/spatial-scene/identity.ts +55 -2
- package/src/spatial-scene/index.ts +36 -0
- package/src/spatial-scene/inspect.ts +35 -6
- package/src/spatial-scene/material-lighting.ts +609 -0
- package/src/spatial-scene/math.ts +21 -4
- package/src/spatial-scene/motion-evidence.ts +88 -0
- package/src/spatial-scene/parametric.ts +765 -0
- package/src/spatial-scene/particle-preparation.ts +183 -0
- package/src/spatial-scene/particle.ts +195 -0
- package/src/spatial-scene/patch.ts +109 -15
- package/src/spatial-scene/performance.ts +1613 -0
- package/src/spatial-scene/probe.ts +137 -0
- package/src/spatial-scene/recipe-pack.ts +86 -0
- package/src/spatial-scene/render-effects.ts +240 -0
- package/src/spatial-scene/review.ts +639 -0
- package/src/spatial-scene/simulation-bake.ts +206 -0
- package/src/spatial-scene/simulation.ts +187 -0
- package/src/spatial-scene/solve.ts +366 -0
- package/src/spatial-scene/temporal-audit.ts +354 -0
- package/src/version.ts +1 -1
- package/dist/index-42zsesc1.js +0 -2257
- package/dist/index-7131pg1c.js +0 -1820
- package/dist/index-7308egqr.js +0 -48
- package/dist/index-8txs6fkn.js +0 -712
- package/dist/index-fava6pge.js +0 -192
- package/dist/index-h1k0fnjq.js +0 -2363
- package/dist/index-r7gdhmsp.js +0 -424
- package/dist/index-sh6xbav6.js +0 -1219
- package/dist/index-z1w83f81.js +0 -4
- package/dist/index-zfnddgay.js +0 -2029
- package/dist/skill-install-dh0ntqba.js +0 -10
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import { canonicalJson } from "../code/canonical-json.js"
|
|
3
|
+
import { deepFreezeJson } from "../code/json-snapshot.js"
|
|
4
|
+
import { createSha256HexHasher } from "../code/sha256.js"
|
|
5
|
+
import {
|
|
6
|
+
SpatialAssetManifestSchema,
|
|
7
|
+
SpatialEntitySchema,
|
|
8
|
+
SpatialGeneratorIdSchema,
|
|
9
|
+
SpatialMaterialSchema,
|
|
10
|
+
SpatialTransformSchema,
|
|
11
|
+
type SpatialAssetManifest,
|
|
12
|
+
type SpatialEntity,
|
|
13
|
+
type SpatialGenerator,
|
|
14
|
+
type SpatialMaterial,
|
|
15
|
+
type SpatialSceneV1,
|
|
16
|
+
type SpatialTransform,
|
|
17
|
+
} from "./contracts.js"
|
|
18
|
+
import { SpatialAssetFactsV1Schema, SpatialCollisionProxySchema, type SpatialAssetFactsV1, type SpatialCollisionProxy, type SpatialRetainedArtifact } from "./asset-admission.js"
|
|
19
|
+
import { mulberry32 } from "./build.js"
|
|
20
|
+
import {
|
|
21
|
+
SPATIAL_GEOMETRY_PROFILE, SpatialGeometryGraphSchema, emitSpatialGeometryGlb, evaluateSpatialGeometry, estimateSpatialGeometryGraph, parseSpatialGeometryGraph,
|
|
22
|
+
type SpatialGeometryMesh,
|
|
23
|
+
} from "./geometry.js"
|
|
24
|
+
import {
|
|
25
|
+
validateSpatialGeometryNativeReceipt, verifySpatialGeometryNativeOutputs,
|
|
26
|
+
spatialGeometryNativeRequestSha256, spatialGeometryNativeReceiptSha256,
|
|
27
|
+
} from "./geometry-native.js"
|
|
28
|
+
import {
|
|
29
|
+
generatedSpatialEntityId, parseSpatialScene, parseSpatialValue, SpatialSceneError,
|
|
30
|
+
spatialAssetManifestSha256, spatialValueSha256,
|
|
31
|
+
} from "./identity.js"
|
|
32
|
+
import {
|
|
33
|
+
buildSpatialGeneratorRecord, deriveSpatialGeneratorSeed, mergeSpatialGeneratorOutput,
|
|
34
|
+
spatialGeneratorParametersSha256,
|
|
35
|
+
} from "./generate.js"
|
|
36
|
+
import { evaluateSpatialScene } from "./evaluate.js"
|
|
37
|
+
import { transformBounds, type Bounds, type Mat4 } from "./math.js"
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Bounded parametric architecture generators. Each kind compiles authored
|
|
41
|
+
* semantic parameters (width, archCount, bevelRadius, seed, …) into the
|
|
42
|
+
* geometry DAG of geometry.ts, evaluates it deterministically, emits a closed
|
|
43
|
+
* GLB payload per LOD level, stamps stable generated entity ids and generator
|
|
44
|
+
* keys, and binds everything into the same retained-asset contract as admitted
|
|
45
|
+
* GLBs: owned asset manifests, derived facts manifests (generator identity,
|
|
46
|
+
* spec/parameter digests, LOD set, collision proxies, retained artifacts) and a
|
|
47
|
+
* content-addressed emission receipt. Identical specs produce byte-identical
|
|
48
|
+
* payloads and receipts; scatter uses only authored seeds through the
|
|
49
|
+
* checked-in mulberry32 stream. Nothing here executes source, reads files, or
|
|
50
|
+
* touches a provider.
|
|
51
|
+
*/
|
|
52
|
+
export const SPATIAL_PARAMETRIC_LIMITS = Object.freeze({
|
|
53
|
+
openings: 8,
|
|
54
|
+
pathPoints: 64,
|
|
55
|
+
scatterCount: 1_024,
|
|
56
|
+
exclusions: 64,
|
|
57
|
+
panes: 8,
|
|
58
|
+
risers: 200,
|
|
59
|
+
archCount: 64,
|
|
60
|
+
parts: 64,
|
|
61
|
+
scatterAttempts: 128,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const meter = z.number().finite().min(0.000001).max(1_000_000)
|
|
65
|
+
const coordinate = z.number().finite().min(-1_000_000).max(1_000_000)
|
|
66
|
+
const specVec2 = z.tuple([coordinate, coordinate])
|
|
67
|
+
const specVec3 = z.tuple([coordinate, coordinate, coordinate])
|
|
68
|
+
const editable = z.array(z.enum(["color", "opacity", "transform"])).max(3).optional()
|
|
69
|
+
const partPlacement = { transform: SpatialTransformSchema.optional(), editable, castShadow: z.boolean().optional(), receiveShadow: z.boolean().optional() }
|
|
70
|
+
|
|
71
|
+
const opening = z.discriminatedUnion("kind", [
|
|
72
|
+
z.strictObject({ kind: z.literal("rect"), center: specVec2, width: meter, height: meter }),
|
|
73
|
+
z.strictObject({ kind: z.literal("arch"), center: specVec2, width: meter, height: meter }),
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
export const SpatialParametricSpecSchema = z.discriminatedUnion("kind", [
|
|
77
|
+
z.strictObject({ ...partPlacement, kind: z.literal("geometry"), graph: SpatialGeometryGraphSchema,
|
|
78
|
+
materials: z.array(SpatialMaterialSchema).min(1).max(16) }),
|
|
79
|
+
z.strictObject({ ...partPlacement, kind: z.literal("wall"), length: meter, height: meter, thickness: meter,
|
|
80
|
+
openings: z.array(opening).max(SPATIAL_PARAMETRIC_LIMITS.openings).optional(), material: SpatialMaterialSchema }),
|
|
81
|
+
z.strictObject({ ...partPlacement, kind: z.literal("floor"), width: meter, depth: meter, thickness: meter, material: SpatialMaterialSchema }),
|
|
82
|
+
z.strictObject({ ...partPlacement, kind: z.literal("stairs"), width: meter, risers: z.number().int().min(2).max(SPATIAL_PARAMETRIC_LIMITS.risers),
|
|
83
|
+
riserHeight: meter, treadDepth: meter, material: SpatialMaterialSchema }),
|
|
84
|
+
z.strictObject({ ...partPlacement, kind: z.literal("arch"), width: meter, height: meter, springline: meter, depth: meter,
|
|
85
|
+
count: z.number().int().min(1).max(SPATIAL_PARAMETRIC_LIMITS.archCount).optional(), spacing: meter.optional(), material: SpatialMaterialSchema }),
|
|
86
|
+
z.strictObject({ ...partPlacement, kind: z.literal("column"), height: meter, radius: meter,
|
|
87
|
+
taper: z.number().finite().min(0.25).max(1).optional(), capital: z.enum(["none", "doric"]).optional(),
|
|
88
|
+
segments: z.number().int().min(8).max(128).optional(), material: SpatialMaterialSchema }),
|
|
89
|
+
z.strictObject({ ...partPlacement, kind: z.literal("window"), width: meter, height: meter, frameWidth: meter, depth: meter,
|
|
90
|
+
panesX: z.number().int().min(1).max(SPATIAL_PARAMETRIC_LIMITS.panes).optional(), panesY: z.number().int().min(1).max(SPATIAL_PARAMETRIC_LIMITS.panes).optional(),
|
|
91
|
+
sill: z.boolean().optional(), material: SpatialMaterialSchema, glassMaterial: SpatialMaterialSchema.optional() }),
|
|
92
|
+
z.strictObject({ ...partPlacement, kind: z.literal("roof"), style: z.enum(["gable", "hip", "shed"]), width: meter, depth: meter,
|
|
93
|
+
rise: meter, overhang: meter.optional(), material: SpatialMaterialSchema }),
|
|
94
|
+
z.strictObject({ ...partPlacement, kind: z.literal("pipe"), radius: meter, segments: z.number().int().min(4).max(64).optional(),
|
|
95
|
+
path: z.array(specVec3).min(2).max(SPATIAL_PARAMETRIC_LIMITS.pathPoints), material: SpatialMaterialSchema }),
|
|
96
|
+
z.strictObject({ ...partPlacement, kind: z.literal("trim"), length: meter, size: meter, profile: z.enum(["square", "cove", "chamfer"]),
|
|
97
|
+
material: SpatialMaterialSchema }),
|
|
98
|
+
z.strictObject({ ...partPlacement, kind: z.literal("scatter"), count: z.number().int().min(1).max(SPATIAL_PARAMETRIC_LIMITS.scatterCount),
|
|
99
|
+
area: z.strictObject({ width: meter, depth: meter }), seed: z.number().int().safe().min(0).max(0xffff_ffff),
|
|
100
|
+
subject: z.discriminatedUnion("shape", [
|
|
101
|
+
z.strictObject({ shape: z.literal("box"), size: z.tuple([meter, meter, meter]) }),
|
|
102
|
+
z.strictObject({ shape: z.literal("cylinder"), radius: meter, height: meter, segments: z.number().int().min(8).max(64).optional() }),
|
|
103
|
+
z.strictObject({ shape: z.literal("sphere"), radius: meter, segments: z.number().int().min(8).max(64).optional() }),
|
|
104
|
+
]),
|
|
105
|
+
exclusions: z.array(z.strictObject({ center: specVec2, halfExtents: specVec2 })).max(SPATIAL_PARAMETRIC_LIMITS.exclusions).optional(),
|
|
106
|
+
material: SpatialMaterialSchema }),
|
|
107
|
+
])
|
|
108
|
+
export type SpatialParametricSpec = Readonly<z.infer<typeof SpatialParametricSpecSchema>>
|
|
109
|
+
|
|
110
|
+
export const SpatialParametricRequestSchema = z.strictObject({
|
|
111
|
+
kind: z.literal("slopcamera.spatial-parametric-request"),
|
|
112
|
+
schemaVersion: z.literal(1),
|
|
113
|
+
generatorId: SpatialGeneratorIdSchema,
|
|
114
|
+
/** Semantic display name; generated identity and payload bytes remain independent of labels. */
|
|
115
|
+
name: z.string().min(1).max(160).optional(),
|
|
116
|
+
spec: SpatialParametricSpecSchema,
|
|
117
|
+
/** Authored seed override; absent derives deterministically from the spec kind source. */
|
|
118
|
+
seed: z.number().int().safe().min(0).max(0xffff_ffff).optional(),
|
|
119
|
+
/** Collision proxies override the per-part automatic proxies (validated against retained artifacts). */
|
|
120
|
+
collision: z.array(SpatialCollisionProxySchema).max(16).optional(),
|
|
121
|
+
/** Optional verified retained-native exchange bound into every part's facts. */
|
|
122
|
+
native: z.strictObject({ request: z.unknown(), receipt: z.unknown(), outputs: z.array(z.instanceof(Uint8Array)).min(1).max(16) }).optional(),
|
|
123
|
+
})
|
|
124
|
+
export type SpatialParametricRequest = Readonly<z.infer<typeof SpatialParametricRequestSchema>>
|
|
125
|
+
|
|
126
|
+
const IDENTITY_TRANSFORM: SpatialTransform = { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] }
|
|
127
|
+
|
|
128
|
+
function pfail(message: string, path = "parametric"): never {
|
|
129
|
+
throw new SpatialSceneError("invalid-data", `${SPATIAL_GEOMETRY_PROFILE}: ${message}`, path)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Part planning: one part per spec (multi-slot parts share one GLB)
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
interface PartLod { readonly level: number; readonly switchDistanceM: number; readonly graph: unknown }
|
|
137
|
+
interface PartPlan {
|
|
138
|
+
readonly key: string
|
|
139
|
+
readonly transform: SpatialTransform
|
|
140
|
+
readonly editable: readonly ("color" | "opacity" | "transform")[]
|
|
141
|
+
readonly materials: readonly SpatialMaterial[]
|
|
142
|
+
readonly collision: readonly SpatialCollisionProxy[]
|
|
143
|
+
readonly lods: readonly PartLod[]
|
|
144
|
+
readonly instances?: readonly SpatialTransform[]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const graph = (nodes: readonly unknown[], output: string) => ({ kind: "slopcamera.spatial-geometry-graph", schemaVersion: 1, nodes, output })
|
|
148
|
+
|
|
149
|
+
function boxBounds(bounds: Bounds): SpatialCollisionProxy {
|
|
150
|
+
return {
|
|
151
|
+
kind: "box",
|
|
152
|
+
center: [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2, (bounds.min[2] + bounds.max[2]) / 2],
|
|
153
|
+
halfExtents: [Math.max(0.0005, (bounds.max[0] - bounds.min[0]) / 2), Math.max(0.0005, (bounds.max[1] - bounds.min[1]) / 2), Math.max(0.0005, (bounds.max[2] - bounds.min[2]) / 2)],
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const LOD1_DISTANCE_M = 25
|
|
158
|
+
|
|
159
|
+
function planWall(spec: Extract<SpatialParametricSpec, { kind: "wall" }>): PartPlan[] {
|
|
160
|
+
const { length, height, thickness } = spec
|
|
161
|
+
const openings = spec.openings ?? []
|
|
162
|
+
const cutterNodes: unknown[] = [], cutterIds: string[] = []
|
|
163
|
+
for (const [index, item] of openings.entries()) {
|
|
164
|
+
const cx = item.center[0], baseY = item.center[1]
|
|
165
|
+
if (cx - item.width / 2 < -length / 2 || cx + item.width / 2 > length / 2 || baseY < 0 || baseY + item.height > height) {
|
|
166
|
+
pfail(`Wall opening ${index} must stay inside the wall rectangle.`, "spec.openings")
|
|
167
|
+
}
|
|
168
|
+
const rectId = `cut${index}`
|
|
169
|
+
cutterNodes.push({ id: rectId, kind: "box", size: [item.width, item.height, thickness * 4] })
|
|
170
|
+
cutterNodes.push({ id: `${rectId}p`, kind: "transform", input: rectId, transform: { position: [cx, baseY + item.height / 2, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } })
|
|
171
|
+
cutterIds.push(`${rectId}p`)
|
|
172
|
+
if (item.kind === "arch") {
|
|
173
|
+
const radius = item.width / 2
|
|
174
|
+
if (radius > item.height) pfail(`Arch opening ${index} rise exceeds its height.`, "spec.openings")
|
|
175
|
+
const cylId = `cyl${index}`
|
|
176
|
+
// Half-cylinder lintel: cylinder rotated so its axis runs through the wall thickness.
|
|
177
|
+
cutterNodes.push({ id: cylId, kind: "cylinder", radius, height: thickness * 4, segments: 24 })
|
|
178
|
+
cutterNodes.push({ id: `${cylId}r`, kind: "transform", input: cylId, transform: { position: [cx, baseY + item.height - radius, 0], rotation: [Math.SQRT1_2, 0, 0, Math.SQRT1_2], scale: [1, 1, 1] } })
|
|
179
|
+
cutterIds.push(`${cylId}r`)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const nodes: unknown[] = [{ id: "body", kind: "box", size: [length, height, thickness] }]
|
|
183
|
+
let output = "body"
|
|
184
|
+
if (cutterIds.length > 0) {
|
|
185
|
+
nodes.push(...cutterNodes)
|
|
186
|
+
nodes.push({ id: "wall", kind: "boolean", operation: "difference", a: "body", cutters: cutterIds })
|
|
187
|
+
output = "wall"
|
|
188
|
+
}
|
|
189
|
+
// Base at y=0: lift the centered box by half the height.
|
|
190
|
+
nodes.push({ id: "lift", kind: "transform", input: output, transform: { position: [0, height / 2, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } })
|
|
191
|
+
const lod1 = graph([
|
|
192
|
+
{ id: "proxy", kind: "box", size: [length, height, thickness] },
|
|
193
|
+
{ id: "lift", kind: "transform", input: "proxy", transform: { position: [0, height / 2, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } },
|
|
194
|
+
], "lift")
|
|
195
|
+
return [{
|
|
196
|
+
key: "wall", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
|
|
197
|
+
materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "lift") }, { level: 1, switchDistanceM: LOD1_DISTANCE_M, graph: lod1 }],
|
|
198
|
+
collision: [],
|
|
199
|
+
}]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function planFloor(spec: Extract<SpatialParametricSpec, { kind: "floor" }>): PartPlan[] {
|
|
203
|
+
const nodes = [
|
|
204
|
+
{ id: "slab", kind: "box", size: [spec.width, spec.thickness, spec.depth] },
|
|
205
|
+
{ id: "drop", kind: "transform", input: "slab", transform: { position: [0, -spec.thickness / 2, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } },
|
|
206
|
+
]
|
|
207
|
+
return [{
|
|
208
|
+
key: "floor", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
|
|
209
|
+
materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "drop") }],
|
|
210
|
+
collision: [],
|
|
211
|
+
}]
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function planStairs(spec: Extract<SpatialParametricSpec, { kind: "stairs" }>): PartPlan[] {
|
|
215
|
+
const points: [number, number][] = [[0, 0]]
|
|
216
|
+
let x = 0, y = 0
|
|
217
|
+
for (let step = 0; step < spec.risers; step++) {
|
|
218
|
+
y += spec.riserHeight; points.push([x, y])
|
|
219
|
+
x += spec.treadDepth; points.push([x, y])
|
|
220
|
+
}
|
|
221
|
+
points.push([x, 0])
|
|
222
|
+
const run = spec.risers * spec.treadDepth, rise = spec.risers * spec.riserHeight
|
|
223
|
+
const nodes = [
|
|
224
|
+
{ id: "flight", kind: "profile", points },
|
|
225
|
+
{ id: "mesh", kind: "extrude", profile: "flight", depth: spec.width },
|
|
226
|
+
// Recenter the extrusion (depth axis is centered already) so the run starts at x=0 stays centered.
|
|
227
|
+
{ id: "shift", kind: "transform", input: "mesh", transform: { position: [-run / 2, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } },
|
|
228
|
+
]
|
|
229
|
+
const lod1 = graph([
|
|
230
|
+
{ id: "proxy", kind: "box", size: [run, rise, spec.width] },
|
|
231
|
+
{ id: "lift", kind: "transform", input: "proxy", transform: { position: [0, rise / 2, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } },
|
|
232
|
+
], "lift")
|
|
233
|
+
return [{
|
|
234
|
+
key: "stairs", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
|
|
235
|
+
materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "shift") }, { level: 1, switchDistanceM: LOD1_DISTANCE_M, graph: lod1 }],
|
|
236
|
+
collision: [],
|
|
237
|
+
}]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function archProfile(width: number, height: number, springline: number, leg: number, arcSegments: number): [number, number][] {
|
|
241
|
+
const half = width / 2, outer = half + leg
|
|
242
|
+
const points: [number, number][] = [[-outer, 0], [-outer, height], [outer, height], [outer, 0], [half, 0], [half, springline]]
|
|
243
|
+
for (let index = 1; index <= arcSegments; index++) {
|
|
244
|
+
const angle = Math.PI * index / arcSegments
|
|
245
|
+
points.push([half * Math.cos(angle), springline + half * Math.sin(angle)])
|
|
246
|
+
}
|
|
247
|
+
points.push([-half, 0])
|
|
248
|
+
return points
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function planArch(spec: Extract<SpatialParametricSpec, { kind: "arch" }>): PartPlan[] {
|
|
252
|
+
const count = spec.count ?? 1, spacing = spec.spacing ?? spec.width
|
|
253
|
+
if (spec.springline >= spec.height) pfail("Arch springline must stay below its apex height.", "spec.springline")
|
|
254
|
+
// Equality pinches the polygon at the crown and creates a self-intersection.
|
|
255
|
+
if (spec.height - spec.springline <= spec.width / 2 + 1e-9) {
|
|
256
|
+
pfail("Arch apex height must leave positive material above the opening radius.", "spec.height")
|
|
257
|
+
}
|
|
258
|
+
const leg = Math.min(spec.width * 0.25, spec.depth)
|
|
259
|
+
const points = archProfile(spec.width, spec.height, spec.springline, leg, 16)
|
|
260
|
+
const nodes: unknown[] = [
|
|
261
|
+
{ id: "profile", kind: "profile", points },
|
|
262
|
+
{ id: "one", kind: "extrude", profile: "profile", depth: spec.depth },
|
|
263
|
+
]
|
|
264
|
+
let output = "one"
|
|
265
|
+
const span = spec.width + 2 * leg
|
|
266
|
+
if (count > 1) nodes.push({ id: "row", kind: "array", input: "one", count, step: [span + spacing, 0, 0] })
|
|
267
|
+
if (count > 1) output = "row"
|
|
268
|
+
nodes.push({ id: "center", kind: "transform", input: output, transform: { position: [-((count - 1) * (span + spacing)) / 2, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } })
|
|
269
|
+
const lod1 = graph([
|
|
270
|
+
{ id: "proxy", kind: "box", size: [span * count + spacing * (count - 1), spec.height, spec.depth] },
|
|
271
|
+
{ id: "lift", kind: "transform", input: "proxy", transform: { position: [0, spec.height / 2, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } },
|
|
272
|
+
], "lift")
|
|
273
|
+
return [{
|
|
274
|
+
key: "arch", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
|
|
275
|
+
materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "center") }, { level: 1, switchDistanceM: LOD1_DISTANCE_M, graph: lod1 }],
|
|
276
|
+
collision: [],
|
|
277
|
+
}]
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function planColumn(spec: Extract<SpatialParametricSpec, { kind: "column" }>): PartPlan[] {
|
|
281
|
+
const segments = spec.segments ?? 32, taper = spec.taper ?? 0.85, capital = spec.capital ?? "doric"
|
|
282
|
+
const r = spec.radius, h = spec.height
|
|
283
|
+
const profile: [number, number][] = capital === "doric"
|
|
284
|
+
? [[r * 1.15, 0], [r * 1.15, h * 0.05], [r, h * 0.08], [r * taper, h * 0.9], [r * taper, h * 0.94], [r * 1.18, h * 0.97], [r * 1.18, h], [0, h], [0, 0]]
|
|
285
|
+
: [[r * 1.1, 0], [r * 1.1, h * 0.05], [r, h * 0.08], [r * taper, h * 0.96], [r * taper, h], [0, h], [0, 0]]
|
|
286
|
+
const nodes = [
|
|
287
|
+
{ id: "silhouette", kind: "profile", points: profile },
|
|
288
|
+
{ id: "shaft", kind: "revolve", profile: "silhouette", segments },
|
|
289
|
+
]
|
|
290
|
+
const lod1Profile: [number, number][] = [[r * 1.18, 0], [r * 1.18, h], [0, h], [0, 0]]
|
|
291
|
+
const lod1 = graph([
|
|
292
|
+
{ id: "silhouette", kind: "profile", points: lod1Profile },
|
|
293
|
+
{ id: "shaft", kind: "revolve", profile: "silhouette", segments: Math.max(8, Math.floor(segments / 4)) },
|
|
294
|
+
], "shaft")
|
|
295
|
+
return [{
|
|
296
|
+
key: "column", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
|
|
297
|
+
materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "shaft") }, { level: 1, switchDistanceM: 20, graph: lod1 }],
|
|
298
|
+
collision: [],
|
|
299
|
+
}]
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function planWindow(spec: Extract<SpatialParametricSpec, { kind: "window" }>): PartPlan[] {
|
|
303
|
+
const { width, height, frameWidth, depth } = spec
|
|
304
|
+
const panesX = spec.panesX ?? 2, panesY = spec.panesY ?? 1
|
|
305
|
+
const nodes: unknown[] = []
|
|
306
|
+
const box = (id: string, size: [number, number, number], position: [number, number, number]) => {
|
|
307
|
+
nodes.push({ id, kind: "box", size })
|
|
308
|
+
nodes.push({ id: `${id}t`, kind: "transform", input: id, transform: { position, rotation: [0, 0, 0, 1], scale: [1, 1, 1] } })
|
|
309
|
+
return `${id}t`
|
|
310
|
+
}
|
|
311
|
+
const hw = width / 2, hh = height / 2, fw = frameWidth / 2
|
|
312
|
+
const frame = [
|
|
313
|
+
box("top", [width, frameWidth, depth], [0, hh - fw, 0]),
|
|
314
|
+
box("bottom", [width, frameWidth, depth], [0, -hh + fw, 0]),
|
|
315
|
+
box("left", [frameWidth, height - 2 * frameWidth, depth], [-hw + fw, 0, 0]),
|
|
316
|
+
box("right", [frameWidth, height - 2 * frameWidth, depth], [hw - fw, 0, 0]),
|
|
317
|
+
]
|
|
318
|
+
if (spec.sill) frame.push(box("sill", [width + 2 * frameWidth, frameWidth, depth * 1.5], [0, -hh - frameWidth / 2, 0]))
|
|
319
|
+
const bars: string[] = []
|
|
320
|
+
for (let index = 1; index < panesX; index++) bars.push(box(`vx${index}`, [frameWidth * 0.5, height - 2 * frameWidth, depth * 0.5], [-hw + frameWidth + index * (width - 2 * frameWidth) / panesX, 0, 0]))
|
|
321
|
+
for (let index = 1; index < panesY; index++) bars.push(box(`hy${index}`, [width - 2 * frameWidth, frameWidth * 0.5, depth * 0.5], [0, -hh + frameWidth + index * (height - 2 * frameWidth) / panesY, 0]))
|
|
322
|
+
nodes.push({ id: "frame", kind: "merge", inputs: [...frame, ...bars] })
|
|
323
|
+
nodes.push({ id: "glass", kind: "box", size: [width - 2 * frameWidth, height - 2 * frameWidth, depth * 0.15] })
|
|
324
|
+
nodes.push({ id: "slot1", kind: "material-slot", input: "glass", slot: 1 })
|
|
325
|
+
nodes.push({ id: "all", kind: "merge", inputs: ["frame", "slot1"] })
|
|
326
|
+
nodes.push({ id: "lift", kind: "transform", input: "all", transform: { position: [0, hh, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } })
|
|
327
|
+
const lod1 = graph([
|
|
328
|
+
{ id: "proxy", kind: "box", size: [width, height + (spec.sill ? frameWidth : 0), depth * (spec.sill ? 1.5 : 1)] },
|
|
329
|
+
{ id: "lift", kind: "transform", input: "proxy", transform: { position: [0, (height + (spec.sill ? frameWidth : 0)) / 2 - (spec.sill ? frameWidth : 0), 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } },
|
|
330
|
+
], "lift")
|
|
331
|
+
const glass = spec.glassMaterial ?? { kind: "standard" as const, color: "#a8c8e0", opacity: 0.4, roughness: 0.1, metalness: 0 }
|
|
332
|
+
return [{
|
|
333
|
+
key: "window", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
|
|
334
|
+
materials: [spec.material, glass], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "lift") }, { level: 1, switchDistanceM: LOD1_DISTANCE_M, graph: lod1 }],
|
|
335
|
+
collision: [],
|
|
336
|
+
}]
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function planRoof(spec: Extract<SpatialParametricSpec, { kind: "roof" }>): PartPlan[] {
|
|
340
|
+
const { width, depth, rise } = spec, overhang = spec.overhang ?? 0
|
|
341
|
+
const w = width + 2 * overhang, d = depth + 2 * overhang
|
|
342
|
+
let nodes: unknown[]
|
|
343
|
+
if (spec.style === "gable") {
|
|
344
|
+
nodes = [
|
|
345
|
+
{ id: "face", kind: "profile", points: [[-w / 2, 0], [w / 2, 0], [0, rise]] },
|
|
346
|
+
{ id: "prism", kind: "extrude", profile: "face", depth: d },
|
|
347
|
+
]
|
|
348
|
+
} else if (spec.style === "shed") {
|
|
349
|
+
nodes = [
|
|
350
|
+
{ id: "face", kind: "profile", points: [[-w / 2, 0], [w / 2, 0], [w / 2, rise]] },
|
|
351
|
+
{ id: "prism", kind: "extrude", profile: "face", depth: d },
|
|
352
|
+
]
|
|
353
|
+
} else {
|
|
354
|
+
const ridge = Math.max(0.02, Math.abs(w - d))
|
|
355
|
+
const alongX = w >= d
|
|
356
|
+
nodes = [
|
|
357
|
+
{ id: "eave", kind: "rect", width: w, height: d },
|
|
358
|
+
{ id: "ridge", kind: "rect", width: alongX ? ridge : 0.02, height: alongX ? 0.02 : ridge },
|
|
359
|
+
{ id: "hip", kind: "loft", bottom: "eave", top: "ridge", height: rise },
|
|
360
|
+
]
|
|
361
|
+
}
|
|
362
|
+
const lod1 = graph([
|
|
363
|
+
{ id: "proxy", kind: "box", size: [w, rise, d] },
|
|
364
|
+
{ id: "lift", kind: "transform", input: "proxy", transform: { position: [0, rise / 2, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } },
|
|
365
|
+
], "lift")
|
|
366
|
+
return [{
|
|
367
|
+
key: "roof", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
|
|
368
|
+
materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, spec.style === "hip" ? "hip" : "prism") }, { level: 1, switchDistanceM: LOD1_DISTANCE_M, graph: lod1 }],
|
|
369
|
+
collision: [],
|
|
370
|
+
}]
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function planPipe(spec: Extract<SpatialParametricSpec, { kind: "pipe" }>): PartPlan[] {
|
|
374
|
+
const segments = spec.segments ?? 12
|
|
375
|
+
const nodes = [
|
|
376
|
+
{ id: "section", kind: "ellipse", radiusX: spec.radius, radiusY: spec.radius, segments },
|
|
377
|
+
{ id: "run", kind: "sweep", profile: "section", path: spec.path },
|
|
378
|
+
]
|
|
379
|
+
const lod1 = graph([
|
|
380
|
+
{ id: "section", kind: "ellipse", radiusX: spec.radius, radiusY: spec.radius, segments: Math.max(4, Math.floor(segments / 4)) },
|
|
381
|
+
{ id: "run", kind: "sweep", profile: "section", path: spec.path },
|
|
382
|
+
], "run")
|
|
383
|
+
const collision: SpatialCollisionProxy[] = []
|
|
384
|
+
const path = spec.path
|
|
385
|
+
for (let index = 0; index + 1 < path.length && collision.length < 16; index++) {
|
|
386
|
+
const a = path[index]!, c = path[index + 1]!
|
|
387
|
+
const delta = [c[0] - a[0], c[1] - a[1], c[2] - a[2]] as const
|
|
388
|
+
const length = Math.hypot(delta[0], delta[1], delta[2])
|
|
389
|
+
if (length < 1e-9) continue
|
|
390
|
+
const axisIndex = [Math.abs(delta[0]), Math.abs(delta[1]), Math.abs(delta[2])].reduce((best, value, axis) => value > Math.abs(delta[best]!) ? axis : best, 0)
|
|
391
|
+
collision.push({
|
|
392
|
+
kind: "capsule",
|
|
393
|
+
center: [(a[0] + c[0]) / 2, (a[1] + c[1]) / 2, (a[2] + c[2]) / 2],
|
|
394
|
+
axis: (["x", "y", "z"] as const)[axisIndex]!, radius: spec.radius,
|
|
395
|
+
halfLength: length / 2 + spec.radius,
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
return [{
|
|
399
|
+
key: "pipe", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
|
|
400
|
+
materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "run") }, { level: 1, switchDistanceM: 20, graph: lod1 }],
|
|
401
|
+
collision,
|
|
402
|
+
}]
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function planTrim(spec: Extract<SpatialParametricSpec, { kind: "trim" }>): PartPlan[] {
|
|
406
|
+
const s = spec.size
|
|
407
|
+
const points: [number, number][] = spec.profile === "chamfer"
|
|
408
|
+
? [[0, 0], [s, 0], [s, s - s * 0.3], [s - s * 0.3, s], [0, s]]
|
|
409
|
+
: spec.profile === "cove"
|
|
410
|
+
? [[0, 0], [s, 0], [s, s * 0.35], [s * 0.85, s * 0.5], [s * 0.6, s * 0.68], [s * 0.35, s], [0, s]]
|
|
411
|
+
: [[0, 0], [s, 0], [s, s], [0, s]]
|
|
412
|
+
const nodes = [
|
|
413
|
+
{ id: "section", kind: "profile", points },
|
|
414
|
+
{ id: "rail", kind: "extrude", profile: "section", depth: spec.length },
|
|
415
|
+
]
|
|
416
|
+
const lod1 = graph([
|
|
417
|
+
{ id: "proxy", kind: "box", size: [s, s, spec.length] },
|
|
418
|
+
{ id: "lift", kind: "transform", input: "proxy", transform: { position: [s / 2, s / 2, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] } },
|
|
419
|
+
], "lift")
|
|
420
|
+
return [{
|
|
421
|
+
key: "trim", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
|
|
422
|
+
materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "rail") }, { level: 1, switchDistanceM: LOD1_DISTANCE_M, graph: lod1 }],
|
|
423
|
+
collision: [],
|
|
424
|
+
}]
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function yawQuaternion(radians: number): readonly [number, number, number, number] {
|
|
428
|
+
return [0, Math.sin(radians / 2), 0, Math.cos(radians / 2)]
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function planScatter(spec: Extract<SpatialParametricSpec, { kind: "scatter" }>): PartPlan[] {
|
|
432
|
+
const subject = spec.subject
|
|
433
|
+
const subjectNode = subject.shape === "box"
|
|
434
|
+
? { id: "subject", kind: "box", size: subject.size }
|
|
435
|
+
: subject.shape === "cylinder"
|
|
436
|
+
? { id: "subject", kind: "cylinder", radius: subject.radius, height: subject.height, segments: subject.segments ?? 16 }
|
|
437
|
+
: { id: "subject", kind: "sphere", radius: subject.radius, segments: subject.segments ?? 16 }
|
|
438
|
+
const subjectBounds = subject.shape === "box"
|
|
439
|
+
? { x: subject.size[0] / 2, y: subject.size[1] / 2, z: subject.size[2] / 2, yBase: 0 }
|
|
440
|
+
: subject.shape === "cylinder"
|
|
441
|
+
? { x: subject.radius, y: subject.height / 2, z: subject.radius, yBase: 0 }
|
|
442
|
+
: { x: subject.radius, y: subject.radius, z: subject.radius, yBase: 0 }
|
|
443
|
+
const lod1Node = subject.shape === "box" ? subjectNode
|
|
444
|
+
: subject.shape === "cylinder" ? { id: "subject", kind: "cylinder", radius: subject.radius, height: subject.height, segments: 8 }
|
|
445
|
+
: { id: "subject", kind: "sphere", radius: subject.radius, segments: 8 }
|
|
446
|
+
const exclusions = spec.exclusions ?? []
|
|
447
|
+
const random = mulberry32(spec.seed)
|
|
448
|
+
const instances: SpatialTransform[] = []
|
|
449
|
+
const halfW = spec.area.width / 2, halfD = spec.area.depth / 2
|
|
450
|
+
let attempts = 0
|
|
451
|
+
while (instances.length < spec.count && ++attempts <= SPATIAL_PARAMETRIC_LIMITS.scatterAttempts * Math.min(spec.count, 64)) {
|
|
452
|
+
const x = (random() * 2 - 1) * Math.max(0, halfW - subjectBounds.x)
|
|
453
|
+
const z = (random() * 2 - 1) * Math.max(0, halfD - subjectBounds.z)
|
|
454
|
+
const blocked = exclusions.some(zone =>
|
|
455
|
+
x + subjectBounds.x > zone.center[0] - zone.halfExtents[0] && x - subjectBounds.x < zone.center[0] + zone.halfExtents[0]
|
|
456
|
+
&& z + subjectBounds.z > zone.center[1] - zone.halfExtents[1] && z - subjectBounds.z < zone.center[1] + zone.halfExtents[1])
|
|
457
|
+
if (blocked) continue
|
|
458
|
+
const yaw = random() * Math.PI * 2
|
|
459
|
+
const scale = 0.85 + random() * 0.3
|
|
460
|
+
instances.push({ position: [x, subjectBounds.yBase, z], rotation: [...yawQuaternion(yaw)] as [number, number, number, number], scale: [scale, scale, scale] })
|
|
461
|
+
}
|
|
462
|
+
if (instances.length < spec.count) {
|
|
463
|
+
pfail(`Scatter placed ${instances.length} of ${spec.count} subjects; relax exclusions or shrink the subject.`, "spec.exclusions")
|
|
464
|
+
}
|
|
465
|
+
const nodes = [subjectNode]
|
|
466
|
+
const lod1 = graph([lod1Node], "subject")
|
|
467
|
+
return [{
|
|
468
|
+
key: "scatter", transform: spec.transform ?? IDENTITY_TRANSFORM, editable: spec.editable ?? ["transform"],
|
|
469
|
+
materials: [spec.material], lods: [{ level: 0, switchDistanceM: 0, graph: graph(nodes, "subject") }, { level: 1, switchDistanceM: LOD1_DISTANCE_M, graph: lod1 }],
|
|
470
|
+
collision: [], instances,
|
|
471
|
+
}]
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const PLANNERS: { [K in SpatialParametricSpec["kind"]]: (spec: Extract<SpatialParametricSpec, { kind: K }>) => PartPlan[] } = {
|
|
475
|
+
geometry: spec => [{ key: "geometry", transform: spec.transform ?? IDENTITY_TRANSFORM,
|
|
476
|
+
editable: spec.editable ?? ["transform"], materials: spec.materials, collision: [],
|
|
477
|
+
lods: [{ level: 0, switchDistanceM: 0, graph: spec.graph }] }],
|
|
478
|
+
wall: planWall, floor: planFloor, stairs: planStairs, arch: planArch, column: planColumn,
|
|
479
|
+
window: planWindow, roof: planRoof, pipe: planPipe, trim: planTrim, scatter: planScatter,
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Retained GLB entities support the closed untextured material profile. */
|
|
483
|
+
function validatePartMaterials(plans: readonly PartPlan[]): void {
|
|
484
|
+
for (const part of plans) {
|
|
485
|
+
for (const material of part.materials) {
|
|
486
|
+
if (material.kind === "pbr" || material.map !== undefined) {
|
|
487
|
+
pfail("Retained parametric geometry requires untextured standard or unlit materials; PBR extension materials and texture maps are unsupported.", "spec.materials")
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
for (const lod of part.lods) {
|
|
491
|
+
const parsed = parseSpatialGeometryGraph(lod.graph)
|
|
492
|
+
for (const node of parsed.nodes) {
|
|
493
|
+
const slots = ["slot" in node ? node.slot : undefined, "sideSlot" in node ? node.sideSlot : undefined, "capSlot" in node ? node.capSlot : undefined]
|
|
494
|
+
if (slots.some(slot => slot !== undefined && slot >= part.materials.length)) pfail("Geometry material slot has no declared material.", "spec.materials")
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Conservative aggregate retained-output budget, computed before mesh evaluation. */
|
|
501
|
+
export interface SpatialParametricEstimate {
|
|
502
|
+
readonly parts: number
|
|
503
|
+
readonly assets: number
|
|
504
|
+
readonly vertices: number
|
|
505
|
+
readonly triangles: number
|
|
506
|
+
readonly bytes: number
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export function estimateSpatialParametric(input: unknown): SpatialParametricEstimate {
|
|
510
|
+
const spec = parseSpatialValue(SpatialParametricSpecSchema, input, "parametric spec")
|
|
511
|
+
const plans = PLANNERS[spec.kind](spec as never)
|
|
512
|
+
if (plans.length > SPATIAL_PARAMETRIC_LIMITS.parts) pfail("Spec expands beyond the part budget.", "spec")
|
|
513
|
+
validatePartMaterials(plans)
|
|
514
|
+
let assets = 0, vertices = 0, triangles = 0, bytes = 0
|
|
515
|
+
for (const part of plans) {
|
|
516
|
+
assets += part.lods.length + 1
|
|
517
|
+
// Includes GLB framing, material JSON, facts, and per-part instance metadata.
|
|
518
|
+
bytes += 65_536 + (part.instances?.length ?? 0) * 512
|
|
519
|
+
for (const lod of part.lods) {
|
|
520
|
+
const estimate = estimateSpatialGeometryGraph(lod.graph)
|
|
521
|
+
vertices += estimate.vertices
|
|
522
|
+
triangles += estimate.triangles
|
|
523
|
+
bytes += estimate.bytes + 65_536
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return deepFreezeJson({ parts: plans.length, assets, vertices, triangles, bytes })
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
// Emission: graphs → meshes → GLB payloads → manifests → entities → facts
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
export interface SpatialParametricArtifact { readonly assetId: string; readonly path: string; readonly bytes: Uint8Array }
|
|
534
|
+
export interface SpatialParametricFacts { readonly manifest: SpatialAssetManifest; readonly facts: SpatialAssetFactsV1 }
|
|
535
|
+
export interface SpatialParametricReceipt {
|
|
536
|
+
readonly name?: string
|
|
537
|
+
readonly kind: "slopcamera.spatial-parametric-receipt"
|
|
538
|
+
readonly schemaVersion: 1
|
|
539
|
+
readonly generatorId: string
|
|
540
|
+
readonly specSha256: string
|
|
541
|
+
readonly parametersSha256: string
|
|
542
|
+
readonly seed: number
|
|
543
|
+
readonly profile: typeof SPATIAL_GEOMETRY_PROFILE
|
|
544
|
+
readonly assets: readonly { assetId: string; sha256: string }[]
|
|
545
|
+
readonly entities: readonly string[]
|
|
546
|
+
readonly native: readonly { requestSha256: string; receiptSha256: string }[]
|
|
547
|
+
}
|
|
548
|
+
export interface SpatialParametricOutput {
|
|
549
|
+
readonly generator: SpatialGenerator
|
|
550
|
+
readonly entities: readonly SpatialEntity[]
|
|
551
|
+
readonly manifests: readonly SpatialAssetManifest[]
|
|
552
|
+
readonly artifacts: readonly SpatialParametricArtifact[]
|
|
553
|
+
readonly facts: readonly SpatialParametricFacts[]
|
|
554
|
+
readonly receipt: SpatialParametricReceipt
|
|
555
|
+
readonly receiptSha256: string
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function sha256Bytes(bytes: Uint8Array): string {
|
|
559
|
+
const hasher = createSha256HexHasher(); hasher.update(bytes); return hasher.digestHex()
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const GLTF_INTERPRETATION = { kind: "gltf" as const, format: "glb" as const, metersPerUnit: 1, sourceUp: "y" as const }
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Compiles a bounded parametric spec into retained generator output: stamped
|
|
566
|
+
* entities, GLB payloads and manifests per LOD level, derived facts manifests,
|
|
567
|
+
* and a content-addressed receipt. Deterministic and effect-free.
|
|
568
|
+
*/
|
|
569
|
+
export function emitSpatialParametric(input: unknown): SpatialParametricOutput {
|
|
570
|
+
const request = parseSpatialValue(SpatialParametricRequestSchema, input, "parametric request")
|
|
571
|
+
const { generatorId, spec } = request
|
|
572
|
+
const retainedArtifacts: SpatialRetainedArtifact[] = []
|
|
573
|
+
if (request.native !== undefined) {
|
|
574
|
+
const receipt = validateSpatialGeometryNativeReceipt({ request: request.native.request, receipt: request.native.receipt })
|
|
575
|
+
const outputs = verifySpatialGeometryNativeOutputs(receipt, request.native.outputs)
|
|
576
|
+
retainedArtifacts.push({
|
|
577
|
+
operation: receipt.operation,
|
|
578
|
+
requestSha256: spatialGeometryNativeRequestSha256(request.native.request),
|
|
579
|
+
receiptSha256: spatialGeometryNativeReceiptSha256(receipt),
|
|
580
|
+
outputs: [...outputs],
|
|
581
|
+
})
|
|
582
|
+
}
|
|
583
|
+
const sourceSha256 = spatialValueSha256({ domain: "slopcamera.parametric-source.v1", profile: SPATIAL_GEOMETRY_PROFILE, kind: spec.kind })
|
|
584
|
+
const parametersSha256 = spatialGeneratorParametersSha256(spec)
|
|
585
|
+
const specSha256 = spatialValueSha256({ domain: "slopcamera.parametric-spec.v1", spec })
|
|
586
|
+
const seed = request.seed ?? deriveSpatialGeneratorSeed(sourceSha256)
|
|
587
|
+
const runtimeSha256 = spatialValueSha256({ domain: "slopcamera.parametric-runtime.v1", profile: SPATIAL_GEOMETRY_PROFILE })
|
|
588
|
+
const plans = PLANNERS[spec.kind](spec as never)
|
|
589
|
+
if (plans.length > SPATIAL_PARAMETRIC_LIMITS.parts) pfail("Spec expands beyond the part budget.", "spec")
|
|
590
|
+
validatePartMaterials(plans)
|
|
591
|
+
const manifests: SpatialAssetManifest[] = []
|
|
592
|
+
const artifacts: SpatialParametricArtifact[] = []
|
|
593
|
+
const entities: SpatialEntity[] = []
|
|
594
|
+
const factsList: SpatialParametricFacts[] = []
|
|
595
|
+
const editableKeys: { key: string; properties: ("color" | "opacity" | "transform")[] }[] = []
|
|
596
|
+
const receiptAssets: { assetId: string; sha256: string }[] = []
|
|
597
|
+
const lod0ByPart = new Map<string, { assetId: string; manifest: SpatialAssetManifest; mesh: SpatialGeometryMesh; part: PartPlan }>()
|
|
598
|
+
for (const part of plans) {
|
|
599
|
+
const lods: { level: number; assetId: string; sha256: string; switchDistanceM: number }[] = []
|
|
600
|
+
for (const lod of part.lods) {
|
|
601
|
+
const evaluation = evaluateSpatialGeometry(lod.graph)
|
|
602
|
+
const bytes = emitSpatialGeometryGlb(evaluation.mesh, part.materials)
|
|
603
|
+
const sha256 = sha256Bytes(bytes)
|
|
604
|
+
const assetId = `asset_${spatialValueSha256({ domain: "slopcamera.parametric-asset.v1", generatorId, key: part.key, level: lod.level, sha256 }).slice(0, 32)}`
|
|
605
|
+
const path = `generated/${generatorId}/${part.key}-lod${lod.level}.glb`
|
|
606
|
+
const manifest = parseSpatialValue(SpatialAssetManifestSchema, {
|
|
607
|
+
assetId, payload: { path, sha256, bytes: bytes.byteLength },
|
|
608
|
+
interpretation: GLTF_INTERPRETATION, dependencies: [],
|
|
609
|
+
provenance: { source: "generated", description: `Parametric ${spec.kind} part "${part.key}" LOD ${lod.level}.`, receiptSha256: "0".repeat(64) },
|
|
610
|
+
}, `parametric asset ${part.key}`)
|
|
611
|
+
manifests.push(manifest)
|
|
612
|
+
artifacts.push({ assetId, path, bytes })
|
|
613
|
+
lods.push({ level: lod.level, assetId, sha256, switchDistanceM: lod.switchDistanceM })
|
|
614
|
+
if (lod.level === 0) lod0ByPart.set(part.key, { assetId, manifest, mesh: evaluation.mesh, part })
|
|
615
|
+
}
|
|
616
|
+
lods.sort((a, b) => a.level - b.level)
|
|
617
|
+
const lod0 = lod0ByPart.get(part.key)!
|
|
618
|
+
if (part.lods[0] === undefined) pfail("Every part requires a level-0 LOD.", "spec")
|
|
619
|
+
// Facts document for the level-0 subject.
|
|
620
|
+
const subject = lod0.manifest.payload
|
|
621
|
+
const collision = request.collision !== undefined ? [...request.collision]
|
|
622
|
+
: part.collision.length > 0 ? [...part.collision]
|
|
623
|
+
: [boxBounds(lod0.mesh.bounds)]
|
|
624
|
+
const materialFacts = part.materials.map(material => ({
|
|
625
|
+
alphaMode: (material.opacity < 1 ? "BLEND" : "OPAQUE") as "BLEND" | "OPAQUE",
|
|
626
|
+
doubleSided: false, maps: [] as [],
|
|
627
|
+
...(material.kind === "standard" && material.emissive !== undefined
|
|
628
|
+
? { emissiveLinear: [1, 1, 1].map(() => 0) as [number, number, number] } : {}),
|
|
629
|
+
}))
|
|
630
|
+
const facts = parseSpatialValue(SpatialAssetFactsV1Schema, {
|
|
631
|
+
kind: "slopcamera.spatial-asset-facts", schemaVersion: 1,
|
|
632
|
+
subject: { path: subject.path, sha256: subject.sha256, bytes: subject.bytes },
|
|
633
|
+
subjectManifestSha256: spatialAssetManifestSha256(lod0.manifest),
|
|
634
|
+
profile: SPATIAL_GEOMETRY_PROFILE, nodeCount: 1, clipDurationsSeconds: [],
|
|
635
|
+
bounds: { modelSpace: lod0.mesh.bounds, sceneSpace: lod0.mesh.bounds },
|
|
636
|
+
materials: materialFacts,
|
|
637
|
+
generator: {
|
|
638
|
+
generatorId, parametricKind: spec.kind, specSha256, parametersSha256,
|
|
639
|
+
lods: lods.map(lod => ({ level: lod.level, assetId: lod.assetId, sha256: lod.sha256, switchDistanceM: lod.switchDistanceM })),
|
|
640
|
+
collision,
|
|
641
|
+
...(retainedArtifacts.length === 0 ? {} : { retainedArtifacts }),
|
|
642
|
+
},
|
|
643
|
+
}, `parametric facts ${part.key}`)
|
|
644
|
+
const factsText = `${canonicalJson(facts)}\n`
|
|
645
|
+
const factsBytes = new TextEncoder().encode(factsText)
|
|
646
|
+
const factsSha256 = sha256Bytes(factsBytes)
|
|
647
|
+
const factsManifest = parseSpatialValue(SpatialAssetManifestSchema, {
|
|
648
|
+
assetId: `asset_${spatialValueSha256({ domain: "slopcamera.parametric-facts.v1", generatorId, key: part.key, sha256: factsSha256 }).slice(0, 32)}`,
|
|
649
|
+
payload: { path: `generated/${generatorId}/${part.key}-facts.json`, sha256: factsSha256, bytes: factsBytes.byteLength },
|
|
650
|
+
interpretation: { kind: "metadata", format: "json", schema: "slopcamera.spatial-asset-facts" },
|
|
651
|
+
dependencies: [lod0.assetId],
|
|
652
|
+
provenance: { source: "derived", description: `Derived parametric facts for ${lod0.assetId}.`, receiptSha256: "0".repeat(64) },
|
|
653
|
+
}, `parametric facts manifest ${part.key}`)
|
|
654
|
+
manifests.push(factsManifest)
|
|
655
|
+
artifacts.push({ assetId: factsManifest.assetId, path: factsManifest.payload.path, bytes: factsBytes })
|
|
656
|
+
factsList.push({ manifest: factsManifest, facts })
|
|
657
|
+
const entityId = generatedSpatialEntityId(generatorId, part.key)
|
|
658
|
+
const multiSlot = part.materials.length > 1
|
|
659
|
+
const entity = parseSpatialValue(SpatialEntitySchema, {
|
|
660
|
+
entityId, kind: "mesh", name: request.name === undefined ? `${spec.kind}:${part.key}` : plans.length === 1 ? request.name : `${request.name}:${part.key}`, parentId: null,
|
|
661
|
+
transform: part.transform, placement: { kind: "world" },
|
|
662
|
+
origin: { kind: "generated", generatorId, key: part.key }, visible: true,
|
|
663
|
+
geometry: { kind: "asset", assetId: lod0.assetId, materialMode: multiSlot ? "source" : "entity" },
|
|
664
|
+
material: part.materials[0]!,
|
|
665
|
+
...(spec.castShadow === undefined ? {} : { castShadow: spec.castShadow }),
|
|
666
|
+
...(spec.receiveShadow === undefined ? {} : { receiveShadow: spec.receiveShadow }),
|
|
667
|
+
...(part.instances === undefined ? {} : { instances: [...part.instances] }),
|
|
668
|
+
}, `parametric entity ${part.key}`)
|
|
669
|
+
entities.push(entity)
|
|
670
|
+
editableKeys.push({ key: part.key, properties: multiSlot ? ["transform"] : [...part.editable] })
|
|
671
|
+
receiptAssets.push(...lods.map(lod => ({ assetId: lod.assetId, sha256: lod.sha256 })))
|
|
672
|
+
receiptAssets.push({ assetId: factsManifest.assetId, sha256: factsSha256 })
|
|
673
|
+
}
|
|
674
|
+
const closureSha256 = spatialValueSha256({ domain: "slopcamera.parametric-closure.v1", sourceSha256, parametersSha256, seed, specSha256 })
|
|
675
|
+
const assetIds = manifests.map(manifest => manifest.assetId).sort()
|
|
676
|
+
const record = buildSpatialGeneratorRecord({
|
|
677
|
+
generatorId, sourceSha256, closureSha256, parametersSha256, seed, runtimeSha256,
|
|
678
|
+
entities, editableKeys, assets: assetIds,
|
|
679
|
+
})
|
|
680
|
+
const receipt = deepFreezeJson({
|
|
681
|
+
kind: "slopcamera.spatial-parametric-receipt" as const, schemaVersion: 1 as const,
|
|
682
|
+
generatorId, specSha256, parametersSha256, seed,
|
|
683
|
+
...(request.name === undefined ? {} : { name: request.name }),
|
|
684
|
+
profile: SPATIAL_GEOMETRY_PROFILE,
|
|
685
|
+
assets: receiptAssets,
|
|
686
|
+
entities: entities.map(entity => entity.entityId).sort(),
|
|
687
|
+
native: retainedArtifacts.map(artifact => ({ requestSha256: artifact.requestSha256, receiptSha256: artifact.receiptSha256 })),
|
|
688
|
+
})
|
|
689
|
+
const receiptSha256 = spatialValueSha256({ domain: "slopcamera.spatial-parametric-receipt.v1", receipt })
|
|
690
|
+
// Bind the receipt digest into generated provenance now that it exists.
|
|
691
|
+
const bound = manifests.map(manifest => manifest.provenance.source === "generated"
|
|
692
|
+
? parseSpatialValue(SpatialAssetManifestSchema, { ...manifest, provenance: { ...manifest.provenance, receiptSha256 } }, `parametric asset ${manifest.assetId}`)
|
|
693
|
+
: manifest)
|
|
694
|
+
// Payload buffers are owned binary outputs, outside the canonical JSON domain.
|
|
695
|
+
const metadata = deepFreezeJson({ generator: record, entities, manifests: bound, facts: factsList, receipt, receiptSha256 })
|
|
696
|
+
return Object.freeze({ ...metadata, artifacts: Object.freeze(artifacts.map(artifact => Object.freeze(artifact))) })
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Merges one emission into a scene through the retained-output contract. */
|
|
700
|
+
export function mergeSpatialParametricOutput(scene: SpatialSceneV1 | undefined, output: SpatialParametricOutput): SpatialSceneV1 {
|
|
701
|
+
return mergeSpatialGeneratorOutput(scene, output.generator, output.entities, output.manifests)
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ---------------------------------------------------------------------------
|
|
705
|
+
// LOD selection and audit
|
|
706
|
+
// ---------------------------------------------------------------------------
|
|
707
|
+
|
|
708
|
+
/** Deterministic LOD rule: the greatest level whose switch distance is met. */
|
|
709
|
+
export function selectSpatialLod(lods: readonly { level: number; assetId: string; switchDistanceM: number }[], distanceM: number): { level: number; assetId: string; switchDistanceM: number } {
|
|
710
|
+
let selected = lods[0]!
|
|
711
|
+
for (const lod of lods) {
|
|
712
|
+
if (lod.level === 0) selected = lod
|
|
713
|
+
else if (distanceM >= lod.switchDistanceM && lod.level > selected.level) selected = lod
|
|
714
|
+
}
|
|
715
|
+
return selected
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export interface SpatialParametricAuditEntry {
|
|
719
|
+
readonly entityId: string
|
|
720
|
+
readonly key: string
|
|
721
|
+
readonly generatorId: string
|
|
722
|
+
readonly distanceM: number
|
|
723
|
+
readonly lodAssetId: string
|
|
724
|
+
readonly lodLevel: number
|
|
725
|
+
readonly collision: readonly string[]
|
|
726
|
+
readonly retainedArtifacts: number
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Reports which LOD/proxy each parametric entity resolves under one camera:
|
|
731
|
+
* distance from the camera position to the transformed bounds center selects
|
|
732
|
+
* the deterministic level, and collision kinds + retained artifact counts come
|
|
733
|
+
* from the asset facts documents.
|
|
734
|
+
*/
|
|
735
|
+
export function auditSpatialParametricScene(sceneInput: unknown, factsInput: readonly unknown[], options: { cameraId: string; timeUs?: number }): readonly SpatialParametricAuditEntry[] {
|
|
736
|
+
const scene = parseSpatialScene(sceneInput)
|
|
737
|
+
const factsBySubject = new Map<string, SpatialAssetFactsV1>()
|
|
738
|
+
for (const input of factsInput) {
|
|
739
|
+
const facts = parseSpatialValue(SpatialAssetFactsV1Schema, input, "asset facts")
|
|
740
|
+
if (facts.generator !== undefined) factsBySubject.set(facts.subject.sha256, facts)
|
|
741
|
+
}
|
|
742
|
+
const snapshot = evaluateSpatialScene(scene, { cameraId: options.cameraId, timeUs: options.timeUs ?? 0 })
|
|
743
|
+
const camera = snapshot.camera.pose.position
|
|
744
|
+
const assetsById = new Map(scene.assets.map(asset => [asset.assetId, asset]))
|
|
745
|
+
const entries: SpatialParametricAuditEntry[] = []
|
|
746
|
+
for (const item of snapshot.entities) {
|
|
747
|
+
const { entity, worldMatrix } = item
|
|
748
|
+
if (entity.origin.kind !== "generated" || entity.kind !== "mesh" || entity.geometry.kind !== "asset") continue
|
|
749
|
+
const asset = assetsById.get(entity.geometry.assetId)
|
|
750
|
+
if (asset === undefined) continue
|
|
751
|
+
const facts = factsBySubject.get(asset.payload.sha256)
|
|
752
|
+
if (facts === undefined || facts.generator === undefined) continue
|
|
753
|
+
const bounds = transformBounds(worldMatrix as Mat4, facts.bounds.modelSpace)
|
|
754
|
+
const center = [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2, (bounds.min[2] + bounds.max[2]) / 2] as const
|
|
755
|
+
const distance = Math.hypot(center[0] - camera[0], center[1] - camera[1], center[2] - camera[2])
|
|
756
|
+
const lod = selectSpatialLod(facts.generator.lods, distance)
|
|
757
|
+
entries.push({
|
|
758
|
+
entityId: entity.entityId, key: entity.origin.key, generatorId: entity.origin.generatorId,
|
|
759
|
+
distanceM: distance, lodAssetId: lod.assetId, lodLevel: lod.level,
|
|
760
|
+
collision: facts.generator.collision.map(proxy => proxy.kind),
|
|
761
|
+
retainedArtifacts: facts.generator.retainedArtifacts?.length ?? 0,
|
|
762
|
+
})
|
|
763
|
+
}
|
|
764
|
+
return deepFreezeJson(entries)
|
|
765
|
+
}
|