@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 +123 -83
- package/dist/parse.cjs.map +1 -1
- package/dist/parse.d.cts +1 -0
- package/dist/parse.d.ts +1 -0
- package/dist/parse.js +123 -83
- package/dist/parse.js.map +1 -1
- package/package.json +1 -1
package/dist/parse.d.cts
CHANGED
|
@@ -4,6 +4,7 @@ import * as THREE from "three";
|
|
|
4
4
|
export declare const meshPolicy: {
|
|
5
5
|
clone(meshes: THREE.Object3D[]): THREE.Object3D[];
|
|
6
6
|
release(meshes: THREE.Object3D[]): void;
|
|
7
|
+
byteSize(meshes: THREE.Object3D[]): number;
|
|
7
8
|
};
|
|
8
9
|
/**
|
|
9
10
|
* Declared structurally rather than imported from `@selvajs/compute` — this package converts
|
package/dist/parse.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as THREE from "three";
|
|
|
4
4
|
export declare const meshPolicy: {
|
|
5
5
|
clone(meshes: THREE.Object3D[]): THREE.Object3D[];
|
|
6
6
|
release(meshes: THREE.Object3D[]): void;
|
|
7
|
+
byteSize(meshes: THREE.Object3D[]): number;
|
|
7
8
|
};
|
|
8
9
|
/**
|
|
9
10
|
* Declared structurally rather than imported from `@selvajs/compute` — this package converts
|
package/dist/parse.js
CHANGED
|
@@ -73,10 +73,24 @@ function cloneSceneObjects(meshes) {
|
|
|
73
73
|
function releaseSceneObjects(meshes) {
|
|
74
74
|
meshes.forEach((root) => disposeObjectTree(root, { materials: false }));
|
|
75
75
|
}
|
|
76
|
+
/** Geometry bytes only: the memo's copies share materials and textures with the live scene. */
|
|
77
|
+
function sceneObjectsByteSize(meshes) {
|
|
78
|
+
let bytes = 0;
|
|
79
|
+
const seen = /* @__PURE__ */ new Set();
|
|
80
|
+
for (const root of meshes) root.traverse((child) => {
|
|
81
|
+
const geometry = child.geometry;
|
|
82
|
+
if (!geometry || seen.has(geometry)) return;
|
|
83
|
+
seen.add(geometry);
|
|
84
|
+
for (const attribute of Object.values(geometry.attributes)) bytes += attribute.array.byteLength;
|
|
85
|
+
if (geometry.index) bytes += geometry.index.array.byteLength;
|
|
86
|
+
});
|
|
87
|
+
return bytes;
|
|
88
|
+
}
|
|
76
89
|
/** Structurally, not nominally, typed as `@selvajs/solve/client`'s `MeshPolicy<THREE.Object3D>` — avoids a dependency on solve. */
|
|
77
90
|
const meshPolicy = {
|
|
78
91
|
clone: cloneSceneObjects,
|
|
79
|
-
release: releaseSceneObjects
|
|
92
|
+
release: releaseSceneObjects,
|
|
93
|
+
byteSize: sceneObjectsByteSize
|
|
80
94
|
};
|
|
81
95
|
/** Opacity < 1 flips `transparent` on. */
|
|
82
96
|
function materialParams(color, opacity) {
|
|
@@ -760,6 +774,95 @@ function textureToDataUri(payload) {
|
|
|
760
774
|
for (let i = 0; i < data.byteLength; i += step) binary += String.fromCharCode(...data.subarray(i, i + step));
|
|
761
775
|
return `data:${mime};base64,${btoa(binary)}`;
|
|
762
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
|
+
}
|
|
763
866
|
// src/parse/webdisplay/binary-parser.ts
|
|
764
867
|
/** Second half of {@link parseBinaryMeshBatch}, for a caller that already holds the raw view. */
|
|
765
868
|
function decodeRawMeshBatch(raw) {
|
|
@@ -831,6 +934,7 @@ function parseBinaryMeshBatchRaw(input) {
|
|
|
831
934
|
} catch (error) {
|
|
832
935
|
throw fail(`Failed to parse metadata JSON: ${error instanceof Error ? error.message : String(error)}`, { metadataLen });
|
|
833
936
|
}
|
|
937
|
+
if (!isMetadataShape(metadata)) throw fail("Metadata JSON must be an object whose materials and groups are arrays.", { metadataLen });
|
|
834
938
|
if (offset + 56 > bytes.byteLength) throw fail("Insufficient data to read geometry header.", {
|
|
835
939
|
expectedBytes: 56,
|
|
836
940
|
availableBytes: bytes.byteLength - offset,
|
|
@@ -921,6 +1025,12 @@ function parseBinaryMeshBatchRaw(input) {
|
|
|
921
1025
|
colors
|
|
922
1026
|
};
|
|
923
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
|
+
}
|
|
924
1034
|
// src/parse/webdisplay/closed-solid.ts
|
|
925
1035
|
/**
|
|
926
1036
|
* True when every member of a geometry is a closed, consistently wound, outward-facing triangle
|
|
@@ -2284,84 +2394,6 @@ function applyVertexColorSRGBDecode(material) {
|
|
|
2284
2394
|
#endif`);
|
|
2285
2395
|
});
|
|
2286
2396
|
}
|
|
2287
|
-
// src/parse/webdisplay/batch/metadata.ts
|
|
2288
|
-
function metadataFail(message, context) {
|
|
2289
|
-
return new VisualizationError(message, ErrorCodes.VALIDATION_ERROR, { context });
|
|
2290
|
-
}
|
|
2291
|
-
/**
|
|
2292
|
-
* Validates the batch's group/mesh metadata against the decoded geometry buffers before any of it
|
|
2293
|
-
* is used arithmetically. Throws on the first inconsistency — out-of-range `materialId`,
|
|
2294
|
-
* non-integer or negative offsets/counts, or a vertex/index window that overruns the buffers — so
|
|
2295
|
-
* malformed or version-skewed metadata fails the parse loudly instead of corrupting the render.
|
|
2296
|
-
*/
|
|
2297
|
-
function validateGroupMetadata(groups, materialCount, totalVertexCount, totalIndexCount) {
|
|
2298
|
-
for (const group of groups) {
|
|
2299
|
-
if (!Number.isInteger(group.materialId) || group.materialId < 0 || group.materialId >= materialCount) throw metadataFail("Group materialId out of range of the materials array.", {
|
|
2300
|
-
materialId: group.materialId,
|
|
2301
|
-
materialCount
|
|
2302
|
-
});
|
|
2303
|
-
for (const mesh of group.meshes) {
|
|
2304
|
-
const fields = {
|
|
2305
|
-
vertexStart: mesh.vertexStart,
|
|
2306
|
-
vertexCount: mesh.vertexCount,
|
|
2307
|
-
indexStart: mesh.indexStart,
|
|
2308
|
-
indexCount: mesh.indexCount
|
|
2309
|
-
};
|
|
2310
|
-
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.`, {
|
|
2311
|
-
meshName: mesh.name,
|
|
2312
|
-
field,
|
|
2313
|
-
value
|
|
2314
|
-
});
|
|
2315
|
-
if (mesh.vertexStart + mesh.vertexCount > totalVertexCount) throw metadataFail("Mesh vertex window exceeds the batch vertex buffer.", {
|
|
2316
|
-
meshName: mesh.name,
|
|
2317
|
-
vertexStart: mesh.vertexStart,
|
|
2318
|
-
vertexCount: mesh.vertexCount,
|
|
2319
|
-
totalVertexCount
|
|
2320
|
-
});
|
|
2321
|
-
if (mesh.indexStart + mesh.indexCount > totalIndexCount) throw metadataFail("Mesh index window exceeds the batch index buffer.", {
|
|
2322
|
-
meshName: mesh.name,
|
|
2323
|
-
indexStart: mesh.indexStart,
|
|
2324
|
-
indexCount: mesh.indexCount,
|
|
2325
|
-
totalIndexCount
|
|
2326
|
-
});
|
|
2327
|
-
}
|
|
2328
|
-
}
|
|
2329
|
-
}
|
|
2330
|
-
/**
|
|
2331
|
-
* Error for an index outside its mesh's declared vertex window
|
|
2332
|
-
* `[vertexStart, vertexStart + vertexCount)`. Rebasing (`index - vertexStart`) writes into an
|
|
2333
|
-
* unsigned array, so an out-of-window index would otherwise wrap to ~4 billion and corrupt the
|
|
2334
|
-
* geometry. Range checks live inline in the copy loops (a function call per index measured
|
|
2335
|
-
* noticeably slower at millions of indices) — this only builds the failure.
|
|
2336
|
-
*/
|
|
2337
|
-
function indexOutOfWindow(indexValue, meshMeta) {
|
|
2338
|
-
return metadataFail("Index references a vertex outside its mesh's vertex window.", {
|
|
2339
|
-
meshName: meshMeta.name,
|
|
2340
|
-
indexValue,
|
|
2341
|
-
vertexStart: meshMeta.vertexStart,
|
|
2342
|
-
vertexCount: meshMeta.vertexCount
|
|
2343
|
-
});
|
|
2344
|
-
}
|
|
2345
|
-
/**
|
|
2346
|
-
* Reconstructs world-unit float32 positions from int16 quantized values:
|
|
2347
|
-
* `world = origin + (q + 32767) * scale`. No rotation — the Three scene uses Rhino's Z-up frame,
|
|
2348
|
-
* so vertices pass through as they arrived.
|
|
2349
|
-
*/
|
|
2350
|
-
function dequantizeInt16(q, origin, scale) {
|
|
2351
|
-
const out = new Float32Array(q.length);
|
|
2352
|
-
const ox = origin[0];
|
|
2353
|
-
const oy = origin[1];
|
|
2354
|
-
const oz = origin[2];
|
|
2355
|
-
const sx = scale[0];
|
|
2356
|
-
const sy = scale[1];
|
|
2357
|
-
const sz = scale[2];
|
|
2358
|
-
for (let i = 0; i < q.length; i += 3) {
|
|
2359
|
-
out[i] = ox + (q[i] + 32767) * sx;
|
|
2360
|
-
out[i + 1] = oy + (q[i + 1] + 32767) * sy;
|
|
2361
|
-
out[i + 2] = oz + (q[i + 2] + 32767) * sz;
|
|
2362
|
-
}
|
|
2363
|
-
return out;
|
|
2364
|
-
}
|
|
2365
2397
|
// src/parse/webdisplay/batch/merge.ts
|
|
2366
2398
|
/**
|
|
2367
2399
|
* Split a material group into one sub-group per layer, preserving mesh order.
|
|
@@ -2751,7 +2783,7 @@ function buildMeshesFromParsed(parsed, opts) {
|
|
|
2751
2783
|
const materialsSrc = parsed.metadata.materials ?? fallback?.materials ?? [];
|
|
2752
2784
|
const groups = parsed.metadata.groups ?? fallback?.groups ?? [];
|
|
2753
2785
|
const isFloat32 = (parsed.flags & 1) !== 0;
|
|
2754
|
-
|
|
2786
|
+
validateBatchMetadata(materialsSrc, groups, parsed.vertices.length / 3, parsed.indices.length);
|
|
2755
2787
|
const worldVertices = isFloat32 ? parsed.vertices : dequantizeInt16(parsed.vertices, parsed.origin, parsed.scale);
|
|
2756
2788
|
if (debug) {
|
|
2757
2789
|
const wireBytes = parsed.vertices.byteLength + parsed.indices.byteLength;
|
|
@@ -2839,7 +2871,7 @@ function buildFromFinishedCache(container, opts) {
|
|
|
2839
2871
|
const { jobs, jobRefs } = planJobs(groups, opts.mergeByMaterial);
|
|
2840
2872
|
const cached = getFinishedBatch(container.geometryBlob, jobs);
|
|
2841
2873
|
if (!cached) return null;
|
|
2842
|
-
|
|
2874
|
+
validateBatchMetadata(materialsSrc, groups, cached.vertexCount, cached.indexCount);
|
|
2843
2875
|
return meshesFromBuilt(cached, jobRefs, materialsSrc, opts.material);
|
|
2844
2876
|
}
|
|
2845
2877
|
/** One job per merged group or per unmerged mesh, with a parallel ref list to unwrap results by index. */
|
|
@@ -2891,7 +2923,7 @@ async function tryBuildViaAssembly(opened, opts) {
|
|
|
2891
2923
|
if (indexCount / 3 < 5e4) return null;
|
|
2892
2924
|
const materialsSrc = raw.metadata.materials ?? opts.fallback?.materials ?? [];
|
|
2893
2925
|
const groups = raw.metadata.groups ?? opts.fallback?.groups ?? [];
|
|
2894
|
-
|
|
2926
|
+
validateBatchMetadata(materialsSrc, groups, raw.vertexCount, indexCount);
|
|
2895
2927
|
const { jobs, jobRefs } = planJobs(groups, opts.mergeByMaterial);
|
|
2896
2928
|
const assembled = await assemble(raw, jobs, opened.source);
|
|
2897
2929
|
if (!assembled) {
|
|
@@ -2931,7 +2963,15 @@ async function assemble(raw, jobs, source) {
|
|
|
2931
2963
|
colors: raw.colors,
|
|
2932
2964
|
jobs
|
|
2933
2965
|
};
|
|
2934
|
-
if (!worker)
|
|
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
|
+
}
|
|
2935
2975
|
if (!ownsBuffer(raw, source)) {
|
|
2936
2976
|
input.vertexData = raw.vertexData.slice();
|
|
2937
2977
|
input.indexData = raw.indexData.slice();
|