first commit
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user