raystrack 1.0.1__py3-none-any.whl
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.
- raystrack/__init__.py +30 -0
- raystrack/api.py +198 -0
- raystrack/io.py +238 -0
- raystrack/main.py +2194 -0
- raystrack/params.py +129 -0
- raystrack/utils/__init__.py +23 -0
- raystrack/utils/bvh.py +74 -0
- raystrack/utils/cpu_trace.py +811 -0
- raystrack/utils/cuda_trace.py +1285 -0
- raystrack/utils/geometry.py +66 -0
- raystrack/utils/halton.py +61 -0
- raystrack/utils/helpers.py +284 -0
- raystrack/utils/prepared.py +442 -0
- raystrack/utils/ray_builder.py +97 -0
- raystrack-1.0.1.dist-info/METADATA +125 -0
- raystrack-1.0.1.dist-info/RECORD +19 -0
- raystrack-1.0.1.dist-info/WHEEL +5 -0
- raystrack-1.0.1.dist-info/licenses/LICENSE +674 -0
- raystrack-1.0.1.dist-info/top_level.txt +1 -0
raystrack/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from .main import (
|
|
2
|
+
view_factor_matrix,
|
|
3
|
+
view_factor,
|
|
4
|
+
view_factor_to_tregenza_sky,
|
|
5
|
+
)
|
|
6
|
+
from .api import view_factor_outside_workflow
|
|
7
|
+
from .params import MatrixParams, SkyParams
|
|
8
|
+
from .utils.prepared import PreparedSolver
|
|
9
|
+
from .io import (
|
|
10
|
+
save_vf_matrix_json,
|
|
11
|
+
load_vf_matrix_json,
|
|
12
|
+
save_meshes_json,
|
|
13
|
+
load_meshes_json,
|
|
14
|
+
merge_vf_matrix
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"view_factor_matrix",
|
|
19
|
+
"view_factor",
|
|
20
|
+
"view_factor_to_tregenza_sky",
|
|
21
|
+
"view_factor_outside_workflow",
|
|
22
|
+
"MatrixParams",
|
|
23
|
+
"SkyParams",
|
|
24
|
+
"PreparedSolver",
|
|
25
|
+
"save_vf_matrix_json",
|
|
26
|
+
"load_vf_matrix_json",
|
|
27
|
+
"save_meshes_json",
|
|
28
|
+
"load_meshes_json",
|
|
29
|
+
"merge_vf_matrix"
|
|
30
|
+
]
|
raystrack/api.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Dict, List, Tuple
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from .main import (
|
|
7
|
+
outside_workflow_shareable,
|
|
8
|
+
view_factor_matrix,
|
|
9
|
+
view_factor_matrix_and_sky,
|
|
10
|
+
view_factor_to_tregenza_sky,
|
|
11
|
+
)
|
|
12
|
+
from .params import MatrixParams, SkyParams
|
|
13
|
+
from .utils.helpers import (
|
|
14
|
+
enforce_reciprocity_and_rowsum as _enforce_reciprocity_and_rowsum,
|
|
15
|
+
enforce_reciprocity_only as _enforce_reciprocity_only,
|
|
16
|
+
)
|
|
17
|
+
from .utils.prepared import PreparedSolver
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _row_sum(row: Dict[str, float]) -> float:
|
|
21
|
+
return float(sum(float(v) for v in row.values()))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def view_factor_outside_workflow(
|
|
25
|
+
meshes: List[Tuple[str, np.ndarray, np.ndarray]],
|
|
26
|
+
*,
|
|
27
|
+
matrix_params: MatrixParams,
|
|
28
|
+
sky_params: SkyParams,
|
|
29
|
+
prepared: PreparedSolver | None = None,
|
|
30
|
+
) -> Tuple[
|
|
31
|
+
Dict[str, Dict[str, float]],
|
|
32
|
+
Dict[str, Dict[str, float]],
|
|
33
|
+
Dict[str, Dict[str, float]],
|
|
34
|
+
]:
|
|
35
|
+
"""Compute scene VF matrix, sky VF and the residual fraction.
|
|
36
|
+
|
|
37
|
+
Steps
|
|
38
|
+
- Compute regular view-factor matrix (scene-to-scene).
|
|
39
|
+
- Compute Radiance-style sky view factor(s): merged (Sky) or 145 patches.
|
|
40
|
+
- For each emitter, compute the residual fraction required so that the
|
|
41
|
+
total view factor sums to one: ``1 - sum(scene VFs) - sky_total``.
|
|
42
|
+
|
|
43
|
+
Shared-ray optimization
|
|
44
|
+
-----------------------
|
|
45
|
+
When ``matrix_params`` and ``sky_params`` use the same sampling and device
|
|
46
|
+
configuration, this workflow traces one set of rays per emitter and derives
|
|
47
|
+
both outputs from that shared sample set. In that mode:
|
|
48
|
+
|
|
49
|
+
- scene hits are accumulated into the view-factor matrix
|
|
50
|
+
- rays that miss all geometry are classified directly into the sky result
|
|
51
|
+
|
|
52
|
+
This avoids tracing the same rays twice. When the two parameter sets are
|
|
53
|
+
not compatible, the workflow falls back to the legacy behaviour of calling
|
|
54
|
+
:func:`view_factor_matrix` and :func:`view_factor_to_tregenza_sky`
|
|
55
|
+
separately.
|
|
56
|
+
|
|
57
|
+
Prepared-state reuse
|
|
58
|
+
--------------------
|
|
59
|
+
Pass a shared :class:`~raystrack.utils.prepared.PreparedSolver` via
|
|
60
|
+
``prepared`` when solving the same mesh set repeatedly. The workflow will
|
|
61
|
+
then reuse prepared triangle buffers, BVHs, ray tables and CUDA uploads
|
|
62
|
+
across runs.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
meshes : list of (name, V, F)
|
|
67
|
+
Scene meshes (float32/float64 vertices, int faces).
|
|
68
|
+
matrix_params, sky_params : MatrixParams
|
|
69
|
+
Passed through to the matrix and sky solvers. To enable the shared-ray
|
|
70
|
+
path, the two parameter sets must agree on the ray-generation settings
|
|
71
|
+
(`samples`, `rays`, `seed`) and execution settings (`bvh`, `device`,
|
|
72
|
+
`cuda_async`, `gpu_raygen`). The matrix solve must also use
|
|
73
|
+
``flip_faces=False``.
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
vf_scene : dict
|
|
78
|
+
Scene view-factor matrix. For compatible parameter sets this comes from
|
|
79
|
+
the shared-ray solve; otherwise it is returned by
|
|
80
|
+
:func:`view_factor_matrix`.
|
|
81
|
+
sky_vf : dict
|
|
82
|
+
Sky view factor(s): either {'Sky': vf} or {'Sky_Patch_i': vf} per
|
|
83
|
+
emitter. For compatible parameter sets this is derived from the same
|
|
84
|
+
rays that produced ``vf_scene``.
|
|
85
|
+
rest_vf : dict
|
|
86
|
+
Residual view factor per emitter (``{"Rest": value}``) so that
|
|
87
|
+
``scene + sky + rest = 1``.
|
|
88
|
+
"""
|
|
89
|
+
if not isinstance(matrix_params, MatrixParams):
|
|
90
|
+
raise TypeError("matrix_params must be a MatrixParams instance")
|
|
91
|
+
if not isinstance(sky_params, SkyParams):
|
|
92
|
+
raise TypeError("sky_params must be a SkyParams instance")
|
|
93
|
+
threshold = 1e-6
|
|
94
|
+
enforce_scene = bool(matrix_params.enforce_reciprocity_rowsum)
|
|
95
|
+
reciprocity_flag = bool(matrix_params.reciprocity)
|
|
96
|
+
|
|
97
|
+
# Ensure we don't auto-enforce rows at matrix stage
|
|
98
|
+
matrix_defaults = MatrixParams(**matrix_params.as_dict())
|
|
99
|
+
matrix_defaults.enforce_reciprocity_rowsum = False
|
|
100
|
+
|
|
101
|
+
if outside_workflow_shareable(matrix_defaults, sky_params):
|
|
102
|
+
vf_scene, sky_vf = view_factor_matrix_and_sky(
|
|
103
|
+
meshes,
|
|
104
|
+
matrix_params=matrix_defaults,
|
|
105
|
+
sky_params=sky_params,
|
|
106
|
+
prepared=prepared,
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
vf_scene = view_factor_matrix(meshes, params=matrix_defaults, prepared=prepared)
|
|
110
|
+
sky_vf = view_factor_to_tregenza_sky(meshes, params=sky_params, prepared=prepared)
|
|
111
|
+
|
|
112
|
+
# Determine convergence tolerances
|
|
113
|
+
tol_matrix = float(matrix_params.tol)
|
|
114
|
+
tol_sky = float(sky_params.tol)
|
|
115
|
+
threshold = abs(float(threshold)) if threshold is not None else max(tol_matrix, tol_sky)
|
|
116
|
+
|
|
117
|
+
mesh_names = [name for name, _, _ in meshes]
|
|
118
|
+
scene_totals = {name: max(0.0, _row_sum(vf_scene.get(name, {}))) for name in mesh_names}
|
|
119
|
+
|
|
120
|
+
if enforce_scene:
|
|
121
|
+
row_targets = [scene_totals.get(name, 0.0) for name in mesh_names]
|
|
122
|
+
_enforce_reciprocity_and_rowsum(vf_scene, meshes, None, row_targets=row_targets)
|
|
123
|
+
|
|
124
|
+
rest_vf: Dict[str, Dict[str, float]] = {}
|
|
125
|
+
|
|
126
|
+
mesh_names = [name for name, _, _ in meshes]
|
|
127
|
+
sky_totals = {name: 0.0 for name in mesh_names}
|
|
128
|
+
|
|
129
|
+
for emitter in mesh_names:
|
|
130
|
+
row = vf_scene.get(emitter, {})
|
|
131
|
+
scene_sum = _row_sum(row)
|
|
132
|
+
sky_row = dict(sky_vf.get(emitter, {}))
|
|
133
|
+
if sky_params.discrete:
|
|
134
|
+
sky_total = float(sum(float(v) for v in sky_row.values()))
|
|
135
|
+
else:
|
|
136
|
+
sky_total = float(sky_row.get("Sky", 0.0))
|
|
137
|
+
|
|
138
|
+
if scene_sum + sky_total > 1.0 + threshold:
|
|
139
|
+
if sky_total > 0.0:
|
|
140
|
+
allowed_sky = max(0.0, 1.0 - scene_sum)
|
|
141
|
+
scale = min(1.0, allowed_sky / sky_total) if sky_total else 0.0
|
|
142
|
+
if sky_params.discrete:
|
|
143
|
+
for key, value in list(sky_row.items()):
|
|
144
|
+
sky_row[key] = float(value) * scale
|
|
145
|
+
sky_total = float(sum(float(v) for v in sky_row.values()))
|
|
146
|
+
else:
|
|
147
|
+
sky_row["Sky"] = float(sky_row.get("Sky", 0.0)) * scale
|
|
148
|
+
sky_total = float(sky_row.get("Sky", 0.0))
|
|
149
|
+
sky_vf[emitter] = sky_row
|
|
150
|
+
else:
|
|
151
|
+
sky_total = 0.0
|
|
152
|
+
|
|
153
|
+
sky_totals[emitter] = max(0.0, sky_total)
|
|
154
|
+
|
|
155
|
+
if enforce_scene:
|
|
156
|
+
row_targets = [max(0.0, 1.0 - sky_totals.get(name, 0.0)) for name in mesh_names]
|
|
157
|
+
_enforce_reciprocity_and_rowsum(vf_scene, meshes, None, row_targets=row_targets)
|
|
158
|
+
elif reciprocity_flag:
|
|
159
|
+
_enforce_reciprocity_only(vf_scene, meshes)
|
|
160
|
+
|
|
161
|
+
for emitter in mesh_names:
|
|
162
|
+
row = vf_scene.get(emitter, {})
|
|
163
|
+
scene_sum = _row_sum(row)
|
|
164
|
+
sky_row = dict(sky_vf.get(emitter, {}))
|
|
165
|
+
if sky_params.discrete:
|
|
166
|
+
sky_total = float(sum(float(v) for v in sky_row.values()))
|
|
167
|
+
else:
|
|
168
|
+
sky_total = float(sky_row.get("Sky", 0.0))
|
|
169
|
+
|
|
170
|
+
combined = scene_sum + sky_total
|
|
171
|
+
if combined > 1.0 + threshold and sky_total > 0.0:
|
|
172
|
+
allowed_sky = max(0.0, 1.0 - scene_sum)
|
|
173
|
+
if allowed_sky <= 0.0:
|
|
174
|
+
sky_row = {key: 0.0 for key in sky_row}
|
|
175
|
+
sky_total = 0.0
|
|
176
|
+
else:
|
|
177
|
+
scale = min(1.0, allowed_sky / sky_total)
|
|
178
|
+
if sky_params.discrete:
|
|
179
|
+
for key, value in list(sky_row.items()):
|
|
180
|
+
sky_row[key] = float(value) * scale
|
|
181
|
+
sky_total = float(sum(float(v) for v in sky_row.values()))
|
|
182
|
+
else:
|
|
183
|
+
sky_row["Sky"] = float(sky_row.get("Sky", 0.0)) * scale
|
|
184
|
+
sky_total = float(sky_row.get("Sky", 0.0))
|
|
185
|
+
sky_vf[emitter] = sky_row
|
|
186
|
+
combined = scene_sum + sky_total
|
|
187
|
+
|
|
188
|
+
residual = 1.0 - combined
|
|
189
|
+
if abs(residual) <= threshold:
|
|
190
|
+
residual = 0.0
|
|
191
|
+
|
|
192
|
+
rest_vf[emitter] = {"Rest": residual}
|
|
193
|
+
|
|
194
|
+
return vf_scene, sky_vf, rest_vf
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
__all__ = ["view_factor_outside_workflow"]
|
|
198
|
+
|
raystrack/io.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Tuple, Union
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
VFDict = Dict[str, Dict[str, float]]
|
|
10
|
+
VFInput = Union[VFDict, List[VFDict]]
|
|
11
|
+
MeshTuple = Tuple[str, np.ndarray, np.ndarray]
|
|
12
|
+
Meshes = List[MeshTuple]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _strip_direction_suffix(name: str) -> str:
|
|
16
|
+
if name.endswith("_front"):
|
|
17
|
+
return name[:-6]
|
|
18
|
+
if name.endswith("_back"):
|
|
19
|
+
return name[:-5]
|
|
20
|
+
return name
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def merge_vf_matrix(vf_matrix: VFInput) -> VFDict:
|
|
24
|
+
"""
|
|
25
|
+
Normalize and deep-merge input to a single dict of the form:
|
|
26
|
+
{ sender: { receiver: value } }
|
|
27
|
+
|
|
28
|
+
Accepts either:
|
|
29
|
+
- a single dict
|
|
30
|
+
- a list of dicts
|
|
31
|
+
|
|
32
|
+
When a list is provided, all dicts are merged.
|
|
33
|
+
If the same `sender` key appears in multiple dicts,
|
|
34
|
+
their nested `receiver` mappings are merged instead of replaced.
|
|
35
|
+
Later entries overwrite earlier ones only for overlapping receiver keys.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
vf_matrix : dict or list of dict
|
|
40
|
+
Input dictionary/dictionaries to normalize and merge.
|
|
41
|
+
|
|
42
|
+
Returns
|
|
43
|
+
-------
|
|
44
|
+
VFDict
|
|
45
|
+
A single merged dictionary with combined receiver entries.
|
|
46
|
+
|
|
47
|
+
Raises
|
|
48
|
+
------
|
|
49
|
+
TypeError
|
|
50
|
+
If vf_matrix is neither a dict nor a list of dicts.
|
|
51
|
+
"""
|
|
52
|
+
if isinstance(vf_matrix, list):
|
|
53
|
+
flat: VFDict = {}
|
|
54
|
+
for d in vf_matrix:
|
|
55
|
+
if not isinstance(d, dict):
|
|
56
|
+
raise TypeError("All elements of vf_matrix list must be dicts")
|
|
57
|
+
for sender, receivers in d.items():
|
|
58
|
+
if sender not in flat:
|
|
59
|
+
flat[sender] = {}
|
|
60
|
+
flat[sender].update(receivers)
|
|
61
|
+
return flat
|
|
62
|
+
|
|
63
|
+
if isinstance(vf_matrix, dict):
|
|
64
|
+
return vf_matrix
|
|
65
|
+
|
|
66
|
+
raise TypeError("vf_matrix must be a dict or list of dicts")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def save_vf_matrix_json(vf_matrix: VFInput, save_path: str, *, strip_dir: bool = False) -> str:
|
|
70
|
+
"""Save a view-factor matrix to a JSON file.
|
|
71
|
+
|
|
72
|
+
Receivers with value exactly 0.0 are omitted from the saved JSON to reduce
|
|
73
|
+
file size and noise.
|
|
74
|
+
|
|
75
|
+
When ``strip_dir=True``, receiver keys ending in ``_front`` or ``_back``
|
|
76
|
+
are written without that suffix and values for the same base receiver are
|
|
77
|
+
summed per sender row before serialization.
|
|
78
|
+
"""
|
|
79
|
+
flat = merge_vf_matrix(vf_matrix)
|
|
80
|
+
|
|
81
|
+
# Basic validation of structure and numeric values
|
|
82
|
+
for sender, row in flat.items():
|
|
83
|
+
if not isinstance(sender, str):
|
|
84
|
+
raise TypeError("Sender keys must be strings")
|
|
85
|
+
if not isinstance(row, dict):
|
|
86
|
+
raise TypeError(f"Row for '{sender}' must be a dict mapping receiver->value")
|
|
87
|
+
for recv, val in row.items():
|
|
88
|
+
if not isinstance(recv, str):
|
|
89
|
+
raise TypeError("Receiver keys must be strings")
|
|
90
|
+
try:
|
|
91
|
+
float(val)
|
|
92
|
+
except Exception:
|
|
93
|
+
raise TypeError(f"Value for '{sender}'->'{recv}' must be numeric")
|
|
94
|
+
|
|
95
|
+
path = Path(save_path)
|
|
96
|
+
if path.suffix.lower() == "":
|
|
97
|
+
path = path.with_suffix(".json")
|
|
98
|
+
if path.parent and not path.parent.exists():
|
|
99
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
|
|
101
|
+
# Drop zero-valued receivers
|
|
102
|
+
cleaned: VFDict = {}
|
|
103
|
+
for sender, row in flat.items():
|
|
104
|
+
pruned: Dict[str, float] = {}
|
|
105
|
+
for key, value in row.items():
|
|
106
|
+
value_f = float(value)
|
|
107
|
+
if value_f == 0.0:
|
|
108
|
+
continue
|
|
109
|
+
out_key = _strip_direction_suffix(key) if strip_dir else key
|
|
110
|
+
pruned[out_key] = pruned.get(out_key, 0.0) + value_f
|
|
111
|
+
cleaned[sender] = pruned
|
|
112
|
+
|
|
113
|
+
with path.open("w", encoding="utf-8") as fh:
|
|
114
|
+
json.dump(cleaned, fh, ensure_ascii=False, indent=2, sort_keys=True)
|
|
115
|
+
|
|
116
|
+
return str(path.resolve())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_vf_matrix_json(load_path: str) -> VFDict:
|
|
120
|
+
"""Load a view-factor matrix JSON file and return a dictionary."""
|
|
121
|
+
path = Path(load_path)
|
|
122
|
+
if not path.exists():
|
|
123
|
+
raise FileNotFoundError(f"File not found: {load_path}")
|
|
124
|
+
|
|
125
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
126
|
+
data = json.load(fh)
|
|
127
|
+
|
|
128
|
+
if not isinstance(data, dict):
|
|
129
|
+
raise TypeError("Loaded JSON must be a dict of dicts")
|
|
130
|
+
|
|
131
|
+
# Validate and coerce values to float
|
|
132
|
+
out: VFDict = {}
|
|
133
|
+
for sender, row in data.items():
|
|
134
|
+
if not isinstance(sender, str) or not isinstance(row, dict):
|
|
135
|
+
raise TypeError("Invalid structure: expected {str: {str: number}}")
|
|
136
|
+
new_row: Dict[str, float] = {}
|
|
137
|
+
for recv, val in row.items():
|
|
138
|
+
if not isinstance(recv, str):
|
|
139
|
+
raise TypeError("Receiver keys must be strings")
|
|
140
|
+
try:
|
|
141
|
+
new_row[recv] = float(val)
|
|
142
|
+
except Exception:
|
|
143
|
+
raise TypeError(f"Value for '{sender}'->'{recv}' must be numeric")
|
|
144
|
+
out[sender] = new_row
|
|
145
|
+
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------
|
|
150
|
+
# Mesh geometry JSON IO
|
|
151
|
+
# ---------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def save_meshes_json(meshes: Meshes, save_path: str) -> str:
|
|
154
|
+
"""Save meshes to a JSON file.
|
|
155
|
+
|
|
156
|
+
The expected input format is a list of tuples:
|
|
157
|
+
[(name: str, V: float32[N,3], F: int32[M,3]), ...]
|
|
158
|
+
|
|
159
|
+
The JSON structure is:
|
|
160
|
+
{ "meshes": [
|
|
161
|
+
{"name": str,
|
|
162
|
+
"vertices": [[x,y,z], ...],
|
|
163
|
+
"faces": [[i,j,k], ...]
|
|
164
|
+
}, ...
|
|
165
|
+
]}
|
|
166
|
+
"""
|
|
167
|
+
if not isinstance(meshes, list):
|
|
168
|
+
raise TypeError("meshes must be a list of (name, V, F) tuples")
|
|
169
|
+
|
|
170
|
+
payload = {"meshes": []}
|
|
171
|
+
for item in meshes:
|
|
172
|
+
if not (isinstance(item, tuple) and len(item) == 3):
|
|
173
|
+
raise TypeError("Each mesh must be a (name, V, F) tuple")
|
|
174
|
+
name, V, F = item
|
|
175
|
+
if not isinstance(name, str) or name.strip() == "":
|
|
176
|
+
raise TypeError("Mesh name must be a non-empty string")
|
|
177
|
+
V = np.asarray(V, dtype=np.float32)
|
|
178
|
+
F = np.asarray(F, dtype=np.int32)
|
|
179
|
+
if V.ndim != 2 or V.shape[1] != 3:
|
|
180
|
+
raise ValueError(f"Vertices for '{name}' must have shape (N,3)")
|
|
181
|
+
if F.ndim != 2 or F.shape[1] != 3:
|
|
182
|
+
raise ValueError(f"Faces for '{name}' must have shape (M,3) of triangles")
|
|
183
|
+
payload["meshes"].append(
|
|
184
|
+
{
|
|
185
|
+
"name": name,
|
|
186
|
+
"vertices": V.tolist(),
|
|
187
|
+
"faces": F.tolist(),
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
path = Path(save_path)
|
|
192
|
+
if path.suffix.lower() == "":
|
|
193
|
+
path = path.with_suffix(".json")
|
|
194
|
+
if path.parent and not path.parent.exists():
|
|
195
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
|
|
197
|
+
with path.open("w", encoding="utf-8") as fh:
|
|
198
|
+
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
|
199
|
+
|
|
200
|
+
return str(path.resolve())
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def load_meshes_json(load_path: str) -> Meshes:
|
|
204
|
+
"""Load meshes from a JSON file saved by save_meshes_json.
|
|
205
|
+
|
|
206
|
+
Returns a list of (name, V, F), where V is float32[N,3], F is int32[M,3].
|
|
207
|
+
"""
|
|
208
|
+
path = Path(load_path)
|
|
209
|
+
if not path.exists():
|
|
210
|
+
raise FileNotFoundError(f"File not found: {load_path}")
|
|
211
|
+
|
|
212
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
213
|
+
data = json.load(fh)
|
|
214
|
+
|
|
215
|
+
if not isinstance(data, dict) or "meshes" not in data:
|
|
216
|
+
raise TypeError("Invalid mesh JSON: expected an object with 'meshes' list")
|
|
217
|
+
meshes_raw = data["meshes"]
|
|
218
|
+
if not isinstance(meshes_raw, list):
|
|
219
|
+
raise TypeError("'meshes' must be a list")
|
|
220
|
+
|
|
221
|
+
out: Meshes = []
|
|
222
|
+
for i, entry in enumerate(meshes_raw):
|
|
223
|
+
if not isinstance(entry, dict):
|
|
224
|
+
raise TypeError("Each entry in 'meshes' must be an object")
|
|
225
|
+
name = entry.get("name")
|
|
226
|
+
V = entry.get("vertices")
|
|
227
|
+
F = entry.get("faces")
|
|
228
|
+
if not isinstance(name, str) or name.strip() == "":
|
|
229
|
+
raise TypeError(f"Entry {i}: 'name' must be a non-empty string")
|
|
230
|
+
V = np.asarray(V, dtype=np.float32)
|
|
231
|
+
F = np.asarray(F, dtype=np.int32)
|
|
232
|
+
if V.ndim != 2 or V.shape[1] != 3:
|
|
233
|
+
raise ValueError(f"Entry {i} ('{name}'): vertices must have shape (N,3)")
|
|
234
|
+
if F.ndim != 2 or F.shape[1] != 3:
|
|
235
|
+
raise ValueError(f"Entry {i} ('{name}'): faces must have shape (M,3)")
|
|
236
|
+
out.append((name, V, F))
|
|
237
|
+
|
|
238
|
+
return out
|