@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.
Files changed (153) hide show
  1. package/README.md +29 -9
  2. package/apps/desktop/application/context.ts +6 -0
  3. package/apps/desktop/application/default-registry.ts +26 -0
  4. package/apps/desktop/application/operation.ts +14 -1
  5. package/apps/desktop/application/operations/index.ts +4 -0
  6. package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
  7. package/apps/desktop/application/operations/spatial-direction.ts +222 -0
  8. package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
  9. package/apps/desktop/application/operations/spatial-review.ts +194 -0
  10. package/apps/desktop/application/operations/spatial-scene.ts +25 -1
  11. package/apps/desktop/application/registry.ts +2 -2
  12. package/apps/desktop/application/spatial-asset-admission.ts +201 -0
  13. package/apps/desktop/application/spatial-assets.ts +160 -31
  14. package/apps/desktop/application/spatial-render.ts +217 -20
  15. package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
  16. package/apps/desktop/application/spatial-review.ts +361 -0
  17. package/apps/desktop/application/spatial-spz.ts +48 -3
  18. package/apps/desktop/application/spatial-world-import.ts +26 -5
  19. package/apps/desktop/application/spatial-world-metadata.ts +105 -0
  20. package/apps/desktop/cli/args.ts +514 -11
  21. package/apps/desktop/cli/capability-manifest.ts +374 -0
  22. package/apps/desktop/cli/cinema-renderer.ts +608 -0
  23. package/apps/desktop/cli/command-host-resources.ts +29 -1
  24. package/apps/desktop/cli/commands.ts +1188 -4
  25. package/apps/desktop/cli/gateway-review-provider.ts +334 -0
  26. package/apps/desktop/cli/help.ts +172 -6
  27. package/apps/desktop/cli/main.ts +3 -3
  28. package/apps/desktop/cli/portable-surface.ts +6 -1
  29. package/apps/desktop/cli/spatial-asset-service.ts +124 -0
  30. package/apps/desktop/cli/spatial-design-service.ts +237 -0
  31. package/apps/desktop/cli/spatial-generate-service.ts +370 -0
  32. package/apps/desktop/cli/spatial-scene-service.ts +271 -4
  33. package/apps/desktop/cli/workflow-code.ts +1 -1
  34. package/apps/desktop/code/semantic-builder.ts +24 -0
  35. package/apps/desktop/contracts/cinema.ts +736 -0
  36. package/apps/desktop/contracts/index.ts +1 -0
  37. package/apps/desktop/contracts/spatial-asset.ts +16 -0
  38. package/apps/desktop/contracts/spatial-world.ts +26 -3
  39. package/apps/desktop/core/cinema-plan.ts +1320 -0
  40. package/apps/desktop/core/index.ts +1 -0
  41. package/apps/desktop/core/storage.ts +75 -0
  42. package/apps/desktop/dist/cli/main.js +725 -273
  43. package/apps/desktop/html-overlay/spatial.ts +619 -57
  44. package/apps/desktop/workflows/cinematic-world.ts +127 -0
  45. package/apps/desktop/workflows/index.ts +6 -0
  46. package/dist/cli.js +25 -429
  47. package/dist/code/advanced.js +1 -269
  48. package/dist/code/index.js +16 -2239
  49. package/dist/generate.js +1 -23
  50. package/dist/host-resources.js +1 -25
  51. package/dist/index-0bbesn4m.js +35 -0
  52. package/dist/index-296qhpf2.js +3 -0
  53. package/dist/index-3cxf2c58.js +3 -0
  54. package/dist/index-77fjfg9f.js +3 -0
  55. package/dist/index-80a36bc0.js +3 -0
  56. package/dist/index-a3vvs3rf.js +3 -0
  57. package/dist/index-b3geqczn.js +5 -0
  58. package/dist/index-grk47tgs.js +3 -0
  59. package/dist/index-gs48afpt.js +7 -0
  60. package/dist/index-qry58nj2.js +10 -0
  61. package/dist/index-zhg36tf9.js +3 -0
  62. package/dist/index.js +1 -226
  63. package/dist/operations.js +1 -28
  64. package/dist/skill-install-gx7afxsd.js +2 -0
  65. package/dist/vectorize/worker.js +1 -141
  66. package/dist/workflow.js +1 -15
  67. package/docs/README.md +5 -1
  68. package/docs/studio.md +12 -0
  69. package/package.json +7 -5
  70. package/skills/slopcamera/SKILL.md +7 -1
  71. package/skills/slopcamera/references/directed-scenes.md +48 -2
  72. package/skills/slopcamera/references/gateway-media.md +15 -1
  73. package/skills/slopcamera/references/image-galleries.md +166 -0
  74. package/skills/slopcamera/references/install.md +1 -1
  75. package/skills/slopcamera/references/music-video.md +4 -0
  76. package/skills/slopcamera/references/native-studio.md +19 -0
  77. package/skills/slopcamera/references/parametric-design.md +14 -0
  78. package/skills/slopcamera/references/scene-building.md +172 -0
  79. package/skills/slopcamera/references/social-collage-banners.md +204 -0
  80. package/skills/slopcamera/references/workflows-sdk.md +3 -3
  81. package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
  82. package/src/capability-manifest.ts +361 -0
  83. package/src/cli.ts +222 -4
  84. package/src/code/canonical-json.ts +9 -0
  85. package/src/generate.ts +2 -2
  86. package/src/icon.ts +1111 -0
  87. package/src/image-gallery.ts +1049 -0
  88. package/src/index.ts +11 -0
  89. package/src/mcp/boundary.ts +11 -11
  90. package/src/mcp/index.ts +3 -0
  91. package/src/mcp/server.ts +2 -1
  92. package/src/mcp/tools.ts +1649 -132
  93. package/src/mcp/types.ts +19 -0
  94. package/src/operations.ts +412 -0
  95. package/src/portable-capability-manifest.ts +67 -0
  96. package/src/scene-gallery.ts +184 -0
  97. package/src/spatial-scene/asset-admission.ts +183 -0
  98. package/src/spatial-scene/audit-rendered.ts +692 -0
  99. package/src/spatial-scene/audit.ts +617 -0
  100. package/src/spatial-scene/behavior-audit.ts +252 -0
  101. package/src/spatial-scene/behavior-bake.ts +232 -0
  102. package/src/spatial-scene/behavior-fns.ts +585 -0
  103. package/src/spatial-scene/behavior-gallery.ts +108 -0
  104. package/src/spatial-scene/behavior-stdlib.ts +323 -0
  105. package/src/spatial-scene/behavior-trace.ts +325 -0
  106. package/src/spatial-scene/behavior.ts +617 -0
  107. package/src/spatial-scene/build.ts +632 -0
  108. package/src/spatial-scene/camera-rig.ts +125 -0
  109. package/src/spatial-scene/camera-track.ts +4 -2
  110. package/src/spatial-scene/character.ts +124 -0
  111. package/src/spatial-scene/contracts.ts +75 -7
  112. package/src/spatial-scene/design-templates.ts +351 -0
  113. package/src/spatial-scene/design.ts +327 -0
  114. package/src/spatial-scene/direction-compile.ts +584 -0
  115. package/src/spatial-scene/direction.ts +122 -0
  116. package/src/spatial-scene/effects.ts +149 -0
  117. package/src/spatial-scene/evaluate.ts +109 -29
  118. package/src/spatial-scene/gallery.ts +277 -0
  119. package/src/spatial-scene/generate.ts +239 -0
  120. package/src/spatial-scene/geometry-native.ts +165 -0
  121. package/src/spatial-scene/geometry.ts +1216 -0
  122. package/src/spatial-scene/gltf.ts +600 -77
  123. package/src/spatial-scene/identity.ts +55 -2
  124. package/src/spatial-scene/index.ts +36 -0
  125. package/src/spatial-scene/inspect.ts +35 -6
  126. package/src/spatial-scene/material-lighting.ts +609 -0
  127. package/src/spatial-scene/math.ts +21 -4
  128. package/src/spatial-scene/motion-evidence.ts +88 -0
  129. package/src/spatial-scene/parametric.ts +765 -0
  130. package/src/spatial-scene/particle-preparation.ts +183 -0
  131. package/src/spatial-scene/particle.ts +195 -0
  132. package/src/spatial-scene/patch.ts +109 -15
  133. package/src/spatial-scene/performance.ts +1613 -0
  134. package/src/spatial-scene/probe.ts +137 -0
  135. package/src/spatial-scene/recipe-pack.ts +86 -0
  136. package/src/spatial-scene/render-effects.ts +240 -0
  137. package/src/spatial-scene/review.ts +639 -0
  138. package/src/spatial-scene/simulation-bake.ts +206 -0
  139. package/src/spatial-scene/simulation.ts +187 -0
  140. package/src/spatial-scene/solve.ts +366 -0
  141. package/src/spatial-scene/temporal-audit.ts +354 -0
  142. package/src/version.ts +1 -1
  143. package/dist/index-42zsesc1.js +0 -2257
  144. package/dist/index-7131pg1c.js +0 -1820
  145. package/dist/index-7308egqr.js +0 -48
  146. package/dist/index-8txs6fkn.js +0 -712
  147. package/dist/index-fava6pge.js +0 -192
  148. package/dist/index-h1k0fnjq.js +0 -2363
  149. package/dist/index-r7gdhmsp.js +0 -424
  150. package/dist/index-sh6xbav6.js +0 -1219
  151. package/dist/index-z1w83f81.js +0 -4
  152. package/dist/index-zfnddgay.js +0 -2029
  153. package/dist/skill-install-dh0ntqba.js +0 -10
@@ -0,0 +1,1216 @@
1
+ import { z } from "zod"
2
+ import { deepFreezeJson } from "../code/json-snapshot.js"
3
+ import {
4
+ SpatialMaterialSchema, SpatialTransformSchema,
5
+ type SpatialMaterial, type SpatialTransform,
6
+ } from "./contracts.js"
7
+ import { parseSpatialValue, SpatialSceneError, spatialTopologicalIds, spatialValueSha256 } from "./identity.js"
8
+ import {
9
+ composeTransform, invertTransform, transformBounds, transformPoint,
10
+ type Bounds, type Vec3,
11
+ } from "./math.js"
12
+
13
+ /**
14
+ * Bounded parametric geometry graph. Authored intent is a DAG of strict-schema
15
+ * nodes: profile sources and 2D modifiers (inset, bevel), mesh sources
16
+ * (box, cylinder, sphere), profile consumers (extrude, revolve, sweep, loft),
17
+ * mesh modifiers (transform, mirror, array, merge, material-slot, uv-project)
18
+ * and a limited boolean for bounded hull inputs. Every node has a closed
19
+ * analytic bound on vertices, triangles, support planes and extent, so node
20
+ * count, evaluation depth, vertex, triangle and byte budgets are enforced
21
+ * BEFORE any mesh work runs. Evaluation is deterministic and emits flat
22
+ * positions, normals, uvs, indices and per-triangle material slots; the GLB
23
+ * emitter produces a document inside the closed parseSpatialGlb profile so
24
+ * generated parts reach the renderer through the identical prepared-geometry
25
+ * path as admitted GLB assets. This module is effect-free: no files, clocks,
26
+ * providers, or native execution.
27
+ */
28
+ export const SPATIAL_GEOMETRY_LIMITS = Object.freeze({
29
+ nodes: 128,
30
+ depth: 32,
31
+ profilePoints: 512,
32
+ pathPoints: 256,
33
+ mergeInputs: 64,
34
+ arrayCount: 512,
35
+ arcSegments: 256,
36
+ booleanPlanes: 64,
37
+ booleanInputTriangles: 4_096,
38
+ vertices: 65_536,
39
+ triangles: 100_000,
40
+ materialSlots: 16,
41
+ outputBytes: 33_554_432,
42
+ coordinate: 1_000_000,
43
+ })
44
+
45
+ export const SPATIAL_GEOMETRY_GRAPH_KIND = "slopcamera.spatial-geometry-graph" as const
46
+ export const SPATIAL_GEOMETRY_PROFILE = "slopcamera.parametric-geometry-v1" as const
47
+
48
+ const finite = z.number().finite().min(-SPATIAL_GEOMETRY_LIMITS.coordinate).max(SPATIAL_GEOMETRY_LIMITS.coordinate)
49
+ const positive = z.number().finite().min(1e-9).max(SPATIAL_GEOMETRY_LIMITS.coordinate)
50
+ const segments = z.number().int().min(3).max(SPATIAL_GEOMETRY_LIMITS.arcSegments)
51
+ const vec2 = z.tuple([finite, finite])
52
+ const vec3 = z.tuple([finite, finite, finite])
53
+ const nodeId = z.string().min(1).max(64).regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/u, "Geometry node ids must be safe identifiers.")
54
+ const materialSlot = z.number().int().min(0).max(SPATIAL_GEOMETRY_LIMITS.materialSlots - 1)
55
+ const axis = z.enum(["x", "y", "z"])
56
+
57
+ /** Profile nodes evaluate to a simple CCW polygon; mesh nodes evaluate to mesh data. */
58
+ export const SpatialGeometryNodeSchema = z.discriminatedUnion("kind", [
59
+ z.strictObject({ id: nodeId, kind: z.literal("profile"), points: z.array(vec2).min(3).max(SPATIAL_GEOMETRY_LIMITS.profilePoints) }),
60
+ z.strictObject({ id: nodeId, kind: z.literal("rect"), width: positive, height: positive }),
61
+ z.strictObject({ id: nodeId, kind: z.literal("ellipse"), radiusX: positive, radiusY: positive, segments }),
62
+ z.strictObject({ id: nodeId, kind: z.literal("inset"), input: nodeId, distance: positive }),
63
+ z.strictObject({ id: nodeId, kind: z.literal("bevel"), input: nodeId, radius: positive, segments: z.number().int().min(1).max(64) }),
64
+ z.strictObject({ id: nodeId, kind: z.literal("box"), size: z.tuple([positive, positive, positive]) }),
65
+ z.strictObject({ id: nodeId, kind: z.literal("cylinder"), radius: positive, height: positive, segments }),
66
+ z.strictObject({ id: nodeId, kind: z.literal("sphere"), radius: positive, segments: z.number().int().min(4).max(SPATIAL_GEOMETRY_LIMITS.arcSegments) }),
67
+ z.strictObject({ id: nodeId, kind: z.literal("extrude"), profile: nodeId, depth: positive, sideSlot: materialSlot.optional(), capSlot: materialSlot.optional() }),
68
+ z.strictObject({ id: nodeId, kind: z.literal("revolve"), profile: nodeId, segments, capSlot: materialSlot.optional() }),
69
+ z.strictObject({ id: nodeId, kind: z.literal("sweep"), profile: nodeId, path: z.array(vec3).min(2).max(SPATIAL_GEOMETRY_LIMITS.pathPoints) }),
70
+ z.strictObject({ id: nodeId, kind: z.literal("loft"), bottom: nodeId, top: nodeId, height: positive }),
71
+ z.strictObject({ id: nodeId, kind: z.literal("transform"), input: nodeId, transform: SpatialTransformSchema }),
72
+ z.strictObject({ id: nodeId, kind: z.literal("mirror"), input: nodeId, axis, offset: finite }),
73
+ z.strictObject({ id: nodeId, kind: z.literal("array"), input: nodeId, count: z.number().int().min(1).max(SPATIAL_GEOMETRY_LIMITS.arrayCount), step: vec3 }),
74
+ z.strictObject({ id: nodeId, kind: z.literal("merge"), inputs: z.array(nodeId).min(2).max(SPATIAL_GEOMETRY_LIMITS.mergeInputs) }),
75
+ z.discriminatedUnion("operation", [
76
+ z.strictObject({ id: nodeId, kind: z.literal("boolean"), operation: z.literal("difference"), a: nodeId, cutters: z.array(nodeId).min(1).max(16) }),
77
+ z.strictObject({ id: nodeId, kind: z.literal("boolean"), operation: z.enum(["union", "intersection"]), a: nodeId, b: nodeId }),
78
+ ]),
79
+ z.strictObject({ id: nodeId, kind: z.literal("material-slot"), input: nodeId, slot: materialSlot }),
80
+ z.strictObject({ id: nodeId, kind: z.literal("uv-project"), input: nodeId, projection: z.discriminatedUnion("mode", [
81
+ z.strictObject({ mode: z.literal("box"), scale: positive }),
82
+ z.strictObject({ mode: z.literal("planar"), axis, scale: positive, offset: vec2.optional() }),
83
+ z.strictObject({ mode: z.literal("cylindrical"), axis, scale: positive }),
84
+ ]) }),
85
+ ])
86
+ export type SpatialGeometryNode = Readonly<z.infer<typeof SpatialGeometryNodeSchema>>
87
+
88
+ export const SpatialGeometryGraphSchema = z.strictObject({
89
+ kind: z.literal(SPATIAL_GEOMETRY_GRAPH_KIND),
90
+ schemaVersion: z.literal(1),
91
+ nodes: z.array(SpatialGeometryNodeSchema).min(1).max(SPATIAL_GEOMETRY_LIMITS.nodes),
92
+ output: nodeId,
93
+ })
94
+ export type SpatialGeometryGraph = Readonly<z.infer<typeof SpatialGeometryGraphSchema>>
95
+
96
+ /** Flat evaluated mesh data; `slots` carries one material slot per triangle. */
97
+ export interface SpatialGeometryMesh {
98
+ readonly positions: readonly number[]
99
+ readonly normals: readonly number[]
100
+ readonly uvs: readonly number[]
101
+ readonly indices: readonly number[]
102
+ readonly slots: readonly number[]
103
+ readonly bounds: Bounds
104
+ readonly vertices: number
105
+ readonly triangles: number
106
+ readonly uvsProjected: boolean
107
+ }
108
+
109
+ /** Static pre-execution bounds: every emitted mesh must fit inside this estimate. */
110
+ export interface SpatialGeometryEstimate {
111
+ readonly nodes: number
112
+ readonly depth: number
113
+ readonly vertices: number
114
+ readonly triangles: number
115
+ readonly bytes: number
116
+ readonly bounds: Bounds
117
+ }
118
+
119
+ export interface SpatialGeometryEvaluation {
120
+ readonly mesh: SpatialGeometryMesh
121
+ readonly estimate: SpatialGeometryEstimate
122
+ readonly graphSha256: string
123
+ }
124
+
125
+ function fail(message: string, path = "geometry"): never {
126
+ throw new SpatialSceneError("invalid-data", `${SPATIAL_GEOMETRY_PROFILE}: ${message}`, path)
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Profile (2D simple polygon) evaluation
131
+ // ---------------------------------------------------------------------------
132
+
133
+ type V2 = readonly [number, number]
134
+
135
+ function polygonArea(points: readonly V2[]): number {
136
+ let area = 0
137
+ for (let index = 0; index < points.length; index++) {
138
+ const a = points[index]!, b = points[(index + 1) % points.length]!
139
+ area += a[0] * b[1] - b[0] * a[1]
140
+ }
141
+ return area / 2
142
+ }
143
+
144
+ function segmentsCross(a1: V2, a2: V2, b1: V2, b2: V2): boolean {
145
+ const cross = (o: V2, p: V2, q: V2) => (p[0] - o[0]) * (q[1] - o[1]) - (p[1] - o[1]) * (q[0] - o[0])
146
+ const d1 = cross(b1, b2, a1), d2 = cross(b1, b2, a2), d3 = cross(a1, a2, b1), d4 = cross(a1, a2, b2)
147
+ return ((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))
148
+ }
149
+
150
+ /** Endpoint touches between non-adjacent edges also invalidate a simple polygon. */
151
+ function pointOnSegment(p: V2, a: V2, b: V2): boolean {
152
+ const cross = (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])
153
+ const scale = Math.hypot(b[0] - a[0], b[1] - a[1]) + 1e-12
154
+ if (Math.abs(cross) > 1e-9 * scale) return false
155
+ return (p[0] - a[0]) * (b[0] - a[0]) + (p[1] - a[1]) * (b[1] - a[1]) >= -1e-12
156
+ && (p[0] - b[0]) * (a[0] - b[0]) + (p[1] - b[1]) * (a[1] - b[1]) >= -1e-12
157
+ }
158
+
159
+ function polygonIsSimple(points: readonly V2[]): boolean {
160
+ const count = points.length
161
+ for (let edge = 0; edge < count; edge++) {
162
+ const a1 = points[edge]!, a2 = points[(edge + 1) % count]!
163
+ for (let other = edge + 1; other < count; other++) {
164
+ if (other === edge || (other + 1) % count === edge || (edge + 1) % count === other) continue
165
+ const b1 = points[other]!, b2 = points[(other + 1) % count]!
166
+ if (segmentsCross(a1, a2, b1, b2)) return false
167
+ if (pointOnSegment(a1, b1, b2) || pointOnSegment(a2, b1, b2) || pointOnSegment(b1, a1, a2)) return false
168
+ }
169
+ }
170
+ return true
171
+ }
172
+
173
+ function checkedProfile(points: readonly V2[], path: string): readonly V2[] {
174
+ if (points.length < 3 || points.length > SPATIAL_GEOMETRY_LIMITS.profilePoints) fail("Profile requires 3–512 points.", path)
175
+ const area = polygonArea(points)
176
+ if (!Number.isFinite(area) || Math.abs(area) < 1e-12) fail("Profile polygon is degenerate or collinear.", path)
177
+ const ccw = area > 0 ? [...points] : [...points].reverse()
178
+ if (!polygonIsSimple(ccw)) fail("Profile polygon must be simple (no self-intersections).", path)
179
+ return Object.freeze(ccw)
180
+ }
181
+
182
+ function unit2(x: number, y: number): V2 {
183
+ const length = Math.hypot(x, y)
184
+ return length === 0 ? [0, 0] : [x / length, y / length]
185
+ }
186
+
187
+ /** Inward offset of a simple polygon by intersecting displaced edge lines (exact for hull vertices). */
188
+ function insetProfile(points: readonly V2[], distance: number, path: string): readonly V2[] {
189
+ const count = points.length
190
+ const out: V2[] = []
191
+ for (let index = 0; index < count; index++) {
192
+ const prev = points[(index - 1 + count) % count]!, vertex = points[index]!, next = points[(index + 1) % count]!
193
+ const e1 = unit2(vertex[0] - prev[0], vertex[1] - prev[1])
194
+ const e2 = unit2(next[0] - vertex[0], next[1] - vertex[1])
195
+ // For CCW winding the interior lies left of each directed edge.
196
+ const n1: V2 = [-e1[1], e1[0]], n2: V2 = [-e2[1], e2[0]]
197
+ const a1: V2 = [prev[0] + n1[0] * distance, prev[1] + n1[1] * distance]
198
+ const b1: V2 = [vertex[0] + n2[0] * distance, vertex[1] + n2[1] * distance]
199
+ const denominator = e1[0] * e2[1] - e1[1] * e2[0]
200
+ if (Math.abs(denominator) < 1e-12) {
201
+ out.push([vertex[0] + (n1[0] + n2[0]) / 2 * distance, vertex[1] + (n1[1] + n2[1]) / 2 * distance])
202
+ continue
203
+ }
204
+ const t = ((b1[0] - a1[0]) * e2[1] - (b1[1] - a1[1]) * e2[0]) / denominator
205
+ out.push([a1[0] + e1[0] * t, a1[1] + e1[1] * t])
206
+ }
207
+ return checkedProfile(out, path)
208
+ }
209
+
210
+ /** Per-corner circular fillet on hull vertices; reflex corners pass through unchanged. */
211
+ function bevelProfile(points: readonly V2[], radius: number, arcSegments: number, path: string): readonly V2[] {
212
+ const count = points.length
213
+ const out: V2[] = []
214
+ for (let index = 0; index < count; index++) {
215
+ const prev = points[(index - 1 + count) % count]!, vertex = points[index]!, next = points[(index + 1) % count]!
216
+ const u = unit2(vertex[0] - prev[0], vertex[1] - prev[1])
217
+ const v = unit2(next[0] - vertex[0], next[1] - vertex[1])
218
+ const turn = u[0] * v[1] - u[1] * v[0]
219
+ if (turn <= 1e-12) { out.push(vertex); continue } // reflex or straight
220
+ const cosInterior = Math.min(1, Math.max(-1, -(u[0] * v[0] + u[1] * v[1])))
221
+ const interior = Math.PI - Math.acos(cosInterior)
222
+ if (interior <= 1e-9) { out.push(vertex); continue }
223
+ const half = interior / 2
224
+ const lenPrev = Math.hypot(vertex[0] - prev[0], vertex[1] - prev[1])
225
+ const lenNext = Math.hypot(next[0] - vertex[0], next[1] - vertex[1])
226
+ const tangent = Math.min(radius / Math.tan(half), lenPrev / 2, lenNext / 2)
227
+ const effectiveRadius = tangent * Math.tan(half)
228
+ const bisector = unit2(v[0] - u[0], v[1] - u[1])
229
+ const center: V2 = [vertex[0] + bisector[0] * effectiveRadius / Math.sin(half), vertex[1] + bisector[1] * effectiveRadius / Math.sin(half)]
230
+ const touchA: V2 = [vertex[0] - u[0] * tangent, vertex[1] - u[1] * tangent]
231
+ const touchB: V2 = [vertex[0] + v[0] * tangent, vertex[1] + v[1] * tangent]
232
+ const start = Math.atan2(touchA[1] - center[1], touchA[0] - center[0])
233
+ let end = Math.atan2(touchB[1] - center[1], touchB[0] - center[0])
234
+ while (end <= start) end += 2 * Math.PI
235
+ out.push(touchA)
236
+ for (let step = 1; step <= arcSegments; step++) {
237
+ const angle = start + (end - start) * step / arcSegments
238
+ out.push([center[0] + effectiveRadius * Math.cos(angle), center[1] + effectiveRadius * Math.sin(angle)])
239
+ }
240
+ }
241
+ if (out.length > SPATIAL_GEOMETRY_LIMITS.profilePoints) fail("Bevel expands the profile beyond the point budget.", path)
242
+ return checkedProfile(out, path)
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Mesh builder
247
+ // ---------------------------------------------------------------------------
248
+
249
+ interface Builder {
250
+ positions: number[]
251
+ normals: number[]
252
+ uvs: number[]
253
+ indices: number[]
254
+ slots: number[]
255
+ uvsSet: boolean
256
+ }
257
+
258
+ function builder(): Builder { return { positions: [], normals: [], uvs: [], indices: [], slots: [], uvsSet: false } }
259
+
260
+ function vertex(b: Builder, position: Vec3, normal: Vec3, uv: V2 = [0, 0]): number {
261
+ const index = b.positions.length / 3
262
+ b.positions.push(position[0], position[1], position[2])
263
+ b.normals.push(normal[0], normal[1], normal[2])
264
+ b.uvs.push(uv[0], uv[1])
265
+ return index
266
+ }
267
+
268
+ const DEGENERATE_AREA2 = 1e-20
269
+
270
+ function triangle(b: Builder, a: number, c: number, d: number, slot: number): void {
271
+ const pa = a * 3, pb = c * 3, pc = d * 3
272
+ const ux = b.positions[pb]! - b.positions[pa]!, uy = b.positions[pb + 1]! - b.positions[pa + 1]!, uz = b.positions[pb + 2]! - b.positions[pa + 2]!
273
+ const vx = b.positions[pc]! - b.positions[pa]!, vy = b.positions[pc + 1]! - b.positions[pa + 1]!, vz = b.positions[pc + 2]! - b.positions[pa + 2]!
274
+ const cx = uy * vz - uz * vy, cy = uz * vx - ux * vz, cz = ux * vy - uy * vx
275
+ if (cx * cx + cy * cy + cz * cz < DEGENERATE_AREA2) return
276
+ b.indices.push(a, c, d)
277
+ b.slots.push(slot)
278
+ }
279
+
280
+ /** Ear-clipping triangulation of a simple polygon; returns index triples. */
281
+ function triangulate(points: readonly V2[], path: string): number[] {
282
+ const remaining = points.map((_, index) => index)
283
+ const indices: number[] = []
284
+ let guard = 0
285
+ while (remaining.length > 3 && ++guard <= points.length * points.length) {
286
+ let clipped = false
287
+ for (let index = 0; index < remaining.length; index++) {
288
+ const ia = remaining[(index - 1 + remaining.length) % remaining.length]!
289
+ const ib = remaining[index]!
290
+ const ic = remaining[(index + 1) % remaining.length]!
291
+ const a = points[ia]!, b = points[ib]!, c = points[ic]!
292
+ const cross = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
293
+ if (cross <= 1e-14) continue
294
+ let empty = true
295
+ for (const other of remaining) {
296
+ if (other === ia || other === ib || other === ic) continue
297
+ const p = points[other]!
298
+ const d1 = (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])
299
+ const d2 = (c[0] - b[0]) * (p[1] - b[1]) - (c[1] - b[1]) * (p[0] - b[0])
300
+ const d3 = (a[0] - c[0]) * (p[1] - c[1]) - (a[1] - c[1]) * (p[0] - c[0])
301
+ if (d1 >= -1e-14 && d2 >= -1e-14 && d3 >= -1e-14) { empty = false; break }
302
+ }
303
+ if (empty) { indices.push(ia, ib, ic); remaining.splice(index, 1); clipped = true; break }
304
+ }
305
+ if (!clipped) fail("Profile triangulation failed; polygon must be simple.", path)
306
+ }
307
+ if (remaining.length === 3) indices.push(remaining[0]!, remaining[1]!, remaining[2]!)
308
+ return indices
309
+ }
310
+
311
+ /** Signed mesh volume; negative means the triangle soup faces inward. */
312
+ function signedVolume(b: Builder): number {
313
+ let volume = 0
314
+ for (let index = 0; index < b.indices.length; index += 3) {
315
+ const a = b.indices[index]! * 3, c = b.indices[index + 1]! * 3, d = b.indices[index + 2]! * 3
316
+ const ax = b.positions[a]!, ay = b.positions[a + 1]!, az = b.positions[a + 2]!
317
+ const bx = b.positions[c]!, by = b.positions[c + 1]!, bz = b.positions[c + 2]!
318
+ const cx = b.positions[d]!, cy = b.positions[d + 1]!, cz = b.positions[d + 2]!
319
+ volume += (ax * (by * cz - bz * cy) + ay * (bz * cx - bx * cz) + az * (bx * cy - by * cx)) / 6
320
+ }
321
+ return volume
322
+ }
323
+
324
+ /** Flips winding and normals when a generator produced inward-facing topology. */
325
+ function orientOutward(b: Builder): void {
326
+ if (b.indices.length === 0 || signedVolume(b) >= 0) return
327
+ for (let index = 0; index < b.indices.length; index += 3) {
328
+ const swap = b.indices[index + 1]!; b.indices[index + 1] = b.indices[index + 2]!; b.indices[index + 2] = swap
329
+ }
330
+ for (let index = 0; index < b.normals.length; index++) b.normals[index] = -b.normals[index]!
331
+ }
332
+
333
+ function freezeMesh(b: Builder): SpatialGeometryMesh {
334
+ const low = [Infinity, Infinity, Infinity], high = [-Infinity, -Infinity, -Infinity]
335
+ for (let index = 0; index < b.positions.length; index += 3) {
336
+ for (let axisIndex = 0; axisIndex < 3; axisIndex++) {
337
+ const value = b.positions[index + axisIndex]!
338
+ if (!Number.isFinite(value) || Math.abs(value) > SPATIAL_GEOMETRY_LIMITS.coordinate) fail("Evaluated geometry exceeds the coordinate bound.")
339
+ low[axisIndex] = Math.min(low[axisIndex]!, value); high[axisIndex] = Math.max(high[axisIndex]!, value)
340
+ }
341
+ }
342
+ if (b.positions.length === 0) fail("Evaluated geometry produced no vertices.")
343
+ const triangles = b.indices.length / 3
344
+ return deepFreezeJson({
345
+ positions: Object.freeze(b.positions), normals: Object.freeze(b.normals), uvs: Object.freeze(b.uvs),
346
+ indices: Object.freeze(b.indices), slots: Object.freeze(b.slots),
347
+ bounds: { min: Object.freeze(low) as unknown as Vec3, max: Object.freeze(high) as unknown as Vec3 },
348
+ vertices: b.positions.length / 3, triangles, uvsProjected: b.uvsSet,
349
+ })
350
+ }
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // Mesh sources
354
+ // ---------------------------------------------------------------------------
355
+
356
+ function boxMesh(size: readonly [number, number, number]): Builder {
357
+ const b = builder()
358
+ const [hx, hy, hz] = [size[0] / 2, size[1] / 2, size[2] / 2]
359
+ const faces: readonly [Vec3, Vec3, Vec3, Vec3, Vec3][] = [
360
+ [[1, 0, 0], [hx, -hy, -hz], [hx, hy, -hz], [hx, hy, hz], [hx, -hy, hz]],
361
+ [[-1, 0, 0], [-hx, -hy, hz], [-hx, hy, hz], [-hx, hy, -hz], [-hx, -hy, -hz]],
362
+ [[0, 1, 0], [-hx, hy, -hz], [-hx, hy, hz], [hx, hy, hz], [hx, hy, -hz]],
363
+ [[0, -1, 0], [-hx, -hy, hz], [-hx, -hy, -hz], [hx, -hy, -hz], [hx, -hy, hz]],
364
+ [[0, 0, 1], [-hx, -hy, hz], [hx, -hy, hz], [hx, hy, hz], [-hx, hy, hz]],
365
+ [[0, 0, -1], [hx, -hy, -hz], [-hx, -hy, -hz], [-hx, hy, -hz], [hx, hy, -hz]],
366
+ ]
367
+ for (const [normal, ...corners] of faces) {
368
+ const uv: readonly V2[] = [[0, 0], [1, 0], [1, 1], [0, 1]]
369
+ const base = corners.map((corner, index) => vertex(b, corner, normal, uv[index]!))
370
+ triangle(b, base[0]!, base[1]!, base[2]!, 0); triangle(b, base[0]!, base[2]!, base[3]!, 0)
371
+ }
372
+ return b
373
+ }
374
+
375
+ function cylinderMesh(radius: number, height: number, seg: number): Builder {
376
+ const b = builder(), half = height / 2
377
+ for (let index = 0; index <= seg; index++) {
378
+ const angle = 2 * Math.PI * index / seg, c = Math.cos(angle), s = Math.sin(angle)
379
+ vertex(b, [radius * c, -half, radius * s], [c, 0, s], [index / seg, 0])
380
+ vertex(b, [radius * c, half, radius * s], [c, 0, s], [index / seg, 1])
381
+ }
382
+ for (let index = 0; index < seg; index++) {
383
+ const a = index * 2, c = index * 2 + 2
384
+ triangle(b, a, a + 1, c + 1, 0); triangle(b, a, c + 1, c, 0)
385
+ }
386
+ for (const [y, normal] of [[half, 1] as const, [-half, -1] as const]) {
387
+ const center = vertex(b, [0, y, 0], [0, normal, 0], [0.5, 0.5])
388
+ const ring: number[] = []
389
+ for (let index = 0; index <= seg; index++) {
390
+ const angle = 2 * Math.PI * index / seg
391
+ ring.push(vertex(b, [radius * Math.cos(angle), y, radius * Math.sin(angle)], [0, normal, 0], [0.5 + 0.5 * Math.cos(angle), 0.5 + 0.5 * Math.sin(angle)]))
392
+ }
393
+ for (let index = 0; index < seg; index++) {
394
+ if (normal === 1) triangle(b, center, ring[index + 1]!, ring[index]!, 0)
395
+ else triangle(b, center, ring[index]!, ring[index + 1]!, 0)
396
+ }
397
+ }
398
+ return b
399
+ }
400
+
401
+ function sphereMesh(radius: number, seg: number): Builder {
402
+ const b = builder(), latitudes = Math.max(2, Math.floor(seg / 2))
403
+ const top = vertex(b, [0, radius, 0], [0, 1, 0], [0.5, 1])
404
+ const rings: number[][] = []
405
+ for (let lat = 1; lat < latitudes; lat++) {
406
+ const phi = Math.PI / 2 - Math.PI * lat / latitudes, y = radius * Math.sin(phi), ringRadius = radius * Math.cos(phi)
407
+ const ring: number[] = []
408
+ for (let index = 0; index <= seg; index++) {
409
+ const angle = 2 * Math.PI * index / seg
410
+ const position: Vec3 = [ringRadius * Math.cos(angle), y, ringRadius * Math.sin(angle)]
411
+ ring.push(vertex(b, position, [position[0] / radius, position[1] / radius, position[2] / radius], [index / seg, lat / latitudes]))
412
+ }
413
+ rings.push(ring)
414
+ }
415
+ const bottom = vertex(b, [0, -radius, 0], [0, -1, 0], [0.5, 0])
416
+ for (let index = 0; index < seg; index++) {
417
+ triangle(b, top, rings[0]![index + 1]!, rings[0]![index]!, 0)
418
+ triangle(b, bottom, rings[rings.length - 1]![index]!, rings[rings.length - 1]![index + 1]!, 0)
419
+ }
420
+ for (let lat = 0; lat + 1 < rings.length; lat++) {
421
+ for (let index = 0; index < seg; index++) {
422
+ const a = rings[lat]![index]!, c = rings[lat]![index + 1]!, d = rings[lat + 1]![index]!, e = rings[lat + 1]![index + 1]!
423
+ triangle(b, a, c, e, 0); triangle(b, a, e, d, 0)
424
+ }
425
+ }
426
+ return b
427
+ }
428
+
429
+ function extrudeMesh(profile: readonly V2[], depth: number, sideSlot: number, capSlot: number, path: string): Builder {
430
+ const b = builder(), half = depth / 2, count = profile.length
431
+ const tris = triangulate(profile, path)
432
+ // Front cap (z = +half, CCW from the front) and back cap (reversed winding).
433
+ const front = profile.map(([x, y]) => vertex(b, [x, y, half], [0, 0, 1], [x, y]))
434
+ const back = profile.map(([x, y]) => vertex(b, [x, y, -half], [0, 0, -1], [x, y]))
435
+ for (let index = 0; index < tris.length; index += 3) {
436
+ triangle(b, front[tris[index]!]!, front[tris[index + 1]!]!, front[tris[index + 2]!]!, capSlot)
437
+ triangle(b, back[tris[index]!]!, back[tris[index + 2]!]!, back[tris[index + 1]!]!, capSlot)
438
+ }
439
+ let perimeter = 0
440
+ const lengths = profile.map((point, index) => {
441
+ const next = profile[(index + 1) % count]!
442
+ const length = Math.hypot(next[0] - point[0], next[1] - point[1])
443
+ perimeter += length
444
+ return length
445
+ })
446
+ let distance = 0
447
+ for (let index = 0; index < count; index++) {
448
+ const a = profile[index]!, c = profile[(index + 1) % count]!
449
+ const edge = unit2(c[0] - a[0], c[1] - a[1])
450
+ const normal: Vec3 = [edge[1], -edge[0], 0] // CCW interior lies left; outward is right.
451
+ const u0 = perimeter === 0 ? 0 : distance / perimeter
452
+ distance += lengths[index]!
453
+ const u1 = perimeter === 0 ? 0 : distance / perimeter
454
+ const v0 = vertex(b, [a[0], a[1], -half], normal, [u0, 0])
455
+ const v1 = vertex(b, [c[0], c[1], -half], normal, [u1, 0])
456
+ const v2 = vertex(b, [c[0], c[1], half], normal, [u1, 1])
457
+ const v3 = vertex(b, [a[0], a[1], half], normal, [u0, 1])
458
+ triangle(b, v0, v1, v2, sideSlot); triangle(b, v0, v2, v3, sideSlot)
459
+ }
460
+ orientOutward(b)
461
+ return b
462
+ }
463
+
464
+ /** Closed profile revolution: every profile edge sweeps a quad strip; axis points collapse to degenerate triangles that are dropped. */
465
+ function revolveMesh(profile: readonly V2[], seg: number, capSlot: number, path: string): Builder {
466
+ const b = builder(), count = profile.length
467
+ if (profile.some(([r]) => r < -1e-12)) fail("Revolve profiles must stay at nonnegative radius.", path)
468
+ const rings: number[][] = []
469
+ for (let point = 0; point < count; point++) {
470
+ const [r, y] = profile[point]!
471
+ const prev = profile[(point - 1 + count) % count]!, next = profile[(point + 1) % count]!
472
+ const tangent = unit2(next[0] - prev[0], next[1] - prev[1])
473
+ const outward = unit2(tangent[1], -tangent[0])
474
+ const ring: number[] = []
475
+ for (let index = 0; index <= seg; index++) {
476
+ const angle = 2 * Math.PI * index / seg, c = Math.cos(angle), s = Math.sin(angle)
477
+ ring.push(vertex(b, [r * c, y, r * s], [outward[0] * c, outward[1], outward[0] * s], [index / seg, point / (count - 1)]))
478
+ }
479
+ rings.push(ring)
480
+ }
481
+ for (let point = 0; point < count; point++) {
482
+ const next = (point + 1) % count
483
+ for (let index = 0; index < seg; index++) {
484
+ const a = rings[point]![index]!, c = rings[point]![index + 1]!, d = rings[next]![index]!, e = rings[next]![index + 1]!
485
+ triangle(b, a, d, e, capSlot); triangle(b, a, e, c, capSlot)
486
+ }
487
+ }
488
+ orientOutward(b)
489
+ return b
490
+ }
491
+
492
+ function sweepMesh(profile: readonly V2[], path: readonly (readonly number[])[], slotPath: string): Builder {
493
+ const b = builder(), count = profile.length
494
+ const points = path.map(point => [point[0]!, point[1]!, point[2]!] as Vec3)
495
+ for (let index = 0; index + 1 < points.length; index++) {
496
+ const a = points[index]!, c = points[index + 1]!
497
+ if (Math.hypot(c[0] - a[0], c[1] - a[1], c[2] - a[2]) < 1e-9) fail("Sweep path requires distinct consecutive points.", slotPath)
498
+ }
499
+ const tangents = points.map((_, index) => {
500
+ const a = points[Math.max(0, index - 1)]!, c = points[Math.min(points.length - 1, index + 1)]!
501
+ const length = Math.hypot(c[0] - a[0], c[1] - a[1], c[2] - a[2])
502
+ return [(c[0] - a[0]) / length, (c[1] - a[1]) / length, (c[2] - a[2]) / length] as Vec3
503
+ })
504
+ // Parallel-transport frames: seed a normal least-aligned with the first tangent.
505
+ let normal: Vec3 = Math.abs(tangents[0]![1]) < 0.9 ? [0, 1, 0] : [1, 0, 0]
506
+ const rings: number[][] = []
507
+ for (let station = 0; station < points.length; station++) {
508
+ const t = tangents[station]!
509
+ const projected = [normal[0] - t[0] * (normal[0] * t[0] + normal[1] * t[1] + normal[2] * t[2]),
510
+ normal[1] - t[1] * (normal[0] * t[0] + normal[1] * t[1] + normal[2] * t[2]),
511
+ normal[2] - t[2] * (normal[0] * t[0] + normal[1] * t[1] + normal[2] * t[2])] as Vec3
512
+ const nLength = Math.hypot(...projected)
513
+ if (nLength < 1e-9) fail("Sweep path curvature degenerates the transport frame.", slotPath)
514
+ normal = [projected[0] / nLength, projected[1] / nLength, projected[2] / nLength]
515
+ const binormal: Vec3 = [
516
+ t[1] * normal[2] - t[2] * normal[1], t[2] * normal[0] - t[0] * normal[2], t[0] * normal[1] - t[1] * normal[0]]
517
+ const ring: number[] = []
518
+ const center = points[station]!
519
+ for (let point = 0; point < count; point++) {
520
+ const prev = profile[(point - 1 + count) % count]!, next = profile[(point + 1) % count]!
521
+ const tangent2 = unit2(next[0] - prev[0], next[1] - prev[1])
522
+ const outward = unit2(tangent2[1], -tangent2[0])
523
+ const [px, py] = profile[point]!
524
+ ring.push(vertex(b, [
525
+ center[0] + normal[0] * px + binormal[0] * py,
526
+ center[1] + normal[1] * px + binormal[1] * py,
527
+ center[2] + normal[2] * px + binormal[2] * py,
528
+ ], [
529
+ normal[0] * outward[0] + binormal[0] * outward[1],
530
+ normal[1] * outward[0] + binormal[1] * outward[1],
531
+ normal[2] * outward[0] + binormal[2] * outward[1],
532
+ ], [point / count, station / (points.length - 1)]))
533
+ }
534
+ rings.push(ring)
535
+ }
536
+ for (let station = 0; station + 1 < rings.length; station++) {
537
+ for (let point = 0; point < count; point++) {
538
+ const next = (point + 1) % count
539
+ const a = rings[station]![point]!, c = rings[station]![next]!, d = rings[station + 1]![point]!, e = rings[station + 1]![next]!
540
+ triangle(b, a, c, e, 0); triangle(b, a, e, d, 0)
541
+ }
542
+ }
543
+ const tris = triangulate(profile, slotPath)
544
+ for (const [station, direction] of [[0, -1] as const, [points.length - 1, 1] as const]) {
545
+ const t = tangents[station]!
546
+ const capNormal: Vec3 = [t[0] * direction, t[1] * direction, t[2] * direction]
547
+ const positions = rings[station]!.map(index => b.positions.slice(index * 3, index * 3 + 3) as unknown as Vec3)
548
+ const cap = positions.map(position => vertex(b, position, capNormal))
549
+ // The transported frame may flip handedness; orient the cap by its first triangle.
550
+ const pa = positions[tris[0]!]!, pb = positions[tris[1]!]!, pc = positions[tris[2]!]!
551
+ const cx = (pb[1] - pa[1]) * (pc[2] - pa[2]) - (pb[2] - pa[2]) * (pc[1] - pa[1])
552
+ const cy = (pb[2] - pa[2]) * (pc[0] - pa[0]) - (pb[0] - pa[0]) * (pc[2] - pa[2])
553
+ const cz = (pb[0] - pa[0]) * (pc[1] - pa[1]) - (pb[1] - pa[1]) * (pc[0] - pa[0])
554
+ const flip = (cx * capNormal[0] + cy * capNormal[1] + cz * capNormal[2]) < 0
555
+ for (let index = 0; index < tris.length; index += 3) {
556
+ if (flip) triangle(b, cap[tris[index]!]!, cap[tris[index + 2]!]!, cap[tris[index + 1]!]!, 0)
557
+ else triangle(b, cap[tris[index]!]!, cap[tris[index + 1]!]!, cap[tris[index + 2]!]!, 0)
558
+ }
559
+ }
560
+ orientOutward(b)
561
+ return b
562
+ }
563
+
564
+ function loftMesh(bottom: readonly V2[], top: readonly V2[], height: number, path: string): Builder {
565
+ const b = builder(), count = bottom.length
566
+ if (top.length !== count) fail("Loft profiles must share one point count.", path)
567
+ const lower = bottom.map(([x, y]) => vertex(b, [x, 0, y], [0, -1, 0], [x, y]))
568
+ const upper = top.map(([x, y]) => vertex(b, [x, height, y], [0, 1, 0], [x, y]))
569
+ for (const [tris, ring, flip] of [[triangulate(bottom, path), lower, true] as const, [triangulate(top, path), upper, false] as const]) {
570
+ for (let index = 0; index < tris.length; index += 3) {
571
+ if (flip) triangle(b, ring[tris[index]!]!, ring[tris[index + 2]!]!, ring[tris[index + 1]!]!, 0)
572
+ else triangle(b, ring[tris[index]!]!, ring[tris[index + 1]!]!, ring[tris[index + 2]!]!, 0)
573
+ }
574
+ }
575
+ const centroid = bottom.reduce((acc, point) => [acc[0] + point[0] / count, acc[1] + point[1] / count] as V2, [0, 0] as V2)
576
+ for (let index = 0; index < count; index++) {
577
+ const next = (index + 1) % count
578
+ const a = bottom[index]!, c = bottom[next]!, d = top[index]!, e = top[next]!
579
+ const ux = c[0] - a[0], uz = c[1] - a[1]
580
+ const wx = d[0] - a[0], wy = height, wz = d[1] - a[1]
581
+ // Geometric normal of (edge x span); flipped away from the profile centroid,
582
+ // and the winding flips with it so geometry and stored normals agree.
583
+ let nx = -(uz * wy), ny = uz * wx - ux * wz, nz = ux * wy
584
+ const midX = (a[0] + c[0] + d[0] + e[0]) / 4 - centroid[0], midZ = (a[1] + c[1] + d[1] + e[1]) / 4 - centroid[1]
585
+ const inward = nx * midX + nz * midZ < 0
586
+ if (inward) { nx = -nx; ny = -ny; nz = -nz }
587
+ const nLength = Math.hypot(nx, ny, nz) || 1
588
+ const normal: Vec3 = [nx / nLength, ny / nLength, nz / nLength]
589
+ const v0 = vertex(b, [a[0], 0, a[1]], normal), v1 = vertex(b, [c[0], 0, c[1]], normal)
590
+ const v2 = vertex(b, [e[0], height, e[1]], normal), v3 = vertex(b, [d[0], height, d[1]], normal)
591
+ if (inward) { triangle(b, v0, v3, v2, 0); triangle(b, v0, v2, v1, 0) }
592
+ else { triangle(b, v0, v1, v2, 0); triangle(b, v0, v2, v3, 0) }
593
+ }
594
+ orientOutward(b)
595
+ return b
596
+ }
597
+
598
+ // ---------------------------------------------------------------------------
599
+ // Mesh modifiers
600
+ // ---------------------------------------------------------------------------
601
+
602
+ function transformMesh(input: Builder, transform: SpatialTransform): Builder {
603
+ const b = builder()
604
+ const matrix = composeTransform(transform)
605
+ const inverse = invertTransform(matrix)
606
+ // Normals transform by the inverse-transpose of the upper 3x3; in column-major
607
+ // storage that is column i of M^-1 dotted with n, i.e. indices i*4+j.
608
+ for (let index = 0; index < input.positions.length; index += 3) {
609
+ const position = transformPoint(matrix, [input.positions[index]!, input.positions[index + 1]!, input.positions[index + 2]!])
610
+ const n0 = input.normals[index]!, n1 = input.normals[index + 1]!, n2 = input.normals[index + 2]!
611
+ const normal: Vec3 = [
612
+ inverse[0]! * n0 + inverse[1]! * n1 + inverse[2]! * n2,
613
+ inverse[4]! * n0 + inverse[5]! * n1 + inverse[6]! * n2,
614
+ inverse[8]! * n0 + inverse[9]! * n1 + inverse[10]! * n2,
615
+ ]
616
+ const length = Math.hypot(normal[0], normal[1], normal[2]) || 1
617
+ vertex(b, position, [normal[0] / length, normal[1] / length, normal[2] / length], [input.uvs[(index / 3) * 2]!, input.uvs[(index / 3) * 2 + 1]!])
618
+ }
619
+ b.indices.push(...input.indices); b.slots.push(...input.slots); b.uvsSet = input.uvsSet
620
+ return b
621
+ }
622
+
623
+ function mirrorMesh(input: Builder, axisIndex: 0 | 1 | 2, offset: number): Builder {
624
+ const b = builder()
625
+ for (let index = 0; index < input.positions.length; index += 3) {
626
+ const position: number[] = [input.positions[index]!, input.positions[index + 1]!, input.positions[index + 2]!]
627
+ const normal: number[] = [input.normals[index]!, input.normals[index + 1]!, input.normals[index + 2]!]
628
+ position[axisIndex] = 2 * offset - position[axisIndex]!
629
+ normal[axisIndex] = -normal[axisIndex]!
630
+ vertex(b, position as unknown as Vec3, normal as unknown as Vec3, [input.uvs[(index / 3) * 2]!, input.uvs[(index / 3) * 2 + 1]!])
631
+ }
632
+ for (let index = 0; index < input.indices.length; index += 3) {
633
+ triangle(b, input.indices[index]!, input.indices[index + 2]!, input.indices[index + 1]!, input.slots[index / 3]!)
634
+ }
635
+ b.uvsSet = input.uvsSet
636
+ return b
637
+ }
638
+
639
+ function arrayMesh(input: Builder, count: number, step: readonly number[]): Builder {
640
+ const b = builder(), vertices = input.positions.length / 3
641
+ for (let copy = 0; copy < count; copy++) {
642
+ const offset = [copy * step[0]!, copy * step[1]!, copy * step[2]!]
643
+ for (let index = 0; index < input.positions.length; index += 3) {
644
+ vertex(b, [input.positions[index]! + offset[0]!, input.positions[index + 1]! + offset[1]!, input.positions[index + 2]! + offset[2]!],
645
+ [input.normals[index]!, input.normals[index + 1]!, input.normals[index + 2]!],
646
+ [input.uvs[(index / 3) * 2]!, input.uvs[(index / 3) * 2 + 1]!])
647
+ }
648
+ for (let index = 0; index < input.indices.length; index += 3) {
649
+ triangle(b, input.indices[index]! + copy * vertices, input.indices[index + 1]! + copy * vertices, input.indices[index + 2]! + copy * vertices, input.slots[index / 3]!)
650
+ }
651
+ }
652
+ b.uvsSet = input.uvsSet
653
+ return b
654
+ }
655
+
656
+ function mergeMeshes(inputs: readonly Builder[]): Builder {
657
+ const b = builder()
658
+ for (const input of inputs) {
659
+ const base = input.positions.length === 0 ? 0 : b.positions.length / 3
660
+ for (let index = 0; index < input.positions.length; index += 3) {
661
+ vertex(b, [input.positions[index]!, input.positions[index + 1]!, input.positions[index + 2]!],
662
+ [input.normals[index]!, input.normals[index + 1]!, input.normals[index + 2]!],
663
+ [input.uvs[(index / 3) * 2]!, input.uvs[(index / 3) * 2 + 1]!])
664
+ }
665
+ for (let index = 0; index < input.indices.length; index += 3) {
666
+ b.indices.push(input.indices[index]! + base, input.indices[index + 1]! + base, input.indices[index + 2]! + base)
667
+ b.slots.push(input.slots[index / 3]!)
668
+ }
669
+ b.uvsSet = b.uvsSet || input.uvsSet
670
+ }
671
+ return b
672
+ }
673
+
674
+ // ---------------------------------------------------------------------------
675
+ // Limited hull boolean
676
+ // ---------------------------------------------------------------------------
677
+
678
+ interface Plane { readonly normal: Vec3; readonly d: number }
679
+
680
+ const PLANE_EPS = 1e-9
681
+
682
+ function trianglePlane(b: Builder, index: number): Plane | null {
683
+ const a = b.indices[index]! * 3, c = b.indices[index + 1]! * 3, d = b.indices[index + 2]! * 3
684
+ const ux = b.positions[c]! - b.positions[a]!, uy = b.positions[c + 1]! - b.positions[a + 1]!, uz = b.positions[c + 2]! - b.positions[a + 2]!
685
+ const vx = b.positions[d]! - b.positions[a]!, vy = b.positions[d + 1]! - b.positions[a + 1]!, vz = b.positions[d + 2]! - b.positions[a + 2]!
686
+ const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
687
+ const length = Math.hypot(nx, ny, nz)
688
+ if (length < 1e-12) return null
689
+ const normal: Vec3 = [nx / length, ny / length, nz / length]
690
+ return { normal, d: normal[0] * b.positions[a]! + normal[1] * b.positions[a + 1]! + normal[2] * b.positions[a + 2]! }
691
+ }
692
+
693
+ function uniquePlanes(b: Builder, path: string): readonly Plane[] {
694
+ const planes: Plane[] = []
695
+ for (let index = 0; index < b.indices.length; index += 3) {
696
+ const plane = trianglePlane(b, index)
697
+ if (plane === null) continue
698
+ if (!planes.some(other => Math.abs(other.d - plane.d) < 1e-7
699
+ && Math.abs(other.normal[0] - plane.normal[0]) < 1e-7
700
+ && Math.abs(other.normal[1] - plane.normal[1]) < 1e-7
701
+ && Math.abs(other.normal[2] - plane.normal[2]) < 1e-7)) planes.push(plane)
702
+ if (planes.length > SPATIAL_GEOMETRY_LIMITS.booleanPlanes) fail("Hull boolean inputs may carry at most 64 support planes.", path)
703
+ }
704
+ return Object.freeze(planes)
705
+ }
706
+
707
+ /** Every vertex must lie on or inside each support plane for a hull closed input. */
708
+ function assertHull(b: Builder, planes: readonly Plane[], path: string): void {
709
+ let extent = 1
710
+ for (let index = 0; index < b.positions.length; index++) extent = Math.max(extent, Math.abs(b.positions[index]!))
711
+ const epsilon = 1e-7 * extent
712
+ for (const plane of planes) {
713
+ for (let index = 0; index < b.positions.length; index += 3) {
714
+ const signed = plane.normal[0] * b.positions[index]! + plane.normal[1] * b.positions[index + 1]! + plane.normal[2] * b.positions[index + 2]! - plane.d
715
+ if (signed > epsilon) fail("Boolean inputs must be closed hull meshes; a vertex lies outside a support plane.", path)
716
+ }
717
+ }
718
+ }
719
+
720
+ interface Fragment { readonly positions: Vec3[]; readonly uvs: V2[]; readonly slot: number }
721
+
722
+ function clipFragment(fragment: Fragment, plane: Plane, keepInside: boolean): Fragment | null {
723
+ const output: Vec3[] = [], uvOut: V2[] = []
724
+ const signed = fragment.positions.map(position => plane.normal[0] * position[0] + plane.normal[1] * position[1] + plane.normal[2] * position[2] - plane.d)
725
+ const inside = signed.map(value => keepInside ? value <= PLANE_EPS : value >= -PLANE_EPS)
726
+ for (let index = 0; index < fragment.positions.length; index++) {
727
+ const next = (index + 1) % fragment.positions.length
728
+ const a = fragment.positions[index]!, c = fragment.positions[next]!
729
+ if (inside[index]) { output.push(a); uvOut.push(fragment.uvs[index]!) }
730
+ if (inside[index] !== inside[next]) {
731
+ const t = signed[index]! / (signed[index]! - signed[next]!)
732
+ output.push([a[0] + (c[0] - a[0]) * t, a[1] + (c[1] - a[1]) * t, a[2] + (c[2] - a[2]) * t])
733
+ uvOut.push([fragment.uvs[index]![0] + (fragment.uvs[next]![0] - fragment.uvs[index]![0]) * t,
734
+ fragment.uvs[index]![1] + (fragment.uvs[next]![1] - fragment.uvs[index]![1]) * t])
735
+ }
736
+ }
737
+ return output.length >= 3 ? { positions: output, uvs: uvOut, slot: fragment.slot } : null
738
+ }
739
+
740
+ /** Partitions every source triangle by all planes into cells that cross none. */
741
+ function partitionTriangles(input: Builder, planes: readonly Plane[]): Fragment[] {
742
+ const result: Fragment[] = []
743
+ for (let index = 0; index < input.indices.length; index += 3) {
744
+ const positions = [0, 1, 2].map(part => input.positions.slice(input.indices[index + part]! * 3, input.indices[index + part]! * 3 + 3) as unknown as Vec3)
745
+ const uvs = [0, 1, 2].map(part => [input.uvs[input.indices[index + part]! * 2]!, input.uvs[input.indices[index + part]! * 2 + 1]!] as V2)
746
+ let fragments: Fragment[] = [{ positions, uvs, slot: input.slots[index / 3]! }]
747
+ for (const plane of planes) {
748
+ const next: Fragment[] = []
749
+ for (const fragment of fragments) {
750
+ const inner = clipFragment(fragment, plane, true)
751
+ const outer = clipFragment(fragment, plane, false)
752
+ if (inner !== null) next.push(inner)
753
+ if (outer !== null) next.push(outer)
754
+ }
755
+ fragments = next
756
+ }
757
+ result.push(...fragments)
758
+ }
759
+ return result
760
+ }
761
+
762
+ /** Hull-solid membership: the fragment centroid must satisfy every support plane. */
763
+ function fragmentInside(planes: readonly Plane[], fragment: Fragment): boolean {
764
+ const count = fragment.positions.length
765
+ const centroid: Vec3 = [
766
+ fragment.positions.reduce((sum, p) => sum + p[0], 0) / count,
767
+ fragment.positions.reduce((sum, p) => sum + p[1], 0) / count,
768
+ fragment.positions.reduce((sum, p) => sum + p[2], 0) / count,
769
+ ]
770
+ return planes.every(plane => plane.normal[0] * centroid[0] + plane.normal[1] * centroid[1] + plane.normal[2] * centroid[2] - plane.d <= PLANE_EPS)
771
+ }
772
+
773
+ function emitFragments(b: Builder, fragments: readonly Fragment[], flip: boolean): void {
774
+ for (const fragment of fragments) {
775
+ let z = 0
776
+ for (let index = 0; index < fragment.positions.length; index++) {
777
+ const a = fragment.positions[index]!, c = fragment.positions[(index + 1) % fragment.positions.length]!
778
+ z += a[0] * c[1] - a[1] * c[0]
779
+ }
780
+ const unit: Vec3 = z < 0 ? [0, 0, -1] : [0, 0, 1]
781
+ const oriented: Vec3 = flip ? [-unit[0], -unit[1], -unit[2]] : unit
782
+ const indices = fragment.positions.map((position, index) => vertex(b, position, oriented, fragment.uvs[index]!))
783
+ for (let index = 1; index + 1 < indices.length; index++) {
784
+ if (flip) triangle(b, indices[0]!, indices[index + 1]!, indices[index]!, fragment.slot)
785
+ else triangle(b, indices[0]!, indices[index]!, indices[index + 1]!, fragment.slot)
786
+ }
787
+ }
788
+ }
789
+
790
+ /**
791
+ * Bounded simple CSG: union/intersection over two hull inputs, or difference
792
+ * of one hull input against a bounded set of hull cutters (intermediate
793
+ * results may be non-hull; recursion is still forbidden because each node is
794
+ * a single bounded pass). Coplanar coincidences resolve by epsilon.
795
+ */
796
+ function booleanMesh(input: Extract<SpatialGeometryNode, { kind: "boolean" }>, meshes: ReadonlyMap<string, Builder>, path: string): Builder {
797
+ const a = meshes.get(input.a)!
798
+ const cutters = input.operation === "difference" ? input.cutters.map(id => meshes.get(id)!) : [meshes.get(input.b)!]
799
+ for (const [index, mesh] of [a, ...cutters].entries()) {
800
+ if (mesh.indices.length / 3 > SPATIAL_GEOMETRY_LIMITS.booleanInputTriangles) {
801
+ fail("Boolean inputs exceed the bounded simple-input triangle budget.", `${path}.${index}`)
802
+ }
803
+ }
804
+ const planesA = uniquePlanes(a, path)
805
+ const planesC = cutters.map(cutter => uniquePlanes(cutter, path))
806
+ assertHull(a, planesA, path)
807
+ cutters.forEach((cutter, index) => assertHull(cutter, planesC[index]!, path))
808
+ const output = builder()
809
+ if (input.operation === "difference") {
810
+ const allCutterPlanes = planesC.flat()
811
+ const kept = partitionTriangles(a, allCutterPlanes).filter(piece => !planesC.some(planes => fragmentInside(planes, piece)))
812
+ emitFragments(output, kept, false)
813
+ for (const [index, cutter] of cutters.entries()) {
814
+ const caps = partitionTriangles(cutter, planesA).filter(piece =>
815
+ fragmentInside(planesA, piece) && !planesC.some((other, otherIndex) => otherIndex !== index && fragmentInside(other, piece)))
816
+ emitFragments(output, caps, true)
817
+ }
818
+ } else {
819
+ const planesB = planesC[0]!, b = cutters[0]!
820
+ const piecesA = partitionTriangles(a, planesB), piecesB = partitionTriangles(b, planesA)
821
+ if (input.operation === "intersection") {
822
+ emitFragments(output, piecesA.filter(piece => fragmentInside(planesB, piece)), false)
823
+ emitFragments(output, piecesB.filter(piece => fragmentInside(planesA, piece)), false)
824
+ } else {
825
+ emitFragments(output, piecesA.filter(piece => !fragmentInside(planesB, piece)), false)
826
+ emitFragments(output, piecesB.filter(piece => !fragmentInside(planesA, piece)), false)
827
+ }
828
+ }
829
+ output.uvsSet = [a, ...cutters].some(mesh => mesh.uvsSet)
830
+ if (output.indices.length === 0) fail("Boolean result is empty; the inputs do not overlap as the operation requires.", path)
831
+ orientOutward(output)
832
+ return output
833
+ }
834
+
835
+ // ---------------------------------------------------------------------------
836
+ // UV projection and material slots
837
+ // ---------------------------------------------------------------------------
838
+
839
+ const AXIS_INDEX: Record<"x" | "y" | "z", 0 | 1 | 2> = { x: 0, y: 1, z: 2 }
840
+
841
+ function projectUvs(input: Builder, projection: Extract<SpatialGeometryNode, { kind: "uv-project" }>["projection"]): Builder {
842
+ const b = builder()
843
+ b.positions.push(...input.positions); b.normals.push(...input.normals)
844
+ b.indices.push(...input.indices); b.slots.push(...input.slots)
845
+ for (let index = 0; index < input.positions.length; index += 3) {
846
+ const p: Vec3 = [input.positions[index]!, input.positions[index + 1]!, input.positions[index + 2]!]
847
+ const n: Vec3 = [input.normals[index]!, input.normals[index + 1]!, input.normals[index + 2]!]
848
+ let uv: V2
849
+ if (projection.mode === "box") {
850
+ const axisIndex = Math.abs(n[0]) >= Math.abs(n[1]) && Math.abs(n[0]) >= Math.abs(n[2]) ? 0 : Math.abs(n[1]) >= Math.abs(n[2]) ? 1 : 2
851
+ const pair = [0, 1, 2].filter(part => part !== axisIndex)
852
+ uv = [p[pair[0]!]! * projection.scale, p[pair[1]!]! * projection.scale]
853
+ } else if (projection.mode === "planar") {
854
+ const pair = [0, 1, 2].filter(part => part !== AXIS_INDEX[projection.axis])
855
+ uv = [p[pair[0]!]! * projection.scale + (projection.offset?.[0] ?? 0), p[pair[1]!]! * projection.scale + (projection.offset?.[1] ?? 0)]
856
+ } else {
857
+ const axisIndex = AXIS_INDEX[projection.axis]
858
+ const pair = [0, 1, 2].filter(part => part !== axisIndex)
859
+ uv = [Math.atan2(p[pair[1]!]!, p[pair[0]!]!) / (2 * Math.PI) + 0.5, p[axisIndex]! * projection.scale]
860
+ }
861
+ b.uvs.push(uv[0], uv[1])
862
+ }
863
+ b.uvsSet = true
864
+ return b
865
+ }
866
+
867
+ // ---------------------------------------------------------------------------
868
+ // Graph validation, static budget, evaluation
869
+ // ---------------------------------------------------------------------------
870
+
871
+ const PROFILE_KINDS = new Set(["profile", "rect", "ellipse", "inset", "bevel"])
872
+
873
+ function nodeDependencies(node: SpatialGeometryNode): readonly string[] {
874
+ switch (node.kind) {
875
+ case "inset": case "bevel": case "transform": case "mirror": case "array": case "material-slot": case "uv-project":
876
+ return [node.input]
877
+ case "extrude": case "revolve": case "sweep": return [node.profile]
878
+ case "loft": return [node.bottom, node.top]
879
+ case "merge": return node.inputs
880
+ case "boolean": return node.operation === "difference" ? [node.a, ...node.cutters] : [node.a, node.b]
881
+ default: return []
882
+ }
883
+ }
884
+
885
+
886
+
887
+ /** Full DAG validation: schema, ids, reference closure, node arity kinds, cycles and depth. */
888
+ export function parseSpatialGeometryGraph(input: unknown): SpatialGeometryGraph {
889
+ const graph = parseSpatialValue(SpatialGeometryGraphSchema, input, "geometry graph")
890
+ const nodesById = new Map<string, SpatialGeometryNode>()
891
+ for (const node of graph.nodes) {
892
+ if (nodesById.has(node.id)) fail(`Duplicate geometry node ${node.id}.`, "geometry.nodes")
893
+ nodesById.set(node.id, node)
894
+ }
895
+ for (const node of graph.nodes) {
896
+ for (const dependency of nodeDependencies(node)) {
897
+ const target = nodesById.get(dependency)
898
+ if (target === undefined) fail(`Geometry node ${node.id} references missing ${dependency}.`, `geometry.nodes.${node.id}`)
899
+ const expectsProfile = (node.kind === "inset" || node.kind === "bevel" || node.kind === "extrude" || node.kind === "revolve" || node.kind === "sweep" || node.kind === "loft")
900
+ if (expectsProfile !== PROFILE_KINDS.has(target.kind)) {
901
+ fail(`Geometry node ${node.id} wires a ${expectsProfile ? "mesh" : "profile"} output into a ${expectsProfile ? "profile" : "mesh"} input.`, `geometry.nodes.${node.id}`)
902
+ }
903
+ }
904
+ }
905
+ const order = spatialTopologicalIds(new Map(graph.nodes.map(node => [node.id, nodeDependencies(node)])), "geometry graph")
906
+ const depth = new Map<string, number>()
907
+ for (const id of order) {
908
+ const node = nodesById.get(id)!
909
+ const value = 1 + Math.max(0, ...nodeDependencies(node).map(dependency => depth.get(dependency)!))
910
+ if (value > SPATIAL_GEOMETRY_LIMITS.depth) fail(`Geometry graph exceeds the ${SPATIAL_GEOMETRY_LIMITS.depth}-node evaluation depth bound.`, `geometry.nodes.${id}`)
911
+ depth.set(id, value)
912
+ }
913
+ if (!nodesById.has(graph.output)) fail("Geometry output names a missing node.", "geometry.output")
914
+ if (PROFILE_KINDS.has(nodesById.get(graph.output)!.kind)) fail("Geometry output must evaluate to a mesh, not a profile.", "geometry.output")
915
+ return deepFreezeJson(graph)
916
+ }
917
+
918
+ interface NodeEstimate { readonly vertices: number; readonly triangles: number; readonly planes: number; readonly bounds: Bounds }
919
+ interface ProfileEstimate { readonly points: number; readonly bounds: Bounds }
920
+
921
+ function profileBounds(points: readonly V2[]): Bounds {
922
+ const xs = points.map(point => point[0]), ys = points.map(point => point[1])
923
+ return { min: [Math.min(...xs), Math.min(...ys), 0], max: [Math.max(...xs), Math.max(...ys), 0] }
924
+ }
925
+
926
+ function boundsUnion(a: Bounds, b: Bounds): Bounds {
927
+ return { min: [Math.min(a.min[0], b.min[0]), Math.min(a.min[1], b.min[1]), Math.min(a.min[2], b.min[2])],
928
+ max: [Math.max(a.max[0], b.max[0]), Math.max(a.max[1], b.max[1]), Math.max(a.max[2], b.max[2])] }
929
+ }
930
+
931
+ function boundsOffset(bounds: Bounds, delta: readonly number[]): Bounds {
932
+ return { min: [bounds.min[0] + delta[0]!, bounds.min[1] + delta[1]!, bounds.min[2] + delta[2]!],
933
+ max: [bounds.max[0] + delta[0]!, bounds.max[1] + delta[1]!, bounds.max[2] + delta[2]!] }
934
+ }
935
+
936
+ function inflateBounds(bounds: Bounds, radius: number): Bounds {
937
+ return { min: [bounds.min[0] - radius, bounds.min[1] - radius, bounds.min[2] - radius],
938
+ max: [bounds.max[0] + radius, bounds.max[1] + radius, bounds.max[2] + radius] }
939
+ }
940
+
941
+ function estimateNode(node: SpatialGeometryNode, meshEstimates: ReadonlyMap<string, NodeEstimate>, profileEstimates: ReadonlyMap<string, ProfileEstimate>, _path: string): NodeEstimate | ProfileEstimate {
942
+ const mesh = (id: string) => meshEstimates.get(id)!
943
+ const profile = (id: string) => profileEstimates.get(id)!
944
+ switch (node.kind) {
945
+ case "profile": return { points: node.points.length, bounds: profileBounds(node.points) }
946
+ case "rect": return { points: 4, bounds: { min: [-node.width / 2, -node.height / 2, 0], max: [node.width / 2, node.height / 2, 0] } }
947
+ case "ellipse": return { points: node.segments, bounds: { min: [-node.radiusX, -node.radiusY, 0], max: [node.radiusX, node.radiusY, 0] } }
948
+ case "inset": return { points: profile(node.input).points, bounds: profile(node.input).bounds }
949
+ case "bevel": {
950
+ const source = profile(node.input)
951
+ return { points: source.points * (node.segments + 1), bounds: source.bounds }
952
+ }
953
+ case "box": {
954
+ const [x, y, z] = [node.size[0] / 2, node.size[1] / 2, node.size[2] / 2]
955
+ return { vertices: 24, triangles: 12, planes: 6, bounds: { min: [-x, -y, -z], max: [x, y, z] } }
956
+ }
957
+ case "cylinder": return { vertices: 4 * node.segments + 6, triangles: 4 * node.segments, planes: node.segments + 2,
958
+ bounds: { min: [-node.radius, -node.height / 2, -node.radius], max: [node.radius, node.height / 2, node.radius] } }
959
+ case "sphere": {
960
+ const latitudes = Math.max(2, Math.floor(node.segments / 2))
961
+ return { vertices: (node.segments + 1) * (latitudes - 1) + 2, triangles: 2 * node.segments * latitudes, planes: node.segments * latitudes,
962
+ bounds: { min: [-node.radius, -node.radius, -node.radius], max: [node.radius, node.radius, node.radius] } }
963
+ }
964
+ case "extrude": {
965
+ const p = profile(node.profile), count = p.points
966
+ return { vertices: 6 * count, triangles: 4 * count, planes: count + 2,
967
+ bounds: { min: [p.bounds.min[0], p.bounds.min[1], -node.depth / 2], max: [p.bounds.max[0], p.bounds.max[1], node.depth / 2] } }
968
+ }
969
+ case "revolve": {
970
+ const p = profile(node.profile)
971
+ const radius = Math.max(Math.abs(p.bounds.min[0]), Math.abs(p.bounds.max[0]))
972
+ return { vertices: p.points * (node.segments + 1), triangles: 4 * p.points * node.segments, planes: p.points * node.segments + p.points,
973
+ bounds: { min: [-radius, p.bounds.min[1], -radius], max: [radius, p.bounds.max[1], radius] } }
974
+ }
975
+ case "sweep": {
976
+ const p = profile(node.profile)
977
+ const radius = Math.hypot(Math.max(Math.abs(p.bounds.min[0]), Math.abs(p.bounds.max[0])), Math.max(Math.abs(p.bounds.min[1]), Math.abs(p.bounds.max[1])))
978
+ const pathBounds = node.path.reduce((acc, point) => boundsUnion(acc, { min: point, max: point }), { min: node.path[0]!, max: node.path[0]! } as Bounds)
979
+ const count = node.path.length
980
+ return { vertices: p.points * count + 2 * p.points, triangles: 4 * p.points * count, planes: p.points * count + 2, bounds: inflateBounds(pathBounds, radius) }
981
+ }
982
+ case "loft": {
983
+ const a = profile(node.bottom), c = profile(node.top), count = a.points + c.points
984
+ const bounds = boundsUnion({ min: [a.bounds.min[0], 0, a.bounds.min[1]], max: [a.bounds.max[0], 0, a.bounds.max[1]] },
985
+ { min: [c.bounds.min[0], node.height, c.bounds.min[1]], max: [c.bounds.max[0], node.height, c.bounds.max[1]] })
986
+ return { vertices: 7 * count, triangles: 4 * count, planes: count + 2, bounds }
987
+ }
988
+ case "transform": {
989
+ const source = mesh(node.input)
990
+ return { ...source, bounds: transformBounds(composeTransform(node.transform), source.bounds) }
991
+ }
992
+ case "mirror": {
993
+ const source = mesh(node.input), index = AXIS_INDEX[node.axis]
994
+ const bounds = { min: [...source.bounds.min], max: [...source.bounds.max] }
995
+ const low = 2 * node.offset - bounds.max[index]!, high = 2 * node.offset - bounds.min[index]!
996
+ bounds.min[index] = low; bounds.max[index] = high
997
+ return { ...source, bounds: bounds as unknown as Bounds }
998
+ }
999
+ case "array": {
1000
+ const source = mesh(node.input)
1001
+ let bounds = source.bounds
1002
+ for (let copy = 1; copy < node.count; copy++) bounds = boundsUnion(bounds, boundsOffset(source.bounds, [copy * node.step[0], copy * node.step[1], copy * node.step[2]]))
1003
+ return { vertices: source.vertices * node.count, triangles: source.triangles * node.count, planes: source.planes * node.count, bounds }
1004
+ }
1005
+ case "merge": {
1006
+ const inputs = node.inputs.map(mesh)
1007
+ return { vertices: inputs.reduce((sum, item) => sum + item.vertices, 0), triangles: inputs.reduce((sum, item) => sum + item.triangles, 0),
1008
+ planes: inputs.reduce((sum, item) => sum + item.planes, 0), bounds: inputs.map(input => input.bounds).reduce(boundsUnion) }
1009
+ }
1010
+ case "boolean": {
1011
+ const a = mesh(node.a)
1012
+ const cutters = node.operation === "difference" ? node.cutters.map(mesh) : [mesh(node.b)]
1013
+ const triangles = a.triangles * (1 + cutters.reduce((sum, cutter) => sum + cutter.planes, 0))
1014
+ + cutters.reduce((sum, cutter) => sum + cutter.triangles * (a.planes + 1), 0)
1015
+ const other = cutters[0]!
1016
+ const bounds = node.operation === "intersection"
1017
+ ? { min: [Math.max(a.bounds.min[0], other.bounds.min[0]), Math.max(a.bounds.min[1], other.bounds.min[1]), Math.max(a.bounds.min[2], other.bounds.min[2])] as unknown as Vec3,
1018
+ max: [Math.min(a.bounds.max[0], other.bounds.max[0]), Math.min(a.bounds.max[1], other.bounds.max[1]), Math.min(a.bounds.max[2], other.bounds.max[2])] as unknown as Vec3 }
1019
+ : node.operation === "union" ? boundsUnion(a.bounds, other.bounds) : a.bounds
1020
+ return { vertices: 3 * triangles, triangles, planes: a.planes + cutters.reduce((sum, cutter) => sum + cutter.planes, 0), bounds }
1021
+ }
1022
+ case "material-slot": case "uv-project": return mesh(node.input)
1023
+ }
1024
+ }
1025
+
1026
+ /** Static pre-execution budget: every estimate is a closed over-approximation of the emitted mesh. */
1027
+ export function estimateSpatialGeometryGraph(input: unknown): SpatialGeometryEstimate {
1028
+ const graph = parseSpatialGeometryGraph(input)
1029
+ const nodesById = new Map(graph.nodes.map(node => [node.id, node]))
1030
+ const order = spatialTopologicalIds(new Map(graph.nodes.map(node => [node.id, nodeDependencies(node)])), "geometry graph")
1031
+ const meshEstimates = new Map<string, NodeEstimate>(), profileEstimates = new Map<string, ProfileEstimate>()
1032
+ let depth = 0
1033
+ const depthOf = new Map<string, number>()
1034
+ for (const id of order) {
1035
+ const node = nodesById.get(id)!
1036
+ depthOf.set(id, 1 + Math.max(0, ...nodeDependencies(node).map(dependency => depthOf.get(dependency)!)))
1037
+ depth = Math.max(depth, depthOf.get(id)!)
1038
+ const estimate = estimateNode(node, meshEstimates, profileEstimates, `geometry.nodes.${id}`)
1039
+ if (PROFILE_KINDS.has(node.kind)) {
1040
+ const candidate = estimate as ProfileEstimate
1041
+ if (candidate.points > SPATIAL_GEOMETRY_LIMITS.profilePoints) fail(`Profile node ${id} exceeds the point budget before evaluation.`, `geometry.nodes.${id}`)
1042
+ profileEstimates.set(id, candidate)
1043
+ } else {
1044
+ const candidate = estimate as NodeEstimate
1045
+ if (candidate.vertices > SPATIAL_GEOMETRY_LIMITS.vertices || candidate.triangles > SPATIAL_GEOMETRY_LIMITS.triangles) {
1046
+ fail(`Geometry node ${id} exceeds the vertex/triangle budget before evaluation.`, `geometry.nodes.${id}`)
1047
+ }
1048
+ meshEstimates.set(id, candidate)
1049
+ }
1050
+ }
1051
+ const output = meshEstimates.get(graph.output)!
1052
+ const bytes = output.vertices * 32 + output.triangles * 16
1053
+ if (bytes > SPATIAL_GEOMETRY_LIMITS.outputBytes) fail("Geometry output exceeds the byte budget before evaluation.", "geometry.output")
1054
+ return deepFreezeJson({ nodes: graph.nodes.length, depth, vertices: output.vertices, triangles: output.triangles, bytes, bounds: output.bounds })
1055
+ }
1056
+
1057
+ /** Deterministic evaluation of a validated graph into flat mesh data. */
1058
+ export function evaluateSpatialGeometry(input: unknown): SpatialGeometryEvaluation {
1059
+ const graph = parseSpatialGeometryGraph(input)
1060
+ const estimate = estimateSpatialGeometryGraph(graph)
1061
+ const nodesById = new Map(graph.nodes.map(node => [node.id, node]))
1062
+ const order = spatialTopologicalIds(new Map(graph.nodes.map(node => [node.id, nodeDependencies(node)])), "geometry graph")
1063
+ const profiles = new Map<string, readonly V2[]>(), meshes = new Map<string, Builder>()
1064
+ for (const id of order) {
1065
+ const node = nodesById.get(id)!, path = `geometry.nodes.${id}`
1066
+ const profile = (key: string) => profiles.get(key)!
1067
+ const mesh = (key: string) => meshes.get(key)!
1068
+ switch (node.kind) {
1069
+ case "profile": profiles.set(id, checkedProfile(node.points, path)); break
1070
+ case "rect": profiles.set(id, checkedProfile([[-node.width / 2, -node.height / 2], [node.width / 2, -node.height / 2], [node.width / 2, node.height / 2], [-node.width / 2, node.height / 2]], path)); break
1071
+ case "ellipse": profiles.set(id, checkedProfile(Array.from({ length: node.segments }, (_, index) => {
1072
+ const angle = 2 * Math.PI * index / node.segments
1073
+ return [node.radiusX * Math.cos(angle), node.radiusY * Math.sin(angle)] as V2
1074
+ }), path)); break
1075
+ case "inset": profiles.set(id, insetProfile(profile(node.input), node.distance, path)); break
1076
+ case "bevel": profiles.set(id, bevelProfile(profile(node.input), node.radius, node.segments, path)); break
1077
+ case "box": meshes.set(id, boxMesh(node.size)); break
1078
+ case "cylinder": meshes.set(id, cylinderMesh(node.radius, node.height, node.segments)); break
1079
+ case "sphere": meshes.set(id, sphereMesh(node.radius, node.segments)); break
1080
+ case "extrude": meshes.set(id, extrudeMesh(profile(node.profile), node.depth, node.sideSlot ?? 0, node.capSlot ?? node.sideSlot ?? 0, path)); break
1081
+ case "revolve": meshes.set(id, revolveMesh(profile(node.profile), node.segments, node.capSlot ?? 0, path)); break
1082
+ case "sweep": meshes.set(id, sweepMesh(profile(node.profile), node.path, path)); break
1083
+ case "loft": meshes.set(id, loftMesh(profile(node.bottom), profile(node.top), node.height, path)); break
1084
+ case "transform": meshes.set(id, transformMesh(mesh(node.input), node.transform)); break
1085
+ case "mirror": meshes.set(id, mirrorMesh(mesh(node.input), AXIS_INDEX[node.axis], node.offset)); break
1086
+ case "array": meshes.set(id, arrayMesh(mesh(node.input), node.count, node.step)); break
1087
+ case "merge": meshes.set(id, mergeMeshes(node.inputs.map(mesh))); break
1088
+ case "boolean": meshes.set(id, booleanMesh(node, meshes, path)); break
1089
+ case "material-slot": {
1090
+ const source = mesh(node.input), copy = builder()
1091
+ copy.positions.push(...source.positions); copy.normals.push(...source.normals); copy.uvs.push(...source.uvs)
1092
+ copy.indices.push(...source.indices); copy.slots.push(...source.slots.map(() => node.slot)); copy.uvsSet = source.uvsSet
1093
+ meshes.set(id, copy); break
1094
+ }
1095
+ case "uv-project": meshes.set(id, projectUvs(mesh(node.input), node.projection)); break
1096
+ }
1097
+ }
1098
+ const mesh = freezeMesh(meshes.get(graph.output)!)
1099
+ if (mesh.vertices > estimate.vertices || mesh.triangles > estimate.triangles) {
1100
+ fail("Evaluated mesh exceeded its static budget estimate.", "geometry.output")
1101
+ }
1102
+ return deepFreezeJson({ mesh, estimate, graphSha256: spatialValueSha256({ domain: "slopcamera.spatial-geometry-graph.v1", graph }) })
1103
+ }
1104
+
1105
+ // ---------------------------------------------------------------------------
1106
+ // Deterministic GLB emission inside the closed parseSpatialGlb profile
1107
+ // ---------------------------------------------------------------------------
1108
+
1109
+ const srgbToLinear = (value: number) => value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4)
1110
+
1111
+ /**
1112
+ * Maps authored materials into the closed GLB profile: no extensions, so
1113
+ * unlit surfaces carry their color as emissive and emissive intensity is
1114
+ * clamped into the [0,1] emissiveFactor range.
1115
+ */
1116
+ function materialToGlb(material: SpatialMaterial): { readonly baseColorLinear: readonly number[]; readonly metallic: number; readonly roughness: number; readonly alphaMode: "OPAQUE" | "BLEND"; readonly emissiveLinear?: readonly number[] } {
1117
+ const rgb = [1, 3, 5].map(index => srgbToLinear(Number.parseInt(material.color.slice(index, index + 2), 16) / 255))
1118
+ const baseColorLinear = [rgb[0]!, rgb[1]!, rgb[2]!, material.opacity]
1119
+ const alphaMode = material.opacity < 1 ? "BLEND" as const : "OPAQUE" as const
1120
+ if (material.kind === "unlit") {
1121
+ return { baseColorLinear, metallic: 0, roughness: 1, alphaMode, emissiveLinear: [rgb[0]!, rgb[1]!, rgb[2]!] }
1122
+ }
1123
+ const emissiveLinear = material.emissive === undefined ? undefined : (() => {
1124
+ const factor = Math.min(1, material.emissive.intensity)
1125
+ const e = [1, 3, 5].map(index => srgbToLinear(Number.parseInt(material.emissive!.color.slice(index, index + 2), 16) / 255) * factor)
1126
+ return e.every(value => value === 0) ? undefined : [e[0]!, e[1]!, e[2]!] as const
1127
+ })()
1128
+ return { baseColorLinear, metallic: material.metalness, roughness: material.roughness, alphaMode, ...(emissiveLinear === undefined ? {} : { emissiveLinear }) }
1129
+ }
1130
+
1131
+ /**
1132
+ * Emits one bounded GLB 2.0 document: a single node and mesh with one
1133
+ * triangle primitive per used material slot, float32 POSITION/NORMAL and
1134
+ * optional TEXCOORD_0, and metallic-roughness materials resolved from the
1135
+ * declared slot table. Byte output is deterministic; the result parses under
1136
+ * the same closed profile as admitted GLB assets.
1137
+ */
1138
+ export function emitSpatialGeometryGlb(mesh: SpatialGeometryMesh, materials: readonly SpatialMaterial[]): Uint8Array {
1139
+ if (materials.length < 1 || materials.length > SPATIAL_GEOMETRY_LIMITS.materialSlots) fail("GLB emission requires 1–16 material slots.")
1140
+ for (const material of materials) parseSpatialValue(SpatialMaterialSchema, material, "geometry material")
1141
+ const slotTriangles = new Map<number, number[]>()
1142
+ for (let index = 0; index < mesh.triangles; index++) {
1143
+ const slot = mesh.slots[index]!
1144
+ if (slot >= materials.length) fail(`Material slot ${slot} has no declared material.`)
1145
+ const list = slotTriangles.get(slot) ?? []
1146
+ list.push(mesh.indices[index * 3]!, mesh.indices[index * 3 + 1]!, mesh.indices[index * 3 + 2]!)
1147
+ slotTriangles.set(slot, list)
1148
+ }
1149
+ const slots = [...slotTriangles.keys()].sort((a, b) => a - b)
1150
+ const vertices = mesh.vertices
1151
+ const indexComponent = vertices <= 65_535 ? 5123 : 5125
1152
+ const indexBytes = indexComponent === 5123 ? 2 : 4
1153
+ const hasUvs = mesh.uvsProjected
1154
+ const positionBytes = vertices * 12, normalBytes = vertices * 12, uvBytes = hasUvs ? vertices * 8 : 0
1155
+ const indexRanges: { slot: number; byteOffset: number; byteLength: number; count: number }[] = []
1156
+ let binLength = positionBytes + normalBytes + uvBytes
1157
+ for (const slot of slots) {
1158
+ const count = slotTriangles.get(slot)!.length
1159
+ indexRanges.push({ slot, byteOffset: binLength, byteLength: count * indexBytes, count })
1160
+ binLength += count * indexBytes
1161
+ binLength = Math.ceil(binLength / 4) * 4
1162
+ }
1163
+ const bin = new Uint8Array(binLength), data = new DataView(bin.buffer)
1164
+ for (let index = 0; index < mesh.positions.length; index++) data.setFloat32(index * 4, mesh.positions[index]!, true)
1165
+ for (let index = 0; index < mesh.normals.length; index++) data.setFloat32(positionBytes + index * 4, mesh.normals[index]!, true)
1166
+ if (hasUvs) for (let index = 0; index < mesh.uvs.length; index++) data.setFloat32(positionBytes + normalBytes + index * 4, mesh.uvs[index]!, true)
1167
+ for (const range of indexRanges) {
1168
+ const indices = slotTriangles.get(range.slot)!
1169
+ for (let index = 0; index < indices.length; index++) {
1170
+ if (indexComponent === 5123) data.setUint16(range.byteOffset + index * 2, indices[index]!, true)
1171
+ else data.setUint32(range.byteOffset + index * 4, indices[index]!, true)
1172
+ }
1173
+ }
1174
+ const positions32 = new Float32Array(mesh.positions as number[])
1175
+ const low = [Infinity, Infinity, Infinity], high = [-Infinity, -Infinity, -Infinity]
1176
+ for (let index = 0; index < positions32.length; index += 3) {
1177
+ for (let axisIndex = 0; axisIndex < 3; axisIndex++) {
1178
+ low[axisIndex] = Math.min(low[axisIndex]!, positions32[index + axisIndex]!); high[axisIndex] = Math.max(high[axisIndex]!, positions32[index + axisIndex]!)
1179
+ }
1180
+ }
1181
+ const bufferViews = [
1182
+ { buffer: 0, byteOffset: 0, byteLength: positionBytes, target: 34962 },
1183
+ { buffer: 0, byteOffset: positionBytes, byteLength: normalBytes, target: 34962 },
1184
+ ...(hasUvs ? [{ buffer: 0, byteOffset: positionBytes + normalBytes, byteLength: uvBytes, target: 34962 }] : []),
1185
+ ...indexRanges.map(range => ({ buffer: 0, byteOffset: range.byteOffset, byteLength: range.byteLength, target: 34963 })),
1186
+ ]
1187
+ const accessors = [
1188
+ { bufferView: 0, componentType: 5126, count: vertices, type: "VEC3", min: low, max: high },
1189
+ { bufferView: 1, componentType: 5126, count: vertices, type: "VEC3" },
1190
+ ...(hasUvs ? [{ bufferView: 2, componentType: 5126, count: vertices, type: "VEC2" }] : []),
1191
+ ...indexRanges.map((range, index) => ({ bufferView: (hasUvs ? 3 : 2) + index, componentType: indexComponent, count: range.count, type: "SCALAR" })),
1192
+ ]
1193
+ const glbMaterials = slots.map(slot => {
1194
+ const resolved = materialToGlb(materials[slot]!)
1195
+ return {
1196
+ pbrMetallicRoughness: { baseColorFactor: resolved.baseColorLinear, metallicFactor: resolved.metallic, roughnessFactor: resolved.roughness },
1197
+ alphaMode: resolved.alphaMode, doubleSided: false,
1198
+ ...(resolved.emissiveLinear === undefined ? {} : { emissiveFactor: resolved.emissiveLinear }),
1199
+ }
1200
+ })
1201
+ const document = {
1202
+ asset: { version: "2.0", generator: SPATIAL_GEOMETRY_PROFILE },
1203
+ buffers: [{ byteLength: binLength }],
1204
+ bufferViews, accessors,
1205
+ scene: 0, scenes: [{ nodes: [0] }], nodes: [{ mesh: 0 }],
1206
+ meshes: [{ primitives: slots.map((_slot, index) => ({ attributes: { POSITION: 0, NORMAL: 1, ...(hasUvs ? { TEXCOORD_0: 2 } : {}) }, indices: (hasUvs ? 3 : 2) + index, material: index, mode: 4 })) }],
1207
+ materials: glbMaterials,
1208
+ }
1209
+ const rawJson = new TextEncoder().encode(JSON.stringify(document))
1210
+ const json = new Uint8Array(Math.ceil(rawJson.length / 4) * 4).fill(32); json.set(rawJson)
1211
+ const bytes = new Uint8Array(28 + json.length + bin.length), view = new DataView(bytes.buffer)
1212
+ view.setUint32(0, 0x46546c67, true); view.setUint32(4, 2, true); view.setUint32(8, bytes.length, true)
1213
+ view.setUint32(12, json.length, true); view.setUint32(16, 0x4e4f534a, true); bytes.set(json, 20)
1214
+ view.setUint32(20 + json.length, bin.length, true); view.setUint32(24 + json.length, 0x004e4942, true); bytes.set(bin, 28 + json.length)
1215
+ return bytes
1216
+ }