first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
@@ -0,0 +1,23 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "cadpy"
version = "0.3.9"
description = "Shared STEP/GLB topology artifact generation runtime for CAD skills."
requires-python = ">=3.12"
dependencies = [
"build123d",
"cadquery-ocp",
]
[project.scripts]
cadpy-step-artifact = "cadpy.step_artifact:main"
[tool.setuptools.packages.find]
where = ["src"]
include = ["cadpy*"]
[tool.setuptools.package-data]
cadpy = ["py.typed"]
@@ -0,0 +1,34 @@
"""Shared CAD artifact generation runtime."""
__all__ = [
"AssemblyHelper",
"MateRelation",
"MateTarget",
"ensure_step_glb_artifact",
"label_text",
"label_shape",
"target",
"validate_step_glb_artifact",
]
def __getattr__(name: str):
if name in {"ensure_step_glb_artifact", "validate_step_glb_artifact"}:
from cadpy.api import ensure_step_glb_artifact, validate_step_glb_artifact
return {
"ensure_step_glb_artifact": ensure_step_glb_artifact,
"validate_step_glb_artifact": validate_step_glb_artifact,
}[name]
if name in {"AssemblyHelper", "MateRelation", "MateTarget", "label_shape", "label_text", "target"}:
from cadpy.assembly import AssemblyHelper, MateRelation, MateTarget, label_shape, label_text, target
return {
"AssemblyHelper": AssemblyHelper,
"MateRelation": MateRelation,
"MateTarget": MateTarget,
"label_text": label_text,
"label_shape": label_shape,
"target": target,
}[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,683 @@
from __future__ import annotations
import hashlib
import json
import math
from typing import Any
from . import lookup
AXIS_NAMES = ("x", "y", "z")
AXIS_INDEX = {name: index for index, name in enumerate(AXIS_NAMES)}
AXIS_ALIGNMENT_THRESHOLD = 0.985
def _float_triplet(value: object) -> tuple[float, float, float] | None:
if not isinstance(value, (list, tuple)) or len(value) != 3:
return None
try:
return (float(value[0]), float(value[1]), float(value[2]))
except (TypeError, ValueError):
return None
def _normalize(vector: tuple[float, float, float] | None) -> tuple[float, float, float] | None:
if vector is None:
return None
length = math.sqrt(sum(component * component for component in vector))
if length <= 1e-12:
return None
return tuple(component / length for component in vector)
def dominant_axis(
vector: object,
*,
aligned_threshold: float = AXIS_ALIGNMENT_THRESHOLD,
) -> dict[str, object] | None:
normalized = _normalize(_float_triplet(vector))
if normalized is None:
return None
magnitudes = [abs(component) for component in normalized]
axis_index = max(range(3), key=lambda index: magnitudes[index])
component = normalized[axis_index]
return {
"axis": AXIS_NAMES[axis_index],
"index": axis_index,
"sign": 1 if component >= 0.0 else -1,
"component": component,
"magnitude": magnitudes[axis_index],
"aligned": magnitudes[axis_index] >= aligned_threshold,
}
def bbox_size(bbox: object) -> list[float] | None:
if not isinstance(bbox, dict):
return None
min_point = _float_triplet(bbox.get("min"))
max_point = _float_triplet(bbox.get("max"))
if min_point is None or max_point is None:
return None
return [
float(max_point[0] - min_point[0]),
float(max_point[1] - min_point[1]),
float(max_point[2] - min_point[2]),
]
def bbox_center(bbox: object) -> list[float] | None:
if not isinstance(bbox, dict):
return None
min_point = _float_triplet(bbox.get("min"))
max_point = _float_triplet(bbox.get("max"))
if min_point is None or max_point is None:
return None
return [
float((min_point[0] + max_point[0]) * 0.5),
float((min_point[1] + max_point[1]) * 0.5),
float((min_point[2] + max_point[2]) * 0.5),
]
def bbox_diag(bbox: object) -> float | None:
size = bbox_size(bbox)
if size is None:
return None
return math.sqrt(sum(component * component for component in size))
def bbox_facts(bbox: object) -> dict[str, object]:
facts: dict[str, object] = {}
if not isinstance(bbox, dict):
return facts
size = bbox_size(bbox)
center = bbox_center(bbox)
diag = bbox_diag(bbox)
if size is not None:
facts["size"] = size
extent_axis = dominant_axis(size, aligned_threshold=0.0)
if extent_axis is not None:
facts["extentAxis"] = extent_axis["axis"]
if center is not None:
facts["center"] = center
if diag is not None:
facts["diag"] = diag
return facts
def _dot(left: tuple[float, float, float], right: tuple[float, float, float]) -> float:
return (left[0] * right[0]) + (left[1] * right[1]) + (left[2] * right[2])
def _transform_tuple(value: object) -> tuple[float, ...] | None:
if not isinstance(value, (list, tuple)) or len(value) != 16:
return None
try:
transform = tuple(float(item) for item in value)
except (TypeError, ValueError):
return None
if not all(math.isfinite(item) for item in transform):
return None
return transform
def _axis_alignment_payload(vector: object) -> dict[str, object] | None:
alignment = dominant_axis(vector)
if alignment is None:
return None
return {
"axis": alignment["axis"],
"sign": alignment["sign"],
"aligned": alignment["aligned"],
"magnitude": alignment["magnitude"],
}
def transform_frame_facts(transform: object) -> dict[str, object] | None:
matrix = _transform_tuple(transform)
if matrix is None:
return None
local_x = _normalize((matrix[0], matrix[4], matrix[8]))
local_y = _normalize((matrix[1], matrix[5], matrix[9]))
local_z = _normalize((matrix[2], matrix[6], matrix[10]))
return {
"translation": [matrix[3], matrix[7], matrix[11]],
"localAxes": {
"x": list(local_x) if local_x is not None else [matrix[0], matrix[4], matrix[8]],
"y": list(local_y) if local_y is not None else [matrix[1], matrix[5], matrix[9]],
"z": list(local_z) if local_z is not None else [matrix[2], matrix[6], matrix[10]],
},
"transform": list(matrix),
}
def positioning_facts_for_row(
selector_type: str,
row: dict[str, object],
index: lookup.SelectorIndex | None = None,
) -> dict[str, object]:
bbox = row.get("bbox")
center = _float_triplet(row.get("center")) or _float_triplet(bbox_center(bbox))
facts: dict[str, object] = {
"selectorType": selector_type,
}
if index is not None and row.get("id"):
facts["selector"] = lookup.display_selector(str(row["id"]), index)
if isinstance(bbox, dict):
facts["bbox"] = bbox
bbox_fact_payload = bbox_facts(bbox)
if bbox_fact_payload:
facts["bboxFacts"] = bbox_fact_payload
if center is not None:
facts["center"] = list(center)
if selector_type == "occurrence":
facts["kind"] = "frame"
name = str(row.get("name") or row.get("sourceName") or "").strip()
if name:
facts["name"] = name
frame = transform_frame_facts(row.get("transform"))
if frame is not None:
facts.update(frame)
return facts
if selector_type == "shape":
facts["kind"] = str(row.get("kind") or "shape")
if index is not None and row.get("occurrenceId"):
facts["occurrenceId"] = lookup.display_selector(str(row["occurrenceId"]), index)
return facts
if selector_type == "vertex":
facts["kind"] = "point"
if center is not None:
facts["point"] = list(center)
if index is not None and row.get("occurrenceId"):
facts["occurrenceId"] = lookup.display_selector(str(row["occurrenceId"]), index)
return facts
params = row.get("params") if isinstance(row.get("params"), dict) else {}
if index is not None and row.get("occurrenceId"):
facts["occurrenceId"] = lookup.display_selector(str(row["occurrenceId"]), index)
if index is not None and row.get("shapeId"):
facts["shapeId"] = lookup.display_selector(str(row["shapeId"]), index)
if selector_type == "face":
surface_type = str(row.get("surfaceType") or "").lower()
facts["kind"] = surface_type or "face"
if row.get("area") not in {None, ""}:
facts["area"] = float(row["area"])
normal = _normalize(
_float_triplet(row.get("normal"))
or _float_triplet(params.get("normal"))
or _float_triplet(params.get("axis"))
)
point = _float_triplet(params.get("origin")) or center
if point is not None:
facts["origin"] = list(point)
if normal is not None:
facts["normal"] = list(normal)
alignment = _axis_alignment_payload(normal)
if alignment is not None:
facts["axisAlignment"] = alignment
facts["normalAxis"] = {
"axis": alignment["axis"],
"sign": alignment["sign"],
"aligned": alignment["aligned"],
}
if bool(alignment["aligned"]) and point is not None:
axis = str(alignment["axis"])
facts["axis"] = axis
facts["coordinate"] = float(point[AXIS_INDEX[axis]])
if point is not None:
facts["planeOffset"] = _dot(normal, point)
radius = params.get("radius")
if radius not in {None, ""}:
facts["radius"] = float(radius)
if surface_type in {"cylinder", "cone", "torus"}:
axis_vector = _normalize(_float_triplet(params.get("axis")))
if axis_vector is not None:
facts["axisVector"] = list(axis_vector)
facts["axisAlignment"] = _axis_alignment_payload(axis_vector)
if surface_type == "sphere":
sphere_center = _float_triplet(params.get("center")) or center
if sphere_center is not None:
facts["center"] = list(sphere_center)
return facts
curve_type = str(row.get("curveType") or "").lower()
facts["kind"] = curve_type or "edge"
if row.get("length") not in {None, ""}:
facts["length"] = float(row["length"])
if curve_type == "line":
origin = _float_triplet(params.get("origin")) or center
direction = _normalize(_float_triplet(params.get("direction")))
if origin is not None:
facts["origin"] = list(origin)
if direction is not None:
facts["direction"] = list(direction)
alignment = _axis_alignment_payload(direction)
if alignment is not None:
facts["axisAlignment"] = alignment
if bool(alignment["aligned"]) and center is not None:
axis = str(alignment["axis"])
facts["axis"] = axis
facts["coordinate"] = float(center[AXIS_INDEX[axis]])
elif curve_type in {"circle", "ellipse"}:
circle_center = _float_triplet(params.get("center")) or center
axis_vector = _normalize(_float_triplet(params.get("axis")))
if circle_center is not None:
facts["center"] = list(circle_center)
if axis_vector is not None:
facts["axisVector"] = list(axis_vector)
facts["axisAlignment"] = _axis_alignment_payload(axis_vector)
for radius_key in ("radius", "majorRadius", "minorRadius"):
if params.get(radius_key) not in {None, ""}:
facts[radius_key] = float(params[radius_key])
return facts
def positioning_point(facts: dict[str, object]) -> list[float] | None:
for key in ("point", "origin", "center", "translation"):
point = _float_triplet(facts.get(key))
if point is not None:
return list(point)
bbox = facts.get("bbox")
center = bbox_center(bbox)
return center if center is not None else None
def positioning_coordinate(
facts: dict[str, object],
axis: str,
) -> tuple[float, str] | None:
normalized_axis = str(axis or "").strip().lower()
if normalized_axis not in AXIS_INDEX:
return None
if str(facts.get("axis") or "") == normalized_axis and facts.get("coordinate") not in {None, ""}:
return float(facts["coordinate"]), "coordinate"
point = positioning_point(facts)
if point is not None:
return float(point[AXIS_INDEX[normalized_axis]]), "point"
return None
def infer_positioning_axis(*fact_payloads: dict[str, object]) -> str | None:
axes: list[str] = []
for facts in fact_payloads:
alignment = facts.get("axisAlignment")
if not isinstance(alignment, dict) or not bool(alignment.get("aligned")):
continue
axis = str(alignment.get("axis") or "")
if axis in AXIS_INDEX:
axes.append(axis)
if axes and all(axis == axes[0] for axis in axes):
return axes[0]
for facts in fact_payloads:
axis = str(facts.get("axis") or "")
if axis in AXIS_INDEX:
return axis
return None
def vector_relationship(
left: object,
right: object,
*,
aligned_threshold: float = AXIS_ALIGNMENT_THRESHOLD,
) -> dict[str, object] | None:
left_vector = _normalize(_float_triplet(left))
right_vector = _normalize(_float_triplet(right))
if left_vector is None or right_vector is None:
return None
dot = _dot(left_vector, right_vector)
abs_dot = abs(dot)
if dot <= -aligned_threshold:
relation = "opposed"
elif dot >= aligned_threshold:
relation = "parallel"
elif abs_dot <= 1.0 - aligned_threshold:
relation = "perpendicular"
else:
relation = "angled"
return {
"relation": relation,
"dot": dot,
"aligned": abs_dot >= aligned_threshold,
}
def geometry_facts_for_row(
selector_type: str,
row: dict[str, object],
index: lookup.SelectorIndex | None = None,
) -> dict[str, object]:
facts = bbox_facts(row.get("bbox"))
if selector_type in {"occurrence", "shape"}:
return facts
params = row.get("params") if isinstance(row.get("params"), dict) else {}
center = _float_triplet(row.get("center"))
if center is not None:
facts.setdefault("center", list(center))
if selector_type == "vertex":
if row.get("edgeCount") not in {None, ""}:
facts["edgeCount"] = int(row["edgeCount"])
if index is not None and row.get("id"):
facts["selector"] = lookup.display_selector(str(row["id"]), index)
return facts
axis_vector = _float_triplet(params.get("axis"))
direction_vector = _float_triplet(params.get("direction"))
normal_vector = _float_triplet(row.get("normal")) or _float_triplet(params.get("normal")) or axis_vector
if selector_type == "face":
surface_type = str(row.get("surfaceType") or "")
if surface_type:
facts["surfaceType"] = surface_type
if row.get("area") not in {None, ""}:
facts["area"] = float(row["area"])
normal_axis = dominant_axis(normal_vector)
if normal_axis is not None:
facts["normalAxis"] = {
"axis": normal_axis["axis"],
"sign": normal_axis["sign"],
"aligned": normal_axis["aligned"],
}
if center is not None and bool(normal_axis["aligned"]):
facts["planeCoordinate"] = center[int(normal_axis["index"])]
radius = params.get("radius")
if radius not in {None, ""}:
facts["radius"] = float(radius)
if surface_type == "plane" and axis_vector is not None:
facts["axis"] = list(axis_vector)
if index is not None and row.get("id"):
facts["selector"] = lookup.display_selector(str(row["id"]), index)
return facts
curve_type = str(row.get("curveType") or "")
if curve_type:
facts["curveType"] = curve_type
if row.get("length") not in {None, ""}:
facts["length"] = float(row["length"])
direction_axis = dominant_axis(direction_vector or axis_vector or bbox_size(row.get("bbox")))
if direction_axis is not None:
facts["directionAxis"] = {
"axis": direction_axis["axis"],
"sign": direction_axis["sign"],
"aligned": direction_axis["aligned"],
}
radius = params.get("radius")
if radius not in {None, ""}:
facts["radius"] = float(radius)
if index is not None and row.get("id"):
facts["selector"] = lookup.display_selector(str(row["id"]), index)
return facts
def _merge_bboxes(boxes: list[dict[str, object]]) -> dict[str, object]:
min_x = min(float(box["min"][0]) for box in boxes)
min_y = min(float(box["min"][1]) for box in boxes)
min_z = min(float(box["min"][2]) for box in boxes)
max_x = max(float(box["max"][0]) for box in boxes)
max_y = max(float(box["max"][1]) for box in boxes)
max_z = max(float(box["max"][2]) for box in boxes)
return {
"min": [min_x, min_y, min_z],
"max": [max_x, max_y, max_z],
}
def major_planar_face_groups(
index: lookup.SelectorIndex,
*,
coordinate_tolerance: float = 1e-3,
min_area_ratio: float = 0.05,
limit: int = 12,
) -> list[dict[str, object]]:
planar_rows = [
row for row in index.faces if str(row.get("surfaceType") or "").lower() == "plane"
]
total_planar_area = sum(float(row.get("area") or 0.0) for row in planar_rows)
grouped: dict[tuple[str, int], dict[str, object]] = {}
for row in planar_rows:
facts = geometry_facts_for_row("face", row, index)
normal_axis = facts.get("normalAxis")
plane_coordinate = facts.get("planeCoordinate")
if not isinstance(normal_axis, dict) or plane_coordinate in {None, ""}:
continue
axis = str(normal_axis.get("axis") or "")
if axis not in AXIS_INDEX:
continue
coordinate = float(plane_coordinate)
bucket = int(round(coordinate / coordinate_tolerance)) if coordinate_tolerance > 0 else 0
key = (axis, bucket)
bbox = row.get("bbox")
if not isinstance(bbox, dict):
continue
group = grouped.get(key)
if group is None:
group = {
"axis": axis,
"coordinate": 0.0,
"normalSign": int(normal_axis.get("sign") or 1),
"faceCount": 0,
"totalArea": 0.0,
"bboxParts": [],
"selectors": [],
}
grouped[key] = group
area = float(row.get("area") or 0.0)
group["coordinate"] = float(group["coordinate"]) + (coordinate * max(area, 1e-9))
group["faceCount"] = int(group["faceCount"]) + 1
group["totalArea"] = float(group["totalArea"]) + area
group["bboxParts"].append(bbox)
selector = lookup.display_selector(str(row.get("id") or ""), index)
if selector:
group["selectors"].append(selector)
result: list[dict[str, object]] = []
for group in grouped.values():
total_area = float(group["totalArea"])
if total_planar_area > 0.0 and total_area / total_planar_area < min_area_ratio:
continue
weighted_coordinate = float(group["coordinate"]) / max(total_area, 1e-9)
merged_bbox = _merge_bboxes(list(group["bboxParts"]))
result.append(
{
"axis": group["axis"],
"coordinate": weighted_coordinate,
"normalSign": group["normalSign"],
"faceCount": group["faceCount"],
"totalArea": total_area,
"bbox": merged_bbox,
"selectors": sorted(set(str(selector) for selector in group["selectors"] if selector)),
}
)
result.sort(key=lambda item: (-float(item["totalArea"]), str(item["axis"]), float(item["coordinate"])))
return result[: max(int(limit), 0)]
def _table_rows(manifest: dict[str, Any], table_name: str, columns_name: str) -> list[dict[str, Any]]:
columns = manifest.get("tables", {}).get(columns_name)
rows = manifest.get(table_name)
if not isinstance(columns, list) or not isinstance(rows, list):
return []
materialized: list[dict[str, Any]] = []
for row in rows:
if not isinstance(row, list):
continue
materialized.append({str(columns[index]): row[index] for index in range(min(len(columns), len(row)))})
return materialized
def _stable_hash(payload: object) -> str:
encoded = json.dumps(payload, separators=(",", ":"), sort_keys=True, ensure_ascii=True)
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def selector_manifest_diff(
old_manifest: dict[str, Any] | None,
new_manifest: dict[str, Any],
) -> dict[str, object]:
if old_manifest is None:
return {
"hasPrevious": False,
"topologyChanged": False,
"geometryChanged": False,
"bboxChanged": False,
"countDelta": {},
}
old_faces = _table_rows(old_manifest, "faces", "faceColumns")
new_faces = _table_rows(new_manifest, "faces", "faceColumns")
old_edges = _table_rows(old_manifest, "edges", "edgeColumns")
new_edges = _table_rows(new_manifest, "edges", "edgeColumns")
old_vertices = _table_rows(old_manifest, "vertices", "vertexColumns")
new_vertices = _table_rows(new_manifest, "vertices", "vertexColumns")
old_topology = {
"occurrences": [row.get("id") for row in _table_rows(old_manifest, "occurrences", "occurrenceColumns")],
"shapes": [row.get("id") for row in _table_rows(old_manifest, "shapes", "shapeColumns")],
"faces": [row.get("id") for row in old_faces],
"edges": [row.get("id") for row in old_edges],
"vertices": [row.get("id") for row in old_vertices],
"faceCount": old_manifest.get("stats", {}).get("faceCount"),
"edgeCount": old_manifest.get("stats", {}).get("edgeCount"),
"vertexCount": old_manifest.get("stats", {}).get("vertexCount"),
}
new_topology = {
"occurrences": [row.get("id") for row in _table_rows(new_manifest, "occurrences", "occurrenceColumns")],
"shapes": [row.get("id") for row in _table_rows(new_manifest, "shapes", "shapeColumns")],
"faces": [row.get("id") for row in new_faces],
"edges": [row.get("id") for row in new_edges],
"vertices": [row.get("id") for row in new_vertices],
"faceCount": new_manifest.get("stats", {}).get("faceCount"),
"edgeCount": new_manifest.get("stats", {}).get("edgeCount"),
"vertexCount": new_manifest.get("stats", {}).get("vertexCount"),
}
old_geometry = {
"bbox": old_manifest.get("bbox"),
"faces": [
{
"id": row.get("id"),
"surfaceType": row.get("surfaceType"),
"center": row.get("center"),
"normal": row.get("normal"),
"bbox": row.get("bbox"),
"area": row.get("area"),
}
for row in old_faces
],
"edges": [
{
"id": row.get("id"),
"curveType": row.get("curveType"),
"center": row.get("center"),
"bbox": row.get("bbox"),
"length": row.get("length"),
}
for row in old_edges
],
"vertices": [
{
"id": row.get("id"),
"center": row.get("center"),
"bbox": row.get("bbox"),
}
for row in old_vertices
],
}
new_geometry = {
"bbox": new_manifest.get("bbox"),
"faces": [
{
"id": row.get("id"),
"surfaceType": row.get("surfaceType"),
"center": row.get("center"),
"normal": row.get("normal"),
"bbox": row.get("bbox"),
"area": row.get("area"),
}
for row in new_faces
],
"edges": [
{
"id": row.get("id"),
"curveType": row.get("curveType"),
"center": row.get("center"),
"bbox": row.get("bbox"),
"length": row.get("length"),
}
for row in new_edges
],
"vertices": [
{
"id": row.get("id"),
"center": row.get("center"),
"bbox": row.get("bbox"),
}
for row in new_vertices
],
}
count_delta = {
"faceCount": int(new_manifest.get("stats", {}).get("faceCount") or 0)
- int(old_manifest.get("stats", {}).get("faceCount") or 0),
"edgeCount": int(new_manifest.get("stats", {}).get("edgeCount") or 0)
- int(old_manifest.get("stats", {}).get("edgeCount") or 0),
"vertexCount": int(new_manifest.get("stats", {}).get("vertexCount") or 0)
- int(old_manifest.get("stats", {}).get("vertexCount") or 0),
"shapeCount": int(new_manifest.get("stats", {}).get("shapeCount") or 0)
- int(old_manifest.get("stats", {}).get("shapeCount") or 0),
}
return {
"hasPrevious": True,
"topologyChanged": _stable_hash(old_topology) != _stable_hash(new_topology),
"geometryChanged": _stable_hash(old_geometry) != _stable_hash(new_geometry),
"bboxChanged": old_manifest.get("bbox") != new_manifest.get("bbox"),
"countDelta": count_delta,
}
def view_name_for_axis(axis: str, sign: int) -> str:
normalized_sign = 1 if sign >= 0 else -1
if axis == "x":
return "right" if normalized_sign > 0 else "left"
if axis == "y":
return "top" if normalized_sign > 0 else "bottom"
return "front" if normalized_sign > 0 else "back"
def aligned_view_name_for_facts(
selector_type: str,
facts: dict[str, object],
) -> str | None:
if selector_type == "face":
normal_axis = facts.get("normalAxis")
if isinstance(normal_axis, dict):
axis = str(normal_axis.get("axis") or "")
sign = int(normal_axis.get("sign") or 1)
if axis in AXIS_INDEX:
return view_name_for_axis(axis, sign)
return None
if selector_type != "edge":
return None
direction_axis = facts.get("directionAxis")
if not isinstance(direction_axis, dict):
return None
axis = str(direction_axis.get("axis") or "")
if axis == "x":
return "front"
if axis == "y":
return "front"
if axis == "z":
return "top"
return None
@@ -0,0 +1,6 @@
from __future__ import annotations
from cadpy.step_artifacts import ensure_step_topology_artifact as ensure_step_glb_artifact
from cadpy.step_targets import validate_step_topology_artifact as validate_step_glb_artifact
__all__ = ["ensure_step_glb_artifact", "validate_step_glb_artifact"]
@@ -0,0 +1,436 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Mapping, Sequence
@dataclass(frozen=True)
class MateTarget:
"""A named native build123d joint on a part-like shape."""
part: Any
frame: str
@dataclass(frozen=True)
class MateRelation:
"""A source-level placement relationship recorded by AssemblyHelper."""
label: str
relation: str
fixed: str
moving: str
parameters: Mapping[str, Any] = field(default_factory=dict)
fixed_endpoint: Mapping[str, Any] = field(default_factory=dict)
moving_endpoint: Mapping[str, Any] = field(default_factory=dict)
def label_text(name: str, *details: object) -> str:
"""Build a compact STEP-friendly label such as m3_standoff:front_left."""
tokens = [_normalize_label_token(name, field_name="name")]
tokens.extend(_normalize_label_token(detail, field_name="detail") for detail in details)
return ":".join(tokens)
def label_shape(
shape: Any,
name: str,
*details: object,
color: Any | None = None,
) -> Any:
"""Assign native build123d label/color metadata and return the shape."""
shape.label = label_text(name, *details)
if color is not None:
shape.color = color
return shape
def mate_label(name: str) -> str:
"""Return the native joint label used for named mate frames."""
return label_text(name)
def target(part: Any, frame: str) -> MateTarget:
return MateTarget(part=part, frame=label_text(frame))
def assembly_mate_payload(relations: Sequence[MateRelation]) -> list[dict[str, Any]]:
"""Return JSON-safe source mate metadata for STEP topology artifacts."""
payload: list[dict[str, Any]] = []
for index, relation in enumerate(relations, start=1):
fallback_label = f"mate_{index}"
label = str(relation.label or fallback_label).strip() or fallback_label
mate_id = f"m{index}"
relation_type = str(relation.relation or "mate").strip() or "mate"
fixed = str(relation.fixed or "fixed").strip() or "fixed"
moving = str(relation.moving or "moving").strip() or "moving"
payload.append(
{
"id": mate_id,
"label": mate_id,
"sourceLabel": label,
"type": relation_type,
"relation": relation_type,
"fixed": fixed,
"moving": moving,
"parameters": _json_safe(relation.parameters),
}
)
fixed_endpoint = _json_safe(relation.fixed_endpoint)
moving_endpoint = _json_safe(relation.moving_endpoint)
if fixed_endpoint:
payload[-1]["fixedEndpoint"] = fixed_endpoint
if moving_endpoint:
payload[-1]["movingEndpoint"] = moving_endpoint
return payload
class AssemblyHelper:
"""Small semantic wrapper around native build123d joints and compounds.
Generated CAD scripts should express named part-local frames and source
relationships here; this helper realizes those relationships with native
build123d Joint objects and returns a labeled Compound assembly.
"""
def __init__(self, name: str) -> None:
self.label = label_text(name)
self.children: list[Any] = []
self.relations: list[MateRelation] = []
def add(
self,
shape: Any,
name: str,
*details: object,
color: Any | None = None,
) -> Any:
label_shape(shape, name, *details, color=color)
self.children.append(shape)
return shape
def add_module(self, name: str, children: Sequence[Any], *details: object, color: Any | None = None) -> Any:
module = self.compound(children, label=label_text(name, *details))
if color is not None:
module.color = color
self.children.append(module)
return module
def feature(
self,
shape: Any,
name: str,
*details: object,
color: Any | None = None,
) -> Any:
return label_shape(shape, name, *details, color=color)
def datum(
self,
shape: Any,
name: str,
*details: object,
color: Any | None = None,
) -> Any:
return label_shape(shape, name, *details, color=color)
def rigid_frame(self, part: Any, name: str, location: Any) -> MateTarget:
return add_rigid_frame(part, name, location)
def revolute_frame(self, part: Any, name: str, axis: Any, **joint_options: Any) -> MateTarget:
return add_axis_frame(part, name, axis, joint_type="RevoluteJoint", **joint_options)
def linear_frame(self, part: Any, name: str, axis: Any, **joint_options: Any) -> MateTarget:
return add_axis_frame(part, name, axis, joint_type="LinearJoint", **joint_options)
def cylindrical_frame(self, part: Any, name: str, axis: Any, **joint_options: Any) -> MateTarget:
return add_axis_frame(part, name, axis, joint_type="CylindricalJoint", **joint_options)
def ball_frame(self, part: Any, name: str, location: Any, **joint_options: Any) -> MateTarget:
return add_joint_frame(
part,
name,
joint_type="BallJoint",
joint_location=location,
**joint_options,
)
def connect(
self,
fixed: MateTarget | tuple[Any, str],
moving: MateTarget | tuple[Any, str],
*,
relation: str = "rigid",
label: str | None = None,
**connect_options: Any,
) -> MateRelation:
fixed_target = _normalize_target(fixed)
moving_target = _normalize_target(moving)
fixed_joint_label, fixed_joint = _joint_for_target(fixed_target)
moving_joint_label, moving_joint = _joint_for_target(moving_target)
options = {key: value for key, value in connect_options.items() if value is not None}
fixed_joint.connect_to(moving_joint, **options)
relation_record = MateRelation(
label=label_text(label) if label is not None else label_text(relation, fixed_joint_label, moving_joint_label),
relation=relation,
fixed=fixed_joint_label,
moving=moving_joint_label,
parameters=options,
fixed_endpoint=_mate_endpoint_payload(fixed_target, fixed_joint_label, fixed_joint),
moving_endpoint=_mate_endpoint_payload(moving_target, moving_joint_label, moving_joint),
)
self.relations.append(relation_record)
return relation_record
def face_to_face(
self,
fixed: MateTarget | tuple[Any, str],
moving: MateTarget | tuple[Any, str],
*,
offset: float | Sequence[float] | Any | None = None,
label: str | None = None,
) -> MateRelation:
fixed_target = _normalize_target(fixed)
if offset is not None:
fixed_target = offset_target(fixed_target, offset, label=label)
return self.connect(
fixed_target,
moving,
relation="face_to_face",
label=label,
)
def coaxial(
self,
fixed: MateTarget | tuple[Any, str],
moving: MateTarget | tuple[Any, str],
*,
offset: float | Sequence[float] | Any | None = None,
label: str | None = None,
) -> MateRelation:
fixed_target = _normalize_target(fixed)
if offset is not None:
fixed_target = offset_target(fixed_target, offset, label=label)
return self.connect(
fixed_target,
moving,
relation="coaxial",
label=label,
)
def revolute(
self,
fixed: MateTarget | tuple[Any, str],
moving: MateTarget | tuple[Any, str],
*,
angle: float | None = None,
label: str | None = None,
) -> MateRelation:
return self.connect(fixed, moving, relation="revolute", label=label, angle=angle)
def linear(
self,
fixed: MateTarget | tuple[Any, str],
moving: MateTarget | tuple[Any, str],
*,
position: float | None = None,
label: str | None = None,
) -> MateRelation:
return self.connect(fixed, moving, relation="linear", label=label, position=position)
def compound(self, children: Sequence[Any] | None = None, *, label: str | None = None) -> Any:
build123d = _import_build123d()
compound = build123d.Compound(
label=label or self.label,
children=list(children if children is not None else self.children),
)
if children is None:
assembly_mates = assembly_mate_payload(self.relations)
if assembly_mates:
compound.assembly_mates = assembly_mates
return compound
def build(self) -> Any:
return self.compound()
def add_rigid_frame(part: Any, name: str, location: Any) -> MateTarget:
return add_joint_frame(
part,
name,
joint_type="RigidJoint",
joint_location=location,
)
def add_axis_frame(part: Any, name: str, axis: Any, *, joint_type: str, **joint_options: Any) -> MateTarget:
return add_joint_frame(
part,
name,
joint_type=joint_type,
axis=axis,
**joint_options,
)
def add_joint_frame(part: Any, name: str, *, joint_type: str, **joint_options: Any) -> MateTarget:
build123d = _import_build123d()
joint_cls = getattr(build123d, joint_type)
label = mate_label(name)
joint_cls(
label=label,
to_part=part,
**{key: value for key, value in joint_options.items() if value is not None},
)
return MateTarget(part=part, frame=label)
def offset_target(
fixed: MateTarget | tuple[Any, str],
offset: float | Sequence[float] | Any,
*,
label: str | None = None,
) -> MateTarget:
fixed_target = _normalize_target(fixed)
fixed_joint_label, fixed_joint = _joint_for_target(fixed_target)
build123d = _import_build123d()
location = getattr(fixed_joint, "location", None)
if location is None:
location = getattr(fixed_joint, "joint_location", None)
if location is None:
raise ValueError(f"Joint {fixed_joint_label!r} does not expose a location")
offset_location = _offset_location(offset)
target_location = location * offset_location
target_label = label_text(label or fixed_joint_label, "offset")
build123d.RigidJoint(
label=target_label,
to_part=fixed_target.part,
joint_location=target_location,
)
return MateTarget(part=fixed_target.part, frame=target_label)
def _normalize_target(value: MateTarget | tuple[Any, str]) -> MateTarget:
if isinstance(value, MateTarget):
return value
if isinstance(value, tuple) and len(value) == 2:
return MateTarget(part=value[0], frame=label_text(value[1]))
raise TypeError("Mate target must be MateTarget or (part, frame_name)")
def _joint_for_target(target_value: MateTarget) -> tuple[str, Any]:
joints = getattr(target_value.part, "joints", None)
if not isinstance(joints, Mapping):
raise ValueError("Mate target part does not expose a build123d joints mapping")
joint = joints.get(target_value.frame)
if joint is not None:
return target_value.frame, joint
raise KeyError(f"Part does not define mate frame {target_value.frame!r}")
def _mate_endpoint_payload(target_value: MateTarget, frame: str, joint: Any) -> dict[str, Any]:
endpoint: dict[str, Any] = {
"part": _part_label(target_value.part),
"frame": frame,
}
location = getattr(joint, "location", None)
if location is None:
location = getattr(joint, "joint_location", None)
location_payload = _location_payload(location)
if location_payload:
endpoint.update(location_payload)
return endpoint
def _part_label(part: Any) -> str:
label = str(getattr(part, "label", "") or "").strip()
if label:
return label
return type(part).__name__
def _location_payload(location: Any) -> dict[str, Any]:
if location is None:
return {}
payload: dict[str, Any] = {}
position = _vector_payload(getattr(location, "position", None))
orientation = _vector_payload(getattr(location, "orientation", None))
if position is not None:
payload["position"] = position
if orientation is not None:
payload["orientation"] = orientation
axes: dict[str, list[float]] = {}
for axis_name, payload_key in (("x_axis", "x"), ("y_axis", "y"), ("z_axis", "z")):
axis = getattr(location, axis_name, None)
direction = _vector_payload(getattr(axis, "direction", None))
if direction is not None:
axes[payload_key] = direction
if axes:
payload["axes"] = axes
return payload
def _vector_payload(value: Any) -> list[float] | None:
if value is None:
return None
components: list[Any] = []
for attr in ("X", "Y", "Z"):
if hasattr(value, attr):
components.append(getattr(value, attr))
if len(components) != 3:
for attr in ("x", "y", "z"):
if hasattr(value, attr):
components.append(getattr(value, attr))
if len(components) > 3:
components = components[-3:]
if len(components) != 3:
try:
components = list(value)
except TypeError:
return None
if len(components) < 3:
return None
try:
return [float(components[0]), float(components[1]), float(components[2])]
except (TypeError, ValueError):
return None
def _offset_location(offset: float | Sequence[float] | Any) -> Any:
build123d = _import_build123d()
if hasattr(offset, "wrapped"):
return offset
if isinstance(offset, (int, float)):
return build123d.Location((0.0, 0.0, float(offset)))
if isinstance(offset, Sequence) and not isinstance(offset, (str, bytes)):
return build123d.Location(tuple(float(value) for value in offset))
return offset
def _normalize_label_token(value: object, *, field_name: str) -> str:
token = str(value).strip()
if not token:
raise ValueError(f"Semantic label {field_name} must be non-empty")
return "_".join(token.replace(":", "_").split())
def _json_safe(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, Mapping):
return {str(key): _json_safe(child) for key, child in value.items()}
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return [_json_safe(child) for child in value]
return str(value)
def _import_build123d() -> Any:
try:
import build123d
except ModuleNotFoundError as exc:
raise RuntimeError("cadpy.assembly requires build123d at runtime") from exc
return build123d
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,974 @@
from __future__ import annotations
import copy
from contextlib import nullcontext
import hashlib
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Sequence
from cadpy.assembly_flatten import AssemblyResolutionError, CatalogEntry
from cadpy.assembly_spec import (
REPO_ROOT,
AssemblySpec,
AssemblySpecError,
AssemblyNodeSpec,
IDENTITY_TRANSFORM,
assembly_spec_from_payload,
assembly_spec_children,
read_assembly_spec,
)
from cadpy.catalog import (
CadSource,
cad_ref_from_step_path,
iter_cad_sources,
normalize_cad_ref,
source_from_path,
)
from cadpy.glb import read_step_topology_manifest_from_glb
from cadpy.render import existing_part_glb_path, part_glb_path
from cadpy.step_export import create_bin_xcaf_doc, export_xcaf_doc_step_scene, quantity_color_rgba_from_color
from cadpy.step_scene import (
LoadedStepScene,
_shape_hash,
load_step_scene,
load_step_scene_cached,
load_step_scene_from_xcaf_doc,
occurrence_selector_id,
)
GIT_LFS_POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1\n"
@dataclass
class _AssemblyBuildCache:
resolver: "_AssemblyCatalogResolver"
step_shapes: dict[tuple[Path, bool, tuple[float, float, float, float] | None], object] = field(default_factory=dict)
step_has_assembly: dict[Path, bool] = field(default_factory=dict)
step_scenes: dict[Path, LoadedStepScene] = field(default_factory=dict)
source_colors: dict[str, tuple[float, float, float, float] | None] = field(default_factory=dict)
@dataclass
class _AssemblyCatalogResolver:
sources: tuple[CadSource, ...] | None = None
by_path: dict[Path, CadSource] = field(default_factory=dict)
by_cad_ref: dict[str, CadSource] = field(default_factory=dict)
entries: dict[Path, CatalogEntry | None] = field(default_factory=dict)
assembly_specs: dict[Path, AssemblySpec] = field(default_factory=dict)
def _ensure_index(self) -> None:
if self.sources is not None:
return
self.sources = iter_cad_sources()
for source in self.sources:
self.by_cad_ref[source.cad_ref] = source
for path in (
source.source_path,
source.step_path,
source.script_path,
source.dxf_path,
source.urdf_path,
*source.generated_paths,
):
if path is not None:
self.by_path[path.resolve()] = source
def source_for_path(self, source_path: Path) -> CadSource | None:
self._ensure_index()
resolved = source_path.resolve()
source = self.by_path.get(resolved)
if source is not None:
return source
return source_from_path(resolved) if resolved.exists() else None
def source_color_for_cad_ref(self, cad_ref: str):
self._ensure_index()
normalized = normalize_cad_ref(cad_ref)
source = self.by_cad_ref.get(normalized or "")
return source.color if source is not None else None
def entry_for_path(self, source_path: Path) -> CatalogEntry | None:
resolved = source_path.resolve()
if resolved in self.entries:
return self.entries[resolved]
entry = self._entry_for_resolved_path(resolved)
self.entries[resolved] = entry
return entry
def _entry_for_resolved_path(self, resolved: Path) -> CatalogEntry | None:
source = self.source_for_path(resolved)
if source is None:
if not resolved.is_file() or resolved.suffix.lower() not in {".step", ".stp"}:
return None
cad_ref = cad_ref_from_step_path(resolved)
return CatalogEntry(
cad_ref=cad_ref,
source_ref=_relative_to_repo(resolved),
kind="part",
source_path=resolved,
step_path=resolved,
glb_path=existing_part_glb_path(resolved) or part_glb_path(resolved),
)
if source.kind == "assembly" and source.script_path is not None:
try:
assembly_spec = self._assembly_spec_for_source(source)
except AssemblySpecError as exc:
raise AssemblyResolutionError(str(exc)) from exc
return CatalogEntry(
cad_ref=source.cad_ref,
source_ref=source.source_ref,
kind="assembly",
source_path=source.source_path,
step_path=source.step_path,
assembly_spec=assembly_spec,
)
if source.step_path is None:
return None
return CatalogEntry(
cad_ref=source.cad_ref,
source_ref=source.source_ref,
kind="part",
source_path=source.source_path,
step_path=source.step_path,
stl_path=source.stl_path,
glb_path=existing_part_glb_path(source.step_path) or part_glb_path(source.step_path),
)
def _assembly_spec_for_source(self, source: CadSource) -> AssemblySpec:
resolved = source.source_path.resolve()
cached = self.assembly_specs.get(resolved)
if cached is None:
cached = read_assembly_spec(resolved)
self.assembly_specs[resolved] = cached
return cached
def _relative_to_repo(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(REPO_ROOT).as_posix()
except ValueError:
return resolved.as_posix()
def _file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.expanduser().resolve().open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _is_git_lfs_pointer(path: Path) -> bool:
try:
with path.open("rb") as handle:
return handle.read(len(GIT_LFS_POINTER_PREFIX)) == GIT_LFS_POINTER_PREFIX
except OSError:
return False
def _location_from_transform(transform: tuple[float, ...]):
import build123d
from OCP.gp import gp_Trsf
trsf = gp_Trsf()
trsf.SetValues(
transform[0],
transform[1],
transform[2],
transform[3],
transform[4],
transform[5],
transform[6],
transform[7],
transform[8],
transform[9],
transform[10],
transform[11],
)
return build123d.Location(trsf)
@lru_cache(maxsize=4096)
def _toploc_from_transform(transform: tuple[float, ...]):
from OCP.gp import gp_Trsf
from OCP.TopLoc import TopLoc_Location
trsf = gp_Trsf()
trsf.SetValues(
transform[0],
transform[1],
transform[2],
transform[3],
transform[4],
transform[5],
transform[6],
transform[7],
transform[8],
transform[9],
transform[10],
transform[11],
)
return TopLoc_Location(trsf)
def _topods_shape_without_location(shape: object) -> object:
from OCP.TopLoc import TopLoc_Location
located = getattr(shape, "Located", None)
if not callable(located):
return shape
try:
return located(TopLoc_Location())
except Exception:
return shape
def _component_name(instance_path: tuple[str, ...]) -> str:
return "__".join(instance_path) or "root"
def _load_step_shape(step_path: Path):
if not step_path.exists():
raise FileNotFoundError(f"Referenced STEP file is missing: {_relative_to_repo(step_path)}")
if _is_git_lfs_pointer(step_path):
raise RuntimeError(f"Referenced STEP file is a Git LFS pointer: {_relative_to_repo(step_path)}")
import build123d
try:
return build123d.import_step(step_path)
except Exception as exc:
raise RuntimeError(f"Failed to load referenced STEP file: {_relative_to_repo(step_path)}") from exc
def _step_has_assembly_artifact(step_path: Path) -> bool | None:
payload = read_step_topology_manifest_from_glb(existing_part_glb_path(step_path) or part_glb_path(step_path))
if payload is None:
return None
step_hash = str(payload.get("stepHash") or "").strip()
if not step_hash or step_hash != _file_sha256(step_path):
return None
root = payload.get("assembly", {}).get("root") if isinstance(payload.get("assembly"), dict) else None
return isinstance(root, dict) and bool(root.get("children"))
def _scene_has_assembly_structure(scene: LoadedStepScene) -> bool:
roots = list(getattr(scene, "roots", []) or [])
if len(roots) > 1:
return True
stack = roots[:]
while stack:
node = stack.pop()
children = list(getattr(node, "children", []) or [])
if children:
return True
stack.extend(children)
return False
def _cached_step_has_assembly_artifact(step_path: Path, *, cache: _AssemblyBuildCache) -> bool:
resolved = step_path.resolve()
cached = cache.step_has_assembly.get(resolved)
if cached is None:
artifact_has_assembly = _step_has_assembly_artifact(resolved)
if artifact_has_assembly is None:
try:
artifact_has_assembly = _scene_has_assembly_structure(_cached_step_scene(resolved, cache=cache))
except Exception:
artifact_has_assembly = False
cached = artifact_has_assembly
cache.step_has_assembly[resolved] = cached
return cached
def _cached_step_scene(step_path: Path, *, cache: _AssemblyBuildCache) -> LoadedStepScene:
resolved = step_path.resolve()
cached = cache.step_scenes.get(resolved)
if cached is None:
cached = load_step_scene_cached(resolved)
cache.step_scenes[resolved] = cached
return cached
def _load_step_assembly_shape(step_path: Path, *, label: str):
import build123d
from cadpy.step_scene import scene_occurrence_shape
scene = load_step_scene(step_path)
def node_label(node: object) -> str:
return str(getattr(node, "name", None) or getattr(node, "source_name", None) or occurrence_selector_id(node)).strip()
def build_node(node: object):
children = list(getattr(node, "children", []) or [])
if children:
child_shapes = [build_node(child) for child in children]
return build123d.Compound(
children=child_shapes,
label=node_label(node),
)
shape = build123d.Shape(obj=scene_occurrence_shape(scene, node))
shape.label = node_label(node)
return shape
roots = [build_node(root) for root in scene.roots]
if not roots:
return _load_step_shape(step_path)
return build123d.Compound(children=roots, label=label)
def _cached_source_color_for_cad_ref(cad_ref: str, *, cache: _AssemblyBuildCache) -> tuple[float, float, float, float] | None:
if cad_ref not in cache.source_colors:
color = cache.resolver.source_color_for_cad_ref(cad_ref)
cache.source_colors[cad_ref] = None if color is None else tuple(float(value) for value in color)
return cache.source_colors[cad_ref]
def _clear_shape_colors(shape: object) -> None:
if hasattr(shape, "color"):
shape.color = None
for child in getattr(shape, "children", []) or []:
_clear_shape_colors(child)
def _apply_source_color(shape: object, cad_ref: str, *, use_source_colors: bool, cache: _AssemblyBuildCache) -> None:
import build123d
source_color = _cached_source_color_for_cad_ref(cad_ref, cache=cache)
if not use_source_colors:
_clear_shape_colors(shape)
elif source_color is not None:
shape.color = build123d.Color(*source_color)
def _copy_cached_shape_tree(shape: object):
import build123d
from OCP.BRepBuilderAPI import BRepBuilderAPI_Copy
if getattr(shape, "children", None):
copied = copy.copy(shape)
copied.label = getattr(shape, "label", None)
else:
copier = BRepBuilderAPI_Copy(shape.wrapped, True, True)
copied_shape = copier.Shape()
copied = build123d.Shape(obj=copied_shape)
copied.label = getattr(shape, "label", None)
if hasattr(copied, "color"):
copied.color = getattr(shape, "color", None)
return copied
def _shape_for_part_entry(entry: CatalogEntry, *, use_source_colors: bool, cache: _AssemblyBuildCache):
step_path = entry.step_path.resolve() if entry.step_path is not None else None
if step_path is None:
raise RuntimeError(f"Part source {entry.source_ref} is missing STEP source path")
source_color = _cached_source_color_for_cad_ref(entry.cad_ref, cache=cache) if use_source_colors else None
key = (step_path, bool(use_source_colors), source_color)
cached = cache.step_shapes.get(key)
if cached is not None:
return cached
shape = (
_load_step_assembly_shape(step_path, label=Path(step_path).stem)
if _cached_step_has_assembly_artifact(step_path, cache=cache)
else _load_step_shape(step_path)
)
_apply_source_color(shape, entry.cad_ref, use_source_colors=use_source_colors, cache=cache)
cache.step_shapes[key] = shape
return shape
def _build_node_shape(
node: AssemblyNodeSpec,
*,
resolve_entry,
instance_path: tuple[str, ...],
parent_use_source_colors: bool,
stack: tuple[str, ...],
cache: _AssemblyBuildCache,
):
import build123d
component_path = (*instance_path, node.instance_id) if instance_path else (node.instance_id,)
label = _component_name(component_path)
use_source_colors = parent_use_source_colors and node.use_source_colors
copy_before_move = False
if node.children:
child_shapes = [
_build_node_shape(
child,
resolve_entry=resolve_entry,
instance_path=component_path,
parent_use_source_colors=use_source_colors,
stack=stack,
cache=cache,
)
for child in node.children
]
shape = build123d.Compound(children=child_shapes, label=label)
else:
if node.source_path is None or node.path is None:
raise RuntimeError(f"Assembly node {label} is missing a STEP source path")
child_entry = resolve_entry(node.source_path)
if child_entry is None:
raise RuntimeError(f"Assembly node {label} references missing CAD source {node.path}")
if child_entry.kind == "assembly":
if child_entry.assembly_spec is None:
raise RuntimeError(f"Assembly source {child_entry.source_ref} is missing assembly spec data")
stack_key = child_entry.source_ref
if stack_key in stack:
cycle = " -> ".join((*stack, stack_key))
raise RuntimeError(f"Assembly cycle detected: {cycle}")
shape = _compound_from_nodes(
assembly_spec_children(child_entry.assembly_spec),
label=label,
resolve_entry=resolve_entry,
instance_path=component_path,
parent_use_source_colors=use_source_colors,
stack=(*stack, stack_key),
cache=cache,
)
elif child_entry.kind == "part":
shape = _shape_for_part_entry(child_entry, use_source_colors=use_source_colors, cache=cache)
copy_before_move = True
else:
raise RuntimeError(f"Assembly node {label} resolved to unsupported CAD source kind: {child_entry.kind}")
if copy_before_move:
shape = _copy_cached_shape_tree(shape)
moved = shape.moved(_location_from_transform(node.transform))
moved.label = label
if not use_source_colors:
_clear_shape_colors(moved)
return moved
def _compound_from_nodes(
nodes: Sequence[AssemblyNodeSpec],
*,
label: str,
resolve_entry,
instance_path: tuple[str, ...] = (),
parent_use_source_colors: bool,
stack: tuple[str, ...],
cache: _AssemblyBuildCache,
):
import build123d
children = [
_build_node_shape(
node,
resolve_entry=resolve_entry,
instance_path=instance_path,
parent_use_source_colors=parent_use_source_colors,
stack=stack,
cache=cache,
)
for node in nodes
]
if not children:
raise RuntimeError(f"Assembly {label} has no resolved STEP instances")
return build123d.Compound(
children=children,
label=label,
)
class _DirectXcafAssemblyWriter:
def __init__(
self,
assembly_spec: AssemblySpec,
*,
label: str,
resolver: _AssemblyCatalogResolver | None = None,
) -> None:
from OCP.XCAFDoc import XCAFDoc_DocumentTool
self.assembly_spec = assembly_spec
self.label = label
self.doc = create_bin_xcaf_doc()
self.shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(self.doc.Main())
self.color_tool = XCAFDoc_DocumentTool.ColorTool_s(self.doc.Main())
self.resolver = resolver or _AssemblyCatalogResolver()
self.cache = _AssemblyBuildCache(resolver=self.resolver)
self.shape_definitions: dict[int, object] = {}
self.step_scene_definitions: dict[tuple[Path, bool, tuple[float, float, float, float] | None], object] = {}
self.step_scene_node_definitions: dict[
tuple[Path, tuple[int, ...], bool, tuple[float, float, float, float] | None],
object,
] = {}
self.step_scene_prototype_definitions: dict[
tuple[Path, int, bool, tuple[float, float, float, float] | None],
object,
] = {}
self.colors_by_rgba: dict[tuple[float, float, float, float], object] = {}
def build_doc(self):
root_label = self.shape_tool.NewShape()
self._set_label_name(root_label, self.label)
root_source = self.resolver.source_for_path(self.assembly_spec.assembly_path)
root_source_ref = (
root_source.source_ref
if root_source is not None
else _relative_to_repo(self.assembly_spec.assembly_path)
)
self._add_nodes_to_assembly(
root_label,
assembly_spec_children(self.assembly_spec),
instance_path=(),
parent_use_source_colors=True,
stack=(root_source_ref,),
)
self.shape_tool.UpdateAssemblies()
return self.doc
def _set_label_name(self, label: object, name: str | None) -> None:
if not name:
return
from OCP.TCollection import TCollection_ExtendedString
from OCP.TDataStd import TDataStd_Name
TDataStd_Name.Set_s(label, TCollection_ExtendedString(str(name)))
def _set_label_color(self, label: object, color: object | None) -> None:
if color is None:
return
from OCP.XCAFDoc import XCAFDoc_ColorType
wrapped = (
self._quantity_color_rgba(color)
if isinstance(color, tuple)
else quantity_color_rgba_from_color(color)
)
if wrapped is None:
return
self.color_tool.SetColor(label, wrapped, XCAFDoc_ColorType.XCAFDoc_ColorSurf)
def _set_shape_face_colors(
self,
shape_label: object,
shape: object,
face_colors: dict[int, tuple[float, float, float, float]],
) -> None:
if not face_colors:
return
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
face = TopoDS.Face_s(explorer.Current())
color = face_colors.get(_shape_hash(face))
if color is not None:
face_label = self.shape_tool.AddSubShape(shape_label, face)
self._set_label_color(face_label, color)
explorer.Next()
def _quantity_color_rgba(self, color: tuple[float, ...]) -> object:
values = tuple(max(0.0, min(1.0, float(component))) for component in color)
if len(values) == 3:
rgba = (values[0], values[1], values[2], 1.0)
elif len(values) >= 4:
rgba = (values[0], values[1], values[2], values[3])
else:
rgba = (0.72, 0.72, 0.72, 1.0)
cached = self.colors_by_rgba.get(rgba)
if cached is None:
cached = quantity_color_rgba_from_color(rgba)
self.colors_by_rgba[rgba] = cached
return cached
def _part_definition_for_entry(self, entry: CatalogEntry, *, use_source_colors: bool) -> object:
step_path = entry.step_path.resolve() if entry.step_path is not None else None
if step_path is None:
raise RuntimeError(f"Part source {entry.source_ref} is missing STEP source path")
if _cached_step_has_assembly_artifact(step_path, cache=self.cache):
return self._step_scene_definition_for_entry(
entry,
step_path=step_path,
use_source_colors=use_source_colors,
)
shape = _shape_for_part_entry(entry, use_source_colors=use_source_colors, cache=self.cache)
return self._shape_definition_for_tree(shape)
def _step_scene_definition_for_entry(
self,
entry: CatalogEntry,
*,
step_path: Path,
use_source_colors: bool,
) -> object:
source_color = _cached_source_color_for_cad_ref(entry.cad_ref, cache=self.cache) if use_source_colors else None
key = (step_path, bool(use_source_colors), source_color)
cached = self.step_scene_definitions.get(key)
if cached is not None:
return cached
scene = _cached_step_scene(step_path, cache=self.cache)
definition_label = self._new_assembly_definition(Path(step_path).stem)
self.step_scene_definitions[key] = definition_label
if source_color is not None:
self._set_label_color(definition_label, source_color)
for root in scene.roots:
root_definition = self._step_scene_node_definition(
scene,
root,
use_source_colors=use_source_colors,
source_color=source_color,
)
root_component = self.shape_tool.AddComponent(
definition_label,
root_definition,
_toploc_from_transform(root.local_transform),
)
self._set_label_name(root_component, self._step_scene_node_name(root))
if use_source_colors:
self._set_label_color(root_component, source_color or root.color)
return definition_label
def _step_scene_node_definition(
self,
scene: LoadedStepScene,
node: object,
*,
use_source_colors: bool,
source_color: tuple[float, float, float, float] | None,
) -> object:
key = (
scene.step_path.resolve(),
tuple(getattr(node, "path", ())),
bool(use_source_colors),
source_color,
)
cached = self.step_scene_node_definitions.get(key)
if cached is not None:
return cached
children = list(getattr(node, "children", []) or [])
if children:
definition_label = self._new_assembly_definition(self._step_scene_node_name(node))
self.step_scene_node_definitions[key] = definition_label
if use_source_colors:
self._set_label_color(definition_label, source_color or getattr(node, "color", None))
for child in children:
child_definition = self._step_scene_node_definition(
scene,
child,
use_source_colors=use_source_colors,
source_color=source_color,
)
child_component = self.shape_tool.AddComponent(
definition_label,
child_definition,
_toploc_from_transform(getattr(child, "local_transform", IDENTITY_TRANSFORM)),
)
self._set_label_name(child_component, self._step_scene_node_name(child))
if use_source_colors:
self._set_label_color(child_component, source_color or getattr(child, "color", None))
return definition_label
prototype_key = getattr(node, "prototype_key", None)
if prototype_key is None or prototype_key not in scene.prototype_shapes:
raise RuntimeError(f"Imported STEP occurrence {occurrence_selector_id(node)} has no prototype shape")
definition_label = self._step_scene_prototype_definition(
scene,
int(prototype_key),
use_source_colors=use_source_colors,
source_color=source_color,
)
self.step_scene_node_definitions[key] = definition_label
return definition_label
def _step_scene_prototype_definition(
self,
scene: LoadedStepScene,
prototype_key: int,
*,
use_source_colors: bool,
source_color: tuple[float, float, float, float] | None,
) -> object:
key = (scene.step_path.resolve(), int(prototype_key), bool(use_source_colors), source_color)
cached = self.step_scene_prototype_definitions.get(key)
if cached is not None:
return cached
definition_shape = _topods_shape_without_location(scene.prototype_shapes[prototype_key])
definition_label = self.shape_tool.AddShape(definition_shape, False)
self.step_scene_prototype_definitions[key] = definition_label
self._set_label_name(definition_label, scene.prototype_names.get(prototype_key))
if use_source_colors:
self._set_label_color(definition_label, source_color or scene.prototype_colors.get(prototype_key))
if source_color is None:
self._set_shape_face_colors(
definition_label,
definition_shape,
scene.prototype_face_colors.get(prototype_key, {}),
)
return definition_label
def _step_scene_node_name(self, node: object) -> str:
return str(
getattr(node, "name", None)
or getattr(node, "source_name", None)
or occurrence_selector_id(node)
).strip()
def _add_nodes_to_assembly(
self,
assembly_label: object,
nodes: Sequence[AssemblyNodeSpec],
*,
instance_path: tuple[str, ...],
parent_use_source_colors: bool,
stack: tuple[str, ...],
) -> None:
for node in nodes:
self._add_node_to_assembly(
assembly_label,
node,
instance_path=instance_path,
parent_use_source_colors=parent_use_source_colors,
stack=stack,
)
def _add_node_to_assembly(
self,
assembly_label: object,
node: AssemblyNodeSpec,
*,
instance_path: tuple[str, ...],
parent_use_source_colors: bool,
stack: tuple[str, ...],
) -> object:
component_path = (*instance_path, node.instance_id) if instance_path else (node.instance_id,)
label = _component_name(component_path)
use_source_colors = parent_use_source_colors and node.use_source_colors
if node.children:
definition_label = self._new_assembly_definition(label)
self._add_nodes_to_assembly(
definition_label,
node.children,
instance_path=component_path,
parent_use_source_colors=use_source_colors,
stack=stack,
)
else:
if node.source_path is None or node.path is None:
raise RuntimeError(f"Assembly node {label} is missing a STEP source path")
child_entry = self.resolver.entry_for_path(node.source_path)
if child_entry is None:
raise RuntimeError(f"Assembly node {label} references missing CAD source {node.path}")
if child_entry.kind == "assembly":
definition_label = self._assembly_definition_for_entry(
child_entry,
instance_path=component_path,
parent_use_source_colors=use_source_colors,
stack=stack,
)
elif child_entry.kind == "part":
definition_label = self._part_definition_for_entry(
child_entry,
use_source_colors=use_source_colors,
)
else:
raise RuntimeError(f"Assembly node {label} resolved to unsupported CAD source kind: {child_entry.kind}")
component_label = self.shape_tool.AddComponent(
assembly_label,
definition_label,
_toploc_from_transform(node.transform),
)
self._set_label_name(component_label, label)
return component_label
def _assembly_definition_for_entry(
self,
entry: CatalogEntry,
*,
instance_path: tuple[str, ...],
parent_use_source_colors: bool,
stack: tuple[str, ...],
) -> object:
assembly_spec = entry.assembly_spec
if assembly_spec is None:
raise RuntimeError(f"Assembly source {entry.source_ref} is missing assembly spec data")
stack_key = entry.source_ref
if stack_key in stack:
cycle = " -> ".join((*stack, stack_key))
raise RuntimeError(f"Assembly cycle detected: {cycle}")
definition_label = self._new_assembly_definition(_component_name(instance_path))
self._add_nodes_to_assembly(
definition_label,
assembly_spec_children(assembly_spec),
instance_path=instance_path,
parent_use_source_colors=parent_use_source_colors,
stack=(*stack, stack_key),
)
return definition_label
def _new_assembly_definition(self, name: str | None = None) -> object:
definition_label = self.shape_tool.NewShape()
self._set_label_name(definition_label, name)
return definition_label
def _shape_definition_for_tree(self, shape: object) -> object:
key = id(shape)
cached = self.shape_definitions.get(key)
if cached is not None:
return cached
children = list(getattr(shape, "children", []) or [])
if children:
definition_label = self._new_assembly_definition(getattr(shape, "label", None))
self.shape_definitions[key] = definition_label
self._set_label_color(definition_label, getattr(shape, "color", None))
for child in children:
child_definition = self._shape_definition_for_tree(child)
child_component = self.shape_tool.AddComponent(
definition_label,
child_definition,
self._shape_location(child),
)
self._set_label_name(child_component, getattr(child, "label", None))
self._set_label_color(child_component, getattr(child, "color", None))
return definition_label
definition_label = self.shape_tool.AddShape(_topods_shape_without_location(shape.wrapped), False)
self.shape_definitions[key] = definition_label
self._set_label_name(definition_label, getattr(shape, "label", None))
self._set_label_color(definition_label, getattr(shape, "color", None))
return definition_label
def _shape_location(self, shape: object) -> object:
location = getattr(getattr(shape, "wrapped", None), "Location", None)
if not callable(location):
return _toploc_from_transform(IDENTITY_TRANSFORM)
try:
return location()
except Exception:
return _toploc_from_transform(IDENTITY_TRANSFORM)
def export_direct_assembly_step_scene(
assembly_spec: AssemblySpec,
output_path: Path,
*,
text_to_cad_entry_kind: str | None = "assembly",
source_path: str | None = None,
source_hash: str | None = None,
resolver: _AssemblyCatalogResolver | None = None,
logger: object | None = None,
) -> LoadedStepScene:
output_path = output_path.expanduser().resolve()
writer = _DirectXcafAssemblyWriter(assembly_spec, label=output_path.stem, resolver=resolver)
with (logger.timed(f"build assembly XCAF {output_path.name}") if logger is not None else nullcontext()):
doc = writer.build_doc()
with (logger.timed(f"export assembly STEP {output_path.name}") if logger is not None else nullcontext()):
scene = export_xcaf_doc_step_scene(
doc,
output_path,
label=output_path.stem,
originating_system="tom-cad direct XCAF assembly",
text_to_cad_entry_kind=text_to_cad_entry_kind,
source_path=source_path,
source_hash=source_hash,
logger=logger,
)
return scene
def build_direct_assembly_step_scene(
assembly_spec: AssemblySpec,
output_path: Path,
*,
source_kind: str = "step",
source_hash: str | None = None,
resolver: _AssemblyCatalogResolver | None = None,
logger: object | None = None,
) -> LoadedStepScene:
output_path = output_path.expanduser().resolve()
writer = _DirectXcafAssemblyWriter(assembly_spec, label=output_path.stem, resolver=resolver)
with (logger.timed(f"build assembly XCAF {output_path.name}") if logger is not None else nullcontext()):
doc = writer.build_doc()
with (logger.timed(f"load assembly scene from XCAF {output_path.name}") if logger is not None else nullcontext()):
return load_step_scene_from_xcaf_doc(
output_path,
doc,
source_kind=source_kind,
source_hash=source_hash,
)
def build_assembly_compound(assembly_spec: AssemblySpec, *, label: str | None = None):
resolver = _AssemblyCatalogResolver()
root_source = resolver.source_for_path(assembly_spec.assembly_path)
root_source_ref = root_source.source_ref if root_source is not None else _relative_to_repo(assembly_spec.assembly_path)
cache = _AssemblyBuildCache(resolver=resolver)
return _compound_from_nodes(
assembly_spec_children(assembly_spec),
label=label or Path(assembly_spec.assembly_path).stem,
resolve_entry=resolver.entry_for_path,
parent_use_source_colors=True,
stack=(root_source_ref,),
cache=cache,
)
def export_assembly_step_scene(
assembly_spec: AssemblySpec,
output_path: Path,
*,
text_to_cad_entry_kind: str | None = "assembly",
source_path: str | None = None,
source_hash: str | None = None,
resolver: _AssemblyCatalogResolver | None = None,
logger: object | None = None,
) -> LoadedStepScene:
try:
scene = export_direct_assembly_step_scene(
assembly_spec,
output_path,
text_to_cad_entry_kind=text_to_cad_entry_kind,
source_path=source_path,
source_hash=source_hash,
resolver=resolver,
logger=logger,
)
except Exception as exc:
raise RuntimeError(f"Failed to write assembly STEP file: {_relative_to_repo(output_path)}") from exc
return scene
def export_assembly_step(assembly_spec: AssemblySpec, output_path: Path) -> Path:
export_assembly_step_scene(assembly_spec, output_path)
return output_path
def export_assembly_step_from_payload(
payload: object,
*,
assembly_path: Path,
output_path: Path,
) -> Path:
return export_assembly_step(
assembly_spec_from_payload(assembly_path, payload),
output_path,
)
def export_assembly_step_scene_from_payload(
payload: object,
*,
assembly_path: Path,
output_path: Path,
text_to_cad_entry_kind: str | None = "assembly",
) -> LoadedStepScene:
return export_assembly_step_scene(
assembly_spec_from_payload(assembly_path, payload),
output_path,
text_to_cad_entry_kind=text_to_cad_entry_kind,
)
@@ -0,0 +1,249 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from cadpy.assembly_spec import (
AssemblySpec,
AssemblySpecError,
AssemblyNodeSpec,
IDENTITY_TRANSFORM,
assembly_spec_children,
multiply_transforms,
read_assembly_spec,
)
from cadpy.catalog import cad_ref_from_step_path, find_source_by_path, source_from_path
from cadpy.render import existing_part_glb_path, part_glb_path, relative_to_repo
class AssemblyResolutionError(ValueError):
pass
@dataclass(frozen=True)
class CatalogEntry:
cad_ref: str
source_ref: str
kind: str
source_path: Path
step_path: Path | None = None
assembly_spec: AssemblySpec | None = None
stl_path: Path | None = None
glb_path: Path | None = None
@dataclass(frozen=True)
class ResolvedPartInstance:
instance_path: tuple[str, ...]
cad_ref: str
name: str | None
color: str | None
use_source_colors: bool
step_path: Path
stl_path: Path | None
glb_path: Path
transform: tuple[float, ...]
EntryResolver = Callable[[Path], CatalogEntry | None]
def filesystem_entry(source_path: Path) -> CatalogEntry | None:
resolved = source_path.resolve()
if resolved.suffix.lower() in {".step", ".stp"}:
source = find_source_by_path(resolved)
if source is not None and source.kind == "assembly" and source.script_path is not None:
try:
assembly_spec = read_assembly_spec(source.source_path)
except AssemblySpecError as exc:
raise AssemblyResolutionError(str(exc)) from exc
return CatalogEntry(
cad_ref=source.cad_ref,
source_ref=source.source_ref,
kind="assembly",
source_path=source.source_path,
step_path=source.step_path,
assembly_spec=assembly_spec,
)
if source is not None and source.step_path is not None:
return CatalogEntry(
cad_ref=source.cad_ref,
source_ref=source.source_ref,
kind="part",
source_path=source.source_path,
step_path=source.step_path,
stl_path=source.stl_path,
glb_path=existing_part_glb_path(source.step_path) or part_glb_path(source.step_path),
)
if not resolved.is_file():
return None
cad_ref = cad_ref_from_step_path(resolved)
return CatalogEntry(
cad_ref=cad_ref,
source_ref=relative_to_repo(resolved),
kind="part",
source_path=resolved,
step_path=resolved,
glb_path=existing_part_glb_path(resolved) or part_glb_path(resolved),
)
source = source_from_path(resolved) if resolved.exists() else None
if source is None:
source = find_source_by_path(source_path)
if source is None:
return None
if source.kind == "assembly" and source.script_path is not None:
try:
assembly_spec = read_assembly_spec(source.source_path)
except AssemblySpecError as exc:
raise AssemblyResolutionError(str(exc)) from exc
return CatalogEntry(
cad_ref=source.cad_ref,
source_ref=source.source_ref,
kind="assembly",
source_path=source.source_path,
assembly_spec=assembly_spec,
)
if source.step_path is None:
return None
return CatalogEntry(
cad_ref=source.cad_ref,
source_ref=source.source_ref,
kind="part",
source_path=source.source_path,
step_path=source.step_path,
stl_path=source.stl_path,
glb_path=existing_part_glb_path(source.step_path) or part_glb_path(source.step_path),
)
def flatten_entry(
entry: CatalogEntry,
*,
resolve_entry: EntryResolver,
parent_transform: tuple[float, ...] = IDENTITY_TRANSFORM,
parent_color: str | None = None,
parent_use_source_colors: bool = True,
instance_path: tuple[str, ...] = (),
stack: tuple[str, ...] = (),
) -> tuple[ResolvedPartInstance, ...]:
if entry.kind == "part":
if entry.step_path is None or entry.glb_path is None:
raise AssemblyResolutionError(f"Part source {entry.source_ref} is missing STEP or GLB source paths")
return (
ResolvedPartInstance(
instance_path=instance_path or (entry.cad_ref,),
cad_ref=entry.cad_ref,
name=None,
color=parent_color,
use_source_colors=parent_use_source_colors,
step_path=entry.step_path,
stl_path=entry.stl_path,
glb_path=entry.glb_path,
transform=parent_transform,
),
)
assembly_spec = entry.assembly_spec
if assembly_spec is None:
raise AssemblyResolutionError(f"Assembly source {entry.source_ref} is missing assembly spec data")
if entry.source_ref in stack:
cycle = " -> ".join((*stack, entry.source_ref))
raise AssemblyResolutionError(f"Assembly cycle detected: {cycle}")
resolved_parts: list[ResolvedPartInstance] = []
next_stack = (*stack, entry.source_ref)
for child in assembly_spec_children(assembly_spec):
resolved_parts.extend(
_flatten_node(
child,
parent_source_ref=entry.source_ref,
resolve_entry=resolve_entry,
parent_transform=parent_transform,
parent_color=parent_color,
parent_use_source_colors=parent_use_source_colors,
instance_path=instance_path,
stack=next_stack,
)
)
return tuple(resolved_parts)
def _flatten_node(
node: AssemblyNodeSpec,
*,
parent_source_ref: str,
resolve_entry: EntryResolver,
parent_transform: tuple[float, ...],
parent_color: str | None,
parent_use_source_colors: bool,
instance_path: tuple[str, ...],
stack: tuple[str, ...],
) -> tuple[ResolvedPartInstance, ...]:
child_transform = multiply_transforms(parent_transform, node.transform)
child_color = parent_color
child_use_source_colors = parent_use_source_colors and node.use_source_colors
child_instance_path = (*instance_path, node.instance_id) if instance_path else (node.instance_id,)
if node.children:
resolved_parts: list[ResolvedPartInstance] = []
for child in node.children:
resolved_parts.extend(
_flatten_node(
child,
parent_source_ref=parent_source_ref,
resolve_entry=resolve_entry,
parent_transform=child_transform,
parent_color=child_color,
parent_use_source_colors=child_use_source_colors,
instance_path=child_instance_path,
stack=stack,
)
)
return tuple(resolved_parts)
if node.source_path is None or node.path is None:
raise AssemblyResolutionError(f"Assembly source {parent_source_ref} contains node {node.name!r} without a STEP path")
child_entry = resolve_entry(node.source_path)
if child_entry is None:
raise AssemblyResolutionError(
f"Assembly source {parent_source_ref} references missing CAD source {node.path}"
)
if child_entry.kind == "part":
if child_entry.step_path is None or child_entry.glb_path is None:
raise AssemblyResolutionError(
f"Part source {child_entry.source_ref} is missing STEP or GLB source paths"
)
return (
ResolvedPartInstance(
instance_path=child_instance_path,
cad_ref=child_entry.cad_ref,
name=node.name,
color=child_color,
use_source_colors=child_use_source_colors,
step_path=child_entry.step_path,
stl_path=child_entry.stl_path,
glb_path=child_entry.glb_path,
transform=child_transform,
),
)
return flatten_entry(
child_entry,
resolve_entry=resolve_entry,
parent_transform=child_transform,
parent_color=child_color,
parent_use_source_colors=child_use_source_colors,
instance_path=child_instance_path,
stack=stack,
)
def flatten_source_path(
source_path: Path,
*,
resolve_entry: EntryResolver = filesystem_entry,
) -> tuple[ResolvedPartInstance, ...]:
entry = resolve_entry(source_path)
if entry is None:
raise AssemblyResolutionError(f"CAD source not found: {source_path}")
return flatten_entry(entry, resolve_entry=resolve_entry)
@@ -0,0 +1,458 @@
from __future__ import annotations
import importlib.util
import math
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from pathlib import PurePosixPath
from cadpy.catalog import find_source_by_cad_ref
from cadpy.metadata import parse_generator_metadata
REPO_ROOT = Path.cwd().resolve()
CAD_ROOT = REPO_ROOT
STEP_SUFFIXES = (".step", ".stp")
INSTANCE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
IDENTITY_TRANSFORM = (
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
)
class AssemblySpecError(ValueError):
pass
@dataclass(frozen=True)
class AssemblyInstanceSpec:
instance_id: str
source_path: Path
path: str
name: str
transform: tuple[float, ...]
use_source_colors: bool = True
@dataclass(frozen=True)
class AssemblyNodeSpec:
instance_id: str
name: str
transform: tuple[float, ...]
use_source_colors: bool = True
source_path: Path | None = None
path: str | None = None
children: tuple["AssemblyNodeSpec", ...] = ()
@dataclass(frozen=True)
class AssemblySpec:
assembly_path: Path
instances: tuple[AssemblyInstanceSpec, ...]
children: tuple[AssemblyNodeSpec, ...] = ()
def _display_path(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(REPO_ROOT).as_posix()
except ValueError:
return resolved.as_posix()
def find_step_path(cad_ref: str) -> Path | None:
source = find_source_by_cad_ref(cad_ref)
if source is not None and source.kind in {"part", "assembly"}:
return source.step_path.resolve() if source.step_path is not None else None
return None
def resolve_cad_source_path(cad_ref: str) -> tuple[str, Path] | None:
source = find_source_by_cad_ref(cad_ref)
if source is not None:
if source.kind == "assembly":
return "assembly", source.source_path
if source.kind == "part":
step_path = source.step_path
return ("part", step_path) if step_path is not None else None
return None
def multiply_transforms(left: tuple[float, ...], right: tuple[float, ...]) -> tuple[float, ...]:
product: list[float] = []
for row in range(4):
for column in range(4):
total = 0.0
for offset in range(4):
total += left[(row * 4) + offset] * right[(offset * 4) + column]
product.append(total)
return tuple(product)
def read_assembly_spec(assembly_path: Path) -> AssemblySpec:
resolved_path = assembly_path.resolve()
payload = _run_assembly_generator(resolved_path)
return assembly_spec_from_payload(resolved_path, payload)
def assembly_spec_from_payload(assembly_path: Path, payload: object) -> AssemblySpec:
resolved_path = assembly_path.resolve()
if isinstance(payload, list):
payload = {"children": payload}
if not isinstance(payload, dict):
raise AssemblySpecError(f"{_display_path(resolved_path)} gen_step() must return an assembly list or object")
allowed_fields = {"instances", "children"}
extra_fields = sorted(str(key) for key in payload if key not in allowed_fields)
if extra_fields:
joined = ", ".join(extra_fields)
raise AssemblySpecError(
f"{_display_path(resolved_path)} has unsupported assembly field(s): {joined}"
)
raw_instances = payload.get("instances")
raw_children = payload.get("children")
has_instances = "instances" in payload
has_children = "children" in payload
if has_instances == has_children:
raise AssemblySpecError(
f"{_display_path(resolved_path)} must define exactly one of non-empty instances or children"
)
if has_instances:
if not isinstance(raw_instances, list) or not raw_instances:
raise AssemblySpecError(f"{_display_path(resolved_path)} must define a non-empty instances array")
instances = tuple(
_parse_instance_spec(resolved_path, raw_instance, field_name=f"instances[{index}]")
for index, raw_instance in enumerate(raw_instances, start=1)
)
_reject_duplicate_sibling_names(resolved_path, instances, field_name="instances")
children = tuple(_node_from_instance(instance) for instance in instances)
else:
if not isinstance(raw_children, list) or not raw_children:
raise AssemblySpecError(f"{_display_path(resolved_path)} must define a non-empty children array")
children = tuple(
_parse_node_spec(resolved_path, raw_child, field_name=f"children[{index}]")
for index, raw_child in enumerate(raw_children, start=1)
)
_reject_duplicate_sibling_names(resolved_path, children, field_name="children")
instances = tuple(_leaf_instances_from_nodes(children))
return AssemblySpec(
assembly_path=resolved_path,
instances=instances,
children=children,
)
def assembly_spec_children(assembly_spec: AssemblySpec) -> tuple[AssemblyNodeSpec, ...]:
if assembly_spec.children:
return assembly_spec.children
return tuple(_node_from_instance(instance) for instance in assembly_spec.instances)
def _node_from_instance(instance: AssemblyInstanceSpec) -> AssemblyNodeSpec:
return AssemblyNodeSpec(
instance_id=instance.instance_id,
name=instance.name,
transform=instance.transform,
use_source_colors=instance.use_source_colors,
source_path=instance.source_path,
path=instance.path,
children=(),
)
def _leaf_instances_from_nodes(nodes: tuple[AssemblyNodeSpec, ...]) -> tuple[AssemblyInstanceSpec, ...]:
instances: list[AssemblyInstanceSpec] = []
for node in nodes:
if node.children:
instances.extend(_leaf_instances_from_nodes(node.children))
continue
if node.source_path is None or node.path is None:
raise AssemblySpecError(f"Assembly leaf node {node.instance_id!r} is missing a STEP path")
instances.append(
AssemblyInstanceSpec(
instance_id=node.instance_id,
source_path=node.source_path,
path=node.path,
name=node.name,
transform=node.transform,
use_source_colors=node.use_source_colors,
)
)
return tuple(instances)
def _run_assembly_generator(assembly_path: Path) -> object:
try:
generator_metadata = parse_generator_metadata(assembly_path)
except Exception as exc:
raise AssemblySpecError(f"Failed to parse {_display_path(assembly_path)}") from exc
if generator_metadata is None or generator_metadata.kind != "assembly":
raise AssemblySpecError(
f"{_display_path(assembly_path)} must define a gen_step() assembly return"
)
module_name = (
"_cad_assembly_"
+ _display_path(assembly_path).replace("/", "_").replace("\\", "_").replace("-", "_").replace(".", "_")
)
module_spec = importlib.util.spec_from_file_location(module_name, assembly_path)
if module_spec is None or module_spec.loader is None:
raise AssemblySpecError(f"Failed to load assembly generator: {_display_path(assembly_path)}")
module = importlib.util.module_from_spec(module_spec)
original_sys_path = list(sys.path)
search_paths = [
str(REPO_ROOT),
str(CAD_ROOT),
str(assembly_path.parent),
]
for parent in assembly_path.parents:
if parent == REPO_ROOT.parent:
break
if (
(parent / "STEP" / "__init__.py").is_file()
or (parent / "robot_common" / "__init__.py").is_file()
):
search_paths.append(str(parent))
for candidate in reversed(search_paths):
if candidate not in sys.path:
sys.path.insert(0, candidate)
try:
sys.modules[module_name] = module
module_spec.loader.exec_module(module)
except Exception as exc:
raise AssemblySpecError(f"{_display_path(assembly_path)} failed while loading") from exc
finally:
sys.path[:] = original_sys_path
gen_step = getattr(module, "gen_step", None)
if not callable(gen_step):
raise AssemblySpecError(f"{_display_path(assembly_path)} does not define a callable gen_step()")
try:
envelope = gen_step()
except Exception as exc:
raise AssemblySpecError(f"{_display_path(assembly_path)} gen_step() failed") from exc
if isinstance(envelope, list):
return {"children": envelope}
if not isinstance(envelope, dict) or ("instances" not in envelope and "children" not in envelope):
raise AssemblySpecError(
f"{_display_path(assembly_path)} gen_step() must return an assembly list or legacy envelope with instances or children"
)
return {key: envelope[key] for key in ("instances", "children") if key in envelope}
def _parse_instance_spec(assembly_path: Path, raw_instance: object, *, field_name: str) -> AssemblyInstanceSpec:
if not isinstance(raw_instance, dict):
raise AssemblySpecError(f"{_display_path(assembly_path)} {field_name} must be an object")
allowed_instance_fields = {"path", "name", "transform", "use_source_colors"}
extra_instance_fields = sorted(str(key) for key in raw_instance if key not in allowed_instance_fields)
if extra_instance_fields:
joined = ", ".join(extra_instance_fields)
raise AssemblySpecError(
f"{_display_path(assembly_path)} {field_name} has unsupported field(s): {joined}"
)
name = _normalize_instance_name(
assembly_path,
raw_instance.get("name"),
field_name=f"{field_name}.name",
)
raw_path = _require_text(
assembly_path,
raw_instance.get("path"),
field_name=f"{field_name}.path",
)
source_path, normalized_path = _resolve_instance_step_path(
assembly_path,
raw_path,
field_name=f"{field_name}.path",
)
return AssemblyInstanceSpec(
instance_id=name,
source_path=source_path,
path=normalized_path,
name=name,
transform=_normalize_transform(
assembly_path,
raw_instance.get("transform"),
field_name=f"{field_name}.transform",
),
use_source_colors=_normalize_bool(
assembly_path,
raw_instance.get("use_source_colors", True),
field_name=f"{field_name}.use_source_colors",
),
)
def _parse_node_spec(assembly_path: Path, raw_node: object, *, field_name: str) -> AssemblyNodeSpec:
if not isinstance(raw_node, dict):
raise AssemblySpecError(f"{_display_path(assembly_path)} {field_name} must be an object")
allowed_node_fields = {"path", "name", "transform", "use_source_colors", "children"}
extra_node_fields = sorted(str(key) for key in raw_node if key not in allowed_node_fields)
if extra_node_fields:
joined = ", ".join(extra_node_fields)
raise AssemblySpecError(
f"{_display_path(assembly_path)} {field_name} has unsupported field(s): {joined}"
)
name = _normalize_instance_name(
assembly_path,
raw_node.get("name"),
field_name=f"{field_name}.name",
)
transform = _normalize_transform(
assembly_path,
raw_node.get("transform"),
field_name=f"{field_name}.transform",
)
use_source_colors = _normalize_bool(
assembly_path,
raw_node.get("use_source_colors", True),
field_name=f"{field_name}.use_source_colors",
)
source_path: Path | None = None
normalized_path: str | None = None
if "path" in raw_node:
raw_path = _require_text(
assembly_path,
raw_node.get("path"),
field_name=f"{field_name}.path",
)
source_path, normalized_path = _resolve_instance_step_path(
assembly_path,
raw_path,
field_name=f"{field_name}.path",
)
raw_children = raw_node.get("children")
children: tuple[AssemblyNodeSpec, ...] = ()
if "children" in raw_node:
if not isinstance(raw_children, list) or not raw_children:
raise AssemblySpecError(f"{_display_path(assembly_path)} {field_name}.children must be a non-empty array")
children = tuple(
_parse_node_spec(assembly_path, child, field_name=f"{field_name}.children[{index}]")
for index, child in enumerate(raw_children, start=1)
)
_reject_duplicate_sibling_names(assembly_path, children, field_name=f"{field_name}.children")
elif source_path is None:
raise AssemblySpecError(f"{_display_path(assembly_path)} {field_name} must define path or children")
return AssemblyNodeSpec(
instance_id=name,
name=name,
transform=transform,
use_source_colors=use_source_colors,
source_path=source_path,
path=normalized_path,
children=children,
)
def _normalize_instance_name(assembly_path: Path, raw_value: object, *, field_name: str) -> str:
name = _require_text(assembly_path, raw_value, field_name=field_name)
if not INSTANCE_NAME_PATTERN.fullmatch(name):
raise AssemblySpecError(
f"{_display_path(assembly_path)} {field_name} must contain only "
"letters, numbers, '.', '_', or '-'"
)
return name
def _reject_duplicate_sibling_names(
assembly_path: Path,
nodes: tuple[AssemblyInstanceSpec, ...] | tuple[AssemblyNodeSpec, ...],
*,
field_name: str,
) -> None:
seen: set[str] = set()
for index, node in enumerate(nodes, start=1):
name = str(node.instance_id)
if name in seen:
raise AssemblySpecError(
f"{_display_path(assembly_path)} {field_name}[{index}].name duplicates {name!r}"
)
seen.add(name)
def _require_text(assembly_path: Path, raw_value: object, *, field_name: str) -> str:
if not isinstance(raw_value, str) or not raw_value.strip():
raise AssemblySpecError(
f"{_display_path(assembly_path)} {field_name} must be a non-empty string"
)
return raw_value.strip()
def _normalize_bool(assembly_path: Path, raw_value: object, *, field_name: str) -> bool:
if isinstance(raw_value, bool):
return raw_value
raise AssemblySpecError(f"{_display_path(assembly_path)} {field_name} must be true or false")
def _normalize_transform(
assembly_path: Path,
raw_value: object,
*,
field_name: str,
) -> tuple[float, ...]:
if not isinstance(raw_value, (list, tuple)) or len(raw_value) != 16:
raise AssemblySpecError(
f"{_display_path(assembly_path)} {field_name} must be a 16-number array"
)
values: list[float] = []
for index, raw_number in enumerate(raw_value, start=1):
if isinstance(raw_number, bool) or not isinstance(raw_number, (int, float)):
raise AssemblySpecError(
f"{_display_path(assembly_path)} {field_name}[{index}] must be a number"
)
value = float(raw_number)
if not math.isfinite(value):
raise AssemblySpecError(
f"{_display_path(assembly_path)} {field_name}[{index}] must be finite"
)
values.append(value)
return tuple(values)
def _resolve_instance_step_path(
assembly_path: Path,
raw_path: str,
*,
field_name: str,
) -> tuple[Path, str]:
if "\\" in raw_path:
raise AssemblySpecError(f"{_display_path(assembly_path)} {field_name} must use POSIX '/' separators")
pure = PurePosixPath(raw_path)
if pure.is_absolute() or any(part in {"", "."} for part in pure.parts):
raise AssemblySpecError(
f"{_display_path(assembly_path)} {field_name} must be a relative STEP path"
)
if pure.suffix.lower() not in STEP_SUFFIXES:
raise AssemblySpecError(f"{_display_path(assembly_path)} {field_name} must end in .step or .stp")
resolved = (assembly_path.parent / Path(*pure.parts)).resolve()
if resolved.is_file():
return resolved, pure.as_posix()
raise AssemblySpecError(
f"{_display_path(assembly_path)} {field_name} does not resolve to a STEP file: {raw_path!r}"
)
@@ -0,0 +1,141 @@
from __future__ import annotations
import re
from dataclasses import dataclass
CAD_TOKEN_RE = re.compile(r"^\s*#([^\s]*)")
OCCURRENCE_SELECTOR_RE = re.compile(r"^o((?:\d+)(?:\.\d+)*)$")
OCCURRENCE_ENTITY_SELECTOR_RE = re.compile(r"^o((?:\d+)(?:\.\d+)*)\.([sfev])(\d+)$")
ENTITY_SELECTOR_RE = re.compile(r"^([sfev])(\d+)$")
@dataclass(frozen=True)
class ParsedSelector:
selector_type: str
occurrence_id: str
ordinal: int | None
canonical: str
@dataclass(frozen=True)
class ParsedToken:
line: int
token: str
cad_path: str
selectors: tuple[str, ...]
def _selector_type_for_kind(kind: str) -> str:
if kind == "s":
return "shape"
if kind == "f":
return "face"
if kind == "e":
return "edge"
return "vertex"
def parse_cad_tokens(text: str) -> list[ParsedToken]:
tokens: list[ParsedToken] = []
for line_no, line in enumerate(text.splitlines() or [text], start=1):
match = CAD_TOKEN_RE.match(line)
if match is None:
continue
selector_text = str(match.group(1) or "")
tokens.append(
ParsedToken(
line=line_no,
token=match.group(0),
cad_path="",
selectors=tuple(normalize_selector_list(selector_text)),
)
)
return tokens
def normalize_cad_path(raw_cad_path: str) -> str | None:
normalized = str(raw_cad_path or "").replace("\\", "/").strip().strip("/")
if not normalized:
return None
for suffix in (".step", ".stp"):
if normalized.lower().endswith(suffix):
normalized = normalized[: -len(suffix)]
break
parts = normalized.split("/")
if any(not part or part in {".", ".."} for part in parts):
return None
return "/".join(parts)
def parse_selector(raw_selector: str, *, inherited_occurrence_id: str = "") -> ParsedSelector | None:
selector = str(raw_selector or "").strip().replace("#", "", 1)
if not selector:
return None
occurrence_entity_match = OCCURRENCE_ENTITY_SELECTOR_RE.match(selector)
if occurrence_entity_match:
occurrence_id = f"o{occurrence_entity_match.group(1)}"
kind = str(occurrence_entity_match.group(2))
ordinal = int(occurrence_entity_match.group(3))
return ParsedSelector(
selector_type=_selector_type_for_kind(kind),
occurrence_id=occurrence_id,
ordinal=ordinal,
canonical=f"{occurrence_id}.{kind}{ordinal}",
)
occurrence_match = OCCURRENCE_SELECTOR_RE.match(selector)
if occurrence_match:
occurrence_id = f"o{occurrence_match.group(1)}"
return ParsedSelector(
selector_type="occurrence",
occurrence_id=occurrence_id,
ordinal=None,
canonical=occurrence_id,
)
entity_match = ENTITY_SELECTOR_RE.match(selector)
if entity_match:
kind = str(entity_match.group(1))
ordinal = int(entity_match.group(2))
if inherited_occurrence_id:
return ParsedSelector(
selector_type=_selector_type_for_kind(kind),
occurrence_id=inherited_occurrence_id,
ordinal=ordinal,
canonical=f"{inherited_occurrence_id}.{kind}{ordinal}",
)
return ParsedSelector(
selector_type=_selector_type_for_kind(kind),
occurrence_id="",
ordinal=ordinal,
canonical=f"{kind}{ordinal}",
)
return ParsedSelector(
selector_type="opaque",
occurrence_id="",
ordinal=None,
canonical=selector,
)
def normalize_selector_list(raw_selector_list: str) -> list[str]:
normalized: list[str] = []
inherited_occurrence_id = ""
for raw_selector in str(raw_selector_list or "").split(","):
parsed = parse_selector(raw_selector, inherited_occurrence_id=inherited_occurrence_id)
if parsed is None:
continue
normalized.append(parsed.canonical)
if parsed.occurrence_id:
inherited_occurrence_id = parsed.occurrence_id
return normalized
def build_cad_token(cad_path: str, selector: str = "") -> str:
_ = cad_path
if not selector:
return "#"
return f"#{selector}"
@@ -0,0 +1,570 @@
from __future__ import annotations
import os
from collections.abc import Sequence
from dataclasses import dataclass
from fnmatch import fnmatch
from pathlib import Path, PurePosixPath
from .metadata import GeneratorMetadata, normalize_mesh_numeric, parse_generator_metadata
REPO_ROOT = Path.cwd().resolve()
CAD_ROOT = REPO_ROOT
STEP_SUFFIXES = (".step", ".stp")
EXPLORER_ARTIFACT_FILENAMES = {
".glb": "model.glb",
".topology.json": "topology.json",
".topology.bin": "topology.bin",
}
IGNORED_DISCOVERY_DIR_NAMES = {
"__pycache__",
".cache",
".eggs",
".env",
".git",
".hg",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".svn",
".tox",
".venv",
"build",
"dist",
"env",
"node_modules",
"site-packages",
"venv",
}
GENERATOR_NAME_MARKERS = (b"gen_step",)
class CadSourceError(ValueError):
pass
@dataclass(frozen=True)
class StepImportOptions:
stl: str | None = None
three_mf: str | None = None
glb: str | None = None
mesh_tolerance: float | None = None
mesh_angular_tolerance: float | None = None
@property
def has_metadata(self) -> bool:
return any(
(
self.stl is not None,
self.three_mf is not None,
self.glb is not None,
self.mesh_tolerance is not None,
self.mesh_angular_tolerance is not None,
)
)
@dataclass(frozen=True)
class CadSource:
source_ref: str
cad_ref: str
kind: str
source_path: Path
source: str
origin_path: Path
script_path: Path | None = None
generator_metadata: GeneratorMetadata | None = None
step_path: Path | None = None
stl_path: Path | None = None
three_mf_path: Path | None = None
native_glb_path: Path | None = None
dxf_path: Path | None = None
urdf_path: Path | None = None
sdf_path: Path | None = None
mesh_tolerance: float | None = None
mesh_angular_tolerance: float | None = None
color: tuple[float, float, float, float] | None = None
@property
def glb_path(self) -> Path | None:
return explorer_artifact_path_for_step_path(self.step_path, ".glb") if self.step_path is not None else None
@property
def generated_paths(self) -> tuple[Path, ...]:
paths: list[Path] = []
if self.source == "generated":
if self.step_path is not None:
paths.append(self.step_path)
if self.dxf_path is not None:
paths.append(self.dxf_path)
if self.urdf_path is not None:
paths.append(self.urdf_path)
if self.sdf_path is not None:
paths.append(self.sdf_path)
if self.stl_path is not None:
paths.append(self.stl_path)
if self.three_mf_path is not None:
paths.append(self.three_mf_path)
if self.native_glb_path is not None:
paths.append(self.native_glb_path)
if self.glb_path is not None:
paths.append(self.glb_path)
return tuple(path.resolve() for path in paths)
def iter_cad_sources(root: Path | None = None) -> tuple[CadSource, ...]:
root = CAD_ROOT if root is None else root
resolved_root = root.resolve()
python_sources = _iter_python_sources(resolved_root)
generated_step_paths = {
source.step_path.resolve()
for source in python_sources
if source.step_path is not None
}
sources = [
*python_sources,
*_iter_step_sources(resolved_root, excluded_step_paths=generated_step_paths),
]
by_cad_ref: dict[str, CadSource] = {}
by_source_ref: dict[str, CadSource] = {}
by_step_path: dict[Path, CadSource] = {}
by_generated_path: dict[Path, CadSource] = {}
for source in sources:
existing = by_cad_ref.get(source.cad_ref)
if existing is not None:
raise CadSourceError(
"Duplicate CAD STEP ref "
f"{source.cad_ref!r}: {_source_label(existing)} and {_source_label(source)}"
)
by_cad_ref[source.cad_ref] = source
existing_source = by_source_ref.get(source.source_ref)
if existing_source is not None:
raise CadSourceError(
"Duplicate CAD source ref "
f"{source.source_ref!r}: {_source_label(existing_source)} and {_source_label(source)}"
)
by_source_ref[source.source_ref] = source
if source.step_path is not None:
existing_step = by_step_path.get(source.step_path.resolve())
if existing_step is not None:
raise CadSourceError(
"Duplicate CAD STEP source "
f"{_relative_to_repo(source.step_path)}: {_source_label(existing_step)} and {_source_label(source)}"
)
by_step_path[source.step_path.resolve()] = source
for generated_path in source.generated_paths:
resolved_generated_path = generated_path.resolve()
existing_generated = by_generated_path.get(resolved_generated_path)
if existing_generated is not None and existing_generated.source_ref != source.source_ref:
raise CadSourceError(
"Duplicate CAD generated output "
f"{_relative_to_repo(generated_path)}: "
f"{_source_label(existing_generated)} and {_source_label(source)}"
)
by_generated_path[resolved_generated_path] = source
return tuple(sorted(by_cad_ref.values(), key=lambda source: source.source_ref))
def source_from_path(
path: Path,
*,
step_kind: str = "part",
step_options: StepImportOptions | None = None,
) -> CadSource | None:
resolved = path.resolve()
if resolved.suffix.lower() == ".py":
return _read_python_source(resolved, allow_dxf_only=True)
if resolved.suffix.lower() in STEP_SUFFIXES:
return _read_step_source(resolved, kind=step_kind, options=step_options)
return None
def source_by_cad_ref(root: Path | None = None) -> dict[str, CadSource]:
return {source.cad_ref: source for source in iter_cad_sources(root)}
def find_source_by_cad_ref(cad_ref: str, root: Path | None = None) -> CadSource | None:
normalized = normalize_cad_ref(cad_ref)
return source_by_cad_ref(root).get(normalized or "")
def find_source_by_source_ref(source_ref: str, root: Path | None = None) -> CadSource | None:
normalized = normalize_source_ref(source_ref)
if not normalized:
return None
for source in iter_cad_sources(root):
if source.source_ref == normalized:
return source
return None
def find_source_by_path(path: Path, root: Path | None = None) -> CadSource | None:
resolved_path = path.resolve()
for source in iter_cad_sources(root):
paths = [
source.source_path,
source.step_path,
source.script_path,
source.dxf_path,
source.urdf_path,
source.sdf_path,
*source.generated_paths,
]
if any(candidate is not None and candidate.resolve() == resolved_path for candidate in paths):
return source
return None
def source_ref_from_path(path: Path) -> str:
resolved = path.resolve()
try:
relative = resolved.relative_to(CAD_ROOT.resolve())
except ValueError:
return resolved.as_posix()
return relative.as_posix()
def cad_ref_from_step_path(path: Path) -> str:
resolved = path.resolve()
try:
relative = resolved.relative_to(CAD_ROOT.resolve())
except ValueError:
relative = PurePosixPath(resolved.as_posix())
name = relative.name
suffix = relative.suffix.lower()
if suffix in STEP_SUFFIXES:
return relative.with_suffix("").as_posix()
raise CadSourceError(f"{_relative_to_repo(path)} is not a CAD STEP source")
def normalize_source_ref(raw_ref: str) -> str | None:
normalized = str(raw_ref or "").replace("\\", "/").strip().strip("/")
if not normalized:
return None
parts = normalized.split("/")
if any(not part or part in {".", ".."} for part in parts):
return None
return "/".join(parts)
def normalize_cad_ref(raw_ref: str) -> str | None:
normalized = normalize_source_ref(raw_ref)
if not normalized:
return None
suffix = PurePosixPath(normalized).suffix.lower()
if suffix in {".py", *STEP_SUFFIXES}:
normalized = str(PurePosixPath(normalized).with_suffix(""))
return normalized
def artifact_path_for_step_path(step_path: Path, suffix: str) -> Path:
return step_path.resolve().with_suffix(suffix)
def hidden_artifact_path_for_step_path(step_path: Path, suffix: str) -> Path:
base = step_path.resolve()
return base.with_name(f".{base.stem}{suffix}").resolve()
def explorer_directory_for_step_path(step_path: Path) -> Path:
base = step_path.resolve()
return (base.parent / f".{base.name}").resolve()
def hidden_glb_path_for_step_path(step_path: Path) -> Path:
base = step_path.resolve()
return (base.parent / f".{base.name}.glb").resolve()
def legacy_explorer_artifact_path_for_step_path(step_path: Path, suffix: str) -> Path:
base = step_path.resolve()
artifact_name = EXPLORER_ARTIFACT_FILENAMES.get(suffix)
if artifact_name is None:
raise ValueError(f"Unsupported STEP explorer artifact suffix: {suffix}")
return (explorer_directory_for_step_path(base) / artifact_name).resolve()
def explorer_artifact_path_for_step_path(step_path: Path, suffix: str) -> Path:
if suffix == ".glb":
return hidden_glb_path_for_step_path(step_path)
return legacy_explorer_artifact_path_for_step_path(step_path, suffix)
def _iter_python_sources(root: Path) -> tuple[CadSource, ...]:
sources: list[CadSource] = []
for script_path in _iter_paths(root, "*.py"):
if not _looks_like_generator_script(script_path):
continue
source = _read_python_source(script_path)
if source is not None:
sources.append(source)
return tuple(sources)
def _read_python_source(script_path: Path, *, allow_dxf_only: bool = False) -> CadSource | None:
resolved_script_path = script_path.resolve()
metadata = parse_generator_metadata(resolved_script_path)
if metadata is None:
return None
if not metadata.has_gen_step:
# Standalone DXF drafting source: valid as an explicit gen_dxf target,
# but it has no STEP entry, so directory catalogs skip it.
if not allow_dxf_only:
return None
return CadSource(
source_ref=source_ref_from_path(resolved_script_path),
cad_ref="",
kind="dxf",
source_path=resolved_script_path,
source="generated",
origin_path=resolved_script_path,
script_path=resolved_script_path,
generator_metadata=metadata,
step_path=None,
stl_path=None,
three_mf_path=None,
native_glb_path=None,
dxf_path=resolved_script_path.with_suffix(".dxf"),
urdf_path=None,
sdf_path=None,
mesh_tolerance=None,
mesh_angular_tolerance=None,
)
if metadata.kind not in {"part", "assembly"}:
raise CadSourceError(
f"{_relative_to_repo(resolved_script_path)} must define a part or assembly gen_step() entry"
)
step_path = resolved_script_path.with_suffix(".step")
dxf_path = resolved_script_path.with_suffix(".dxf") if metadata.has_gen_dxf else None
urdf_path = resolved_script_path.with_suffix(".urdf") if metadata.has_gen_urdf else None
sdf_path = resolved_script_path.with_suffix(".sdf") if metadata.has_gen_sdf else None
return CadSource(
source_ref=source_ref_from_path(resolved_script_path),
cad_ref=cad_ref_from_step_path(step_path),
kind=metadata.kind,
source_path=resolved_script_path,
source="generated",
origin_path=resolved_script_path,
script_path=resolved_script_path,
generator_metadata=metadata,
step_path=step_path,
stl_path=None,
three_mf_path=None,
native_glb_path=None,
dxf_path=dxf_path,
urdf_path=urdf_path,
sdf_path=sdf_path,
mesh_tolerance=None,
mesh_angular_tolerance=None,
)
def _iter_step_sources(root: Path, *, excluded_step_paths: set[Path]) -> tuple[CadSource, ...]:
sources: list[CadSource] = []
for pattern in ("*.step", "*.stp"):
for step_path in _iter_paths(root, pattern):
if step_path.resolve() in excluded_step_paths:
continue
sources.append(_read_step_source(step_path, kind="part"))
return tuple(sorted(sources, key=lambda source: source.source_ref))
def _read_step_source(
step_path: Path,
*,
kind: str,
options: StepImportOptions | None = None,
) -> CadSource:
resolved_step_path = step_path.resolve()
options = options or StepImportOptions()
if kind not in {"part", "assembly"}:
raise CadSourceError(f"{_relative_to_repo(resolved_step_path)} kind must be 'part' or 'assembly'")
if resolved_step_path.suffix.lower() not in STEP_SUFFIXES:
raise CadSourceError(f"{_relative_to_repo(resolved_step_path)} source must end in .step or .stp")
if not resolved_step_path.is_file():
raise CadSourceError(
f"{_relative_to_repo(resolved_step_path)} source does not exist"
)
stl_path = (
_resolve_configured_artifact_path(
options.stl,
base_path=resolved_step_path,
default_path=None,
expected_suffixes=(".stl",),
field_name="stl",
)
if options.stl is not None
else None
)
three_mf_path = (
_resolve_configured_artifact_path(
options.three_mf,
base_path=resolved_step_path,
default_path=None,
expected_suffixes=(".3mf",),
field_name="3mf",
)
if options.three_mf is not None
else None
)
native_glb_path = (
_resolve_configured_artifact_path(
options.glb,
base_path=resolved_step_path,
default_path=None,
expected_suffixes=(".glb",),
field_name="glb",
)
if options.glb is not None
else None
)
cad_ref = cad_ref_from_step_path(resolved_step_path)
return CadSource(
source_ref=source_ref_from_path(resolved_step_path),
cad_ref=cad_ref,
kind=str(kind),
source_path=resolved_step_path,
source="imported",
origin_path=resolved_step_path,
step_path=resolved_step_path,
stl_path=stl_path,
three_mf_path=three_mf_path,
native_glb_path=native_glb_path,
mesh_tolerance=normalize_step_numeric(
options.mesh_tolerance,
base_path=resolved_step_path,
field_name="mesh_tolerance",
),
mesh_angular_tolerance=normalize_step_numeric(
options.mesh_angular_tolerance,
base_path=resolved_step_path,
field_name="mesh_angular_tolerance",
),
)
def _iter_paths(root: Path, pattern: str) -> tuple[Path, ...]:
paths: list[Path] = []
for current_root, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(
dirname
for dirname in dirnames
if dirname not in IGNORED_DISCOVERY_DIR_NAMES
)
for filename in sorted(filenames):
if not fnmatch(filename, pattern):
continue
path = (Path(current_root) / filename).resolve()
if path.is_file():
paths.append(path)
return tuple(paths)
def _looks_like_generator_script(script_path: Path) -> bool:
try:
source_bytes = script_path.read_bytes()
except OSError:
return False
return any(marker in source_bytes for marker in GENERATOR_NAME_MARKERS)
def normalize_step_numeric(raw_value: object, *, base_path: Path, field_name: str) -> float | None:
try:
return normalize_mesh_numeric(raw_value, field_name=field_name)
except ValueError as exc:
raise CadSourceError(f"{_relative_to_repo(base_path)} {exc}") from exc
def normalize_step_color(
raw_value: object,
*,
base_path: Path,
field_name: str,
) -> tuple[float, float, float, float] | None:
if raw_value is None:
return None
if isinstance(raw_value, str):
value = raw_value.strip()
if value.startswith("#"):
value = value[1:]
if len(value) not in {6, 8}:
raise CadSourceError(f"{_relative_to_repo(base_path)} {field_name} must be #RRGGBB or #RRGGBBAA")
try:
components = [int(value[index : index + 2], 16) / 255.0 for index in range(0, len(value), 2)]
except ValueError as exc:
raise CadSourceError(f"{_relative_to_repo(base_path)} {field_name} must be valid hex") from exc
elif (
isinstance(raw_value, Sequence)
and not isinstance(raw_value, (bytes, bytearray))
and len(raw_value) in {3, 4}
):
components = []
for component in raw_value:
try:
number = float(component)
except (TypeError, ValueError) as exc:
raise CadSourceError(
f"{_relative_to_repo(base_path)} {field_name} components must be numeric"
) from exc
if not 0.0 <= number <= 1.0:
raise CadSourceError(
f"{_relative_to_repo(base_path)} {field_name} components must be between 0 and 1"
)
components.append(number)
else:
raise CadSourceError(f"{_relative_to_repo(base_path)} {field_name} must be an RGB/RGBA array or hex string")
if len(components) == 3:
components.append(1.0)
return (float(components[0]), float(components[1]), float(components[2]), float(components[3]))
def _resolve_configured_artifact_path(
raw_value: object,
*,
base_path: Path,
default_path: Path | None,
expected_suffixes: tuple[str, ...],
field_name: str,
) -> Path:
if raw_value is None:
if default_path is None:
raise CadSourceError(f"{_relative_to_repo(base_path)} {field_name} is required")
resolved = default_path.resolve()
else:
if not isinstance(raw_value, str) or not raw_value.strip():
raise CadSourceError(f"{_relative_to_repo(base_path)} {field_name} must be a non-empty string")
value = raw_value.strip()
if "\\" in value:
raise CadSourceError(f"{_relative_to_repo(base_path)} {field_name} must use POSIX '/' separators")
pure = PurePosixPath(value)
if pure.is_absolute() or any(part in {"", "."} for part in pure.parts):
raise CadSourceError(f"{_relative_to_repo(base_path)} {field_name} must be relative")
resolved = (base_path.parent.resolve() / Path(*pure.parts)).resolve()
suffix = resolved.suffix.lower()
if suffix not in expected_suffixes:
joined = " or ".join(expected_suffixes)
raise CadSourceError(f"{_relative_to_repo(base_path)} {field_name} must end in {joined}")
return resolved
def _required_output(raw_value: str | None, *, script_path: Path, field_name: str) -> str:
if raw_value is None:
raise CadSourceError(f"{_relative_to_repo(script_path)} {field_name} is required")
return raw_value
def _source_label(source: CadSource) -> str:
if source.script_path is not None:
return _relative_to_repo(source.script_path)
return _relative_to_repo(source.source_path)
def _relative_to_repo(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(REPO_ROOT).as_posix()
except ValueError:
return resolved.as_posix()
@@ -0,0 +1,58 @@
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass, field
import sys
import time
from typing import Iterator, TextIO
def format_elapsed(seconds: float) -> str:
milliseconds = seconds * 1000.0
if milliseconds < 1000.0:
return f"{milliseconds:.0f}ms"
if seconds < 60.0:
return f"{seconds:.2f}s"
minutes, remainder = divmod(seconds, 60.0)
return f"{int(minutes)}m {remainder:.1f}s"
@dataclass
class CliLogger:
name: str
verbose: bool = False
stream: TextIO | None = None
_started_at: float = field(default_factory=time.perf_counter)
def __post_init__(self) -> None:
if self.stream is None:
self.stream = sys.stderr
def info(self, message: str) -> None:
print(self._line(message), file=self.stream)
def warning(self, message: str) -> None:
print(self._line(f"warning: {message}"), file=self.stream)
def debug(self, message: str) -> None:
if self.verbose:
self.info(message)
def timing(self, label: str, elapsed: float) -> None:
if self.verbose:
self.info(f"{label} completed in {format_elapsed(elapsed)}")
@contextmanager
def timed(self, label: str) -> Iterator[None]:
started_at = time.perf_counter()
self.debug(f"{label} started")
try:
yield
finally:
self.timing(label, time.perf_counter() - started_at)
def total(self, label: str = "total") -> None:
self.timing(label, time.perf_counter() - self._started_at)
def _line(self, message: str) -> str:
return f"[{self.name}] {message}"
@@ -0,0 +1,61 @@
from __future__ import annotations
import re
from pathlib import Path
TEXT_TO_CAD_METADATA_PREFIX = "cadpy:"
TEXT_TO_CAD_SOURCE_PATH_KEY = "sourcePath"
TEXT_TO_CAD_SOURCE_HASH_KEY = "sourceHash"
def text_to_cad_identity_metadata(
*,
source_path: str,
source_hash: str,
) -> dict[str, str]:
return {
TEXT_TO_CAD_SOURCE_PATH_KEY: source_path,
TEXT_TO_CAD_SOURCE_HASH_KEY: source_hash,
}
def write_dxf_text_to_cad_metadata(dxf_path: Path, metadata: dict[str, str]) -> None:
path = dxf_path.expanduser().resolve()
text = path.read_text(encoding="utf-8")
lines = text.splitlines(keepends=True)
start = 0
while start + 1 < len(lines) and lines[start].strip() == "999":
value = lines[start + 1].strip()
if not value.startswith(TEXT_TO_CAD_METADATA_PREFIX):
break
start += 2
body = "".join(lines[start:])
prefix = "".join(
f"999\n{TEXT_TO_CAD_METADATA_PREFIX}{key}={value}\n"
for key, value in metadata.items()
if str(value or "").strip()
)
path.write_text(prefix + body, encoding="utf-8")
def read_dxf_text_to_cad_metadata(dxf_path: Path) -> dict[str, str]:
metadata: dict[str, str] = {}
try:
lines = dxf_path.expanduser().resolve().read_text(encoding="utf-8").splitlines()
except OSError:
return metadata
index = 0
while index + 1 < len(lines):
if lines[index].strip() != "999":
index += 1
continue
value = lines[index + 1].strip()
index += 2
if not value.startswith(TEXT_TO_CAD_METADATA_PREFIX):
continue
key_value = value[len(TEXT_TO_CAD_METADATA_PREFIX):]
key, separator, raw_value = key_value.partition("=")
if separator and re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", key):
metadata[key] = raw_value.strip()
return metadata
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,179 @@
from __future__ import annotations
import contextlib
import json
import os
import threading
import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterator, Sequence
GENERATION_STATUS_SCHEMA_VERSION = 1
GENERATION_LOCK_SUFFIX = ".generation.lock.json"
_HEARTBEAT_INTERVAL_SEC = 1.0
_ACTIVE_RUN = threading.local()
@dataclass(frozen=True)
class GenerationOutput:
path: Path
kind: str
def generation_lock_path(output_path: Path | str, run_id: str) -> Path:
resolved_output = Path(output_path).expanduser().resolve()
return resolved_output.parent / f".{resolved_output.name}.{_safe_lock_token(run_id)}{GENERATION_LOCK_SUFFIX}"
def track_generation_run(
*,
source_path: Path | None,
generator: str,
outputs: Sequence[GenerationOutput | tuple[Path, str] | Path | str],
repo_root: Path | None = None,
) -> contextlib.AbstractContextManager[None]:
tracker = _GenerationStatusTracker(
repo_root=repo_root or Path.cwd(),
source_path=source_path,
generator=generator,
outputs=outputs,
)
return tracker.run()
class _GenerationStatusTracker:
def __init__(
self,
*,
repo_root: Path,
source_path: Path | None,
generator: str,
outputs: Sequence[GenerationOutput | tuple[Path, str] | Path | str],
) -> None:
self.repo_root = repo_root.expanduser().resolve()
self.source_path = source_path.expanduser().resolve() if source_path is not None else None
self.generator = str(generator or "").strip()
self.outputs = tuple(_normalize_output(output) for output in outputs)
self.run_id = f"{os.getpid()}-{uuid.uuid4().hex}"
self.status_paths = tuple(
dict.fromkeys(
generation_lock_path(output.path, self.run_id)
for output in self.outputs
if output.path is not None
)
)
self.status_path = self.status_paths[0] if self.status_paths else None
self._started_at = _now_iso()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
@contextlib.contextmanager
def run(self) -> Iterator[None]:
active_depth = int(getattr(_ACTIVE_RUN, "depth", 0) or 0)
if active_depth > 0:
yield
return
_ACTIVE_RUN.depth = active_depth + 1
self._start()
try:
yield
finally:
try:
self._finish()
finally:
_ACTIVE_RUN.depth = active_depth
def _start(self) -> None:
if not self.status_paths:
return
self._write_status()
self._thread = threading.Thread(target=self._heartbeat, daemon=True)
self._thread.start()
def _finish(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=0.25)
for status_path in self.status_paths:
try:
status_path.unlink()
except FileNotFoundError:
pass
except OSError:
self._write_status(status="finished", status_paths=(status_path,))
def _heartbeat(self) -> None:
while not self._stop.wait(_HEARTBEAT_INTERVAL_SEC):
self._write_status()
def _write_status(
self,
*,
status: str = "running",
status_paths: Sequence[Path] | None = None,
) -> None:
for status_path in tuple(status_paths or self.status_paths):
try:
status_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = status_path.with_name(f"{status_path.name}.{os.getpid()}.tmp")
payload = self._status_payload(status=status, status_path=status_path)
tmp_path.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
tmp_path.replace(status_path)
except OSError:
pass
def _status_payload(self, *, status: str, status_path: Path) -> dict[str, object]:
base_dir = status_path.parent
return {
"schemaVersion": GENERATION_STATUS_SCHEMA_VERSION,
"id": self.run_id,
"status": status,
"pid": os.getpid(),
"startedAt": self._started_at,
"updatedAt": _now_iso(),
"sourcePath": _display_path(self.source_path, base_dir),
"generator": self.generator,
"outputs": [
{
"path": _display_path(output.path, base_dir),
"kind": output.kind,
}
for output in self.outputs
if output.path is not None
],
}
def _normalize_output(output: GenerationOutput | tuple[Path, str] | Path | str) -> GenerationOutput:
if isinstance(output, GenerationOutput):
return output
if isinstance(output, tuple):
path, kind = output
return GenerationOutput(path=Path(path), kind=str(kind or "").strip())
path = Path(output)
kind = path.suffix.lower().replace(".", "") or "output"
return GenerationOutput(path=path, kind=kind)
def _safe_lock_token(value: object) -> str:
token = "".join(
character if character.isalnum() or character in {"-", "_"} else "_"
for character in str(value or "").strip()
).strip("._")
return token or uuid.uuid4().hex
def _display_path(path: Path | None, repo_root: Path) -> str:
if path is None:
return ""
resolved = path.expanduser().resolve()
return os.path.relpath(resolved, start=repo_root.expanduser().resolve()).replace(os.sep, "/")
def _now_iso() -> str:
return datetime.fromtimestamp(time.time(), tz=timezone.utc).isoformat().replace("+00:00", "Z")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,431 @@
from __future__ import annotations
import math
from array import array
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any
from OCP.BRep import BRep_Tool
from OCP.TopAbs import TopAbs_FACE, TopAbs_REVERSED
from OCP.TopExp import TopExp_Explorer
from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import TopoDS
ColorRGBA = tuple[float, float, float, float]
CAD_TO_GLB_SCALE = 0.001
DEFAULT_MATERIAL: ColorRGBA = (0.72, 0.72, 0.72, 1.0)
@dataclass
class ShapeGlbMeshPayload:
positions: array
normals: array
barycentrics: array
edge_classes: array
primitives: list[tuple[ColorRGBA, array]]
minimum: list[float]
maximum: list[float]
face_runs_by_hash: dict[int, tuple[int, int, int]]
surface_half_edges_by_face_ordinal: dict[int, list[tuple[int, int, int, int, int]]]
def normalize_rgba(color: ColorRGBA | tuple[float, ...] | None) -> ColorRGBA:
if color is None:
return DEFAULT_MATERIAL
values = tuple(max(0.0, min(1.0, float(component))) for component in color)
if len(values) == 3:
return (values[0], values[1], values[2], 1.0)
if len(values) >= 4:
return (values[0], values[1], values[2], values[3])
return DEFAULT_MATERIAL
def color_key(color: ColorRGBA) -> tuple[int, int, int, int]:
return tuple(int(round(max(0.0, min(1.0, component)) * 255.0)) for component in color)
def occurrence_color_for_id(
occurrence_id: str,
occurrence_colors: Mapping[str, ColorRGBA],
) -> ColorRGBA | None:
current = occurrence_id
while current:
color = occurrence_colors.get(current)
if color is not None:
return normalize_rgba(color)
if "." not in current:
break
current = current.rsplit(".", 1)[0]
return None
def _empty_payload() -> ShapeGlbMeshPayload:
return ShapeGlbMeshPayload(
positions=array("f"),
normals=array("f"),
barycentrics=array("f"),
edge_classes=array("B"),
primitives=[],
minimum=[0.0, 0.0, 0.0],
maximum=[0.0, 0.0, 0.0],
face_runs_by_hash={},
surface_half_edges_by_face_ordinal={},
)
def _triangle_side_key(left: int, right: int) -> tuple[int, int]:
a = max(0, int(left))
b = max(0, int(right))
return (a, b) if a < b else (b, a)
def _triangle_area_twice(
a: Sequence[float],
b: Sequence[float],
c: Sequence[float],
) -> float:
abx = b[0] - a[0]
aby = b[1] - a[1]
abz = b[2] - a[2]
acx = c[0] - a[0]
acy = c[1] - a[1]
acz = c[2] - a[2]
cross_x = aby * acz - abz * acy
cross_y = abz * acx - abx * acz
cross_z = abx * acy - aby * acx
return math.sqrt(cross_x * cross_x + cross_y * cross_y + cross_z * cross_z)
def _transform_point_from_occ(point: object, location: TopLoc_Location) -> list[float]:
transformed = point.Transformed(location.Transformation())
return [float(transformed.X()), float(transformed.Y()), float(transformed.Z())]
def transform_normal_from_occ(
normal: object,
location: TopLoc_Location,
*,
reversed_face: bool,
) -> tuple[float, float, float]:
transformed = normal.Transformed(location.Transformation())
x = float(transformed.X())
y = float(transformed.Y())
z = float(transformed.Z())
if reversed_face:
x, y, z = -x, -y, -z
length = math.sqrt((x * x) + (y * y) + (z * z))
if length <= 1e-15 or not math.isfinite(length):
return (0.0, 0.0, 1.0)
return (x / length, y / length, z / length)
def _append_face_payload(
*,
positions: array,
normals: array,
barycentrics: array,
edge_classes: array,
primitive_indices_by_key: dict[tuple[int, int, int, int], tuple[int, ColorRGBA, array]],
face_runs_by_hash: dict[int, tuple[int, int, int]],
surface_half_edges_by_face_ordinal: dict[int, list[tuple[int, int, int, int, int]]],
min_values: list[float],
max_values: list[float],
face_hash: int,
face_ordinal: int,
default_color: ColorRGBA,
face_colors: Mapping[int, ColorRGBA],
edge_side_ordinals: Mapping[tuple[int, int], int],
edge_surface_class_codes: Mapping[int, int],
include_surface_edges: bool,
nodes: Sequence[Sequence[float]],
node_normals: Sequence[Sequence[float]],
triangles: Sequence[Sequence[int]],
) -> None:
if not nodes or not triangles:
return
normalized_color = normalize_rgba(face_colors.get(face_hash, default_color))
key = color_key(normalized_color)
bucket = primitive_indices_by_key.get(key)
if bucket is None:
bucket = (len(primitive_indices_by_key), normalized_color, array("I"))
primitive_indices_by_key[key] = bucket
primitive_index, _primitive_color, primitive_indices = bucket
triangle_start = len(primitive_indices) // 3
fallback_normal = (0.0, 0.0, 1.0)
if len(node_normals) != len(nodes):
node_normals = [fallback_normal] * len(nodes)
if not include_surface_edges:
vertex_offset = len(positions) // 3
for node_index in range(len(nodes)):
point = nodes[node_index]
x = float(point[0]) * CAD_TO_GLB_SCALE
y = float(point[1]) * CAD_TO_GLB_SCALE
z = float(point[2]) * CAD_TO_GLB_SCALE
positions.extend((x, y, z))
min_values[0] = min(min_values[0], x)
min_values[1] = min(min_values[1], y)
min_values[2] = min(min_values[2], z)
max_values[0] = max(max_values[0], x)
max_values[1] = max(max_values[1], y)
max_values[2] = max(max_values[2], z)
normal = node_normals[node_index]
normals.extend((float(normal[0]), float(normal[1]), float(normal[2])))
for triangle in triangles:
primitive_indices.extend(vertex_offset + int(node_index) for node_index in triangle)
face_runs_by_hash[face_hash] = (primitive_index, triangle_start, len(triangles))
return
barycentric_triplet = ((1, 0, 0), (0, 1, 0), (0, 0, 1))
for triangle in triangles:
triangle_index = len(primitive_indices) // 3
side_pairs = (
(int(triangle[1]), int(triangle[2])),
(int(triangle[2]), int(triangle[0])),
(int(triangle[0]), int(triangle[1])),
)
side_classes_list = [0, 0, 0]
for side, (left, right) in enumerate(side_pairs):
edge_ordinal = edge_side_ordinals.get((left, right) if left < right else (right, left))
if edge_ordinal is None:
continue
class_code = int(edge_surface_class_codes.get(int(edge_ordinal), 0) or 0)
side_classes_list[side] = max(0, class_code)
if class_code:
surface_half_edges_by_face_ordinal.setdefault(face_ordinal, []).append(
(
int(edge_ordinal),
int(primitive_index),
int(triangle_index),
int(side),
int(class_code),
)
)
side_classes = (side_classes_list[0], side_classes_list[1], side_classes_list[2])
for local_vertex, node_index in enumerate(triangle):
source_node_index = int(node_index)
point = nodes[source_node_index]
x = float(point[0]) * CAD_TO_GLB_SCALE
y = float(point[1]) * CAD_TO_GLB_SCALE
z = float(point[2]) * CAD_TO_GLB_SCALE
positions.extend((x, y, z))
min_values[0] = min(min_values[0], x)
min_values[1] = min(min_values[1], y)
min_values[2] = min(min_values[2], z)
max_values[0] = max(max_values[0], x)
max_values[1] = max(max_values[1], y)
max_values[2] = max(max_values[2], z)
normal = node_normals[source_node_index]
normals.extend((float(normal[0]), float(normal[1]), float(normal[2])))
barycentrics.extend(barycentric_triplet[local_vertex])
edge_classes.extend(side_classes)
primitive_indices.append((len(positions) // 3) - 1)
face_runs_by_hash[face_hash] = (primitive_index, triangle_start, len(triangles))
def prototype_glb_mesh_payload(
prototype: Mapping[str, Any],
*,
default_color: ColorRGBA,
face_colors: Mapping[int, ColorRGBA],
include_surface_edges: bool = False,
) -> ShapeGlbMeshPayload:
positions = array("f")
normals = array("f")
barycentrics = array("f")
edge_classes = array("B")
primitive_indices_by_key: dict[tuple[int, int, int, int], tuple[int, ColorRGBA, array]] = {}
face_runs_by_hash: dict[int, tuple[int, int, int]] = {}
surface_half_edges_by_face_ordinal: dict[int, list[tuple[int, int, int, int, int]]] = {}
min_values = [math.inf, math.inf, math.inf]
max_values = [-math.inf, -math.inf, -math.inf]
edge_surface_class_codes = {
int(edge_entry.get("ordinal") or 0): int(edge_entry.get("surfaceClassCode") or 0)
for edge_entry in prototype.get("edges", [])
if isinstance(edge_entry, Mapping)
}
for face_entry in prototype.get("faces", []):
if not isinstance(face_entry, Mapping):
continue
face_hash = int(face_entry.get("shapeHash") or 0)
face_ordinal = int(face_entry.get("ordinal") or 0)
triangles = face_entry.get("triangles", ())
nodes = face_entry.get("triangleNodes", ())
node_normals = face_entry.get("triangleNormals", ())
if not node_normals:
normal = face_entry.get("normal") or (0.0, 0.0, 1.0)
node_normals = [normal] * len(nodes)
_append_face_payload(
positions=positions,
normals=normals,
barycentrics=barycentrics,
edge_classes=edge_classes,
primitive_indices_by_key=primitive_indices_by_key,
face_runs_by_hash=face_runs_by_hash,
surface_half_edges_by_face_ordinal=surface_half_edges_by_face_ordinal,
min_values=min_values,
max_values=max_values,
face_hash=face_hash,
face_ordinal=face_ordinal,
default_color=default_color,
face_colors=face_colors,
edge_side_ordinals=face_entry.get("edgeSideOrdinals") or {},
edge_surface_class_codes=edge_surface_class_codes,
include_surface_edges=include_surface_edges,
nodes=nodes,
node_normals=node_normals,
triangles=triangles,
)
if not positions or not all(math.isfinite(value) for value in min_values + max_values):
return _empty_payload()
primitives = [
(primitive_color, primitive_indices)
for _index, primitive_color, primitive_indices in sorted(
primitive_indices_by_key.values(),
key=lambda item: item[0],
)
]
return ShapeGlbMeshPayload(
positions=positions,
normals=normals,
barycentrics=barycentrics,
edge_classes=edge_classes,
primitives=primitives,
minimum=min_values,
maximum=max_values,
face_runs_by_hash=face_runs_by_hash,
surface_half_edges_by_face_ordinal=surface_half_edges_by_face_ordinal,
)
def shape_glb_mesh_payload(
shape: object,
*,
default_color: ColorRGBA,
face_colors: Mapping[int, ColorRGBA],
include_surface_edges: bool = False,
) -> ShapeGlbMeshPayload:
face_entries: list[dict[str, Any]] = []
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
face = TopoDS.Face_s(explorer.Current())
location = TopLoc_Location()
triangulation = BRep_Tool.Triangulation_s(face, location)
if triangulation is None:
explorer.Next()
continue
if not triangulation.HasNormals():
triangulation.ComputeNormals()
reversed_face = face.Orientation() == TopAbs_REVERSED
nodes = [
_transform_point_from_occ(triangulation.Node(index), location)
for index in range(1, triangulation.NbNodes() + 1)
]
node_normals = [
transform_normal_from_occ(triangulation.Normal(index), location, reversed_face=reversed_face)
for index in range(1, triangulation.NbNodes() + 1)
]
triangles: list[tuple[int, int, int]] = []
for index in range(1, triangulation.NbTriangles() + 1):
node_a, node_b, node_c = triangulation.Triangle(index).Get()
triangle = [node_a - 1, node_b - 1, node_c - 1]
if reversed_face:
triangle[1], triangle[2] = triangle[2], triangle[1]
vertices = [nodes[node_index] for node_index in triangle]
if _triangle_area_twice(vertices[0], vertices[1], vertices[2]) <= 1e-9:
continue
triangles.append((triangle[0], triangle[1], triangle[2]))
if triangles:
face_entries.append(
{
"shapeHash": hash(face),
"triangleNodes": nodes,
"triangleNormals": node_normals,
"triangles": triangles,
}
)
explorer.Next()
return prototype_glb_mesh_payload(
{"faces": face_entries},
default_color=default_color,
face_colors=face_colors,
include_surface_edges=include_surface_edges,
)
def scene_glb_mesh_payload_key(
scene: object,
prototype_key: int,
*,
default_color: ColorRGBA,
suppress_face_colors: bool,
include_surface_edges: bool = False,
surface_edge_class_signature: Sequence[str] | tuple[str, ...] = (),
) -> tuple[object, ...]:
return (
int(prototype_key),
color_key(normalize_rgba(default_color)),
bool(suppress_face_colors),
bool(include_surface_edges),
tuple(str(item) for item in surface_edge_class_signature),
getattr(scene, "mesh_signature", None),
)
def scene_glb_mesh_payload(
scene: object,
prototype_key: int,
*,
default_color: ColorRGBA,
suppress_face_colors: bool,
prototype: Mapping[str, Any] | None = None,
include_surface_edges: bool = False,
surface_edge_class_signature: Sequence[str] | tuple[str, ...] = (),
) -> ShapeGlbMeshPayload:
key = scene_glb_mesh_payload_key(
scene,
prototype_key,
default_color=default_color,
suppress_face_colors=suppress_face_colors,
include_surface_edges=include_surface_edges,
surface_edge_class_signature=surface_edge_class_signature,
)
cache = getattr(scene, "glb_mesh_payloads", None)
if cache is None:
cache = {}
setattr(scene, "glb_mesh_payloads", cache)
cached = cache.get(key)
if cached is not None:
return cached
face_colors = (
{}
if suppress_face_colors
else getattr(scene, "prototype_face_colors", {}).get(prototype_key, {})
)
if prototype is not None:
payload = prototype_glb_mesh_payload(
prototype,
default_color=normalize_rgba(default_color),
face_colors=face_colors,
include_surface_edges=include_surface_edges,
)
else:
shape = getattr(scene, "prototype_shapes", {}).get(prototype_key)
payload = (
_empty_payload()
if shape is None
else shape_glb_mesh_payload(
shape,
default_color=normalize_rgba(default_color),
face_colors=face_colors,
include_surface_edges=include_surface_edges,
)
)
cache[key] = payload
return payload
@@ -0,0 +1,499 @@
from __future__ import annotations
import json
import os
import struct
from array import array
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from cadpy.selector_types import SelectorBundle
STEP_TOPOLOGY_EXTENSION = "STEP_topology"
STEP_TOPOLOGY_SCHEMA_VERSION = 2
STEP_TOPOLOGY_EDGE_CLASSIFICATION_ALGORITHM = "oc-brep-continuity-v1"
STEP_TOPOLOGY_SURFACE_EDGE_ALGORITHM = "oc-polygon-on-triangulation-v1"
STEP_TOPOLOGY_EDGE_ANGULAR_TOLERANCE_DEG = 2
STEP_TOPOLOGY_EDGE_SAMPLE_COUNT = 3
STEP_EDGE_BARYCENTRIC_ATTRIBUTE = "_CAD_EDGE_BARYCENTRIC"
STEP_EDGE_CLASS_ATTRIBUTE = "_CAD_EDGE_CLASS"
STEP_EDGE_FLAGS = {
"DEGENERATE": 1 << 1,
"SEAM": 1 << 2,
"NOT_REFERENCEABLE": 1 << 3,
"BOUNDARY": 1 << 4,
"NON_MANIFOLD": 1 << 5,
"HARD": 1 << 6,
"TANGENT": 1 << 7,
"UNKNOWN_CONTINUITY": 1 << 8,
}
STEP_EDGE_VISIBILITY_CLASSES = {
"FEATURE": "feature",
"TANGENT": "tangent",
"SEAM": "seam",
"DEGENERATE": "degenerate",
"BOUNDARY": "boundary",
"NON_MANIFOLD": "nonManifold",
"UNKNOWN": "unknown",
}
STEP_EDGE_RENDER_VISIBILITY_CLASSES = (
STEP_EDGE_VISIBILITY_CLASSES["FEATURE"],
STEP_EDGE_VISIBILITY_CLASSES["TANGENT"],
STEP_EDGE_VISIBILITY_CLASSES["SEAM"],
STEP_EDGE_VISIBILITY_CLASSES["DEGENERATE"],
)
STEP_EDGE_DEFAULT_RENDER_VISIBILITY_CLASSES = STEP_EDGE_RENDER_VISIBILITY_CLASSES
STEP_EDGE_SURFACE_CLASS_CODES = {
"none": 0,
"feature": 1,
"tangent": 2,
"seam": 3,
"degenerate": 4,
"boundary": 5,
"nonManifold": 6,
"unknown": 7,
}
STEP_SURFACE_HALF_EDGE_COLUMNS = (
"edgeRow",
"faceRow",
"occurrenceRow",
"primitiveIndex",
"triangleIndex",
"side",
"classCode",
)
GLB_MAGIC = 0x46546C67
GLB_VERSION = 2
REPO_ROOT = Path.cwd().resolve()
UNSIGNED_BYTE = 5121
def _display_path(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(REPO_ROOT).as_posix()
except ValueError:
return resolved.as_posix()
def _parse_glb_header(path: Path, payload: bytes) -> tuple[int, int]:
if len(payload) < 20:
raise ValueError(f"Not a GLB file: {_display_path(path)}")
magic, version, length = struct.unpack_from("<III", payload, 0)
if magic != GLB_MAGIC or version != GLB_VERSION or length > len(payload):
raise ValueError(f"Not a GLB v2 file: {_display_path(path)}")
return version, length
def _read_glb_chunks(path: Path) -> tuple[dict[str, Any], bytes]:
payload = path.expanduser().resolve().read_bytes()
_, length = _parse_glb_header(path, payload)
offset = 12
json_payload: bytes | None = None
binary_payload = b""
while offset + 8 <= length:
chunk_length, chunk_type = struct.unpack_from("<I4s", payload, offset)
offset += 8
chunk = payload[offset : offset + chunk_length]
offset += chunk_length
if chunk_type == b"JSON":
json_payload = chunk
elif chunk_type == b"BIN\0":
binary_payload = chunk
if json_payload is None:
raise ValueError(f"GLB is missing JSON chunk: {_display_path(path)}")
gltf = json.loads(json_payload.decode("utf-8").rstrip(" \t\r\n\0"))
if not isinstance(gltf, dict):
raise ValueError(f"GLB JSON chunk is not an object: {_display_path(path)}")
return gltf, binary_payload
def _read_glb_json_and_bin_location(path: Path) -> tuple[dict[str, Any], int, int]:
resolved = path.expanduser().resolve()
with resolved.open("rb") as handle:
header = handle.read(12)
if len(header) != 12:
raise ValueError(f"Not a GLB file: {_display_path(path)}")
magic, version, length = struct.unpack("<III", header)
if magic != GLB_MAGIC or version != GLB_VERSION:
raise ValueError(f"Not a GLB v2 file: {_display_path(path)}")
file_size = resolved.stat().st_size
if length > file_size:
raise ValueError(f"Not a GLB v2 file: {_display_path(path)}")
offset = 12
json_payload: bytes | None = None
binary_offset = 0
binary_length = 0
while offset + 8 <= length:
handle.seek(offset)
chunk_header = handle.read(8)
if len(chunk_header) != 8:
break
chunk_length, chunk_type = struct.unpack("<I4s", chunk_header)
offset += 8
if offset + chunk_length > length:
raise ValueError(f"Invalid GLB chunk length: {_display_path(path)}")
if chunk_type == b"JSON":
json_payload = handle.read(chunk_length)
elif chunk_type == b"BIN\0":
binary_offset = offset
binary_length = chunk_length
handle.seek(chunk_length, os.SEEK_CUR)
else:
handle.seek(chunk_length, os.SEEK_CUR)
offset += chunk_length
if json_payload is None:
raise ValueError(f"GLB is missing JSON chunk: {_display_path(path)}")
gltf = json.loads(json_payload.decode("utf-8").rstrip(" \t\r\n\0"))
if not isinstance(gltf, dict):
raise ValueError(f"GLB JSON chunk is not an object: {_display_path(path)}")
return gltf, binary_offset, binary_length
def _buffer_view_range(gltf: Mapping[str, Any], binary_offset: int, binary_length: int, view_index: object) -> tuple[int, int]:
if not isinstance(view_index, int):
raise ValueError("GLB bufferView index must be an integer")
buffer_views = gltf.get("bufferViews")
if not isinstance(buffer_views, list) or not (0 <= view_index < len(buffer_views)):
raise ValueError(f"GLB bufferView index is out of range: {view_index}")
view = buffer_views[view_index]
if not isinstance(view, Mapping):
raise ValueError(f"GLB bufferView is not an object: {view_index}")
if int(view.get("buffer") or 0) != 0:
raise ValueError("STEP topology only supports GLB buffer 0")
byte_offset = int(view.get("byteOffset") or 0)
byte_length = int(view.get("byteLength") or 0)
if byte_offset < 0 or byte_length < 0 or byte_offset + byte_length > binary_length:
raise ValueError(f"GLB bufferView range is invalid: {view_index}")
return binary_offset + byte_offset, byte_length
def _read_file_range(path: Path, byte_offset: int, byte_length: int) -> bytes:
with path.expanduser().resolve().open("rb") as handle:
handle.seek(byte_offset)
payload = handle.read(byte_length)
if len(payload) != byte_length:
raise ValueError("GLB bufferView range is invalid")
return payload
def _buffer_view_bytes(gltf: Mapping[str, Any], binary_payload: bytes, view_index: object) -> bytes:
byte_offset, byte_length = _buffer_view_range(gltf, 0, len(binary_payload), view_index)
return binary_payload[byte_offset : byte_offset + byte_length]
def _array_from_view(gltf: Mapping[str, Any], binary_payload: bytes, view: Mapping[str, Any]) -> array:
dtype = str(view.get("dtype") or "")
typecode = "f" if dtype == "float32" else "I" if dtype == "uint32" else ""
if not typecode:
raise ValueError(f"Unsupported STEP topology buffer dtype: {dtype}")
raw = _buffer_view_bytes(gltf, binary_payload, view.get("bufferView"))
byte_offset = int(view.get("byteOffset") or 0)
count = int(view.get("count") or 0)
item_size = int(view.get("itemSize") or array(typecode).itemsize)
byte_length = int(view.get("byteLength") or (count * item_size))
if byte_offset < 0 or byte_length < 0 or byte_offset + byte_length > len(raw):
raise ValueError("STEP topology buffer view range is invalid")
values = array(typecode)
values.frombytes(raw[byte_offset : byte_offset + byte_length])
if count >= 0 and len(values) > count:
del values[count:]
return values
def _array_from_legacy_binary_view(binary_payload: bytes, view: Mapping[str, Any]) -> array:
dtype = str(view.get("dtype") or "")
typecode = "f" if dtype == "float32" else "I" if dtype == "uint32" else ""
if not typecode:
raise ValueError(f"Unsupported STEP topology buffer dtype: {dtype}")
byte_offset = int(view.get("byteOffset") or 0)
count = int(view.get("count") or 0)
item_size = int(view.get("itemSize") or array(typecode).itemsize)
byte_length = int(view.get("byteLength") or (count * item_size))
if byte_offset < 0 or byte_length < 0 or byte_offset + byte_length > len(binary_payload):
raise ValueError("STEP topology buffer view range is invalid")
values = array(typecode)
values.frombytes(binary_payload[byte_offset : byte_offset + byte_length])
if count >= 0 and len(values) > count:
del values[count:]
return values
def _step_topology_extension(gltf: Mapping[str, Any]) -> Mapping[str, Any] | None:
extensions = gltf.get("extensions")
extension = extensions.get(STEP_TOPOLOGY_EXTENSION) if isinstance(extensions, Mapping) else None
if not isinstance(extension, Mapping):
return None
if str(extension.get("encoding") or "utf-8").lower() != "utf-8":
return None
return extension
def normalize_step_edge_render_visibility_classes(value: object) -> tuple[str, ...]:
if value is None:
return STEP_EDGE_DEFAULT_RENDER_VISIBILITY_CLASSES
raw_values = value if isinstance(value, (list, tuple, set, frozenset)) else [value]
valid_values = set(STEP_EDGE_VISIBILITY_CLASSES.values())
normalized: list[str] = []
for raw in raw_values:
normalized_value = str(raw or "").strip()
if normalized_value in valid_values and normalized_value not in normalized:
normalized.append(normalized_value)
if STEP_EDGE_VISIBILITY_CLASSES["FEATURE"] not in normalized:
normalized.insert(0, STEP_EDGE_VISIBILITY_CLASSES["FEATURE"])
ordered = [
class_id
for class_id in STEP_EDGE_RENDER_VISIBILITY_CLASSES
if class_id in normalized
]
extras = [
class_id
for class_id in normalized
if class_id not in STEP_EDGE_RENDER_VISIBILITY_CLASSES
]
return tuple(ordered + extras)
def step_topology_capabilities(
edge_visibility_classes: object = None,
) -> dict[str, Any]:
visibility_classes = normalize_step_edge_render_visibility_classes(edge_visibility_classes)
return {
"edgeClassification": {
"algorithm": STEP_TOPOLOGY_EDGE_CLASSIFICATION_ALGORITHM,
"angularToleranceDeg": STEP_TOPOLOGY_EDGE_ANGULAR_TOLERANCE_DEG,
"samples": STEP_TOPOLOGY_EDGE_SAMPLE_COUNT,
},
"surfaceEdgeRendering": {
"algorithm": STEP_TOPOLOGY_SURFACE_EDGE_ALGORITHM,
"primitiveAttributes": {
"barycentric": STEP_EDGE_BARYCENTRIC_ATTRIBUTE,
"class": STEP_EDGE_CLASS_ATTRIBUTE,
},
"classCodes": STEP_EDGE_SURFACE_CLASS_CODES,
"visibilityClasses": list(visibility_classes),
},
}
def step_edge_surface_class_code(
edge: Mapping[str, Any],
*,
enabled_visibility_classes: object = None,
) -> int:
flags = int(edge.get("flags") or 0)
visibility_class = str(edge.get("visibilityClass") or "").strip()
if enabled_visibility_classes is not None:
enabled = set(normalize_step_edge_render_visibility_classes(enabled_visibility_classes))
if visibility_class not in enabled:
return STEP_EDGE_SURFACE_CLASS_CODES["none"]
if flags & STEP_EDGE_FLAGS["DEGENERATE"] or visibility_class == STEP_EDGE_VISIBILITY_CLASSES["DEGENERATE"]:
return STEP_EDGE_SURFACE_CLASS_CODES["degenerate"]
if flags & STEP_EDGE_FLAGS["SEAM"] or visibility_class == STEP_EDGE_VISIBILITY_CLASSES["SEAM"]:
return STEP_EDGE_SURFACE_CLASS_CODES["seam"]
if flags & STEP_EDGE_FLAGS["BOUNDARY"] or visibility_class == STEP_EDGE_VISIBILITY_CLASSES["BOUNDARY"]:
return STEP_EDGE_SURFACE_CLASS_CODES["boundary"]
if flags & STEP_EDGE_FLAGS["NON_MANIFOLD"] or visibility_class == STEP_EDGE_VISIBILITY_CLASSES["NON_MANIFOLD"]:
return STEP_EDGE_SURFACE_CLASS_CODES["nonManifold"]
if flags & STEP_EDGE_FLAGS["UNKNOWN_CONTINUITY"] or visibility_class == STEP_EDGE_VISIBILITY_CLASSES["UNKNOWN"]:
return STEP_EDGE_SURFACE_CLASS_CODES["unknown"]
if flags & STEP_EDGE_FLAGS["TANGENT"] or visibility_class == STEP_EDGE_VISIBILITY_CLASSES["TANGENT"]:
return STEP_EDGE_SURFACE_CLASS_CODES["tangent"]
if flags & STEP_EDGE_FLAGS["HARD"] or visibility_class == STEP_EDGE_VISIBILITY_CLASSES["FEATURE"]:
return STEP_EDGE_SURFACE_CLASS_CODES["feature"]
return STEP_EDGE_SURFACE_CLASS_CODES["feature"]
def is_displayable_step_edge_surface_class_code(value: object) -> bool:
try:
code = int(value)
except (TypeError, ValueError):
return False
return code not in {STEP_EDGE_SURFACE_CLASS_CODES["none"], STEP_EDGE_SURFACE_CLASS_CODES["degenerate"]}
def _legacy_topology_manifest_path_for_glb(glb_path: Path) -> Path | None:
resolved = glb_path.expanduser().resolve()
if resolved.name != "model.glb":
return None
return resolved.parent / "topology.json"
def _read_legacy_topology_manifest(glb_path: Path) -> dict[str, Any] | None:
manifest_path = _legacy_topology_manifest_path_for_glb(glb_path)
if manifest_path is None or not manifest_path.is_file():
return None
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return manifest if isinstance(manifest, dict) else None
def _read_legacy_topology_bundle(glb_path: Path) -> SelectorBundle | None:
manifest = _read_legacy_topology_manifest(glb_path)
if manifest is None:
return None
buffers: dict[str, array] = {}
buffer_spec = manifest.get("buffers")
views = buffer_spec.get("views") if isinstance(buffer_spec, Mapping) else None
uri = str(buffer_spec.get("uri") or "") if isinstance(buffer_spec, Mapping) else ""
if isinstance(views, Mapping) and uri:
manifest_path = _legacy_topology_manifest_path_for_glb(glb_path)
bin_path = manifest_path.parent / uri if manifest_path is not None else Path(uri)
try:
binary_payload = bin_path.read_bytes()
except OSError:
binary_payload = b""
if binary_payload:
for name, view in views.items():
if not isinstance(view, Mapping):
continue
try:
buffers[str(name)] = _array_from_legacy_binary_view(binary_payload, view)
except ValueError:
continue
return SelectorBundle(manifest=manifest, buffers=buffers)
def read_step_topology_bundle_from_glb(glb_path: Path) -> SelectorBundle | None:
try:
gltf, binary_payload = _read_glb_chunks(glb_path)
except (OSError, ValueError, json.JSONDecodeError):
return _read_legacy_topology_bundle(glb_path)
extension = _step_topology_extension(gltf)
if extension is None:
return _read_legacy_topology_bundle(glb_path)
try:
manifest_bytes = _buffer_view_bytes(gltf, binary_payload, extension.get("selectorView"))
manifest = json.loads(manifest_bytes.decode("utf-8"))
except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
return _read_legacy_topology_bundle(glb_path)
if not isinstance(manifest, dict):
return None
buffers: dict[str, array] = {}
views = manifest.get("buffers", {}).get("views") if isinstance(manifest.get("buffers"), Mapping) else None
if isinstance(views, Mapping):
for name, view in views.items():
if not isinstance(view, Mapping):
continue
try:
buffers[str(name)] = _array_from_view(gltf, binary_payload, view)
except ValueError:
continue
return SelectorBundle(manifest=manifest, buffers=buffers)
def read_step_topology_index_from_glb(glb_path: Path) -> dict[str, Any] | None:
try:
gltf, binary_offset, binary_length = _read_glb_json_and_bin_location(glb_path)
except (OSError, ValueError, json.JSONDecodeError):
return _read_legacy_topology_manifest(glb_path)
extension = _step_topology_extension(gltf)
if extension is None:
return _read_legacy_topology_manifest(glb_path)
try:
manifest_offset, manifest_length = _buffer_view_range(
gltf,
binary_offset,
binary_length,
extension.get("indexView"),
)
manifest = json.loads(_read_file_range(glb_path, manifest_offset, manifest_length).decode("utf-8"))
except (ValueError, UnicodeDecodeError, json.JSONDecodeError, OSError):
return None
return manifest if isinstance(manifest, dict) else None
def read_step_display_edge_manifest_from_glb(glb_path: Path) -> dict[str, Any] | None:
try:
gltf, binary_offset, binary_length = _read_glb_json_and_bin_location(glb_path)
except (OSError, ValueError, json.JSONDecodeError):
return None
extension = _step_topology_extension(gltf)
if extension is None:
return None
try:
manifest_offset, manifest_length = _buffer_view_range(
gltf,
binary_offset,
binary_length,
extension.get("edgeView", extension.get("displayEdgeView")),
)
manifest = json.loads(_read_file_range(glb_path, manifest_offset, manifest_length).decode("utf-8"))
except (ValueError, UnicodeDecodeError, json.JSONDecodeError, OSError):
return None
return manifest if isinstance(manifest, dict) else None
def read_step_topology_manifest_from_glb(glb_path: Path) -> dict[str, Any] | None:
return read_step_topology_index_from_glb(glb_path)
def glb_primitives_have_surface_edge_attributes(glb_path: Path) -> bool:
try:
gltf, _binary_offset, _binary_length = _read_glb_json_and_bin_location(glb_path)
except (OSError, ValueError, json.JSONDecodeError):
return False
primitive_count = 0
meshes = gltf.get("meshes")
if not isinstance(meshes, list):
return False
for mesh in meshes:
primitives = mesh.get("primitives") if isinstance(mesh, Mapping) else None
if not isinstance(primitives, list):
continue
for primitive in primitives:
primitive_count += 1
attributes = primitive.get("attributes") if isinstance(primitive, Mapping) else None
if not isinstance(attributes, Mapping):
return False
if STEP_EDGE_BARYCENTRIC_ATTRIBUTE not in attributes or STEP_EDGE_CLASS_ATTRIBUTE not in attributes:
return False
return primitive_count > 0
def glb_surface_edge_class_has_nonzero_values(glb_path: Path) -> bool:
try:
gltf, binary_offset, binary_length = _read_glb_json_and_bin_location(glb_path)
except (OSError, ValueError, json.JSONDecodeError):
return False
meshes = gltf.get("meshes")
accessors = gltf.get("accessors")
if not isinstance(meshes, list) or not isinstance(accessors, list):
return False
for mesh in meshes:
primitives = mesh.get("primitives") if isinstance(mesh, Mapping) else None
if not isinstance(primitives, list):
continue
for primitive in primitives:
attributes = primitive.get("attributes") if isinstance(primitive, Mapping) else None
accessor_index = attributes.get(STEP_EDGE_CLASS_ATTRIBUTE) if isinstance(attributes, Mapping) else None
if not isinstance(accessor_index, int) or accessor_index < 0 or accessor_index >= len(accessors):
continue
accessor = accessors[accessor_index]
if not isinstance(accessor, Mapping):
continue
if int(accessor.get("componentType") or 0) != UNSIGNED_BYTE or accessor.get("type") != "VEC3":
continue
try:
view_offset, view_length = _buffer_view_range(
gltf,
binary_offset,
binary_length,
accessor.get("bufferView"),
)
except ValueError:
continue
byte_offset = int(accessor.get("byteOffset") or 0)
count = int(accessor.get("count") or 0) * 3
if byte_offset < 0 or count <= 0 or byte_offset + count > view_length:
continue
try:
if any(_read_file_range(glb_path, view_offset + byte_offset, count)):
return True
except (OSError, ValueError):
continue
return False
@@ -0,0 +1,243 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any
from cadpy import cad_ref_syntax as syntax
def _table_rows(manifest: dict[str, Any], table_name: str, columns_name: str) -> list[dict[str, Any]]:
columns = manifest.get("tables", {}).get(columns_name)
rows = manifest.get(table_name)
if not isinstance(columns, list) or not isinstance(rows, list):
return []
materialized: list[dict[str, Any]] = []
for row in rows:
if not isinstance(row, list):
continue
materialized.append({str(columns[index]): row[index] for index in range(min(len(columns), len(row)))})
return materialized
def _buffer_rows(buffers: Mapping[str, Any] | None, view_name: object) -> list[int]:
if not isinstance(view_name, str) or buffers is None:
return []
values = buffers.get(view_name)
if values is None:
return []
try:
return [int(value) for value in values]
except (TypeError, ValueError):
return []
def _relations(manifest: dict[str, Any], buffers: Mapping[str, Any] | None = None) -> dict[str, list[int]]:
relations = manifest.get("relations")
if not isinstance(relations, dict):
return {"faceEdgeRows": [], "edgeFaceRows": [], "edgeVertexRows": [], "vertexEdgeRows": []}
def rows(name: str) -> list[int]:
inline = relations.get(name)
if isinstance(inline, list):
try:
return [int(value) for value in inline]
except (TypeError, ValueError):
return []
return _buffer_rows(buffers, relations.get(f"{name}View"))
return {
"faceEdgeRows": rows("faceEdgeRows"),
"edgeFaceRows": rows("edgeFaceRows"),
"edgeVertexRows": rows("edgeVertexRows"),
"vertexEdgeRows": rows("vertexEdgeRows"),
}
@dataclass(frozen=True)
class SelectorIndex:
manifest: dict[str, Any]
occurrences: list[dict[str, Any]]
shapes: list[dict[str, Any]]
faces: list[dict[str, Any]]
edges: list[dict[str, Any]]
vertices: list[dict[str, Any]]
relations: dict[str, list[int]]
occurrence_by_id: dict[str, dict[str, Any]]
shape_by_id: dict[str, dict[str, Any]]
face_by_id: dict[str, dict[str, Any]]
edge_by_id: dict[str, dict[str, Any]]
vertex_by_id: dict[str, dict[str, Any]]
single_occurrence_id: str
leaf_occurrence_ids: tuple[str, ...]
def build_selector_index(manifest: dict[str, Any], *, buffers: Mapping[str, Any] | None = None) -> SelectorIndex:
occurrences = _table_rows(manifest, "occurrences", "occurrenceColumns")
shapes = _table_rows(manifest, "shapes", "shapeColumns")
faces = _table_rows(manifest, "faces", "faceColumns")
edges = _table_rows(manifest, "edges", "edgeColumns")
vertices = _table_rows(manifest, "vertices", "vertexColumns")
relations = _relations(manifest, buffers=buffers)
occurrence_by_id = {str(row.get("id")): row for row in occurrences if row.get("id")}
shape_by_id = {str(row.get("id")): row for row in shapes if row.get("id")}
face_by_id = {str(row.get("id")): row for row in faces if row.get("id")}
edge_by_id = {str(row.get("id")): row for row in edges if row.get("id")}
vertex_by_id = {str(row.get("id")): row for row in vertices if row.get("id")}
leaf_occurrence_ids = tuple(
sorted(
{
str(row.get("occurrenceId"))
for row in shapes
if isinstance(row.get("occurrenceId"), str) and row.get("occurrenceId")
}
)
)
single_occurrence_id = leaf_occurrence_ids[0] if len(leaf_occurrence_ids) == 1 else ""
return SelectorIndex(
manifest=manifest,
occurrences=occurrences,
shapes=shapes,
faces=faces,
edges=edges,
vertices=vertices,
relations=relations,
occurrence_by_id=occurrence_by_id,
shape_by_id=shape_by_id,
face_by_id=face_by_id,
edge_by_id=edge_by_id,
vertex_by_id=vertex_by_id,
single_occurrence_id=single_occurrence_id,
leaf_occurrence_ids=leaf_occurrence_ids,
)
def entry_summary(index: SelectorIndex) -> dict[str, Any]:
stats = index.manifest.get("stats") if isinstance(index.manifest.get("stats"), dict) else {}
bbox = index.manifest.get("bbox") if isinstance(index.manifest.get("bbox"), dict) else None
return {
"kind": "part",
"occurrenceCount": int(stats.get("occurrenceCount") or len(index.occurrences)),
"leafOccurrenceCount": int(stats.get("leafOccurrenceCount") or len(index.leaf_occurrence_ids)),
"shapeCount": int(stats.get("shapeCount") or len(index.shapes)),
"faceCount": int(stats.get("faceCount") or len(index.faces)),
"edgeCount": int(stats.get("edgeCount") or len(index.edges)),
"vertexCount": int(stats.get("vertexCount") or len(index.vertices)),
"bounds": bbox,
}
def canonicalize_selector(raw_selector: str, index: SelectorIndex) -> str | None:
parsed = syntax.parse_selector(raw_selector)
if parsed is None:
return None
if parsed.selector_type == "opaque":
return parsed.canonical
if parsed.occurrence_id:
return parsed.canonical
if not index.single_occurrence_id:
return None
if parsed.selector_type == "shape":
return f"{index.single_occurrence_id}.s{parsed.ordinal}"
if parsed.selector_type == "face":
return f"{index.single_occurrence_id}.f{parsed.ordinal}"
if parsed.selector_type == "edge":
return f"{index.single_occurrence_id}.e{parsed.ordinal}"
if parsed.selector_type == "vertex":
return f"{index.single_occurrence_id}.v{parsed.ordinal}"
return parsed.canonical
def display_selector(selector: str, index: SelectorIndex) -> str:
if not selector or not index.single_occurrence_id:
return selector
prefix = f"{index.single_occurrence_id}."
if selector.startswith(prefix):
tail = selector[len(prefix) :]
if tail.startswith(("s", "f", "e", "v")):
return tail
return selector
def lookup_selector(selector: str, index: SelectorIndex) -> tuple[str, dict[str, Any]] | None:
normalized = canonicalize_selector(selector, index)
if not normalized:
return None
if normalized in index.occurrence_by_id:
return "occurrence", index.occurrence_by_id[normalized]
if normalized in index.shape_by_id:
return "shape", index.shape_by_id[normalized]
if normalized in index.face_by_id:
return "face", index.face_by_id[normalized]
if normalized in index.edge_by_id:
return "edge", index.edge_by_id[normalized]
if normalized in index.vertex_by_id:
return "vertex", index.vertex_by_id[normalized]
return None
def face_adjacent_edge_selectors(face_row: dict[str, Any], index: SelectorIndex) -> list[str]:
start = int(face_row.get("edgeStart") or 0)
count = int(face_row.get("edgeCount") or 0)
selectors: list[str] = []
for edge_row_index in index.relations.get("faceEdgeRows", [])[start : start + count]:
if 0 <= int(edge_row_index) < len(index.edges):
selectors.append(str(index.edges[int(edge_row_index)].get("id")))
return selectors
def edge_adjacent_face_selectors(edge_row: dict[str, Any], index: SelectorIndex) -> list[str]:
start = int(edge_row.get("faceStart") or 0)
count = int(edge_row.get("faceCount") or 0)
selectors: list[str] = []
for face_row_index in index.relations.get("edgeFaceRows", [])[start : start + count]:
if 0 <= int(face_row_index) < len(index.faces):
selectors.append(str(index.faces[int(face_row_index)].get("id")))
return selectors
def edge_adjacent_vertex_selectors(edge_row: dict[str, Any], index: SelectorIndex) -> list[str]:
start = int(edge_row.get("vertexStart") or 0)
count = int(edge_row.get("vertexCount") or 0)
selectors: list[str] = []
for vertex_row_index in index.relations.get("edgeVertexRows", [])[start : start + count]:
if 0 <= int(vertex_row_index) < len(index.vertices):
selectors.append(str(index.vertices[int(vertex_row_index)].get("id")))
return selectors
def vertex_adjacent_edge_selectors(vertex_row: dict[str, Any], index: SelectorIndex) -> list[str]:
start = int(vertex_row.get("edgeStart") or 0)
count = int(vertex_row.get("edgeCount") or 0)
selectors: list[str] = []
for edge_row_index in index.relations.get("vertexEdgeRows", [])[start : start + count]:
if 0 <= int(edge_row_index) < len(index.edges):
selectors.append(str(index.edges[int(edge_row_index)].get("id")))
return selectors
def vertex_adjacent_face_selectors(vertex_row: dict[str, Any], index: SelectorIndex) -> list[str]:
selectors: list[str] = []
seen: set[str] = set()
for edge_selector in vertex_adjacent_edge_selectors(vertex_row, index):
edge_row = index.edge_by_id.get(edge_selector)
if edge_row is None:
continue
for face_selector in edge_adjacent_face_selectors(edge_row, index):
if face_selector not in seen:
seen.add(face_selector)
selectors.append(face_selector)
return selectors
def topology_payload(index: SelectorIndex) -> dict[str, list[str]]:
return {
"occurrences": [display_selector(str(row.get("id")), index) for row in index.occurrences if row.get("id")],
"shapes": [display_selector(str(row.get("id")), index) for row in index.shapes if row.get("id")],
"faces": [display_selector(str(row.get("id")), index) for row in index.faces if row.get("id")],
"edges": [display_selector(str(row.get("id")), index) for row in index.edges if row.get("id")],
"vertices": [display_selector(str(row.get("id")), index) for row in index.vertices if row.get("id")],
}
@@ -0,0 +1,513 @@
from __future__ import annotations
import ast
import math
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path.cwd().resolve()
CAD_ROOT = REPO_ROOT
DEFAULT_MESH_TOLERANCE = 0.02
DEFAULT_MESH_ANGULAR_TOLERANCE = 0.6
@dataclass(frozen=True)
class MeshSettings:
tolerance: float
angular_tolerance: float
@dataclass(frozen=True)
class GeneratorMetadata:
script_path: Path
kind: str
display_name: str | None
generator_names: tuple[str, ...]
has_gen_step: bool
has_gen_dxf: bool
has_gen_urdf: bool
has_gen_sdf: bool
stl: str | None
three_mf: str | None
mesh_tolerance: float | None
mesh_angular_tolerance: float | None
STEP_ENVELOPE_FIELDS = {
"shape",
"instances",
"children",
"assembly_mates",
"stl",
"3mf",
"mesh_tolerance",
"mesh_angular_tolerance",
}
DXF_ENVELOPE_FIELDS = {"document"}
URDF_ENVELOPE_FIELDS = {"xml"}
SDF_ENVELOPE_FIELDS = {"xml"}
DEFAULT_MESH_SETTINGS = MeshSettings(
tolerance=DEFAULT_MESH_TOLERANCE,
angular_tolerance=DEFAULT_MESH_ANGULAR_TOLERANCE,
)
def _display_path(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(REPO_ROOT).as_posix()
except ValueError:
return resolved.as_posix()
def normalize_mesh_numeric(value: object, *, field_name: str) -> float | None:
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{field_name} must be a number")
normalized = float(value)
if not math.isfinite(normalized):
raise ValueError(f"{field_name} must be finite")
if normalized <= 0.0:
raise ValueError(f"{field_name} must be greater than 0")
return normalized
def resolve_mesh_settings(
*,
cad_ref: str,
generator_metadata: GeneratorMetadata | None,
mesh_tolerance: float | None = None,
mesh_angular_tolerance: float | None = None,
) -> MeshSettings:
tolerance = DEFAULT_MESH_SETTINGS.tolerance
angular_tolerance = DEFAULT_MESH_SETTINGS.angular_tolerance
if mesh_tolerance is not None:
tolerance = mesh_tolerance
if mesh_angular_tolerance is not None:
angular_tolerance = mesh_angular_tolerance
return MeshSettings(
tolerance=tolerance,
angular_tolerance=angular_tolerance,
)
def parse_generator_metadata(script_path: Path) -> GeneratorMetadata | None:
try:
tree = ast.parse(script_path.read_text(), filename=str(script_path))
except (FileNotFoundError, SyntaxError, UnicodeDecodeError) as exc:
raise RuntimeError(f"Failed to parse {_display_path(script_path)}") from exc
display_name: str | None = None
kind: str | None = None
has_gen_step = False
has_gen_dxf = False
has_gen_urdf = False
has_gen_sdf = False
generator_names: list[str] = []
for node in tree.body:
target: ast.expr | None = None
value: ast.AST | None = None
if isinstance(node, ast.Assign) and len(node.targets) == 1:
target = node.targets[0]
value = node.value
elif isinstance(node, ast.AnnAssign):
target = node.target
value = node.value
if isinstance(target, ast.Name) and value is not None:
if target.id == "DISPLAY_NAME" and isinstance(value, ast.Constant) and isinstance(value.value, str):
display_name = value.value.strip()
if not isinstance(node, ast.FunctionDef) or node.name not in {"gen_step", "gen_dxf", "gen_urdf", "gen_sdf"}:
continue
generator_names.append(node.name)
if node.args.args or node.args.posonlyargs or node.args.kwonlyargs:
raise ValueError(
f"{_display_path(script_path)} {node.name}() must not require arguments"
)
if node.args.vararg or node.args.kwarg:
raise ValueError(
f"{_display_path(script_path)} {node.name}() must not accept variadic arguments"
)
if node.decorator_list:
raise ValueError(
f"{_display_path(script_path)} {node.name}() must not use CAD generator decorators; "
"return the generated content directly instead"
)
if node.name == "gen_step":
kind = _parse_step_return_metadata(
script_path=script_path,
function=node,
)
has_gen_step = True
elif node.name == "gen_dxf":
_parse_dxf_envelope_metadata(
script_path=script_path,
function=node,
)
has_gen_dxf = True
elif node.name == "gen_urdf":
_parse_urdf_envelope_metadata(
script_path=script_path,
function=node,
)
has_gen_urdf = True
else:
_parse_sdf_envelope_metadata(
script_path=script_path,
function=node,
)
has_gen_sdf = True
if not has_gen_step and not has_gen_dxf and not has_gen_urdf and not has_gen_sdf:
return None
if not has_gen_step and (has_gen_urdf or has_gen_sdf):
raise ValueError(
f"{_display_path(script_path)} gen_urdf() and gen_sdf() require gen_step()"
)
return GeneratorMetadata(
script_path=script_path.resolve(),
kind=kind,
display_name=display_name,
generator_names=tuple(generator_names),
has_gen_step=has_gen_step,
has_gen_dxf=has_gen_dxf,
has_gen_urdf=has_gen_urdf,
has_gen_sdf=has_gen_sdf,
stl=None,
three_mf=None,
mesh_tolerance=None,
mesh_angular_tolerance=None,
)
def _parse_step_return_metadata(
*,
script_path: Path,
function: ast.FunctionDef,
) -> str:
return_node = _single_return_value(script_path=script_path, function=function)
local_assignments = _function_local_assignments(function)
if not isinstance(return_node, ast.Dict):
return _parse_bare_step_return(
script_path=script_path,
function=function,
return_node=return_node,
local_assignments=local_assignments,
)
envelope = _parse_literal_return_envelope(script_path=script_path, function=function)
_reject_unsupported_fields(
script_path=script_path,
function_name=function.name,
envelope=envelope,
allowed_fields=STEP_ENVELOPE_FIELDS,
)
has_shape = "shape" in envelope
has_instances = "instances" in envelope
has_children = "children" in envelope
has_assembly = has_instances or has_children
if has_instances and has_children:
raise ValueError(
f"{_display_path(script_path)} gen_step() envelope must define only one of "
"'instances' or 'children'"
)
if has_shape == has_assembly:
raise ValueError(
f"{_display_path(script_path)} gen_step() envelope must define exactly one of "
"'shape', 'instances', or 'children'"
)
if has_shape:
return (
"assembly"
if _is_compound_assembly_expression(
envelope["shape"],
local_assignments=local_assignments,
)
else "part"
)
return "assembly"
def _parse_bare_step_return(
*,
script_path: Path,
function: ast.FunctionDef,
return_node: ast.expr,
local_assignments: dict[str, ast.expr] | None = None,
) -> str:
if isinstance(return_node, ast.List):
return "assembly"
if isinstance(return_node, ast.Name) and return_node.id in {"instances", "children"}:
return "assembly"
if _is_compound_assembly_expression(
return_node,
local_assignments=local_assignments or {},
):
return "assembly"
if isinstance(return_node, ast.Constant) and return_node.value is None:
raise ValueError(
f"{_display_path(script_path)} {function.name}() must return a shape, assembly list, "
"or legacy envelope dict"
)
return "part"
def _function_local_assignments(function: ast.FunctionDef) -> dict[str, ast.expr]:
assignments: dict[str, ast.expr] = {}
for statement in function.body:
if isinstance(statement, ast.Return):
break
target: ast.expr | None = None
value: ast.expr | None = None
if isinstance(statement, ast.Assign) and len(statement.targets) == 1:
target = statement.targets[0]
value = statement.value
elif isinstance(statement, ast.AnnAssign) and isinstance(statement.value, ast.expr):
target = statement.target
value = statement.value
if isinstance(target, ast.Name) and value is not None:
assignments[target.id] = value
return assignments
def _is_compound_assembly_expression(
expression: ast.expr,
*,
local_assignments: dict[str, ast.expr],
seen_names: set[str] | None = None,
) -> bool:
if isinstance(expression, ast.Name):
seen_names = set(seen_names or set())
if expression.id in seen_names:
return False
target = local_assignments.get(expression.id)
if target is None:
return False
seen_names.add(expression.id)
return _is_compound_assembly_expression(
target,
local_assignments=local_assignments,
seen_names=seen_names,
)
if not isinstance(expression, ast.Call) or _call_tail_name(expression.func) != "Compound":
return False
if expression.args and _is_multi_item_sequence_expression(
expression.args[0],
local_assignments=local_assignments,
):
return True
for keyword in expression.keywords:
if keyword.arg == "children" and _is_nonempty_expression(keyword.value):
return True
if keyword.arg == "obj" and _is_multi_item_sequence_expression(
keyword.value,
local_assignments=local_assignments,
):
return True
return False
def _call_tail_name(function: ast.expr) -> str | None:
if isinstance(function, ast.Name):
return function.id
if isinstance(function, ast.Attribute):
return function.attr
return None
def _is_nonempty_expression(expression: ast.expr) -> bool:
if isinstance(expression, ast.Constant) and expression.value is None:
return False
if isinstance(expression, (ast.List, ast.Tuple, ast.Set)):
return bool(expression.elts)
return True
def _is_multi_item_sequence_expression(
expression: ast.expr,
*,
local_assignments: dict[str, ast.expr],
seen_names: set[str] | None = None,
) -> bool:
if isinstance(expression, ast.Name):
seen_names = set(seen_names or set())
if expression.id in seen_names:
return False
target = local_assignments.get(expression.id)
if target is None:
return False
seen_names.add(expression.id)
return _is_multi_item_sequence_expression(
target,
local_assignments=local_assignments,
seen_names=seen_names,
)
if isinstance(expression, (ast.List, ast.Tuple, ast.Set)):
return len(expression.elts) > 1
return False
def _parse_dxf_envelope_metadata(
*,
script_path: Path,
function: ast.FunctionDef,
) -> str | None:
return_node = _single_return_value(script_path=script_path, function=function)
if not isinstance(return_node, ast.Dict):
return None
envelope = _parse_literal_return_envelope(script_path=script_path, function=function)
_reject_unsupported_fields(
script_path=script_path,
function_name=function.name,
envelope=envelope,
allowed_fields=DXF_ENVELOPE_FIELDS,
)
if "document" not in envelope:
raise ValueError(f"{_display_path(script_path)} gen_dxf() envelope must define 'document'")
return None
def _parse_urdf_envelope_metadata(
*,
script_path: Path,
function: ast.FunctionDef,
) -> str | None:
return_node = _single_return_value(script_path=script_path, function=function)
if not isinstance(return_node, ast.Dict):
return None
envelope = _parse_literal_return_envelope(script_path=script_path, function=function)
_reject_unsupported_fields(
script_path=script_path,
function_name=function.name,
envelope=envelope,
allowed_fields=URDF_ENVELOPE_FIELDS,
)
if "xml" not in envelope:
raise ValueError(f"{_display_path(script_path)} gen_urdf() envelope must define 'xml'")
return None
def _parse_sdf_envelope_metadata(
*,
script_path: Path,
function: ast.FunctionDef,
) -> str | None:
return_node = _single_return_value(script_path=script_path, function=function)
if not isinstance(return_node, ast.Dict):
return None
envelope = _parse_literal_return_envelope(script_path=script_path, function=function)
_reject_unsupported_fields(
script_path=script_path,
function_name=function.name,
envelope=envelope,
allowed_fields=SDF_ENVELOPE_FIELDS,
)
if "xml" not in envelope:
raise ValueError(f"{_display_path(script_path)} gen_sdf() envelope must define 'xml'")
return None
def _parse_literal_return_envelope(
*,
script_path: Path,
function: ast.FunctionDef,
) -> dict[str, ast.expr]:
value = _single_return_value(script_path=script_path, function=function)
if not isinstance(value, ast.Dict):
raise ValueError(
f"{_display_path(script_path)} {function.name}() must return a generator envelope dict"
)
envelope: dict[str, ast.expr] = {}
for key_node, value_node in zip(value.keys, value.values, strict=True):
if not isinstance(key_node, ast.Constant) or not isinstance(key_node.value, str):
raise ValueError(
f"{_display_path(script_path)} {function.name}() envelope keys must be string literals"
)
key = key_node.value
if key in envelope:
raise ValueError(
f"{_display_path(script_path)} {function.name}() envelope duplicate field: {key}"
)
envelope[key] = value_node
return envelope
def _single_return_value(
*,
script_path: Path,
function: ast.FunctionDef,
) -> ast.expr:
returns = [statement for statement in function.body if isinstance(statement, ast.Return)]
if len(returns) != 1 or returns[0].value is None:
raise ValueError(
f"{_display_path(script_path)} {function.name}() must return one value"
)
return returns[0].value
def _reject_unsupported_fields(
*,
script_path: Path,
function_name: str,
envelope: dict[str, ast.expr],
allowed_fields: set[str],
) -> None:
extra_fields = sorted(key for key in envelope if key not in allowed_fields)
if extra_fields:
joined = ", ".join(extra_fields)
raise ValueError(
f"{_display_path(script_path)} {function_name}() envelope has unsupported field(s): {joined}"
)
def _literal_field(
*,
script_path: Path,
function_name: str,
envelope: dict[str, ast.expr],
field_name: str,
) -> object | None:
if field_name not in envelope:
return None
try:
return ast.literal_eval(envelope[field_name])
except (ValueError, SyntaxError) as exc:
raise ValueError(
f"{_display_path(script_path)} {function_name}() envelope {field_name} must be a literal"
) from exc
def _parse_path_field(
*,
script_path: Path,
function_name: str,
envelope: dict[str, ast.expr],
field_name: str,
) -> str | None:
value = _literal_field(
script_path=script_path,
function_name=function_name,
envelope=envelope,
field_name=field_name,
)
if value is None:
return None
if not isinstance(value, str) or not value.strip():
raise ValueError(
f"{_display_path(script_path)} {function_name}() envelope {field_name} "
"must be a non-empty string"
)
if "\\" in value:
raise ValueError(
f"{_display_path(script_path)} {function_name}() envelope {field_name} "
"must use POSIX '/' separators"
)
return value.strip()
@@ -0,0 +1 @@
@@ -0,0 +1,97 @@
from __future__ import annotations
import hashlib
import os
from pathlib import Path
from cadpy.catalog import (
cad_ref_from_step_path as fallback_cad_ref_from_step_path,
find_source_by_cad_ref,
find_source_by_path,
explorer_artifact_path_for_step_path,
explorer_directory_for_step_path,
legacy_explorer_artifact_path_for_step_path,
)
REPO_ROOT = Path.cwd().resolve()
CAD_ROOT = REPO_ROOT
def _source_for_step_path(step_path: Path):
source = find_source_by_path(step_path)
if source is not None:
return source
cad_ref = fallback_cad_ref_from_step_path(step_path)
source = find_source_by_cad_ref(cad_ref)
if source is None:
raise ValueError(f"CAD STEP ref not found: {cad_ref}")
return source
def part_stl_path(step_path: Path) -> Path:
source = _source_for_step_path(step_path)
if source.stl_path is None:
raise ValueError(f"CAD STEP ref has no configured STL output: {source.cad_ref}")
return source.stl_path
def part_3mf_path(step_path: Path) -> Path:
source = _source_for_step_path(step_path)
if source.three_mf_path is None:
raise ValueError(f"CAD STEP ref has no configured 3MF output: {source.cad_ref}")
return source.three_mf_path
def part_native_glb_path(step_path: Path) -> Path:
source = _source_for_step_path(step_path)
if source.native_glb_path is None:
raise ValueError(f"CAD STEP ref has no configured native GLB output: {source.cad_ref}")
return source.native_glb_path
def native_component_glb_dir(step_path: Path) -> Path:
return explorer_directory_for_step_path(step_path) / "components"
def part_glb_path(step_path: Path) -> Path:
return explorer_artifact_path_for_step_path(step_path, ".glb")
def legacy_part_glb_path(step_path: Path) -> Path:
return legacy_explorer_artifact_path_for_step_path(step_path, ".glb")
def existing_part_glb_path(step_path: Path) -> Path | None:
preferred_path = part_glb_path(step_path)
if preferred_path.is_file():
return preferred_path
legacy_path = legacy_part_glb_path(step_path)
return legacy_path if legacy_path.is_file() else None
def relative_to_repo(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(REPO_ROOT).as_posix()
except ValueError:
return resolved.as_posix()
def relative_to_directory(path: Path, base_dir: Path) -> str:
return os.path.relpath(
path.expanduser().resolve(),
start=base_dir.expanduser().resolve(),
).replace(os.sep, "/")
def relative_to_file(path: Path, owner_path: Path) -> str:
return relative_to_directory(path, owner_path.expanduser().resolve().parent)
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
@@ -0,0 +1,133 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from cadpy import analysis, lookup
from cadpy.validators import geometry_summary_from_manifest
@dataclass(frozen=True)
class EntryReportOptions:
json: bool = False
facts: bool = False
positioning: bool = False
planes: bool = False
topology: bool = False
plane_coordinate_tolerance: float = 1e-3
plane_min_area_ratio: float = 0.05
plane_limit: int = 12
@property
def requested(self) -> bool:
return self.json or self.facts or self.positioning or self.planes or self.topology
@property
def refs_required(self) -> bool:
return self.facts or self.positioning or self.planes or self.topology
def entry_summary_payload(
manifest: dict[str, Any],
*,
kind: str,
selector_index: lookup.SelectorIndex | None = None,
) -> dict[str, object]:
if selector_index is not None:
summary = lookup.entry_summary(selector_index)
summary["kind"] = kind
return summary
summary = geometry_summary_from_manifest(manifest)
return {
"kind": kind,
"occurrenceCount": summary.get("occurrenceCount"),
"shapeCount": summary.get("shapeCount"),
"faceCount": summary.get("faceCount"),
"edgeCount": summary.get("edgeCount"),
"bounds": summary.get("bbox"),
}
def major_planes_payload(
selector_index: lookup.SelectorIndex | None,
options: EntryReportOptions,
) -> list[dict[str, object]]:
if selector_index is None:
return []
return analysis.major_planar_face_groups(
selector_index,
coordinate_tolerance=options.plane_coordinate_tolerance,
min_area_ratio=options.plane_min_area_ratio,
limit=options.plane_limit,
)
def entry_facts_payload(
manifest: dict[str, Any],
*,
kind: str,
selector_index: lookup.SelectorIndex | None = None,
options: EntryReportOptions = EntryReportOptions(),
major_planes: list[dict[str, object]] | None = None,
) -> dict[str, object]:
facts = analysis.bbox_facts(manifest.get("bbox"))
facts["kind"] = kind
return facts
def entry_positioning_payload(
manifest: dict[str, Any],
*,
kind: str,
selector_index: lookup.SelectorIndex | None = None,
options: EntryReportOptions = EntryReportOptions(),
major_planes: list[dict[str, object]] | None = None,
) -> dict[str, object]:
payload: dict[str, object] = {
"kind": kind,
"bbox": manifest.get("bbox"),
}
bbox_facts = analysis.bbox_facts(manifest.get("bbox"))
if bbox_facts:
payload["bboxFacts"] = bbox_facts
if selector_index is not None and major_planes is not None:
payload["majorPlanes"] = major_planes
return payload
def entry_report_payload(
manifest: dict[str, Any],
*,
kind: str,
options: EntryReportOptions,
selector_index: lookup.SelectorIndex | None = None,
) -> dict[str, object]:
payload: dict[str, object] = {
"summary": entry_summary_payload(manifest, kind=kind, selector_index=selector_index),
}
major_planes = (
major_planes_payload(selector_index, options)
if selector_index is not None and options.planes
else None
)
if options.facts:
payload["entryFacts"] = entry_facts_payload(
manifest,
kind=kind,
selector_index=selector_index,
options=options,
major_planes=major_planes,
)
if options.positioning:
payload["entryPositioning"] = entry_positioning_payload(
manifest,
kind=kind,
selector_index=selector_index,
options=options,
major_planes=major_planes,
)
if options.planes:
payload["planes"] = major_planes if major_planes is not None else []
if options.topology and selector_index is not None:
payload["topology"] = lookup.topology_payload(selector_index)
return payload
@@ -0,0 +1,18 @@
from __future__ import annotations
from array import array
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
class SelectorProfile(str, Enum):
SUMMARY = "summary"
REFS = "refs"
ARTIFACT = "artifact"
@dataclass
class SelectorBundle:
manifest: dict[str, Any]
buffers: dict[str, array] = field(default_factory=dict)
@@ -0,0 +1,62 @@
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path
from cadpy import catalog
_PACKAGE_ROOT = Path(__file__).resolve().parents[1]
@dataclass(frozen=True)
class PythonSourceHash:
source_path: str
source_hash: str
def python_source_hash(script_path: Path) -> PythonSourceHash:
"""Hash the generator script content and record its metadata path."""
resolved_script = script_path.expanduser().resolve()
return PythonSourceHash(
source_path=_manifest_path(resolved_script),
source_hash=_sha256_file(resolved_script),
)
def _manifest_roots() -> tuple[Path, ...]:
return tuple(_dedupe_paths([
catalog.CAD_ROOT.resolve(),
catalog.REPO_ROOT.resolve(),
_PACKAGE_ROOT.resolve(),
]))
def _dedupe_paths(paths: list[Path]) -> list[Path]:
result: list[Path] = []
seen: set[Path] = set()
for path in paths:
resolved = path.resolve()
if resolved not in seen:
seen.add(resolved)
result.append(resolved)
return result
def _manifest_path(path: Path) -> str:
resolved = path.resolve()
for root in _manifest_roots():
try:
return resolved.relative_to(root).as_posix()
except ValueError:
continue
return resolved.as_posix()
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
@@ -0,0 +1,367 @@
from __future__ import annotations
import argparse
from dataclasses import replace
import json
from pathlib import Path
from cadpy.cli_logging import CliLogger
from cadpy.generation import (
EntrySpec,
_existing_topology_artifact_matches_spec_without_scene,
_entry_spec_from_source,
_generate_part_outputs,
run_script_generator,
)
from cadpy.metadata import DEFAULT_MESH_ANGULAR_TOLERANCE, DEFAULT_MESH_TOLERANCE, normalize_mesh_numeric
from cadpy.render import part_glb_path, relative_to_repo
from cadpy.step_export import write_xcaf_doc_step_file
from cadpy.step_metadata import read_text_to_cad_step_metadata
from cadpy.step_scene import LoadedStepScene, load_step_scene, step_file_hash
from cadpy.catalog import iter_cad_sources, source_from_path
from cadpy.step_targets import (
ResolvedStepTarget,
StepTopologyArtifact,
StepTopologyArtifactError,
validate_step_topology_artifact,
)
def _repo_relative(repo_root: Path, path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(repo_root).as_posix()
except ValueError:
return resolved.as_posix()
def _cad_ref_for_step(repo_root: Path, step_path: Path) -> str:
relative = _repo_relative(repo_root, step_path)
suffix = step_path.suffix
return relative[: -len(suffix)] if suffix else relative
def _scene_has_assembly_structure(scene: LoadedStepScene) -> bool:
stack = list(scene.roots)
if len(stack) > 1:
return True
while stack:
node = stack.pop()
if node.children:
return True
stack.extend(node.children)
return False
def _infer_entry_kind(step_path: Path, scene: LoadedStepScene) -> str:
metadata_kind = None
try:
metadata_kind = read_text_to_cad_step_metadata(step_path).get("entryKind")
except Exception:
metadata_kind = None
if metadata_kind in {"part", "assembly"}:
return metadata_kind
return "assembly" if _scene_has_assembly_structure(scene) else "part"
def _build_entry_spec(
repo_root: Path,
step_path: Path,
scene: LoadedStepScene,
*,
kind: str,
mesh_tolerance: float | None = None,
mesh_angular_tolerance: float | None = None,
) -> EntrySpec:
cad_ref = _cad_ref_for_step(repo_root, step_path)
return EntrySpec(
source_ref=_repo_relative(repo_root, step_path),
cad_ref=cad_ref,
kind=kind,
source_path=step_path,
display_name=step_path.stem,
source="imported",
step_path=step_path,
mesh_tolerance=mesh_tolerance if mesh_tolerance is not None else DEFAULT_MESH_TOLERANCE,
mesh_angular_tolerance=(
mesh_angular_tolerance
if mesh_angular_tolerance is not None
else DEFAULT_MESH_ANGULAR_TOLERANCE
),
mesh_tolerance_explicit=mesh_tolerance is not None,
mesh_angular_tolerance_explicit=mesh_angular_tolerance is not None,
)
def _entries_by_step_path_for_repo(repo_root: Path, spec: EntrySpec) -> dict[Path, EntrySpec]:
entries: dict[Path, EntrySpec] = {}
try:
for source in iter_cad_sources(repo_root):
entry_spec = _entry_spec_from_source(source)
if entry_spec.step_path is not None:
entries[entry_spec.step_path.resolve()] = entry_spec
except Exception:
entries = {}
if spec.step_path is not None:
entries[spec.step_path.resolve()] = spec
return entries
def _result_payload(
spec: EntrySpec,
*,
entry_kind: str,
source_kind: str,
glb_path: Path,
step_hash: str | None = None,
source_hash: str | None = None,
stats: dict[str, object] | None = None,
load_elapsed_ms: float | None = None,
skipped: bool = False,
) -> dict[str, object]:
payload: dict[str, object] = {
"ok": True,
"stepPath": relative_to_repo(spec.step_path),
"glbPath": relative_to_repo(glb_path),
"entryKind": entry_kind,
"sourceKind": source_kind,
"stats": stats or {},
"sourceRef": spec.source_ref,
"cadPath": spec.cad_ref,
}
if step_hash:
payload["stepHash"] = step_hash
if source_hash:
payload["sourceHash"] = source_hash
if load_elapsed_ms is not None:
payload["loadElapsedMs"] = round(load_elapsed_ms, 1)
if skipped:
payload["skipped"] = True
return payload
def _generated_result_payload(spec: EntrySpec, scene: LoadedStepScene, stats: dict[str, object] | None = None) -> dict[str, object]:
glb_path = part_glb_path(spec.step_path)
source_kind = str(getattr(scene, "source_kind", "step") or "step").strip().lower()
step_hash = str(getattr(scene, "step_hash", "") or "").strip()
if not step_hash and spec.step_path is not None and spec.step_path.is_file():
step_hash = step_file_hash(spec.step_path)
return _result_payload(
spec,
entry_kind=spec.kind,
source_kind=source_kind,
step_hash=step_hash or None,
source_hash=getattr(scene, "source_hash", None) if source_kind == "python" else None,
glb_path=glb_path,
stats=stats,
load_elapsed_ms=scene.load_elapsed * 1000.0,
)
def _existing_result_payload(spec: EntrySpec, artifact: StepTopologyArtifact) -> dict[str, object]:
entry_kind = str(artifact.manifest.get("entryKind") or spec.kind)
source_kind = str(artifact.manifest.get("sourceKind") or "step").strip().lower()
step_hash = str(artifact.manifest.get("stepHash") or "")
source_hash = str(artifact.manifest.get("sourceHash") or "")
if source_kind != "python" and not step_hash:
step_hash = step_file_hash(spec.step_path)
stats = artifact.manifest.get("stats")
return _result_payload(
spec,
entry_kind=entry_kind,
source_kind=source_kind,
step_hash=step_hash or None,
source_hash=source_hash or None,
glb_path=artifact.glb_path,
stats=stats if isinstance(stats, dict) else {},
skipped=True,
)
def _write_step_after_artifact(spec: EntrySpec, scene: LoadedStepScene, *, logger: CliLogger) -> str:
if scene.doc is None:
raise RuntimeError(f"Cannot write STEP after GLB artifact; scene has no XCAF document: {spec.step_path}")
source_hash = (
str(getattr(scene, "source_hash", "") or "").strip()
if str(getattr(scene, "source_kind", "") or "").strip().lower() == "python"
else ""
)
source_path = (
str(getattr(scene, "source_path", "") or "").strip()
if str(getattr(scene, "source_kind", "") or "").strip().lower() == "python"
else ""
)
return write_xcaf_doc_step_file(
scene.doc,
spec.step_path,
label=spec.step_path.stem,
originating_system="tom-cad direct XCAF assembly" if spec.kind == "assembly" else "build123d",
text_to_cad_entry_kind=spec.kind,
source_path=source_path or None,
source_hash=source_hash or None,
logger=logger,
)
def _current_artifact_for_spec(spec: EntrySpec) -> StepTopologyArtifact | None:
if not _existing_topology_artifact_matches_spec_without_scene(spec):
return None
try:
return validate_step_topology_artifact(
ResolvedStepTarget(
cad_path=spec.cad_ref,
kind=spec.kind,
source_path=spec.source_path,
step_path=spec.step_path,
),
glb_path=part_glb_path(spec.step_path),
require_selector=True,
)
except StepTopologyArtifactError:
return None
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="python -m cadpy.step_artifact",
description="Generate the hidden CAD Viewer GLB/topology artifact for one STEP/STP file.",
)
parser.add_argument("--repo-root", required=True, help="Repository/workspace root for relative STEP metadata.")
parser.add_argument("--step", required=True, help="STEP/STP source file to process.")
parser.add_argument("--source-path", help="Python gen_step() source path for a logical STEP output.")
parser.add_argument("--kind", choices=("part", "assembly"), help="Override inferred STEP entry kind.")
parser.add_argument("--skip-step-write", action="store_true", help="Generate from same-stem Python generator without writing STEP.")
parser.add_argument("--write-step-after-artifact", action="store_true", help="With --skip-step-write, write/update the STEP file after the GLB artifact is ready.")
parser.add_argument("--force", action="store_true", help="Regenerate even if a current artifact exists.")
parser.add_argument("--mesh-tolerance", type=float, help="Override automatic mesh linear deflection.")
parser.add_argument("--mesh-angular-tolerance", type=float, help="Override automatic mesh angular deflection.")
parser.add_argument("--verbose", action="store_true", help="Show detailed timing on stderr.")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if bool(args.write_step_after_artifact) and not bool(args.skip_step_write):
raise ValueError("--write-step-after-artifact requires --skip-step-write")
repo_root = Path(args.repo_root).expanduser().resolve()
step_path = Path(args.step).expanduser().resolve()
if bool(args.skip_step_write):
script_path = (
Path(args.source_path).expanduser().resolve()
if args.source_path
else step_path.with_suffix(".py")
)
if not script_path.is_file():
raise FileNotFoundError(f"Python generator does not exist for logical STEP path: {script_path}")
source = source_from_path(script_path)
if source is None:
raise RuntimeError(f"Python generator is not a gen_step() CAD source: {script_path}")
spec = _entry_spec_from_source(source)
if spec.step_path is None or spec.step_path.resolve() != step_path:
if not args.source_path or spec.step_path is None:
raise RuntimeError(f"Python generator does not map to logical STEP path: {step_path}")
spec = replace(
spec,
cad_ref=_cad_ref_for_step(repo_root, step_path),
display_name=step_path.stem,
step_path=step_path,
)
if args.kind is not None and args.kind != spec.kind:
raise ValueError(f"Requested --kind {args.kind!r} does not match generator kind {spec.kind!r}")
elif not step_path.is_file():
raise FileNotFoundError(f"STEP file does not exist: {step_path}")
if step_path.suffix.lower() not in {".step", ".stp"}:
raise ValueError(f"Expected a STEP/STP file: {step_path}")
logger = CliLogger("step-artifact", verbose=bool(args.verbose))
mesh_tolerance = normalize_mesh_numeric(args.mesh_tolerance, field_name="mesh_tolerance")
mesh_angular_tolerance = normalize_mesh_numeric(
args.mesh_angular_tolerance,
field_name="mesh_angular_tolerance",
)
if bool(args.skip_step_write):
existing_spec = spec
if mesh_tolerance is not None or mesh_angular_tolerance is not None:
existing_spec = replace(
existing_spec,
mesh_tolerance=mesh_tolerance if mesh_tolerance is not None else existing_spec.mesh_tolerance,
mesh_angular_tolerance=(
mesh_angular_tolerance
if mesh_angular_tolerance is not None
else existing_spec.mesh_angular_tolerance
),
mesh_tolerance_explicit=mesh_tolerance is not None,
mesh_angular_tolerance_explicit=mesh_angular_tolerance is not None,
)
else:
existing_spec = EntrySpec(
source_ref=_repo_relative(repo_root, step_path),
cad_ref=_cad_ref_for_step(repo_root, step_path),
kind=args.kind or "part",
source_path=step_path,
display_name=step_path.stem,
source="imported",
step_path=step_path,
mesh_tolerance=mesh_tolerance if mesh_tolerance is not None else DEFAULT_MESH_TOLERANCE,
mesh_angular_tolerance=(
mesh_angular_tolerance
if mesh_angular_tolerance is not None
else DEFAULT_MESH_ANGULAR_TOLERANCE
),
mesh_tolerance_explicit=mesh_tolerance is not None,
mesh_angular_tolerance_explicit=mesh_angular_tolerance is not None,
)
if not args.force:
existing_artifact = _current_artifact_for_spec(existing_spec)
if existing_artifact is not None:
print(json.dumps(_existing_result_payload(existing_spec, existing_artifact), separators=(",", ":")))
return 0
glb_path = part_glb_path(step_path)
if bool(args.skip_step_write):
scene = run_script_generator(
existing_spec,
"gen_step",
logger=logger,
force=bool(args.force),
load_current_scene=False,
skip_step_write=True,
)
if scene is None:
raise RuntimeError(f"Python generator did not produce a STEP scene: {existing_spec.source_ref}")
spec = existing_spec
if bool(args.write_step_after_artifact):
step_hash = _write_step_after_artifact(spec, scene, logger=logger)
scene.step_hash = step_hash
else:
with logger.timed(f"load STEP {relative_to_repo(step_path)}"):
scene = load_step_scene(step_path)
kind = args.kind or _infer_entry_kind(step_path, scene)
spec = _build_entry_spec(
repo_root,
step_path,
scene,
kind=kind,
mesh_tolerance=mesh_tolerance,
mesh_angular_tolerance=mesh_angular_tolerance,
)
result = _generate_part_outputs(
spec,
entries_by_step_path=_entries_by_step_path_for_repo(repo_root, spec),
preloaded_scene=scene,
require_step_file=not bool(args.skip_step_write),
force=bool(args.force),
logger=logger,
)
stats = result.selector_bundle.manifest.get("stats") if result.selector_bundle is not None else {}
payload = _generated_result_payload(spec, scene, stats if isinstance(stats, dict) else {})
if bool(args.write_step_after_artifact):
print(json.dumps({**payload, "stepWrite": {"status": "complete", "stepHash": payload.get("stepHash", "")}}, separators=(",", ":")))
else:
print(json.dumps(payload, separators=(",", ":")))
logger.total()
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,245 @@
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from cadpy.assembly_spec import REPO_ROOT
from cadpy.catalog import source_from_path
from cadpy.cli_logging import CliLogger
from cadpy.generation import (
EntrySpec,
_entry_spec_from_source,
_existing_topology_artifact_matches_spec_without_scene,
_generate_part_outputs,
run_script_generator,
)
from cadpy.metadata import DEFAULT_MESH_ANGULAR_TOLERANCE, DEFAULT_MESH_TOLERANCE
from cadpy.render import part_glb_path
from cadpy.step_metadata import read_text_to_cad_step_metadata
from cadpy.step_scene import LoadedStepScene, load_step_scene
from cadpy.step_targets import (
REGENERATE_STEP_COMMAND,
REGENERATE_STEP_PROMPT,
ResolvedStepTarget,
StepTopologyArtifact,
StepTopologyArtifactError,
validate_step_topology_artifact,
)
def cad_ref_for_step_path(repo_root: Path, step_path: Path) -> str:
relative = _repo_relative(repo_root, step_path)
suffix = step_path.suffix
return relative[: -len(suffix)] if suffix else relative
def ensure_step_topology_artifact(
target: ResolvedStepTarget,
*,
glb_path: Path | None = None,
require_selector: bool = False,
force: bool = False,
logger: CliLogger | None = None,
mesh_tolerance: float | None = None,
mesh_angular_tolerance: float | None = None,
owner: str = "cadpy-step-artifact",
) -> StepTopologyArtifact:
spec = _entry_spec_for_target(
target,
mesh_tolerance=mesh_tolerance,
mesh_angular_tolerance=mesh_angular_tolerance,
)
resolved_glb_path = glb_path or part_glb_path(spec.step_path)
if not force:
artifact = _current_artifact_for_spec(spec, glb_path=resolved_glb_path, require_selector=require_selector)
if artifact is not None:
return artifact
try:
spec, scene = _scene_for_regeneration(spec, logger=logger, force=force)
_generate_part_outputs(
spec,
entries_by_step_path={spec.step_path: spec},
preloaded_scene=scene,
require_step_file=(spec.source != "generated"),
force=True,
logger=logger,
)
except StepTopologyArtifactError:
raise
except Exception as exc:
raise StepTopologyArtifactError(
code="glb_regeneration_failed",
cad_path=spec.cad_ref,
step_path=spec.step_path,
glb_path=resolved_glb_path,
regenerate_command=REGENERATE_STEP_COMMAND,
message=(
f"Failed to regenerate GLB/topology artifact for {spec.cad_ref}: {exc}.\n"
f"{REGENERATE_STEP_PROMPT}"
),
) from exc
return validate_step_topology_artifact(
ResolvedStepTarget(
cad_path=spec.cad_ref,
kind=spec.kind,
source_path=spec.source_path,
step_path=spec.step_path,
),
glb_path=resolved_glb_path,
require_selector=require_selector,
)
def _entry_spec_for_target(
target: ResolvedStepTarget,
*,
mesh_tolerance: float | None,
mesh_angular_tolerance: float | None,
) -> EntrySpec:
python_source = _python_source_for_target(target)
if python_source is not None:
source = source_from_path(python_source)
if source is None:
raise RuntimeError(f"Python generator is not a gen_step() CAD source: {python_source}")
spec = _entry_spec_from_source(source)
return _with_mesh_overrides(spec, mesh_tolerance=mesh_tolerance, mesh_angular_tolerance=mesh_angular_tolerance)
if not target.step_path.is_file():
raise FileNotFoundError(f"STEP file does not exist: {target.step_path}")
return EntrySpec(
source_ref=_repo_relative(REPO_ROOT, target.step_path),
cad_ref=target.cad_path,
kind=target.kind if target.kind in {"part", "assembly"} else "part",
source_path=target.step_path,
display_name=target.step_path.stem,
source="imported",
step_path=target.step_path,
mesh_tolerance=mesh_tolerance if mesh_tolerance is not None else DEFAULT_MESH_TOLERANCE,
mesh_angular_tolerance=(
mesh_angular_tolerance
if mesh_angular_tolerance is not None
else DEFAULT_MESH_ANGULAR_TOLERANCE
),
mesh_tolerance_explicit=mesh_tolerance is not None,
mesh_angular_tolerance_explicit=mesh_angular_tolerance is not None,
)
def _scene_for_regeneration(
spec: EntrySpec,
*,
logger: CliLogger | None,
force: bool,
) -> tuple[EntrySpec, LoadedStepScene]:
if spec.source == "generated":
scene = run_script_generator(
spec,
"gen_step",
logger=logger,
force=force,
load_current_scene=False,
skip_step_write=True,
)
if scene is None:
raise RuntimeError(f"Python generator did not produce a STEP scene: {spec.source_ref}")
return spec, scene
with (logger.timed(f"load STEP {spec.cad_ref}") if logger is not None else _null_context()):
scene = load_step_scene(spec.step_path)
inferred_kind = _infer_entry_kind(spec.step_path, scene)
if inferred_kind != spec.kind:
spec = replace(spec, kind=inferred_kind)
return spec, scene
def _current_artifact_for_spec(
spec: EntrySpec,
*,
glb_path: Path,
require_selector: bool,
) -> StepTopologyArtifact | None:
if not _existing_topology_artifact_matches_spec_without_scene(spec, require_selector=require_selector):
return None
try:
return validate_step_topology_artifact(
ResolvedStepTarget(
cad_path=spec.cad_ref,
kind=spec.kind,
source_path=spec.source_path,
step_path=spec.step_path,
),
glb_path=glb_path,
require_selector=require_selector,
)
except StepTopologyArtifactError:
return None
def _python_source_for_target(target: ResolvedStepTarget) -> Path | None:
if target.step_path.is_file():
return None
if target.source_path.suffix.lower() == ".py" and target.source_path.is_file():
return target.source_path
candidate = target.step_path.with_suffix(".py")
return candidate if candidate.is_file() else None
def _with_mesh_overrides(
spec: EntrySpec,
*,
mesh_tolerance: float | None,
mesh_angular_tolerance: float | None,
) -> EntrySpec:
if mesh_tolerance is None and mesh_angular_tolerance is None:
return spec
return replace(
spec,
mesh_tolerance=mesh_tolerance if mesh_tolerance is not None else spec.mesh_tolerance,
mesh_angular_tolerance=(
mesh_angular_tolerance
if mesh_angular_tolerance is not None
else spec.mesh_angular_tolerance
),
mesh_tolerance_explicit=mesh_tolerance is not None,
mesh_angular_tolerance_explicit=mesh_angular_tolerance is not None,
)
def _scene_has_assembly_structure(scene: LoadedStepScene) -> bool:
stack = list(scene.roots)
if len(stack) > 1:
return True
while stack:
node = stack.pop()
if node.children:
return True
stack.extend(node.children)
return False
def _infer_entry_kind(step_path: Path, scene: LoadedStepScene) -> str:
metadata_kind = None
try:
metadata_kind = read_text_to_cad_step_metadata(step_path).get("entryKind")
except Exception:
metadata_kind = None
if metadata_kind in {"part", "assembly"}:
return metadata_kind
return "assembly" if _scene_has_assembly_structure(scene) else "part"
def _repo_relative(repo_root: Path, path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(repo_root.resolve()).as_posix()
except ValueError:
return resolved.as_posix()
class _null_context:
def __enter__(self) -> None:
return None
def __exit__(self, *_args: object) -> None:
return None
@@ -0,0 +1,386 @@
from __future__ import annotations
import os
from contextlib import nullcontext
from pathlib import Path
from typing import Any
from cadpy.step_scene import LoadedStepScene, load_step_scene_from_xcaf_doc, step_file_hash
from cadpy.step_metadata import TEXT_TO_CAD_GENERATOR, inject_text_to_cad_step_metadata
def _collect_assembly_mates(shape: Any) -> list[dict[str, Any]]:
mates: list[dict[str, Any]] = []
seen: set[str] = set()
def visit(node: Any) -> None:
raw_mates = getattr(node, "assembly_mates", None)
if isinstance(raw_mates, list):
for raw_mate in raw_mates:
if not isinstance(raw_mate, dict):
continue
key = repr(raw_mate)
if key in seen:
continue
seen.add(key)
mate = dict(raw_mate)
mate_id = f"m{len(mates) + 1}"
source_label = str(
mate.get("sourceLabel") or
mate.get("name") or
mate.get("label") or
mate.get("id") or
""
).strip()
mate["id"] = mate_id
mate["label"] = mate_id
if source_label and source_label != mate_id:
mate["sourceLabel"] = source_label
mates.append(mate)
for child in list(getattr(node, "children", []) or []):
visit(child)
visit(shape)
return mates
def _attach_assembly_mates(scene: LoadedStepScene, shape: Any) -> LoadedStepScene:
assembly_mates = _collect_assembly_mates(shape)
if assembly_mates:
scene.assembly_mates = assembly_mates
return scene
def create_bin_xcaf_doc() -> Any:
from OCP.BinXCAFDrivers import BinXCAFDrivers
from build123d.exporters3d import (
TCollection_ExtendedString,
TDocStd_Document,
UNITS_PER_METER,
Unit,
XCAFApp_Application,
XCAFDoc_DocumentTool,
)
doc = TDocStd_Document(TCollection_ExtendedString("BinXCAF"))
application = XCAFApp_Application.GetApplication_s()
BinXCAFDrivers.DefineFormat_s(application)
application.NewDocument(TCollection_ExtendedString("BinXCAF"), doc)
application.InitDocument(doc)
XCAFDoc_DocumentTool.SetLengthUnit_s(doc, 1 / UNITS_PER_METER[Unit.MM])
return doc
def quantity_color_rgba_from_color(color: object) -> object | None:
"""Return a Quantity_ColorRGBA with explicit linear RGB semantics.
build123d.Color exposes its values through Quantity_ColorRGBA.GetRGB().
Different OCP versions can serialize that wrapped color with different
implicit color-space assumptions, so normalize through Quantity_TOC_RGB
before writing XCAF labels.
"""
if color is None:
return None
if isinstance(color, tuple):
values = tuple(max(0.0, min(1.0, float(component))) for component in color)
if len(values) == 3:
rgba = (values[0], values[1], values[2], 1.0)
elif len(values) >= 4:
rgba = (values[0], values[1], values[2], values[3])
else:
return None
else:
wrapped = getattr(color, "wrapped", None)
if wrapped is None:
return None
try:
rgb = wrapped.GetRGB()
rgba = (
max(0.0, min(1.0, float(rgb.Red()))),
max(0.0, min(1.0, float(rgb.Green()))),
max(0.0, min(1.0, float(rgb.Blue()))),
max(0.0, min(1.0, float(wrapped.Alpha()))),
)
except Exception:
return wrapped
from OCP.Quantity import Quantity_Color, Quantity_ColorRGBA, Quantity_TOC_RGB
rgb_color = Quantity_Color(rgba[0], rgba[1], rgba[2], Quantity_TOC_RGB)
wrapped_rgba = Quantity_ColorRGBA(rgb_color)
wrapped_rgba.SetAlpha(rgba[3])
return wrapped_rgba
def _create_bin_xcaf_doc(to_export: Any) -> Any:
import warnings
from OCP.TopLoc import TopLoc_Location
from build123d.exporters3d import (
Compound,
Curve,
Part,
PreOrderIter,
Sketch,
TCollection_ExtendedString,
TDataStd_Name,
TopExp_Explorer,
XCAFDoc_ColorType,
XCAFDoc_DocumentTool,
ta,
)
doc = create_bin_xcaf_doc()
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main())
is_assembly = isinstance(to_export, Compound) and len(to_export.children) > 0
shape_definitions: dict[int, object] = {}
def set_label_name(label: object, name: str | None) -> None:
if name and not label.IsNull():
TDataStd_Name.Set_s(label, TCollection_ExtendedString(str(name)))
def set_label_color(label: object, color: object | None) -> None:
if color is None or label.IsNull():
return
wrapped = quantity_color_rgba_from_color(color)
if wrapped is None:
return
color_tool.SetColor(
label,
wrapped,
XCAFDoc_ColorType.XCAFDoc_ColorSurf,
)
def shape_location(shape: object) -> object:
wrapped = getattr(shape, "wrapped", None)
if wrapped is None:
return TopLoc_Location()
location = getattr(wrapped, "Location", None)
if not callable(location):
return TopLoc_Location()
try:
return location()
except Exception:
return TopLoc_Location()
def shape_without_location(shape: object) -> object:
wrapped = getattr(shape, "wrapped", None)
if wrapped is None:
return shape
located = getattr(wrapped, "Located", None)
if not callable(located):
return wrapped
try:
return located(TopLoc_Location())
except Exception:
return wrapped
def shape_definition_for_tree(shape: object) -> object:
key = id(shape)
cached = shape_definitions.get(key)
if cached is not None:
return cached
children = list(getattr(shape, "children", []) or [])
if children:
definition_label = shape_tool.NewShape()
shape_definitions[key] = definition_label
set_label_name(definition_label, getattr(shape, "label", None))
set_label_color(definition_label, getattr(shape, "color", None))
for child in children:
child_definition = shape_definition_for_tree(child)
child_component = shape_tool.AddComponent(
definition_label,
child_definition,
shape_location(child),
)
set_label_name(child_component, getattr(child, "label", None))
set_label_color(child_component, getattr(child, "color", None))
return definition_label
definition_label = shape_tool.AddShape(shape_without_location(shape), False)
shape_definitions[key] = definition_label
set_label_name(definition_label, getattr(shape, "label", None))
set_label_color(definition_label, getattr(shape, "color", None))
return definition_label
if is_assembly:
shape_definition_for_tree(to_export)
shape_tool.UpdateAssemblies()
return doc
shape_tool.AddShape(to_export.wrapped, is_assembly)
for node in PreOrderIter(to_export):
if not node.label and node.color is None:
continue
node_label = shape_tool.FindShape(node.wrapped, findInstance=False)
sub_node_labels = []
if node.color is not None and isinstance(node, Compound) and not node.children:
sub_nodes = []
if isinstance(node, Part):
explorer = TopExp_Explorer(node.wrapped, ta.TopAbs_SOLID)
elif isinstance(node, Sketch):
explorer = TopExp_Explorer(node.wrapped, ta.TopAbs_FACE)
elif isinstance(node, Curve):
explorer = TopExp_Explorer(node.wrapped, ta.TopAbs_EDGE)
else:
warnings.warn("Unknown Compound type, color not set", stacklevel=2)
explorer = TopExp_Explorer()
while explorer.More():
sub_nodes.append(explorer.Current())
explorer.Next()
sub_node_labels = [
shape_tool.FindShape(sub_node, findInstance=False)
for sub_node in sub_nodes
]
set_label_name(node_label, node.label)
if node.color is not None:
for label in [node_label] + sub_node_labels:
set_label_color(label, node.color)
shape_tool.UpdateAssemblies()
return doc
def export_xcaf_doc_step_scene(
doc: Any,
output_path: Path,
*,
label: str | None = None,
originating_system: str = "build123d",
text_to_cad_entry_kind: str | None = None,
source_path: str | None = None,
source_hash: str | None = None,
logger: object | None = None,
) -> LoadedStepScene:
step_hash = write_xcaf_doc_step_file(
doc,
output_path,
label=label,
originating_system=originating_system,
text_to_cad_entry_kind=text_to_cad_entry_kind,
source_path=source_path,
source_hash=source_hash,
logger=logger,
)
with (logger.timed(f"load scene from XCAF {output_path.name}") if logger is not None else nullcontext()):
return load_step_scene_from_xcaf_doc(
output_path,
doc,
step_hash=step_hash,
)
def write_xcaf_doc_step_file(
doc: Any,
output_path: Path,
*,
label: str | None = None,
originating_system: str = "build123d",
text_to_cad_entry_kind: str | None = None,
source_path: str | None = None,
source_hash: str | None = None,
logger: object | None = None,
) -> str:
from build123d.exporters3d import (
APIHeaderSection_MakeHeader,
IFSelect_ReturnStatus,
IGESControl_Controller,
Interface_Static,
Message,
Message_Gravity,
PrecisionMode,
STEPCAFControl_Controller,
STEPCAFControl_Writer,
STEPControl_Controller,
STEPControl_StepModelType,
TCollection_HAsciiString,
XSControl_WorkSession,
)
output_path = output_path.expanduser().resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
messenger = Message.DefaultMessenger_s()
for printer in messenger.Printers():
printer.SetTraceLevel(Message_Gravity(Message_Gravity.Message_Fail))
session = XSControl_WorkSession()
writer = STEPCAFControl_Writer(session, False)
writer.SetColorMode(True)
writer.SetLayerMode(True)
writer.SetNameMode(True)
header = APIHeaderSection_MakeHeader(writer.Writer().Model())
if label:
header.SetName(TCollection_HAsciiString(label))
header.SetOriginatingSystem(
TCollection_HAsciiString(TEXT_TO_CAD_GENERATOR if text_to_cad_entry_kind else originating_system)
)
STEPCAFControl_Controller.Init_s()
STEPControl_Controller.Init_s()
IGESControl_Controller.Init_s()
Interface_Static.SetIVal_s("write.surfacecurve.mode", 1)
Interface_Static.SetIVal_s("write.precision.mode", PrecisionMode.AVERAGE.value)
with (logger.timed(f"transfer XCAF to STEP model {output_path.name}") if logger is not None else nullcontext()):
writer.Transfer(doc, STEPControl_StepModelType.STEPControl_AsIs)
with (logger.timed(f"write STEP file {output_path.name}") if logger is not None else nullcontext()):
if writer.Write(os.fspath(output_path)) != IFSelect_ReturnStatus.IFSelect_RetDone:
raise RuntimeError(f"Failed to write STEP file: {output_path}")
if not output_path.exists() or output_path.stat().st_size <= 0:
raise RuntimeError(f"STEP export did not create {output_path}")
if text_to_cad_entry_kind:
with (logger.timed(f"inject STEP metadata {output_path.name}") if logger is not None else nullcontext()):
inject_text_to_cad_step_metadata(
output_path,
entry_kind=text_to_cad_entry_kind,
source_path=source_path,
source_hash=source_hash,
)
return step_file_hash(output_path)
def export_build123d_step_scene(
to_export: Any,
output_path: Path,
*,
text_to_cad_entry_kind: str | None = None,
source_path: str | None = None,
source_hash: str | None = None,
) -> LoadedStepScene:
doc = _create_bin_xcaf_doc(to_export)
scene = export_xcaf_doc_step_scene(
doc,
output_path,
label=getattr(to_export, "label", None),
text_to_cad_entry_kind=text_to_cad_entry_kind,
source_path=source_path,
source_hash=source_hash,
)
return _attach_assembly_mates(scene, to_export)
def build_build123d_step_scene(
to_export: Any,
output_path: Path,
*,
source_kind: str = "step",
source_hash: str | None = None,
) -> LoadedStepScene:
doc = _create_bin_xcaf_doc(to_export)
scene = load_step_scene_from_xcaf_doc(
output_path,
doc,
source_kind=source_kind,
source_hash=source_hash,
)
return _attach_assembly_mates(scene, to_export)
@@ -0,0 +1,12 @@
from __future__ import annotations
import hashlib
from pathlib import Path
def step_file_hash(step_path: Path) -> str:
digest = hashlib.sha256()
with step_path.expanduser().resolve().open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
@@ -0,0 +1,321 @@
from __future__ import annotations
import re
import posixpath
from pathlib import Path
TEXT_TO_CAD_GENERATOR = "cadpy"
TEXT_TO_CAD_GENERATOR_PROPERTY = "cadpy:generator"
TEXT_TO_CAD_ENTRY_KIND_PROPERTY = "cadpy:entryKind"
TEXT_TO_CAD_SOURCE_PATH_PROPERTY = "cadpy:sourcePath"
TEXT_TO_CAD_SOURCE_HASH_PROPERTY = "cadpy:sourceHash"
TEXT_TO_CAD_METADATA_GROUP = "cadpy metadata"
TEXT_TO_CAD_METADATA_TAIL_BYTES = 1024 * 1024
TEXT_TO_CAD_METADATA_HEAD_BYTES = 2 * 1024 * 1024
_STEP_STRING_PATTERN = r"'(?:''|[^'])*'"
def _step_escape(value: object) -> str:
return str(value).replace("'", "''")
def _step_unescape(value: str) -> str:
raw = value.strip()
if len(raw) >= 2 and raw[0] == "'" and raw[-1] == "'":
raw = raw[1:-1]
return raw.replace("''", "'")
def normalize_text_to_cad_entry_kind(value: object) -> str | None:
normalized = str(value or "").strip().lower()
return normalized if normalized in {"part", "assembly"} else None
def normalize_text_to_cad_source_path(value: object) -> str | None:
raw = str(value or "").strip().replace("\\", "/")
if not raw or raw.startswith("/") or re.match(r"^[A-Za-z]:/", raw):
return None
normalized = posixpath.normpath(raw)
if normalized in {"", ".", ".."}:
return None
return normalized
def _max_entity_id(step_text: str) -> int:
ids = [int(match) for match in re.findall(r"#(\d+)\s*=", step_text)]
return max(ids, default=0)
def _root_product_definition_ref(step_text: str) -> str | None:
shape_definition_match = re.search(
r"#\d+\s*=\s*SHAPE_DEFINITION_REPRESENTATION\s*\(\s*(#\d+)\s*,\s*#\d+\s*\)\s*;",
step_text,
re.IGNORECASE | re.DOTALL,
)
if shape_definition_match:
shape_definition_ref = re.escape(shape_definition_match.group(1).lstrip("#"))
product_shape_match = re.search(
rf"#{shape_definition_ref}\s*=\s*PRODUCT_DEFINITION_SHAPE\s*\(.*?,\s*(#\d+)\s*\)\s*;",
step_text,
re.IGNORECASE | re.DOTALL,
)
if product_shape_match:
return product_shape_match.group(1)
match = re.search(r"#(\d+)\s*=\s*PRODUCT_DEFINITION\s*\(", step_text, re.IGNORECASE)
return f"#{match.group(1)}" if match else None
def _shape_representation_context_ref(step_text: str) -> str | None:
match = re.search(
r"#\d+\s*=\s*(?:ADVANCED_BREP_SHAPE_REPRESENTATION|SHAPE_REPRESENTATION)\s*\(.*?,\s*#(\d+)\s*\)\s*;",
step_text,
re.IGNORECASE | re.DOTALL,
)
return f"#{match.group(1)}" if match else None
def _metadata_entities(
*,
first_id: int,
product_definition_ref: str,
representation_context_ref: str,
properties: list[tuple[str, str]],
) -> list[str]:
next_id = first_id
entities: list[str] = []
for property_name, property_value in properties:
item_id = next_id
repr_id = next_id + 1
prop_id = next_id + 2
link_id = next_id + 3
next_id += 4
property_name_escaped = _step_escape(property_name)
property_value_escaped = _step_escape(property_value)
metadata_group_escaped = _step_escape(TEXT_TO_CAD_METADATA_GROUP)
entities.extend(
[
(
f"#{item_id}=DESCRIPTIVE_REPRESENTATION_ITEM("
f"'{property_name_escaped}','{property_value_escaped}');"
),
(
f"#{repr_id}=REPRESENTATION("
f"'{property_name_escaped}',(#{item_id}),{representation_context_ref});"
),
(
f"#{prop_id}=PROPERTY_DEFINITION("
f"'{metadata_group_escaped}','{property_name_escaped}',{product_definition_ref});"
),
f"#{link_id}=PROPERTY_DEFINITION_REPRESENTATION(#{prop_id},#{repr_id});",
]
)
return entities
def inject_text_to_cad_step_metadata(
step_path: Path,
*,
entry_kind: str,
generator: str = TEXT_TO_CAD_GENERATOR,
source_path: str | None = None,
source_hash: str | None = None,
) -> None:
normalized_entry_kind = normalize_text_to_cad_entry_kind(entry_kind)
if normalized_entry_kind is None:
raise ValueError(f"Unsupported cadpy STEP entry kind: {entry_kind!r}")
normalized_source_path = normalize_text_to_cad_source_path(source_path)
if source_path and normalized_source_path is None:
raise ValueError(f"Unsupported cadpy STEP source path: {source_path!r}")
step_path = step_path.expanduser().resolve()
if _try_inject_text_to_cad_step_metadata_tail(
step_path,
entry_kind=normalized_entry_kind,
generator=generator,
source_path=normalized_source_path,
source_hash=source_hash,
):
return
step_text = step_path.read_text(encoding="utf-8")
product_definition_ref = _root_product_definition_ref(step_text)
representation_context_ref = _shape_representation_context_ref(step_text)
data_end = list(re.finditer(r"ENDSEC\s*;", step_text, re.IGNORECASE))
if not product_definition_ref or not representation_context_ref or not data_end:
raise RuntimeError(f"Could not locate STEP product metadata insertion point in {step_path}")
properties = [
(TEXT_TO_CAD_GENERATOR_PROPERTY, generator),
(TEXT_TO_CAD_ENTRY_KIND_PROPERTY, normalized_entry_kind),
]
if normalized_source_path:
properties.append((TEXT_TO_CAD_SOURCE_PATH_PROPERTY, normalized_source_path))
if source_hash:
properties.append((TEXT_TO_CAD_SOURCE_HASH_PROPERTY, source_hash))
entities = _metadata_entities(
first_id=_max_entity_id(step_text) + 1,
product_definition_ref=product_definition_ref,
representation_context_ref=representation_context_ref,
properties=properties,
)
insertion = "\n" + "\n".join(entities) + "\n"
insert_at = data_end[-1].start()
step_path.write_text(step_text[:insert_at] + insertion + step_text[insert_at:], encoding="utf-8")
def _read_step_head_text(step_path: Path, *, head_bytes: int = TEXT_TO_CAD_METADATA_HEAD_BYTES) -> str:
with step_path.expanduser().resolve().open("rb") as handle:
payload = handle.read(max(1, int(head_bytes)))
return payload.decode("utf-8", errors="ignore")
def _try_inject_text_to_cad_step_metadata_tail(
step_path: Path,
*,
entry_kind: str,
generator: str,
source_path: str | None,
source_hash: str | None,
) -> bool:
tail_payload, offset, is_full_file = _read_step_tail_payload(step_path)
try:
tail_text = tail_payload.decode("utf-8")
except UnicodeDecodeError:
return False
data_end = list(re.finditer(r"ENDSEC\s*;", tail_text, re.IGNORECASE))
if not data_end:
return False
header_text = tail_text if is_full_file else _read_step_head_text(step_path)
product_definition_ref = _root_product_definition_ref(header_text)
representation_context_ref = _shape_representation_context_ref(header_text)
max_entity_id = _max_entity_id(tail_text)
if not product_definition_ref or not representation_context_ref or max_entity_id <= 0:
return False
properties = [
(TEXT_TO_CAD_GENERATOR_PROPERTY, generator),
(TEXT_TO_CAD_ENTRY_KIND_PROPERTY, entry_kind),
]
if source_path:
properties.append((TEXT_TO_CAD_SOURCE_PATH_PROPERTY, source_path))
if source_hash:
properties.append((TEXT_TO_CAD_SOURCE_HASH_PROPERTY, source_hash))
entities = _metadata_entities(
first_id=max_entity_id + 1,
product_definition_ref=product_definition_ref,
representation_context_ref=representation_context_ref,
properties=properties,
)
insertion = "\n" + "\n".join(entities) + "\n"
insert_local = data_end[-1].start()
encoded_tail = (tail_text[:insert_local] + insertion + tail_text[insert_local:]).encode("utf-8")
with step_path.open("r+b") as handle:
handle.seek(offset)
handle.write(encoded_tail)
handle.truncate()
return True
def _read_step_tail_payload(
step_path: Path,
*,
tail_bytes: int = TEXT_TO_CAD_METADATA_TAIL_BYTES,
) -> tuple[bytes, int, bool]:
resolved = step_path.expanduser().resolve()
size = resolved.stat().st_size
offset = max(0, size - max(1, int(tail_bytes)))
with resolved.open("rb") as handle:
handle.seek(offset)
payload = handle.read()
return payload, offset, offset == 0
def read_text_to_cad_step_metadata_text(step_text: str) -> dict[str, str]:
descriptive_items: dict[str, tuple[str, str]] = {}
for match in re.finditer(
rf"#(\d+)\s*=\s*DESCRIPTIVE_REPRESENTATION_ITEM\s*\(\s*({_STEP_STRING_PATTERN})\s*,\s*({_STEP_STRING_PATTERN})\s*\)\s*;",
step_text,
re.IGNORECASE | re.DOTALL,
):
descriptive_items[f"#{match.group(1)}"] = (_step_unescape(match.group(2)), _step_unescape(match.group(3)))
representations: dict[str, tuple[str, list[str]]] = {}
for match in re.finditer(
rf"#(\d+)\s*=\s*REPRESENTATION\s*\(\s*({_STEP_STRING_PATTERN})\s*,\s*\(([^)]*)\)\s*,\s*#\d+\s*\)\s*;",
step_text,
re.IGNORECASE | re.DOTALL,
):
item_refs = re.findall(r"#\d+", match.group(3))
representations[f"#{match.group(1)}"] = (_step_unescape(match.group(2)), item_refs)
property_definitions: dict[str, str] = {}
for match in re.finditer(
rf"#(\d+)\s*=\s*PROPERTY_DEFINITION\s*\(\s*({_STEP_STRING_PATTERN})\s*,\s*({_STEP_STRING_PATTERN})\s*,\s*#[0-9]+\s*\)\s*;",
step_text,
re.IGNORECASE | re.DOTALL,
):
property_name = _step_unescape(match.group(3))
if property_name in {
TEXT_TO_CAD_GENERATOR_PROPERTY,
TEXT_TO_CAD_ENTRY_KIND_PROPERTY,
TEXT_TO_CAD_SOURCE_PATH_PROPERTY,
TEXT_TO_CAD_SOURCE_HASH_PROPERTY,
"cadpy:entry_kind",
}:
property_definitions[f"#{match.group(1)}"] = property_name
metadata: dict[str, str] = {}
for match in re.finditer(
r"#\d+\s*=\s*PROPERTY_DEFINITION_REPRESENTATION\s*\(\s*(#\d+)\s*,\s*(#\d+)\s*\)\s*;",
step_text,
re.IGNORECASE | re.DOTALL,
):
property_name = property_definitions.get(match.group(1))
representation = representations.get(match.group(2))
if not property_name or not representation:
continue
_, item_refs = representation
value = ""
for item_ref in item_refs:
item = descriptive_items.get(item_ref)
if item is not None:
value = item[1]
break
if property_name == TEXT_TO_CAD_GENERATOR_PROPERTY:
metadata["generator"] = value
elif property_name in {TEXT_TO_CAD_ENTRY_KIND_PROPERTY, "cadpy:entry_kind"}:
normalized_entry_kind = normalize_text_to_cad_entry_kind(value)
if normalized_entry_kind is not None:
metadata["entryKind"] = normalized_entry_kind
elif property_name == TEXT_TO_CAD_SOURCE_PATH_PROPERTY:
normalized_source_path = normalize_text_to_cad_source_path(value)
if normalized_source_path is not None:
metadata["sourcePath"] = normalized_source_path
elif property_name == TEXT_TO_CAD_SOURCE_HASH_PROPERTY:
metadata["sourceHash"] = value
return metadata
def _read_step_tail_text(step_path: Path, *, tail_bytes: int = TEXT_TO_CAD_METADATA_TAIL_BYTES) -> tuple[str, bool]:
payload, _offset, is_full_file = _read_step_tail_payload(step_path, tail_bytes=tail_bytes)
return payload.decode("utf-8", errors="ignore"), is_full_file
def read_text_to_cad_step_metadata(step_path: Path) -> dict[str, str]:
tail_text, is_full_file = _read_step_tail_text(step_path)
if (
TEXT_TO_CAD_GENERATOR_PROPERTY not in tail_text
and TEXT_TO_CAD_ENTRY_KIND_PROPERTY not in tail_text
and TEXT_TO_CAD_SOURCE_PATH_PROPERTY not in tail_text
and TEXT_TO_CAD_SOURCE_HASH_PROPERTY not in tail_text
):
return {}
metadata = read_text_to_cad_step_metadata_text(tail_text)
if metadata or is_full_file:
return metadata
return read_text_to_cad_step_metadata_text(step_path.read_text(encoding="utf-8"))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,483 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from cadpy.assembly_spec import REPO_ROOT, find_step_path, resolve_cad_source_path
from cadpy.cad_ref_syntax import normalize_cad_path, parse_cad_tokens
from cadpy.catalog import find_source_by_cad_ref
from cadpy.glb_topology import (
STEP_EDGE_BARYCENTRIC_ATTRIBUTE,
STEP_EDGE_CLASS_ATTRIBUTE,
STEP_EDGE_VISIBILITY_CLASSES,
STEP_TOPOLOGY_SCHEMA_VERSION,
glb_primitives_have_surface_edge_attributes,
glb_surface_edge_class_has_nonzero_values,
normalize_step_edge_render_visibility_classes,
read_step_display_edge_manifest_from_glb,
read_step_topology_bundle_from_glb,
read_step_topology_manifest_from_glb,
)
from cadpy.render import existing_part_glb_path, part_glb_path
from cadpy.selector_types import SelectorBundle
from cadpy.step_hash import step_file_hash
STEP_SUFFIXES = (".step", ".stp")
REGENERATE_STEP_COMMAND = "python scripts/step"
REGENERATE_STEP_PROMPT = "Regenerate STEP artifacts with the following command using the CAD skill:"
class CadRefError(RuntimeError):
pass
@dataclass(frozen=True)
class EntryTarget:
cad_path: str
selectors: tuple[str, ...] = ()
@property
def token(self) -> str:
from cadpy.cad_ref_syntax import build_cad_token
if not self.selectors:
return build_cad_token(self.cad_path)
return build_cad_token(self.cad_path, ",".join(self.selectors))
@dataclass(frozen=True)
class ResolvedStepTarget:
cad_path: str
kind: str
source_path: Path
step_path: Path
@dataclass(frozen=True)
class StepTopologyArtifact:
cad_path: str
kind: str
source_path: Path
step_path: Path
glb_path: Path
manifest: dict[str, object]
selector_bundle: SelectorBundle | None = None
class StepTopologyArtifactError(CadRefError):
def __init__(
self,
*,
code: str,
message: str,
cad_path: str,
step_path: Path,
glb_path: Path,
regenerate_command: str,
) -> None:
super().__init__(message)
self.code = code
self.cad_path = cad_path
self.step_path = step_path
self.glb_path = glb_path
self.regenerate_command = regenerate_command
def to_error(self) -> dict[str, object]:
return {
"code": self.code,
"message": str(self),
"cadPath": self.cad_path,
"stepPath": _relative_to_repo(self.step_path),
"glbPath": _relative_to_repo(self.glb_path),
"regenerateCommand": self.regenerate_command,
}
def cad_ref_error_payload(exc: CadRefError) -> dict[str, object]:
if isinstance(exc, StepTopologyArtifactError):
return exc.to_error()
return {"message": str(exc)}
def cad_path_from_target(target: str) -> str:
return entry_target_from_target(target).cad_path
def entry_target_from_target(target: str) -> EntryTarget:
parsed_tokens = parse_cad_tokens(target)
if parsed_tokens:
raise CadRefError("Selector refs require an explicit STEP target argument.")
raw_target = str(target or "").strip()
if _raw_step_path(raw_target) is not None:
normalized = normalize_cad_path(raw_target)
if normalized is not None:
return EntryTarget(normalized)
normalized = normalize_cad_path(target)
if normalized is None:
raise CadRefError(f"Invalid CAD entry target: {target}")
return EntryTarget(normalized)
def step_path_from_target(target: str) -> Path:
raw_step_path = _raw_step_path(str(target or "").strip())
if raw_step_path is not None:
return raw_step_path
entry_target = entry_target_from_target(target)
lookup_cad_path = _lookup_cad_path(entry_target.cad_path)
step_path = find_step_path(lookup_cad_path)
if step_path is not None:
return step_path
direct_step_path = _direct_step_path(entry_target.cad_path)
if direct_step_path is not None:
return direct_step_path
raise CadRefError(f"STEP file not found for target '{target}'.")
def resolve_step_target(target: str) -> ResolvedStepTarget:
entry_target = entry_target_from_target(target)
cad_path = entry_target.cad_path
raw_step_path = _raw_step_path(str(target or "").strip())
if raw_step_path is not None:
lookup_cad_path = _lookup_cad_path(cad_path)
source = find_source_by_cad_ref(lookup_cad_path)
resolved_step_path = source.step_path if source is not None else None
if source is not None and resolved_step_path is not None and resolved_step_path.resolve() == raw_step_path.resolve():
return ResolvedStepTarget(
cad_path=cad_path,
kind=source.kind,
source_path=source.source_path,
step_path=raw_step_path,
)
return ResolvedStepTarget(
cad_path=cad_path,
kind="part",
source_path=raw_step_path,
step_path=raw_step_path,
)
lookup_cad_path = _lookup_cad_path(cad_path)
source = find_source_by_cad_ref(lookup_cad_path)
if source is not None and source.kind in {"part", "assembly"}:
if source.step_path is None:
raise CadRefError(f"STEP file not found for ref '{cad_path}'.")
return ResolvedStepTarget(
cad_path=cad_path,
kind=source.kind,
source_path=source.source_path,
step_path=source.step_path.resolve(),
)
if source is not None:
raise CadRefError(f"CAD target '{cad_path}' is not STEP-backed.")
direct_step_path = _direct_step_path(cad_path)
if direct_step_path is not None:
return ResolvedStepTarget(
cad_path=cad_path,
kind="part",
source_path=direct_step_path,
step_path=direct_step_path,
)
raise CadRefError(f"CAD STEP ref not found for '{cad_path}'.")
def validate_step_topology_artifact(
target: ResolvedStepTarget,
*,
glb_path: Path | None = None,
require_selector: bool = False,
) -> StepTopologyArtifact:
resolved_glb_path = glb_path or existing_part_glb_path(target.step_path) or part_glb_path(target.step_path)
if not resolved_glb_path.is_file():
raise _topology_artifact_error(
code="missing_glb",
reason="STEP topology validation requires the generated GLB artifact, but it is missing",
cad_path=target.cad_path,
kind=target.kind,
source_path=target.source_path,
step_path=target.step_path,
glb_path=resolved_glb_path,
)
manifest = read_step_topology_manifest_from_glb(resolved_glb_path)
if manifest is None:
raise _topology_artifact_error(
code="missing_step_topology",
reason="STEP topology validation requires readable STEP_topology indexView in the GLB",
cad_path=target.cad_path,
kind=target.kind,
source_path=target.source_path,
step_path=target.step_path,
glb_path=resolved_glb_path,
)
try:
schema_version = int(manifest.get("schemaVersion") or 0)
except (TypeError, ValueError):
schema_version = 0
if schema_version != STEP_TOPOLOGY_SCHEMA_VERSION:
raise _topology_artifact_error(
code="unsupported_step_topology",
reason=f"STEP topology validation requires STEP_topology schemaVersion {STEP_TOPOLOGY_SCHEMA_VERSION} in the GLB",
cad_path=target.cad_path,
kind=target.kind,
source_path=target.source_path,
step_path=target.step_path,
glb_path=resolved_glb_path,
)
source_kind = str(manifest.get("sourceKind") or "step").strip().lower()
manifest_source_path = _source_path_from_manifest(manifest, glb_path=resolved_glb_path)
if manifest_source_path is None:
raise _topology_artifact_error(
code="missing_source_path",
reason="GLB STEP_topology is missing required sourcePath identity",
cad_path=target.cad_path,
kind=target.kind,
source_path=target.source_path,
step_path=target.step_path,
glb_path=resolved_glb_path,
)
if source_kind == "python":
if manifest_source_path.suffix.lower() != ".py":
raise _topology_artifact_error(
code="missing_source_path",
reason="GLB STEP_topology Python sourcePath must point at a Python generator",
cad_path=target.cad_path,
kind=target.kind,
source_path=target.source_path,
step_path=target.step_path,
glb_path=resolved_glb_path,
)
step_hash = str(manifest.get("stepHash") or "").strip()
if target.step_path.is_file():
if not step_hash:
raise _topology_artifact_error(
code="missing_step_hash",
reason="GLB STEP_topology is missing STEP file identity",
cad_path=target.cad_path,
kind=target.kind,
source_path=target.source_path,
step_path=target.step_path,
glb_path=resolved_glb_path,
)
current_step_hash = step_file_hash(target.step_path)
if step_hash != current_step_hash:
raise _topology_artifact_error(
code="stale_step_artifact",
reason="Generated GLB doesn't match the hash of the STEP file",
cad_path=target.cad_path,
kind=target.kind,
source_path=target.source_path,
step_path=target.step_path,
glb_path=resolved_glb_path,
)
edge_manifest = read_step_display_edge_manifest_from_glb(resolved_glb_path)
if edge_manifest is None:
raise _topology_artifact_error(
code="missing_edge_topology",
reason="STEP topology validation requires readable STEP_topology edgeView in the GLB",
cad_path=target.cad_path,
kind=target.kind,
source_path=target.source_path,
step_path=target.step_path,
glb_path=resolved_glb_path,
)
try:
edge_schema_version = int(edge_manifest.get("schemaVersion") or 0)
except (TypeError, ValueError):
edge_schema_version = 0
primitive_attributes = edge_manifest.get("primitiveAttributes")
edge_rendering = edge_manifest.get("edgeRendering")
index_edge_rendering = manifest.get("edgeRendering")
edge_visibility_classes = (
normalize_step_edge_render_visibility_classes(edge_rendering.get("visibilityClasses"))
if isinstance(edge_rendering, dict)
else ()
)
index_edge_visibility_classes = (
normalize_step_edge_render_visibility_classes(index_edge_rendering.get("visibilityClasses"))
if isinstance(index_edge_rendering, dict)
else ()
)
edge_matches_source = (
not step_hash
or str(edge_manifest.get("stepHash") or "").strip() == step_hash
)
if (
edge_schema_version != STEP_TOPOLOGY_SCHEMA_VERSION
or edge_manifest.get("profile") != "surface-edges"
or str(edge_manifest.get("sourcePath") or "").strip() != str(manifest.get("sourcePath") or "").strip()
or not edge_matches_source
or not edge_visibility_classes
or edge_visibility_classes != index_edge_visibility_classes
or STEP_EDGE_VISIBILITY_CLASSES["FEATURE"] not in edge_visibility_classes
or not isinstance(edge_manifest.get("buffers"), dict)
or not isinstance(edge_manifest.get("buffers", {}).get("views"), dict)
or "surfaceHalfEdges" not in edge_manifest.get("buffers", {}).get("views", {})
):
raise _topology_artifact_error(
code="missing_edge_topology",
reason=f"STEP topology validation requires STEP_topology edgeView schemaVersion {STEP_TOPOLOGY_SCHEMA_VERSION} in the GLB",
cad_path=target.cad_path,
kind=target.kind,
source_path=target.source_path,
step_path=target.step_path,
glb_path=resolved_glb_path,
)
if not (
isinstance(primitive_attributes, dict)
and primitive_attributes.get("barycentric") == STEP_EDGE_BARYCENTRIC_ATTRIBUTE
and primitive_attributes.get("class") == STEP_EDGE_CLASS_ATTRIBUTE
and glb_primitives_have_surface_edge_attributes(resolved_glb_path)
):
raise _topology_artifact_error(
code="missing_surface_edge_attributes",
reason=f"STEP topology validation requires {STEP_EDGE_BARYCENTRIC_ATTRIBUTE} and {STEP_EDGE_CLASS_ATTRIBUTE} on STEP mesh primitives",
cad_path=target.cad_path,
kind=target.kind,
source_path=target.source_path,
step_path=target.step_path,
glb_path=resolved_glb_path,
)
edge_stats = edge_manifest.get("stats")
surface_half_edge_count = (
int(edge_stats.get("surfaceHalfEdgeCount") or 0)
if isinstance(edge_stats, dict)
else 0
)
generated_counts = (
edge_rendering.get("generatedVisibilityClassCounts")
if isinstance(edge_rendering, dict) and isinstance(edge_rendering.get("generatedVisibilityClassCounts"), dict)
else {}
)
expects_surface_edge_classes = surface_half_edge_count > 0 or any(
int(value or 0) > 0 for value in generated_counts.values()
)
if expects_surface_edge_classes and not glb_surface_edge_class_has_nonzero_values(resolved_glb_path):
raise _topology_artifact_error(
code="missing_surface_edge_attributes",
reason=f"STEP topology validation requires nonzero {STEP_EDGE_CLASS_ATTRIBUTE} values for generated surface edges",
cad_path=target.cad_path,
kind=target.kind,
source_path=target.source_path,
step_path=target.step_path,
glb_path=resolved_glb_path,
)
selector_bundle = None
if require_selector:
selector_bundle = read_step_topology_bundle_from_glb(resolved_glb_path)
if selector_bundle is None:
raise _topology_artifact_error(
code="missing_selector_topology",
reason="STEP topology validation requires readable STEP_topology selectorView in the GLB",
cad_path=target.cad_path,
kind=target.kind,
source_path=target.source_path,
step_path=target.step_path,
glb_path=resolved_glb_path,
)
return StepTopologyArtifact(
cad_path=target.cad_path,
kind=target.kind,
source_path=target.source_path,
step_path=target.step_path,
glb_path=resolved_glb_path,
manifest=manifest,
selector_bundle=selector_bundle,
)
def _direct_step_path(cad_path: str) -> Path | None:
for suffix in STEP_SUFFIXES:
candidate = (REPO_ROOT / f"{cad_path}{suffix}").resolve()
if candidate.is_file():
return candidate
return None
def _raw_step_path(target: str) -> Path | None:
if not target:
return None
path = Path(target).expanduser()
if path.suffix.lower() not in STEP_SUFFIXES:
return None
resolved = path.resolve() if path.is_absolute() else (REPO_ROOT / path).resolve()
return resolved if resolved.is_file() else None
def _source_path_from_manifest(manifest: dict[str, object] | None, *, glb_path: Path) -> Path | None:
raw_path = str((manifest or {}).get("sourcePath") or "").strip()
if not raw_path:
return None
return _resolved_manifest_path(raw_path, base_dir=glb_path.parent)
def _resolved_manifest_path(raw_path: str, *, base_dir: Path) -> Path | None:
if not raw_path:
return None
candidates = (
_resolve_manifest_path_from_base(raw_path, base_dir),
_resolve_manifest_path_from_base(raw_path, REPO_ROOT),
)
existing = next((candidate for candidate in candidates if candidate is not None and candidate.is_file()), None)
if existing is not None:
return existing
return next((candidate for candidate in candidates if candidate is not None), None)
def _resolve_manifest_path_from_base(raw_path: str, base_dir: Path) -> Path | None:
path = Path(str(raw_path).replace("\\", "/"))
resolved = path.resolve() if path.is_absolute() else (base_dir / path).resolve()
try:
resolved.relative_to(REPO_ROOT)
except ValueError:
return None
return resolved
def _topology_artifact_error(
*,
code: str,
reason: str,
cad_path: str,
kind: str,
source_path: Path,
step_path: Path,
glb_path: Path,
) -> StepTopologyArtifactError:
return StepTopologyArtifactError(
code=code,
cad_path=cad_path,
step_path=step_path,
glb_path=glb_path,
regenerate_command=REGENERATE_STEP_COMMAND,
message=(
f"{reason}: {_relative_to_repo(glb_path)}.\n"
f"{REGENERATE_STEP_PROMPT}"
),
)
def _cad_path_lookup_candidates(cad_path: str) -> tuple[str, ...]:
return (cad_path,) if cad_path else ()
def _lookup_cad_path(cad_path: str) -> str:
for candidate in _cad_path_lookup_candidates(cad_path):
if resolve_cad_source_path(candidate) is not None:
return candidate
return cad_path
def _relative_to_repo(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(REPO_ROOT).as_posix()
except ValueError:
return resolved.as_posix()
@@ -0,0 +1,33 @@
from __future__ import annotations
from pathlib import Path
from OCP.StlAPI import StlAPI_Writer
from cadpy.render import REPO_ROOT, part_stl_path
from cadpy.step_scene import (
LoadedStepScene,
scene_export_shape,
)
def _display_path(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(REPO_ROOT).as_posix()
except ValueError:
return resolved.as_posix()
def export_part_stl_from_scene(step_path: Path, scene: LoadedStepScene, *, target_path: Path | None = None) -> Path:
target_path = target_path or part_stl_path(step_path)
export_shape_stl(scene_export_shape(scene), target_path)
return target_path
def export_shape_stl(shape: object, target_path: Path) -> Path:
target_path.parent.mkdir(parents=True, exist_ok=True)
writer = StlAPI_Writer()
writer.ASCIIMode = False
if not writer.Write(shape, str(target_path)):
raise RuntimeError(f"Failed to write STL output: {_display_path(target_path)}")
return target_path
@@ -0,0 +1,565 @@
from __future__ import annotations
import math
import zipfile
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from xml.etree import ElementTree as ET
from OCP.BRep import BRep_Tool
from OCP.TopAbs import TopAbs_FACE, TopAbs_REVERSED
from OCP.TopExp import TopExp_Explorer
from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import TopoDS
from cadpy.render import REPO_ROOT, part_3mf_path
from cadpy.step_scene import ColorRGBA, LoadedStepScene, OccurrenceNode, _shape_hash, occurrence_selector_id
CORE_NS = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
RELATIONSHIPS_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
MODEL_REL_TYPE = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"
DEFAULT_MATERIAL: ColorRGBA = (0.4677837961, 0.5520114015, 0.6172065624, 1.0)
MATERIAL_RESOURCE_ID = "1"
ET.register_namespace("", CORE_NS)
@dataclass(frozen=True)
class _FaceMesh:
face_hash: int
vertices: list[tuple[float, float, float]]
triangles: list[tuple[int, int, int]]
class _MaterialRegistry:
def __init__(self, default_color: ColorRGBA = DEFAULT_MATERIAL) -> None:
self._materials: list[tuple[str, ColorRGBA]] = []
self._indices: dict[tuple[int, int, int, int], int] = {}
self._default_color = _normalize_rgba(default_color)
@staticmethod
def color_key(color: ColorRGBA) -> tuple[int, int, int, int]:
red, green, blue, alpha = _normalize_rgba(color)
return (
_srgb_byte_from_linear(red),
_srgb_byte_from_linear(green),
_srgb_byte_from_linear(blue),
_byte_from_unit_channel(alpha),
)
def index(self, color: ColorRGBA, *, name: str | None = None) -> int:
normalized = _normalize_rgba(color)
key = self.color_key(normalized)
existing = self._indices.get(key)
if existing is not None:
return existing
index = len(self._materials)
self._indices[key] = index
default_key = self.color_key(self._default_color)
material_name = name or (
"Default" if key == default_key else f"Color {key[0]:02X}{key[1]:02X}{key[2]:02X}{key[3]:02X}"
)
self._materials.append((material_name, normalized))
return index
def append_xml(self, resources: ET.Element) -> None:
base_materials = ET.SubElement(resources, f"{{{CORE_NS}}}basematerials", {"id": MATERIAL_RESOURCE_ID})
for name, color in self._materials:
ET.SubElement(
base_materials,
f"{{{CORE_NS}}}base",
{
"name": name,
"displaycolor": _display_color(color),
},
)
def _display_path(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(REPO_ROOT).as_posix()
except ValueError:
return resolved.as_posix()
def export_part_3mf_from_scene(
step_path: Path,
scene: LoadedStepScene,
*,
target_path: Path | None = None,
color: ColorRGBA | None = None,
occurrence_colors: Mapping[str, ColorRGBA] | None = None,
) -> Path:
target_path = target_path or part_3mf_path(step_path)
export_scene_3mf(scene, target_path, color=color, occurrence_colors=occurrence_colors)
return target_path
def _normalize_rgba(color: ColorRGBA | tuple[float, float, float] | None) -> ColorRGBA:
if color is None:
return DEFAULT_MATERIAL
if len(color) == 3:
red, green, blue = color
alpha = 1.0
else:
red, green, blue, alpha = color
return (
max(0.0, min(1.0, float(red))),
max(0.0, min(1.0, float(green))),
max(0.0, min(1.0, float(blue))),
max(0.0, min(1.0, float(alpha))),
)
def _byte_from_unit_channel(channel: float) -> int:
return max(0, min(255, int(round(channel * 255.0))))
def _srgb_byte_from_linear(channel: float) -> int:
if channel <= 0.0031308:
encoded = 12.92 * channel
else:
encoded = 1.055 * math.pow(channel, 1.0 / 2.4) - 0.055
return _byte_from_unit_channel(encoded)
def _display_color(color: ColorRGBA) -> str:
red, green, blue, alpha = _MaterialRegistry.color_key(color)
return f"#{red:02X}{green:02X}{blue:02X}{alpha:02X}"
def _point_from_occ(point: object, location: TopLoc_Location) -> tuple[float, float, float]:
transformed = point.Transformed(location.Transformation())
return (float(transformed.X()), float(transformed.Y()), float(transformed.Z()))
def _triangle_area_twice(a: tuple[float, float, float], b: tuple[float, float, float], c: tuple[float, float, float]) -> float:
abx = b[0] - a[0]
aby = b[1] - a[1]
abz = b[2] - a[2]
acx = c[0] - a[0]
acy = c[1] - a[1]
acz = c[2] - a[2]
cross_x = aby * acz - abz * acy
cross_y = abz * acx - abx * acz
cross_z = abx * acy - aby * acx
return math.sqrt(cross_x * cross_x + cross_y * cross_y + cross_z * cross_z)
def _format_number(value: float) -> str:
if not math.isfinite(value):
raise ValueError(f"3MF mesh coordinate must be finite, got {value!r}")
return f"{value:.9g}"
def _iter_shape_face_meshes(shape: object):
explorer = TopExp_Explorer(shape, TopAbs_FACE)
while explorer.More():
face = TopoDS.Face_s(explorer.Current())
location = TopLoc_Location()
triangulation = BRep_Tool.Triangulation_s(face, location)
if triangulation is not None:
nodes = [
_point_from_occ(triangulation.Node(index), location)
for index in range(1, triangulation.NbNodes() + 1)
]
triangles: list[tuple[int, int, int]] = []
for index in range(1, triangulation.NbTriangles() + 1):
node_a, node_b, node_c = triangulation.Triangle(index).Get()
vertex_indices = [node_a - 1, node_b - 1, node_c - 1]
if face.Orientation() == TopAbs_REVERSED:
vertex_indices[1], vertex_indices[2] = vertex_indices[2], vertex_indices[1]
vertices = [nodes[node_index] for node_index in vertex_indices]
if _triangle_area_twice(vertices[0], vertices[1], vertices[2]) > 1e-12:
triangles.append(tuple(vertex_indices))
if triangles:
yield _FaceMesh(_shape_hash(face), nodes, triangles)
explorer.Next()
def _object_name(name: str | None, fallback: str) -> str:
normalized = " ".join((name or "").split())
return normalized or fallback
def _identity_transform() -> tuple[float, ...]:
return (
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
)
def _is_identity_transform(transform: tuple[float, ...] | None) -> bool:
if transform is None or len(transform) != 16:
return True
identity = _identity_transform()
return all(abs(float(actual) - expected) <= 1e-12 for actual, expected in zip(transform, identity))
def _format_transform(transform: tuple[float, ...] | None) -> str | None:
if _is_identity_transform(transform):
return None
if transform is None or len(transform) != 16:
raise ValueError("3MF component transform must be a 4x4 matrix")
values = (
transform[0],
transform[4],
transform[8],
transform[1],
transform[5],
transform[9],
transform[2],
transform[6],
transform[10],
transform[3],
transform[7],
transform[11],
)
return " ".join(_format_number(value) for value in values)
def _append_mesh_object(
resources: ET.Element,
*,
object_id: int,
name: str,
shape: object,
materials: _MaterialRegistry,
default_color: ColorRGBA,
face_colors: dict[int, ColorRGBA],
) -> None:
face_meshes = list(_iter_shape_face_meshes(shape))
if not face_meshes:
raise RuntimeError(f"Cannot write empty 3MF mesh object: {name}")
triangle_materials: list[int] = []
for face_mesh in face_meshes:
triangle_materials.extend(
[materials.index(face_colors.get(face_mesh.face_hash, default_color))] * len(face_mesh.triangles)
)
unique_materials = set(triangle_materials)
object_attributes = {
"id": str(object_id),
"type": "model",
"name": name,
}
if len(unique_materials) == 1:
object_attributes["pid"] = MATERIAL_RESOURCE_ID
object_attributes["pindex"] = str(next(iter(unique_materials)))
model_object = ET.SubElement(resources, f"{{{CORE_NS}}}object", object_attributes)
mesh = ET.SubElement(model_object, f"{{{CORE_NS}}}mesh")
vertices_element = ET.SubElement(mesh, f"{{{CORE_NS}}}vertices")
triangles_element = ET.SubElement(mesh, f"{{{CORE_NS}}}triangles")
vertex_index = 0
material_index = 0
for face_mesh in face_meshes:
for x, y, z in face_mesh.vertices:
ET.SubElement(
vertices_element,
f"{{{CORE_NS}}}vertex",
{
"x": _format_number(x),
"y": _format_number(y),
"z": _format_number(z),
},
)
for a, b, c in face_mesh.triangles:
triangle_attributes = {
"v1": str(vertex_index + a),
"v2": str(vertex_index + b),
"v3": str(vertex_index + c),
}
if len(unique_materials) > 1:
pindex = str(triangle_materials[material_index])
triangle_attributes.update(
{
"pid": MATERIAL_RESOURCE_ID,
"p1": pindex,
"p2": pindex,
"p3": pindex,
}
)
ET.SubElement(triangles_element, f"{{{CORE_NS}}}triangle", triangle_attributes)
material_index += 1
vertex_index += len(face_mesh.vertices)
def _append_component_object(
resources: ET.Element,
*,
object_id: int,
name: str,
components: list[tuple[int, tuple[float, ...] | None]],
) -> None:
model_object = ET.SubElement(
resources,
f"{{{CORE_NS}}}object",
{
"id": str(object_id),
"type": "model",
"name": name,
},
)
components_element = ET.SubElement(model_object, f"{{{CORE_NS}}}components")
for component_object_id, transform in components:
attributes = {"objectid": str(component_object_id)}
transform_value = _format_transform(transform)
if transform_value is not None:
attributes["transform"] = transform_value
ET.SubElement(components_element, f"{{{CORE_NS}}}component", attributes)
class _SceneObjectWriter:
def __init__(
self,
scene: LoadedStepScene,
resources: ET.Element,
materials: _MaterialRegistry,
*,
source_color: ColorRGBA | None,
occurrence_colors: Mapping[str, ColorRGBA] | None,
) -> None:
self.scene = scene
self.resources = resources
self.materials = materials
self.source_color = _normalize_rgba(source_color) if source_color is not None else None
self.occurrence_colors = {
str(key): _normalize_rgba(value)
for key, value in (occurrence_colors or {}).items()
}
self.next_object_id = 2
self.mesh_objects: dict[tuple[int, tuple[int, int, int, int]], int] = {}
def _allocate_object_id(self) -> int:
object_id = self.next_object_id
self.next_object_id += 1
return object_id
def _occurrence_color_for_node(self, node: OccurrenceNode) -> ColorRGBA | None:
occurrence_id = occurrence_selector_id(node)
while occurrence_id:
color = self.occurrence_colors.get(occurrence_id)
if color is not None:
return color
if "." not in occurrence_id:
return None
occurrence_id = occurrence_id.rsplit(".", 1)[0]
return None
def _default_color_for_node(self, node: OccurrenceNode) -> ColorRGBA:
occurrence_color = self._occurrence_color_for_node(node)
if occurrence_color is not None:
return occurrence_color
if node.color is not None:
return _normalize_rgba(node.color)
if node.prototype_key is not None and node.prototype_key in self.scene.prototype_colors:
return _normalize_rgba(self.scene.prototype_colors[node.prototype_key])
if self.source_color is not None:
return self.source_color
return DEFAULT_MATERIAL
def _mesh_object_for_node(self, node: OccurrenceNode) -> int:
if node.prototype_key is None:
raise RuntimeError(f"3MF leaf object has no prototype: {node.name or node.source_name or node.path}")
shape = self.scene.prototype_shapes.get(node.prototype_key)
if shape is None:
raise RuntimeError(f"3MF leaf object has no prototype shape: {node.prototype_key}")
default_color = self._default_color_for_node(node)
object_key = (node.prototype_key, _MaterialRegistry.color_key(default_color))
existing = self.mesh_objects.get(object_key)
if existing is not None:
return existing
object_id = self._allocate_object_id()
self.mesh_objects[object_key] = object_id
name = _object_name(
node.source_name or self.scene.prototype_names.get(node.prototype_key) or node.name,
f"mesh-{object_id}",
)
face_colors = self.scene.prototype_face_colors.get(node.prototype_key, {})
_append_mesh_object(
self.resources,
object_id=object_id,
name=name,
shape=shape,
materials=self.materials,
default_color=default_color,
face_colors=face_colors,
)
return object_id
def object_for_node(self, node: OccurrenceNode) -> int:
if not node.children:
return self._mesh_object_for_node(node)
components: list[tuple[int, tuple[float, ...] | None]] = []
for child in node.children:
components.append((self.object_for_node(child), child.local_transform))
object_id = self._allocate_object_id()
_append_component_object(
self.resources,
object_id=object_id,
name=_object_name(node.name or node.source_name, f"component-{object_id}"),
components=components,
)
return object_id
def _build_model_root() -> tuple[ET.Element, ET.Element, ET.Element]:
model = ET.Element(
f"{{{CORE_NS}}}model",
{
"unit": "millimeter",
"{http://www.w3.org/XML/1998/namespace}lang": "en-US",
},
)
metadata = ET.SubElement(model, f"{{{CORE_NS}}}metadata", {"name": "Application"})
metadata.text = "tom-cad"
resources = ET.SubElement(model, f"{{{CORE_NS}}}resources")
build = ET.SubElement(model, f"{{{CORE_NS}}}build")
return model, resources, build
def _build_shape_model_xml(shape: object, *, color: ColorRGBA | None = None) -> bytes:
model, resources, build = _build_model_root()
materials = _MaterialRegistry(_normalize_rgba(color))
_append_mesh_object(
resources,
object_id=2,
name="Model",
shape=shape,
materials=materials,
default_color=_normalize_rgba(color),
face_colors={},
)
objects = [child for child in list(resources) if child.tag == f"{{{CORE_NS}}}object"]
for child in objects:
resources.remove(child)
materials.append_xml(resources)
for child in objects:
resources.append(child)
ET.SubElement(build, f"{{{CORE_NS}}}item", {"objectid": "2"})
return ET.tostring(model, encoding="utf-8", xml_declaration=True)
def _build_scene_model_xml(
scene: LoadedStepScene,
*,
color: ColorRGBA | None = None,
occurrence_colors: Mapping[str, ColorRGBA] | None = None,
) -> bytes:
if not scene.roots:
raise RuntimeError(f"No CAD geometry available for 3MF export: {scene.step_path}")
model, resources, build = _build_model_root()
materials = _MaterialRegistry(_normalize_rgba(color))
writer = _SceneObjectWriter(scene, resources, materials, source_color=color, occurrence_colors=occurrence_colors)
root_components = [(writer.object_for_node(root), root.local_transform) for root in scene.roots]
if len(root_components) == 1:
root_object_id, root_transform = root_components[0]
attributes = {"objectid": str(root_object_id)}
transform = _format_transform(root_transform)
if transform is not None:
attributes["transform"] = transform
ET.SubElement(build, f"{{{CORE_NS}}}item", attributes)
else:
root_object_id = writer._allocate_object_id()
_append_component_object(
resources,
object_id=root_object_id,
name=_object_name(scene.step_path.stem, f"component-{root_object_id}"),
components=root_components,
)
ET.SubElement(build, f"{{{CORE_NS}}}item", {"objectid": str(root_object_id)})
# 3MF resources can be referenced before they appear in the XML, but keeping
# materials first makes the package easier to inspect and friendlier to parsers.
objects = [child for child in list(resources) if child.tag == f"{{{CORE_NS}}}object"]
for child in objects:
resources.remove(child)
materials.append_xml(resources)
for child in objects:
resources.append(child)
return ET.tostring(model, encoding="utf-8", xml_declaration=True)
def _content_types_xml() -> bytes:
types = ET.Element(f"{{{CONTENT_TYPES_NS}}}Types")
ET.SubElement(
types,
f"{{{CONTENT_TYPES_NS}}}Default",
{
"Extension": "rels",
"ContentType": "application/vnd.openxmlformats-package.relationships+xml",
},
)
ET.SubElement(
types,
f"{{{CONTENT_TYPES_NS}}}Default",
{
"Extension": "model",
"ContentType": "application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
},
)
return ET.tostring(types, encoding="utf-8", xml_declaration=True)
def _relationships_xml() -> bytes:
relationships = ET.Element(f"{{{RELATIONSHIPS_NS}}}Relationships")
ET.SubElement(
relationships,
f"{{{RELATIONSHIPS_NS}}}Relationship",
{
"Target": "/3D/3dmodel.model",
"Id": "rel0",
"Type": MODEL_REL_TYPE,
},
)
return ET.tostring(relationships, encoding="utf-8", xml_declaration=True)
def _write_package(target_path: Path, model_xml: bytes) -> Path:
target_path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(target_path, "w", compression=zipfile.ZIP_DEFLATED) as package:
package.writestr("[Content_Types].xml", _content_types_xml())
package.writestr("_rels/.rels", _relationships_xml())
package.writestr("3D/3dmodel.model", model_xml)
if not target_path.exists() or target_path.stat().st_size <= 0:
raise RuntimeError(f"Failed to write 3MF output: {_display_path(target_path)}")
return target_path
def export_scene_3mf(
scene: LoadedStepScene,
target_path: Path,
*,
color: ColorRGBA | None = None,
occurrence_colors: Mapping[str, ColorRGBA] | None = None,
) -> Path:
return _write_package(target_path, _build_scene_model_xml(scene, color=color, occurrence_colors=occurrence_colors))
def export_shape_3mf(shape: object, target_path: Path) -> Path:
return _write_package(target_path, _build_shape_model_xml(shape))
@@ -0,0 +1,119 @@
from __future__ import annotations
from typing import Any
from cadpy.analysis import AXIS_INDEX, bbox_center, bbox_size, major_planar_face_groups
from cadpy.lookup import build_selector_index
def axis_index(axis: str) -> int:
normalized = str(axis or "").strip().lower()
if normalized not in AXIS_INDEX:
raise ValueError(f"Axis must be one of x, y, z; got {axis!r}")
return int(AXIS_INDEX[normalized])
def _coerce_bbox(bbox: object) -> dict[str, object]:
if not isinstance(bbox, dict):
raise ValueError("Expected bbox dict with min/max coordinates.")
if not isinstance(bbox.get("min"), list) or not isinstance(bbox.get("max"), list):
raise ValueError("BBox dict must include min/max coordinate lists.")
return bbox
def bbox_coordinate(bbox: object, axis: str, side: str) -> float:
normalized_bbox = _coerce_bbox(bbox)
axis_idx = axis_index(axis)
normalized_side = str(side or "").strip().lower()
if normalized_side not in {"min", "max"}:
raise ValueError(f"Side must be 'min' or 'max'; got {side!r}")
return float(normalized_bbox[normalized_side][axis_idx])
def bbox_span(bbox: object, axis: str) -> float:
size = bbox_size(_coerce_bbox(bbox))
if size is None:
raise ValueError("Failed to compute bbox span.")
return float(size[axis_index(axis)])
def assert_close(actual: float, expected: float, *, tol: float = 1e-6, label: str = "value") -> float:
actual_value = float(actual)
expected_value = float(expected)
if abs(actual_value - expected_value) > float(tol):
raise AssertionError(
f"{label} mismatch: expected {expected_value:.6f}, got {actual_value:.6f} (tol={float(tol):.6f})"
)
return actual_value
def assert_bbox_coordinate(
bbox: object,
axis: str,
side: str,
expected: float,
*,
tol: float = 1e-6,
label: str | None = None,
) -> float:
actual = bbox_coordinate(bbox, axis, side)
return assert_close(actual, expected, tol=tol, label=label or f"bbox {side} {axis}")
def assert_bbox_span(
bbox: object,
axis: str,
expected: float,
*,
tol: float = 1e-6,
label: str | None = None,
) -> float:
actual = bbox_span(bbox, axis)
return assert_close(actual, expected, tol=tol, label=label or f"bbox span {axis}")
def selector_count(manifest: dict[str, Any], selector_type: str) -> int:
stats = manifest.get("stats")
if not isinstance(stats, dict):
return 0
if selector_type == "shape":
return int(stats.get("shapeCount") or 0)
if selector_type == "face":
return int(stats.get("faceCount") or 0)
if selector_type == "edge":
return int(stats.get("edgeCount") or 0)
if selector_type == "occurrence":
return int(stats.get("occurrenceCount") or 0)
raise ValueError(f"Unsupported selector_type {selector_type!r}")
def assert_selector_count(
manifest: dict[str, Any],
selector_type: str,
expected: int,
*,
label: str | None = None,
) -> int:
actual = selector_count(manifest, selector_type)
if actual != int(expected):
raise AssertionError(f"{label or selector_type} count mismatch: expected {int(expected)}, got {actual}")
return actual
def geometry_summary_from_manifest(manifest: dict[str, Any]) -> dict[str, object]:
bbox = _coerce_bbox(manifest.get("bbox"))
summary: dict[str, object] = {
"bbox": bbox,
"center": bbox_center(bbox),
"size": bbox_size(bbox),
"occurrenceCount": selector_count(manifest, "occurrence"),
"shapeCount": selector_count(manifest, "shape"),
"faceCount": selector_count(manifest, "face"),
"edgeCount": selector_count(manifest, "edge"),
}
try:
index = build_selector_index(manifest)
except Exception:
return summary
summary["majorPlanes"] = major_planar_face_groups(index)
return summary