Skip to content

Commit 018bf17

Browse files
committed
feat: Add InlineArray type for inline JSON serialization; update related documentation and utilities
1 parent 01c9410 commit 018bf17

8 files changed

Lines changed: 103 additions & 33 deletions

File tree

python/README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@ pip install meshly
2626

2727
- **`Array`**: Generic array type with meshoptimizer compression
2828
- **`IndexSequence`**: Optimized encoding for mesh indices (1D array)
29+
- **`InlineArray`**: Array serialized as inline JSON list (no binary compression)
2930

3031
### Key Capabilities
3132

32-
- Automatic encoding/decoding of numpy array attributes via `Array`, `IndexSequence` type annotations
33+
- Automatic encoding/decoding of numpy array attributes via `Array`, `IndexSequence`, `InlineArray` type annotations
3334
- Custom subclasses with additional array fields are automatically serialized
3435
- **Extract/Reconstruct API** for content-addressable storage with deduplication
3536
- **PackableStore** for file-based persistent storage with automatic deduplication
@@ -158,7 +159,7 @@ loaded = TexturedMesh.load_from_zip("textured.zip")
158159
Use specialized array types for optimized encoding:
159160

160161
```python
161-
from meshly import Packable, Array, IndexSequence
162+
from meshly import Packable, Array, IndexSequence, InlineArray
162163
from pydantic import Field
163164

164165
class OptimizedMesh(Packable):
@@ -171,6 +172,9 @@ class OptimizedMesh(Packable):
171172

172173
# IndexSequence: optimized for mesh indices
173174
indices: IndexSequence = Field(..., description="Triangle indices")
175+
176+
# InlineArray: small arrays serialized inline (no binary compression)
177+
color: InlineArray = Field(..., description="RGB color")
174178
```
175179

176180
> **Note:** All dtypes are supported. Arrays with non-4-byte dtypes (e.g., `float16`, `int8`, `uint8`) are automatically padded to 4-byte alignment during encoding and unpadded during decoding (meshoptimizer requirement). For best performance, prefer 4-byte aligned dtypes like `float32`, `int32`, or `float64`.
@@ -469,6 +473,7 @@ Packable (base class)
469473
```
470474
Array → Generic meshoptimizer compression
471475
IndexSequence → Optimized for mesh indices
476+
InlineArray → Serialized as inline JSON list (no binary $ref)
472477
```
473478

474479
The `Packable` base class provides:
@@ -625,13 +630,14 @@ jax_mesh = mesh.convert_to("jax")
625630
### Array Type Annotations
626631

627632
```python
628-
from meshly import Array, IndexSequence
633+
from meshly import Array, IndexSequence, InlineArray
629634

630635
# Use in Pydantic models for automatic encoding
631636
class MyData(Packable):
632637
generic_data: Array # Generic meshoptimizer compression
633638
vertices: Array # All arrays use meshoptimizer compression
634639
indices: IndexSequence # Optimized for mesh indices
640+
color: InlineArray # Small arrays as inline JSON (no $ref)
635641
```
636642

637643
### ArrayUtils

python/meshly/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
ExtractedArray,
2222
ArrayEncoding,
2323
IndexSequence,
24-
List,
24+
InlineArray,
2525
)
2626
from meshly.cell_types import (
2727
CellType,
@@ -55,7 +55,7 @@
5555
"TMesh",
5656
# Array types and utilities
5757
"Array",
58-
"List",
58+
"InlineArray",
5959
"IndexSequence",
6060
"ExtractedArray",
6161
"ArrayEncoding",

python/meshly/array.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def __eq__(self, other):
8585
return isinstance(other, _ArrayAnnotation) and self.encoding == other.encoding
8686

8787

88-
class _ListAnnotation:
88+
class _InlineArrayAnnotation:
8989
"""Pydantic annotation for arrays serialized as inline JSON lists."""
9090

9191
def __get_pydantic_core_schema__(
@@ -108,13 +108,13 @@ def _validate(self, v: Any) -> np.ndarray:
108108
def __get_pydantic_json_schema__(
109109
self, core_schema: CoreSchema, handler: GetJsonSchemaHandler
110110
) -> JsonSchemaValue:
111-
return {"type": "list"}
111+
return {"type": "inline_array"}
112112

113113
def __hash__(self):
114-
return hash("list")
114+
return hash("inline_array")
115115

116116
def __eq__(self, other):
117-
return isinstance(other, _ListAnnotation)
117+
return isinstance(other, _InlineArrayAnnotation)
118118

119119

120120
# Public type aliases for Pydantic models
@@ -124,8 +124,8 @@ def __eq__(self, other):
124124
IndexSequence = Annotated[np.ndarray, _ArrayAnnotation("index_sequence")]
125125
"""Optimized for mesh indices (1D array)."""
126126

127-
List = Annotated[np.ndarray, _ListAnnotation()]
128-
"""Array serialized as inline JSON list (no binary $ref)."""
127+
InlineArray = Annotated[Union[list, np.ndarray], _InlineArrayAnnotation()]
128+
"""Array serialized as inline JSON list (no binary $ref). Accepts list or ndarray input."""
129129

130130

131131
# =============================================================================
@@ -225,20 +225,20 @@ def get_array_encoding(annotation: Any) -> ArrayEncoding:
225225
return "array"
226226

227227
@staticmethod
228-
def is_list_annotation(annotation: Any) -> bool:
229-
"""Check if a type annotation contains _ListAnnotation."""
228+
def is_inlined_array_annotation(annotation: Any) -> bool:
229+
"""Check if a type annotation contains _InlineArrayAnnotation."""
230230
if annotation is None:
231231
return False
232232
origin = get_origin(annotation)
233233
if origin is Union or isinstance(annotation, types.UnionType):
234234
for arg in get_args(annotation):
235235
if arg is not type(None):
236-
if ArrayUtils.is_list_annotation(arg):
236+
if ArrayUtils.is_inlined_array_annotation(arg):
237237
return True
238238
return False
239239
if get_origin(annotation) is Annotated:
240240
for arg in get_args(annotation):
241-
if isinstance(arg, _ListAnnotation):
241+
if isinstance(arg, _InlineArrayAnnotation):
242242
return True
243243
return False
244244

python/meshly/packable.py

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,23 @@
1515
Serialization options:
1616
- save_to_zip() / load_from_zip(): Single self-contained zip file
1717
- save() / load(): File-based asset store with deduplication
18+
19+
Checksum Scheme:
20+
Packable checksums are computed from the JSON representation of extracted data.
21+
This makes checksum recreation straightforward outside this library.
22+
23+
Format: SHA256 of compact JSON: {"data":<data>,"json_schema":<schema>}
24+
Keys are sorted, no whitespace (single line).
25+
26+
The `data` dict contains $ref entries (e.g. {"$ref":"abc123..."}) pointing
27+
to asset checksums, so the packable checksum transitively covers all binary
28+
content without embedding the actual bytes.
29+
30+
To recreate a checksum externally:
31+
import hashlib, json
32+
payload = {"data": packable_data, "json_schema": schema}
33+
compact_json = json.dumps(payload, sort_keys=True, separators=(',', ':'))
34+
checksum = hashlib.sha256(compact_json.encode()).hexdigest()
1835
"""
1936

2037
import time
@@ -68,9 +85,33 @@ class ExtractedPackable(BaseModel):
6885

6986
data: dict[str, Any] = Field(..., description="Serializable dict with primitive fields and checksum refs for arrays")
7087
json_schema: Optional[dict[str, Any]] = Field(default=None, description="JSON Schema with encoding info")
71-
checksum: Optional[str] = Field(default=None, description="Content checksum of the source Packable")
7288
assets: dict[str, bytes] = Field(default_factory=dict, exclude=True, description="Map of checksum -> encoded bytes for all arrays")
7389

90+
@cached_property
91+
def checksum(self) -> str:
92+
"""SHA256 checksum computed from data and json_schema.
93+
94+
Checksum Format:
95+
SHA256 of compact JSON: {"data":<data>,"json_schema":<schema>}
96+
Keys are sorted, no whitespace (single line).
97+
98+
Why JSON-based:
99+
The data dict contains $ref entries pointing to asset checksums,
100+
so this checksum transitively covers all array/binary content.
101+
This format makes checksum recreation straightforward outside meshly:
102+
103+
import hashlib, json
104+
payload = {"data": extracted_data, "json_schema": schema}
105+
compact_json = json.dumps(payload, sort_keys=True, separators=(',', ':'))
106+
checksum = hashlib.sha256(compact_json.encode()).hexdigest()
107+
108+
Returns:
109+
SHA256 hex digest string
110+
"""
111+
payload = {"data": self.data, "json_schema": self.json_schema}
112+
json_bytes = orjson.dumps(payload, option=orjson.OPT_SORT_KEYS)
113+
return ChecksumUtils.compute_bytes_checksum(json_bytes)
114+
74115
def extract_checksums(self) -> list[str]:
75116
"""Extract all $ref checksums from a serialized data dict.
76117
@@ -209,7 +250,6 @@ def load_extracted(self, key: str) -> "ExtractedPackable":
209250
return ExtractedPackable(
210251
data=extracted_data["data"],
211252
json_schema=extracted_data.get("json_schema"),
212-
checksum=extracted_data.get("checksum"),
213253
)
214254

215255
def extracted_exists(self, key: str) -> bool:
@@ -298,7 +338,7 @@ def extract(self) -> "ExtractedPackable":
298338
Results are cached for efficiency. Subsequent calls return the cached result.
299339
300340
Returns:
301-
ExtractedPackable with metadata (data + schema) and binary assets.
341+
ExtractedPackable with metadata (data + schema + checksum) and binary assets.
302342
"""
303343
if self._cached_extract is not None:
304344
return self._cached_extract
@@ -310,11 +350,13 @@ def extract(self) -> "ExtractedPackable":
310350

311351
assert isinstance(extracted_result.value, dict), "Extracted value must be a dict for Packable models"
312352

313-
self._cached_extract = ExtractedPackable(
353+
extracted = ExtractedPackable(
314354
data=extracted_result.value,
315355
json_schema=type(self).cached_json_schema(),
316356
assets=extracted_result.assets,
317357
)
358+
359+
self._cached_extract = extracted
318360
return self._cached_extract
319361

320362
@cached_property
@@ -351,8 +393,22 @@ def encode(self) -> bytes:
351393

352394
@cached_property
353395
def checksum(self) -> str:
354-
"""SHA256 checksum of this Packable's encoded bytes (cached)."""
355-
return ChecksumUtils.compute_bytes_checksum(self._encoded)
396+
"""SHA256 checksum of this Packable's extracted JSON representation (cached).
397+
398+
Checksum Format:
399+
SHA256 of compact JSON: {"data":<extracted_data>,"json_schema":<schema>}
400+
Keys are sorted, no whitespace (single line).
401+
402+
The data dict contains $ref entries pointing to asset checksums (e.g.,
403+
{"$ref":"abc123..."}), so this checksum transitively covers all binary content.
404+
405+
To recreate this checksum outside meshly:
406+
import hashlib, json
407+
payload = {"data": packable_data, "json_schema": schema}
408+
compact_json = json.dumps(payload, sort_keys=True, separators=(',', ':'))
409+
checksum = hashlib.sha256(compact_json.encode()).hexdigest()
410+
"""
411+
return self.extract().checksum
356412

357413
def set_checksum(self, checksum: str) -> None:
358414
"""Pre-populate the cached checksum to avoid re-encoding."""
@@ -392,9 +448,8 @@ def decode(
392448
)
393449
result = cls.reconstruct(extracted, array_type=array_type)
394450

395-
# Cache to ensure consistent checksums on re-encoding
451+
# Cache for efficiency
396452
result._cached_encode = buf
397-
result.set_checksum(ChecksumUtils.compute_bytes_checksum(buf))
398453
result._cached_extract = extracted
399454
return result
400455

@@ -445,7 +500,7 @@ def reconstruct(
445500
resolved_data = SchemaUtils.resolve_from_class(cls, extracted.data, asset_provider, array_type)
446501
result = cls(**resolved_data)
447502

448-
if extracted.checksum and isinstance(result, Packable):
503+
if isinstance(result, Packable):
449504
result.set_checksum(extracted.checksum)
450505
return result
451506

python/meshly/resource.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"""
55

66
from pathlib import Path
7-
from typing import Union
7+
from typing import Optional, Union
88

99
from pydantic import ConfigDict, Field, computed_field
1010

@@ -43,8 +43,8 @@ class SimulationCase(Packable):
4343
model_config = ConfigDict(arbitrary_types_allowed=True)
4444

4545
data: bytes = Field(exclude=True)
46-
ext: str = ""
47-
name: str = ""
46+
ext: Optional[str] = None
47+
name: Optional[str] = None
4848

4949
@staticmethod
5050
def from_path(path: Union[str, Path]) -> "Resource":

python/meshly/utils/schema_utils.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ def _resolve_with_type(
180180
if SchemaUtils._is_resource_ref(expected_type):
181181
asset_bytes = SerializationUtils.get_asset(assets, value["$ref"])
182182
data = gzip.decompress(asset_bytes)
183-
return Resource(data=data, ext=value.get("ext", ""), name=value.get("name", ""))
183+
return Resource(data=data, ext=value.get("ext"), name=value.get("name"))
184184
if isinstance(expected_type, type) and issubclass(expected_type, Packable):
185185
return expected_type.decode(
186186
SerializationUtils.get_asset(assets, value["$ref"]), array_type
@@ -230,7 +230,7 @@ def _resolve_with_type(
230230
return {k: SchemaUtils._resolve_with_type(v, object, assets, array_type) for k, v in value.items()}
231231

232232
# List annotation → reconstruct numpy array from inline JSON list
233-
if isinstance(value, list) and ArrayUtils.is_list_annotation(expected_type):
233+
if isinstance(value, list) and ArrayUtils.is_inlined_array_annotation(expected_type):
234234
return ArrayUtils.convert_array(np.array(value), array_type)
235235

236236
# List/tuple
@@ -313,7 +313,7 @@ def _resolve_with_prop(
313313
if prop and prop.is_resource_type():
314314
# Resource - assets from _extract_resource are always gzip compressed
315315
data = gzip.decompress(asset_bytes)
316-
return Resource(data=data, ext=metadata.get("ext", ""), name=metadata.get("name", ""))
316+
return Resource(data=data, ext=metadata.get("ext"), name=metadata.get("name"))
317317

318318
if prop and prop.is_array_type():
319319
# Array

python/meshly/utils/serialization_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ def extract_basemodel(value: BaseModel, include_computed: bool = False) -> Extra
253253
continue
254254

255255
if ArrayUtils.is_array(field_value):
256-
if ArrayUtils.is_list_annotation(hints.get(name)):
256+
if ArrayUtils.is_inlined_array_annotation(hints.get(name)):
257257
data[name] = np.asarray(field_value).tolist()
258258
else:
259259
encoding = ArrayUtils.get_array_encoding(hints.get(name))

typescript/src/json-schema.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,20 +140,29 @@ export interface JsonSchema {
140140
*/
141141
export class JsonSchemaUtils {
142142
/**
143-
* Check if a property is a meshly array type (not a JSON Schema array like list[str]).
143+
* Check if a property is a meshly binary array type (uses $ref for serialization).
144+
* Note: inline_array is NOT included here - it serializes inline as JSON, not as $ref.
144145
*/
145146
static isArrayType(prop: JsonSchemaProperty): boolean {
146147
// vertex_buffer and index_sequence are always meshly types
147148
if (prop.type === "vertex_buffer" || prop.type === "index_sequence") {
148149
return true
149150
}
150-
// type="array" with items is a JSON Schema list, without items is a meshly array
151+
// type="array" with items is a JSON Schema list[T], without items is a meshly array
151152
if (prop.type === "array") {
152153
return prop.items === undefined
153154
}
154155
return false
155156
}
156157

158+
/**
159+
* Check if a property is an inline array type (serialized as JSON list, no $ref).
160+
* This matches Python's InlineArray annotation.
161+
*/
162+
static isInlineArrayType(prop: JsonSchemaProperty): boolean {
163+
return prop.type === "inline_array"
164+
}
165+
157166
/**
158167
* Check if a property is a resource type.
159168
*/

0 commit comments

Comments
 (0)