"""Persistence and reproducible workflows for core ``pyvcad.MeshSelection`` objects."""
# STD
import json
import math
from pathlib import Path
# OpenVCAD
import pyvcad as pv
#: Value of the ``format`` field in a saved mesh-selection JSON file.
SELECTION_FILE_FORMAT = "openvcad-mesh-selection"
#: Version of the mesh-selection file format written by :func:`save_mesh_selection`.
SELECTION_FILE_VERSION = 1
SELECTION_METHODS = (
"manual",
"linked",
"linked_by_face_angle",
"linked_constrained",
"enclosed_region",
"geodesic_radius",
)
[docs]
def resolve_selection_mesh(source, voxel_size=None):
"""Return a ``SurfaceMesh`` from an existing mesh or an OpenVCAD tree.
Tree resolution always requires an explicit voxel size. The resulting triangle IDs are tied to
that exact resolved mesh and must not be reused after changing the resolution.
Arguments:
source (SurfaceMesh | Node): A mesh to use as is, or a tree to mesh.
voxel_size (float, optional): Meshing voxel size in mm. Required for a ``Node``, and must be
None for a ``SurfaceMesh``.
Returns:
SurfaceMesh: The mesh that triangle IDs refer to.
Raises:
ValueError: If ``voxel_size`` is missing for a ``Node`` or given for a ``SurfaceMesh``.
TypeError: If ``source`` is neither type.
"""
if isinstance(source, pv.SurfaceMesh):
if voxel_size is not None:
raise ValueError("voxel_size is only valid when resolving an OpenVCAD Node")
return source
if not isinstance(source, pv.Node):
raise TypeError("source must be a pyvcad.SurfaceMesh or pyvcad.Node")
if voxel_size is None or not math.isfinite(float(voxel_size)) or float(voxel_size) <= 0.0:
raise ValueError("resolving an OpenVCAD Node requires a positive finite voxel_size")
return pv.SurfaceMesh(source, float(voxel_size))
[docs]
def mesh_selection_recipe(
method,
triangle_id=None,
vertex_id=None,
point=None,
ray_origin=None,
ray_direction=None,
max_angle_degrees=None,
radius=None,
blocked_edges=None,
triangle_regions=None,
boundary_edges=None,
):
"""Build a validated, JSON-serializable selection recipe.
A point or ray seed is geometrically replayable after deliberate remeshing. A triangle-ID seed
is exact but is only safe while the mesh fingerprint remains unchanged.
Arguments:
method (str): "manual", "linked", "linked_by_face_angle", "linked_constrained",
"enclosed_region" or "geodesic_radius". A "manual" recipe takes no other arguments.
triangle_id (int, optional): Seed triangle. Not valid for "geodesic_radius".
vertex_id (int, optional): Seed vertex. Only valid for "geodesic_radius".
point (Vec3 | sequence, optional): Seed point; the nearest triangle or vertex is used.
ray_origin (Vec3 | sequence, optional): Ray seed origin. Requires ``ray_direction``.
ray_direction (Vec3 | sequence, optional): Ray seed direction; the first hit is used.
max_angle_degrees (float, optional): Face-angle limit in [0, 180]. Required for
"linked_by_face_angle", optional for "linked_constrained".
radius (float, optional): Geodesic radius in mm. Required for "geodesic_radius".
blocked_edges (list[tuple[int, int]], optional): Mesh edges growth cannot cross, for
"linked_constrained".
triangle_regions (list[int], optional): Per-triangle labels; unequal labels stop growth, for
"linked_constrained".
boundary_edges (list[tuple[int, int]], optional): Closed loop of at least three edges.
Required for "enclosed_region".
Exactly one seed (``triangle_id``, ``vertex_id``, ``point``, or the ray pair) is required for
every method except "manual".
Returns:
dict: The recipe, ready for :func:`apply_mesh_selection_recipe` or
:func:`save_mesh_selection`.
Raises:
ValueError: If the method is unknown or the seed and parameters do not fit it.
"""
if method not in SELECTION_METHODS:
raise ValueError("unsupported mesh-selection recipe method")
modes = [
triangle_id is not None,
vertex_id is not None,
point is not None,
ray_origin is not None or ray_direction is not None,
]
if method == "manual":
if (
any(modes)
or max_angle_degrees is not None
or radius is not None
or blocked_edges is not None
or triangle_regions is not None
or boundary_edges is not None
):
raise ValueError("manual selections do not use a seed recipe")
return {"method": "manual"}
if sum(bool(mode) for mode in modes) != 1:
raise ValueError("provide exactly one seed: triangle_id, point, or ray origin/direction")
if method == "geodesic_radius" and triangle_id is not None:
raise ValueError("geodesic_radius uses vertex_id, point, or ray seeds")
if method != "geodesic_radius" and vertex_id is not None:
raise ValueError("vertex_id seeds are only valid for geodesic_radius")
if triangle_id is not None:
triangle_id = int(triangle_id)
if triangle_id < 0:
raise ValueError("triangle_id must be non-negative")
seed = {"mode": "triangle_id", "triangle_id": triangle_id}
elif vertex_id is not None:
vertex_id = int(vertex_id)
if vertex_id < 0:
raise ValueError("vertex_id must be non-negative")
seed = {"mode": "vertex_id", "vertex_id": vertex_id}
elif point is not None:
seed = {"mode": "point", "point": _vector_values(point, "point")}
else:
if ray_origin is None or ray_direction is None:
raise ValueError("ray seeds require both ray_origin and ray_direction")
direction = _vector_values(ray_direction, "ray_direction")
if sum(component * component for component in direction) <= 0.0:
raise ValueError("ray_direction must be non-zero")
seed = {
"mode": "ray",
"origin": _vector_values(ray_origin, "ray_origin"),
"direction": direction,
}
recipe = {"method": method, "seed": seed}
if method == "linked_by_face_angle":
if max_angle_degrees is None:
raise ValueError("linked_by_face_angle requires max_angle_degrees")
angle = float(max_angle_degrees)
if not math.isfinite(angle) or angle < 0.0 or angle > 180.0:
raise ValueError("max_angle_degrees must be finite and between 0 and 180")
recipe["max_angle_degrees"] = angle
elif method == "linked_constrained":
if max_angle_degrees is not None:
angle = float(max_angle_degrees)
if not math.isfinite(angle) or angle < 0.0 or angle > 180.0:
raise ValueError("max_angle_degrees must be finite and between 0 and 180")
recipe["max_angle_degrees"] = angle
recipe["blocked_edges"] = _edge_values(blocked_edges or [], "blocked_edges")
try:
recipe["triangle_regions"] = [int(value) for value in (triangle_regions or [])]
except (TypeError, ValueError) as error:
raise ValueError("triangle_regions must contain integer labels") from error
elif max_angle_degrees is not None:
raise ValueError(
"max_angle_degrees is only valid for linked_by_face_angle or linked_constrained"
)
if method == "enclosed_region":
recipe["boundary_edges"] = _edge_values(boundary_edges or [], "boundary_edges")
if len(recipe["boundary_edges"]) < 3:
raise ValueError("enclosed_region requires a closed boundary of at least three edges")
elif boundary_edges is not None:
raise ValueError("boundary_edges is only valid for enclosed_region")
if method == "geodesic_radius":
if radius is None:
raise ValueError("geodesic_radius requires radius")
distance = float(radius)
if not math.isfinite(distance) or distance < 0.0:
raise ValueError("radius must be finite and non-negative")
recipe["radius"] = distance
elif radius is not None:
raise ValueError("radius is only valid for geodesic_radius")
if method != "linked_constrained" and (blocked_edges is not None or triangle_regions is not None):
raise ValueError("blocked_edges and triangle_regions are only valid for linked_constrained")
return recipe
[docs]
def apply_mesh_selection_recipe(mesh, recipe):
"""Run a selection recipe on a resolved ``SurfaceMesh``.
Arguments:
mesh (SurfaceMesh): The mesh to select on, for example from :func:`resolve_selection_mesh`.
recipe (dict): A recipe from :func:`mesh_selection_recipe`. "manual" recipes hold no seed and
are rejected.
Returns:
MeshSelection: The selected triangles.
Raises:
ValueError: If the recipe is invalid or "manual".
RuntimeError: If a ray seed misses the mesh.
"""
if not isinstance(mesh, pv.SurfaceMesh):
raise TypeError("mesh must be a pyvcad.SurfaceMesh")
recipe = _validated_recipe(recipe)
method = recipe["method"]
if method == "manual":
raise ValueError("manual selection files contain IDs and cannot be replayed geometrically")
seed = recipe["seed"]
hit = None
if seed["mode"] == "ray":
hit = mesh.ray_intersection(_vec3(seed["origin"]), _vec3(seed["direction"]))
if hit is None:
raise RuntimeError("mesh-selection recipe ray does not intersect the resolved mesh")
if method == "geodesic_radius":
if seed["mode"] == "vertex_id":
vertex_id = seed["vertex_id"]
elif seed["mode"] == "point":
vertex_id = mesh.nearest_vertex(_vec3(seed["point"]))
else:
vertex_id = mesh.nearest_vertex(hit.point)
return mesh.select_by_geodesic_radius(vertex_id, recipe["radius"])
if seed["mode"] == "triangle_id":
triangle_id = seed["triangle_id"]
elif seed["mode"] == "point":
triangle_id = mesh.nearest_triangle(_vec3(seed["point"])).triangle_id
else:
triangle_id = hit.triangle_id
if method == "linked":
return mesh.select_linked(triangle_id)
if method == "linked_by_face_angle":
return mesh.select_linked_by_face_angle(triangle_id, recipe["max_angle_degrees"])
if method == "linked_constrained":
return mesh.select_linked_constrained(
triangle_id,
recipe.get("max_angle_degrees"),
recipe["blocked_edges"],
recipe["triangle_regions"],
)
return mesh.select_enclosed_region(recipe["boundary_edges"], triangle_id)
[docs]
def save_mesh_selection(selection, path, recipe=None, voxel_size=None):
"""Write a versioned triangle-ID selection file with source safety metadata.
The JSON file stores the triangle IDs, the source mesh fingerprint and counts, and the recipe,
so :func:`load_mesh_selection` can refuse a changed mesh and :func:`replay_mesh_selection` can
rerun a point or ray seed on it.
Arguments:
selection (MeshSelection): The selection to save. Its source mesh must be unchanged.
path (str | Path): Output file. Parent directories are created.
recipe (dict, optional): The recipe that made the selection. Defaults to a "manual" recipe.
voxel_size (float, optional): The voxel size the source tree was meshed at, recorded for
reference.
Raises:
TypeError: If ``selection`` is not a ``MeshSelection``.
RuntimeError: If the selection's source mesh has changed.
"""
if not isinstance(selection, pv.MeshSelection):
raise TypeError("selection must be a pyvcad.MeshSelection")
if not selection.source_is_current:
raise RuntimeError("cannot save a selection whose source mesh has changed")
if recipe is None:
recipe = {"method": "manual"}
recipe = _validated_recipe(recipe)
if voxel_size is not None:
voxel_size = float(voxel_size)
if not math.isfinite(voxel_size) or voxel_size <= 0.0:
raise ValueError("voxel_size must be positive and finite")
mesh = selection.source_mesh
document = {
"format": SELECTION_FILE_FORMAT,
"version": SELECTION_FILE_VERSION,
"source": {
"fingerprint": selection.source_fingerprint,
"vertex_count": mesh.vertex_count,
"triangle_count": mesh.triangle_count,
"voxel_size": voxel_size,
},
"selection": {
"triangle_ids": list(selection.triangle_ids),
"triangle_count": selection.triangle_count,
"area_mm2": selection.area,
},
"recipe": recipe,
}
output = Path(path)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
[docs]
def load_mesh_selection(path, source, voxel_size=None):
"""Load exact triangle IDs, rejecting any source fingerprint mismatch.
Arguments:
path (str | Path): A file written by :func:`save_mesh_selection`.
source (SurfaceMesh | Node): The mesh, or the tree to mesh, that the selection was made on.
voxel_size (float, optional): Meshing voxel size in mm. Required for a ``Node``.
Returns:
MeshSelection: The saved triangles on the resolved mesh.
Raises:
RuntimeError: If the resolved mesh's fingerprint differs from the saved one.
"""
document = _load_document(path)
mesh = resolve_selection_mesh(source, voxel_size=voxel_size)
expected = document["source"]["fingerprint"]
if mesh.fingerprint != expected:
raise RuntimeError(
"mesh-selection source fingerprint mismatch; triangle IDs are unsafe for this mesh. "
"Use replay_mesh_selection() only when the saved point/ray recipe should be rerun."
)
return mesh.select_triangles(document["selection"]["triangle_ids"])
[docs]
def replay_mesh_selection(path, source, voxel_size=None):
"""Replay a saved point/ray recipe on intentionally changed or remeshed geometry.
Exact IDs are returned when the fingerprint still matches. Manual selections and triangle-ID
recipes cannot be replayed when it differs because they contain no geometry-stable seed.
Arguments:
path (str | Path): A file written by :func:`save_mesh_selection`.
source (SurfaceMesh | Node): The current mesh, or the tree to mesh.
voxel_size (float, optional): Meshing voxel size in mm. Required for a ``Node``.
Returns:
MeshSelection: The saved triangles, or the recipe rerun on the changed mesh.
Raises:
RuntimeError: If the mesh changed and the recipe has no point or ray seed, or uses a
topology-specific method ("linked_constrained" or "enclosed_region").
"""
document = _load_document(path)
mesh = resolve_selection_mesh(source, voxel_size=voxel_size)
if mesh.fingerprint == document["source"]["fingerprint"]:
return mesh.select_triangles(document["selection"]["triangle_ids"])
recipe = document["recipe"]
topology_specific = recipe["method"] in ("linked_constrained", "enclosed_region")
if (
recipe["method"] == "manual"
or recipe.get("seed", {}).get("mode") in ("triangle_id", "vertex_id")
or topology_specific
):
raise RuntimeError(
"mesh-selection source changed and the saved selection has no replayable point/ray seed"
)
return apply_mesh_selection_recipe(mesh, recipe)
def _load_document(path):
try:
document = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise ValueError("unable to read mesh-selection JSON: {}".format(error)) from error
if not isinstance(document, dict):
raise ValueError("mesh-selection file must contain a JSON object")
if document.get("format") != SELECTION_FILE_FORMAT:
raise ValueError("not an OpenVCAD mesh-selection file")
if document.get("version") != SELECTION_FILE_VERSION:
raise ValueError("unsupported mesh-selection file version")
source = document.get("source")
selection = document.get("selection")
if not isinstance(source, dict) or not isinstance(source.get("fingerprint"), str):
raise ValueError("mesh-selection file is missing source fingerprint metadata")
if not isinstance(selection, dict) or not isinstance(selection.get("triangle_ids"), list):
raise ValueError("mesh-selection file is missing triangle IDs")
if not all(isinstance(value, int) and value >= 0 for value in selection["triangle_ids"]):
raise ValueError("mesh-selection triangle IDs must be non-negative integers")
document["recipe"] = _validated_recipe(document.get("recipe", {"method": "manual"}))
return document
def _validated_recipe(recipe):
if not isinstance(recipe, dict):
raise ValueError("mesh-selection recipe must be a dictionary")
method = recipe.get("method")
if method == "manual":
return {"method": "manual"}
seed = recipe.get("seed")
if not isinstance(seed, dict):
raise ValueError("linked mesh-selection recipe is missing its seed")
mode = seed.get("mode")
if mode == "triangle_id":
return mesh_selection_recipe(
method,
triangle_id=seed.get("triangle_id"),
max_angle_degrees=recipe.get("max_angle_degrees"),
blocked_edges=recipe.get("blocked_edges"),
triangle_regions=recipe.get("triangle_regions"),
boundary_edges=recipe.get("boundary_edges"),
radius=recipe.get("radius"),
)
if mode == "vertex_id":
return mesh_selection_recipe(
method,
vertex_id=seed.get("vertex_id"),
radius=recipe.get("radius"),
)
if mode == "point":
return mesh_selection_recipe(
method,
point=seed.get("point"),
max_angle_degrees=recipe.get("max_angle_degrees"),
blocked_edges=recipe.get("blocked_edges"),
triangle_regions=recipe.get("triangle_regions"),
boundary_edges=recipe.get("boundary_edges"),
radius=recipe.get("radius"),
)
if mode == "ray":
return mesh_selection_recipe(
method,
ray_origin=seed.get("origin"),
ray_direction=seed.get("direction"),
max_angle_degrees=recipe.get("max_angle_degrees"),
blocked_edges=recipe.get("blocked_edges"),
triangle_regions=recipe.get("triangle_regions"),
boundary_edges=recipe.get("boundary_edges"),
radius=recipe.get("radius"),
)
raise ValueError("mesh-selection recipe has an unsupported seed mode")
def _vector_values(value, name):
if isinstance(value, pv.Vec3):
result = [float(value.x), float(value.y), float(value.z)]
else:
try:
result = [float(component) for component in value]
except (TypeError, ValueError) as error:
raise ValueError("{} must contain three finite coordinates".format(name)) from error
if len(result) != 3 or not all(math.isfinite(component) for component in result):
raise ValueError("{} must contain three finite coordinates".format(name))
return result
def _vec3(values):
return pv.Vec3(values[0], values[1], values[2])
def _edge_values(edges, name):
result = []
try:
for edge in edges:
values = [int(value) for value in edge]
if len(values) != 2 or values[0] < 0 or values[1] < 0:
raise ValueError
result.append(values)
except (TypeError, ValueError) as error:
raise ValueError("{} must contain non-negative vertex-index pairs".format(name)) from error
return result
__all__ = [
"SELECTION_FILE_FORMAT",
"SELECTION_FILE_VERSION",
"apply_mesh_selection_recipe",
"load_mesh_selection",
"mesh_selection_recipe",
"replay_mesh_selection",
"resolve_selection_mesh",
"save_mesh_selection",
]