@selvajs/visualization 1.7.1-beta.0 → 1.8.0-beta.2

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.js CHANGED
@@ -774,6 +774,95 @@ function textureToDataUri(payload) {
774
774
  for (let i = 0; i < data.byteLength; i += step) binary += String.fromCharCode(...data.subarray(i, i + step));
775
775
  return `data:${mime};base64,${btoa(binary)}`;
776
776
  }
777
+ // src/parse/webdisplay/batch/metadata.ts
778
+ function metadataFail(message, context) {
779
+ return new VisualizationError(message, ErrorCodes.VALIDATION_ERROR, { context });
780
+ }
781
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
782
+ /**
783
+ * Validates the batch's material and group/mesh metadata against the decoded geometry buffers
784
+ * before any of it is used arithmetically. Throws on the first inconsistency — a material, group or
785
+ * mesh that isn't an object, out-of-range `materialId`, non-integer or negative offsets/counts, or a
786
+ * vertex/index window that overruns the buffers — so malformed or version-skewed metadata fails the
787
+ * parse loudly instead of corrupting the render.
788
+ */
789
+ function validateBatchMetadata(materials, groups, totalVertexCount, totalIndexCount) {
790
+ if (!Array.isArray(materials)) throw metadataFail("Batch materials must be an array.", { materials: typeof materials });
791
+ const badMaterial = materials.findIndex((material) => !isObject(material));
792
+ if (badMaterial !== -1) throw metadataFail("Each material entry must be an object.", { materialIndex: badMaterial });
793
+ const materialCount = materials.length;
794
+ if (!Array.isArray(groups)) throw metadataFail("Batch groups must be an array.", { groups: typeof groups });
795
+ groups.forEach((group, groupIndex) => {
796
+ if (!isObject(group) || !Array.isArray(group.meshes)) throw metadataFail("Each group must be an object with a meshes array.", { groupIndex });
797
+ if (!group.meshes.every(isObject)) throw metadataFail("Each mesh entry must be an object.", { groupIndex });
798
+ });
799
+ for (const group of groups) {
800
+ if (!Number.isInteger(group.materialId) || group.materialId < 0 || group.materialId >= materialCount) throw metadataFail("Group materialId out of range of the materials array.", {
801
+ materialId: group.materialId,
802
+ materialCount
803
+ });
804
+ for (const mesh of group.meshes) {
805
+ const fields = {
806
+ vertexStart: mesh.vertexStart,
807
+ vertexCount: mesh.vertexCount,
808
+ indexStart: mesh.indexStart,
809
+ indexCount: mesh.indexCount
810
+ };
811
+ 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.`, {
812
+ meshName: mesh.name,
813
+ field,
814
+ value
815
+ });
816
+ if (mesh.vertexStart + mesh.vertexCount > totalVertexCount) throw metadataFail("Mesh vertex window exceeds the batch vertex buffer.", {
817
+ meshName: mesh.name,
818
+ vertexStart: mesh.vertexStart,
819
+ vertexCount: mesh.vertexCount,
820
+ totalVertexCount
821
+ });
822
+ if (mesh.indexStart + mesh.indexCount > totalIndexCount) throw metadataFail("Mesh index window exceeds the batch index buffer.", {
823
+ meshName: mesh.name,
824
+ indexStart: mesh.indexStart,
825
+ indexCount: mesh.indexCount,
826
+ totalIndexCount
827
+ });
828
+ }
829
+ }
830
+ }
831
+ /**
832
+ * Error for an index outside its mesh's declared vertex window
833
+ * `[vertexStart, vertexStart + vertexCount)`. Rebasing (`index - vertexStart`) writes into an
834
+ * unsigned array, so an out-of-window index would otherwise wrap to ~4 billion and corrupt the
835
+ * geometry. Range checks live inline in the copy loops (a function call per index measured
836
+ * noticeably slower at millions of indices) — this only builds the failure.
837
+ */
838
+ function indexOutOfWindow(indexValue, meshMeta) {
839
+ return metadataFail("Index references a vertex outside its mesh's vertex window.", {
840
+ meshName: meshMeta.name,
841
+ indexValue,
842
+ vertexStart: meshMeta.vertexStart,
843
+ vertexCount: meshMeta.vertexCount
844
+ });
845
+ }
846
+ /**
847
+ * Reconstructs world-unit float32 positions from int16 quantized values:
848
+ * `world = origin + (q + 32767) * scale`. No rotation — the Three scene uses Rhino's Z-up frame,
849
+ * so vertices pass through as they arrived.
850
+ */
851
+ function dequantizeInt16(q, origin, scale) {
852
+ const out = new Float32Array(q.length);
853
+ const ox = origin[0];
854
+ const oy = origin[1];
855
+ const oz = origin[2];
856
+ const sx = scale[0];
857
+ const sy = scale[1];
858
+ const sz = scale[2];
859
+ for (let i = 0; i < q.length; i += 3) {
860
+ out[i] = ox + (q[i] + 32767) * sx;
861
+ out[i + 1] = oy + (q[i + 1] + 32767) * sy;
862
+ out[i + 2] = oz + (q[i + 2] + 32767) * sz;
863
+ }
864
+ return out;
865
+ }
777
866
  // src/parse/webdisplay/binary-parser.ts
778
867
  /** Second half of {@link parseBinaryMeshBatch}, for a caller that already holds the raw view. */
779
868
  function decodeRawMeshBatch(raw) {
@@ -845,6 +934,7 @@ function parseBinaryMeshBatchRaw(input) {
845
934
  } catch (error) {
846
935
  throw fail(`Failed to parse metadata JSON: ${error instanceof Error ? error.message : String(error)}`, { metadataLen });
847
936
  }
937
+ if (!isMetadataShape(metadata)) throw fail("Metadata JSON must be an object whose materials and groups are arrays.", { metadataLen });
848
938
  if (offset + 56 > bytes.byteLength) throw fail("Insufficient data to read geometry header.", {
849
939
  expectedBytes: 56,
850
940
  availableBytes: bytes.byteLength - offset,
@@ -935,6 +1025,12 @@ function parseBinaryMeshBatchRaw(input) {
935
1025
  colors
936
1026
  };
937
1027
  }
1028
+ /** Missing `materials`/`groups` are allowed: the JSON envelope's copies fill them in. */
1029
+ function isMetadataShape(value) {
1030
+ if (!isObject(value)) return false;
1031
+ const { materials, groups } = value;
1032
+ return (materials === void 0 || Array.isArray(materials)) && (groups === void 0 || Array.isArray(groups));
1033
+ }
938
1034
  // src/parse/webdisplay/closed-solid.ts
939
1035
  /**
940
1036
  * True when every member of a geometry is a closed, consistently wound, outward-facing triangle
@@ -2298,84 +2394,6 @@ function applyVertexColorSRGBDecode(material) {
2298
2394
  #endif`);
2299
2395
  });
2300
2396
  }
2301
- // src/parse/webdisplay/batch/metadata.ts
2302
- function metadataFail(message, context) {
2303
- return new VisualizationError(message, ErrorCodes.VALIDATION_ERROR, { context });
2304
- }
2305
- /**
2306
- * Validates the batch's group/mesh metadata against the decoded geometry buffers before any of it
2307
- * is used arithmetically. Throws on the first inconsistency — out-of-range `materialId`,
2308
- * non-integer or negative offsets/counts, or a vertex/index window that overruns the buffers — so
2309
- * malformed or version-skewed metadata fails the parse loudly instead of corrupting the render.
2310
- */
2311
- function validateGroupMetadata(groups, materialCount, totalVertexCount, totalIndexCount) {
2312
- for (const group of groups) {
2313
- if (!Number.isInteger(group.materialId) || group.materialId < 0 || group.materialId >= materialCount) throw metadataFail("Group materialId out of range of the materials array.", {
2314
- materialId: group.materialId,
2315
- materialCount
2316
- });
2317
- for (const mesh of group.meshes) {
2318
- const fields = {
2319
- vertexStart: mesh.vertexStart,
2320
- vertexCount: mesh.vertexCount,
2321
- indexStart: mesh.indexStart,
2322
- indexCount: mesh.indexCount
2323
- };
2324
- 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.`, {
2325
- meshName: mesh.name,
2326
- field,
2327
- value
2328
- });
2329
- if (mesh.vertexStart + mesh.vertexCount > totalVertexCount) throw metadataFail("Mesh vertex window exceeds the batch vertex buffer.", {
2330
- meshName: mesh.name,
2331
- vertexStart: mesh.vertexStart,
2332
- vertexCount: mesh.vertexCount,
2333
- totalVertexCount
2334
- });
2335
- if (mesh.indexStart + mesh.indexCount > totalIndexCount) throw metadataFail("Mesh index window exceeds the batch index buffer.", {
2336
- meshName: mesh.name,
2337
- indexStart: mesh.indexStart,
2338
- indexCount: mesh.indexCount,
2339
- totalIndexCount
2340
- });
2341
- }
2342
- }
2343
- }
2344
- /**
2345
- * Error for an index outside its mesh's declared vertex window
2346
- * `[vertexStart, vertexStart + vertexCount)`. Rebasing (`index - vertexStart`) writes into an
2347
- * unsigned array, so an out-of-window index would otherwise wrap to ~4 billion and corrupt the
2348
- * geometry. Range checks live inline in the copy loops (a function call per index measured
2349
- * noticeably slower at millions of indices) — this only builds the failure.
2350
- */
2351
- function indexOutOfWindow(indexValue, meshMeta) {
2352
- return metadataFail("Index references a vertex outside its mesh's vertex window.", {
2353
- meshName: meshMeta.name,
2354
- indexValue,
2355
- vertexStart: meshMeta.vertexStart,
2356
- vertexCount: meshMeta.vertexCount
2357
- });
2358
- }
2359
- /**
2360
- * Reconstructs world-unit float32 positions from int16 quantized values:
2361
- * `world = origin + (q + 32767) * scale`. No rotation — the Three scene uses Rhino's Z-up frame,
2362
- * so vertices pass through as they arrived.
2363
- */
2364
- function dequantizeInt16(q, origin, scale) {
2365
- const out = new Float32Array(q.length);
2366
- const ox = origin[0];
2367
- const oy = origin[1];
2368
- const oz = origin[2];
2369
- const sx = scale[0];
2370
- const sy = scale[1];
2371
- const sz = scale[2];
2372
- for (let i = 0; i < q.length; i += 3) {
2373
- out[i] = ox + (q[i] + 32767) * sx;
2374
- out[i + 1] = oy + (q[i + 1] + 32767) * sy;
2375
- out[i + 2] = oz + (q[i + 2] + 32767) * sz;
2376
- }
2377
- return out;
2378
- }
2379
2397
  // src/parse/webdisplay/batch/merge.ts
2380
2398
  /**
2381
2399
  * Split a material group into one sub-group per layer, preserving mesh order.
@@ -2765,7 +2783,7 @@ function buildMeshesFromParsed(parsed, opts) {
2765
2783
  const materialsSrc = parsed.metadata.materials ?? fallback?.materials ?? [];
2766
2784
  const groups = parsed.metadata.groups ?? fallback?.groups ?? [];
2767
2785
  const isFloat32 = (parsed.flags & 1) !== 0;
2768
- validateGroupMetadata(groups, materialsSrc.length, parsed.vertices.length / 3, parsed.indices.length);
2786
+ validateBatchMetadata(materialsSrc, groups, parsed.vertices.length / 3, parsed.indices.length);
2769
2787
  const worldVertices = isFloat32 ? parsed.vertices : dequantizeInt16(parsed.vertices, parsed.origin, parsed.scale);
2770
2788
  if (debug) {
2771
2789
  const wireBytes = parsed.vertices.byteLength + parsed.indices.byteLength;
@@ -2853,7 +2871,7 @@ function buildFromFinishedCache(container, opts) {
2853
2871
  const { jobs, jobRefs } = planJobs(groups, opts.mergeByMaterial);
2854
2872
  const cached = getFinishedBatch(container.geometryBlob, jobs);
2855
2873
  if (!cached) return null;
2856
- validateGroupMetadata(groups, materialsSrc.length, cached.vertexCount, cached.indexCount);
2874
+ validateBatchMetadata(materialsSrc, groups, cached.vertexCount, cached.indexCount);
2857
2875
  return meshesFromBuilt(cached, jobRefs, materialsSrc, opts.material);
2858
2876
  }
2859
2877
  /** One job per merged group or per unmerged mesh, with a parallel ref list to unwrap results by index. */
@@ -2905,7 +2923,7 @@ async function tryBuildViaAssembly(opened, opts) {
2905
2923
  if (indexCount / 3 < 5e4) return null;
2906
2924
  const materialsSrc = raw.metadata.materials ?? opts.fallback?.materials ?? [];
2907
2925
  const groups = raw.metadata.groups ?? opts.fallback?.groups ?? [];
2908
- validateGroupMetadata(groups, materialsSrc.length, raw.vertexCount, indexCount);
2926
+ validateBatchMetadata(materialsSrc, groups, raw.vertexCount, indexCount);
2909
2927
  const { jobs, jobRefs } = planJobs(groups, opts.mergeByMaterial);
2910
2928
  const assembled = await assemble(raw, jobs, opened.source);
2911
2929
  if (!assembled) {
@@ -2945,7 +2963,15 @@ async function assemble(raw, jobs, source) {
2945
2963
  colors: raw.colors,
2946
2964
  jobs
2947
2965
  };
2948
- if (!worker) return assembleGeometries(input).map((geometry, i) => finishGeometry(geometry, jobs[i].windows));
2966
+ if (!worker) {
2967
+ let geometries;
2968
+ try {
2969
+ geometries = assembleGeometries(input);
2970
+ } catch (error) {
2971
+ throw new VisualizationError(error instanceof Error ? error.message : String(error), ErrorCodes.VALIDATION_ERROR, { originalError: error instanceof Error ? error : void 0 });
2972
+ }
2973
+ return geometries.map((geometry, i) => finishGeometry(geometry, jobs[i].windows));
2974
+ }
2949
2975
  if (!ownsBuffer(raw, source)) {
2950
2976
  input.vertexData = raw.vertexData.slice();
2951
2977
  input.indexData = raw.indexData.slice();