Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/npm-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/python-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion python/meshly/packable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand All @@ -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,
)

Expand Down Expand Up @@ -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
Expand Down
63 changes: 32 additions & 31 deletions python/meshly/utils/checksum_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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":<data>,"json_schema":<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:
Expand Down
21 changes: 17 additions & 4 deletions python/meshly/utils/schema_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions python/meshly/utils/serialization_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
4 changes: 3 additions & 1 deletion typescript/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions typescript/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading