@solidrt/3d 0.0.51 → 0.0.53

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,122 @@
1
+ // The baked model container (.srtm): ModelData as one file whose payload IS
2
+ // the GPU layout, so loading it is a header parse plus typed-array views -
3
+ // no per-vertex work. Written by tools/model.ts under bun (from parseGltf),
4
+ // read by loadModel on flux; both ends are this pure module.
5
+ //
6
+ // Layout, all little-endian:
7
+ // "SRTM" u32 | version u32 | jsonLength u32 | json (padded to 4) | payload
8
+ // The JSON header describes each block's byte range into the payload; every
9
+ // block starts 4-aligned so Float32Array/Uint32Array views sit on it
10
+ // directly. Images travel as their encoded files (PNG/JPEG bytes).
11
+
12
+ import type { ModelData, ModelMaterial } from "./gltf.ts"
13
+ import { layoutKey, layoutStride, STANDARD_FLOATS } from "./geometry.ts"
14
+
15
+ /** "SRTM" read as a little-endian u32. */
16
+ const MAGIC = 0x4d545253
17
+ const VERSION = 1
18
+
19
+ type Block = { offset: number; bytes: number }
20
+
21
+ type PartHeader = Block & {
22
+ name: string
23
+ material: number
24
+ layout: string
25
+ vertexCount: number
26
+ indexBits: 16 | 32
27
+ index: Block
28
+ }
29
+
30
+ type Header = {
31
+ materials: ModelMaterial[]
32
+ images: Block[]
33
+ parts: PartHeader[]
34
+ bounds: number[]
35
+ }
36
+
37
+ /** Serialize a model into the .srtm container. */
38
+ export function encodeModel(data: ModelData): Uint8Array {
39
+ let blocks: Uint8Array[] = []
40
+ let offset = 0
41
+ let push = (bytes: Uint8Array): Block => {
42
+ let block = { offset, bytes: bytes.byteLength }
43
+ blocks.push(bytes)
44
+ offset += bytes.byteLength
45
+ let pad = (4 - (offset % 4)) % 4
46
+ if (pad) {
47
+ blocks.push(new Uint8Array(pad))
48
+ offset += pad
49
+ }
50
+ return block
51
+ }
52
+
53
+ let parts: PartHeader[] = data.parts.map((part) => {
54
+ let g = part.geometry
55
+ if (layoutStride(g.layout) !== STANDARD_FLOATS) {
56
+ throw new Error("encodeModel: part '" + part.name + "' has a " + layoutKey(g.layout) + " layout; only standard is written")
57
+ }
58
+ let vertices = push(new Uint8Array(g.vertices.buffer, g.vertices.byteOffset, g.vertices.byteLength))
59
+ let index = push(new Uint8Array(g.indices.buffer, g.indices.byteOffset, g.indices.byteLength))
60
+ return {
61
+ ...vertices,
62
+ name: part.name,
63
+ material: part.material,
64
+ layout: "standard",
65
+ vertexCount: g.vertices.length / STANDARD_FLOATS,
66
+ indexBits: g.indices instanceof Uint32Array ? 32 : 16,
67
+ index,
68
+ }
69
+ })
70
+ let images = data.images.map((image) => push(image))
71
+ let header: Header = { materials: data.materials, images, parts, bounds: Array.from(data.bounds) }
72
+
73
+ let json = new TextEncoder().encode(JSON.stringify(header))
74
+ let jsonPadded = json.byteLength + ((4 - (json.byteLength % 4)) % 4)
75
+ let out = new Uint8Array(12 + jsonPadded + offset)
76
+ let dv = new DataView(out.buffer)
77
+ dv.setUint32(0, MAGIC, true)
78
+ dv.setUint32(4, VERSION, true)
79
+ dv.setUint32(8, json.byteLength, true)
80
+ out.set(json, 12)
81
+ let at = 12 + jsonPadded
82
+ for (let block of blocks) {
83
+ out.set(block, at)
84
+ at += block.byteLength
85
+ }
86
+ return out
87
+ }
88
+
89
+ /**
90
+ * Read a .srtm container back into ModelData. The geometry arrays are
91
+ * VIEWS onto `bytes` (copied once only when the input is not 4-aligned),
92
+ * so the bytes must outlive the model.
93
+ */
94
+ export function decodeModel(bytes: Uint8Array): ModelData {
95
+ if (bytes.byteOffset % 4 !== 0) bytes = new Uint8Array(bytes)
96
+ let buffer = bytes.buffer as ArrayBuffer
97
+ let base = bytes.byteOffset
98
+ if (bytes.byteLength < 12) throw new Error("decodeModel: not a model file (too short)")
99
+ let head = new DataView(buffer, base, 12)
100
+ if (head.getUint32(0, true) !== MAGIC) throw new Error("decodeModel: not a model file (bad magic)")
101
+ let version = head.getUint32(4, true)
102
+ if (version !== VERSION) throw new Error("decodeModel: version " + version + ", expected " + VERSION)
103
+ let jsonLength = head.getUint32(8, true)
104
+ let header: Header = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, base + 12, jsonLength)))
105
+ let payload = base + 12 + jsonLength + ((4 - (jsonLength % 4)) % 4)
106
+
107
+ let parts = header.parts.map((part) => {
108
+ if (part.layout !== "standard") throw new Error("decodeModel: part '" + part.name + "' has an unsupported layout " + part.layout)
109
+ let indexCount = part.index.bytes / (part.indexBits / 8)
110
+ return {
111
+ name: part.name,
112
+ material: part.material,
113
+ geometry: {
114
+ vertices: new Float32Array(buffer, payload + part.offset, part.vertexCount * STANDARD_FLOATS),
115
+ indices: part.indexBits === 32 ? new Uint32Array(buffer, payload + part.index.offset, indexCount) : new Uint16Array(buffer, payload + part.index.offset, indexCount),
116
+ label: part.name,
117
+ },
118
+ }
119
+ })
120
+ let images = header.images.map((block) => new Uint8Array(buffer, payload + block.offset, block.bytes))
121
+ return { parts, materials: header.materials, images, bounds: Float32Array.from(header.bounds) }
122
+ }
package/src/model.ts ADDED
@@ -0,0 +1,105 @@
1
+ // Models in a scene: ModelData (parsed glTF or a decoded .srtm) becomes a
2
+ // Group of meshes, one per part, with the images uploaded as textures and
3
+ // a material per glTF material - Three's `gltf.scene`, an object you add
4
+ // to the scene and place with setTransform. The model owns what it
5
+ // created (geometry buffers, textures): dispose() frees them and detaches
6
+ // the group. loadGltf / loadModel are the read-then-create conveniences
7
+ // over flux:fs; parseGltf / decodeModel + createModel are the primitives
8
+ // under them, for bytes obtained any other way (a binary import, a fetch).
9
+
10
+ import { file } from "flux:fs"
11
+ import { decodeImage } from "@solidrt/core"
12
+ import { createTexture, destroyTexture } from "@solidrt/core/gpu"
13
+ import type { TextureId } from "@solidrt/core/gpu"
14
+ import { gltfExternalUris, isGlb, parseGltf } from "./gltf.ts"
15
+ import type { ModelData, ModelMaterial } from "./gltf.ts"
16
+ import { decodeModel } from "./model-file.ts"
17
+ import { disposeGeometry } from "./geometry-gpu.ts"
18
+ import { lit } from "./material.ts"
19
+ import type { Material } from "./material.ts"
20
+ import { add, createGroup, createMesh, remove } from "./scene.ts"
21
+ import type { Mesh, SceneNode } from "./scene.ts"
22
+
23
+ export type ModelOptions = {
24
+ /** The material for each glTF material (default: `lit` with its color,
25
+ * map and transparency). `map` is the uploaded base color texture, or
26
+ * null. Called once per material, shared by every part using it. */
27
+ material?: (material: ModelMaterial, map: TextureId | null) => Material
28
+ /** Debug name for the textures. */
29
+ label?: string
30
+ }
31
+
32
+ /** A model in the scene: a Group whose children are the parts' meshes. */
33
+ export type Model = SceneNode & {
34
+ kind: "group"
35
+ /** The parts by name, in file order; each `mesh` is a child of the model. */
36
+ parts: { name: string; mesh: Mesh }[]
37
+ /** One per glTF material, in file order. */
38
+ materials: Material[]
39
+ /** Local [minX, minY, minZ, maxX, maxY, maxZ] over every part. */
40
+ bounds: Float32Array
41
+ /** Detach the model and free its geometry buffers and textures. */
42
+ dispose(): void
43
+ }
44
+
45
+ /**
46
+ * Build the scene object for parsed model data: upload its images (repeat
47
+ * wrap, mipmapped), make a material per glTF material, a mesh per part,
48
+ * all under one Group. Synchronous - the data is already in memory.
49
+ */
50
+ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
51
+ let label = opts.label
52
+ let textures: TextureId[] = data.images.map((bytes, i) => {
53
+ let image = decodeImage(bytes)
54
+ return createTexture(image.data, image.width, image.height, {
55
+ wrap: "repeat",
56
+ mipmap: true,
57
+ autoFree: false,
58
+ label: label ? label + "-image" + i : undefined,
59
+ })
60
+ })
61
+ let make = opts.material ?? ((m: ModelMaterial, map: TextureId | null): Material => lit({ color: m.color, map: map ?? undefined, transparent: m.transparent }))
62
+ let materials = data.materials.map((m) => make(m, m.map === null ? null : textures[m.map]!))
63
+
64
+ let model = createGroup() as Model
65
+ model.parts = data.parts.map((part) => {
66
+ let material = materials[part.material]
67
+ if (material === undefined) throw new Error("createModel: part '" + part.name + "' names a missing material " + part.material)
68
+ let mesh = createMesh(part.geometry, material)
69
+ add(model, mesh)
70
+ return { name: part.name, mesh }
71
+ })
72
+ model.materials = materials
73
+ model.bounds = data.bounds
74
+ model.dispose = () => {
75
+ if (model.parent !== null) remove(model)
76
+ for (let part of model.parts) disposeGeometry(part.mesh.geometry)
77
+ for (let id of textures) destroyTexture(id)
78
+ textures.length = 0
79
+ }
80
+ return model
81
+ }
82
+
83
+ /**
84
+ * Read a .glb or .gltf (with its external .bin and image files, resolved
85
+ * next to it) and build the model. The parse runs on the runtime - fine
86
+ * for models of tens of thousands of vertices; bake bigger ones with
87
+ * `srt tool 3d/model` and use loadModel.
88
+ */
89
+ export async function loadGltf(path: string, opts?: ModelOptions): Promise<Model> {
90
+ let bytes = await file(path).bytes()
91
+ let files = new Map<string, Uint8Array>()
92
+ if (!isGlb(bytes)) {
93
+ let dir = path.slice(0, path.lastIndexOf("/") + 1)
94
+ for (let uri of gltfExternalUris(bytes)) {
95
+ if (!files.has(uri)) files.set(uri, await file(dir + decodeURIComponent(uri)).bytes())
96
+ }
97
+ }
98
+ return createModel(parseGltf(bytes, (uri) => files.get(uri)!), opts)
99
+ }
100
+
101
+ /** Read a baked .srtm model (`srt tool 3d/model`) and build it: no parsing,
102
+ * the geometry views the file's bytes directly. */
103
+ export async function loadModel(path: string, opts?: ModelOptions): Promise<Model> {
104
+ return createModel(decodeModel(await file(path).bytes()), opts)
105
+ }
package/src/orbit.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  // about that point instead - the spot under the fingers stays under the
23
23
  // fingers, with the target sliding toward it. Only the app can own that
24
24
  // mapping: screen-to-ray needs the projection and the element's placement
25
- // (fov, aspect, viewBox scaling), which live in the app's camera and layout,
25
+ // (fov, aspect, designSize scaling), which live in the app's camera and layout,
26
26
  // not here.
27
27
  //
28
28
  // Anchored zoom leaves the target wherever the zoom carried it - possibly a
@@ -48,7 +48,7 @@
48
48
  import { createSignal } from "@solidjs/signals"
49
49
  import { createTransform } from "@solidrt/core"
50
50
  import type { PointerEvent } from "@solidrt/core"
51
- import type { Scene } from "./scene.ts"
51
+ import type { CameraUpdate } from "./scene.ts"
52
52
  import type { Vec3 } from "./math.ts"
53
53
 
54
54
  // Baseline sensitivities at rotateSpeed/zoomSpeed 1, in radians per dragged
@@ -57,6 +57,9 @@ const DRAG_AZIMUTH = 0.008
57
57
  const DRAG_ELEVATION = 0.006
58
58
  const WHEEL_ZOOM = 0.0015
59
59
 
60
+ /** What an orbit camera drives: a Scene, or one of its Views. */
61
+ export type OrbitTarget = { setCamera(update: CameraUpdate): void }
62
+
60
63
  export type OrbitCameraOptions = {
61
64
  /** The point the camera orbits and looks at (default origin). */
62
65
  target?: Vec3
@@ -123,7 +126,7 @@ export type OrbitCamera = {
123
126
  /** Whether the auto-orbit is running. Reactive (signal-backed), so HUD
124
127
  * text can read it. */
125
128
  orbiting(): boolean
126
- /** Advance the auto-orbit and push any pose change to the scene camera.
129
+ /** Advance the auto-orbit and push any pose change to the driven camera.
127
130
  * Call from onFrame with the frame's dt in seconds; returns whether the
128
131
  * pose changed. */
129
132
  update(dt: number): boolean
@@ -142,12 +145,13 @@ export type OrbitCamera = {
142
145
  let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
143
146
 
144
147
  /**
145
- * Create an orbit camera driving `scene`'s camera position and target (fov,
146
- * near, and far stay yours via scene.setCamera). The initial pose applies
147
- * immediately. In a component tree, reach the scene via `<Scene ref>` or
148
- * useScene() and hand the handlers to whichever element owns input.
148
+ * Create an orbit camera driving `camera`'s position and target, where
149
+ * `camera` is a Scene or one of its Views (fov, near, and far stay yours via
150
+ * its setCamera). The initial pose applies immediately. In a component tree,
151
+ * reach the scene via `<Scene ref>` or useScene() (a view via `<View ref>`)
152
+ * and hand the handlers to whichever element owns input.
149
153
  */
150
- export function createOrbitCamera(scene: Scene, options: OrbitCameraOptions = {}): OrbitCamera {
154
+ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptions = {}): OrbitCamera {
151
155
  let target: Vec3 = options.target ? [options.target[0], options.target[1], options.target[2]] : [0, 0, 0]
152
156
  let azimuth = options.azimuth ?? 0
153
157
  let elevation = options.elevation ?? 0
@@ -197,7 +201,7 @@ export function createOrbitCamera(scene: Scene, options: OrbitCameraOptions = {}
197
201
  target[2] + distance * ce * Math.cos(azimuth),
198
202
  ]
199
203
  }
200
- let apply = () => scene.setCamera({ position: eye(), target })
204
+ let apply = () => camera.setCamera({ position: eye(), target })
201
205
 
202
206
  // Zoom by `ratio` (new distance over old, before clamping) about a world
203
207
  // anchor (null zooms toward the target). Scaling eye and target about the
package/src/order.ts CHANGED
@@ -22,13 +22,20 @@ export type Orderable<T> = {
22
22
  * center. The center, not the origin (Three's key), so geometry built
23
23
  * off-origin sorts by where it is; and not the nearest bounds point, which
24
24
  * would draw a large translucent ground plane over the small translucents
25
- * resting on it. Per-mesh only: no per-triangle sort, no OIT.
25
+ * resting on it. Per-mesh only: no per-triangle sort, no OIT. `entry`
26
+ * picks the id ordered for each mesh (default its own `_entry`; a view
27
+ * passes its per-mesh entries in its target).
26
28
  */
27
- export function orderEntries<T>(meshes: readonly Orderable<T>[], view: Mat4, first?: T): T[] {
29
+ export function orderEntries<T>(
30
+ meshes: readonly Orderable<T>[],
31
+ view: Mat4,
32
+ first?: T,
33
+ entry: (m: Orderable<T>) => T | null = m => m._entry,
34
+ ): T[] {
28
35
  let opaque: Orderable<T>[] = []
29
36
  let transparent: Orderable<T>[] = []
30
37
  for (let m of meshes) {
31
- if (m._entry === null) continue
38
+ if (entry(m) === null) continue
32
39
  ;(m._transparent ? transparent : opaque).push(m)
33
40
  }
34
41
  // Array sort is stable, so equal keys keep add order.
@@ -45,7 +52,7 @@ export function orderEntries<T>(meshes: readonly Orderable<T>[], view: Mat4, fir
45
52
  }
46
53
  let order: T[] = []
47
54
  if (first !== undefined) order.push(first)
48
- for (let m of opaque) order.push(m._entry!)
49
- for (let m of transparent) order.push(m._entry!)
55
+ for (let m of opaque) order.push(entry(m)!)
56
+ for (let m of transparent) order.push(entry(m)!)
50
57
  return order
51
58
  }
package/src/profile.ts CHANGED
@@ -8,8 +8,8 @@
8
8
  // for custom flat work. The swept-solid generators consuming this
9
9
  // vocabulary - extrude, lathe, sweep, tube - live in sweep.ts.
10
10
 
11
- import { packIndices } from "./geometry.ts"
12
- import type { Geometry } from "./geometry.ts"
11
+ import { packGeometry } from "./geometry.ts"
12
+ import type { Geometry, GeometryOptions } from "./geometry.ts"
13
13
  import type { Vec2 } from "./math.ts"
14
14
 
15
15
  /** A profile point: `p` in profile space, `smooth` to share an averaged
@@ -298,7 +298,7 @@ export function roundRect(
298
298
  * profile's bounding box to the unit square like plane(); rotate flat the
299
299
  * same way: `rotation={[-Math.PI / 2, 0, 0]}`.
300
300
  */
301
- export function shape(profile: Profile, label?: string): Geometry {
301
+ export function shape(profile: Profile, options: GeometryOptions = {}): Geometry {
302
302
  let pts = normalizeProfile(profile)
303
303
  let { minX, maxY, w, h } = profileBounds(pts)
304
304
  let px = pts.map((p) => p.x)
@@ -307,9 +307,5 @@ export function shape(profile: Profile, label?: string): Geometry {
307
307
  for (let p of pts) {
308
308
  verts.push(p.x, p.y, 0, 0, 0, 1, (p.x - minX) / w, (maxY - p.y) / h)
309
309
  }
310
- return {
311
- vertices: new Float32Array(verts),
312
- indices: packIndices(earClip(px, py), pts.length),
313
- label,
314
- }
310
+ return packGeometry(verts, earClip(px, py), options)
315
311
  }