@hraness/slopcamera 3.2.8 → 3.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +268 -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-cgpkyjqa.js +3 -0
- package/dist/index-f0ptv8dm.js +7 -0
- package/dist/index-grk47tgs.js +3 -0
- package/dist/index-qry58nj2.js +10 -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/package.json +7 -5
- package/skills/slopcamera/SKILL.md +6 -1
- package/skills/slopcamera/references/directed-scenes.md +22 -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/parametric-design.md +14 -0
- package/skills/slopcamera/references/scene-building.md +32 -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 +1642 -130
- 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 +233 -0
- package/src/spatial-scene/behavior-fns.ts +585 -0
- package/src/spatial-scene/behavior-gallery.ts +109 -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 +618 -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,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
|
+
}
|