@flighthq/mesh 0.3.0-next.906.07cea63 → 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.
package/package.json CHANGED
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "name": "@flighthq/mesh",
3
- "version": "0.3.0-next.906.07cea63",
3
+ "version": "0.3.0",
4
+ "author": "Joshua Granick and other contributors",
5
+ "license": "MIT",
4
6
  "repository": {
5
7
  "type": "git",
6
8
  "url": "https://github.com/flighthq/flight.git",
@@ -36,9 +38,9 @@
36
38
  "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
37
39
  },
38
40
  "dependencies": {
39
- "@flighthq/entity": "0.3.0-next.906.07cea63",
40
- "@flighthq/geometry": "0.3.0-next.906.07cea63",
41
- "@flighthq/types": "0.3.0-next.906.07cea63"
41
+ "@flighthq/entity": "0.3.0",
42
+ "@flighthq/geometry": "0.3.0",
43
+ "@flighthq/types": "0.3.0"
42
44
  },
43
45
  "devDependencies": {
44
46
  "typescript": "^5.3.0"
@@ -16,6 +16,7 @@ import {
16
16
  getMeshGeometrySkinBindPose,
17
17
  getMeshGeometryVertexCount,
18
18
  hasMeshGeometrySkin,
19
+ invalidateMeshGeometry,
19
20
  setMeshGeometryMorphBindPose,
20
21
  setMeshGeometrySkinBindPose,
21
22
  } from './meshGeometry';
@@ -67,6 +68,17 @@ describe('cloneMeshGeometry', () => {
67
68
  expect(clone.bounds!.min.x).toBe(-1);
68
69
  expect(clone.bounds!.max.z).toBe(1);
69
70
  });
71
+
72
+ it('copies tangent-split smoothing identity without sharing its runtime array', () => {
73
+ const source = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: makeVertices(2) });
74
+ (source[EntityRuntimeKey] as MeshGeometryRuntime).tangentSmoothingSources = new Uint32Array([0, 0]);
75
+
76
+ const clone = cloneMeshGeometry(source);
77
+
78
+ const clonedSources = (clone[EntityRuntimeKey] as MeshGeometryRuntime).tangentSmoothingSources;
79
+ expect(Array.from(clonedSources!)).toEqual([0, 0]);
80
+ expect(clonedSources).not.toBe((source[EntityRuntimeKey] as MeshGeometryRuntime).tangentSmoothingSources);
81
+ });
70
82
  });
71
83
 
72
84
  describe('createMeshGeometry', () => {
@@ -118,6 +130,7 @@ describe('createMeshGeometry', () => {
118
130
  const runtime = geometry[EntityRuntimeKey] as MeshGeometryRuntime;
119
131
  expect(runtime.webglData).toBeNull();
120
132
  expect(runtime.webgpuData).toBeNull();
133
+ expect(runtime.tangentSmoothingSources).toBeNull();
121
134
  });
122
135
  });
123
136
 
@@ -196,6 +209,18 @@ describe('hasMeshGeometrySkin', () => {
196
209
  });
197
210
  });
198
211
 
212
+ describe('invalidateMeshGeometry', () => {
213
+ it('advances the shared upload revision as a wrapping u32', () => {
214
+ const geometry = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: makeVertices(1) });
215
+ invalidateMeshGeometry(geometry);
216
+ expect(geometry.version).toBe(1);
217
+
218
+ geometry.version = 0xffffffff;
219
+ invalidateMeshGeometry(geometry);
220
+ expect(geometry.version).toBe(0);
221
+ });
222
+ });
223
+
199
224
  describe('setMeshGeometryMorphBindPose', () => {
200
225
  it('stores and clears the morph base pose on the runtime slot', () => {
201
226
  const geometry = createMeshGeometry({ layout: CANONICAL_LAYOUT, vertices: makeVertices(3) });
@@ -1,7 +1,7 @@
1
1
  import { createAabb, createBoundingSphere } from '@flighthq/geometry/contract';
2
2
  import type { VertexAttributeLayout } from '@flighthq/types/contract';
3
3
 
4
- import { createMeshGeometry } from './meshGeometry';
4
+ import { createMeshGeometry, invalidateMeshGeometry } from './meshGeometry';
5
5
  import {
6
6
  computeMeshGeometryBoundingSphere,
7
7
  computeMeshGeometryBounds,
@@ -11,6 +11,7 @@ import {
11
11
  ensureMeshGeometryBounds,
12
12
  refreshMeshGeometryBounds,
13
13
  } from './meshGeometryCompute';
14
+ import { CANONICAL_SKINNED_MESH_GEOMETRY_LAYOUT } from './meshGeometryLayout';
14
15
 
15
16
  const CANONICAL_LAYOUT: VertexAttributeLayout = {
16
17
  attributes: [
@@ -190,6 +191,8 @@ describe('computeMeshGeometryTangents', () => {
190
191
  it('writes a unit tangent aligned with +X for the canonical UV mapping', () => {
191
192
  const geometry = makeTriangle();
192
193
  computeMeshGeometryNormals(geometry, geometry);
194
+ const originalVertices = geometry.vertices;
195
+ const originalIndices = geometry.indices;
193
196
  computeMeshGeometryTangents(geometry, geometry);
194
197
  // u increases along +X, so tangent.xyz ~ (1,0,0).
195
198
  expect(geometry.vertices[6]).toBeCloseTo(1);
@@ -197,6 +200,9 @@ describe('computeMeshGeometryTangents', () => {
197
200
  expect(geometry.vertices[8]).toBeCloseTo(0);
198
201
  // Right-handed mapping => positive handedness.
199
202
  expect(geometry.vertices[9]).toBe(1);
203
+ // The common single-handed case writes in place without replacing either payload buffer.
204
+ expect(geometry.vertices).toBe(originalVertices);
205
+ expect(geometry.indices).toBe(originalIndices);
200
206
  });
201
207
 
202
208
  it('writes into a distinct out geometry', () => {
@@ -217,8 +223,190 @@ describe('computeMeshGeometryTangents', () => {
217
223
  computeMeshGeometryTangents(geometry, geometry);
218
224
  expect(geometry.vertices[9]).toBe(-1);
219
225
  });
226
+
227
+ it('splits shared vertices at a mirrored-UV handedness boundary', () => {
228
+ // Two CCW triangles share vertices 0/2. Their UV determinants have opposite signs, so a single
229
+ // tangent.w cannot describe both triangles: the shared records must be duplicated and remapped.
230
+ const vertices = new Float32Array(4 * 12);
231
+ setCanonicalVertex(vertices, 0, 0, 0, 0, 0);
232
+ setCanonicalVertex(vertices, 1, 1, 0, 1, 0);
233
+ setCanonicalVertex(vertices, 2, 0, 1, 0, 1);
234
+ setCanonicalVertex(vertices, 3, -1, 0, 1, 0);
235
+ const geometry = createMeshGeometry({
236
+ indices: new Uint16Array([0, 1, 2, 0, 2, 3]),
237
+ layout: CANONICAL_LAYOUT,
238
+ vertices,
239
+ });
240
+ computeMeshGeometryNormals(geometry, geometry);
241
+ const previousVersion = geometry.version;
242
+
243
+ computeMeshGeometryTangents(geometry, geometry);
244
+
245
+ expect(geometry.vertices.length / 12).toBe(6);
246
+ expect(geometry.version).toBe(previousVersion + 1);
247
+ const indices = geometry.indices!;
248
+ const firstSign = geometry.vertices[indices[0] * 12 + 9];
249
+ const mirroredSign = geometry.vertices[indices[3] * 12 + 9];
250
+ expect(firstSign).toBe(1);
251
+ expect(mirroredSign).toBe(-1);
252
+ for (let corner = 0; corner < 3; corner++) {
253
+ expect(geometry.vertices[indices[corner] * 12 + 9]).toBe(firstSign);
254
+ expect(geometry.vertices[indices[corner + 3] * 12 + 9]).toBe(mirroredSign);
255
+ }
256
+ // Both shared corners moved on the mirrored triangle; the positive triangle kept the originals.
257
+ expect(indices[0]).toBe(0);
258
+ expect(indices[2]).toBe(2);
259
+ expect(indices[3]).toBeGreaterThanOrEqual(4);
260
+ expect(indices[4]).toBeGreaterThanOrEqual(4);
261
+ });
262
+
263
+ it('splits same-handed corners whose tangent directions cannot share a stable frame', () => {
264
+ // Both triangles are CCW and both UV determinants are positive, but their U axes point in
265
+ // opposite model-space directions at shared vertex 0. Handedness-only reconciliation leaves the
266
+ // shared tangent cancelled or dominated by one face; Flight must create a second complete record.
267
+ const vertices = new Float32Array(5 * 12);
268
+ setCanonicalVertex(vertices, 0, 0, 0, 0, 0);
269
+ setCanonicalVertex(vertices, 1, 1, 0, 1, 0);
270
+ setCanonicalVertex(vertices, 2, 0, 1, 0, 1);
271
+ setCanonicalVertex(vertices, 3, -1, 0, 1, 0);
272
+ setCanonicalVertex(vertices, 4, 0, -1, 0, 1);
273
+ const geometry = createMeshGeometry({
274
+ indices: new Uint16Array([0, 1, 2, 0, 3, 4]),
275
+ layout: CANONICAL_LAYOUT,
276
+ vertices,
277
+ });
278
+ computeMeshGeometryNormals(geometry, geometry);
279
+
280
+ computeMeshGeometryTangents(geometry, geometry);
281
+
282
+ expect(geometry.vertices.length / 12).toBe(6);
283
+ const indices = geometry.indices!;
284
+ expect(indices[0]).not.toBe(indices[3]);
285
+ expect(geometry.vertices[indices[0] * 12 + 6]).toBeGreaterThan(0.9);
286
+ expect(geometry.vertices[indices[3] * 12 + 6]).toBeLessThan(-0.9);
287
+ // This is a directional split within one handedness, not a mirror split.
288
+ expect(geometry.vertices[indices[0] * 12 + 9]).toBe(1);
289
+ expect(geometry.vertices[indices[3] * 12 + 9]).toBe(1);
290
+
291
+ // The reference MD5 demo historically repeated both authoring calls after import. Repeating them
292
+ // must neither grow topology again nor turn the Flight-created tangent split into a hard normal seam.
293
+ computeMeshGeometryNormals(geometry, geometry);
294
+ computeMeshGeometryTangents(geometry, geometry);
295
+ expect(geometry.vertices.length / 12).toBe(6);
296
+ expect(geometry.vertices[indices[0] * 12 + 3]).toBeCloseTo(geometry.vertices[indices[3] * 12 + 3]);
297
+ expect(geometry.vertices[indices[0] * 12 + 4]).toBeCloseTo(geometry.vertices[indices[3] * 12 + 4]);
298
+ expect(geometry.vertices[indices[0] * 12 + 5]).toBeCloseTo(geometry.vertices[indices[3] * 12 + 5]);
299
+ });
300
+
301
+ it('keeps smooth normals shared across a folded mirrored tangent seam', () => {
302
+ const vertices = new Float32Array(4 * 12);
303
+ setCanonicalVertex(vertices, 0, 0, 0, 0, 0);
304
+ setCanonicalVertex(vertices, 1, 1, 0, 1, 0);
305
+ setCanonicalVertex(vertices, 2, 0, 1, 0, 1);
306
+ setCanonicalVertex(vertices, 3, -1, 0, 1, 0);
307
+ vertices[3 * 12 + 2] = 1;
308
+ const geometry = createMeshGeometry({
309
+ indices: new Uint16Array([0, 1, 2, 0, 2, 3]),
310
+ layout: CANONICAL_LAYOUT,
311
+ vertices,
312
+ });
313
+ computeMeshGeometryNormals(geometry, geometry);
314
+ const originalNormal = Array.from(geometry.vertices.subarray(3, 6));
315
+ computeMeshGeometryTangents(geometry, geometry);
316
+ const indices = geometry.indices!;
317
+ expect(indices[0]).not.toBe(indices[3]);
318
+
319
+ computeMeshGeometryNormals(geometry, geometry);
320
+
321
+ for (const vertex of [indices[0], indices[3]]) {
322
+ expect(geometry.vertices[vertex * 12 + 3]).toBeCloseTo(originalNormal[0]);
323
+ expect(geometry.vertices[vertex * 12 + 4]).toBeCloseTo(originalNormal[1]);
324
+ expect(geometry.vertices[vertex * 12 + 5]).toBeCloseTo(originalNormal[2]);
325
+ }
326
+ });
327
+
328
+ it('copies the complete skinned record when splitting a mirrored tangent seam', () => {
329
+ const stride = CANONICAL_SKINNED_MESH_GEOMETRY_LAYOUT.stride / 4;
330
+ const vertices = new Float32Array(4 * stride);
331
+ setSkinnedVertex(vertices, stride, 0, 0, 0, 0, 0, 10);
332
+ setSkinnedVertex(vertices, stride, 1, 1, 0, 1, 0, 20);
333
+ setSkinnedVertex(vertices, stride, 2, 0, 1, 0, 1, 30);
334
+ setSkinnedVertex(vertices, stride, 3, -1, 0, 1, 0, 40);
335
+ const geometry = createMeshGeometry({
336
+ indices: new Uint16Array([0, 1, 2, 0, 2, 3]),
337
+ layout: CANONICAL_SKINNED_MESH_GEOMETRY_LAYOUT,
338
+ vertices,
339
+ });
340
+ computeMeshGeometryNormals(geometry, geometry);
341
+
342
+ computeMeshGeometryTangents(geometry, geometry);
343
+
344
+ const split = geometry.indices![3];
345
+ expect(split).toBeGreaterThanOrEqual(4);
346
+ // The split for source vertex 0 retains its joints0 and weights0 exactly. These channels are what
347
+ // make a topologically repaired MD5 vertex continue following the same skeleton influences.
348
+ expect(Array.from(geometry.vertices.subarray(split * stride + 12, split * stride + 16))).toEqual([10, 11, 12, 13]);
349
+ expect(geometry.vertices[split * stride + 16]).toBeCloseTo(0.1);
350
+ expect(geometry.vertices[split * stride + 17]).toBeCloseTo(0.2);
351
+ expect(geometry.vertices[split * stride + 18]).toBeCloseTo(0.3);
352
+ expect(geometry.vertices[split * stride + 19]).toBeCloseTo(0.4);
353
+ });
354
+
355
+ it('uses a unit perpendicular fallback for degenerate UVs', () => {
356
+ const geometry = makeTriangle();
357
+ for (let vertex = 0; vertex < 3; vertex++) {
358
+ const base = vertex * 12;
359
+ geometry.vertices[base + 3] = 1;
360
+ geometry.vertices[base + 4] = 0;
361
+ geometry.vertices[base + 5] = 0;
362
+ geometry.vertices[base + 10] = 0;
363
+ geometry.vertices[base + 11] = 0;
364
+ }
365
+
366
+ computeMeshGeometryTangents(geometry, geometry);
367
+
368
+ const tx = geometry.vertices[6];
369
+ const ty = geometry.vertices[7];
370
+ const tz = geometry.vertices[8];
371
+ expect(Math.hypot(tx, ty, tz)).toBeCloseTo(1);
372
+ expect(tx).toBeCloseTo(0);
373
+ expect(Math.abs(geometry.vertices[9])).toBe(1);
374
+ });
220
375
  });
221
376
 
377
+ function setCanonicalVertex(vertices: Float32Array, vertex: number, x: number, y: number, u: number, v: number): void {
378
+ const base = vertex * 12;
379
+ vertices[base] = x;
380
+ vertices[base + 1] = y;
381
+ vertices[base + 10] = u;
382
+ vertices[base + 11] = v;
383
+ }
384
+
385
+ function setSkinnedVertex(
386
+ vertices: Float32Array,
387
+ stride: number,
388
+ vertex: number,
389
+ x: number,
390
+ y: number,
391
+ u: number,
392
+ v: number,
393
+ joint: number,
394
+ ): void {
395
+ const base = vertex * stride;
396
+ vertices[base] = x;
397
+ vertices[base + 1] = y;
398
+ vertices[base + 10] = u;
399
+ vertices[base + 11] = v;
400
+ vertices[base + 12] = joint;
401
+ vertices[base + 13] = joint + 1;
402
+ vertices[base + 14] = joint + 2;
403
+ vertices[base + 15] = joint + 3;
404
+ vertices[base + 16] = 0.1;
405
+ vertices[base + 17] = 0.2;
406
+ vertices[base + 18] = 0.3;
407
+ vertices[base + 19] = 0.4;
408
+ }
409
+
222
410
  describe('ensureMeshGeometryBounds', () => {
223
411
  it('computes on first query, then reuses the cache until the version moves', () => {
224
412
  const geometry = makeTriangle();
@@ -232,8 +420,8 @@ describe('ensureMeshGeometryBounds', () => {
232
420
  geometry.vertices[12] = 4;
233
421
  expect(ensureMeshGeometryBounds(geometry)?.max.x).toBe(1);
234
422
 
235
- // Bumping the version (what every deform does) is what marks the cache stale.
236
- geometry.version++;
423
+ // The public direct-write escape hatch marks the cache stale.
424
+ invalidateMeshGeometry(geometry);
237
425
  expect(ensureMeshGeometryBounds(geometry)?.max.x).toBe(4);
238
426
  });
239
427
 
@@ -1,4 +1,5 @@
1
- import type { VertexAttributeLayout } from '@flighthq/types/contract';
1
+ import type { MeshGeometryRuntime, VertexAttributeLayout } from '@flighthq/types/contract';
2
+ import { EntityRuntimeKey } from '@flighthq/types/contract';
2
3
 
3
4
  import { createMeshGeometry } from './meshGeometry';
4
5
  import {
@@ -90,6 +91,16 @@ describe('convertMeshGeometryLayout', () => {
90
91
  expect(converted.vertices[1]).toBeCloseTo(2);
91
92
  expect(converted.vertices[2]).toBeCloseTo(3);
92
93
  });
94
+ it('preserves tangent-split smoothing identity across a layout conversion', () => {
95
+ const source = createMeshGeometry({ layout: CANONICAL_MESH_GEOMETRY_LAYOUT, vertices: new Float32Array(24) });
96
+ (source[EntityRuntimeKey] as MeshGeometryRuntime).tangentSmoothingSources = new Uint32Array([0, 0]);
97
+
98
+ const converted = convertMeshGeometryLayout(source, POSITION_ONLY_LAYOUT);
99
+
100
+ const convertedSources = (converted[EntityRuntimeKey] as MeshGeometryRuntime).tangentSmoothingSources;
101
+ expect(Array.from(convertedSources!)).toEqual([0, 0]);
102
+ expect(convertedSources).not.toBe((source[EntityRuntimeKey] as MeshGeometryRuntime).tangentSmoothingSources);
103
+ });
93
104
  it('strips to position + uv0 layout', () => {
94
105
  const source = makeCanonicalGeometry();
95
106
  const converted = convertMeshGeometryLayout(source, POSITION_UV0_LAYOUT);