added mesh export preview

This commit is contained in:
ykcheungab
2026-09-20 13:46:58 +08:00
parent 39caecbfda
commit f9662df08c
16 changed files with 2025 additions and 11 deletions
+129 -7
View File
@@ -53,8 +53,8 @@ def _require_object(name: str, expected_type: str | None = None) -> bpy.types.Ob
return obj
def _load_job(path: Path) -> tuple[str | None, dict]:
"""Load output path and objects map from a JSON export job."""
def _load_job(path: Path) -> tuple[str | None, dict, list[str] | None]:
"""Load output path, objects map, and optional housing collections."""
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"Export job must be a JSON object: {path}")
@@ -62,7 +62,15 @@ def _load_job(path: Path) -> tuple[str | None, dict]:
if not isinstance(objects, dict):
raise ValueError(f"Export job 'objects' must be a mapping: {path}")
output = data.get("output")
return (str(output) if output else None), objects
housing = data.get("housing_collections")
if housing is not None:
if not isinstance(housing, list) or not all(
isinstance(name, str) for name in housing
):
raise ValueError(
f"Export job 'housing_collections' must be a list of names: {path}"
)
return (str(output) if output else None), objects, housing
def export_camera_intrinsics(camera_obj: bpy.types.Object, scene: bpy.types.Scene):
@@ -158,6 +166,95 @@ def export_mesh_in_sensor_frame(
cleanup()
def _housing_objects(collection_names: list[str]) -> list[bpy.types.Object]:
"""Unique mesh objects in the configured collections, in a stable order."""
found: dict[str, bpy.types.Object] = {}
for name in collection_names:
collection = bpy.data.collections.get(name)
if collection is None:
print(
f"WARNING: housing collection not found: {name!r}",
file=sys.stderr,
)
continue
for obj in collection.all_objects:
if obj.type == "MESH" and obj.name not in found:
found[obj.name] = obj
return list(found.values())
def export_housing_in_sensor_frame(
objects: list[bpy.types.Object],
t_sensor_from_world: Matrix,
filepath: Path,
) -> tuple[int, int, int]:
"""Export the housing meshes in the sensor frame, keeping part identity.
Parts are concatenated into one buffer -- the same schema family as
``mesh.npz`` -- with ``part_index`` linking every face to a ``part_names``
entry. Housing never goes into ``mesh.npz``: the SDK unroll reads that file's
vertices as the sensing surface.
"""
vertex_blocks: list[np.ndarray] = []
face_blocks: list[np.ndarray] = []
part_index: list[int] = []
part_names: list[str] = []
offset = 0
for obj in objects:
mesh, world, cleanup = _triangulated_mesh(obj)
try:
transform = t_sensor_from_world @ world
faces_list = [tuple(poly.vertices) for poly in mesh.polygons]
if not faces_list:
print(f"WARNING: housing object {obj.name!r} has no faces; skipped")
continue
if any(len(face) != 3 for face in faces_list):
raise RuntimeError(
f"Housing object {obj.name!r} is not fully triangulated after export"
)
vertex_blocks.append(
np.array([transform @ v.co for v in mesh.vertices], dtype=np.float32)
)
faces = np.asarray(faces_list, dtype=np.int32) + offset
face_blocks.append(faces)
part_index.extend([len(part_names)] * len(faces))
part_names.append(obj.name)
offset += len(mesh.vertices)
finally:
cleanup()
if vertex_blocks:
vertices = np.concatenate(vertex_blocks)
faces = np.concatenate(face_blocks)
else:
vertices = np.zeros((0, 3), dtype=np.float32)
faces = np.zeros((0, 3), dtype=np.int32)
names = (
np.array(part_names, dtype=np.str_)
if part_names
else np.array([""], dtype=np.str_)[:0]
)
np.savez_compressed(
filepath,
vertices=vertices,
faces=faces,
part_index=np.asarray(part_index, dtype=np.int32),
part_names=names,
)
print(
f"Housing exported to {filepath} "
f"({len(part_names)} part(s), {len(vertices)} verts, {len(faces)} tris)"
)
if not part_names:
print(
"WARNING: no housing meshes found; housing.npz is empty",
file=sys.stderr,
)
return len(part_names), int(len(vertices)), int(len(faces))
def export_camera_pose_sensor_frame(
camera_obj: bpy.types.Object,
t_sensor_from_world: Matrix,
@@ -199,18 +296,27 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
default=None,
help="Sensor-frame empty object name (overrides job)",
)
parser.add_argument(
"--housing-collections",
dest="housing_collections",
default=None,
help="Comma-separated collections exported as housing (overrides job)",
)
return parser.parse_args(argv)
def _resolve_config(args: argparse.Namespace) -> tuple[Path, str, str, str]:
def _resolve_config(
args: argparse.Namespace,
) -> tuple[Path, str, str, str, list[str]]:
"""Merge --job JSON with explicit flag overrides. Explicit flags win."""
job_output: str | None = None
job_objects: dict = {}
job_housing: list[str] | None = None
if args.job is not None:
job_path = args.job.resolve()
if not job_path.is_file():
raise RuntimeError(f"Export job not found: {job_path}")
job_output, job_objects = _load_job(job_path)
job_output, job_objects, job_housing = _load_job(job_path)
output_raw = args.output or job_output
if not output_raw:
@@ -227,13 +333,24 @@ def _resolve_config(args: argparse.Namespace) -> tuple[Path, str, str, str]:
or job_objects.get("origin_sensor")
or _DEFAULT_OBJECTS["origin_sensor"]
)
return Path(output_raw).resolve(), str(mesh), str(cam), str(origin)
housing: list[str] = []
if args.housing_collections:
housing = [
name.strip()
for name in str(args.housing_collections).split(",")
if name.strip()
]
elif job_housing is not None:
housing = list(job_housing)
return Path(output_raw).resolve(), str(mesh), str(cam), str(origin), housing
def main() -> int:
args = parse_args(_argv_after_double_dash())
try:
output_dir, mesh_name, cam_name, origin_name = _resolve_config(args)
output_dir, mesh_name, cam_name, origin_name, housing_collections = (
_resolve_config(args)
)
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
@@ -252,11 +369,15 @@ def main() -> int:
scene = bpy.context.scene
mesh_path = output_dir / "mesh.npz"
housing_path = output_dir / "housing.npz"
intrinsics_path = output_dir / "camera_intrinsics.npz"
pose_path = output_dir / "world_to_cam.npy"
try:
export_mesh_in_sensor_frame(mesh_obj, t_sensor_from_world, mesh_path)
export_housing_in_sensor_frame(
_housing_objects(housing_collections), t_sensor_from_world, housing_path
)
K, width, height = export_camera_intrinsics(cam_obj, scene)
np.savez(
intrinsics_path,
@@ -272,6 +393,7 @@ def main() -> int:
print(f"Export complete: {output_dir}")
print(f" {mesh_path.name}")
print(f" {housing_path.name}")
print(f" {intrinsics_path.name}")
print(f" {pose_path.name}")
return 0