@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/material.ts CHANGED
@@ -41,7 +41,7 @@ import type {
41
41
  Topology,
42
42
  VertexAttribute,
43
43
  } from "@solidrt/core/gpu"
44
- import { layoutAttributes, layoutKey, layoutSlot } from "./geometry.ts"
44
+ import { FORMAT_FLOATS, layoutAttributes, layoutKey, layoutSlot } from "./geometry.ts"
45
45
  import type { VertexLayout } from "./geometry.ts"
46
46
  import { linearColor } from "./color.ts"
47
47
  import {
@@ -63,9 +63,10 @@ import {
63
63
  } from "./glsl.ts"
64
64
 
65
65
  export type Material = {
66
- /** The pipeline this material draws with for geometry of `layout`
67
- * (lazily created, one per layout met). */
68
- pipeline(layout: VertexLayout | undefined): RenderPipelineId
66
+ /** The pipeline this material draws with for geometry of `layout` and
67
+ * `topology` (lazily created, one per pair met): the geometry says what
68
+ * its indices are, the material draws them that way. */
69
+ pipeline(layout: VertexLayout | undefined, topology: Topology): RenderPipelineId
69
70
  /** Per-entry uniform values this material contributes at addDraw. */
70
71
  params: ShaderParams
71
72
  /** Per-entry sampler bindings, when the material samples textures. */
@@ -1072,7 +1073,6 @@ export type ShaderMaterialClassOptions = {
1072
1073
  depthWrite?: boolean
1073
1074
  blend?: BlendMode
1074
1075
  cull?: CullMode
1075
- topology?: Topology
1076
1076
  label?: string
1077
1077
  }
1078
1078
 
@@ -1142,6 +1142,24 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
1142
1142
  let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
1143
1143
  let depth = opts.depth ?? true
1144
1144
  let cull = opts.cull ?? "back"
1145
+ // Formats are the vertex vocabulary (f32/vec2/vec3/vec4), not WebGPU's
1146
+ // spelling, and an unknown one has no float count: it would leave here
1147
+ // intact, make a NaN record stride at the mesh, and fail as an arithmetic
1148
+ // complaint from createRecordMesh naming neither the attribute nor the
1149
+ // format. Checked where the name is written instead.
1150
+ for (let attr of opts.instanceAttributes ?? []) {
1151
+ if (!(attr.format in FORMAT_FLOATS)) {
1152
+ throw new Error(
1153
+ "shaderMaterial unknown instance attribute format '" +
1154
+ String(attr.format) +
1155
+ "' for " +
1156
+ attr.name +
1157
+ " (expected " +
1158
+ Object.keys(FORMAT_FLOATS).join(", ") +
1159
+ ")",
1160
+ )
1161
+ }
1162
+ }
1145
1163
  // An empty list declares nothing - same as absent (the engine requires an
1146
1164
  // instance buffer exactly when attributes are declared).
1147
1165
  let instanceAttributes = opts.instanceAttributes?.length ? opts.instanceAttributes.map(a => ({ ...a })) : undefined
@@ -1192,8 +1210,8 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
1192
1210
  // record buffer, declared on the pipeline beside the layout).
1193
1211
  let attributes = (): VertexAttribute[] =>
1194
1212
  programAttributes(programFor()).filter(a => !instanceAttributes?.some(i => i.name === a.name))
1195
- let pipelineFor = (layout: VertexLayout | undefined): RenderPipelineId => {
1196
- let key = layoutKey(layout)
1213
+ let pipelineFor = (layout: VertexLayout | undefined, topology: Topology): RenderPipelineId => {
1214
+ let key = layoutKey(layout) + "|" + topology
1197
1215
  let pipeline = pipelines.get(key)
1198
1216
  if (pipeline === undefined) {
1199
1217
  pipeline = createRenderPipeline(programFor(), {
@@ -1205,7 +1223,7 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
1205
1223
  depthWrite: opts.depthWrite ?? (transparent && depth ? false : undefined),
1206
1224
  blend: opts.blend ?? (transparent ? "alpha" : undefined),
1207
1225
  cull,
1208
- topology: opts.topology,
1226
+ topology,
1209
1227
  label: opts.label,
1210
1228
  })
1211
1229
  pipelines.set(key, pipeline)
package/src/mesh.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  // scene side is reached through the node's SceneHooks (node.ts).
5
5
 
6
6
  import { createBuffer, destroyBuffer, writeBuffer } from "@solidrt/core/gpu"
7
- import type { BufferId, DrawId, InstanceAttribute, ShaderParams, TextureBindings, VertexAttribute } from "@solidrt/core/gpu"
8
- import { geometryBounds, plane } from "./geometry.ts"
7
+ import type { BufferId, DrawId, InstanceAttribute, ShaderParams, TextureBindings } from "@solidrt/core/gpu"
8
+ import { FORMAT_FLOATS, geometryBounds, plane } from "./geometry.ts"
9
9
  import type { Geometry } from "./geometry.ts"
10
10
  import type { GeometryBuffers } from "./geometry-gpu.ts"
11
11
  import type { Material } from "./material.ts"
@@ -24,7 +24,8 @@ export type Mesh = SceneNode & {
24
24
  renderOrder: number
25
25
  /** Draw into the scene's shadow map (default false, Three's default).
26
26
  * Set with setCastShadow. A casting instanced mesh is skipped (the
27
- * depth pass cannot know its record layout). */
27
+ * depth pass cannot know its record layout), and so is any geometry
28
+ * that is not a triangle list: lines and points cast nothing. */
28
29
  castShadow: boolean
29
30
  /** Layer membership bitmask (default 1, Three's `object.layers`): a
30
31
  * target draws the mesh when its mask intersects this. Not inherited
@@ -225,13 +226,11 @@ export function localBounds(mesh: Mesh): Float32Array | null {
225
226
  return mesh._sprite ? SPRITE_BOUNDS : geometryBounds(mesh.geometry)
226
227
  }
227
228
 
228
- const ATTRIBUTE_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
229
-
230
229
  /** Floats per record of one instance slot of an attribute list (slot 0
231
230
  * by default; the list's `slot` keys pick the others). */
232
231
  export function instanceStride(attributes: InstanceAttribute[], slot = 0): number {
233
232
  let stride = 0
234
- for (let a of attributes) if ((a.slot ?? 0) === slot) stride += ATTRIBUTE_FLOATS[a.format]
233
+ for (let a of attributes) if ((a.slot ?? 0) === slot) stride += FORMAT_FLOATS[a.format]
235
234
  return stride
236
235
  }
237
236
 
package/src/model.ts CHANGED
@@ -24,6 +24,8 @@ import { add, createGroup, remove, setTransform } from "./node.ts"
24
24
  import type { SceneNode } from "./node.ts"
25
25
  import { createMesh } from "./mesh.ts"
26
26
  import type { Mesh } from "./mesh.ts"
27
+ import { refreshJointBounds, unbindSkeleton } from "./skeleton.ts"
28
+ import type { WornPiece } from "./skeleton.ts"
27
29
 
28
30
  /** Anisotropic filtering level for a model's textures: the engines' usual
29
31
  * default (Godot ships 2x, Unity's quality presets 2-8x) - enough to keep a
@@ -80,8 +82,30 @@ export type Model = SceneNode & {
80
82
  /** Local rest-pose [minX, minY, minZ, maxX, maxY, maxZ] over every part
81
83
  * (conservative for parts under rotated nodes). */
82
84
  bounds: Float32Array
83
- /** Detach the model and free its geometry buffers and textures. */
85
+ /** Detach the model and free its geometry buffers and textures. A
86
+ * worn piece (bindSkeleton) comes off its body; a body takes the
87
+ * pieces it wears with it. */
84
88
  dispose(): void
89
+ /** @internal The skins the parts draw, in file order: the palette
90
+ * texture (shared by identical skins), the joint nodes in row order,
91
+ * their inverse binds and joint-space influence boxes. */
92
+ _skins: ModelSkinNodes[]
93
+ /** @internal The pieces bound onto this model's skeleton. */
94
+ _worn: WornPiece[]
95
+ /** @internal The body this model is bound onto, or null. */
96
+ _body: Model | null
97
+ /** @internal Subtrees bindSkeleton moved under the body's joints (no
98
+ * longer under this model); detached again by dispose. */
99
+ _grafts: SceneNode[]
100
+ }
101
+
102
+ /** @internal One skin of a built model: ModelSkin with its joints
103
+ * resolved to the model's nodes and its palette texture. */
104
+ export type ModelSkinNodes = {
105
+ texture: TextureId
106
+ joints: SceneNode[]
107
+ inverseBind: Float32Array
108
+ jointBounds: Float32Array
85
109
  }
86
110
 
87
111
  /**
@@ -162,6 +186,10 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
162
186
  let materials = data.materials.map((_, i) => materialFor(i, false))
163
187
 
164
188
  let model = createGroup() as Model
189
+ model._skins = []
190
+ model._worn = []
191
+ model._body = null
192
+ model._grafts = []
165
193
  // The node table is pre-order, so every parent group exists before its
166
194
  // children reference it.
167
195
  let groups: SceneNode[] = data.nodes.map((n) => {
@@ -183,39 +211,38 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
183
211
  // joints move - there is no JS palette walk, and posing joints with
184
212
  // setTransform is always enough. Identical skins (same joints, same
185
213
  // inverse binds - the body/legs and LOD splits exporters produce) share
186
- // one texture, so their palette is computed and uploaded once.
187
- let skinTextures: TextureId[] = []
214
+ // one texture, so their palette is computed and uploaded once. A worn
215
+ // piece's rows move onto another model's joints (bindSkeleton).
188
216
  data.skins.forEach((skin, i) => {
189
- let joints = skin.joints.join(",")
190
- for (let j = 0; j < i; j++) {
191
- let other = data.skins[j]!
192
- if (
193
- other.joints.join(",") === joints &&
194
- other.inverseBind.length === skin.inverseBind.length &&
195
- other.inverseBind.every((v, k) => v === skin.inverseBind[k])
196
- ) {
197
- skinTextures.push(skinTextures[j]!)
198
- return
199
- }
200
- }
201
- let texture = createMutableTexture(new Float32Array(skin.joints.length * 16), 4, skin.joints.length, {
202
- format: "rgba32f",
203
- autoFree: false,
204
- label: label ? label + "-skin" + i : "skin" + i,
217
+ let joints = skin.joints.map((n) => {
218
+ let joint = groups[n]
219
+ if (joint === undefined) throw new Error("createModel: skin " + i + " names a missing node " + n)
220
+ return joint
205
221
  })
222
+ let key = skin.joints.join(",")
223
+ let same = model._skins.find(
224
+ (other, j) =>
225
+ data.skins[j]!.joints.join(",") === key &&
226
+ other.inverseBind.length === skin.inverseBind.length &&
227
+ other.inverseBind.every((v, k) => v === skin.inverseBind[k]),
228
+ )
229
+ let texture =
230
+ same?.texture ??
231
+ createMutableTexture(new Float32Array(skin.joints.length * 16), 4, skin.joints.length, {
232
+ format: "rgba32f",
233
+ autoFree: false,
234
+ label: label ? label + "-skin" + i : "skin" + i,
235
+ })
236
+ model._skins.push({ texture, joints, inverseBind: skin.inverseBind, jointBounds: skin.jointBounds })
237
+ if (same !== undefined) return
206
238
  textures.push(texture)
207
- skinTextures.push(texture)
208
- for (let j = 0; j < skin.joints.length; j++) {
209
- let joint = groups[skin.joints[j]!]
210
- if (joint === undefined) throw new Error("createModel: skin " + i + " names a missing node " + skin.joints[j])
239
+ joints.forEach((joint, j) => {
211
240
  ;(joint._palettes ??= []).push({ texture, row: j, post: skin.inverseBind.slice(j * 16, j * 16 + 16), anchor: model })
212
- // The joint's influence box, in joint space: culling-only bounds the
213
- // flush carries through the pose. A joint influencing nothing (an
214
- // inverted box) gets none and contributes nothing to the union.
215
- let jb = skin.jointBounds.subarray(j * 6, j * 6 + 6)
216
- if (jb[0]! <= jb[3]! && jb[1]! <= jb[4]! && jb[2]! <= jb[5]!) joint._cullBounds = Float32Array.from(jb)
217
- }
241
+ })
218
242
  })
243
+ // Each joint's influence box, in joint space: culling-only bounds the
244
+ // flush carries through the pose, united over every skin reaching it.
245
+ refreshJointBounds(model)
219
246
  model.parts = data.parts.map((part) => {
220
247
  let skinned = part.skin !== null
221
248
  let material = materialFor(part.material, skinned)
@@ -225,11 +252,11 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
225
252
  // matrices place them, so its mesh hangs off the model root (the
226
253
  // spec ignores the node's transform for skinned meshes) and uModel
227
254
  // stays the model's own placement.
228
- let texture = skinTextures[part.skin!]
229
- if (texture === undefined) throw new Error("createModel: part '" + part.name + "' names a missing skin " + part.skin)
230
- mesh._textures = { uBones: texture }
255
+ let skin = model._skins[part.skin!]
256
+ if (skin === undefined) throw new Error("createModel: part '" + part.name + "' names a missing skin " + part.skin)
257
+ mesh._textures = { uBones: skin.texture }
231
258
  // Culled by its joints' boxes, so the box follows the animation.
232
- mesh._cullJoints = data.skins[part.skin!]!.joints.map((n) => groups[n]!)
259
+ mesh._cullJoints = skin.joints.slice()
233
260
  add(model, mesh)
234
261
  } else {
235
262
  let node = groups[part.node]
@@ -242,6 +269,11 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
242
269
  model.clips = data.clips
243
270
  model.bounds = data.bounds
244
271
  model.dispose = () => {
272
+ // A worn piece comes off first (its rows sit on the body's joints);
273
+ // a body takes its pieces with it (their skins read joints that are
274
+ // about to go).
275
+ if (model._body !== null) unbindSkeleton(model)
276
+ for (let worn of model._worn.slice()) worn.piece.dispose()
245
277
  if (model.parent !== null) remove(model)
246
278
  for (let part of model.parts) disposeGeometry(part.mesh.geometry)
247
279
  for (let id of textures) destroyTexture(id)
package/src/node.ts CHANGED
@@ -10,12 +10,14 @@ import * as spatial from "flux:spatial"
10
10
  import type { NodeId, NodeTransition } from "flux:spatial"
11
11
  import { on } from "srt:events"
12
12
  import type { ShaderParams, TextureId } from "@solidrt/core/gpu"
13
+ import type { PointerEvent as ElementPointerEvent, WheelEvent as ElementWheelEvent } from "@solidrt/core"
13
14
  // The scene's lookAt() aims a node; math's builds a camera's view matrix -
14
15
  // the same pairing (and the same name) as Three's Object3D/Matrix4.
15
16
  import { compose, eulerFromQuat, identity, mat4, multiply, quat, quatFromFrame, transformPoint, updateRotation, updateScale } from "./math.ts"
16
17
  import type { Mat4, Quat, TransformUpdate, Vec3, Vec4 } from "./math.ts"
17
18
  import type { CastingLight, Light } from "./light.ts"
18
19
  import type { InstancedMesh, InstanceNode, Mesh } from "./mesh.ts"
20
+ import type { Scene, ViewHandle } from "./scene.ts"
19
21
 
20
22
  // lookAt()'s default roll reference. Read-only: quatFromFrame never
21
23
  // writes its inputs, so one shared vector is safe.
@@ -107,15 +109,23 @@ export type SceneNode = {
107
109
  visible: boolean
108
110
  /** Pointer event handlers - plain fields, assign freely (they touch no
109
111
  * GPU state, so they need no setTransform-style write path; components
110
- * sync their props here). Down/move/up dispatch on the hit mesh and
111
- * bubble through its ancestors (stopPropagation stops the walk);
112
- * enter/leave fire on the mesh alone. Events flow once the element
113
- * showing the scene carries `scene.handlers`. */
114
- onPointerDown?: (event: ScenePointerEvent) => void
115
- onPointerMove?: (event: ScenePointerEvent) => void
116
- onPointerUp?: (event: ScenePointerEvent) => void
117
- onPointerEnter?: (event: ScenePointerEvent) => void
118
- onPointerLeave?: (event: ScenePointerEvent) => void
112
+ * sync their props here). Down/move/up/wheel/tap dispatch on the
113
+ * nearest hit - the struck instance of an instanced mesh, else the mesh
114
+ * - bubble through its ancestors and end at the scene's listeners
115
+ * (stopPropagation stops the walk, and a stopped down claims the whole
116
+ * press); enter/leave fire on the struck node alone. Events flow once
117
+ * the element showing the scene carries `scene.handlers`. */
118
+ onPointerDown?: (event: NodePointerEvent) => void
119
+ onPointerMove?: (event: NodePointerEvent) => void
120
+ onPointerUp?: (event: NodePointerEvent) => void
121
+ onPointerEnter?: (event: NodePointerEvent) => void
122
+ onPointerLeave?: (event: NodePointerEvent) => void
123
+ /** The wheel over the node (NodeWheelEvent: `deltaX`/`deltaY`), bubbling
124
+ * like down/move/up. */
125
+ onWheel?: (event: NodeWheelEvent) => void
126
+ /** A press released on this node within the slop, alone for its whole
127
+ * press (NodeTapEvent: `tapCount`); bubbles like down/move/up. */
128
+ onTap?: (event: NodeTapEvent) => void
119
129
  /** A declared transition (setTransition) settled naturally on one
120
130
  * component; a cancel, snap or scene leave never fires. */
121
131
  onTransitionEnd?: (event: TransitionEndEvent) => void
@@ -133,7 +143,8 @@ export type SceneNode = {
133
143
  * skin): bound to the core at every scene enter, so the flush writes
134
144
  * `inverse(anchorWorld) * world * post` - the model-local bone matrix -
135
145
  * to the palette texture's row whenever the node moves. `anchor` is the
136
- * model root; null for non-joints (the common case pays one null check). */
146
+ * model root - the body's, for a piece bound onto its skeleton - and
147
+ * null for non-joints (the common case pays one null check). */
137
148
  _palettes: { texture: TextureId; row: number; post: Float32Array; anchor: SceneNode }[] | null
138
149
  /** A culling-only local box (a model joint's influence region, in joint
139
150
  * space): bound at every scene enter so the joint's world box follows
@@ -146,26 +157,19 @@ export type TransitionEndEvent = {
146
157
  component: "position" | "rotation" | "scale"
147
158
  }
148
159
 
149
- /**
150
- * The event a mesh, instance or ancestor group handler receives: the
151
- * element pointer vocabulary carried over, plus the 3D fields.
152
- * `point`/`distance` are null exactly when the ray misses the dispatch
153
- * target - which happens only during a captured drag or on a leave.
154
- */
155
- export type ScenePointerEvent = {
156
- /** The mesh the event is about (the hit, or the captured mesh during a
157
- * drag) - constant while the event bubbles. */
158
- mesh: Mesh
160
+ /** What every scene pointer event carries, whichever handler sees it:
161
+ * the element pointer vocabulary carried over, plus the 3D fields. */
162
+ export type SceneEventBase = {
159
163
  /** The instance struck when `mesh` is an instanced mesh (the walk
160
164
  * starts there), null otherwise - constant while the event bubbles. */
161
165
  instance: InstanceNode | null
162
- /** Node whose handler is running; changes as the event bubbles. */
163
- currentTarget: SceneNode
164
- /** World-space hit point on `mesh`, or null when the ray misses it. */
166
+ /** World-space hit point on `mesh`, or null when there is no mesh or
167
+ * the ray misses it (a captured drag off the mesh, a leave). */
165
168
  point: Vec3 | null
166
169
  /** Camera-ray distance to `point` in world units, or null with it. */
167
170
  distance: number | null
168
- /** Pointer position in scene pixels - project()'s coordinate space. */
171
+ /** Pointer position in scene pixels - project()'s coordinate space
172
+ * (a view's own pixels under a view leaf). */
169
173
  x: number
170
174
  y: number
171
175
  pointerId: number
@@ -175,10 +179,77 @@ export type ScenePointerEvent = {
175
179
  ctrlKey: boolean
176
180
  altKey: boolean
177
181
  metaKey: boolean
178
- /** Stops the bubble walk after the current handler. */
182
+ /**
183
+ * The element event the scene's leaf received, in the leaf's own frame
184
+ * (localX/localY, clientX/clientY, movementX/Y): what core's
185
+ * recognizers consume, so a mesh drags itself through `createPan` and
186
+ * a camera control's feed rides the same events the walk carries.
187
+ */
188
+ native: ElementPointerEvent
189
+ /**
190
+ * Stops the walk after the current handler: no ancestor and none of the
191
+ * scene's listeners see the event. Stopping a DOWN claims the whole
192
+ * press - that pointer's move, up and tap never reach the scene either,
193
+ * so a mesh that drags itself stops its down once and an orbit control
194
+ * fed at the root never turns under it.
195
+ */
179
196
  stopPropagation(): void
180
197
  }
181
198
 
199
+ /**
200
+ * The event a mesh, instance or ancestor group handler receives: `mesh`
201
+ * is the hit (or the captured mesh during a drag), constant while the
202
+ * event bubbles; `currentTarget` the node whose handler is running.
203
+ */
204
+ export type NodePointerEvent = SceneEventBase & {
205
+ mesh: Mesh
206
+ currentTarget: SceneNode
207
+ }
208
+
209
+ /**
210
+ * The event as the SCENE's listeners see it (scene.listen, the `<Scene>`
211
+ * pointer props; a view's under its own leaf), the last stop of the walk:
212
+ * `mesh` is the hit it bubbled from, or null over empty space, where the
213
+ * scene is the only target.
214
+ */
215
+ export type ScenePointerEvent = SceneEventBase & {
216
+ mesh: Mesh | null
217
+ currentTarget: Scene | ViewHandle
218
+ }
219
+
220
+ type WheelFields = {
221
+ /** The wheel delta as the element event reports it. */
222
+ deltaX: number
223
+ deltaY: number
224
+ native: ElementWheelEvent
225
+ }
226
+ type TapFields = {
227
+ /** 1 for a tap, 2 for the second of a double tap (the same target,
228
+ * within the repeat interval and distance), and so on - DOM's `detail`,
229
+ * Unity's `clickCount`. */
230
+ tapCount: number
231
+ }
232
+
233
+ export type NodeWheelEvent = NodePointerEvent & WheelFields
234
+ export type SceneWheelEvent = ScenePointerEvent & WheelFields
235
+ /**
236
+ * A press that released on the target it pressed without travelling past
237
+ * the slop, the only pointer down for its whole press (a pinch never
238
+ * taps). Dispatched after the up, bubbling the same way; `x`/`y` and
239
+ * `point` are the release point.
240
+ */
241
+ export type NodeTapEvent = NodePointerEvent & TapFields
242
+ export type SceneTapEvent = ScenePointerEvent & TapFields
243
+
244
+ /** A listener at the scene's (or a view's) root; see Scene.listen. */
245
+ export type ScenePointerListener = {
246
+ onPointerDown?: (event: ScenePointerEvent) => void
247
+ onPointerMove?: (event: ScenePointerEvent) => void
248
+ onPointerUp?: (event: ScenePointerEvent) => void
249
+ onWheel?: (event: SceneWheelEvent) => void
250
+ onTap?: (event: SceneTapEvent) => void
251
+ }
252
+
182
253
  /** A bare node record (internal: the mesh and light constructors build on it). */
183
254
  export function makeNode(kind: SceneNode["kind"]): SceneNode {
184
255
  return {
@@ -245,7 +316,10 @@ export function enterScene(node: SceneNode, scene: SceneHooks): void {
245
316
  }
246
317
  }
247
318
  if (node._cullBounds !== null) spatial.setCullBounds(node._node, node._cullBounds)
248
- if (node.kind === "mesh") scene._attach(node as Mesh)
319
+ if (node.kind === "mesh") {
320
+ spatial.setLayers(node._node, (node as Mesh).layers)
321
+ scene._attach(node as Mesh)
322
+ }
249
323
  else if (node.kind === "light") scene._attachLight(node as Light)
250
324
  else if (node.kind === "instance") scene._attachInstance(node as InstanceNode)
251
325
  for (let c of node.children) enterScene(c, scene)