sprite-machine 0.1.0 → 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/src/skin.js CHANGED
@@ -1,48 +1,25 @@
1
- // ---------------------------------------------------------------------------
2
- // The skin: the model's color as a TEXTURE, so the mesher can merge on
3
- // occupancy alone (regions.js). Pure — no THREE, no canvas — and Node-tested;
4
- // mesh-util.js turns the bytes into the DataTexture the material samples.
1
+ // The skin: the model's color as a texture, so the mesher can merge faces on
2
+ // occupancy alone (regions.js). mesh-util.js turns the bytes into a
3
+ // DataTexture.
5
4
  //
6
- // What is in it. A region whose pieces are NOT all one color becomes a
7
- // CHART: the region's bounding box as texels, one per cell, holding its
8
- // pieces' colors verbatim a face's, or a gable cap's wedge color — padded
9
- // by one texel on every side (the gutter) and with every texel the pieces do
10
- // not cover (the gutter, a hole, the box outside a diagonal edge) filled
11
- // from the NEAREST piece texel, a breadth-first flood from the pieces
12
- // outward: a fragment on the region's edge that rounds to the neighbouring
13
- // texel still reads its own color, and an importer with bilinear filtering
14
- // on gets no bleed. A region of one color — every one-color wall, every
15
- // solid cube's face — gets no chart: its triangles point at the SWATCH
16
- // STRIP, one 1×1 chart per distinct color (the palette, plus anything the
17
- // faces actually hold), sampled at the texel's center — one texel read at
18
- // its middle needs no gutter. A wedge's slope is one material by the gate,
19
- // so it points at a swatch too. The skin is therefore only the regions that
20
- // cross a color, plus the strip — a fraction of "every exposed face" — and
21
- // its size is bounded by the multi-color regions' boxes, never by the grid.
5
+ // A region with more than one color gets a chart: its bounding box at one
6
+ // texel per cell, padded by a one-texel gutter. Texels no piece covers (the
7
+ // gutter, holes, the box outside a diagonal edge) take the nearest piece's
8
+ // color by a breadth-first flood, so edge fragments and bilinear filtering
9
+ // read the region's own colors. A one-color region, and every wedge slope,
10
+ // samples a 1×1 swatch at its texel center. There is one swatch per color.
22
11
  //
23
- // Packing is a shelf packer, deterministic (the goldens depend on it): the
24
- // padded charts sorted by height then width, descending, laid left to right
25
- // on shelves, the swatches after them. The width starts at the smallest
26
- // power of two holding the widest padded chart (16 at least) and doubles
27
- // while the packed height, rounded up to a power of two, would exceed it —
28
- // no ceiling (a skin past a device's texture limit is a sprite the carve
29
- // could not have rebuilt live either). Power-of-two sides are not required
30
- // by three or by WebGL2; exporters and older engines are happier with them,
31
- // and it costs nothing here.
12
+ // Packing: padded charts sorted by height then width, descending, on
13
+ // left-to-right shelves, then the swatches. The width starts at the smallest
14
+ // power of two that fits the widest chart (at least 16) and doubles while the
15
+ // packed height, rounded up to a power of two, exceeds it.
32
16
  //
33
- // Orientation is stated ONCE: a chart's texel (i, j) is the cell at tangent
34
- // (a + i, b + j) of the region's box i along FACE_GEO[face].A, j along .B
35
- // and a vertex's UV is the same affine read of its lattice position
36
- // (uvOfLattice). There is no per-face flip table, so nothing can drift; if a
37
- // face ever renders mirrored the bug is in the corner-to-UV read, not the
38
- // bake. The texture's row 0 is v = 0 (DataTexture's flipY is false) — leave
39
- // it there.
17
+ // Orientation: chart texel (i, j) is the cell at tangent (a + i, b + j) of the
18
+ // region's box, i along FACE_GEO[face].A and j along .B. A vertex's UV is the
19
+ // same affine map of its lattice position (uvOfLattice). Texture row 0 is v = 0.
40
20
  //
41
- // Built from bytes: the packed colors are written straight into the RGBA
42
- // array. No 2D canvas, no getImageData, so a privacy browser's canvas farble
43
- // (the Helium bug, wedge-mesh.test.mjs) cannot touch it. Keep it that way:
44
- // no canvas in this file, ever.
45
- // ---------------------------------------------------------------------------
21
+ // The RGBA bytes are written directly. Do not use a canvas here: privacy
22
+ // browsers perturb getImageData.
46
23
 
47
24
  import { FACE_GEO } from './faces.js';
48
25
  import { unpackRGBA } from './ingest.js';
@@ -50,11 +27,11 @@ import { AXIS_INDEX } from './views.js';
50
27
 
51
28
  /**
52
29
  * @typedef {{u0:number, v0:number, w:number, h:number}} Chart
53
- * a charted region's texels, in texel coords, the gutter excluded.
30
+ * a chart's texel rect, excluding the gutter.
54
31
  * @typedef {{width:number, height:number, data:Uint8Array,
55
32
  * charts:(Chart|null)[], swatch:Map<number, {u:number, v:number}>}} Skin
56
- * charts[i] is regions[i]'s chart null where the region is one color (a
57
- * swatch); swatch maps a packed color to its 1×1 chart's texel.
33
+ * charts[i] is regions[i]'s chart, or null for a one-color region. swatch
34
+ * maps a packed color to its texel.
58
35
  */
59
36
 
60
37
  const GUTTER = 1;
@@ -66,9 +43,8 @@ const pow2ceil = (n) => {
66
43
  return p;
67
44
  };
68
45
 
69
- // Lay `items` (each {pw, ph}, padded sizes) on shelves of `width`, left to
70
- // right, a new shelf when the row is full. Every item is narrower than the
71
- // width by construction. Returns the packed height and each item's origin.
46
+ // Pack `items` ({pw, ph}, padded sizes) left to right on shelves of `width`.
47
+ // Every item fits the width. Returns the packed height and each item's origin.
72
48
  function shelfPack(items, width) {
73
49
  const at = new Array(items.length);
74
50
  let x = 0,
@@ -87,10 +63,9 @@ function shelfPack(items, width) {
87
63
  return { height: y + shelf, at };
88
64
  }
89
65
 
90
- // The padded box of a region as texel colors: the pieces' own, and every
91
- // other texel gutter, hole, the box beyond a diagonal the color of the
92
- // nearest piece texel, a multi-source breadth-first flood (deterministic:
93
- // the pieces seed in row order, the four neighbours in a fixed order).
66
+ // A region's padded box as texel colors. Uncovered texels take the nearest
67
+ // piece texel's color by a multi-source breadth-first flood. Seeds go in row
68
+ // order and neighbors in a fixed order, so the result is deterministic.
94
69
  function floodBox(region) {
95
70
  const { w, h, texels, present } = region;
96
71
  const W = w + 2 * GUTTER;
@@ -126,13 +101,12 @@ function floodBox(region) {
126
101
  * Bake the skin for a mesh: a chart per multi-color region, a swatch per color.
127
102
  * @param {import('./regions.js').Region2D[]} regions the base regions, in emit order
128
103
  * @param {number[]} colors colors to give a swatch (the build's palette);
129
- * every value the faces and the regions hold is unioned in, the guard for
130
- * a relaxed or dominant color the palette snap left off it
104
+ * every color the faces and regions hold is added too
131
105
  * @param {Map<number, number>} faceColor colorize's per-face colors, keyed idx*6 + f
132
106
  * @returns {Skin}
133
107
  */
134
108
  export function bakeSkin(regions, colors, faceColor) {
135
- // 1. The regions that chart: the ones not of one color.
109
+ // 1. Regions with more than one color get a chart.
136
110
  /** @type {{i:number, region:import('./regions.js').Region2D}[]} */
137
111
  const bodies = [];
138
112
  /** @type {(Chart|null)[]} */
@@ -141,8 +115,8 @@ export function bakeSkin(regions, colors, faceColor) {
141
115
  if (region.uniform === null) bodies.push({ i, region });
142
116
  });
143
117
 
144
- // 2. The swatch colors: the given palette, then anything the faces and the
145
- // regions' pieces hold (a cap's wedge color rides a region, never a face).
118
+ // 2. Swatch colors: the palette, then every color in the faces and the
119
+ // regions' pieces. A cap's wedge color appears only in a region.
146
120
  const seen = new Set();
147
121
  /** @type {number[]} */
148
122
  const swatchColors = [];
@@ -158,9 +132,8 @@ export function bakeSkin(regions, colors, faceColor) {
158
132
  for (let i = 0; i < region.present.length; i++)
159
133
  if (region.present[i]) addColor(region.texels[i]);
160
134
 
161
- // 3. Pack: padded charts by height then width, descending (the region's own
162
- // order the tiebreak, so the pack is a pure function of the input); the
163
- // swatches, 1×1 and unpadded, after them.
135
+ // 3. Pack padded charts by height then width, descending, ties in region
136
+ // order. The 1×1 unpadded swatches follow.
164
137
  /** @type {{pw:number, ph:number, body?:{i:number, region:import('./regions.js').Region2D}, color?:number}[]} */
165
138
  const items = bodies.map((body) => ({
166
139
  body,
@@ -181,7 +154,7 @@ export function bakeSkin(regions, colors, faceColor) {
181
154
  height = pow2ceil(packed.height);
182
155
  }
183
156
 
184
- // 4. Bake. Unused texels stay transparent black — nothing samples them.
157
+ // 4. Bake. Unused texels stay transparent black.
185
158
  const data = new Uint8Array(width * height * 4);
186
159
  const put = (x, y, c) => {
187
160
  const o = (y * width + x) * 4;
@@ -211,11 +184,9 @@ export function bakeSkin(regions, colors, faceColor) {
211
184
  }
212
185
 
213
186
  /**
214
- * The texel coordinates of a point on a charted region's plane: an affine
215
- * read of its position along the face's tangent axes from the region's box
216
- * origin, so a box corner lands on the chart's corner exactly, and a vertex
217
- * the T-junction repair inserted along an edge lands on the texel line
218
- * between two cells. Divide by the skin's width and height for the UV.
187
+ * The texel coordinates of a point on a charted region's plane: its offset
188
+ * along the face's tangent axes from the region's box origin, added to the
189
+ * chart's origin. Divide by the skin's width and height for the UV.
219
190
  * @param {Chart} chart
220
191
  * @param {{face:string, a:number, b:number}} region
221
192
  * @param {number[]} p a point [x, y, z] on the region's plane, in voxel units
@@ -230,7 +201,7 @@ export function uvOfLattice(chart, region, p) {
230
201
  }
231
202
 
232
203
  /**
233
- * The texel CENTER of a color's swatch, in texel coordinates.
204
+ * The texel center of a color's swatch, in texel coordinates.
234
205
  * @param {Skin} skin
235
206
  * @param {number} packed a packed RGBA color the skin holds a swatch for
236
207
  * @returns {[number, number]}
package/src/t-junction.js CHANGED
@@ -1,38 +1,21 @@
1
- // ---------------------------------------------------------------------------
2
1
  // T-junction elimination for lattice meshes.
3
2
  //
4
- // Greedy-merging faces into large rectangles introduces T-junctions: a vertex
5
- // that lands in the MIDDLE of a longer triangle's edge — e.g. where a merged
6
- // base rectangle meets a unit-scale wedge edge, or two faces merged to different
7
- // extents meet at an object corner. It's invisible for opaque flat-shaded
8
- // geometry, but it makes the surface NON-manifold (edges no longer paired),
9
- // which breaks the watertight weld and clean glTF export.
3
+ // Merged faces leave T-junctions: a vertex in the middle of another triangle's
4
+ // edge. They make the surface non-manifold, which breaks the watertight weld
5
+ // and glTF export. Every vertex is on the integer lattice, so the repair is
6
+ // exact: split each triangle edge at every mesh vertex strictly inside it, then
7
+ // re-triangulate the convex result with its own vertices.
10
8
  //
11
- // Every vertex here sits on the integer lattice, so repair is exact — no
12
- // floating-point tolerance. Collect all vertex positions, then split each
13
- // triangle edge at any vertex lying strictly on its interior, re-triangulating
14
- // the (convex) result with lattice-only vertices. Pure integer geometry: no
15
- // THREE, Node-testable.
9
+ // Only axis-aligned and 45° diagonal edges can carry interior vertices. Any
10
+ // other edge is a triangulation chord across a region or slope, where a valid
11
+ // surface has no vertex, so it is skipped.
16
12
  //
17
- // Two kinds of edge can carry interior lattice points here: an AXIS-ALIGNED
18
- // one (a region polygon's, a slope's ridge edge) and a 45° DIAGONAL (a slope
19
- // block's staircase edge, a region's cut beside it — both long since the
20
- // merges of Sep 7 2026). Any other segment is a triangulation chord across a
21
- // region's or a slope's interior, where a valid surface never has a vertex,
22
- // so it is left alone.
23
- //
24
- // A triangle is an opaque record beyond its three vertices: every other field
25
- // (the normal, and the mesher's paint — a chart and its rect, or a swatch
26
- // color) is copied onto each piece a split produces, so the repair never has
27
- // to know what rides on a triangle. UVs are NOT carried through here: they
28
- // are a function of position (skin.js uvOfLattice), read after the repair.
29
- // ---------------------------------------------------------------------------
13
+ // UVs are computed from position after the repair (skin.js uvOfLattice).
30
14
 
31
15
  const key = (p) => p[0] + ',' + p[1] + ',' + p[2];
32
16
 
33
- // Integer lattice points strictly interior to segment p->q that are in `vset`,
34
- // ordered p->q. Returns [] unless p->q is axis-aligned or a 45° diagonal (two
35
- // axes stepping by the same magnitude).
17
+ // Vertices in `vset` strictly inside segment p->q, ordered p->q. Returns []
18
+ // unless p->q is axis-aligned or a 45° diagonal.
36
19
  function interiorPointsOnEdge(p, q, vset) {
37
20
  const d = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
38
21
  const axes = [];
@@ -49,10 +32,10 @@ function interiorPointsOnEdge(p, q, vset) {
49
32
  return out;
50
33
  }
51
34
 
52
- // Triangulate a convex polygon (wound CCW wrt `normal`) using only its own
53
- // vertices, tolerating collinear boundary points. Clip only a strictly-convex
54
- // corner whose EAR EDGE (prev->next) carries no other vertex otherwise the ear
55
- // would span a subdivided edge and re-introduce the T-junction we're removing.
35
+ // Triangulate a convex polygon, wound CCW about `normal`, using only its own
36
+ // vertices. Collinear boundary points are allowed. Only a strictly convex
37
+ // corner whose edge prev->next contains no other vertex is clipped. Otherwise
38
+ // the ear would span a split edge and reintroduce a T-junction.
56
39
  function triangulateConvex(ring, normal, emit) {
57
40
  const turn = (o, a, b) => {
58
41
  const ux = a[0] - o[0],
@@ -67,7 +50,7 @@ function triangulateConvex(ring, normal, emit) {
67
50
  (ux * vy - uy * vx) * normal[2]
68
51
  );
69
52
  };
70
- // v strictly interior to segment p->q (collinear + between the endpoints).
53
+ // v lies strictly inside segment p->q.
71
54
  const onSeg = (p, q, v) => {
72
55
  const dx = q[0] - p[0],
73
56
  dy = q[1] - p[1],
@@ -104,8 +87,8 @@ function triangulateConvex(ring, normal, emit) {
104
87
  break;
105
88
  }
106
89
  if (!clipped) {
107
- // Defensive: fan from a strictly-convex vertex (clean-ear clipping should
108
- // always progress for a convex polygon, so this is a safety net only).
90
+ // Fallback: fan from a strictly convex vertex. Ear clipping should always
91
+ // progress on a convex polygon.
109
92
  let ai = 0;
110
93
  for (let i = 0; i < poly.length; i++) {
111
94
  const prev = poly[(i - 1 + poly.length) % poly.length];
@@ -130,11 +113,10 @@ function triangulateConvex(ring, normal, emit) {
130
113
  /**
131
114
  * @template {{a:number[], b:number[], c:number[], normal:number[]}} T
132
115
  * @param {T[]} tris
133
- * triangles with INTEGER-lattice vertex coords, wound CCW wrt `normal`.
116
+ * triangles with integer lattice vertices, wound CCW about `normal`.
134
117
  * @returns {T[]}
135
- * an equivalent surface with no T-junctions (every edge split at interior
136
- * verts); a split triangle's pieces carry every field of their source but
137
- * the three vertices.
118
+ * the same surface with no T-junctions. Pieces of a split triangle keep its
119
+ * other fields.
138
120
  */
139
121
  export function eliminateTJunctions(tris) {
140
122
  const vset = new Set();
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/views.js CHANGED
@@ -1,30 +1,20 @@
1
- // ---------------------------------------------------------------------------
2
- // View conventions and projection mappings.
1
+ // View conventions and projections.
3
2
  //
4
- // World frame: +x = right, +y = up, +z = toward the front (camera).
5
- // A "view" is an orthographic face render. Each view has:
6
- // - normal: the world-space outward normal of the face it observes.
7
- // - axis: the world axis it looks ALONG (the depth / extrusion axis).
8
- // - project(x, y, z, dims) -> {u, v}: which pixel of the view a voxel maps to.
9
- // u,v are image coords with (0,0) = top-left, v growing DOWN.
3
+ // World frame: +x right, +y up, +z toward the front (camera).
4
+ // A view is an orthographic render of one face. project(x, y, z, dims) -> {u, v}
5
+ // gives the view pixel for a voxel, with (0, 0) at top-left and v growing down.
10
6
  //
11
- // The image width/height of a view are tied to two of the grid dims:
12
- // FRONT/BACK : image is (nx wide, ny tall) -> sees the X/Y plane
13
- // LEFT/RIGHT : image is (nz wide, ny tall) -> sees the Z/Y plane
14
- // TOP/BOTTOM : image is (nx wide, nz tall) -> sees the X/Z plane
7
+ // View image sizes in grid dims:
8
+ // FRONT/BACK : nx wide, ny tall (X/Y plane)
9
+ // LEFT/RIGHT : nz wide, ny tall (Z/Y plane)
10
+ // TOP/BOTTOM : nx wide, nz tall (X/Z plane)
15
11
  //
16
- // These six mappings are set-and-forget: identical for every model. They were
17
- // chosen so that, standing at the camera and looking at each face, the sprite's
18
- // pixel (col,row) lands where a human artist expects (art drawn upright and
19
- // left-to-right as seen from outside the object).
20
- // ---------------------------------------------------------------------------
12
+ // The mappings are the same for every model. Art drawn upright and left to
13
+ // right, as seen from outside the object, appears that way on the model.
21
14
 
22
15
  /** @typedef {{nx:number, ny:number, nz:number}} Dims */
23
16
 
24
- // Human-facing view names -> face normals. Its inverse
25
- // FACE_TO_VIEW is consumed by colorize, VIEW_TO_FACE by the edge hints (which
26
- // need a view's own face to know which way it looks); every pair is already
27
- // implied by the face metadata below.
17
+ // View name -> the face key it colors, and the inverse.
28
18
  export const VIEW_TO_FACE = {
29
19
  right: 'nx',
30
20
  left: 'px',
@@ -37,10 +27,8 @@ export const FACE_TO_VIEW = Object.fromEntries(
37
27
  Object.entries(VIEW_TO_FACE).map(([k, v]) => [v, k])
38
28
  );
39
29
 
40
- // Outward unit normals per face key. The single source of truth for per-face
41
- // axis/direction FACE_AXIS, carve's NEIGHBORS, and faces.js's quad normals are
42
- // all derived from this so they can't drift from the convention the 6-bit surface
43
- // mask and the faceColor keying (idx*6+f) depend on.
30
+ // Outward unit normal per face key. FACE_AXIS, carve's NEIGHBORS and faces.js
31
+ // derive from it.
44
32
  export const FACE_NORMAL = {
45
33
  px: [1, 0, 0],
46
34
  nx: [-1, 0, 0],
@@ -50,29 +38,26 @@ export const FACE_NORMAL = {
50
38
  nz: [0, 0, -1],
51
39
  };
52
40
 
53
- // Canonical face-key order: the 6-bit surface-exposure mask and the faceColor map
54
- // (keyed idx*6+f) both index by this position, so it is load-bearing. Co-located
55
- // with FACE_NORMAL; carve.js re-exports it for the consumers that read it there.
41
+ // Face-key order. The 6-bit surface mask and the faceColor keys (idx*6 + f)
42
+ // index by position, so the order must not change. carve.js re-exports it.
56
43
  export const FACE_KEYS = ['px', 'nx', 'py', 'ny', 'pz', 'nz'];
57
44
 
58
- // Face key -> its index in FACE_KEYS (memoized indexOf).
45
+ // Face key -> index in FACE_KEYS.
59
46
  export const FACE_INDEX = Object.fromEntries(FACE_KEYS.map((k, i) => [k, i]));
60
47
 
61
- // World-axis name -> its index in an [x, y, z] triple.
48
+ // Axis name -> index in an [x, y, z] triple.
62
49
  export const AXIS_INDEX = { x: 0, y: 1, z: 2 };
63
50
 
64
51
  // The face key whose outward normal points along world `axis` with `sign` (±1).
65
- // Derived from FACE_NORMAL so an (axis, sign) pair can never drift from the normals.
66
52
  export const faceKeyOf = (axis, sign) =>
67
53
  FACE_KEYS.find((k) => FACE_NORMAL[k][AXIS_INDEX[axis]] === sign);
68
54
 
69
- // Which world axis each face's outward normal lies on. Derived from
70
- // FACE_NORMAL so it can't drift; used by colorize for mirror-fill.
55
+ // The world axis of each face's normal. colorize uses it for mirror-fill.
71
56
  export const FACE_AXIS = Object.fromEntries(
72
57
  Object.entries(FACE_NORMAL).map(([k, n]) => [k, n[0] ? 'x' : n[1] ? 'y' : 'z'])
73
58
  );
74
59
 
75
- // Opposite face (for mirror-fill).
60
+ // Opposite face, for mirror-fill.
76
61
  export const FACE_OPPOSITE = {
77
62
  px: 'nx',
78
63
  nx: 'px',
@@ -82,18 +67,11 @@ export const FACE_OPPOSITE = {
82
67
  nz: 'pz',
83
68
  };
84
69
 
85
- // For each face/view: the image dimensions in grid units and the pixel projection.
86
- // The march axis + direction used for first-hit visibility is NOT stored here — it
87
- // is derived from FACE_NORMAL in colorize.firstHitFromFace, the single source of
88
- // truth (a `step`/`from` field here would be a silent drift hazard).
89
- //
90
- // projectInto(x,y,z,d,out) writes integer image coords into the reused `out` (no
91
- // per-voxel allocation in carve's hot triple loop); project() is the allocating
92
- // convenience that delegates to it, so each view has exactly ONE formula. `imgW`/
93
- // `imgH` give the expected view image size for a grid, so carve can place each view
94
- // at native scale (padding, never stretching) and index it 1:1.
70
+ // Per view: the image size for a grid (imgW, imgH) and the pixel projection.
71
+ // projectInto writes into a reused `out` so carve's per-voxel loop does not
72
+ // allocate. project allocates and delegates to it.
95
73
  export const VIEWS = {
96
- // FRONT: looks toward -z from +z. Sees +z face. Image = X (right) by Y (up).
74
+ // FRONT: looks toward -z and sees the +z face. Image is X by Y.
97
75
  front: {
98
76
  imgW: (d) => d.nx,
99
77
  imgH: (d) => d.ny,
@@ -102,7 +80,7 @@ export const VIEWS = {
102
80
  return this.projectInto(x, y, z, d, { u: 0, v: 0 });
103
81
  },
104
82
  },
105
- // BACK: looks toward +z from -z. Sees -z face. Left-right mirrored vs front.
83
+ // BACK: looks toward +z and sees the -z face. Mirrored left to right from FRONT.
106
84
  back: {
107
85
  imgW: (d) => d.nx,
108
86
  imgH: (d) => d.ny,
@@ -111,11 +89,9 @@ export const VIEWS = {
111
89
  return this.projectInto(x, y, z, d, { u: 0, v: 0 });
112
90
  },
113
91
  },
114
- // LEFT: the atlas tile drawn as the object's LEFT side. It colors the +x face
115
- // viewed straight-on from +x that face reads as a left-side profile, so the
116
- // *tile* is named by how it reads (a deliberate labeling choice; see README),
117
- // not by the world axis it happens to occupy. Image = Z by Y. u = nz-1-z puts
118
- // the object's front (+z) at the left column, matching a nose-left profile.
92
+ // LEFT: the tile for the object's left side. It colors the +x face, which
93
+ // seen from +x reads as a left-side profile (see README). Image is Z by Y.
94
+ // u = nz-1-z puts the front (+z) in the left column.
119
95
  left: {
120
96
  imgW: (d) => d.nz,
121
97
  imgH: (d) => d.ny,
@@ -124,8 +100,8 @@ export const VIEWS = {
124
100
  return this.projectInto(x, y, z, d, { u: 0, v: 0 });
125
101
  },
126
102
  },
127
- // RIGHT: the object's RIGHT side; colors the -x face, which reads as a
128
- // right-side profile. Mirror of left along z — u = z puts front at the right column.
103
+ // RIGHT: the object's right side. It colors the -x face. u = z puts the front
104
+ // in the right column.
129
105
  right: {
130
106
  imgW: (d) => d.nz,
131
107
  imgH: (d) => d.ny,
@@ -134,9 +110,8 @@ export const VIEWS = {
134
110
  return this.projectInto(x, y, z, d, { u: 0, v: 0 });
135
111
  },
136
112
  },
137
- // TOP: looks toward -y from +y. Sees +y face. Image = X by Z.
138
- // Looking straight down: v = nz-1-z puts the object's front (+z, z=nz-1) on
139
- // the TOP row of the image (v=0), matching the FRONT view's top-is-v=0.
113
+ // TOP: looks toward -y and sees the +y face. Image is X by Z.
114
+ // v = nz-1-z puts the front (+z) on the top row.
140
115
  top: {
141
116
  imgW: (d) => d.nx,
142
117
  imgH: (d) => d.nz,
@@ -145,10 +120,9 @@ export const VIEWS = {
145
120
  return this.projectInto(x, y, z, d, { u: 0, v: 0 });
146
121
  },
147
122
  },
148
- // BOTTOM: looks toward +y from -y. Sees -y face. The car is flipped SIDEWAYS
149
- // (rolled about its front-back axis), NOT end-over-end — so the front stays on
150
- // the TOP row like TOP (v = nz-1-z) and only left/right swap (u = nx-1-x). That
151
- // way the TOP and BOTTOM tiles register front-to-front on the same edge.
123
+ // BOTTOM: looks toward +y and sees the -y face. The object is rolled about its
124
+ // front-back axis, so the front stays on the top row as in TOP (v = nz-1-z)
125
+ // and only u flips (u = nx-1-x).
152
126
  bottom: {
153
127
  imgW: (d) => d.nx,
154
128
  imgH: (d) => d.nz,
@@ -161,19 +135,11 @@ export const VIEWS = {
161
135
 
162
136
  export const VIEW_NAMES = Object.keys(VIEWS);
163
137
 
164
- // The six view names in atlas-sheet (row-major) order, matching atlas.js
165
- // DEFAULT_ATLAS_LAYOUT (LEFT FRONT TOP / RIGHT BACK BOTTOM). A pinned convention
166
- // (a test asserts it equals the layout) — there is no on-screen faces-preview
167
- // grid; the editor switches faces with text tabs.
138
+ // View names in atlas row-major order, matching atlas.js DEFAULT_ATLAS_LAYOUT.
168
139
  export const VIEW_DISPLAY_ORDER = ['left', 'front', 'top', 'right', 'back', 'bottom'];
169
140
 
170
- // Which image edge of a view's tile the object's FRONT (+z, the "nose") points
171
- // toward. A projection-convention pin (a test checks it against VIEWS' projections)
172
- // and the reference behind the README's per-face "Front points" column. Derived
173
- // meaning: in LEFT, front (z=nz-1) maps to u=0, the left column; TOP and BOTTOM
174
- // both put the front on their TOP edge (BOTTOM is the sideways flip of TOP);
175
- // FRONT/BACK look straight down +z/-z, so their nose points out of / into the
176
- // screen — there is no in-plane front edge (null).
141
+ // The edge of each view's tile that the object's front (+z) points toward.
142
+ // FRONT and BACK look along z, so they have none (null).
177
143
  export const VIEW_FRONT_EDGE = {
178
144
  right: 'right',
179
145
  left: 'left',
@@ -183,7 +149,7 @@ export const VIEW_FRONT_EDGE = {
183
149
  back: null,
184
150
  };
185
151
 
186
- // Each view's opposite (the mirror-fill source when a view has no art of its own).
152
+ // Each view's opposite, the mirror-fill source for a view with no art.
187
153
  export const VIEW_OPPOSITE = {
188
154
  right: 'left',
189
155
  left: 'right',
@@ -193,16 +159,13 @@ export const VIEW_OPPOSITE = {
193
159
  bottom: 'top',
194
160
  };
195
161
 
196
- // To DISPLAY a mirror-derived face, flip its opposite view's tile along this
197
- // IMAGE axis. It is 'x' for EVERY pair (a single constant, not a per-view table
198
- // that would imply the axis varies): the projections make each pair mirror
199
- // HORIZONTALLY — left↔right and front↔back on the X/Z planes, and top↔bottom too
200
- // because BOTTOM is the sideways (left/right) flip of TOP, not an end-over-end one.
201
- // (mirrorImage's 'y' branch in derive.js is therefore unexercised in practice.)
162
+ // The image axis along which an opposite view's tile is flipped to display a
163
+ // mirror-derived face. Every pair mirrors horizontally, top and bottom included,
164
+ // because BOTTOM is TOP rolled sideways.
202
165
  export const MIRROR_AXIS = 'x';
203
166
 
204
- // Which grid axes a view's (imgW, imgH) constrain. Used by dimension
205
- // reconciliation. Each entry: [axisForImgW, axisForImgH].
167
+ // The grid dims a view's image constrains: [axis for imgW, axis for imgH].
168
+ // Used by dimension reconciliation.
206
169
  export const VIEW_AXES = {
207
170
  front: ['nx', 'ny'],
208
171
  back: ['nx', 'ny'],
@@ -212,12 +175,10 @@ export const VIEW_AXES = {
212
175
  bottom: ['nx', 'nz'],
213
176
  };
214
177
 
215
- // For each view: which world axis its image COLUMNS (u) and ROWS (v) run along,
216
- // and whether the image index runs the SAME direction as the world coordinate
217
- // (flip:false) or the OPPOSITE (flip:true). Probed from project() at load so it
218
- // can never drift from the projections above (test/views.test.mjs pins it).
219
- // Consumed by the sheet resize (atlas.js resizeAtlas) to place each
220
- // face's tile so every face sharing a world axis shifts identically.
178
+ // Per view: the axis its image columns (u) and rows (v) run along, and whether
179
+ // the image index runs against the world coordinate (flip). Probed from
180
+ // project() at load. atlas.js resizeAtlas uses it so faces that share an axis
181
+ // shift together.
221
182
  const AXIS_ARG = { nx: 0, ny: 1, nz: 2 };
222
183
  function probeFlip(spec, axisName, which) {
223
184
  const d = { nx: 2, ny: 2, nz: 2 };