lecodes-cli 0.1.7 → 0.3.0

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.
@@ -0,0 +1,83 @@
1
+ // 2D pointer dispatch + track (drag) system. The host (createViewer2d canvas / Creator2DView) delivers
2
+ // raw pointer phases via _creator2d.setOnPointer; this module hit-tests in the active scene and routes
3
+ // to the struck node and the scene's listeners. Mirrors the 3D gl/touch.ts, but the hit-test is done
4
+ // here (screen→world via the camera, then a physics overlap), not by the host.
5
+ //
6
+ // What can be hit: a node with a Physics2D shape (precise) → its 'touchstart'/'click' events; the
7
+ // active Scene2D always gets the event too (target = the hit node or null). A node listener can call
8
+ // ev.track({ onMove, onEnd, onCancel }) to capture the rest of the gesture (drag/pan).
9
+
10
+ import { ClickEvent, TouchStartEvent, type TrackHandler } from "../runtime/touch"
11
+ import { Physics2D } from "./Physics2D"
12
+ import { Scene2D } from "./Scene2D"
13
+ import type { Node2D } from "./Node2D"
14
+
15
+ type Active = { tracker?: TrackHandler, lastX: number, lastY: number }
16
+ const active = new Map<number, Active>()
17
+
18
+ let registered = false
19
+ /** Register the host pointer callback once, on the first click/touchstart listener (node or scene). */
20
+ export const ensurePointerEvents = (): void => {
21
+ if (registered) return
22
+ registered = true
23
+ _creator2d.setOnPointer((phase, id, x, y) => {
24
+ if (phase === 0) onDown(id, x, y)
25
+ else if (phase === 1) onMove(id, x, y)
26
+ else if (phase === 2) onUp(id, x, y)
27
+ else onCancel(id)
28
+ })
29
+ }
30
+
31
+ // Hit-test: screen (logical px) → world → topmost physics body at the point.
32
+ const hitTest = (scene: Scene2D, sx: number, sy: number): { node: Node2D | null, wx: number, wy: number } => {
33
+ const w = scene.camera.screenToWorld(sx, sy)
34
+ return { node: Physics2D.overlapPoint(w), wx: w.x, wy: w.y }
35
+ }
36
+
37
+ const onDown = (id: number, sx: number, sy: number): void => {
38
+ const scene = Scene2D.active
39
+ if (!scene) return
40
+ const { node, wx, wy } = hitTest(scene, sx, sy)
41
+ const ev = new TouchStartEvent<Node2D | null>(sx, sy, id)
42
+ ev.worldX = wx; ev.worldY = wy
43
+ ev.target = node
44
+
45
+ let tracker: TrackHandler | undefined
46
+ if (node) { node._dispatchEvent("touchstart", ev); tracker = ev._trackHandler }
47
+ if (!tracker) {
48
+ for (const l of scene._touchStartListeners.slice()) {
49
+ l(ev)
50
+ if (ev._trackHandler) { tracker = ev._trackHandler; break }
51
+ }
52
+ }
53
+ active.set(id, { tracker, lastX: sx, lastY: sy })
54
+ }
55
+
56
+ const onMove = (id: number, sx: number, sy: number): void => {
57
+ const a = active.get(id)
58
+ if (!a) return
59
+ a.tracker?.onMove?.({ clientX: sx, clientY: sy, deltaX: sx - a.lastX, deltaY: sy - a.lastY })
60
+ a.lastX = sx; a.lastY = sy
61
+ }
62
+
63
+ const onUp = (id: number, sx: number, sy: number): void => {
64
+ const a = active.get(id)
65
+ active.delete(id)
66
+ a?.tracker?.onEnd?.({ clientX: sx, clientY: sy, deltaX: a ? sx - a.lastX : 0, deltaY: a ? sy - a.lastY : 0 })
67
+
68
+ // A pointer-up is also a click, dispatched at the up location (independent of any track).
69
+ const scene = Scene2D.active
70
+ if (!scene) return
71
+ const { node, wx, wy } = hitTest(scene, sx, sy)
72
+ const ev = new ClickEvent<Node2D | null>(sx, sy, id)
73
+ ev.worldX = wx; ev.worldY = wy
74
+ ev.target = node
75
+ if (node) node._dispatchEvent("click", ev)
76
+ for (const l of scene._clickListeners.slice()) l(ev)
77
+ }
78
+
79
+ const onCancel = (id: number): void => {
80
+ const a = active.get(id)
81
+ active.delete(id)
82
+ a?.tracker?.onCancel?.()
83
+ }
@@ -0,0 +1,29 @@
1
+ // A 3D collider for click/touch hit-testing, as an aspect. Derives a box from the mesh by default.
2
+ // (3D is the default engine, so this is the unsuffixed Collider; the 2D one is Collider2D.)
3
+ // node.aspect(Collider, { shape: 'box' })
4
+ // node.aspect(Collider, { shape: 'box', center: [0,1,0], size: [2,2,2] })
5
+
6
+ import { Aspect } from "../core/Aspect"
7
+ import { cx, cy, cz, type Vec3Like } from "../math/vec"
8
+ import type { Node } from "./Node"
9
+
10
+ export class Collider extends Aspect<"collider", Node> {
11
+ static readonly aspect = "collider"
12
+ shape: "box" | "sphere" = "box"
13
+ /** Explicit box: center + full size. */
14
+ center?: Vec3Like
15
+ size?: Vec3Like
16
+ /** Explicit sphere: center + radius. */
17
+ radius?: number
18
+
19
+ onAttach(): void {
20
+ const id = this.node.id
21
+ if (this.shape === "box" && this.center && this.size) {
22
+ _creator.setColliderBox(id, cx(this.center), cy(this.center), cz(this.center), cx(this.size), cy(this.size), cz(this.size))
23
+ } else if (this.shape === "sphere" && this.center && this.radius !== undefined) {
24
+ _creator.setColliderSphere(id, cx(this.center), cy(this.center), cz(this.center), this.radius)
25
+ } else {
26
+ _creator.setColliderFromMesh(id, id, this.shape === "sphere" ? 1 : 0)
27
+ }
28
+ }
29
+ }
@@ -6,6 +6,12 @@
6
6
  import { Color, type ColorInput } from "../core/color"
7
7
  import { fetch, type FetchResponse } from "../runtime/fetch"
8
8
  import { Texture } from "./Texture"
9
+ // Type-only (erased at build) so a 3D material never pulls the 2D-canvas runtime; the guard is inline.
10
+ import type { Canvas } from "../canvas/Canvas"
11
+
12
+ /** Duck-type a Canvas without importing it (keeps 3D material free of 2D-canvas code). */
13
+ const isCanvasSource = (v: unknown): v is Canvas =>
14
+ typeof v === "object" && v !== null && (v as { __canvas?: unknown }).__canvas === true
9
15
 
10
16
  type UniformValue = string | Texture | number | boolean | number[] | Float32Array | null
11
17
 
@@ -28,7 +34,7 @@ const applyUniform = (id: number, key: string, value: UniformValue): void => {
28
34
  }
29
35
  }
30
36
 
31
- export type MaterialColorOptions = { color?: ColorInput, map?: Texture | null }
37
+ export type MaterialColorOptions = { color?: ColorInput, map?: Texture | Canvas | null }
32
38
 
33
39
  export class Material {
34
40
  /** @internal native material-instance handle. */
@@ -65,11 +71,12 @@ export class Material {
65
71
  if (this._colorKey) this.uniforms[this._colorKey] = Color.toHexString(c)
66
72
  }
67
73
 
68
- set map(value: Texture | null) {
74
+ set map(value: Texture | Canvas | null) {
69
75
  if (value === null) {
70
76
  if (this._useMapFlag) this.uniforms.useBaseColorMap = false
71
77
  } else {
72
- this.uniforms.baseColorMap = value
78
+ // A Canvas bakes to a (cached) 3D texture on assign — same as Sprite accepting a Canvas in 2D.
79
+ this.uniforms.baseColorMap = isCanvasSource(value) ? Texture.fromCanvas(value) : value
73
80
  if (this._useMapFlag) this.uniforms.useBaseColorMap = true
74
81
  }
75
82
  }
@@ -1,9 +1,7 @@
1
- // A renderable 3D node. Static factories build the common shapes; Mesh.from wraps a custom Geometry;
2
- // Mesh.load brings in a GLB (returned as its root Node, with baked animation clips). Ported from
3
- // worker/src/components/Mesh.ts — folded into the node (no component).
1
+ // A renderable 3D node from raw geometry — the primitive shapes (box/sphere/cylinder/plane) and
2
+ // Mesh.from for a custom Geometry. A GLB import is a different kind: see Model.load.
4
3
 
5
4
  import { cx, cy, cz, type Vec3Like } from "../math/vec"
6
- import { fetch, type FetchResponse } from "../runtime/fetch"
7
5
  import {
8
6
  Geometry,
9
7
  type CylinderOptions,
@@ -87,27 +85,6 @@ export class Mesh extends Node {
87
85
  static from(geometry: Geometry, options: MeshOptions = {}): Mesh {
88
86
  return apply(new Mesh(geometry, options.material), options)
89
87
  }
90
-
91
- /**
92
- * Load a GLB model. Returns the model's root Node (a GLB is a node hierarchy); its animation clips
93
- * are available via node.clips and driven with node.play(name|index).
94
- */
95
- static load(source: string | FetchResponse, options: { culling?: boolean, onProgress?: (p: { loaded: number, total?: number }) => void } = {}): Promise<Node> {
96
- if (typeof source === "string") {
97
- return fetch(source, { useOnce: true, onProgress: options.onProgress }).then((resp) => {
98
- if (resp.status >= 400) return Promise.reject(new Error(`Failed to load GLB from ${source}. HTTP ${resp.status}`))
99
- return Mesh.load(resp, options)
100
- })
101
- }
102
- return new Promise<Node>((resolve, reject) => {
103
- _creator.createGlb((source as any)._id, (entityId) => {
104
- if (entityId === 0) { reject(new Error("Failed to load GLB")); return }
105
- const node = new Node(entityId)
106
- if (options.culling !== true) _creator.setGlbCulling(entityId, false)
107
- resolve(node)
108
- }, reject)
109
- })
110
- }
111
88
  }
112
89
 
113
90
  const sizeToVec = (s: Vec3Like | number): [number, number, number] => (typeof s === "number" ? [ s, s, s ] : [ cx(s), cy(s), cz(s) ])
@@ -0,0 +1,40 @@
1
+ // A loaded GLB model — its own node kind (a GLB is a node hierarchy with baked animation clips),
2
+ // distinct from Mesh (raw primitives, no animation). Animation is always present, reached as
3
+ // model.anim:
4
+ // const hero = await Model.load(asset('./hero.glb'))
5
+ // hero.anim.play('Run')
6
+
7
+ import { fetch, type FetchResponse } from "../runtime/fetch"
8
+ import { Node } from "./Node"
9
+ import { ModelAnimation } from "./ModelAnimation"
10
+
11
+ export class Model extends Node {
12
+ /** GLB skeletal animation — always present on a Model. */
13
+ declare readonly anim: ModelAnimation
14
+
15
+ constructor(internalId: number) {
16
+ super(internalId)
17
+ this.aspect(ModelAnimation)
18
+ }
19
+
20
+ /** Load a GLB model. Returns its root as a Model; play its baked clips via model.anim. */
21
+ static load(
22
+ source: string | FetchResponse,
23
+ options: { culling?: boolean, onProgress?: (p: { loaded: number, total?: number }) => void } = {},
24
+ ): Promise<Model> {
25
+ if (typeof source === "string") {
26
+ return fetch(source, { useOnce: true, onProgress: options.onProgress }).then((resp) => {
27
+ if (resp.status >= 400) return Promise.reject(new Error(`Failed to load GLB from ${source}. HTTP ${resp.status}`))
28
+ return Model.load(resp, options)
29
+ })
30
+ }
31
+ return new Promise<Model>((resolve, reject) => {
32
+ _creator.createGlb((source as unknown as { _id: number })._id, (entityId: number) => {
33
+ if (entityId === 0) { reject(new Error("Failed to load GLB")); return }
34
+ const model = new Model(entityId)
35
+ if (options.culling !== true) _creator.setGlbCulling(entityId, false)
36
+ resolve(model)
37
+ }, reject)
38
+ })
39
+ }
40
+ }
@@ -0,0 +1,64 @@
1
+ // GLB skeletal animation as an aspect. Auto-attached by Model (a glb always has its clips), so you
2
+ // reach it directly: model.anim.play('Run'). Clips come baked in the glb; play by name or index.
3
+
4
+ import { Aspect } from "../core/Aspect"
5
+ import { nodeRegistry } from "./Node"
6
+ import type { Model } from "./Model"
7
+
8
+ let animatorRegistered = false
9
+ const ensureAnimator = (): void => {
10
+ if (animatorRegistered) return
11
+ animatorRegistered = true
12
+ _creator.setOnAnimationComplete((entityId, clipIndex) => nodeRegistry.get(entityId)?._onAnimationComplete(clipIndex) ?? true)
13
+ }
14
+
15
+ export class ModelAnimation extends Aspect<"anim", Model> {
16
+ static readonly aspect = "anim"
17
+
18
+ private _playing = false
19
+ private _clip = 0
20
+ private _speed = 1
21
+ private _loop = false
22
+
23
+ onAttach(): void {
24
+ this.node._animComplete = (clipIndex) => {
25
+ if (clipIndex !== this._clip) return true
26
+ if (!this._loop) { this._playing = false; this.node._emitAnim("completed", clipIndex) }
27
+ else this.node._emitAnim("loopReached", clipIndex)
28
+ return this._playing
29
+ }
30
+ }
31
+
32
+ /** The clips baked into the glb. */
33
+ get clips(): { name: string, duration: number }[] { return _creator.getGlbAnimations(this.node.id) }
34
+
35
+ get playing(): boolean { return this._playing }
36
+ set playing(value: boolean) {
37
+ this._playing = value
38
+ _creator.setAnimation(this.node.id, value, this._clip, this._speed)
39
+ }
40
+
41
+ /** Play a clip by name or index. Chainable. */
42
+ play(clip?: string | number, options: { loop?: boolean } = {}): this {
43
+ if (typeof clip === "number") this._clip = clip
44
+ else if (typeof clip === "string") {
45
+ const i = this.clips.findIndex((c) => c.name === clip)
46
+ if (i >= 0) this._clip = i
47
+ }
48
+ if (options.loop !== undefined) this._loop = options.loop
49
+ this.playing = true
50
+ ensureAnimator()
51
+ return this
52
+ }
53
+ stop(): this { this.playing = false; return this }
54
+
55
+ get loop(): boolean { return this._loop }
56
+ set loop(v: boolean) { this._loop = v }
57
+ get speed(): number { return this._speed }
58
+ set speed(v: number) {
59
+ this._speed = v
60
+ _creator.setAnimation(this.node.id, this._playing, this._clip, v)
61
+ }
62
+ get time(): number { return _creator.getAnimationTime(this.node.id) }
63
+ set time(v: number) { _creator.setAnimationTime(this.node.id, this._clip, v) }
64
+ }
@@ -2,15 +2,14 @@
2
2
  // on them. Native (creator-gl) owns the authoritative transform; reads decompose a cached local
3
3
  // matrix (re-synced lazily, since physics can write the transform from native each frame).
4
4
  //
5
- // "Behaviors are methods, not components": .physics() (+ node.body), .collider(), GLB animation
6
- // (.play()/.stop()), and hierarchy all live here.
5
+ // Capabilities are aspects: node.aspect(Physics, …) (→ node.physics), node.aspect(Collider, …).
6
+ // A GLB import is the Model node kind (with the ModelAnimation aspect). Hierarchy lives here.
7
7
 
8
- import { Emitter } from "../core/events"
8
+ import { AspectHost } from "../core/Aspect"
9
9
  import { Registry } from "../core/registry"
10
10
  import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
11
11
  import { Quat, cw, type QuatLike } from "../math/quat"
12
12
  import { Mat4, type Mat4Like } from "../math/mat4"
13
- import { Body, type PhysicsOptions } from "./Body"
14
13
  import type { Geometry } from "./Geometry"
15
14
  import { registerTouchEndEvent, registerTouchStartEvent } from "./touch"
16
15
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
@@ -31,29 +30,10 @@ export type NodeEvents = {
31
30
  untrack: () => void
32
31
  }
33
32
 
34
- export type ColliderOptions = {
35
- shape?: "box" | "sphere"
36
- /** Explicit box: center + full size. */
37
- center?: Vec3Like
38
- size?: Vec3Like
39
- /** Explicit sphere: center + radius. */
40
- radius?: number
41
- }
42
-
43
- let animatorRegistered = false
44
- const ensureAnimator = (): void => {
45
- if (animatorRegistered) return
46
- animatorRegistered = true
47
- _creator.setOnAnimationComplete((entityId, clipIndex) => nodeRegistry.get(entityId)?._onAnimationComplete(clipIndex) ?? true)
48
- }
49
-
50
- export class Node extends Emitter<NodeEvents> {
33
+ export class Node extends AspectHost<NodeEvents> {
51
34
  /** Native entity handle. */
52
35
  readonly id: number
53
36
 
54
- /** Physics handle once .physics() is called. */
55
- body?: Body
56
-
57
37
  /** AR anchors only — whether the anchor is currently being tracked. Kept in step with the
58
38
  * 'track' / 'untrack' events; `false` for any non-anchor node. */
59
39
  isTracked = false
@@ -63,10 +43,8 @@ export class Node extends Emitter<NodeEvents> {
63
43
  private _worldMatrix?: Float32Array
64
44
  private _scaleCache?: [number, number, number]
65
45
 
66
- private _animPlaying = false
67
- private _animClip = 0
68
- private _animSpeed = 1
69
- private _animLoop = false
46
+ /** @internal — the active animation aspect's clip-complete handler (installed by ModelAnimation). */
47
+ _animComplete?: (clipIndex: number) => boolean
70
48
 
71
49
  constructor(internalId?: number) {
72
50
  super()
@@ -193,26 +171,7 @@ export class Node extends Emitter<NodeEvents> {
193
171
  _creator.traverse(this.id, (id) => callback(nodeRegistry.get(id) ?? new Node(id)))
194
172
  }
195
173
 
196
- // --- behaviors ---
197
-
198
- /** Add a physics body (reach it at node.body). Chainable. */
199
- physics(options: PhysicsOptions = {}): this {
200
- this.body = new Body(this, options)
201
- return this
202
- }
203
-
204
- /** Add a collider for click/touch hit-testing. Derives from the mesh by default. Chainable. */
205
- collider(options: ColliderOptions = {}): this {
206
- const form = options.shape ?? "box"
207
- if (form === "box" && options.center && options.size) {
208
- _creator.setColliderBox(this.id, cx(options.center), cy(options.center), cz(options.center), cx(options.size), cy(options.size), cz(options.size))
209
- } else if (form === "sphere" && options.center && options.radius !== undefined) {
210
- _creator.setColliderSphere(this.id, cx(options.center), cy(options.center), cz(options.center), options.radius)
211
- } else {
212
- _creator.setColliderFromMesh(this.id, this.id, form === "sphere" ? 1 : 0)
213
- }
214
- return this
215
- }
174
+ // --- behaviors are aspects: node.aspect(Physics, …) / node.aspect(Collider, …) ---
216
175
 
217
176
  /** Orient the node so the given axis (default "-z", "forward") points at a world point. */
218
177
  lookAt(point: Vec3Like, mode: "z" | "-z" | "x" | "-x" | "y" | "-y" = "-z", ortho: Vec3Like = [ 0, 1, 0 ]): this {
@@ -244,39 +203,6 @@ export class Node extends Emitter<NodeEvents> {
244
203
  return this
245
204
  }
246
205
 
247
- // --- GLB animation (no-op until a GLB is loaded as this node) ---
248
- get clips(): { name: string, duration: number }[] { return _creator.getGlbAnimations(this.id) }
249
-
250
- get animationPlaying(): boolean { return this._animPlaying }
251
- set animationPlaying(value: boolean) {
252
- this._animPlaying = value
253
- _creator.setAnimation(this.id, value, this._animClip, this._animSpeed)
254
- }
255
-
256
- /** Play a GLB clip by name or index. Chainable. */
257
- play(clip?: string | number, options: { loop?: boolean } = {}): this {
258
- if (typeof clip === "number") this._animClip = clip
259
- else if (typeof clip === "string") {
260
- const i = this.clips.findIndex((c) => c.name === clip)
261
- if (i >= 0) this._animClip = i
262
- }
263
- if (options.loop !== undefined) this._animLoop = options.loop
264
- this.animationPlaying = true
265
- ensureAnimator()
266
- return this
267
- }
268
- stop(): this { this.animationPlaying = false; return this }
269
-
270
- get animationLoop(): boolean { return this._animLoop }
271
- set animationLoop(v: boolean) { this._animLoop = v }
272
- get animationSpeed(): number { return this._animSpeed }
273
- set animationSpeed(v: number) {
274
- this._animSpeed = v
275
- _creator.setAnimation(this.id, this._animPlaying, this._animClip, v)
276
- }
277
- get animationTime(): number { return _creator.getAnimationTime(this.id) }
278
- set animationTime(v: number) { _creator.setAnimationTime(this.id, this._animClip, v) }
279
-
280
206
  // --- events ---
281
207
  override addEventListener<K extends keyof NodeEvents>(channel: K, callback: NodeEvents[K]): void {
282
208
  if (channel === "click") registerTouchEndEvent()
@@ -289,14 +215,15 @@ export class Node extends Emitter<NodeEvents> {
289
215
  this.dispatch(channel, ...args)
290
216
  }
291
217
 
292
- /** @internal — host animation-complete callback; returns whether the clip should keep playing. */
218
+ /** @internal — host animation-complete callback; forwards to the ModelAnimation aspect (if any).
219
+ * Returns whether the clip should keep playing. */
293
220
  _onAnimationComplete(clipIndex: number): boolean {
294
- if (clipIndex !== this._animClip) return true
295
- if (!this._animLoop) { this._animPlaying = false; this.dispatch("completed", clipIndex) }
296
- else this.dispatch("loopReached", clipIndex)
297
- return this._animPlaying
221
+ return this._animComplete?.(clipIndex) ?? true
298
222
  }
299
223
 
224
+ /** @internal — emit a GLB animation event (the ModelAnimation aspect can't reach protected dispatch). */
225
+ _emitAnim(channel: "loopReached" | "completed", clip: number): void { this.dispatch(channel, clip) }
226
+
300
227
  destroy(): void {
301
228
  nodeRegistry.delete(this.id)
302
229
  _creator.destroyEntity(this.id)
@@ -1,10 +1,11 @@
1
- // Physics handle for a Node, backed by a JoltPhysics body in creator-gl. Created by node.physics();
2
- // reached as node.body. Ported from worker/src/components/RigidBody.ts.
1
+ // Physics body as an aspect on a 3D Node, backed by a JoltPhysics body in creator-gl.
2
+ // node.aspect(Physics, { motion: 'dynamic' }); node.physics.applyImpulse([0, 6, 0])
3
3
  //
4
4
  // For a `dynamic` body, physics OWNS the transform — do NOT set node.position per frame; drive it
5
5
  // via velocity / applyImpulse. Reading node.position is free (native already wrote it). Kinematic
6
- // bodies are driven with moveTo.
6
+ // bodies are driven with moveTo. (Ported from the former Body class.)
7
7
 
8
+ import { Aspect } from "../core/Aspect"
8
9
  import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
9
10
  import type { Node } from "./Node"
10
11
 
@@ -17,40 +18,34 @@ export type BodyShape =
17
18
  | { type: "sphere", radius: number }
18
19
  | { type: "cylinder", halfHeight: number, radius: number }
19
20
 
20
- export type PhysicsOptions = {
21
+ export class Physics extends Aspect<"physics", Node> {
22
+ static readonly aspect = "physics"
23
+
21
24
  /** Default "dynamic". */
22
- motion?: MotionType
25
+ motion: MotionType = "dynamic"
23
26
  /** Mass in kg for dynamic bodies (default 1). Ignored for static/kinematic. */
24
- mass?: number
27
+ mass = 1
25
28
  /** Default { type: "auto" } — a box from the mesh AABB × world scale. */
26
- shape?: BodyShape
27
- }
29
+ shape: BodyShape = { type: "auto" }
28
30
 
29
- export class Body {
30
31
  private _bodyId = 0
31
- private readonly _motion: MotionType
32
-
33
- constructor(node: Node, options: PhysicsOptions = {}) {
34
- this._motion = options.motion ?? "dynamic"
35
- const mass = options.mass ?? 1
36
32
 
33
+ onAttach(): void {
37
34
  // No physics in this build (CREATOR_GL_PHYSICS=OFF) → bodyId stays 0; all methods no-op.
38
35
  if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
39
-
40
- const motion = MOTION[this._motion]
41
- const shape = resolveShape(node, options.shape ?? { type: "auto" })
36
+ const motion = MOTION[this.motion]
37
+ const shape = resolveShape(this.node, this.shape)
42
38
  if (shape.type === "sphere") {
43
- this._bodyId = _creator.physicsCreateSphere(node.id, shape.radius, motion, mass)
39
+ this._bodyId = _creator.physicsCreateSphere(this.node.id, shape.radius, motion, this.mass)
44
40
  } else if (shape.type === "cylinder") {
45
- this._bodyId = _creator.physicsCreateCylinder(node.id, shape.halfHeight, shape.radius, motion, mass)
41
+ this._bodyId = _creator.physicsCreateCylinder(this.node.id, shape.halfHeight, shape.radius, motion, this.mass)
46
42
  } else {
47
- this._bodyId = _creator.physicsCreateBox(node.id, cx(shape.halfExtents), cy(shape.halfExtents), cz(shape.halfExtents), motion, mass)
43
+ this._bodyId = _creator.physicsCreateBox(this.node.id, cx(shape.halfExtents), cy(shape.halfExtents), cz(shape.halfExtents), motion, this.mass)
48
44
  }
49
45
  }
50
46
 
51
- /** Native Jolt body id. 0 until assigned, or if the build has no physics support. */
47
+ /** Native Jolt body id. 0 until attached, or if the build has no physics support. */
52
48
  get id(): number { return this._bodyId }
53
- get motion(): MotionType { return this._motion }
54
49
 
55
50
  /** Linear velocity in world units/second (fresh Vec3 on read). */
56
51
  get velocity(): Vec3 {
@@ -74,8 +69,7 @@ export class Body {
74
69
  return this
75
70
  }
76
71
 
77
- /** Remove the body from the simulation. */
78
- remove(): void {
72
+ onDetach(): void {
79
73
  if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
80
74
  }
81
75
  }
@@ -19,6 +19,17 @@ export class Texture {
19
19
  this._id = id
20
20
  }
21
21
 
22
+ // Wrap a Canvas's baked surface as a 3D texture (RGBA8, already rasterized → synchronous, no decode).
23
+ // The id is cached on the canvas (opaque _tex3dId), so backing several materials shares one texture and
24
+ // canvas.update() re-uploads it. Structural type avoids a Canvas import (keeps 3D free of 2D-canvas code).
25
+ static fromCanvas(canvas: { _bakeSurface(): number, width: number, height: number, pixelRatio: number, _tex3dId: number }): Texture {
26
+ if (!canvas._tex3dId) {
27
+ const surfaceId = canvas._bakeSurface()
28
+ canvas._tex3dId = _creator.createTextureFromCanvas(surfaceId)
29
+ }
30
+ return new Texture(Math.round(canvas.width * canvas.pixelRatio), Math.round(canvas.height * canvas.pixelRatio), canvas._tex3dId)
31
+ }
32
+
22
33
  static load(source: string | FetchResponse): Promise<Texture> {
23
34
  if (typeof source === "string") {
24
35
  return fetch(source, { useOnce: true }).then((resp) => {
@@ -14,6 +14,13 @@ export { Quat } from "./math/quat"
14
14
  export { Mat4 } from "./math/mat4"
15
15
  export { Color } from "./core/color"
16
16
 
17
+ // ---- aspects (attachable node capabilities) ----
18
+ export { Aspect, type With } from "./core/Aspect"
19
+
20
+ // ---- canvas (core 2D drawing that bakes to a texture; shared by 2D/3D/UI) ----
21
+ export { Canvas } from "./canvas/Canvas"
22
+ export { Label } from "./canvas/Label"
23
+
17
24
  // ---- platform runtime (injected globals; the host provides the lower-level _creator* bridges) ----
18
25
  export { toast, SvgSource } from "./runtime/misc"
19
26
  export { fetch, fetchLocal, FormData, File } from "./runtime/fetch"
@@ -28,7 +35,13 @@ export { ClickEvent, TouchStartEvent } from "./runtime/touch"
28
35
  // ---- 2D engine (creator-2d) ----
29
36
  export { Scene2D } from "./g2/Scene2D"
30
37
  export { Node2D } from "./g2/Node2D"
38
+ export { Camera2D } from "./g2/Camera2D"
31
39
  export { Sprite } from "./g2/Sprite"
40
+ export { SpriteAnimation } from "./g2/SpriteAnimation"
41
+ export { Shape } from "./g2/Shape"
42
+ export { Physics2D } from "./g2/Physics2D"
43
+ export { Trigger } from "./g2/Trigger"
44
+ export { CharacterController2D } from "./g2/CharacterController2D"
32
45
  export { Tilemap } from "./g2/Tilemap"
33
46
  export { Texture2D } from "./g2/Texture2D"
34
47
 
@@ -61,6 +74,10 @@ export { QRScanner } from "./plugins/qr"
61
74
  export { Scene, ARScene } from "./gl/Scene"
62
75
  export { Node } from "./gl/Node"
63
76
  export { Mesh } from "./gl/Mesh"
77
+ export { Model } from "./gl/Model"
78
+ export { Physics } from "./gl/Physics"
79
+ export { Collider } from "./gl/Collider"
80
+ export { ModelAnimation } from "./gl/ModelAnimation"
64
81
  export { Light } from "./gl/Light"
65
82
  export { Material } from "./gl/Material"
66
83
  export { Geometry } from "./gl/Geometry"
@@ -8,6 +8,9 @@ export class ClickEvent<T = any> {
8
8
  readonly pointerId: number
9
9
  readonly clientX: number
10
10
  readonly clientY: number
11
+ /** World-space hit position (2D scenes only; undefined in 3D). */
12
+ worldX?: number
13
+ worldY?: number
11
14
  target!: T
12
15
  constructor(clientX: number, clientY: number, pointerId: number) {
13
16
  this.clientX = clientX
@@ -28,6 +31,9 @@ export class TouchStartEvent<T = any> {
28
31
  readonly pointerId: number
29
32
  readonly clientX: number
30
33
  readonly clientY: number
34
+ /** World-space hit position (2D scenes only; undefined in 3D). */
35
+ worldX?: number
36
+ worldY?: number
31
37
  target!: T
32
38
  /** @internal — read by the dispatch system after the handler runs. */
33
39
  _trackHandler?: TrackHandler