@solidrt/3d 0.0.56 → 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.
Files changed (48) hide show
  1. package/AGENTS.md +1052 -639
  2. package/README.md +2 -1
  3. package/examples/README.md +19 -8
  4. package/examples/collision.tsx +17 -4
  5. package/examples/first-person.tsx +20 -10
  6. package/examples/fleet.tsx +11 -5
  7. package/examples/model.tsx +3 -3
  8. package/examples/pick.tsx +144 -34
  9. package/examples/probe.tsx +10 -4
  10. package/examples/scene-views.tsx +49 -58
  11. package/examples/sky-lit.tsx +10 -4
  12. package/examples/skybox.tsx +10 -4
  13. package/examples/standard.tsx +10 -4
  14. package/examples/wireframe.tsx +107 -0
  15. package/package.json +2 -2
  16. package/src/collision.ts +14 -193
  17. package/src/components/context.tsx +35 -0
  18. package/src/components/directional-light.tsx +48 -0
  19. package/src/components/first-person-camera.tsx +72 -0
  20. package/src/components/group.tsx +18 -0
  21. package/src/components/hemisphere-light.tsx +24 -0
  22. package/src/components/index.ts +46 -0
  23. package/src/components/instanced-mesh.tsx +105 -0
  24. package/src/components/mesh.tsx +92 -0
  25. package/src/components/node-props.ts +90 -0
  26. package/src/components/orbit-camera.tsx +75 -0
  27. package/src/components/perspective-camera.tsx +32 -0
  28. package/src/components/point-light.tsx +52 -0
  29. package/src/components/record-mesh.tsx +68 -0
  30. package/src/components/scene.tsx +281 -0
  31. package/src/components/spot-light.tsx +65 -0
  32. package/src/components/sprite.tsx +38 -0
  33. package/src/components/view3d.tsx +152 -0
  34. package/src/first-person.ts +140 -188
  35. package/src/geometry-gpu.ts +45 -13
  36. package/src/geometry.ts +186 -5
  37. package/src/gltf.ts +97 -16
  38. package/src/index.ts +15 -10
  39. package/src/input.ts +79 -0
  40. package/src/material.ts +26 -8
  41. package/src/mesh.ts +5 -6
  42. package/src/model.ts +65 -33
  43. package/src/node.ts +100 -26
  44. package/src/orbit.ts +266 -235
  45. package/src/scene-pointer.ts +229 -101
  46. package/src/scene.ts +256 -79
  47. package/src/skeleton.ts +211 -0
  48. package/src/components.tsx +0 -1101
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
- const FORMAT_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
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
- if (geometry.indices.length === 0) throw new Error(name + ": no indices")
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. The
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
package/src/gltf.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  // glTF 2.0, the subset an app needs to show authored models: the scene's
2
2
  // node tree RETAINED - a node table of local TRS with parent links (matrix
3
3
  // nodes decomposed; shear dropped), vertices in NODE-LOCAL space, one part
4
- // per mesh primitive referencing its node - triangles with positions,
5
- // normals (flat ones generated when absent, per the spec), one UV set and
6
- // indices, and materials reduced to what lit()/unlit() draw - base color
4
+ // per mesh primitive referencing its node - positions, normals (flat ones
5
+ // generated when absent, per the spec, for triangles), one UV set and
6
+ // indices in the primitive's topology (strips and fans unrolled to
7
+ // triangle lists, a line loop closed into a strip; lines and points keep
8
+ // theirs on the geometry), and materials reduced to what lit()/unlit()
9
+ // draw - base color
7
10
  // factor and texture, normal map (with scale), emissive factor and map
8
11
  // (KHR_materials_emissive_strength as the intensity), double-sidedness, alpha
9
12
  // blending and masking - plus the file's animations as baked clips
@@ -19,8 +22,8 @@
19
22
  // bytes in `images`, and uploading is the engine side's job.
20
23
  //
21
24
  // Outside the subset: Draco/meshopt-compressed meshes and any other
22
- // required extension throw naming it; non-triangle primitives, sparse
23
- // accessors and morph targets (the "weights" channel path) are skipped
25
+ // required extension throw naming it; sparse accessors and morph
26
+ // targets (the "weights" channel path) are skipped
24
27
  // or ignored; vertex colors, tangents and further UV sets are dropped
25
28
  // (the standard layout has no slot for them yet). Skins ARE parsed:
26
29
  // joints, inverse binds, and the "skinned" vertex layout.
@@ -30,6 +33,7 @@ import { linearToSrgb } from "./color.ts"
30
33
  import type { Mat4, Quat, Vec3 } from "./math.ts"
31
34
  import { layoutStride, packGeometry, packIndices, STANDARD_FLOATS } from "./geometry.ts"
32
35
  import type { Geometry } from "./geometry.ts"
36
+ import type { Topology } from "@solidrt/core/gpu"
33
37
 
34
38
  /** What lit()/unlit() take from a glTF material. */
35
39
  export type ModelMaterial = {
@@ -182,7 +186,39 @@ export type UriResolver = (uri: string) => Uint8Array
182
186
  const GLB_MAGIC = 0x46546c67
183
187
  const CHUNK_JSON = 0x4e4f534a
184
188
  const CHUNK_BIN = 0x004e4942
189
+ // glTF primitive modes (the GL enum values).
190
+ const MODE_POINTS = 0
191
+ const MODE_LINES = 1
192
+ const MODE_LINE_LOOP = 2
193
+ const MODE_LINE_STRIP = 3
185
194
  const MODE_TRIANGLES = 4
195
+ const MODE_TRIANGLE_STRIP = 5
196
+ const MODE_TRIANGLE_FAN = 6
197
+
198
+ /** A triangle strip unrolled to a list: triangle t is (t, t+1, t+2),
199
+ * every odd one with its last two swapped so the winding stays put. */
200
+ function stripTriangles(strip: ArrayLike<number>): number[] {
201
+ let out: number[] = []
202
+ for (let t = 0; t + 2 < strip.length; t++) {
203
+ if (t % 2 === 0) out.push(strip[t]!, strip[t + 1]!, strip[t + 2]!)
204
+ else out.push(strip[t]!, strip[t + 2]!, strip[t + 1]!)
205
+ }
206
+ return out
207
+ }
208
+
209
+ /** A triangle fan unrolled to a list: every triangle shares the hub. */
210
+ function fanTriangles(fan: ArrayLike<number>): number[] {
211
+ let out: number[] = []
212
+ for (let t = 1; t + 1 < fan.length; t++) out.push(fan[0]!, fan[t]!, fan[t + 1]!)
213
+ return out
214
+ }
215
+
216
+ /** A line loop as a strip that returns to its first vertex. */
217
+ function closeLoop(loop: ArrayLike<number>): number[] {
218
+ let out = Array.from(loop)
219
+ if (loop.length > 0) out.push(loop[0]!)
220
+ return out
221
+ }
186
222
 
187
223
  // The spec's alphaCutoff when a MASK material leaves it out.
188
224
  const GLTF_ALPHA_CUTOFF = 0.5
@@ -383,8 +419,8 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
383
419
  let bounds = new Float32Array([Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity])
384
420
 
385
421
  let emit = (prim: any, name: string, node: number, world: Mat4, skin: number | null): void => {
386
- if ((prim.mode ?? MODE_TRIANGLES) !== MODE_TRIANGLES) return
387
422
  if (prim.attributes?.POSITION === undefined) return
423
+ let mode: number = prim.mode ?? MODE_TRIANGLES
388
424
  let pos = accessorFloats(prim.attributes.POSITION, name + " POSITION")
389
425
  if (pos.elements !== 3) throw new Error("parseGltf: " + name + " POSITION is not VEC3")
390
426
  let count = pos.count
@@ -408,17 +444,57 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
408
444
  weights = w.data
409
445
  }
410
446
  let stride = skinned ? SKINNED_FLOATS : STANDARD_FLOATS
411
- let indices: ArrayLike<number> =
447
+ let raw: ArrayLike<number> =
412
448
  prim.indices !== undefined ? accessorFloats(prim.indices, name + " indices").data : Array.from({ length: count }, (_, i) => i)
413
- if (indices.length % 3 !== 0) throw new Error("parseGltf: " + name + " index count is not a multiple of 3")
449
+ // The primitive's topology, carried on the geometry. Triangle strips
450
+ // and fans are unrolled to lists (what the winding flip, the
451
+ // flat-shading un-index and the picking shape expect) and a line
452
+ // loop, which has no GPU topology, is closed into a strip.
453
+ let topology: Topology
454
+ let indices: ArrayLike<number>
455
+ switch (mode) {
456
+ case MODE_TRIANGLES:
457
+ topology = "triangles"
458
+ indices = raw
459
+ break
460
+ case MODE_TRIANGLE_STRIP:
461
+ topology = "triangles"
462
+ indices = stripTriangles(raw)
463
+ break
464
+ case MODE_TRIANGLE_FAN:
465
+ topology = "triangles"
466
+ indices = fanTriangles(raw)
467
+ break
468
+ case MODE_LINES:
469
+ topology = "lines"
470
+ indices = raw
471
+ break
472
+ case MODE_LINE_STRIP:
473
+ topology = "line-strip"
474
+ indices = raw
475
+ break
476
+ case MODE_LINE_LOOP:
477
+ topology = "line-strip"
478
+ indices = closeLoop(raw)
479
+ break
480
+ case MODE_POINTS:
481
+ topology = "points"
482
+ indices = raw
483
+ break
484
+ default:
485
+ throw new Error("parseGltf: " + name + " has an unknown primitive mode " + mode)
486
+ }
487
+ let triangles = topology === "triangles"
488
+ if (triangles && indices.length % 3 !== 0) throw new Error("parseGltf: " + name + " index count is not a multiple of 3")
414
489
 
415
490
  // A mirroring chain (negative world determinant at the rest pose)
416
491
  // flips the displayed winding, so the index order is flipped here to
417
492
  // compensate - cull: "back" keeps the outside. Stored normals stay the
418
493
  // authored ones: the runtime's inverse-transpose maps them outward for
419
494
  // the flipped winding. Baked from the REST pose; a scale animated
420
- // across zero would unbake it, which is pathological.
421
- let flip = !skinned && det3(world) < 0
495
+ // across zero would unbake it, which is pathological. Lines and
496
+ // points have no winding.
497
+ let flip = triangles && !skinned && det3(world) < 0
422
498
  // Joint boxes need the skin's inverse binds, and skins are built after
423
499
  // the walk (their joints are ordinary nodes the walk registers), so
424
500
  // the arrays are parked under the FILE's skin index and grown then.
@@ -426,14 +502,18 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
426
502
 
427
503
  let vertices: Float32Array
428
504
  let packedIndices: number[]
429
- if (nrm !== null) {
505
+ if (nrm !== null || !triangles) {
506
+ // Indexed as authored. Lines and points without normals get zero
507
+ // ones (there is no face to take one from; nothing lights them).
430
508
  vertices = new Float32Array(count * stride)
431
509
  for (let i = 0; i < count; i++) writeVertex(vertices, i, pos.data, nrm, uv, i, stride, joints, weights)
432
- packedIndices = new Array(indices.length)
433
- for (let i = 0; i < indices.length; i += 3) {
434
- packedIndices[i] = indices[i]!
435
- packedIndices[i + 1] = flip ? indices[i + 2]! : indices[i + 1]!
436
- packedIndices[i + 2] = flip ? indices[i + 1]! : indices[i + 2]!
510
+ packedIndices = Array.from(indices)
511
+ if (flip) {
512
+ for (let i = 0; i < packedIndices.length; i += 3) {
513
+ let b = packedIndices[i + 1]!
514
+ packedIndices[i + 1] = packedIndices[i + 2]!
515
+ packedIndices[i + 2] = b
516
+ }
437
517
  }
438
518
  } else {
439
519
  // No normals: the spec asks for flat shading, which needs one vertex
@@ -508,6 +588,7 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
508
588
  let geometry: Geometry = skinned
509
589
  ? { vertices, indices: packIndices(packedIndices, count), layout: "skinned", label: name }
510
590
  : packGeometry(vertices, packedIndices, { label: name })
591
+ if (!triangles) geometry.topology = topology
511
592
  parts.push({ name, node, skin, geometry, material })
512
593
  }
513
594
 
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // PerspectiveCamera) on top. See AGENTS.md for the model and the traps.
7
7
 
8
8
  export { add, createGroup, getRotation, getTransform, lookAt, remove, setTransform, setTransition, setVisible, worldPosition } from "./node.ts"
9
- export type { SceneNode, ScenePointerEvent, TransformUpdate, TransitionEndEvent } from "./node.ts"
9
+ export type { NodePointerEvent, NodeTapEvent, NodeWheelEvent, SceneEventBase, SceneNode, ScenePointerEvent, ScenePointerListener, SceneTapEvent, SceneWheelEvent, TransformUpdate, TransitionEndEvent } from "./node.ts"
10
10
  export { addInstance, createInstancedMesh, createMesh, createRecordMesh, createSprite, disposeInstances, removeInstance, setCastShadow, setCulling, setGeometry, setInstanceStyle, setLayers, setMaterial, setMeshParams, setRecordCount, setRecords, setRenderOrder, INSTANCE_FLOATS } from "./mesh.ts"
11
11
  export type { InstancedMesh as InstancedMeshNode, InstancedMeshOptions, InstanceNode, InstanceSlots, InstanceStyle, Mesh as MeshNode, MeshInstances, RecordMesh as RecordMeshNode, RecordMeshOptions } from "./mesh.ts"
12
12
  export { createDirectionalLight, createHemisphereLight, createPointLight, createSpotLight, setLight, MAX_SHADOWS } from "./light.ts"
@@ -26,15 +26,16 @@ export type {
26
26
  SpotShadowOptions,
27
27
  } from "./light.ts"
28
28
  export { createScene } from "./scene.ts"
29
- export type { Capsule, EnvironmentOptions, FogOptions, Hit, Impact, OrientedBox, Overlap, QueryOptions, ReflectionProbe, ReflectionProbeOptions, Scene as SceneHandle, SceneHandlers, SceneOptions, ScreenRay, SkyboxOptions, Sphere, ToneMapping, View, ViewOptions, Volume } from "./scene.ts"
29
+ export { feedPointer } from "./scene-pointer.ts"
30
+ export type { Capsule, EnvironmentOptions, FogOptions, Hit, Impact, MoveOptions, MoveResult, OrientedBox, Overlap, QueryOptions, ReflectionProbe, ReflectionProbeOptions, Scene as SceneHandle, SceneHandlers, SceneOptions, ScreenRay, SkyboxOptions, Sphere, ToneMapping, ViewHandle, ViewOptions, Volume } from "./scene.ts"
30
31
  export { moveAndSlide } from "./collision.ts"
31
- export type { MoveOptions, MoveResult, MoveScene } from "./collision.ts"
32
+ export type { MoveScene } from "./collision.ts"
32
33
  export { equirectToCube, loadEnvironment } from "./environment.ts"
33
34
  export { linearColor, linearToSrgb, srgbToLinear } from "./color.ts"
34
35
  export type { CameraState, CameraUpdate, OrthoExtent } from "./camera.ts"
35
36
  export type { NodeTransition, NodeTransitionSpec } from "flux:spatial"
36
37
  export { disposeGeometry } from "./geometry-gpu.ts"
37
- export { box, circle, cone, cylinder, fillAttribute, fillColors, geometryBounds, layoutAttributes, layoutKey, layoutSlot, layoutStride, mergeGeometries, packGeometry, plane, ring, sphere, torus, torusKnot, transformGeometry, validateGeometry, withAttribute, withColors, STANDARD_FLOATS, VERTEX_LAYOUTS } from "./geometry.ts"
38
+ export { box, circle, cone, cylinder, fillAttribute, fillColors, edgesGeometry, geometryBounds, geometryTopology, layoutAttributes, layoutKey, layoutSlot, layoutStride, mergeGeometries, packGeometry, plane, ring, sphere, torus, torusKnot, transformGeometry, validateGeometry, wireframeGeometry, withAttribute, withColors, STANDARD_FLOATS, VERTEX_LAYOUTS } from "./geometry.ts"
38
39
  export type { AttributeFill, BoxOptions, CircleOptions, ColorFill, ConeOptions, CylinderOptions, Geometry, GeometryOptions, PlaneOptions, RingOptions, SphereOptions, TorusKnotOptions, TorusOptions, VertexLayout } from "./geometry.ts"
39
40
  export { fillet, roundRect, shape, triangulate } from "./profile.ts"
40
41
  export type { Profile, ProfilePoint } from "./profile.ts"
@@ -42,8 +43,9 @@ export { extrude, lathe, pathFrames, sweep, tube } from "./sweep.ts"
42
43
  export type { ExtrudeOptions, LatheOptions, PathFrames, PathPoint, SweepPath, TubeOptions } from "./sweep.ts"
43
44
  export { lit, shaderMaterial, shaderMaterialClass, sprite, standard, unlit } from "./material.ts"
44
45
  export type { LitOptions, Material, ShaderMaterialClass, ShaderMaterialClassOptions, ShaderMaterialInstanceOptions, ShaderMaterialOptions, SpriteOptions, StandardOptions, UnlitOptions } from "./material.ts"
45
- export { DirectionalLight, FirstPersonCamera, Group, HemisphereLight, Instance, InstancedMesh, Mesh, OrbitCamera, PerspectiveCamera, PointLight, RecordMesh, Scene, SpotLight, Sprite, useScene } from "./components.tsx"
46
+ export { DirectionalLight, FirstPersonCamera, Group, HemisphereLight, Instance, InstancedMesh, Mesh, OrbitCamera, PerspectiveCamera, PointLight, RecordMesh, Scene, SpotLight, Sprite, View3d, useScene } from "./components/index.ts"
46
47
  export type {
48
+ CameraTarget,
47
49
  DirectionalLightProps,
48
50
  FirstPersonCameraProps,
49
51
  HemisphereLightProps,
@@ -55,25 +57,28 @@ export type {
55
57
  PointerEventProps,
56
58
  PointLightProps,
57
59
  RecordMeshProps,
58
- SceneInput,
59
- SceneInputListener,
60
+ ScenePointerProps,
60
61
  SceneProps,
61
62
  SpotLightProps,
62
63
  SpriteProps,
63
64
  TransformProps,
64
- } from "./components.tsx"
65
+ View3dProps,
66
+ } from "./components/index.ts"
65
67
  export { gltfExternalUris, isGlb, parseGltf } from "./gltf.ts"
66
68
  export type { ModelChannel, ModelClip, ModelData, ModelMaterial, ModelNode, ModelPart, ModelSkin, UriResolver } from "./gltf.ts"
67
69
  export { decodeModel, encodeModel } from "./model-file.ts"
68
70
  export { createModel, loadGltf, loadModel } from "./model.ts"
69
71
  export type { Model, ModelMaps, ModelOptions } from "./model.ts"
72
+ export { bindSkeleton } from "./skeleton.ts"
70
73
  export { createMixer } from "./mixer.ts"
71
74
  export type { Mixer, MixerOptions, MixerPlayOptions } from "./mixer.ts"
72
75
  export { sampleChannel } from "./clip.ts"
73
76
  export { createOrbitCamera } from "./orbit.ts"
74
- export type { OrbitCamera as OrbitCameraHandle, OrbitCameraOptions, OrbitPose, OrbitTarget } from "./orbit.ts"
77
+ export type { OrbitAxes, OrbitCamera as OrbitCameraHandle, OrbitCameraOptions, OrbitPose, OrbitTarget } from "./orbit.ts"
75
78
  export { createFirstPersonCamera } from "./first-person.ts"
76
- export type { FirstPersonCamera as FirstPersonCameraHandle, FirstPersonCameraOptions, FirstPersonPose, FirstPersonTarget } from "./first-person.ts"
79
+ export type { FirstPersonAxes, FirstPersonCamera as FirstPersonCameraHandle, FirstPersonCameraOptions, FirstPersonPose, FirstPersonTarget } from "./first-person.ts"
80
+ export { firstPersonActions, firstPersonBindings, orbitActions, orbitBindings } from "./input.ts"
81
+ export type { CameraDevices } from "./input.ts"
77
82
  // math's lookAt (the camera view matrix) stays on the /math subpath: the
78
83
  // root's lookAt is the scene verb, the same split as `add`.
79
84
  export { rayBoxDistance, compose, copy, eulerFromQuat, identity, mat4, multiply, normalMatrix, orthographic, perspective, quat, quatFromAxisAngle, quatFromEuler, quatFromFrame, quatFromTo, quatMultiply, quatNormalize, quatSlerp } from "./math.ts"
package/src/input.ts ADDED
@@ -0,0 +1,79 @@
1
+ // The standard input wiring for the 3d camera controls: the action
2
+ // declarations each control consumes (its axes, by the shared vocabulary)
3
+ // and a preset per control that binds the devices an app hands in. A
4
+ // preset is plain data - the bindings the app could write by hand -
5
+ // applied with `input.bind(orbitBindings({ pointer, gamepad }))` and
6
+ // adjusted afterwards with unbind; nothing binds unless the app says so
7
+ // (ARCHITECTURE.md).
8
+ //
9
+ // Devices are the nouns: `pointer` is the feed of the element showing the
10
+ // scene (`createPointerFeed()`, handed to `<Scene pointer>`), `gamepad` a
11
+ // core gamepad device (`gamepad(0)`, or `gamepad()` for any pad), and
12
+ // `keyboard` the core keyboard device, whose key events reach the map
13
+ // through `input.handlers` spread on the window.
14
+ //
15
+ // Keys and sticks move the CAMERA where a drag moves the content (the
16
+ // stick right orbits the eye to the right; a drag right spins the scene
17
+ // to the right), so the rotate and pan bindings of those devices go
18
+ // through invert() and each axis keeps its one meaning. `look` is the
19
+ // exception by nature: a drag and a stick both turn the eye.
20
+
21
+ import { invert } from "@solidrt/core/input"
22
+ import type { Binding, GamepadDevice, KeyboardDevice, PointerFeed } from "@solidrt/core"
23
+
24
+ export type CameraDevices = {
25
+ pointer?: PointerFeed
26
+ gamepad?: GamepadDevice
27
+ keyboard?: KeyboardDevice
28
+ }
29
+
30
+ /** The orbit control's actions: `rotate`, `zoom`, `pan`. */
31
+ export let orbitActions = { rotate: "vec2", zoom: "axis", pan: "vec2" } as const
32
+
33
+ /**
34
+ * The orbit camera's standard bindings: a drag rotates, a Ctrl-drag or a
35
+ * right-drag pans (the desktop pans, Three's and Blender's; a chorded or
36
+ * right-button drag feeds only `pan`, never `rotate`), a pinch and the
37
+ * wheel zoom, two fingers pan; the right stick rotates, the triggers
38
+ * zoom, the left stick pans; the arrow keys rotate and minus/equals zoom.
39
+ */
40
+ export function orbitBindings(devices: CameraDevices): Binding[] {
41
+ let out: Binding[] = []
42
+ let { pointer, gamepad, keyboard } = devices
43
+ if (pointer) {
44
+ out.push(
45
+ { action: "rotate", source: pointer.drag },
46
+ { action: "pan", source: pointer.drag("Ctrl") },
47
+ { action: "pan", source: pointer.drag("Right") },
48
+ { action: "pan", source: pointer.pan },
49
+ { action: "zoom", source: pointer.pinch },
50
+ { action: "zoom", source: pointer.wheel },
51
+ )
52
+ }
53
+ if (gamepad) {
54
+ out.push({ action: "rotate", source: invert(gamepad.rightStick) }, { action: "zoom", source: gamepad.triggers }, { action: "pan", source: invert(gamepad.leftStick) })
55
+ }
56
+ if (keyboard) {
57
+ out.push({ action: "rotate", source: invert(keyboard.arrows) }, { action: "zoom", source: keyboard.axis("Minus", "Equal") })
58
+ }
59
+ return out
60
+ }
61
+
62
+ /** The first-person control's actions: `look`, `move`, `rise`. */
63
+ export let firstPersonActions = { look: "vec2", move: "vec2", rise: "axis" } as const
64
+
65
+ /**
66
+ * The first-person camera's standard bindings: a drag looks around and so
67
+ * does mouse motion while the pointer is locked (the app locks it: see
68
+ * examples/first-person.tsx); the right stick looks, the left stick
69
+ * walks, the shoulders rise and sink in fly mode; WASD and the arrows
70
+ * walk, Q/E rise and sink.
71
+ */
72
+ export function firstPersonBindings(devices: CameraDevices): Binding[] {
73
+ let out: Binding[] = []
74
+ let { pointer, gamepad, keyboard } = devices
75
+ if (pointer) out.push({ action: "look", source: pointer.drag }, { action: "look", source: pointer.mouseDelta })
76
+ if (gamepad) out.push({ action: "look", source: gamepad.rightStick }, { action: "move", source: gamepad.leftStick }, { action: "rise", source: gamepad.shoulders })
77
+ if (keyboard) out.push({ action: "move", source: keyboard.wasd }, { action: "move", source: keyboard.arrows }, { action: "rise", source: keyboard.axis("KeyQ", "KeyE") })
78
+ return out
79
+ }