diff --git a/.github/workflows/npm-test.yml b/.github/workflows/npm-test.yml index 9e6434e..24c46e0 100644 --- a/.github/workflows/npm-test.yml +++ b/.github/workflows/npm-test.yml @@ -27,6 +27,18 @@ jobs: uses: pnpm/action-setup@v2 with: version: 8 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install Python dependencies and generate fixtures + run: | + cd python + pip install -e . + cd ../typescript/src/__tests__/fixtures + python generate_textured_mesh.py - name: Install dependencies run: | diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 9a6904c..4b25186 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9', '3.10', '3.11'] + python-version: ['3.10', '3.11'] steps: - uses: actions/checkout@v3 diff --git a/python/meshly/packable.py b/python/meshly/packable.py index a057c44..942e72d 100644 --- a/python/meshly/packable.py +++ b/python/meshly/packable.py @@ -278,6 +278,10 @@ class Mesh(Packable): _cached_encode: Optional[bytes] = PrivateAttr(default=None) """Cached encoded bytes for reconstructed Packables to avoid re-encoding.""" + _original_json_schema: Optional[dict[str, Any]] = PrivateAttr(default=None) + """JSON schema carried from reconstruction, used when model_json_schema() is unavailable + (e.g. dynamic models with numpy fields that Pydantic cannot generate a schema for).""" + class Config: arbitrary_types_allowed = True @@ -314,6 +318,21 @@ def cached_json_schema(cls) -> dict[str, Any]: """ return cls.model_json_schema() + def _get_json_schema(self) -> dict[str, Any]: + """Return the JSON schema for this instance. + + Prefers the class-level cached schema from ``cached_json_schema()``. + Falls back to ``_original_json_schema`` carried from reconstruction + when the class-level call fails (e.g. dynamic models whose numpy + fields prevent Pydantic from generating a schema). + """ + try: + return type(self).cached_json_schema() + except Exception: + if self._original_json_schema is not None: + return self._original_json_schema + raise + def extract(self) -> "ExtractedPackable": """Extract arrays and Packables from this model into serializable data and assets. @@ -332,9 +351,17 @@ def extract(self) -> "ExtractedPackable": assert isinstance(extracted_result.value, dict), "Extracted value must be a dict for Packable models" + try: + schema = type(self).cached_json_schema() + except Exception: + if self._original_json_schema is not None: + schema = self._original_json_schema + else: + raise + extracted = ExtractedPackable( data=extracted_result.value, - json_schema=type(self).cached_json_schema(), + json_schema=self._get_json_schema(), assets=extracted_result.assets, ) @@ -472,6 +499,12 @@ def reconstruct( resolved_data = SchemaUtils.resolve_from_class(cls, extracted.data, asset_provider, array_type) result = cls(**resolved_data) + # Preserve the original schema on Packable instances so that + # _get_json_schema() can fall back to it when the class-level + # cached_json_schema() is unavailable (dynamic models). + if isinstance(result, Packable) and not is_lazy: + result._original_json_schema = extracted.json_schema + return result @staticmethod diff --git a/python/meshly/utils/checksum_utils.py b/python/meshly/utils/checksum_utils.py index 716eaa2..46f0389 100644 --- a/python/meshly/utils/checksum_utils.py +++ b/python/meshly/utils/checksum_utils.py @@ -3,10 +3,8 @@ import hashlib import json from pathlib import Path -from typing import Any, Optional, Union +from typing import Any, Optional -import orjson -from pydantic import BaseModel class ChecksumUtils: """Utility class for computing checksums.""" @@ -15,44 +13,47 @@ class ChecksumUtils: LARGE_FILE_THRESHOLD = 10 * 1024 * 1024 # 10MB LARGE_DIR_FILE_COUNT_THRESHOLD = 100 - @staticmethod - def compute_dict_checksum(data: Union[dict, BaseModel]) -> str: - """SHA256 checksum computed from data and json_schema. - - Checksum Format: - SHA256 of compact JSON: {"data":,"json_schema":} - Keys are sorted, no whitespace (single line). - - Why JSON-based: - The data dict contains $ref entries pointing to asset checksums, - so this checksum transitively covers all array/binary content. - This format makes checksum recreation straightforward outside meshly: - - import hashlib, json - payload = {"data": extracted_data, "json_schema": schema} - compact_json = json.dumps(payload, sort_keys=True, separators=(',', ':')) - checksum = hashlib.sha256(compact_json.encode()).hexdigest() - - Returns: - SHA256 hex digest string - """ - data_dict = data.model_dump() if isinstance(data, BaseModel) else data - json_bytes = orjson.dumps(data_dict, option=orjson.OPT_SORT_KEYS) - return ChecksumUtils.compute_bytes_checksum(json_bytes) - @staticmethod def compute_bytes_checksum(data: bytes) -> str: - """Compute SHA256 checksum for bytes. + """Compute SHA-256 checksum for bytes. Args: data: Bytes to hash Returns: - 16-character hex string (SHA256) + 64-character hex string (SHA-256) """ - return hashlib.sha256(data).hexdigest()[:16] + return hashlib.sha256(data).hexdigest() + + @staticmethod + def compute_dict_checksum( + data: dict[str, Any], + assets: Optional[dict[str, bytes]] = None, + ) -> str: + """Compute checksum for a data dict, optionally including asset bytes. + Uses compact JSON (no whitespace) with sorted keys to match the + TypeScript ChecksumUtils.computeDictChecksum implementation. + When *assets* is provided and non-empty, a null-byte separator and + the concatenated asset bytes (in sorted-key order) are appended + before hashing. + + Args: + data: JSON-serializable dict + assets: Optional map of checksum -> bytes + + Returns: + 16-character hex string (truncated SHA256) + """ + data_json = json.dumps(data, sort_keys=True, separators=(",", ":")).encode("utf-8") + hasher = hashlib.sha256() + hasher.update(data_json) + if assets: + hasher.update(b"\x00") + for checksum in sorted(assets.keys()): + hasher.update(assets[checksum]) + return hasher.hexdigest()[:16] @staticmethod def compute_file_checksum(file_path: Path, fast: bool = False) -> str: diff --git a/python/meshly/utils/schema_utils.py b/python/meshly/utils/schema_utils.py index 932b4b1..e64015c 100644 --- a/python/meshly/utils/schema_utils.py +++ b/python/meshly/utils/schema_utils.py @@ -114,13 +114,19 @@ def _is_resource_ref(t: type) -> bool: @staticmethod def _matches_discriminator(model_type: type[BaseModel], value: dict) -> bool: - """Check if a BaseModel type matches data via a Literal discriminator field.""" + """Check if a BaseModel type matches data via Literal discriminator fields. + + All Literal fields present in both the model and the data must match. + Returns False if no discriminator fields are found or any mismatch. + """ from typing import Literal + matched = False for field_name, field_info in model_type.model_fields.items(): if field_name in value and get_origin(field_info.annotation) is Literal: - if value[field_name] in get_args(field_info.annotation): - return True - return False + if value[field_name] not in get_args(field_info.annotation): + return False + matched = True + return matched @staticmethod def _load_class(module_path: str) -> Union[type, None]: @@ -202,6 +208,13 @@ def _resolve_with_type( return expected_type.decode( SerializationUtils.get_asset(assets, value["$ref"]), array_type ) + # Untyped reference — no type info to determine how to decode. + # If it looks like an array ref (has shape/dtype), fall through to + # array decoding. Otherwise return raw dict so e.g. JSON-patch + # values with $ref keys pass through. + if expected_type is object or expected_type is typing.Any: + if "shape" not in value or "dtype" not in value: + return value # Array - get encoding from type annotation encoding = ArrayUtils.get_array_encoding(expected_type) return ArrayUtils.reconstruct( diff --git a/python/meshly/utils/serialization_utils.py b/python/meshly/utils/serialization_utils.py index fb6ab66..d995c6b 100644 --- a/python/meshly/utils/serialization_utils.py +++ b/python/meshly/utils/serialization_utils.py @@ -160,9 +160,9 @@ def extract_value(value: object) -> ExtractedResult: assets={k: v for e in items for k, v in e.assets.items()}, ) - # BaseModels: extract fields + # BaseModels: extract fields (include computed fields like bbox, render_order) if isinstance(value, BaseModel): - return SerializationUtils.extract_basemodel(value) + return SerializationUtils.extract_basemodel(value, include_computed=True) # Common non-primitive types if isinstance(value, datetime): diff --git a/python/pyproject.toml b/python/pyproject.toml index d886375..a58fac6 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "meshly" -version = "3.3.0-alpha" +version = "3.4.0-alpha" description = "High-level abstractions and utilities for working with meshoptimizer" readme = "README.md" license = {text = "MIT"} diff --git a/typescript/package.json b/typescript/package.json index 15be57c..bbd5f00 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -1,6 +1,6 @@ { "name": "meshly", - "version": "3.3.0-alpha", + "version": "3.4.0-alpha", "type": "commonjs", "description": "TypeScript library to decode Python meshoptimizer zip files into THREE.js geometries", "main": "dist/index.js", @@ -19,11 +19,13 @@ "author": "", "license": "MIT", "dependencies": { + "earcut": "^3.0.2", "jszip": "^3.10.1", "meshoptimizer": "^0.22.0", "three": "^0.162.0" }, "devDependencies": { + "@types/earcut": "^3.0.0", "@types/node": "^20.17.24", "@types/three": "^0.162.0", "@typescript-eslint/eslint-plugin": "^8.26.0", diff --git a/typescript/pnpm-lock.yaml b/typescript/pnpm-lock.yaml index 29eb635..7420438 100644 --- a/typescript/pnpm-lock.yaml +++ b/typescript/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + earcut: + specifier: ^3.0.2 + version: 3.0.2 jszip: specifier: ^3.10.1 version: 3.10.1 @@ -18,6 +21,9 @@ importers: specifier: ^0.162.0 version: 0.162.0 devDependencies: + '@types/earcut': + specifier: ^3.0.0 + version: 3.0.0 '@types/node': specifier: ^20.17.24 version: 20.17.24 @@ -355,51 +361,61 @@ packages: resolution: {integrity: sha512-mrA0v3QMy6ZSvEuLs0dMxcO2LnaCONs1Z73GUDBHWbY8tFFocM6yl7YyMu7rz4zS81NDSqhrUuolyZXGi8TEqg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.35.0': resolution: {integrity: sha512-DnYhhzcvTAKNexIql8pFajr0PiDGrIsBYPRvCKlA5ixSS3uwo/CWNZxB09jhIapEIg945KOzcYEAGGSmTSpk7A==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.35.0': resolution: {integrity: sha512-uagpnH2M2g2b5iLsCTZ35CL1FgyuzzJQ8L9VtlJ+FckBXroTwNOaD0z0/UF+k5K3aNQjbm8LIVpxykUOQt1m/A==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.35.0': resolution: {integrity: sha512-XQxVOCd6VJeHQA/7YcqyV0/88N6ysSVzRjJ9I9UA/xXpEsjvAgDTgH3wQYz5bmr7SPtVK2TsP2fQ2N9L4ukoUg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.35.0': resolution: {integrity: sha512-5pMT5PzfgwcXEwOaSrqVsz/LvjDZt+vQ8RT/70yhPU06PTuq8WaHhfT1LW+cdD7mW6i/J5/XIkX/1tCAkh1W6g==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.35.0': resolution: {integrity: sha512-c+zkcvbhbXF98f4CtEIP1EBA/lCic5xB0lToneZYvMeKu5Kamq3O8gqrxiYYLzlZH6E3Aq+TSW86E4ay8iD8EA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.35.0': resolution: {integrity: sha512-s91fuAHdOwH/Tad2tzTtPX7UZyytHIRR6V4+2IGlV0Cej5rkG0R61SX4l4y9sh0JBibMiploZx3oHKPnQBKe4g==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.35.0': resolution: {integrity: sha512-hQRkPQPLYJZYGP+Hj4fR9dDBMIM7zrzJDWFEMPdTnTy95Ljnv0/4w/ixFw3pTBMEuuEuoqtBINYND4M7ujcuQw==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.35.0': resolution: {integrity: sha512-Pim1T8rXOri+0HmV4CdKSGrqcBWX0d1HoPnQ0uw0bdp1aP5SdQVNBy8LjYncvnLgu3fnnCt17xjWGd4cqh8/hA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.35.0': resolution: {integrity: sha512-QysqXzYiDvQWfUiTm8XmJNO2zm9yC9P/2Gkrwg2dH9cxotQzunBHYr6jk4SujCTqnfGxduOmQcI7c2ryuW8XVg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.35.0': resolution: {integrity: sha512-OUOlGqPkVJCdJETKOCEf1mw848ZyJ5w50/rZ/3IBQVdLfR5jk/6Sr5m3iO2tdPgwo0x7VcncYuOvMhBWZq8ayg==} @@ -431,6 +447,9 @@ packages: '@tweenjs/tween.js@23.1.3': resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@types/earcut@3.0.0': + resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -650,6 +669,9 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -1526,6 +1548,8 @@ snapshots: '@tweenjs/tween.js@23.1.3': {} + '@types/earcut@3.0.0': {} + '@types/estree@1.0.6': {} '@types/json-schema@7.0.15': {} @@ -1776,6 +1800,8 @@ snapshots: diff@4.0.2: {} + earcut@3.0.2: {} + eastasianwidth@0.2.0: {} emoji-regex@8.0.0: {} diff --git a/typescript/src/__tests__/fixtures/generate_textured_mesh.py b/typescript/src/__tests__/fixtures/generate_textured_mesh.py index dc23eee..71997f6 100644 --- a/typescript/src/__tests__/fixtures/generate_textured_mesh.py +++ b/typescript/src/__tests__/fixtures/generate_textured_mesh.py @@ -71,8 +71,8 @@ class TexturedMesh(Mesh): def create_textured_mesh() -> TexturedMesh: - """Create a simple textured cube mesh.""" - # Create vertices for a cube + """Create a simple textured cube mesh using Mesh.from_triangles.""" + # Cube vertices vertices = np.array([ [-0.5, -0.5, -0.5], # 0: bottom-left-back [0.5, -0.5, -0.5], # 1: bottom-right-back @@ -84,38 +84,26 @@ def create_textured_mesh() -> TexturedMesh: [-0.5, 0.5, 0.5] # 7: top-left-front ], dtype=np.float32) - # Create indices for the cube (2 triangles per face, 6 faces) - indices = np.array([ - 0, 1, 2, 2, 3, 0, # back face - 1, 5, 6, 6, 2, 1, # right face - 5, 4, 7, 7, 6, 5, # front face - 4, 0, 3, 3, 7, 4, # left face - 3, 2, 6, 6, 7, 3, # top face - 4, 5, 1, 1, 0, 4 # bottom face + # Cube triangles (2 per face, 6 faces = 12 triangles) + triangles = np.array([ + [0, 1, 2], [2, 3, 0], # back face + [1, 5, 6], [6, 2, 1], # right face + [5, 4, 7], [7, 6, 5], # front face + [4, 0, 3], [3, 7, 4], # left face + [3, 2, 6], [6, 7, 3], # top face + [4, 5, 1], [1, 0, 4] # bottom face ], dtype=np.uint32) - # Create texture coordinates (one for each vertex) + # Texture coordinates (per vertex) texture_coords = np.array([ - [0.0, 0.0], # 0 - [1.0, 0.0], # 1 - [1.0, 1.0], # 2 - [0.0, 1.0], # 3 - [0.0, 0.0], # 4 - [1.0, 0.0], # 5 - [1.0, 1.0], # 6 - [0.0, 1.0] # 7 + [0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], + [0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0] ], dtype=np.float32) - # Create normals (one for each vertex) + # Normals (per vertex) normals = np.array([ - [0.0, 0.0, -1.0], # 0: back - [0.0, 0.0, -1.0], # 1: back - [0.0, 0.0, -1.0], # 2: back - [0.0, 0.0, -1.0], # 3: back - [0.0, 0.0, 1.0], # 4: front - [0.0, 0.0, 1.0], # 5: front - [0.0, 0.0, 1.0], # 6: front - [0.0, 0.0, 1.0] # 7: front + [0.0, 0.0, -1.0], [0.0, 0.0, -1.0], [0.0, 0.0, -1.0], [0.0, 0.0, -1.0], + [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0] ], dtype=np.float32) # Create MaterialProperties instances @@ -145,10 +133,11 @@ def create_textured_mesh() -> TexturedMesh: ], dtype=np.float32) ) - # Create the textured mesh - mesh = TexturedMesh( + # Create the textured mesh using from_triangles + mesh = TexturedMesh.from_triangles( vertices=vertices, - indices=indices, + triangles=triangles, + dim=2, # Surface mesh (triangles are 2D elements) texture_coords=texture_coords, normals=normals, physics=physics, diff --git a/typescript/src/__tests__/markers.test.ts b/typescript/src/__tests__/markers.test.ts index 7d93032..c53997f 100644 --- a/typescript/src/__tests__/markers.test.ts +++ b/typescript/src/__tests__/markers.test.ts @@ -141,7 +141,7 @@ describe('Mesh Markers', () => { }) expect(() => mesh.extractByMarker("incomplete")).toThrow( - "Marker 'incomplete' is missing sizes or cell type information" + "Marker 'incomplete' is missing sizes information" ) }) diff --git a/typescript/src/index.ts b/typescript/src/index.ts index d9489c2..2f28f94 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -66,6 +66,8 @@ export { DecodeArrayMessage, DecodeArrayResponse, DecodeMessage, + DecodeMeshMessage, + DecodeMeshResponse, DecodeResponse, PackableWorkerClient, PackableWorkerMessage, PackableWorkerResponse, diff --git a/typescript/src/mesh.ts b/typescript/src/mesh.ts index 583094a..4110f03 100644 --- a/typescript/src/mesh.ts +++ b/typescript/src/mesh.ts @@ -1,3 +1,4 @@ +import earcut from 'earcut' import JSZip from 'jszip' import * as THREE from 'three' import { ArrayUtils, TypedArray } from './array' @@ -45,12 +46,12 @@ export interface MeshData { markers?: Record /** - * Sizes of each marker element + * Number of vertices per marker element */ markerSizes?: Record /** - * VTK cell types for each marker element + * VTK cell type for each marker element (uint8) */ markerCellTypes?: Record } @@ -80,8 +81,8 @@ export class Mesh extends Packable { declare cellTypes?: TypedArray declare dim?: number declare markers?: Record - declare markerSizes?: Record - declare markerCellTypes?: Record + declare markerSizes?: Record + declare markerCellTypes?: Record // ============================================================ // Decode from zip @@ -137,7 +138,7 @@ export class Mesh extends Packable { indexSizes: (result.index_sizes || result.indexSizes) as Uint32Array | undefined, cellTypes: (result.cell_types || result.cellTypes) as Uint32Array | undefined, dim: result.dim as number | undefined, - markers: Mesh._convertMarkers(result.markers as Record | undefined), + markers: Mesh._convertMarkers(result.markers as Record | undefined) as Record | undefined, markerSizes: Mesh._convertMarkers(result.marker_sizes as Record | undefined), markerCellTypes: Mesh._convertMarkers(result.marker_cell_types as Record | undefined), } @@ -212,16 +213,15 @@ export class Mesh extends Packable { } /** - * Convert markers from Record to Record + * Convert markers from Record to Record. + * Preserves the original typed array type (uint8 for cell types, uint32 for indices/sizes). */ - private static _convertMarkers(markers: Record | undefined): Record | undefined { + private static _convertMarkers(markers: Record | undefined): Record | undefined { if (!markers) return undefined - const result: Record = {} + const result: Record = {} for (const [key, value] of Object.entries(markers)) { - if (value instanceof Uint32Array) { - result[key] = value - } else if (ArrayBuffer.isView(value)) { - result[key] = new Uint32Array((value as TypedArray).buffer) + if (ArrayBuffer.isView(value)) { + result[key] = value as TypedArray } } return Object.keys(result).length > 0 ? result : undefined @@ -302,8 +302,8 @@ export class Mesh extends Packable { const markerSizes = this.markerSizes?.[markerName] const markerCellTypes = this.markerCellTypes?.[markerName] - if (!markerSizes || !markerCellTypes) { - throw new Error(`Marker '${markerName}' is missing sizes or cell type information`) + if (!markerSizes) { + throw new Error(`Marker '${markerName}' is missing sizes information`) } // Find all unique vertex indices @@ -354,7 +354,8 @@ export class Mesh extends Packable { // ============================================================ /** - * Convert this mesh to a THREE.js BufferGeometry + * Convert this mesh to a THREE.js BufferGeometry. + * Triangulates non-triangle faces using earcut (handles concave polygons). */ toBufferGeometry(): THREE.BufferGeometry { const geometry = new THREE.BufferGeometry() @@ -362,12 +363,233 @@ export class Mesh extends Packable { geometry.setAttribute('position', new THREE.BufferAttribute(this.vertices, 3)) if (this.indices && this.indices.length > 0) { - geometry.setIndex(new THREE.BufferAttribute(this.indices, 1)) + const triIndices = Mesh.triangulateIndices(this.indices, this.indexSizes, this.cellTypes, this.vertices) + geometry.setIndex(new THREE.BufferAttribute(triIndices, 1)) } + geometry.computeVertexNormals() return geometry } + /** + * Create a BufferGeometry from pre-computed raw arrays (from worker). + */ + static rawToBufferGeometry(raw: { positions: Float32Array; indices: Uint32Array; normals: Float32Array }): THREE.BufferGeometry { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.BufferAttribute(raw.positions, 3)) + geometry.setAttribute('normal', new THREE.BufferAttribute(raw.normals, 3)) + if (raw.indices.length > 0) { + geometry.setIndex(new THREE.BufferAttribute(raw.indices, 1)) + } + return geometry + } + + /** + * Triangulate indices into triangle indices. Handles 3D volume cells (tet, hex, + * wedge, pyramid) and surface cells (tri, quad, polygon with earcut). + * This is the single source of truth for all triangulation in meshly. + */ + static triangulateIndices( + indices: Uint32Array, + indexSizes?: ArrayLike, + cellTypes?: ArrayLike, + vertices?: Float32Array + ): Uint32Array { + if (!indices || indices.length === 0) return new Uint32Array(0) + + // Fast path: all triangles + if (!indexSizes || Mesh._allTriangles(indexSizes)) return indices + + return Mesh._triangulateIndicesImpl(indices, indexSizes, cellTypes, vertices) + } + + private static _allTriangles(sizes: ArrayLike): boolean { + for (let i = 0; i < sizes.length; i++) { + if (sizes[i] !== 3) return false + } + return true + } + + // VTK 3D cell type constants + private static readonly VTK_TETRA = 10 + private static readonly VTK_HEXAHEDRON = 12 + private static readonly VTK_WEDGE = 13 + private static readonly VTK_PYRAMID = 14 + + // Face tables for 3D cells (VTK canonical winding, outward normals) + private static readonly TET_FACES = [[0, 1, 3], [1, 2, 3], [2, 0, 3], [0, 2, 1]] + private static readonly HEX_FACES = [ + [0, 3, 2, 1], [4, 5, 6, 7], + [0, 1, 5, 4], [2, 3, 7, 6], + [0, 4, 7, 3], [1, 2, 6, 5], + ] + private static readonly WEDGE_FACES = [ + [0, 2, 1], [3, 4, 5], + [0, 1, 4, 3], [1, 2, 5, 4], [0, 3, 5, 2], + ] + private static readonly PYRAMID_FACES = [ + [0, 3, 2, 1], + [0, 1, 4], [1, 2, 4], [2, 3, 4], [3, 0, 4], + ] + + /** + * Get the face table for a 3D VTK cell type, or null for surface cells. + */ + private static _getCellFaces(cellType: number): number[][] | null { + switch (cellType) { + case Mesh.VTK_TETRA: return Mesh.TET_FACES + case Mesh.VTK_HEXAHEDRON: return Mesh.HEX_FACES + case Mesh.VTK_WEDGE: return Mesh.WEDGE_FACES + case Mesh.VTK_PYRAMID: return Mesh.PYRAMID_FACES + default: return null + } + } + + /** + * Count the number of output triangles for a cell given its type and vertex count. + */ + private static _cellTriCount(cellType: number | undefined, n: number): number { + if (cellType !== undefined) { + const faces = Mesh._getCellFaces(cellType) + if (faces) { + let count = 0 + for (const f of faces) count += f.length - 2 + return count + } + } + return Math.max(0, n - 2) + } + + private static _triangulateIndicesImpl( + indices: Uint32Array, + sizes: ArrayLike, + cellTypes?: ArrayLike, + verts?: Float32Array + ): Uint32Array { + + // First pass: count output triangles + let triCount = 0 + for (let i = 0; i < sizes.length; i++) { + triCount += Mesh._cellTriCount(cellTypes?.[i], sizes[i]) + } + + const out = new Uint32Array(triCount * 3) + let srcOffset = 0 + let dstOffset = 0 + + for (let i = 0; i < sizes.length; i++) { + const n = sizes[i] + const ct = cellTypes?.[i] + + // Check for 3D volume cell types + const faces = ct !== undefined ? Mesh._getCellFaces(ct) : null + if (faces) { + for (const face of faces) { + const v0 = indices[srcOffset + face[0]] + if (face.length === 3) { + out[dstOffset++] = v0 + out[dstOffset++] = indices[srcOffset + face[1]] + out[dstOffset++] = indices[srcOffset + face[2]] + } else { + // Quad face -> 2 triangles (fan) + const v1 = indices[srcOffset + face[1]] + const v2 = indices[srcOffset + face[2]] + const v3 = indices[srcOffset + face[3]] + out[dstOffset++] = v0; out[dstOffset++] = v1; out[dstOffset++] = v2 + out[dstOffset++] = v0; out[dstOffset++] = v2; out[dstOffset++] = v3 + } + } + srcOffset += n + continue + } + + // Surface cells + if (n === 3) { + out[dstOffset++] = indices[srcOffset] + out[dstOffset++] = indices[srcOffset + 1] + out[dstOffset++] = indices[srcOffset + 2] + } else if (n === 4) { + const v0 = indices[srcOffset], v1 = indices[srcOffset + 1] + const v2 = indices[srcOffset + 2], v3 = indices[srcOffset + 3] + out[dstOffset++] = v0; out[dstOffset++] = v1; out[dstOffset++] = v2 + out[dstOffset++] = v0; out[dstOffset++] = v2; out[dstOffset++] = v3 + } else if (n > 4) { + // Use earcut for polygons with 5+ vertices (handles concave) + const projected = verts ? Mesh._projectFaceTo2D(indices, srcOffset, n, verts) : null + const localTris = projected ? earcut(projected, undefined, 2) : [] + + if (localTris.length > 0) { + for (let j = 0; j < localTris.length; j++) { + out[dstOffset++] = indices[srcOffset + localTris[j]] + } + } else { + const v0 = indices[srcOffset] + for (let j = 1; j < n - 1; j++) { + out[dstOffset++] = v0 + out[dstOffset++] = indices[srcOffset + j] + out[dstOffset++] = indices[srcOffset + j + 1] + } + } + } + + srcOffset += n + } + + return dstOffset === out.length ? out : out.slice(0, dstOffset) + } + + /** + * Project a 3D polygon face onto its dominant 2D plane for earcut. + * Computes face normal and projects vertices onto the plane perpendicular to the largest normal component. + */ + private static _projectFaceTo2D( + indices: Uint32Array, + offset: number, + count: number, + verts: Float32Array + ): number[] { + // Compute face normal via Newell's method + let nx = 0, ny = 0, nz = 0 + for (let i = 0; i < count; i++) { + const ci = indices[offset + i] * 3 + const ni = indices[offset + (i + 1) % count] * 3 + const cx = verts[ci], cy = verts[ci + 1], cz = verts[ci + 2] + const nnx = verts[ni], nny = verts[ni + 1], nnz = verts[ni + 2] + nx += (cy - nny) * (cz + nnz) + ny += (cz - nnz) * (cx + nnx) + nz += (cx - nnx) * (cy + nny) + } + + const ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz) + const coords: number[] = new Array(count * 2) + + // Project onto the plane perpendicular to the dominant normal axis + if (ax >= ay && ax >= az) { + // Drop X, use Y-Z + for (let i = 0; i < count; i++) { + const vi = indices[offset + i] * 3 + coords[i * 2] = verts[vi + 1] + coords[i * 2 + 1] = verts[vi + 2] + } + } else if (ay >= ax && ay >= az) { + // Drop Y, use X-Z + for (let i = 0; i < count; i++) { + const vi = indices[offset + i] * 3 + coords[i * 2] = verts[vi] + coords[i * 2 + 1] = verts[vi + 2] + } + } else { + // Drop Z, use X-Y + for (let i = 0; i < count; i++) { + const vi = indices[offset + i] * 3 + coords[i * 2] = verts[vi] + coords[i * 2 + 1] = verts[vi + 1] + } + } + + return coords + } + /** * Extract a submesh by marker and convert to BufferGeometry */ diff --git a/typescript/src/packable-worker.ts b/typescript/src/packable-worker.ts index ca8b780..65d2436 100644 --- a/typescript/src/packable-worker.ts +++ b/typescript/src/packable-worker.ts @@ -16,6 +16,7 @@ import { ArrayRefInfo, ArrayUtils } from './array' import { JsonSchema } from './json-schema' +import { Mesh } from './mesh' import { Packable } from './packable' // ─── Worker Message Types ─── @@ -47,7 +48,13 @@ export interface DecodeArrayMessage { info: ArrayRefInfo } -export type PackableWorkerMessage = ReconstructMessage | DecodeMessage | DecodeArrayMessage +export interface DecodeMeshMessage { + type: 'decodeMesh' + requestId: string + zipData: ArrayBuffer +} + +export type PackableWorkerMessage = ReconstructMessage | DecodeMessage | DecodeArrayMessage | DecodeMeshMessage // ─── Worker Response Types ─── @@ -75,7 +82,14 @@ export interface DecodeArrayResponse { error?: string } -export type PackableWorkerResponse = ReconstructResponse | DecodeResponse | DecodeArrayResponse +export interface DecodeMeshResponse { + type: 'meshDecoded' + requestId: string + result: Record | null + error?: string +} + +export type PackableWorkerResponse = ReconstructResponse | DecodeResponse | DecodeArrayResponse | DecodeMeshResponse // ─── Worker Implementation ─── @@ -109,6 +123,55 @@ function collectTransferables(obj: unknown, transferables: Set): vo } } +/** + * Convert a Mesh to raw geometry arrays (positions, indices, normals) without THREE.js. + * Uses Mesh.triangulateIndices for cell-type-aware triangulation. + */ +function meshToRawGeometry(mesh: Mesh): { positions: Float32Array; indices: Uint32Array; normals: Float32Array } { + const positions = mesh.vertices + const triIndices = mesh.indices + ? Mesh.triangulateIndices(mesh.indices, mesh.indexSizes, mesh.cellTypes, mesh.vertices) + : new Uint32Array(0) + + const normals = new Float32Array(positions.length) + + // Accumulate face normals onto each vertex + for (let i = 0; i < triIndices.length; i += 3) { + const i0 = triIndices[i] * 3 + const i1 = triIndices[i + 1] * 3 + const i2 = triIndices[i + 2] * 3 + + // Edge vectors from v0 + const ax = positions[i1] - positions[i0] + const ay = positions[i1 + 1] - positions[i0 + 1] + const az = positions[i1 + 2] - positions[i0 + 2] + const bx = positions[i2] - positions[i0] + const by = positions[i2 + 1] - positions[i0 + 1] + const bz = positions[i2 + 2] - positions[i0 + 2] + + // Cross product (face normal, area-weighted) + const nx = ay * bz - az * by + const ny = az * bx - ax * bz + const nz = ax * by - ay * bx + + normals[i0] += nx; normals[i0 + 1] += ny; normals[i0 + 2] += nz + normals[i1] += nx; normals[i1 + 1] += ny; normals[i1 + 2] += nz + normals[i2] += nx; normals[i2 + 1] += ny; normals[i2 + 2] += nz + } + + // Normalize + for (let i = 0; i < normals.length; i += 3) { + const len = Math.sqrt(normals[i] ** 2 + normals[i + 1] ** 2 + normals[i + 2] ** 2) + if (len > 0) { + normals[i] /= len + normals[i + 1] /= len + normals[i + 2] /= len + } + } + + return { positions, indices: triIndices, normals } +} + /** * Initialize the worker message handler. * Call this at the top level of your worker file. @@ -212,6 +275,47 @@ export function initPackableWorker(): void { } self.postMessage(response) } + } else if (message.type === 'decodeMesh') { + const { requestId, zipData } = message + try { + const mesh = await Mesh.decode(zipData) + const result: Record = {} + const transferables = new Set() + + const markerNames = mesh.markers ? Object.keys(mesh.markers) : [] + if (markerNames.length > 0) { + for (const name of markerNames) { + const subMesh = mesh.extractByMarker(name) + const geo = meshToRawGeometry(subMesh) + result[name] = geo + transferables.add(geo.positions.buffer) + transferables.add(geo.indices.buffer) + transferables.add(geo.normals.buffer) + } + } else { + const geo = meshToRawGeometry(mesh) + result['default'] = geo + transferables.add(geo.positions.buffer) + transferables.add(geo.indices.buffer) + transferables.add(geo.normals.buffer) + } + + const response: DecodeMeshResponse = { + type: 'meshDecoded', + requestId, + result, + } + + self.postMessage(response, { transfer: Array.from(transferables) }) + } catch (error) { + const response: DecodeMeshResponse = { + type: 'meshDecoded', + requestId, + result: null, + error: error instanceof Error ? error.message : String(error), + } + self.postMessage(response) + } } }) @@ -401,6 +505,31 @@ export class PackableWorkerClient { }) } + /** + * Decode a mesh zip, triangulate per marker, and compute normals in the worker. + * Handles 3D volume cell types (tet, hex, wedge, pyramid) by extracting boundary faces. + */ + async decodeMesh(zipData: ArrayBuffer): Promise> { + await this.ready + + const requestId = this.generateRequestId() + + return new Promise((resolve, reject) => { + this.pendingRequests.set(requestId, { + resolve: resolve as (value: unknown) => void, + reject, + }) + + const message: DecodeMeshMessage = { + type: 'decodeMesh', + requestId, + zipData, + } + + this.worker.postMessage(message, { transfer: [zipData] }) + }) + } + /** * Terminate the worker. */