@solidrt/3d 0.0.47 → 0.0.49

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 CHANGED
@@ -11,7 +11,8 @@ blendMode and pointer events like any element. Design rationale:
11
11
 
12
12
  - Two layers. The imperative core is Solid-free: `createScene`,
13
13
  `createMesh(geometry, material)`, `add`/`remove`, `setTransform`,
14
- `setVisible` - plain objects with dirty flags, batched to a microtask,
14
+ `lookAt`, `getRotation`, `setVisible` - plain objects with dirty flags, batched to a
15
+ microtask,
15
16
  one `setDrawParams` (uModel, plus uNormal for materials declaring it)
16
17
  per changed mesh and ONE `setTargetParams` (the shared uViewProj +
17
18
  uCamPos) per camera change, however many meshes. The component
@@ -48,9 +49,9 @@ blendMode and pointer events like any element. Design rationale:
48
49
 
49
50
  | Component | Props |
50
51
  | --- | --- |
51
- | `Scene` | `width`, `height` (target pixels), `clearColor?`, `label?`, `ref?(scene)`, `output?(texture)` |
52
- | `Group` | `position?`, `rotation?` (Euler radians, x-y-z order), `scale?` (number = uniform), `visible?`, `ref?(node)` |
53
- | `Mesh` | `geometry`, `material`, transforms as Group, `ref?(mesh)` |
52
+ | `Scene` | `width`, `height` (target pixels), `clearColor?`, `background?` (fragment GLSL), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
53
+ | `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
54
+ | `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), pointer events (below), `ref?(mesh)` |
54
55
  | `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` |
55
56
 
56
57
  Output composition: without `output`, `Scene` emits a minimal
@@ -101,6 +102,40 @@ immediately, so set-then-project in one tick is exact. `scene.viewProj(out?)`
101
102
  copies the view-projection matrix for batch work. Never rebuild the
102
103
  camera matrices by hand for a HUD.
103
104
 
105
+ Picking: `scene.pick(x, y)` is project()'s inverse - the camera ray
106
+ through a scene pixel, returning `Hit[]` (`{ mesh, distance, point }`,
107
+ world units, nearest first; every hit along the ray, not just the front
108
+ one). `scene.raycast(origin, direction)` is the world-space primitive
109
+ under it. The volume tier: hits test each mesh's local bounding box,
110
+ transformed exactly under any node transform (non-uniform scale
111
+ included), so results are conservative - a ray through a knot's hole
112
+ still hits (no `face`/`uv` fields until a triangle tier exists).
113
+ Broadphase is a dynamic AABB tree (BVH) the sync walk keeps current from
114
+ its own dirty set - maintenance is O(changed), a query O(log meshes) -
115
+ so per-pointer-move picking puts no ceiling on scene size. Both methods
116
+ flush pending writes first (the lookAt/project immediacy contract), and
117
+ both skip invisible meshes.
118
+
119
+ Mesh pointer events - the element vocabulary one tree deeper:
120
+ `onPointerDown/Move/Up/Enter/Leave` as plain fields on any node (and as
121
+ Mesh/Group props). The nearest hit mesh is the target; down/move/up
122
+ bubble mesh -> ancestor groups (`stopPropagation()` stops the walk);
123
+ enter/leave fire on the mesh alone, pairing on hover changes. A
124
+ pointer-down CAPTURES its mesh until the up: moves and the up keep
125
+ dispatching to it off-mesh (the platform's captured-drag rule), with
126
+ `point`/`distance` null while the ray misses it. The event carries the
127
+ element fields (pointerId, pointerType, button, modifiers) plus `mesh`,
128
+ `currentTarget`, `point`, `distance`, and `x`/`y` in scene pixels.
129
+ Wiring: the built-in `<Scene>` leaf carries `scene.handlers`
130
+ automatically (opt out: `events={false}`); an `output` leaf or
131
+ imperative composition spreads `{...scene.handlers}` onto the element
132
+ showing the texture. `scene.handlers` assumes that leaf is LAID OUT at
133
+ the target size - true for the built-in leaf and a d-texture at natural
134
+ size, under any ancestor transforms or viewBox fits (the hit test
135
+ undoes them; localX/localY arrive in the leaf's layout frame). A leaf
136
+ laid out at a different size (the supersampling pattern) uses
137
+ `scene.handlersFor(() => ({ width, height }))` with its layout size.
138
+
104
139
  Geometry: `box(w?, h?, d?)`; `plane(w?, h?)`, `circle(radius?, seg?)` and
105
140
  `ring(inner?, outer?, seg?)` (XY, facing +z - rotate `[-Math.PI/2, 0, 0]`
106
141
  for a floor); `sphere(radius?, wSeg?, hSeg?)`;
@@ -129,10 +164,21 @@ Winding is normalized, so either authoring direction works.
129
164
  with a quarter-round bevel at both rims; `lathe(profile, segs?, angle?,
130
165
  start?)` revolves a CLOSED (x = radius, y = height) profile about the y
131
166
  axis - watertight by construction, flat caps on partial sweeps;
132
- `shape(profile)` fills one flat (facing +z, like circle);
133
- `triangulate(points)` is the ear-clipping core (fan fallback, never drops
134
- a cap), exported for custom flat work. These pick uint16/uint32 indices
135
- by vertex count automatically.
167
+ `sweep(profile, path)` runs the profile along an open 3D polyline with
168
+ MITRED joints (each cross-section sits on its bend's bisector plane, so
169
+ bends never gape or overlap) and flat caps at both ends. The path
170
+ mirrors the profile convention: bare `[x, y, z]` points crease (a strap
171
+ folding over an edge), `{ p, smooth }` points shade continuous (tag a
172
+ sampled curve's points); the profile's y starts as close to world up as
173
+ the first segment allows, then parallel-transports without spinning.
174
+ Closed loops are NOT supported yet - overlap the ends by a segment to
175
+ fake one. `tube(path, radius?, radialSegs?)` is the round-profile
176
+ shorthand (wire, rope, pipe), and `pathFrames(path)` exports the
177
+ per-segment frames (tangents, cross-section axes, arc lengths) for
178
+ custom work along a path. `shape(profile)` fills one flat (facing +z,
179
+ like circle); `triangulate(points)` is the ear-clipping core (fan
180
+ fallback, never drops a cap), exported for custom flat work. These pick
181
+ uint16/uint32 indices by vertex count automatically.
136
182
 
137
183
  Materials:
138
184
 
@@ -158,7 +204,25 @@ Materials:
158
204
  pipeline preamble. App-driven uniforms beyond the standard set: seed
159
205
  via `params`, then write per mesh with
160
206
  `setMeshParams(mesh, { name: value })` (validated names; values persist
161
- across entry rebuilds; frame-rate-safe like setTransform).
207
+ across entry rebuilds; frame-rate-safe like setTransform) or declaratively
208
+ with the `Mesh` `params` prop (same merge semantics - a key that
209
+ disappears from the object keeps its old value; for per-frame values
210
+ prefer `ref` + setMeshParams from onFrame, the setTransform split).
211
+
212
+ Background: `scene.setBackground(source | null)`, the `background` option
213
+ on createScene, and the reactive `Scene` prop. Fragment GLSL drawn as the
214
+ FIRST entry of the scene's own pass (attributeless fullscreen triangle,
215
+ depth off) - one target instead of a backdrop texture stacked under the
216
+ scene, with no separate resize plumbing. The source gets the
217
+ shader-target fragment contract exactly (vUV 0..1 top-left origin,
218
+ iResolution, fragColor; no `#version` line = the standard preamble), so
219
+ a `createShaderTexture` backdrop ports verbatim. Three's
220
+ `scene.background = color` is `clearColor` here; a texture-id form can
221
+ widen the signature later (a branded TextureId is a number, so
222
+ `string | TextureId` disambiguates at runtime). No app-driven uniforms:
223
+ a background is static art - anything animated is a mesh's own
224
+ shaderMaterial (or, until blend factors land, a separate shader texture
225
+ underneath, which translucent grounds also still need).
162
226
 
163
227
  Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
164
228
  into shaderMaterial sources with plain template literals - `LIT_VERTEX`
@@ -182,14 +246,84 @@ system.
182
246
  and show mesh interiors.
183
247
  - `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
184
248
  cheap off switch). Hidden meshes skip uModel writes; the fresh matrix is
185
- written on unhide.
249
+ written on unhide. A freshly attached entry starts off the same way and
250
+ sync() turns it on when it writes uModel - never add one live: it has no
251
+ world matrix yet, and drawn before the sync microtask it flashes at the
252
+ world origin for a frame.
186
253
  - Alpha does not blend in v1: pipelines are opaque (`blend: "none"`), a
187
254
  translucent color overwrites. Transparency waits on blend factors +
188
255
  sorting (research note, staging step 4).
189
- - Rotation is Euler radians applied x, then y, then z. No quaternions in
190
- v1.
191
- - Transforms have ONE write path: `setTransform`/`setVisible` (or the
192
- props that call them). Mutating `node.position` directly does not sync.
256
+ - Rotation is stored as a QUATERNION (`node.quaternion`, `[x, y, z, w]`,
257
+ always unit). There is exactly one rotation field: no `node.rotation`
258
+ shadowing it, because a second field is a second thing to go stale (an
259
+ aimed node whose Euler triple still reads as the old pose is the bug
260
+ this model deletes). Euler triples are a boundary format only -
261
+ `setTransform({ rotation })` and the `rotation` prop convert in,
262
+ `getRotation(node, out?)` converts out.
263
+ - Euler triples are XYZ order (x applied first: `R = Rx * Ry * Rz`),
264
+ Three's `Euler` default, so a triple copied from a Three scene means the
265
+ same thing here. This CHANGED 2026-08-11: the old `compose()` built
266
+ `Rz * Ry * Rx` (Three's `'ZYX'`) while its comment claimed XYZ. Every
267
+ rotation triple then in the repo, examples, demos and projects was
268
+ single-axis, which is order-independent, so the fix moved no pixels -
269
+ verified, not assumed. There is ONE order and no order argument: a
270
+ per-call order is how one triple ends up meaning two things.
271
+ - `getRotation` cannot recover the triple that was written, only a triple
272
+ meaning the same rotation (and at the poles it pins z to 0 and folds the
273
+ roll into x). It is for reading and debugging; anything composing or
274
+ interpolating rotations works with the quaternion.
275
+ - `eulerFromQuat` extracts y with `atan2(m02, cos(y))`, NOT Three's
276
+ `asin(m02)`: asin's derivative blows up at the poles, turning 1e-16 of
277
+ matrix error into 1e-8 of angle. Same reason its pole branch starts at
278
+ `cos(y) < 1e-7` rather than Three's `|m02| > 0.9999999` (which is
279
+ `cos(y) ~ 4.5e-4` - three orders early, and inside that band Three
280
+ silently discards real roll). Do not "restore parity" here.
281
+ - Aim with `lookAt(node, target, up?)`, never by extracting angles by
282
+ hand. Three's `Object3D.lookAt` semantics deliberately: `target` and
283
+ `up` are WORLD space (ancestor transforms are undone, and the ancestor
284
+ chain is refreshed on the spot rather than waiting for the sync), and
285
+ local +z ends up pointing at the target. To aim along a DIRECTION, add
286
+ it to `worldPosition(node)` - the same conversion Three asks for.
287
+ +z is the library's own sweep axis, so `extrude`/`sweep`/`tube` output
288
+ needs no correction. For a y-axis solid (`cylinder`, `cone`) use
289
+ `quatFromTo(q, [0, 1, 0], dir)` instead of correcting lookAt's +z.
290
+ Divergences from Three, both deliberate: `up` is an argument, NOT a
291
+ per-node field (Three's `object.up` is hidden state that costs a vector
292
+ on every node), and degenerate frames pick a stable perpendicular
293
+ instead of Three's epsilon nudge of the eye.
294
+ There is no `setTransform(node, { matrix })`, and lookAt is a MUTATOR,
295
+ not a rotation-returning function.
296
+ - `quatFromTo` is Three's `setFromUnitVectors`, renamed after Unity's
297
+ `FromToRotation` / glam's `from_rotation_arc`: the Three name states a
298
+ precondition instead of the operation, and ours has no such
299
+ precondition (it normalizes). Check Unity/glam/Godot too before copying
300
+ a Three name that reads as an artifact of its class layout.
301
+ - The composition set: `quatFromAxisAngle` (radians; normalizes the axis -
302
+ Three/Unity/glam all require a unit axis and silently corrupt
303
+ otherwise), `quatMultiply` (same order contract as the mat4 `multiply`:
304
+ `a * b`, b applies first; does NOT renormalize - the unit product only
305
+ drifts under long accumulation, and setTransform renormalizes on
306
+ write), `quatSlerp` (shortest path across the double cover, constant
307
+ angular velocity, unit output; the damped follow is
308
+ `quatSlerp(q, q, target, 1 - Math.exp(-k * dt))`). All aim/verb usage
309
+ live in `examples/aim.tsx`.
310
+ - `setTransform` NORMALIZES an incoming quaternion, and passing `rotation`
311
+ and `quaternion` in one call throws. A non-unit quaternion scales
312
+ geometry by `|q|^2` through `compose()` - Three leaves that trap open
313
+ and documents it; we close it at the one write path instead of paying
314
+ for a check in every compose.
315
+ - `lookAt` is exact for rotation and uniform scale up the chain. A
316
+ non-uniformly scaled ancestor shears the frame, so the aim is
317
+ approximate - Three has the identical limitation (both read the parent's
318
+ upper 3x3 as if it were a rotation), and the fix is not to special-case
319
+ it here but to not shear parents of things you aim.
320
+ - The package root's `lookAt` is the scene verb; `@solidrt/3d/math` keeps
321
+ its own `lookAt` (the camera view matrix) on the SUBPATH ONLY, the same
322
+ collision rule the Vec3 helpers follow - and the same Object3D/Matrix4
323
+ split Three makes under one name. Do not re-export math's from the root.
324
+ - Transforms have ONE write path: `setTransform`/`lookAt`/`setVisible` (or
325
+ the props that call them). Mutating `node.position` directly does not
326
+ sync. Components have no `lookAt` prop - aim through a `ref`.
193
327
  - A camera change is ONE `setTargetParams` write (uViewProj + uCamPos are
194
328
  target state), independent of mesh count - never reintroduce per-mesh
195
329
  camera writes (uEye-style per-mesh params are exactly the O(scene) cost
@@ -222,3 +356,33 @@ system.
222
356
  vertex source - a comment counts - selects the "colored" layout, and
223
357
  the material then rejects standard geometry at add(). Do not mention
224
358
  aColor you do not read.
359
+ - Picking is the VOLUME tier: a hit means the ray crossed the mesh's
360
+ transformed bounding box, not its surface. Never present `point` as a
361
+ surface point (it is the box-entry point), and never add a
362
+ triangle-accurate path in JS - per-triangle rays at mesh scale are
363
+ interpreter-hostile; that tier is core work (BVH descent per the
364
+ differentiators ladder).
365
+ - `scene.handlers` vs `handlersFor`: localX/localY arrive in the leaf's
366
+ LAYOUT frame (every ancestor transform and viewBox fit is already
367
+ undone by the element hit test). `handlers` therefore assumes leaf
368
+ layout == target pixels; scaling by `getBoundingBox` would be WRONG -
369
+ the box composes transforms, and it would double-correct the built-in
370
+ leaf under a viewBox. Only a leaf whose layout size deliberately
371
+ differs from the target (supersampling) needs `handlersFor`, fed the
372
+ layout size the app itself set.
373
+ - Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
374
+ under a still pointer fires nothing until the next move - the same
375
+ limit the element hit test has (hit-test-per-frame is an open platform
376
+ item). Do not poll pick() per frame to fake it.
377
+ - Geometry local bounds cache on the Geometry (like its GPU buffers):
378
+ geometry is immutable after creation. Mutating `vertices` after a mesh
379
+ used them leaves stale bounds AND a stale GPU buffer - make a new
380
+ Geometry instead.
381
+ - The background covers the whole target with depth off, drawn first: it
382
+ REPLACES the clearColor visually (the clear still runs; you just never
383
+ see it), and a translucent mesh does not blend over it in-pass (blend
384
+ is none|add today - the fade-over-backdrop look still needs the
385
+ two-layer composition until blend factors land).
386
+ - The background pipeline/program are SCENE-OWNED (unlike shared
387
+ material pipelines): setBackground(null), replacement, and dispose()
388
+ destroy them. Do not hand the background's pipeline to anything else.
package/README.md CHANGED
@@ -37,10 +37,34 @@ props, chain a post-effect shader target, or return null and composite
37
37
 
38
38
  There is also an imperative layer underneath (`createScene`, `createMesh`,
39
39
  `setTransform`, ...) usable without components, plus a small math module
40
- (`@solidrt/3d/math`: column-major mat4, perspective, lookAt). For HUD
40
+ (`@solidrt/3d/math`: column-major mat4, perspective, lookAt). To aim a
41
+ node, `lookAt(node, target, up?)` points its local +z at a world point,
42
+ Three's `Object3D.lookAt`; `worldPosition(node)` is the companion for
43
+ aiming along a direction, and `quatFromTo` aims any other axis. For HUD
41
44
  overlays, `scene.project(point)` maps a world point to scene pixels.
45
+
46
+ Rotation is stored as a quaternion (`quaternion` prop, `node.quaternion`),
47
+ so aiming and interpolation are gimbal-free and there is no second
48
+ rotation field to fall out of step. Euler triples stay the easy way to
49
+ author one - the `rotation` prop takes radians in XYZ order, matching
50
+ Three's `Euler` default, and `getRotation(node)` reads one back. The
51
+ verbs: `quatFromAxisAngle`, `quatMultiply`, and `quatSlerp` (smooth
52
+ tracking, damped follows) round out `quatFromTo`; `examples/aim.tsx`
53
+ shows each aiming style live.
54
+
55
+ Meshes take pointer events like elements do: `onPointerDown/Move/Up/
56
+ Enter/Leave` props on `<Mesh>` and `<Group>`, with bubbling, capture on
57
+ drag, and hover enter/leave pairs - hit testing runs over a BVH the
58
+ scene maintains incrementally, so events put no ceiling on scene size.
59
+ Underneath sit `scene.pick(x, y)` (the camera ray through a pixel,
60
+ `project()`'s inverse) and `scene.raycast(origin, direction)`; hits are
61
+ bounding-box accurate in v1. A scene also takes a `background` - fragment
62
+ GLSL drawn inside its own pass behind the meshes, replacing the stacked
63
+ backdrop-texture pattern.
42
64
  Custom materials get a standard uniform set - per-mesh `uModel`/`uNormal`,
43
- shared `uViewProj`/`uCamPos`, each written once per change - and
65
+ shared `uViewProj`/`uCamPos`, each written once per change - plus your own
66
+ uniforms per mesh, declaratively via the `params` prop on `<Mesh>` or
67
+ imperatively via `setMeshParams` - and
44
68
  `@solidrt/3d/glsl` exports the lighting pieces (hemisphere, lambert,
45
69
  blinn, fresnel, a standard vertex stage) to compose your own lit looks
46
70
  from plain template literals.
@@ -48,12 +72,14 @@ from plain template literals.
48
72
  v1 scope: unlit color/textured materials plus `shaderMaterial` (your own
49
73
  GLSL as a first-class material), geometry generators (box, plane, circle,
50
74
  ring, sphere, cylinder, cone, torus, torus knot), a profile kit for custom
51
- solids (`extrude` with bevels, `lathe`, flat `shape`, with `fillet`/
52
- `roundRect`/`triangulate` helpers), a per-vertex data channel
75
+ solids (`extrude` with bevels, `lathe`, polyline `sweep`/`tube` with
76
+ mitred joints, flat `shape`, with `fillet`/`roundRect`/`triangulate`
77
+ helpers), a per-vertex data channel
53
78
  (`withColors` adds an `aColor` vec4 - tint, baked AO, any four scalars -
54
79
  to any geometry, for materials that read it), one perspective camera
55
- with an orbit control (`createOrbitCamera`: drag, pinch/wheel zoom, auto-orbit).
56
- Lights, transparency, model loading and picking are
80
+ with an orbit control (`createOrbitCamera`: drag, pinch/wheel zoom, auto-orbit),
81
+ mesh picking with pointer events, and scene backgrounds.
82
+ Lights, transparency and model loading are
57
83
  staged next - see `okf/research/scene-graph-3d.md` for the roadmap. Full
58
84
  usage notes and traps: [AGENTS.md](AGENTS.md); runnable examples:
59
85
  [examples/](examples/).
@@ -7,3 +7,19 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
7
7
  texture leaf, `<PerspectiveCamera>`, a ground plane, a spinning
8
8
  `<Group>` of unlit meshes with real depth-buffer occlusion, geometry
9
9
  and pipeline sharing, and the one-signal onFrame drive.
10
+ - `sweep-paths.tsx` - swept solids along polylines: a flat strap folding
11
+ over a crate (bare path points crease on the mitred bends) and a coiled
12
+ tube (smooth-tagged helix, one continuous mesh), lit via the exported
13
+ GLSL so the creased-vs-smooth normals actually show.
14
+ - `aim.tsx` - the rotation verbs, one pointer each tracking an orbiting
15
+ target: `lookAt` for a +z solid, `quatFromTo` for aiming a y-axis cone,
16
+ and a `quatSlerp` damped follow that visibly lags; all driven from
17
+ onFrame through refs, no per-frame signals.
18
+ - `pick.tsx` - mesh pointer events: hover tints (enter/leave), click
19
+ pops (down), a Group hearing its children's clicks through bubbling
20
+ and one mesh stopping the walk; a STATIC scene rendered only when an
21
+ event changes something, with hit testing over the scene's BVH.
22
+ - `scene-background.tsx` - a fragment-GLSL background drawn inside the
23
+ scene's own pass (`<Scene background>`): one target, no stacked
24
+ backdrop texture, no resize plumbing; the source is shader-target
25
+ compatible verbatim.
@@ -0,0 +1,119 @@
1
+ // Aiming and rotation: a target orbits, and three fixed pointers track it,
2
+ // each through a different rotation verb.
3
+ // - lookAt(node, target) the z-axis rod: one call, world semantics
4
+ // - quatFromTo(y, direction) the cone: aiming an axis other than +z
5
+ // - quatSlerp damped follow the lazy cone: visibly lags, then catches up
6
+ // All three drive their nodes from onFrame through refs - the frame-rate
7
+ // escape hatch - so no per-frame signals exist; the one signal-free scene
8
+ // re-renders because setTransform marks the moved nodes dirty.
9
+ import { onFrame, pct, render } from "@solidrt/core"
10
+ import {
11
+ cone,
12
+ lookAt,
13
+ Mesh,
14
+ PerspectiveCamera,
15
+ plane,
16
+ quat,
17
+ quatFromTo,
18
+ quatSlerp,
19
+ Scene,
20
+ setTransform,
21
+ sphere,
22
+ tube,
23
+ unlit,
24
+ worldPosition,
25
+ } from "@solidrt/3d"
26
+ import type { MeshNode, Vec3 } from "@solidrt/3d"
27
+
28
+ const SIZE = 720
29
+ const Y_AXIS: Vec3 = [0, 1, 0]
30
+
31
+ function App() {
32
+ let target!: MeshNode
33
+ let rod!: MeshNode
34
+ let cannon!: MeshNode
35
+ let lazy!: MeshNode
36
+
37
+ // Allocated once; every per-frame write reuses them.
38
+ let targetPos: Vec3 = [0, 0, 0]
39
+ let dir: Vec3 = [0, 0, 0]
40
+ let aimQ = quat()
41
+
42
+ // A world-space direction from a node to the target: the documented
43
+ // recipe, worldPosition + subtract (exact here - the pointers have no
44
+ // transformed ancestors - and correct even if they get some).
45
+ let aimFrom = (node: MeshNode) => {
46
+ let p = worldPosition(node, dir)
47
+ dir[0] = targetPos[0] - p[0]
48
+ dir[1] = targetPos[1] - p[1]
49
+ dir[2] = targetPos[2] - p[2]
50
+ return dir
51
+ }
52
+
53
+ onFrame(tick => {
54
+ let t = tick / 1500
55
+ targetPos[0] = Math.cos(t) * 1.9
56
+ targetPos[1] = 1.1 + Math.sin(t * 0.7) * 0.7
57
+ targetPos[2] = Math.sin(t) * 1.9
58
+ setTransform(target, { position: targetPos })
59
+
60
+ // The rod is a +z solid (tube paths run along z): lookAt is the whole
61
+ // aiming story, a world-space point in, done.
62
+ lookAt(rod, targetPos)
63
+
64
+ // The cone points along +y, not +z, so lookAt would aim its side.
65
+ // quatFromTo rotates the axis you name onto the direction you want.
66
+ quatFromTo(aimQ, Y_AXIS, aimFrom(cannon))
67
+ setTransform(cannon, { quaternion: aimQ })
68
+
69
+ // Damped follow: slerp the CURRENT rotation a fixed fraction of the
70
+ // way toward the aimed one each frame. The 0.04 makes the lag obvious;
71
+ // a real app uses 1 - Math.exp(-k * dt) to stay frame-rate independent.
72
+ quatFromTo(aimQ, Y_AXIS, aimFrom(lazy))
73
+ quatSlerp(aimQ, lazy.quaternion, aimQ, 0.04)
74
+ setTransform(lazy, { quaternion: aimQ })
75
+ })
76
+
77
+ let ball = sphere(0.22)
78
+ let pointer = cone(0.3, 0.9)
79
+
80
+ return (
81
+ <window>
82
+ <view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
83
+ <Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.07, 0.1, 1]} label="aim">
84
+ <PerspectiveCamera fov={55} position={[0, 2.6, 4.6]} lookAt={[0, 0.7, 0]} />
85
+ <Mesh
86
+ geometry={plane(7, 7, "floor")}
87
+ material={unlit({ color: [0.16, 0.17, 0.22] })}
88
+ rotation={[-Math.PI / 2, 0, 0]}
89
+ />
90
+ <Mesh
91
+ geometry={ball}
92
+ material={unlit({ color: [0.95, 0.85, 0.4] })}
93
+ ref={n => (target = n)}
94
+ />
95
+ <Mesh
96
+ geometry={tube([[0, 0, 0], [0, 0, 1.1]], 0.09, 10, "rod")}
97
+ material={unlit({ color: [0.85, 0.3, 0.3] })}
98
+ position={[-1.4, 0.5, 0]}
99
+ ref={n => (rod = n)}
100
+ />
101
+ <Mesh
102
+ geometry={pointer}
103
+ material={unlit({ color: [0.35, 0.65, 0.9] })}
104
+ position={[1.4, 0.5, 0]}
105
+ ref={n => (cannon = n)}
106
+ />
107
+ <Mesh
108
+ geometry={pointer}
109
+ material={unlit({ color: [0.45, 0.8, 0.5] })}
110
+ position={[0, 0.5, -1.4]}
111
+ ref={n => (lazy = n)}
112
+ />
113
+ </Scene>
114
+ </view>
115
+ </window>
116
+ )
117
+ }
118
+
119
+ render(() => <App />)
@@ -0,0 +1,98 @@
1
+ // Mesh pointer events: hover to tint (enter/leave), click to pop (down),
2
+ // with a Group seeing its children's clicks through bubbling and one mesh
3
+ // stopping the walk. The scene is STATIC - no onFrame - so it renders only
4
+ // when an event changes something: picking is demand-driven like
5
+ // everything else. Events flow because the built-in <Scene> leaf carries
6
+ // scene.handlers (default on); hit testing runs over the scene's BVH, so a
7
+ // pointer move costs O(log meshes).
8
+ //
9
+ // The volume tier is honest about its shape: hits test each mesh's
10
+ // bounding box, so hovering just outside the ball's silhouette (inside its
11
+ // box corners) still counts. Triangle-accurate hits are a later tier.
12
+
13
+ import { createSignal, pct, render } from "@solidrt/core"
14
+ import { box, cone, Group, Mesh, PerspectiveCamera, plane, Scene, setMeshParams, setTransform, sphere, torus, unlit } from "@solidrt/3d"
15
+ import type { Geometry, ScenePointerEvent, Vec3 } from "@solidrt/3d"
16
+
17
+ const SIZE = 720
18
+
19
+ type Color = [number, number, number]
20
+
21
+ let [hovered, setHovered] = createSignal("nothing")
22
+ let popped = new Set<object>()
23
+
24
+ /** A pickable mesh: unlit color that brightens on hover, scale-pop on
25
+ * click. setMeshParams writes raw uniform values, so the tint premultiplies
26
+ * here (alpha 1: the rgb passes through). */
27
+ function Pickable(p: { name: string; color: Color; geometry: Geometry; position: Vec3; scale?: number; stop?: boolean }) {
28
+ let lift = (up: number): [number, number, number, number] => [
29
+ Math.min(p.color[0] + up, 1),
30
+ Math.min(p.color[1] + up, 1),
31
+ Math.min(p.color[2] + up, 1),
32
+ 1,
33
+ ]
34
+ return (
35
+ <Mesh
36
+ geometry={p.geometry}
37
+ material={unlit({ color: p.color })}
38
+ position={p.position}
39
+ scale={p.scale}
40
+ onPointerEnter={(e: ScenePointerEvent) => {
41
+ setHovered(p.name)
42
+ setMeshParams(e.mesh, { uColor: lift(0.25) })
43
+ console.log(`enter ${p.name}`)
44
+ }}
45
+ onPointerLeave={(e: ScenePointerEvent) => {
46
+ setHovered("nothing")
47
+ setMeshParams(e.mesh, { uColor: lift(0) })
48
+ console.log(`leave ${p.name}`)
49
+ }}
50
+ onPointerDown={(e: ScenePointerEvent) => {
51
+ let pop = !popped.has(e.mesh)
52
+ if (pop) popped.add(e.mesh)
53
+ else popped.delete(e.mesh)
54
+ setTransform(e.mesh, { scale: pop ? (p.scale ?? 1) * 1.25 : (p.scale ?? 1) })
55
+ let pt = e.point!
56
+ console.log(`down ${p.name} at ${pt[0].toFixed(2)},${pt[1].toFixed(2)},${pt[2].toFixed(2)} d=${e.distance!.toFixed(2)}`)
57
+ // The cone demonstrates stopping the bubble: its group never hears it.
58
+ if (p.stop) e.stopPropagation()
59
+ }}
60
+ />
61
+ )
62
+ }
63
+
64
+ function App() {
65
+ let floor = plane(8, 8, "floor")
66
+ let crate = box()
67
+ let ball = sphere(0.45)
68
+ let spike = cone(0.45, 1)
69
+ let ring = torus(0.4, 0.14)
70
+
71
+ return (
72
+ <window>
73
+ <view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
74
+ <Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.07, 0.1, 1]} label="pick">
75
+ <PerspectiveCamera fov={55} position={[0, 2.6, 5]} lookAt={[0, 0.4, 0]} />
76
+ <Mesh geometry={floor} material={unlit({ color: [0.15, 0.16, 0.2] })} rotation={[-Math.PI / 2, 0, 0]} />
77
+ {/* The group hears every child click that is not stopped. */}
78
+ <Group onPointerDown={() => console.log("group saw down")}>
79
+ <Pickable name="crate" color={[0.85, 0.3, 0.3]} geometry={crate} position={[-1.6, 0.4, 0]} scale={0.8} />
80
+ <Pickable name="cone" color={[0.35, 0.65, 0.9]} geometry={spike} position={[1.6, 0.5, 0]} stop />
81
+ </Group>
82
+ <Pickable name="ball" color={[0.9, 0.8, 0.35]} geometry={ball} position={[0, 0.45, 0]} />
83
+ <Pickable name="ring" color={[0.5, 0.85, 0.5]} geometry={ring} position={[0.9, 0.35, -1.6]} />
84
+ </Scene>
85
+ <view position="absolute" x={0} y={0} padding={16} gap={4}>
86
+ <text color="#eef4ff" fontSize={22} fontWeight={700}>
87
+ {`hover: ${hovered()}`}
88
+ </text>
89
+ <text color="#8fa6c8" fontSize={13}>
90
+ hover tints, click pops - a static scene, rendered only on change
91
+ </text>
92
+ </view>
93
+ </view>
94
+ </window>
95
+ )
96
+ }
97
+
98
+ render(() => <App />)
@@ -0,0 +1,43 @@
1
+ // A scene background: fragment GLSL drawn as the first entry of the
2
+ // scene's own pass - no second target, no stacked texture layers, no
3
+ // resize plumbing. The source below uses only the shader-target contract
4
+ // (vUV, iResolution, fragColor), so it would work unchanged in
5
+ // createShaderTexture; here it costs nothing extra - the one scene pass
6
+ // paints backdrop and meshes together, and a static scene still renders
7
+ // zero passes when idle.
8
+
9
+ import { pct, render } from "@solidrt/core"
10
+ import { glsl } from "@solidrt/core/gpu"
11
+ import { box, Mesh, PerspectiveCamera, Scene, sphere, torusKnot, unlit } from "@solidrt/3d"
12
+
13
+ const SIZE = 720
14
+
15
+ // A radial night-sky gradient with hash grain so the ramp does not band.
16
+ let BACKDROP = glsl`
17
+ void main() {
18
+ float d = distance(vUV, vec2(0.5, 0.35));
19
+ vec3 near = vec3(0.10, 0.13, 0.22);
20
+ vec3 far = vec3(0.02, 0.03, 0.06);
21
+ vec3 col = mix(near, far, smoothstep(0.05, 0.9, d));
22
+ float n = fract(sin(dot(vUV * iResolution, vec2(12.9898, 78.233))) * 43758.5453);
23
+ col += (n - 0.5) * 0.012;
24
+ fragColor = vec4(col, 1.0);
25
+ }
26
+ `
27
+
28
+ function App() {
29
+ return (
30
+ <window>
31
+ <view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
32
+ <Scene width={SIZE} height={SIZE} background={BACKDROP} label="backdrop-demo">
33
+ <PerspectiveCamera fov={55} position={[0, 1.8, 4.4]} lookAt={[0, 0.4, 0]} />
34
+ <Mesh geometry={torusKnot(0.7, 0.2, 128, 16)} material={unlit({ color: [0.85, 0.55, 0.25] })} position={[0, 0.9, 0]} />
35
+ <Mesh geometry={box()} material={unlit({ color: [0.3, 0.5, 0.8] })} position={[-1.5, 0.4, -0.5]} scale={0.8} />
36
+ <Mesh geometry={sphere(0.4)} material={unlit({ color: [0.4, 0.75, 0.45] })} position={[1.5, 0.4, -0.5]} />
37
+ </Scene>
38
+ </view>
39
+ </window>
40
+ )
41
+ }
42
+
43
+ render(() => <App />)
@@ -0,0 +1,79 @@
1
+ // Swept solids along 3D polylines. sweep() runs a 2D profile along a
2
+ // path with mitred joints: bare path points crease - the strap folds
3
+ // over the crate's edges like real webbing - while smooth-tagged points
4
+ // share averaged normals, so the helix sweeps into ONE continuous coil
5
+ // (per-segment boxes with unmitred gaps are exactly what this replaces).
6
+ // tube() is the round-profile shorthand. Lit materials on purpose:
7
+ // creased vs smooth joints differ only in normals, which unlit color
8
+ // would hide.
9
+ import { createSignal, onFrame, pct, render } from "@solidrt/core"
10
+ import { box, Group, Mesh, PerspectiveCamera, plane, roundRect, Scene, shaderMaterial, sweep, tube, unlit } from "@solidrt/3d"
11
+ import type { SweepPath } from "@solidrt/3d"
12
+ import { HEMISPHERE, LAMBERT, LIT_VERTEX } from "@solidrt/3d/glsl"
13
+
14
+ const SIZE = 720
15
+
16
+ function App() {
17
+ let [spin, setSpin] = createSignal(0)
18
+ onFrame(tick => setSpin(tick / 4000))
19
+
20
+ // One hemisphere + lambert look per color (a material instance is the
21
+ // pipeline handle, so each look is created once and shared).
22
+ let lit = (r: number, g: number, b: number) =>
23
+ shaderMaterial({
24
+ vertex: LIT_VERTEX,
25
+ fragment: `
26
+ in vec3 vNormal;
27
+ ${HEMISPHERE}
28
+ ${LAMBERT}
29
+ void main() {
30
+ vec3 n = normalize(vNormal);
31
+ vec3 l = normalize(vec3(0.5, 0.8, 0.4));
32
+ vec3 base = vec3(${r}, ${g}, ${b});
33
+ fragColor = vec4(base * (hemisphere(n, vec3(0.45), vec3(0.22)) + 0.8 * lambert(n, l)), 1.0);
34
+ }`,
35
+ })
36
+
37
+ // The strap hugs the crate at half its thickness; every bend is a bare
38
+ // (sharp) point, so each fold creases exactly on a crate edge.
39
+ let o = 0.013
40
+ let strapPath: SweepPath = [
41
+ [-1.75, o, 0],
42
+ [-1.3 - o, o, 0],
43
+ [-1.3 - o, 0.6 + o, 0],
44
+ [-0.3 + o, 0.6 + o, 0],
45
+ [-0.3 + o, o, 0],
46
+ [0.15, o, 0],
47
+ ]
48
+ let strap = sweep(roundRect(0.3, 0.026, 0.008), strapPath, "strap")
49
+
50
+ // A smooth-tagged helix: one continuous tube, not a stack of segments.
51
+ let coilPath: SweepPath = []
52
+ for (let i = 0; i <= 60; i++) {
53
+ let a = (i / 60) * Math.PI * 5
54
+ coilPath.push({ p: [0.85 + Math.cos(a) * 0.35, 0.055 + i * 0.0095, Math.sin(a) * 0.35], smooth: true })
55
+ }
56
+ let coil = tube(coilPath, 0.05, 12, "coil")
57
+
58
+ return (
59
+ <window>
60
+ <view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
61
+ <Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.07, 0.1, 1]} label="sweep-paths">
62
+ <PerspectiveCamera fov={55} position={[0, 1.9, 3.9]} lookAt={[0, 0.35, 0]} />
63
+ <Mesh
64
+ geometry={plane(6, 6, "floor")}
65
+ material={unlit({ color: [0.16, 0.17, 0.22] })}
66
+ rotation={[-Math.PI / 2, 0, 0]}
67
+ />
68
+ <Group rotation={[0, spin(), 0]}>
69
+ <Mesh geometry={box(1, 0.6, 0.8)} material={lit(0.55, 0.42, 0.28)} position={[-0.8, 0.3, 0]} />
70
+ <Mesh geometry={strap} material={lit(0.9, 0.55, 0.2)} />
71
+ <Mesh geometry={coil} material={lit(0.45, 0.6, 0.8)} />
72
+ </Group>
73
+ </Scene>
74
+ </view>
75
+ </window>
76
+ )
77
+ }
78
+
79
+ render(() => <App />)