added mesh export preview
This commit is contained in:
+129
-7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user