@solidrt/3d 0.0.57 → 0.0.58
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/AGENTS.md +859 -528
- package/examples/README.md +6 -0
- package/examples/wireframe.tsx +107 -0
- package/package.json +2 -2
- package/src/geometry-gpu.ts +45 -13
- package/src/geometry.ts +186 -5
- package/src/gltf.ts +97 -16
- package/src/index.ts +2 -1
- package/src/input.ts +11 -2
- package/src/material.ts +26 -8
- package/src/mesh.ts +5 -6
- package/src/model.ts +65 -33
- package/src/node.ts +2 -1
- package/src/scene.ts +6 -3
- package/src/skeleton.ts +211 -0
package/examples/README.md
CHANGED
|
@@ -142,6 +142,12 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
|
|
|
142
142
|
transparent dome and a mesh without normals) parsed with `parseGltf`
|
|
143
143
|
from a binary import and built by `createModel` into a Group of named
|
|
144
144
|
parts; clicking a part hides it, clicking the body restores all.
|
|
145
|
+
- `wireframe.tsx` - the rover drawn solid, as a wireframe or as its
|
|
146
|
+
feature edges: `wireframeGeometry`/`edgesGeometry` build "lines"
|
|
147
|
+
geometry over each part's own vertices (one shared vertex upload, a
|
|
148
|
+
second index buffer) and `setGeometry`/`setMaterial` swap it onto the
|
|
149
|
+
live mesh, no node touched. Space, a tap on the rover, or the `wire`
|
|
150
|
+
debug command (`{ mode: "solid" | "wireframe" | "edges" }`) cycles.
|
|
145
151
|
- `model-load.tsx` - the same rover loaded ASYNC with `loadModel` from
|
|
146
152
|
`assets/` under a `<Loading>` boundary: the async read lives in a memo,
|
|
147
153
|
a second memo derives the scene JSX after that read, and the shell
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The rover drawn solid, as a wireframe or as its feature edges. Both line
|
|
2
|
+
// forms are GEOMETRY: wireframeGeometry lists every edge of a triangle
|
|
3
|
+
// geometry once, edgesGeometry only the edges where two faces meet at the
|
|
4
|
+
// threshold angle or more (plus the open borders), each as a "lines"
|
|
5
|
+
// geometry over the source's OWN vertex array. The topology rides on the
|
|
6
|
+
// geometry, so the plain unlit material draws it as lines, and the vertex
|
|
7
|
+
// upload is shared, so a mode costs an index buffer, not a second model.
|
|
8
|
+
// The swap is setGeometry + setMaterial on the live mesh: the node, its
|
|
9
|
+
// transform and its pointer handlers are untouched. Space, a tap on the
|
|
10
|
+
// rover or the `wire` debug command ({ mode }) cycles the modes.
|
|
11
|
+
|
|
12
|
+
import { createSignal, onFrame, pct, render } from "@solidrt/core"
|
|
13
|
+
import { createTexture } from "@solidrt/core/gpu"
|
|
14
|
+
import type { TextureId } from "@solidrt/core/gpu"
|
|
15
|
+
import { registerDebug } from "srt:dev"
|
|
16
|
+
import { add, createModel, DirectionalLight, edgesGeometry, equirectToCube, Group, parseGltf, PerspectiveCamera, Scene, setGeometry, setMaterial, unlit, wireframeGeometry } from "@solidrt/3d"
|
|
17
|
+
import type { SceneNode } from "@solidrt/3d"
|
|
18
|
+
import modelBytes from "./model.glb" with { type: "binary" }
|
|
19
|
+
|
|
20
|
+
const MODES = ["solid", "wireframe", "edges"] as const
|
|
21
|
+
type Mode = (typeof MODES)[number]
|
|
22
|
+
// Feature-edge threshold in degrees: above the facet angle of the rover's
|
|
23
|
+
// rounded parts, so their seams stay hidden and only rims and creases draw.
|
|
24
|
+
const EDGE_ANGLE = 20
|
|
25
|
+
const LINE_COLOR: [number, number, number] = [0.85, 0.95, 1]
|
|
26
|
+
// Face edge of the gradient environment cube: a gradient needs no detail.
|
|
27
|
+
const ENV_FACE = 16
|
|
28
|
+
// The gradient, zenith to nadir, sRGB bytes: sky, horizon, ground, below.
|
|
29
|
+
const ENV_ROWS: [number, number, number][] = [
|
|
30
|
+
[96, 122, 168],
|
|
31
|
+
[176, 190, 210],
|
|
32
|
+
[120, 105, 90],
|
|
33
|
+
[60, 52, 45],
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
// A tiny equirectangular panorama turned into the cube the solid rover's
|
|
37
|
+
// standard materials light by (see model.tsx).
|
|
38
|
+
function gradientEnvironment(): TextureId {
|
|
39
|
+
let px = new Uint8Array(2 * ENV_ROWS.length * 4)
|
|
40
|
+
ENV_ROWS.forEach((c, y) => {
|
|
41
|
+
for (let x = 0; x < 2; x++) px.set([c[0], c[1], c[2], 255], (y * 2 + x) * 4)
|
|
42
|
+
})
|
|
43
|
+
let panorama = createTexture(px, 2, ENV_ROWS.length, { format: "rgba8-srgb", filter: "linear" })
|
|
44
|
+
return equirectToCube(panorama, ENV_FACE, { format: "rgba8-srgb", mipmap: true, label: "gradient-env" })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function App() {
|
|
48
|
+
let cube = gradientEnvironment()
|
|
49
|
+
let [t, setT] = createSignal(0)
|
|
50
|
+
onFrame(tick => setT(tick / 1000))
|
|
51
|
+
|
|
52
|
+
let model = createModel(parseGltf(modelBytes), { label: "rover" })
|
|
53
|
+
let b = model.bounds
|
|
54
|
+
let center: [number, number, number] = [(b[0]! + b[3]!) / 2, (b[1]! + b[4]!) / 2, (b[2]! + b[5]!) / 2]
|
|
55
|
+
let radius = Math.hypot(b[3]! - b[0]!, b[4]! - b[1]!, b[5]! - b[2]!) / 2
|
|
56
|
+
|
|
57
|
+
// One line material for every part and both line modes. Per part, what
|
|
58
|
+
// each mode swaps in: the line geometries share the solid's vertices,
|
|
59
|
+
// so building both up front costs their index buffers only.
|
|
60
|
+
let lines = unlit({ color: LINE_COLOR })
|
|
61
|
+
let looks = model.parts.map(part => ({
|
|
62
|
+
mesh: part.mesh,
|
|
63
|
+
solid: { geometry: part.mesh.geometry, material: part.mesh.material },
|
|
64
|
+
wireframe: wireframeGeometry(part.mesh.geometry),
|
|
65
|
+
edges: edgesGeometry(part.mesh.geometry, EDGE_ANGLE),
|
|
66
|
+
}))
|
|
67
|
+
let mode: Mode = "solid"
|
|
68
|
+
let apply = (next: Mode): void => {
|
|
69
|
+
mode = next
|
|
70
|
+
for (let look of looks) {
|
|
71
|
+
// Each call rebuilds the draw entry, so the intermediate state is
|
|
72
|
+
// always the line material over the solid's triangles - a pipeline
|
|
73
|
+
// unlit has anyway - never the solid material over lines.
|
|
74
|
+
if (next === "solid") {
|
|
75
|
+
setGeometry(look.mesh, look.solid.geometry)
|
|
76
|
+
setMaterial(look.mesh, look.solid.material)
|
|
77
|
+
} else {
|
|
78
|
+
setMaterial(look.mesh, lines)
|
|
79
|
+
setGeometry(look.mesh, look[next])
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
let cycle = (): void => apply(MODES[(MODES.indexOf(mode) + 1) % MODES.length]!)
|
|
84
|
+
for (let part of model.parts) part.mesh.onTap = cycle
|
|
85
|
+
registerDebug("wire", (args?: Record<string, unknown>) => {
|
|
86
|
+
if (typeof args?.mode === "string" && (MODES as readonly string[]).includes(args.mode)) apply(args.mode as Mode)
|
|
87
|
+
else cycle()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<window
|
|
92
|
+
onKeyDown={e => {
|
|
93
|
+
if (e.key === " ") cycle()
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
<view width={pct(100)} height={pct(100)}>
|
|
97
|
+
<Scene clearColor={[0.1, 0.11, 0.14, 1]} environment={{ cube }} samples={4} label="wireframe">
|
|
98
|
+
<PerspectiveCamera fov={40} position={[center[0] + radius * 1.4, center[1] + radius * 1.1, center[2] + radius * 2.2]} lookAt={center} />
|
|
99
|
+
<DirectionalLight direction={[0.5, -0.8, 0.3]} color={[1, 0.95, 0.85]} intensity={0.9} />
|
|
100
|
+
<Group rotation={[0, t() / 3, 0]} ref={(g: SceneNode) => add(g, model)} />
|
|
101
|
+
</Scene>
|
|
102
|
+
</view>
|
|
103
|
+
</window>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
render(() => <App />)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/3d",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.58",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"funding": "https://github.com/sponsors/wellawaretech",
|
|
6
6
|
"author": "Antoine van Wel",
|
|
@@ -19,6 +19,6 @@
|
|
|
19
19
|
],
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"@solidjs/signals": "2.0.0-rc.6",
|
|
22
|
-
"@solidrt/core": "0.0.
|
|
22
|
+
"@solidrt/core": "0.0.58"
|
|
23
23
|
}
|
|
24
24
|
}
|
package/src/geometry-gpu.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { createBuffer, destroyBuffer } from "@solidrt/core/gpu"
|
|
|
15
15
|
import type { BufferId, IndexFormat } from "@solidrt/core/gpu"
|
|
16
16
|
import { createShape, destroyShape } from "flux:spatial"
|
|
17
17
|
import type { ShapeId } from "flux:spatial"
|
|
18
|
-
import { layoutStride } from "./geometry.ts"
|
|
18
|
+
import { geometryTopology, layoutStride } from "./geometry.ts"
|
|
19
19
|
import type { Geometry } from "./geometry.ts"
|
|
20
20
|
|
|
21
21
|
/** An acquired reference to a geometry's GPU buffers: what a draw entry
|
|
@@ -26,14 +26,45 @@ export type GeometryBuffers = {
|
|
|
26
26
|
buffer: BufferId
|
|
27
27
|
index: BufferId
|
|
28
28
|
indexFormat: IndexFormat
|
|
29
|
-
/** The picking shape (positions at 0, uv at 6 of every layout)
|
|
30
|
-
|
|
29
|
+
/** The picking shape (positions at 0, uv at 6 of every layout); null
|
|
30
|
+
* for anything but a triangle list, which picks by its box. */
|
|
31
|
+
shape: ShapeId | null
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
type GpuEntry = GeometryBuffers & { geometry: Geometry; refs: number }
|
|
34
|
+
type GpuEntry = GeometryBuffers & { geometry: Geometry; vertices: Float32Array; refs: number }
|
|
34
35
|
|
|
35
36
|
let entries = new WeakMap<Geometry, GpuEntry>()
|
|
36
37
|
|
|
38
|
+
/** Vertex uploads keyed by the array itself: geometries sharing one
|
|
39
|
+
* vertex array (a wireframe or edges geometry over its source's) share
|
|
40
|
+
* one GPU buffer, each entry holding a reference to it. */
|
|
41
|
+
let vertexUploads = new WeakMap<Float32Array, { buffer: BufferId; refs: number }>()
|
|
42
|
+
|
|
43
|
+
function acquireVertices(geometry: Geometry): BufferId {
|
|
44
|
+
let upload = vertexUploads.get(geometry.vertices)
|
|
45
|
+
if (upload === undefined) {
|
|
46
|
+
upload = {
|
|
47
|
+
buffer: createBuffer(geometry.vertices, {
|
|
48
|
+
autoFree: false,
|
|
49
|
+
label: geometry.label ? geometry.label + "-verts" : undefined,
|
|
50
|
+
}),
|
|
51
|
+
refs: 0,
|
|
52
|
+
}
|
|
53
|
+
vertexUploads.set(geometry.vertices, upload)
|
|
54
|
+
}
|
|
55
|
+
upload.refs++
|
|
56
|
+
return upload.buffer
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function releaseVertices(vertices: Float32Array): void {
|
|
60
|
+
let upload = vertexUploads.get(vertices)
|
|
61
|
+
if (upload === undefined) return
|
|
62
|
+
upload.refs--
|
|
63
|
+
if (upload.refs > 0) return
|
|
64
|
+
vertexUploads.delete(vertices)
|
|
65
|
+
destroyBuffer(upload.buffer)
|
|
66
|
+
}
|
|
67
|
+
|
|
37
68
|
/** The geometry's GPU buffers, created on first use, plus the index format
|
|
38
69
|
* the draw entry must bind them with. Takes a reference - pair every
|
|
39
70
|
* acquire with a releaseGeometryBuffers of the returned token when the
|
|
@@ -43,16 +74,17 @@ export function acquireGeometryBuffers(geometry: Geometry): GeometryBuffers {
|
|
|
43
74
|
if (entry === undefined) {
|
|
44
75
|
entry = {
|
|
45
76
|
geometry,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
label: geometry.label ? geometry.label + "-verts" : undefined,
|
|
49
|
-
}),
|
|
77
|
+
vertices: geometry.vertices,
|
|
78
|
+
buffer: acquireVertices(geometry),
|
|
50
79
|
index: createBuffer(geometry.indices, {
|
|
51
80
|
autoFree: false,
|
|
52
81
|
label: geometry.label ? geometry.label + "-indices" : undefined,
|
|
53
82
|
}),
|
|
54
83
|
indexFormat: geometry.indices instanceof Uint32Array ? "uint32" : "uint16",
|
|
55
|
-
shape:
|
|
84
|
+
shape:
|
|
85
|
+
geometryTopology(geometry) === "triangles"
|
|
86
|
+
? createShape(geometry.vertices, layoutStride(geometry.layout), 0, 6, geometry.indices)
|
|
87
|
+
: null,
|
|
56
88
|
refs: 0,
|
|
57
89
|
}
|
|
58
90
|
entries.set(geometry, entry)
|
|
@@ -74,9 +106,9 @@ export function releaseGeometryBuffers(acquired: GeometryBuffers): void {
|
|
|
74
106
|
queueMicrotask(() => {
|
|
75
107
|
if (entries.get(entry.geometry) !== entry || entry.refs > 0) return
|
|
76
108
|
entries.delete(entry.geometry)
|
|
77
|
-
|
|
109
|
+
releaseVertices(entry.vertices)
|
|
78
110
|
destroyBuffer(entry.index)
|
|
79
|
-
destroyShape(entry.shape)
|
|
111
|
+
if (entry.shape !== null) destroyShape(entry.shape)
|
|
80
112
|
})
|
|
81
113
|
}
|
|
82
114
|
|
|
@@ -91,7 +123,7 @@ export function disposeGeometry(geometry: Geometry): void {
|
|
|
91
123
|
let entry = entries.get(geometry)
|
|
92
124
|
if (entry === undefined) return
|
|
93
125
|
entries.delete(geometry)
|
|
94
|
-
|
|
126
|
+
releaseVertices(entry.vertices)
|
|
95
127
|
destroyBuffer(entry.index)
|
|
96
|
-
destroyShape(entry.shape)
|
|
128
|
+
if (entry.shape !== null) destroyShape(entry.shape)
|
|
97
129
|
}
|
package/src/geometry.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// array math (the check rig checks/geometry-check.ts runs it headless on
|
|
22
22
|
// flux). The GPU buffer step lives in geometry-gpu.ts.
|
|
23
23
|
|
|
24
|
-
import type { VertexAttribute } from "@solidrt/core/gpu"
|
|
24
|
+
import type { Topology, VertexAttribute } from "@solidrt/core/gpu"
|
|
25
25
|
import { add, compose, cross, mat4, normalize, normalMatrix, sub, updateRotation, updateScale } from "./math.ts"
|
|
26
26
|
import type { Quat, TransformUpdate, Vec2, Vec3 } from "./math.ts"
|
|
27
27
|
|
|
@@ -50,7 +50,10 @@ export const VERTEX_LAYOUTS: Record<"standard" | "colored" | "skinned", VertexAt
|
|
|
50
50
|
* format before packing). */
|
|
51
51
|
export const STANDARD_FLOATS = 8
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
/** Floats per attribute format: the vertex vocabulary itself, so it is
|
|
54
|
+
* also the list a declared format must be one of (vertex attributes and
|
|
55
|
+
* instance attributes both). */
|
|
56
|
+
export const FORMAT_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
|
|
54
57
|
|
|
55
58
|
/** The attribute list of a layout (a preset name resolves to its list). */
|
|
56
59
|
export function layoutAttributes(layout?: VertexLayout): VertexAttribute[] {
|
|
@@ -123,7 +126,35 @@ export function validateGeometry(geometry: Geometry): void {
|
|
|
123
126
|
name + ": " + geometry.vertices.length + " vertex floats is not a whole number of " + stride + "-float (" + layoutKey(layout) + ") vertices",
|
|
124
127
|
)
|
|
125
128
|
}
|
|
126
|
-
|
|
129
|
+
let topology = geometryTopology(geometry)
|
|
130
|
+
let count = geometry.indices.length
|
|
131
|
+
// Lines and points may be empty: a feature-edge pass over a smooth
|
|
132
|
+
// closed shape has nothing to draw, and that is a result, not a bug.
|
|
133
|
+
switch (topology) {
|
|
134
|
+
case "triangles":
|
|
135
|
+
if (count === 0) throw new Error(name + ": no indices")
|
|
136
|
+
if (count % 3 !== 0) throw new Error(name + ": " + count + " indices is not a whole number of triangles")
|
|
137
|
+
break
|
|
138
|
+
case "lines":
|
|
139
|
+
if (count % 2 !== 0) throw new Error(name + ": " + count + " indices is not a whole number of lines")
|
|
140
|
+
break
|
|
141
|
+
case "line-strip":
|
|
142
|
+
if (count < 2) throw new Error(name + ": a line strip needs at least 2 indices")
|
|
143
|
+
break
|
|
144
|
+
case "triangle-strip":
|
|
145
|
+
if (count < 3) throw new Error(name + ": a triangle strip needs at least 3 indices")
|
|
146
|
+
break
|
|
147
|
+
case "points":
|
|
148
|
+
break
|
|
149
|
+
default:
|
|
150
|
+
throw new Error(name + ": unknown topology '" + String(topology) + "'")
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The geometry's topology with the default applied: what its indices
|
|
155
|
+
* list, "triangles" unless it says otherwise. */
|
|
156
|
+
export function geometryTopology(geometry: Geometry): Topology {
|
|
157
|
+
return geometry.topology ?? "triangles"
|
|
127
158
|
}
|
|
128
159
|
|
|
129
160
|
/** The options every generator shares (each generator's own options type
|
|
@@ -194,6 +225,20 @@ export type Geometry = {
|
|
|
194
225
|
/** The array type picks the draw's index format: Uint32Array past 64k
|
|
195
226
|
* vertices. */
|
|
196
227
|
indices: Uint16Array | Uint32Array
|
|
228
|
+
/** What the indices describe; absent means "triangles". The index
|
|
229
|
+
* buffer and the primitive it lists travel together (Godot's surface
|
|
230
|
+
* primitive, Unity's Mesh.SetIndices topology, Three's Line/Points
|
|
231
|
+
* object types over one BufferGeometry): the material draws whatever
|
|
232
|
+
* topology its geometry carries, one pipeline per (layout, topology)
|
|
233
|
+
* pair. Only a triangle list gets the picking shape - a lines, points
|
|
234
|
+
* or strip geometry picks (and collides) by its bounds box, the tier a
|
|
235
|
+
* sprite or an instanced mesh is at, so hits carry no face/uv. Lines
|
|
236
|
+
* are one pixel wide on GL ES whatever the display density. A lines
|
|
237
|
+
* or points geometry may have no indices at all (it then draws
|
|
238
|
+
* nothing; the other topologies reject that at add()).
|
|
239
|
+
* wireframeGeometry/edgesGeometry build "lines" over a triangle
|
|
240
|
+
* geometry's own vertices. */
|
|
241
|
+
topology?: Topology
|
|
197
242
|
/** Vertex layout; absent means "standard". Must match the material's
|
|
198
243
|
* layout - the scene rejects a mismatched pair at add(). */
|
|
199
244
|
layout?: VertexLayout
|
|
@@ -270,6 +315,7 @@ export function withAttribute(geometry: Geometry, attr: VertexAttribute, fill: A
|
|
|
270
315
|
return {
|
|
271
316
|
vertices: out,
|
|
272
317
|
indices: geometry.indices,
|
|
318
|
+
topology: geometry.topology,
|
|
273
319
|
layout,
|
|
274
320
|
label: label ?? (geometry.label ? geometry.label + "-" + attr.name : undefined),
|
|
275
321
|
}
|
|
@@ -394,6 +440,7 @@ export function transformGeometry(geometry: Geometry, transform: TransformUpdate
|
|
|
394
440
|
return {
|
|
395
441
|
vertices: out,
|
|
396
442
|
indices: geometry.indices,
|
|
443
|
+
topology: geometry.topology,
|
|
397
444
|
layout: geometry.layout,
|
|
398
445
|
label: label ?? (geometry.label ? geometry.label + "-transformed" : undefined),
|
|
399
446
|
}
|
|
@@ -403,7 +450,9 @@ export function transformGeometry(geometry: Geometry, transform: TransformUpdate
|
|
|
403
450
|
* Concatenate geometries into one: vertices appended in order, indices
|
|
404
451
|
* offset to match, uint32 indices past 64k vertices. Every part must share
|
|
405
452
|
* one layout - a mixed list throws, because the strides differ and a merge
|
|
406
|
-
* that picked one would draw garbage, not a mesh missing a channel
|
|
453
|
+
* that picked one would draw garbage, not a mesh missing a channel - and
|
|
454
|
+
* one list topology (a mixed list throws the same way; strips cannot be
|
|
455
|
+
* concatenated at all, the seam would join them). The
|
|
407
456
|
* second half of authoring a static scene as data (Three's
|
|
408
457
|
* `BufferGeometryUtils.mergeGeometries`): the result is one draw entry and
|
|
409
458
|
* one uModel write however many parts went in, so only what actually moves
|
|
@@ -414,12 +463,17 @@ export function mergeGeometries(parts: Geometry[], label?: string): Geometry {
|
|
|
414
463
|
let layout = parts[0]!.layout
|
|
415
464
|
let key = layoutKey(layout)
|
|
416
465
|
let stride = layoutStride(layout)
|
|
466
|
+
let topology = geometryTopology(parts[0]!)
|
|
467
|
+
if (topology === "line-strip" || topology === "triangle-strip") throw new Error("mergeGeometries: cannot merge " + topology + " geometry")
|
|
417
468
|
let floats = 0
|
|
418
469
|
let indexCount = 0
|
|
419
470
|
for (let part of parts) {
|
|
420
471
|
if (layoutKey(part.layout) !== key) {
|
|
421
472
|
throw new Error("mergeGeometries: mixed layouts (" + key + " and " + layoutKey(part.layout) + ")")
|
|
422
473
|
}
|
|
474
|
+
if (geometryTopology(part) !== topology) {
|
|
475
|
+
throw new Error("mergeGeometries: mixed topologies (" + topology + " and " + geometryTopology(part) + ")")
|
|
476
|
+
}
|
|
423
477
|
if (part.vertices.length % stride !== 0) {
|
|
424
478
|
throw new Error("mergeGeometries: a part's vertex data is not a whole number of " + key + " vertices")
|
|
425
479
|
}
|
|
@@ -439,7 +493,134 @@ export function mergeGeometries(parts: Geometry[], label?: string): Geometry {
|
|
|
439
493
|
vOffset += part.vertices.length
|
|
440
494
|
iOffset += src.length
|
|
441
495
|
}
|
|
442
|
-
return { vertices, indices, layout, label }
|
|
496
|
+
return { vertices, indices, topology: parts[0]!.topology, layout, label }
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Positions closer than this (model units) are one vertex to the edge
|
|
500
|
+
* builders: a uv seam or a per-face normal split duplicates a position,
|
|
501
|
+
* and an edge is the same edge whichever copy a triangle names. */
|
|
502
|
+
const WELD_PRECISION = 1e-4
|
|
503
|
+
|
|
504
|
+
/** Degrees between two faces' normals from which the edge between them
|
|
505
|
+
* counts as a feature edge in edgesGeometry; under it the faces read as
|
|
506
|
+
* one smooth surface and the edge stays hidden. Three's default. */
|
|
507
|
+
const EDGES_THRESHOLD_ANGLE = 1
|
|
508
|
+
|
|
509
|
+
/** One id per vertex, shared by every vertex at the same position (to
|
|
510
|
+
* WELD_PRECISION) whatever its normal and uv; plus the id count. */
|
|
511
|
+
function weldPositions(geometry: Geometry): { ids: Uint32Array; count: number } {
|
|
512
|
+
let v = geometry.vertices
|
|
513
|
+
let stride = layoutStride(geometry.layout)
|
|
514
|
+
let count = v.length / stride
|
|
515
|
+
let ids = new Uint32Array(count)
|
|
516
|
+
let seen = new Map<string, number>()
|
|
517
|
+
let scale = 1 / WELD_PRECISION
|
|
518
|
+
for (let i = 0; i < count; i++) {
|
|
519
|
+
let b = i * stride
|
|
520
|
+
let key = Math.round(v[b]! * scale) + "," + Math.round(v[b + 1]! * scale) + "," + Math.round(v[b + 2]! * scale)
|
|
521
|
+
let id = seen.get(key)
|
|
522
|
+
if (id === undefined) {
|
|
523
|
+
id = seen.size
|
|
524
|
+
seen.set(key, id)
|
|
525
|
+
}
|
|
526
|
+
ids[i] = id
|
|
527
|
+
}
|
|
528
|
+
return { ids, count: seen.size }
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** The edge table of a triangle geometry, welded by position, as a lines
|
|
532
|
+
* index list: every edge once for `cosThreshold` null, else the feature
|
|
533
|
+
* edges - those with one face (a border) or whose two faces' normals
|
|
534
|
+
* dot at or below the threshold. Each edge names the vertex pair of the
|
|
535
|
+
* first triangle that had it. */
|
|
536
|
+
function edgeIndices(geometry: Geometry, name: string, cosThreshold: number | null): Uint16Array | Uint32Array {
|
|
537
|
+
if (geometryTopology(geometry) !== "triangles") {
|
|
538
|
+
throw new Error(name + ": needs a triangle geometry, got " + geometryTopology(geometry))
|
|
539
|
+
}
|
|
540
|
+
let v = geometry.vertices
|
|
541
|
+
let stride = layoutStride(geometry.layout)
|
|
542
|
+
let weld = weldPositions(geometry)
|
|
543
|
+
let src = geometry.indices
|
|
544
|
+
type Edge = { a: number; b: number; nx: number; ny: number; nz: number; faces: number; sharp: boolean }
|
|
545
|
+
let edges = new Map<number, Edge>()
|
|
546
|
+
for (let t = 0; t + 2 < src.length; t += 3) {
|
|
547
|
+
let i0 = src[t]!, i1 = src[t + 1]!, i2 = src[t + 2]!
|
|
548
|
+
let p0 = i0 * stride, p1 = i1 * stride, p2 = i2 * stride
|
|
549
|
+
// The face normal, for the feature test.
|
|
550
|
+
let ux = v[p1]! - v[p0]!, uy = v[p1 + 1]! - v[p0 + 1]!, uz = v[p1 + 2]! - v[p0 + 2]!
|
|
551
|
+
let wx = v[p2]! - v[p0]!, wy = v[p2 + 1]! - v[p0 + 1]!, wz = v[p2 + 2]! - v[p0 + 2]!
|
|
552
|
+
let nx = uy * wz - uz * wy, ny = uz * wx - ux * wz, nz = ux * wy - uy * wx
|
|
553
|
+
let len = Math.hypot(nx, ny, nz) || 1
|
|
554
|
+
nx /= len
|
|
555
|
+
ny /= len
|
|
556
|
+
nz /= len
|
|
557
|
+
for (let k = 0; k < 3; k++) {
|
|
558
|
+
let ia = k === 0 ? i0 : k === 1 ? i1 : i2
|
|
559
|
+
let ib = k === 0 ? i1 : k === 1 ? i2 : i0
|
|
560
|
+
let wa = weld.ids[ia]!, wb = weld.ids[ib]!
|
|
561
|
+
if (wa === wb) continue
|
|
562
|
+
let key = Math.min(wa, wb) * weld.count + Math.max(wa, wb)
|
|
563
|
+
let e = edges.get(key)
|
|
564
|
+
if (e === undefined) {
|
|
565
|
+
edges.set(key, { a: ia, b: ib, nx, ny, nz, faces: 1, sharp: false })
|
|
566
|
+
} else {
|
|
567
|
+
e.faces++
|
|
568
|
+
if (cosThreshold !== null && !e.sharp && e.nx * nx + e.ny * ny + e.nz * nz <= cosThreshold) e.sharp = true
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
let out: number[] = []
|
|
573
|
+
for (let e of edges.values()) {
|
|
574
|
+
if (cosThreshold === null || e.faces === 1 || e.sharp) out.push(e.a, e.b)
|
|
575
|
+
}
|
|
576
|
+
return packIndices(out, v.length / stride)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Every edge of a triangle geometry as a "lines" geometry over the SAME
|
|
581
|
+
* vertices (Three's WireframeGeometry): each triangle's three sides, once
|
|
582
|
+
* each - edges are matched by position, so a uv seam or a per-face
|
|
583
|
+
* normal split draws one line, not two on top of each other. The
|
|
584
|
+
* source's vertex array and layout are shared by reference, and so is
|
|
585
|
+
* their GPU upload, which makes the wireframe a second index buffer over
|
|
586
|
+
* the same vertices: a "skinned" part's wireframe draws in its pose
|
|
587
|
+
* under `unlit({ skinned: true })` with the mesh's own palette, and any
|
|
588
|
+
* material draws it as lines because the topology rides on the
|
|
589
|
+
* geometry. Lines are one pixel wide.
|
|
590
|
+
*/
|
|
591
|
+
export function wireframeGeometry(geometry: Geometry, label?: string): Geometry {
|
|
592
|
+
return {
|
|
593
|
+
vertices: geometry.vertices,
|
|
594
|
+
indices: edgeIndices(geometry, "wireframeGeometry", null),
|
|
595
|
+
topology: "lines",
|
|
596
|
+
layout: geometry.layout,
|
|
597
|
+
label: label ?? (geometry.label ? geometry.label + "-wireframe" : undefined),
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* The feature edges of a triangle geometry as a "lines" geometry over the
|
|
603
|
+
* same vertices (Three's EdgesGeometry): an edge draws when its two faces
|
|
604
|
+
* meet at `thresholdAngle` degrees or more, or when only one face has it
|
|
605
|
+
* (an open border): the outline that a dense mesh's wireframe, a solid
|
|
606
|
+
* blob, cannot give. A box draws its twelve edges and no face diagonals
|
|
607
|
+
* at any threshold; a generated round shape is faceted, so its facet
|
|
608
|
+
* lines show until the threshold passes its facet angle (360 divided by
|
|
609
|
+
* the radial segments: a default cylinder loses its side seams and keeps
|
|
610
|
+
* its two rims from 16 degrees, a default sphere draws nothing from 16).
|
|
611
|
+
* A smooth closed shape above its facet angle has no edges at all, and
|
|
612
|
+
* the empty lines geometry that comes back attaches and draws nothing.
|
|
613
|
+
* Shares vertices, layout and GPU upload with the source like
|
|
614
|
+
* wireframeGeometry.
|
|
615
|
+
*/
|
|
616
|
+
export function edgesGeometry(geometry: Geometry, thresholdAngle = EDGES_THRESHOLD_ANGLE, label?: string): Geometry {
|
|
617
|
+
return {
|
|
618
|
+
vertices: geometry.vertices,
|
|
619
|
+
indices: edgeIndices(geometry, "edgesGeometry", Math.cos((thresholdAngle * Math.PI) / 180)),
|
|
620
|
+
topology: "lines",
|
|
621
|
+
layout: geometry.layout,
|
|
622
|
+
label: label ?? (geometry.label ? geometry.label + "-edges" : undefined),
|
|
623
|
+
}
|
|
443
624
|
}
|
|
444
625
|
|
|
445
626
|
// Indices for a row-major (cellRows + 1) x (cellCols + 1) vertex grid: two
|