@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 +178 -14
- package/README.md +32 -6
- package/examples/README.md +16 -0
- package/examples/aim.tsx +119 -0
- package/examples/pick.tsx +98 -0
- package/examples/scene-background.tsx +43 -0
- package/examples/sweep-paths.tsx +79 -0
- package/package.json +3 -3
- package/src/bvh.ts +258 -0
- package/src/components.tsx +81 -10
- package/src/geometry.ts +35 -0
- package/src/index.ts +10 -6
- package/src/material.ts +37 -0
- package/src/math.ts +336 -19
- package/src/profile.ts +43 -248
- package/src/scene.ts +530 -17
- package/src/sweep.ts +460 -0
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
|
|
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,
|
|
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
|
-
`
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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).
|
|
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 -
|
|
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`,
|
|
52
|
-
|
|
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
|
-
|
|
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/).
|
package/examples/README.md
CHANGED
|
@@ -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.
|
package/examples/aim.tsx
ADDED
|
@@ -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 />)
|