sprite-machine 0.1.1 → 0.2.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.
package/README.md CHANGED
@@ -1,20 +1,20 @@
1
1
  # sprite-machine
2
2
 
3
3
  Turns a 3×2 sheet of pixel-art face sprites (left / front / top over right /
4
- back / bottom) into a low-poly, textured three.js mesh or a glTF 2.0 binary.
5
- One pixel is one voxel. 45° wedges smooth every same-colour staircase, and
6
- the colour comes from a nearest-sampled skin texture.
4
+ back / bottom) into a low-poly, textured model: indexed triangle buffers and
5
+ a skin bitmap, or a glTF 2.0 binary. One pixel is one voxel. 45° wedges
6
+ smooth every same-colour staircase, and the colour comes from a
7
+ nearest-sampled skin texture.
7
8
 
8
9
  This is the engine behind [Sprite Machine](https://aportilla.github.io/sprite-machine/),
9
10
  the desktop app that draws these sheets. It runs at a build step, at a
10
11
  server's startup, or in the browser.
11
12
 
12
13
  ```bash
13
- npm install sprite-machine three
14
+ npm install sprite-machine
14
15
  ```
15
16
 
16
- `three` is a peer dependency: the mesh is a `THREE.Mesh`, and the mesher uses
17
- three's geometry utilities. Requires Node 20.19+ or 22.12+.
17
+ The root entry depends on `earcut` alone. Requires Node 20.19+ or 22.12+.
18
18
 
19
19
  ## The API
20
20
 
@@ -23,8 +23,11 @@ import { buildModel, modelToGlb } from 'sprite-machine';
23
23
 
24
24
  // sheet: {width, height, data}, an ImageData or the same shape
25
25
  const model = buildModel(sheet, { transforms, layers });
26
- // → { mesh, dims, triangles, warnings, unitsPerVoxel: 1 }
27
- // mesh is a THREE.Mesh at one unit per voxel, its skin the material's map.
26
+ // → { geometry, skin, color, triangles, dims, warnings, unitsPerVoxel: 1 }
27
+ // geometry is { position, normal, uv, index, bounds }: Float32 xyz per
28
+ // vertex at one unit per voxel, centered on X and Z, unit normals, uv
29
+ // pairs in [0, 1], a Uint32 CCW triangle index and the bounds. skin is
30
+ // the texture, { width, height, data }, sRGB RGBA with row 0 at v = 0.
28
31
  // transforms is the per-view reorientation (optional). layers is the
29
32
  // sheet's block count (optional, 1 by default; see Layers).
30
33
 
@@ -34,10 +37,27 @@ const glb = modelToGlb(model, { name: 'car', voxelsPerMeter: 10 });
34
37
  ```
35
38
 
36
39
  Both functions are synchronous and pure. `buildModel` throws on an invalid
37
- sheet or a sheet with no painted view in any layer. A three.js page can use
38
- `model.mesh` directly, and a Node process writes the glb. To build off the main
40
+ sheet or a sheet with no painted view in any layer. To build off the main
39
41
  thread, call them from a worker or a child process.
40
42
 
43
+ ### In three.js
44
+
45
+ Two routes. `sprite-machine/three` turns the model into a `THREE.Mesh`, with
46
+ `three` installed beside the engine:
47
+
48
+ ```js
49
+ import { toMesh, toGeometry, skinTexture } from 'sprite-machine/three';
50
+
51
+ const mesh = toMesh(model); // a flat-shaded MeshStandardMaterial over the skin
52
+ const geo = toGeometry(model.geometry); // a BufferGeometry with its bounds
53
+ const map = skinTexture(model.skin); // a DataTexture, nearest, sRGB, flipY false
54
+ ```
55
+
56
+ Or write the glb and load it with `GLTFLoader`, which reads the sampler, the
57
+ sRGB texture, the unlit extension and the node name. Every other engine loads
58
+ the glb. `three` is an optional peer (0.152 or later), and the root entry
59
+ never imports it.
60
+
41
61
  ### In Node: a document PNG in
42
62
 
43
63
  ```js
@@ -56,8 +76,7 @@ otherwise. It takes an optional `name`, which overrides the Title chunk. With
56
76
  neither, the name is `'sprite'`.
57
77
 
58
78
  `sprite-machine/node` is the only entry with a PNG decoder. The root entry
59
- takes pixels and depends only on three, so a browser bundle never includes
60
- `pngjs`. In a browser, decode with `createImageBitmap` and a canvas, and pass
79
+ takes pixels, so a browser bundle never includes `pngjs`. In a browser, decode with `createImageBitmap` and a canvas, and pass
61
80
  the `ImageData` to `buildModel`.
62
81
 
63
82
  ### The CLI
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sprite-machine",
3
- "version": "0.1.1",
4
- "description": "Turn a sheet of pixel-art face sprites into a low-poly, textured three.js mesh or a glTF binary — the engine behind Sprite Machine.",
3
+ "version": "0.2.0",
4
+ "description": "Turn a sheet of pixel-art face sprites into a low-poly, textured glTF binary — the engine behind Sprite Machine.",
5
5
  "license": "MIT",
6
6
  "author": "Adam Portilla",
7
7
  "type": "module",
@@ -28,7 +28,8 @@
28
28
  "sideEffects": false,
29
29
  "exports": {
30
30
  ".": "./src/index.js",
31
- "./node": "./src/node.js"
31
+ "./node": "./src/node.js",
32
+ "./three": "./src/three.js"
32
33
  },
33
34
  "bin": {
34
35
  "sprite-machine": "./bin/sprite-machine.mjs"
@@ -44,9 +45,15 @@
44
45
  "typecheck": "tsc -p tsconfig.json"
45
46
  },
46
47
  "peerDependencies": {
47
- "three": "^0.185.0"
48
+ "three": ">=0.152.0"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "three": {
52
+ "optional": true
53
+ }
48
54
  },
49
55
  "dependencies": {
56
+ "earcut": "3.0.2",
50
57
  "pngjs": "^7.0.0"
51
58
  }
52
59
  }
package/src/diag.js CHANGED
@@ -3,11 +3,15 @@
3
3
  // edges used an odd number of times are boundaries. Also returns a histogram of
4
4
  // triangle normals by axis.
5
5
 
6
- /** @param {import('three').BufferGeometry} geo */
7
- export function computeDiag(geo) {
8
- const pos = geo.attributes.position.array;
9
- const nrm = geo.attributes.normal.array;
10
- const idx = geo.index ? geo.index.array : null;
6
+ /**
7
+ * @param {{position:ArrayLike<number>, normal:ArrayLike<number>,
8
+ * index?:ArrayLike<number>|null}} geometry
9
+ * flat triangle buffers, non-indexed when index is null
10
+ */
11
+ export function computeDiag(geometry) {
12
+ const pos = geometry.position;
13
+ const nrm = geometry.normal;
14
+ const idx = geometry.index ?? null;
11
15
  const triCount = idx ? idx.length / 3 : pos.length / 9;
12
16
  const key = (i) => {
13
17
  const x = Math.round(pos[i * 3] * 1e4);
package/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Public API of the sprite-machine package. Exports are listed by name: with
2
2
  // export *, a name exported by two modules is silently dropped. The Node adapter
3
- // is sprite-machine/node.
3
+ // is sprite-machine/node, the three adapter sprite-machine/three.
4
4
 
5
5
  export {
6
6
  packRGBA,
@@ -64,7 +64,7 @@ export { LAYERS_CHUNK, layersChunk, parseLayersChunk, layerCount } from './layer
64
64
  export { traceRegions, planeKey, faceRegions } from './regions.js';
65
65
  export { eliminateTJunctions } from './t-junction.js';
66
66
  export { bakeSkin, uvOfLattice, swatchUV } from './skin.js';
67
- export { skinTexture, finishVoxelMesh } from './mesh-util.js';
67
+ export { weldVertices } from './weld.js';
68
68
  export { wedgeMesh } from './wedge-mesh.js';
69
69
  export {
70
70
  PNG_SIGNATURE,
package/src/model.js CHANGED
@@ -1,25 +1,24 @@
1
1
  // Headless entry: a sheet's pixels to a model, and a model to a glb.
2
2
  //
3
3
  // buildModel runs slice, ingest, carve, colorize, the layer union and the
4
- // wedge mesher, and returns the mesh at one unit per voxel, so a position is a
5
- // lattice coordinate. modelToGlb writes the same glb as the app's export, with
6
- // the skin encoded from bytes. Both are synchronous.
4
+ // wedge mesher, and returns the record at one unit per voxel, so a position is
5
+ // a lattice coordinate. modelToGlb writes the same glb as the app's export,
6
+ // with the skin encoded from bytes. Both are synchronous.
7
7
 
8
8
  import { sliceLayers, validateSheet } from './atlas.js';
9
9
  import { buildLayeredVoxels } from './pipeline.js';
10
10
  import { wedgeMesh } from './wedge-mesh.js';
11
11
  import { encodePng } from './png-encode.js';
12
12
  import { glbFromModel } from './gltf.js';
13
+ import { unpackRGBA } from './ingest.js';
13
14
  import { VIEW_NAMES } from './views.js';
14
15
 
15
16
  /**
16
- * A built model: the mesh, the lattice dims and the mesh's units per voxel
17
+ * A built model: the mesher's record, the lattice dims and the units per voxel
17
18
  * (1 from buildModel).
18
- * @typedef {{
19
- * mesh: import('three').Mesh,
19
+ * @typedef {import('./wedge-mesh.js').Built & {
20
20
  * dims: {nx:number, ny:number, nz:number},
21
21
  * unitsPerVoxel: number,
22
- * triangles: number,
23
22
  * warnings: string[],
24
23
  * }} Model
25
24
  */
@@ -34,7 +33,7 @@ import { VIEW_NAMES } from './views.js';
34
33
  * `transforms`: per-view reorientation, as stored in the
35
34
  * `sprite-machine:transforms` chunk, applied in every layer; `layers`: the
36
35
  * sheet's block count
37
- * @returns {Model} the mesh at one unit per voxel
36
+ * @returns {Model} the model at one unit per voxel
38
37
  * @throws on an invalid sheet, or one with no painted view in any layer
39
38
  */
40
39
  export function buildModel(sheet, { transforms = {}, layers = 1 } = {}) {
@@ -51,20 +50,29 @@ export function buildModel(sheet, { transforms = {}, layers = 1 } = {}) {
51
50
  throw new Error('buildModel: the sheet has no painted view.');
52
51
  }
53
52
  const { nx, ny, nz } = result.dims;
54
- const mesh = wedgeMesh(result, { worldSize: Math.max(nx, ny, nz) });
55
53
  return {
56
- mesh,
54
+ ...wedgeMesh(result, { worldSize: Math.max(nx, ny, nz) }),
57
55
  dims: result.dims,
58
56
  unitsPerVoxel: 1,
59
- triangles: Number(mesh.userData.triangles) || 0,
60
57
  warnings: [...sliced.warnings, ...(result.warnings || [])],
61
58
  };
62
59
  }
63
60
 
61
+ /** The sRGB transfer to linear, one channel in 0..1. */
62
+ const srgbToLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
63
+
64
+ /** A packed sRGB color as linear RGB in 0..1. */
65
+ function linearRGB(packed) {
66
+ const { r, g, b } = unpackRGBA(packed);
67
+ return [r / 255, g / 255, b / 255].map(srgbToLinear);
68
+ }
69
+
64
70
  /**
65
71
  * The model as a glb, with its skin embedded behind a nearest sampler, or its
66
- * flat color when the material has no map.
67
- * @param {{mesh: import('three').Mesh, dims: {nx:number, ny:number, nz:number}, unitsPerVoxel?: number}} model
72
+ * flat color, made linear, when it has no skin.
73
+ * @param {{geometry: import('./wedge-mesh.js').Geometry,
74
+ * skin: import('./skin.js').Skin|null, color?: number|null,
75
+ * dims: {nx:number, ny:number, nz:number}, unitsPerVoxel?: number}} model
68
76
  * @param {{name: string, voxelsPerMeter?: number, unlit?: boolean, generator?: string}} opts
69
77
  * `voxelsPerMeter` sets the scale (at 10, a 40-voxel car is 4 m long);
70
78
  * `unlit` adds KHR_materials_unlit; `generator` is written to the asset
@@ -74,20 +82,17 @@ export function modelToGlb(
74
82
  model,
75
83
  { name, voxelsPerMeter = 10, unlit = false, generator }
76
84
  ) {
77
- const { mesh, dims } = model;
85
+ const { geometry, skin, dims } = model;
78
86
  const unitsPerVoxel = model.unitsPerVoxel ?? 1;
79
- const geo = mesh.geometry;
80
- const material = /** @type {import('three').MeshStandardMaterial} */ (mesh.material);
81
- const map = material.map;
82
87
  return glbFromModel({
83
88
  name,
84
- position: geo.attributes.position.array,
85
- normal: geo.attributes.normal.array,
86
- uv: geo.attributes.uv.array,
87
- index: geo.index.array,
89
+ position: geometry.position,
90
+ normal: geometry.normal,
91
+ uv: geometry.uv,
92
+ index: geometry.index,
88
93
  scale: 1 / (unitsPerVoxel * voxelsPerMeter),
89
- image: map ? { bytes: encodePng(map.image) } : null,
90
- color: map ? null : material.color.toArray(),
94
+ image: skin ? { bytes: encodePng(skin) } : null,
95
+ color: skin || model.color == null ? null : linearRGB(model.color),
91
96
  unlit,
92
97
  generator,
93
98
  extras: { 'sprite-machine': { voxelsPerMeter, dims: { ...dims } } },
package/src/three.js ADDED
@@ -0,0 +1,60 @@
1
+ // Three adapter (sprite-machine/three): the mesher's record as three objects.
2
+ // The only entry that imports three, an optional peer.
3
+
4
+ import * as THREE from 'three';
5
+ import { unpackRGBA } from './ingest.js';
6
+
7
+ /**
8
+ * The skin as a DataTexture. The class defaults match the bake: nearest
9
+ * filtering, no mipmaps, and flipY false so texel row 0 is v = 0. The bytes
10
+ * are sRGB.
11
+ * @param {import('./skin.js').Skin} skin
12
+ * @returns {THREE.DataTexture}
13
+ */
14
+ export function skinTexture(skin) {
15
+ const tex = new THREE.DataTexture(skin.data, skin.width, skin.height, THREE.RGBAFormat);
16
+ tex.colorSpace = THREE.SRGBColorSpace;
17
+ tex.needsUpdate = true;
18
+ return tex;
19
+ }
20
+
21
+ /**
22
+ * The geometry as an indexed BufferGeometry with its bounds computed. The
23
+ * arrays are copied.
24
+ * @param {import('./wedge-mesh.js').Geometry} geometry
25
+ * @returns {THREE.BufferGeometry}
26
+ */
27
+ export function toGeometry(geometry) {
28
+ const geo = new THREE.BufferGeometry();
29
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(geometry.position, 3));
30
+ geo.setAttribute('normal', new THREE.Float32BufferAttribute(geometry.normal, 3));
31
+ geo.setAttribute('uv', new THREE.Float32BufferAttribute(geometry.uv, 2));
32
+ geo.setIndex(new THREE.Uint32BufferAttribute(geometry.index, 1));
33
+ geo.computeBoundingBox();
34
+ geo.computeBoundingSphere();
35
+ return geo;
36
+ }
37
+
38
+ /**
39
+ * The model as a flat-shaded Mesh with shadows on. The material takes the
40
+ * skin as `map`, or with no skin the packed sRGB `color`.
41
+ * @param {import('./wedge-mesh.js').Built} model
42
+ * @returns {THREE.Mesh}
43
+ */
44
+ export function toMesh(model) {
45
+ const map = model.skin ? skinTexture(model.skin) : null;
46
+ const mat = new THREE.MeshStandardMaterial({
47
+ flatShading: true,
48
+ metalness: 0,
49
+ roughness: 1,
50
+ ...(map ? { map } : {}),
51
+ });
52
+ if (!map && model.color != null) {
53
+ const { r, g, b } = unpackRGBA(model.color);
54
+ mat.color.setRGB(r / 255, g / 255, b / 255, THREE.SRGBColorSpace);
55
+ }
56
+ const mesh = new THREE.Mesh(toGeometry(model.geometry), mat);
57
+ mesh.castShadow = true;
58
+ mesh.receiveShadow = true;
59
+ return mesh;
60
+ }
package/src/wedge-mesh.js CHANGED
@@ -12,22 +12,21 @@
12
12
  // - Slopes: the wedge cells of one 45° plane form a grid, greedy-merged by
13
13
  // color into one quad per block.
14
14
  // - Base faces: coplanar regions (regions.js) of exposed faces and cap halves,
15
- // triangulated with earcut (THREE.ShapeUtils).
15
+ // triangulated with earcut.
16
16
  // Color comes from the skin (skin.js), so regions merge on occupancy alone.
17
17
  // UVs are read from lattice positions after the T-junction repair.
18
18
  //
19
19
  // Not handled: convex staircases still step, and 3D corners where two ridges
20
20
  // meet become a step.
21
21
 
22
- import * as THREE from 'three';
23
- import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
22
+ import earcut from 'earcut';
24
23
  import { voxIndex } from './carve.js';
25
24
  import { FACE_GEO, pointOf } from './faces.js';
26
25
  import { faceRegions, planeKey } from './regions.js';
27
26
  import { unpackRGBA } from './ingest.js';
28
27
  import { bakeSkin, uvOfLattice, swatchUV } from './skin.js';
29
28
  import { eliminateTJunctions } from './t-junction.js';
30
- import { finishVoxelMesh, skinTexture } from './mesh-util.js';
29
+ import { weldVertices } from './weld.js';
31
30
  import { AXIS_INDEX, FACE_INDEX, faceKeyOf } from './views.js';
32
31
  import { DEFAULT_WORLD_SIZE } from './constants.js';
33
32
 
@@ -51,6 +50,28 @@ const RIDGES = [
51
50
  * swatch:number|null}} Tri
52
51
  */
53
52
 
53
+ /**
54
+ * Indexed triangle buffers: xyz positions centered on X and Z with Y as
55
+ * authored (see carve.js), unit normals, uv pairs in [0, 1] (zero in flat
56
+ * mode), CCW index triples and the positions' bounds.
57
+ * @typedef {{position:Float32Array, normal:Float32Array, uv:Float32Array,
58
+ * index:Uint32Array, bounds:{min:number[], max:number[]}}} Geometry
59
+ */
60
+
61
+ /**
62
+ * The mesher's output: the geometry, the skin or, in flat mode, a packed flat
63
+ * color, and the counts. sprite-machine/three turns it into a THREE.Mesh.
64
+ * @typedef {{geometry:Geometry, skin:import('./skin.js').Skin|null,
65
+ * color:number|null, triangles:number, wedges:number,
66
+ * slopes:number, charts:number}} Built
67
+ */
68
+
69
+ /**
70
+ * @param {object} result a buildVoxels result
71
+ * @param {{flat?:boolean, worldSize?:number}} [opts] `flat` skips the skin
72
+ * and the wedge gate; `worldSize` is the longest side's length
73
+ * @returns {Built}
74
+ */
54
75
  export function wedgeMesh(result, opts = {}) {
55
76
  const { dims, solid, surfaceMask, faceColor } = result;
56
77
  const { nx, ny, nz } = dims;
@@ -284,15 +305,18 @@ export function wedgeMesh(result, opts = {}) {
284
305
  const paint = chart
285
306
  ? { chart, region }
286
307
  : { swatch: flat ? FLAT_COLOR : /** @type {number} */ (region.uniform) >>> 0 };
287
- const toV2 = (loop) => loop.map(([a, b]) => new THREE.Vector2(a, b));
288
- const faces = THREE.ShapeUtils.triangulateShape(
289
- toV2(region.outer),
290
- region.holes.map(toV2)
291
- );
308
+ // earcut takes the loops flat, the holes by their first vertex's index.
309
+ const coords = [];
310
+ const holeStarts = [];
311
+ for (const hole of [region.outer, ...region.holes]) {
312
+ if (hole !== region.outer) holeStarts.push(coords.length / 2);
313
+ for (const [a, b] of hole) coords.push(a, b);
314
+ }
315
+ const faces = earcut(coords, holeStarts);
292
316
  const verts = [region.outer, ...region.holes].flat();
293
317
  let area = 0;
294
- for (const [i0, i1, i2] of faces) {
295
- const [p, q, r] = [verts[i0], verts[i1], verts[i2]];
318
+ for (let k = 0; k < faces.length; k += 3) {
319
+ const [p, q, r] = [verts[faces[k]], verts[faces[k + 1]], verts[faces[k + 2]]];
296
320
  area += Math.abs((q[0] - p[0]) * (r[1] - p[1]) - (r[0] - p[0]) * (q[1] - p[1]));
297
321
  pushTri(
298
322
  pointOf(region.face, p[0], p[1], region.s),
@@ -336,28 +360,33 @@ export function wedgeMesh(result, opts = {}) {
336
360
  }
337
361
  }
338
362
 
339
- // Assemble.
340
- let geo = new THREE.BufferGeometry();
341
- geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
342
- geo.setAttribute('normal', new THREE.Float32BufferAttribute(nrm, 3));
343
- geo.setAttribute('uv', new THREE.Float32BufferAttribute(uv, 2));
344
- // Weld by position, normal and uv, so vertices of differently facing
345
- // triangles or of different charts stay split. flatShading ignores the
346
- // normals, but this weld and diag.js depend on them.
347
- geo = mergeVertices(geo, 1e-4);
363
+ // 5. Weld by position, normal and uv, so vertices of differently facing
364
+ // triangles or of different charts stay split. Flat shading ignores the
365
+ // normals, but this weld and diag.js depend on them. Then center X and Z and
366
+ // take the bounds.
367
+ const welded = weldVertices(pos, nrm, uv);
368
+ const { position } = welded;
369
+ const dx = (-nx * s) / 2;
370
+ const dz = (-nz * s) / 2;
371
+ const min = [Infinity, Infinity, Infinity];
372
+ const max = [-Infinity, -Infinity, -Infinity];
373
+ for (let i = 0; i < position.length; i += 3) {
374
+ position[i] += dx;
375
+ position[i + 2] += dz;
376
+ for (let k = 0; k < 3; k++) {
377
+ const v = position[i + k];
378
+ if (v < min[k]) min[k] = v;
379
+ if (v > max[k]) max[k] = v;
380
+ }
381
+ }
348
382
 
349
- const charted = skin ? skin.charts.reduce((n, c) => n + (c ? 1 : 0), 0) : 0;
350
- const paint = skin ? { map: skinTexture(skin) } : { color: FLAT_COLOR };
351
- return finishVoxelMesh(geo, {
352
- nx,
353
- nz,
354
- s,
355
- ...paint,
356
- userData: {
357
- triangles: geo.index ? geo.index.count / 3 : repaired.length,
358
- wedges: wedges.length, // cells
359
- slopes,
360
- skin: skin ? { width: skin.width, height: skin.height, charts: charted } : null,
361
- },
362
- });
383
+ return {
384
+ geometry: { ...welded, bounds: { min, max } },
385
+ skin,
386
+ color: skin ? null : FLAT_COLOR,
387
+ triangles: welded.index.length / 3,
388
+ wedges: wedges.length, // cells
389
+ slopes,
390
+ charts: skin ? skin.charts.reduce((n, c) => n + (c ? 1 : 0), 0) : 0,
391
+ };
363
392
  }
package/src/weld.js ADDED
@@ -0,0 +1,56 @@
1
+ // Vertex weld for the mesher. Indexes flat triangle buffers, merging the
2
+ // vertices whose position, normal and uv agree. A component's key is its value
3
+ // scaled by ten thousand, offset by a half and truncated, the rule three's
4
+ // mergeVertices applies at its default tolerance, so the welded buffers match
5
+ // what it produced. Vertices keep first-seen order.
6
+
7
+ const SCALE = 1e4;
8
+ const key = (v) => ~~(v * SCALE + 0.5);
9
+
10
+ /**
11
+ * @param {Float32Array} position xyz per vertex, three vertices per triangle
12
+ * @param {Float32Array} normal xyz per vertex
13
+ * @param {Float32Array} uv uv per vertex
14
+ * @returns {{position:Float32Array, normal:Float32Array, uv:Float32Array, index:Uint32Array}}
15
+ * the unique vertices and a triangle index into them
16
+ */
17
+ export function weldVertices(position, normal, uv) {
18
+ const count = position.length / 3;
19
+ if (!Number.isInteger(count) || normal.length !== count * 3 || uv.length !== count * 2)
20
+ throw new Error('weldVertices: one normal and one uv per position');
21
+ const outPosition = new Float32Array(position.length);
22
+ const outNormal = new Float32Array(normal.length);
23
+ const outUv = new Float32Array(uv.length);
24
+ const index = new Uint32Array(count);
25
+ const seen = new Map();
26
+ let next = 0;
27
+ for (let i = 0; i < count; i++) {
28
+ const p = i * 3;
29
+ const t = i * 2;
30
+ const k =
31
+ `${key(position[p])},${key(position[p + 1])},${key(position[p + 2])},` +
32
+ `${key(normal[p])},${key(normal[p + 1])},${key(normal[p + 2])},` +
33
+ `${key(uv[t])},${key(uv[t + 1])}`;
34
+ let j = seen.get(k);
35
+ if (j === undefined) {
36
+ j = next++;
37
+ seen.set(k, j);
38
+ const q = j * 3;
39
+ outPosition[q] = position[p];
40
+ outPosition[q + 1] = position[p + 1];
41
+ outPosition[q + 2] = position[p + 2];
42
+ outNormal[q] = normal[p];
43
+ outNormal[q + 1] = normal[p + 1];
44
+ outNormal[q + 2] = normal[p + 2];
45
+ outUv[j * 2] = uv[t];
46
+ outUv[j * 2 + 1] = uv[t + 1];
47
+ }
48
+ index[i] = j;
49
+ }
50
+ return {
51
+ position: outPosition.slice(0, next * 3),
52
+ normal: outNormal.slice(0, next * 3),
53
+ uv: outUv.slice(0, next * 2),
54
+ index,
55
+ };
56
+ }
package/src/mesh-util.js DELETED
@@ -1,54 +0,0 @@
1
- // Mesh helpers for wedge-mesh.js: the skin as a THREE texture, and the final
2
- // framing and material.
3
-
4
- import * as THREE from 'three';
5
- import { unpackRGBA } from './ingest.js';
6
-
7
- /**
8
- * The skin as a DataTexture. The class defaults match the bake: nearest
9
- * filtering, no mipmaps, and flipY false so texel row 0 is v = 0. The bytes
10
- * are sRGB.
11
- * @param {import('./skin.js').Skin} skin
12
- * @returns {THREE.DataTexture}
13
- */
14
- export function skinTexture(skin) {
15
- const tex = new THREE.DataTexture(skin.data, skin.width, skin.height, THREE.RGBAFormat);
16
- tex.colorSpace = THREE.SRGBColorSpace;
17
- tex.needsUpdate = true;
18
- return tex;
19
- }
20
-
21
- /**
22
- * Final assembly: center the geometry on X and Z, leave Y as authored (see
23
- * carve.js), compute bounds, and wrap it in a flat-shaded material with
24
- * shadows on. The material takes the skin as `map`, or with no map a packed
25
- * sRGB `color`.
26
- * @param {THREE.BufferGeometry} geo
27
- * @param {{nx:number, nz:number, s:number, map?:THREE.Texture|null, color?:number|null,
28
- * userData?:Record<string,unknown>}} opts
29
- * @returns {THREE.Mesh}
30
- */
31
- export function finishVoxelMesh(
32
- geo,
33
- { nx, nz, s, map = null, color = null, userData = {} }
34
- ) {
35
- geo.translate((-nx * s) / 2, 0, (-nz * s) / 2);
36
- geo.computeBoundingBox();
37
- geo.computeBoundingSphere();
38
-
39
- const mat = new THREE.MeshStandardMaterial({
40
- flatShading: true,
41
- metalness: 0,
42
- roughness: 1,
43
- ...(map ? { map } : {}),
44
- });
45
- if (!map && color != null) {
46
- const { r, g, b } = unpackRGBA(color);
47
- mat.color.setRGB(r / 255, g / 255, b / 255, THREE.SRGBColorSpace);
48
- }
49
- const mesh = new THREE.Mesh(geo, mat);
50
- mesh.castShadow = true;
51
- mesh.receiveShadow = true;
52
- Object.assign(mesh.userData, userData);
53
- return mesh;
54
- }