@selvajs/visualization 1.7.0 → 1.7.1-beta.1

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/dist/parse.cjs CHANGED
@@ -75,10 +75,24 @@ function cloneSceneObjects(meshes) {
75
75
  function releaseSceneObjects(meshes) {
76
76
  meshes.forEach((root) => require_gpu_dispose.disposeObjectTree(root, { materials: false }));
77
77
  }
78
+ /** Geometry bytes only: the memo's copies share materials and textures with the live scene. */
79
+ function sceneObjectsByteSize(meshes) {
80
+ let bytes = 0;
81
+ const seen = /* @__PURE__ */ new Set();
82
+ for (const root of meshes) root.traverse((child) => {
83
+ const geometry = child.geometry;
84
+ if (!geometry || seen.has(geometry)) return;
85
+ seen.add(geometry);
86
+ for (const attribute of Object.values(geometry.attributes)) bytes += attribute.array.byteLength;
87
+ if (geometry.index) bytes += geometry.index.array.byteLength;
88
+ });
89
+ return bytes;
90
+ }
78
91
  /** Structurally, not nominally, typed as `@selvajs/solve/client`'s `MeshPolicy<THREE.Object3D>` — avoids a dependency on solve. */
79
92
  const meshPolicy = {
80
93
  clone: cloneSceneObjects,
81
- release: releaseSceneObjects
94
+ release: releaseSceneObjects,
95
+ byteSize: sceneObjectsByteSize
82
96
  };
83
97
  /** Opacity < 1 flips `transparent` on. */
84
98
  function materialParams(color, opacity) {
@@ -762,6 +776,95 @@ function textureToDataUri(payload) {
762
776
  for (let i = 0; i < data.byteLength; i += step) binary += String.fromCharCode(...data.subarray(i, i + step));
763
777
  return `data:${mime};base64,${btoa(binary)}`;
764
778
  }
779
+ // src/parse/webdisplay/batch/metadata.ts
780
+ function metadataFail(message, context) {
781
+ return new require_gpu_dispose.VisualizationError(message, require_gpu_dispose.ErrorCodes.VALIDATION_ERROR, { context });
782
+ }
783
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
784
+ /**
785
+ * Validates the batch's material and group/mesh metadata against the decoded geometry buffers
786
+ * before any of it is used arithmetically. Throws on the first inconsistency — a material, group or
787
+ * mesh that isn't an object, out-of-range `materialId`, non-integer or negative offsets/counts, or a
788
+ * vertex/index window that overruns the buffers — so malformed or version-skewed metadata fails the
789
+ * parse loudly instead of corrupting the render.
790
+ */
791
+ function validateBatchMetadata(materials, groups, totalVertexCount, totalIndexCount) {
792
+ if (!Array.isArray(materials)) throw metadataFail("Batch materials must be an array.", { materials: typeof materials });
793
+ const badMaterial = materials.findIndex((material) => !isObject(material));
794
+ if (badMaterial !== -1) throw metadataFail("Each material entry must be an object.", { materialIndex: badMaterial });
795
+ const materialCount = materials.length;
796
+ if (!Array.isArray(groups)) throw metadataFail("Batch groups must be an array.", { groups: typeof groups });
797
+ groups.forEach((group, groupIndex) => {
798
+ if (!isObject(group) || !Array.isArray(group.meshes)) throw metadataFail("Each group must be an object with a meshes array.", { groupIndex });
799
+ if (!group.meshes.every(isObject)) throw metadataFail("Each mesh entry must be an object.", { groupIndex });
800
+ });
801
+ for (const group of groups) {
802
+ if (!Number.isInteger(group.materialId) || group.materialId < 0 || group.materialId >= materialCount) throw metadataFail("Group materialId out of range of the materials array.", {
803
+ materialId: group.materialId,
804
+ materialCount
805
+ });
806
+ for (const mesh of group.meshes) {
807
+ const fields = {
808
+ vertexStart: mesh.vertexStart,
809
+ vertexCount: mesh.vertexCount,
810
+ indexStart: mesh.indexStart,
811
+ indexCount: mesh.indexCount
812
+ };
813
+ for (const [field, value] of Object.entries(fields)) if (!Number.isInteger(value) || value < 0) throw metadataFail(`Mesh metadata field "${field}" must be a non-negative integer.`, {
814
+ meshName: mesh.name,
815
+ field,
816
+ value
817
+ });
818
+ if (mesh.vertexStart + mesh.vertexCount > totalVertexCount) throw metadataFail("Mesh vertex window exceeds the batch vertex buffer.", {
819
+ meshName: mesh.name,
820
+ vertexStart: mesh.vertexStart,
821
+ vertexCount: mesh.vertexCount,
822
+ totalVertexCount
823
+ });
824
+ if (mesh.indexStart + mesh.indexCount > totalIndexCount) throw metadataFail("Mesh index window exceeds the batch index buffer.", {
825
+ meshName: mesh.name,
826
+ indexStart: mesh.indexStart,
827
+ indexCount: mesh.indexCount,
828
+ totalIndexCount
829
+ });
830
+ }
831
+ }
832
+ }
833
+ /**
834
+ * Error for an index outside its mesh's declared vertex window
835
+ * `[vertexStart, vertexStart + vertexCount)`. Rebasing (`index - vertexStart`) writes into an
836
+ * unsigned array, so an out-of-window index would otherwise wrap to ~4 billion and corrupt the
837
+ * geometry. Range checks live inline in the copy loops (a function call per index measured
838
+ * noticeably slower at millions of indices) — this only builds the failure.
839
+ */
840
+ function indexOutOfWindow(indexValue, meshMeta) {
841
+ return metadataFail("Index references a vertex outside its mesh's vertex window.", {
842
+ meshName: meshMeta.name,
843
+ indexValue,
844
+ vertexStart: meshMeta.vertexStart,
845
+ vertexCount: meshMeta.vertexCount
846
+ });
847
+ }
848
+ /**
849
+ * Reconstructs world-unit float32 positions from int16 quantized values:
850
+ * `world = origin + (q + 32767) * scale`. No rotation — the Three scene uses Rhino's Z-up frame,
851
+ * so vertices pass through as they arrived.
852
+ */
853
+ function dequantizeInt16(q, origin, scale) {
854
+ const out = new Float32Array(q.length);
855
+ const ox = origin[0];
856
+ const oy = origin[1];
857
+ const oz = origin[2];
858
+ const sx = scale[0];
859
+ const sy = scale[1];
860
+ const sz = scale[2];
861
+ for (let i = 0; i < q.length; i += 3) {
862
+ out[i] = ox + (q[i] + 32767) * sx;
863
+ out[i + 1] = oy + (q[i + 1] + 32767) * sy;
864
+ out[i + 2] = oz + (q[i + 2] + 32767) * sz;
865
+ }
866
+ return out;
867
+ }
765
868
  // src/parse/webdisplay/binary-parser.ts
766
869
  /** Second half of {@link parseBinaryMeshBatch}, for a caller that already holds the raw view. */
767
870
  function decodeRawMeshBatch(raw) {
@@ -833,6 +936,7 @@ function parseBinaryMeshBatchRaw(input) {
833
936
  } catch (error) {
834
937
  throw fail(`Failed to parse metadata JSON: ${error instanceof Error ? error.message : String(error)}`, { metadataLen });
835
938
  }
939
+ if (!isMetadataShape(metadata)) throw fail("Metadata JSON must be an object whose materials and groups are arrays.", { metadataLen });
836
940
  if (offset + 56 > bytes.byteLength) throw fail("Insufficient data to read geometry header.", {
837
941
  expectedBytes: 56,
838
942
  availableBytes: bytes.byteLength - offset,
@@ -923,6 +1027,12 @@ function parseBinaryMeshBatchRaw(input) {
923
1027
  colors
924
1028
  };
925
1029
  }
1030
+ /** Missing `materials`/`groups` are allowed: the JSON envelope's copies fill them in. */
1031
+ function isMetadataShape(value) {
1032
+ if (!isObject(value)) return false;
1033
+ const { materials, groups } = value;
1034
+ return (materials === void 0 || Array.isArray(materials)) && (groups === void 0 || Array.isArray(groups));
1035
+ }
926
1036
  // src/parse/webdisplay/closed-solid.ts
927
1037
  /**
928
1038
  * True when every member of a geometry is a closed, consistently wound, outward-facing triangle
@@ -2286,84 +2396,6 @@ function applyVertexColorSRGBDecode(material) {
2286
2396
  #endif`);
2287
2397
  });
2288
2398
  }
2289
- // src/parse/webdisplay/batch/metadata.ts
2290
- function metadataFail(message, context) {
2291
- return new require_gpu_dispose.VisualizationError(message, require_gpu_dispose.ErrorCodes.VALIDATION_ERROR, { context });
2292
- }
2293
- /**
2294
- * Validates the batch's group/mesh metadata against the decoded geometry buffers before any of it
2295
- * is used arithmetically. Throws on the first inconsistency — out-of-range `materialId`,
2296
- * non-integer or negative offsets/counts, or a vertex/index window that overruns the buffers — so
2297
- * malformed or version-skewed metadata fails the parse loudly instead of corrupting the render.
2298
- */
2299
- function validateGroupMetadata(groups, materialCount, totalVertexCount, totalIndexCount) {
2300
- for (const group of groups) {
2301
- if (!Number.isInteger(group.materialId) || group.materialId < 0 || group.materialId >= materialCount) throw metadataFail("Group materialId out of range of the materials array.", {
2302
- materialId: group.materialId,
2303
- materialCount
2304
- });
2305
- for (const mesh of group.meshes) {
2306
- const fields = {
2307
- vertexStart: mesh.vertexStart,
2308
- vertexCount: mesh.vertexCount,
2309
- indexStart: mesh.indexStart,
2310
- indexCount: mesh.indexCount
2311
- };
2312
- for (const [field, value] of Object.entries(fields)) if (!Number.isInteger(value) || value < 0) throw metadataFail(`Mesh metadata field "${field}" must be a non-negative integer.`, {
2313
- meshName: mesh.name,
2314
- field,
2315
- value
2316
- });
2317
- if (mesh.vertexStart + mesh.vertexCount > totalVertexCount) throw metadataFail("Mesh vertex window exceeds the batch vertex buffer.", {
2318
- meshName: mesh.name,
2319
- vertexStart: mesh.vertexStart,
2320
- vertexCount: mesh.vertexCount,
2321
- totalVertexCount
2322
- });
2323
- if (mesh.indexStart + mesh.indexCount > totalIndexCount) throw metadataFail("Mesh index window exceeds the batch index buffer.", {
2324
- meshName: mesh.name,
2325
- indexStart: mesh.indexStart,
2326
- indexCount: mesh.indexCount,
2327
- totalIndexCount
2328
- });
2329
- }
2330
- }
2331
- }
2332
- /**
2333
- * Error for an index outside its mesh's declared vertex window
2334
- * `[vertexStart, vertexStart + vertexCount)`. Rebasing (`index - vertexStart`) writes into an
2335
- * unsigned array, so an out-of-window index would otherwise wrap to ~4 billion and corrupt the
2336
- * geometry. Range checks live inline in the copy loops (a function call per index measured
2337
- * noticeably slower at millions of indices) — this only builds the failure.
2338
- */
2339
- function indexOutOfWindow(indexValue, meshMeta) {
2340
- return metadataFail("Index references a vertex outside its mesh's vertex window.", {
2341
- meshName: meshMeta.name,
2342
- indexValue,
2343
- vertexStart: meshMeta.vertexStart,
2344
- vertexCount: meshMeta.vertexCount
2345
- });
2346
- }
2347
- /**
2348
- * Reconstructs world-unit float32 positions from int16 quantized values:
2349
- * `world = origin + (q + 32767) * scale`. No rotation — the Three scene uses Rhino's Z-up frame,
2350
- * so vertices pass through as they arrived.
2351
- */
2352
- function dequantizeInt16(q, origin, scale) {
2353
- const out = new Float32Array(q.length);
2354
- const ox = origin[0];
2355
- const oy = origin[1];
2356
- const oz = origin[2];
2357
- const sx = scale[0];
2358
- const sy = scale[1];
2359
- const sz = scale[2];
2360
- for (let i = 0; i < q.length; i += 3) {
2361
- out[i] = ox + (q[i] + 32767) * sx;
2362
- out[i + 1] = oy + (q[i + 1] + 32767) * sy;
2363
- out[i + 2] = oz + (q[i + 2] + 32767) * sz;
2364
- }
2365
- return out;
2366
- }
2367
2399
  // src/parse/webdisplay/batch/merge.ts
2368
2400
  /**
2369
2401
  * Split a material group into one sub-group per layer, preserving mesh order.
@@ -2753,7 +2785,7 @@ function buildMeshesFromParsed(parsed, opts) {
2753
2785
  const materialsSrc = parsed.metadata.materials ?? fallback?.materials ?? [];
2754
2786
  const groups = parsed.metadata.groups ?? fallback?.groups ?? [];
2755
2787
  const isFloat32 = (parsed.flags & 1) !== 0;
2756
- validateGroupMetadata(groups, materialsSrc.length, parsed.vertices.length / 3, parsed.indices.length);
2788
+ validateBatchMetadata(materialsSrc, groups, parsed.vertices.length / 3, parsed.indices.length);
2757
2789
  const worldVertices = isFloat32 ? parsed.vertices : dequantizeInt16(parsed.vertices, parsed.origin, parsed.scale);
2758
2790
  if (debug) {
2759
2791
  const wireBytes = parsed.vertices.byteLength + parsed.indices.byteLength;
@@ -2841,7 +2873,7 @@ function buildFromFinishedCache(container, opts) {
2841
2873
  const { jobs, jobRefs } = planJobs(groups, opts.mergeByMaterial);
2842
2874
  const cached = getFinishedBatch(container.geometryBlob, jobs);
2843
2875
  if (!cached) return null;
2844
- validateGroupMetadata(groups, materialsSrc.length, cached.vertexCount, cached.indexCount);
2876
+ validateBatchMetadata(materialsSrc, groups, cached.vertexCount, cached.indexCount);
2845
2877
  return meshesFromBuilt(cached, jobRefs, materialsSrc, opts.material);
2846
2878
  }
2847
2879
  /** One job per merged group or per unmerged mesh, with a parallel ref list to unwrap results by index. */
@@ -2893,7 +2925,7 @@ async function tryBuildViaAssembly(opened, opts) {
2893
2925
  if (indexCount / 3 < 5e4) return null;
2894
2926
  const materialsSrc = raw.metadata.materials ?? opts.fallback?.materials ?? [];
2895
2927
  const groups = raw.metadata.groups ?? opts.fallback?.groups ?? [];
2896
- validateGroupMetadata(groups, materialsSrc.length, raw.vertexCount, indexCount);
2928
+ validateBatchMetadata(materialsSrc, groups, raw.vertexCount, indexCount);
2897
2929
  const { jobs, jobRefs } = planJobs(groups, opts.mergeByMaterial);
2898
2930
  const assembled = await assemble(raw, jobs, opened.source);
2899
2931
  if (!assembled) {
@@ -2933,7 +2965,15 @@ async function assemble(raw, jobs, source) {
2933
2965
  colors: raw.colors,
2934
2966
  jobs
2935
2967
  };
2936
- if (!worker) return assembleGeometries(input).map((geometry, i) => finishGeometry(geometry, jobs[i].windows));
2968
+ if (!worker) {
2969
+ let geometries;
2970
+ try {
2971
+ geometries = assembleGeometries(input);
2972
+ } catch (error) {
2973
+ throw new require_gpu_dispose.VisualizationError(error instanceof Error ? error.message : String(error), require_gpu_dispose.ErrorCodes.VALIDATION_ERROR, { originalError: error instanceof Error ? error : void 0 });
2974
+ }
2975
+ return geometries.map((geometry, i) => finishGeometry(geometry, jobs[i].windows));
2976
+ }
2937
2977
  if (!ownsBuffer(raw, source)) {
2938
2978
  input.vertexData = raw.vertexData.slice();
2939
2979
  input.indexData = raw.indexData.slice();