439 lines
19 KiB
Python
439 lines
19 KiB
Python
"""Deterministic, CPU-only CAD technical render bundles.
|
|
|
|
OpenCascade computes exact visible/hidden edges from the revision STEP file.
|
|
Pillow rasterizes the resulting technical drawings. Neither stage needs a web
|
|
browser, OpenGL, a desktop session, nor a GPU, which keeps published artifacts
|
|
consistent on macOS, Linux, and Windows workers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import json
|
|
import math
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from app.settings import Settings
|
|
|
|
|
|
CANONICAL_VIEWS = ("top", "bottom", "front", "back", "left", "right", "isometric")
|
|
RENDER_SIZE = 2048
|
|
REVIEW_SIZE = 1024
|
|
FRAME_PADDING = 0.14
|
|
BACKGROUND_RGB = (246, 248, 251)
|
|
VISIBLE_EDGE_RGB = (34, 54, 69)
|
|
HIDDEN_EDGE_RGB = (142, 157, 170)
|
|
|
|
|
|
class RenderBundleError(RuntimeError):
|
|
"""The fixed-view renderer was unavailable or produced incomplete evidence."""
|
|
|
|
|
|
def renderer_status() -> tuple[bool, str]:
|
|
"""Verify that the pure-Python/OCC renderer dependencies are importable."""
|
|
try:
|
|
_render_modules()
|
|
except RenderBundleError as error:
|
|
return False, str(error)
|
|
return True, ""
|
|
|
|
|
|
def _render_modules() -> tuple[Any, Any, Any]:
|
|
try:
|
|
pillow_image = importlib.import_module("PIL.Image")
|
|
pillow_draw = importlib.import_module("PIL.ImageDraw")
|
|
import_step = importlib.import_module("build123d").import_step
|
|
except (ImportError, AttributeError) as error:
|
|
raise RenderBundleError(
|
|
"Python technical renderer is unavailable; install backend requirements (build123d and Pillow)"
|
|
) from error
|
|
return pillow_image, pillow_draw, import_step
|
|
|
|
|
|
def _number_list(value: Any, *, size: int) -> list[float] | None:
|
|
if not isinstance(value, list) or len(value) < size:
|
|
return None
|
|
try:
|
|
values = [float(item) for item in value[:size]]
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return values if all(math.isfinite(item) for item in values) else None
|
|
|
|
|
|
def _bounds_center(bounds: list[float]) -> list[float]:
|
|
return [
|
|
(bounds[0] + bounds[1]) / 2,
|
|
(bounds[2] + bounds[3]) / 2,
|
|
(bounds[4] + bounds[5]) / 2,
|
|
]
|
|
|
|
|
|
def _shape_bounds(shape: Any) -> list[float]:
|
|
box = shape.bounding_box()
|
|
bounds = [float(box.min.X), float(box.max.X), float(box.min.Y), float(box.max.Y), float(box.min.Z), float(box.max.Z)]
|
|
if not all(math.isfinite(value) for value in bounds):
|
|
raise RenderBundleError("STEP render source has invalid bounds")
|
|
return bounds
|
|
|
|
|
|
def _target_frame(target: dict[str, Any] | None, model_bounds: list[float]) -> tuple[list[float], float]:
|
|
model_center = _bounds_center(model_bounds)
|
|
model_extent = max(model_bounds[1] - model_bounds[0], model_bounds[3] - model_bounds[2], model_bounds[5] - model_bounds[4], 1.0)
|
|
if not isinstance(target, dict):
|
|
return model_center, 0.0
|
|
bbox = _number_list(target.get("bbox_mm"), size=6)
|
|
if bbox and bbox[3] > bbox[0] and bbox[4] > bbox[1] and bbox[5] > bbox[2]:
|
|
center = [(bbox[0] + bbox[3]) / 2, (bbox[1] + bbox[4]) / 2, (bbox[2] + bbox[5]) / 2]
|
|
extent = max(bbox[3] - bbox[0], bbox[4] - bbox[1], bbox[5] - bbox[2], 1.0)
|
|
return center, min(model_extent, extent * 1.6)
|
|
center = _number_list(target.get("center_mm"), size=3)
|
|
try:
|
|
radius = float(target.get("radius_mm"))
|
|
except (TypeError, ValueError):
|
|
radius = 0.0
|
|
if center and math.isfinite(radius) and radius > 0:
|
|
return center, min(model_extent, max(radius * 2, 1.0) * 1.6)
|
|
return model_center, 0.0
|
|
|
|
|
|
def _camera_for(view_id: str, center: list[float]) -> dict[str, Any]:
|
|
directions = {
|
|
"top": ([0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
|
|
"bottom": ([0.0, 0.0, -1.0], [0.0, 1.0, 0.0]),
|
|
"front": ([0.0, -1.0, 0.0], [0.0, 0.0, 1.0]),
|
|
"back": ([0.0, 1.0, 0.0], [0.0, 0.0, 1.0]),
|
|
"left": ([-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
|
|
"right": ([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
|
|
"isometric": ([1.0, -1.0, 0.8], [0.0, 0.0, 1.0]),
|
|
}
|
|
direction, view_up = directions.get(view_id, directions["isometric"])
|
|
length = math.sqrt(sum(item * item for item in direction)) or 1.0
|
|
normal = [item / length for item in direction]
|
|
# Orthographic HLR ignores the distance, but a large deterministic value
|
|
# makes the intended camera convention explicit in the manifest.
|
|
position = [center[index] + normal[index] * 100000.0 for index in range(3)]
|
|
return {"projection": "orthographic", "position": position, "focal_point": center, "view_up": view_up}
|
|
|
|
|
|
def _edge_points(edge: Any, spacing: float) -> list[tuple[float, float]]:
|
|
count = max(2, min(1024, int(math.ceil(float(edge.length) / max(spacing, 0.002))) + 1))
|
|
try:
|
|
points = edge.positions([index / (count - 1) for index in range(count)])
|
|
except Exception:
|
|
points = [edge.position_at(0), edge.position_at(1)]
|
|
return [(float(point.X), float(point.Y)) for point in points]
|
|
|
|
|
|
def _projected_bounds(edges: list[Any]) -> tuple[float, float, float, float]:
|
|
points = [point for edge in edges for point in _edge_points(edge, 0.5)]
|
|
if not points:
|
|
raise RenderBundleError("Hidden-line projection produced no drawable edges")
|
|
xs, ys = zip(*points)
|
|
return min(xs), max(xs), min(ys), max(ys)
|
|
|
|
|
|
def _frame_bounds(edges: list[Any], target_extent: float) -> tuple[float, float, float, float]:
|
|
min_x, max_x, min_y, max_y = _projected_bounds(edges)
|
|
if target_extent > 0:
|
|
# HLR maps the look-at target to the projection origin, making this
|
|
# an exact, deterministic local crop without a GPU clipping plane.
|
|
half = target_extent / (2 * (1 - 2 * FRAME_PADDING))
|
|
return -half, half, -half, half
|
|
center_x, center_y = (min_x + max_x) / 2, (min_y + max_y) / 2
|
|
extent = max(max_x - min_x, max_y - min_y, 1.0)
|
|
half = extent / (2 * (1 - 2 * FRAME_PADDING))
|
|
return center_x - half, center_x + half, center_y - half, center_y + half
|
|
|
|
|
|
def _pixel(point: tuple[float, float], frame: tuple[float, float, float, float], size: int) -> tuple[int, int]:
|
|
min_x, max_x, min_y, max_y = frame
|
|
x = round((point[0] - min_x) * (size - 1) / (max_x - min_x))
|
|
y = round((max_y - point[1]) * (size - 1) / (max_y - min_y))
|
|
return int(x), int(y)
|
|
|
|
|
|
def _draw_dashed(draw: Any, points: list[tuple[int, int]], *, fill: tuple[int, int, int], width: int) -> None:
|
|
dash, gap = 16, 10
|
|
for start, end in zip(points, points[1:]):
|
|
dx, dy = end[0] - start[0], end[1] - start[1]
|
|
length = math.hypot(dx, dy)
|
|
if length <= 0:
|
|
continue
|
|
distance = 0.0
|
|
while distance < length:
|
|
segment_end = min(length, distance + dash)
|
|
first = (round(start[0] + dx * distance / length), round(start[1] + dy * distance / length))
|
|
last = (round(start[0] + dx * segment_end / length), round(start[1] + dy * segment_end / length))
|
|
draw.line((first, last), fill=fill, width=width)
|
|
distance += dash + gap
|
|
|
|
|
|
def _rasterize(
|
|
*,
|
|
visible: list[Any],
|
|
hidden: list[Any],
|
|
frame: tuple[float, float, float, float],
|
|
output_dir: Path,
|
|
view_id: str,
|
|
intentional_crop: bool,
|
|
) -> dict[str, Any]:
|
|
pillow_image, pillow_draw, _ = _render_modules()
|
|
image = pillow_image.new("RGB", (RENDER_SIZE, RENDER_SIZE), BACKGROUND_RGB)
|
|
mask = pillow_image.new("L", (RENDER_SIZE, RENDER_SIZE), 0)
|
|
draw = pillow_draw.Draw(image)
|
|
mask_draw = pillow_draw.Draw(mask)
|
|
spacing = max((frame[1] - frame[0]) / 1800, 0.01)
|
|
for edge in hidden:
|
|
points = [_pixel(point, frame, RENDER_SIZE) for point in _edge_points(edge, spacing)]
|
|
_draw_dashed(draw, points, fill=HIDDEN_EDGE_RGB, width=3)
|
|
_draw_dashed(mask_draw, points, fill=128, width=4)
|
|
for edge in visible:
|
|
points = [_pixel(point, frame, RENDER_SIZE) for point in _edge_points(edge, spacing)]
|
|
if len(points) >= 2:
|
|
draw.line(points, fill=VISIBLE_EDGE_RGB, width=4, joint="curve")
|
|
mask_draw.line(points, fill=255, width=5, joint="curve")
|
|
high_path = output_dir / "internal" / f"{view_id}-2x.png"
|
|
high_path.parent.mkdir(parents=True, exist_ok=True)
|
|
image.save(high_path, optimize=True)
|
|
output_path = output_dir / f"{view_id}.png"
|
|
image.resize((REVIEW_SIZE, REVIEW_SIZE), resample=pillow_image.Resampling.LANCZOS).save(output_path, optimize=True)
|
|
diagnostic_dir = output_dir / "internal" / view_id
|
|
diagnostic_dir.mkdir(parents=True, exist_ok=True)
|
|
mask_path = diagnostic_dir / "line-mask.png"
|
|
mask.save(mask_path)
|
|
edge_path = diagnostic_dir / "edge.png"
|
|
mask.save(edge_path)
|
|
box = mask.getbbox()
|
|
coverage = (RENDER_SIZE * RENDER_SIZE - mask.histogram()[0]) / (RENDER_SIZE * RENDER_SIZE)
|
|
pixel_bbox = list(box) if box else []
|
|
touches_border = bool(box and (box[0] <= 1 or box[1] <= 1 or box[2] >= RENDER_SIZE - 1 or box[3] >= RENDER_SIZE - 1))
|
|
valid = bool(box and coverage >= 0.00005 and coverage <= 0.20 and (intentional_crop or not touches_border))
|
|
return {
|
|
"path": str(output_path),
|
|
"high_resolution_path": str(high_path),
|
|
"diagnostics": {
|
|
"line_mask_path": str(mask_path),
|
|
"edge_path": str(edge_path),
|
|
"coverage": coverage,
|
|
"pixel_bbox": pixel_bbox,
|
|
"touches_border": touches_border,
|
|
"intentional_crop": intentional_crop,
|
|
"visible_edge_count": len(visible),
|
|
"hidden_edge_count": len(hidden),
|
|
"valid": valid,
|
|
},
|
|
}
|
|
|
|
|
|
def _render_view(
|
|
*,
|
|
shape: Any,
|
|
view_id: str,
|
|
projection_id: str,
|
|
target: dict[str, Any] | None,
|
|
model_bounds: list[float],
|
|
output_dir: Path,
|
|
) -> dict[str, Any]:
|
|
center, target_extent = _target_frame(target, model_bounds)
|
|
camera = _camera_for(projection_id, center)
|
|
try:
|
|
visible, hidden = shape.project_to_viewport(
|
|
camera["position"], viewport_up=camera["view_up"], look_at=camera["focal_point"]
|
|
)
|
|
except Exception as error:
|
|
raise RenderBundleError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error
|
|
visible_edges, hidden_edges = list(visible), list(hidden)
|
|
frame = _frame_bounds([*visible_edges, *hidden_edges], target_extent)
|
|
rendered = _rasterize(
|
|
visible=visible_edges,
|
|
hidden=hidden_edges,
|
|
frame=frame,
|
|
output_dir=output_dir,
|
|
view_id=view_id,
|
|
intentional_crop=target_extent > 0,
|
|
)
|
|
if not rendered["diagnostics"]["valid"]:
|
|
raise RenderBundleError(f"Render bundle quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}")
|
|
return {"id": view_id, "camera": {**camera, "view": projection_id, "frame_mm": list(frame)}, "target": target, **rendered}
|
|
|
|
|
|
def _contact_sheet(views: list[dict[str, Any]], output_dir: Path) -> str:
|
|
"""Create compact whole-model images for published CAD artifacts."""
|
|
pillow_image, pillow_draw, _ = _render_modules()
|
|
canonical = [item for item in views if item["id"] in CANONICAL_VIEWS]
|
|
if not canonical:
|
|
return ""
|
|
tile = 400
|
|
sheet = pillow_image.new("RGB", (tile * 3, tile * 3), BACKGROUND_RGB)
|
|
draw = pillow_draw.Draw(sheet)
|
|
for index, item in enumerate(canonical):
|
|
image = pillow_image.open(str(item["path"])).convert("RGB").resize((tile, tile), resample=pillow_image.Resampling.LANCZOS)
|
|
x, y = (index % 3) * tile, (index // 3) * tile
|
|
sheet.paste(image, (x, y))
|
|
draw.rectangle((x + 8, y + 8, x + 96, y + 33), fill=(255, 255, 255))
|
|
draw.text((x + 14, y + 13), str(item["id"]), fill=VISIBLE_EDGE_RGB)
|
|
path = output_dir / "contact-sheet.jpg"
|
|
sheet.save(path, quality=88, optimize=True, progressive=True)
|
|
return str(path)
|
|
|
|
|
|
def render_checkpoint(
|
|
settings: Settings,
|
|
*,
|
|
step_path: Path,
|
|
output_dir: Path,
|
|
detail_targets: list[dict[str, Any]] | None = None,
|
|
include_canonical: bool = True,
|
|
) -> dict[str, Any]:
|
|
"""Render STEP geometry into stable canonical and bounded node-detail views."""
|
|
del settings
|
|
ready, detail = renderer_status()
|
|
if not ready:
|
|
raise RenderBundleError(detail)
|
|
if not step_path.is_file():
|
|
raise RenderBundleError(f"STEP render source is missing: {step_path.name}")
|
|
_, _, import_step = _render_modules()
|
|
try:
|
|
shape = import_step(str(step_path))
|
|
except Exception as error:
|
|
raise RenderBundleError(f"Unable to read STEP render source: {error}") from error
|
|
bounds = _shape_bounds(shape)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
jobs: list[tuple[str, dict[str, Any] | None]] = []
|
|
if include_canonical:
|
|
jobs.extend((view_id, None) for view_id in CANONICAL_VIEWS)
|
|
jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((detail_targets or [])[:3]))
|
|
views = [
|
|
_render_view(
|
|
shape=shape,
|
|
view_id=view_id,
|
|
projection_id="isometric" if view_id.startswith("detail-") else view_id,
|
|
target=target,
|
|
model_bounds=bounds,
|
|
output_dir=output_dir,
|
|
)
|
|
for view_id, target in jobs
|
|
]
|
|
canonical = {item["id"] for item in views if not str(item["id"]).startswith("detail-")}
|
|
if include_canonical and canonical != set(CANONICAL_VIEWS):
|
|
raise RenderBundleError("Python render bundle generator did not produce every canonical view")
|
|
contact_sheet_path = _contact_sheet(views, output_dir) if include_canonical else ""
|
|
manifest = {
|
|
"schema_version": "cad.render-manifest.v2",
|
|
"renderer": "python-occ-hlr-pillow",
|
|
"source": {"type": "step", "path": str(step_path), "bounds_mm": bounds},
|
|
"high_resolution": {"width": RENDER_SIZE, "height": RENDER_SIZE, "method": "occ_hidden_line"},
|
|
"render_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"},
|
|
"contact_sheet_path": contact_sheet_path,
|
|
"views": views,
|
|
}
|
|
(output_dir / "render-manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
return manifest
|
|
|
|
|
|
def render_section(
|
|
settings: Settings,
|
|
*,
|
|
step_path: Path,
|
|
output_dir: Path,
|
|
origin_mm: list[float],
|
|
normal: list[float],
|
|
) -> dict[str, Any]:
|
|
"""Create an actual OpenCascade section drawing, not a clipped viewport.
|
|
|
|
It intentionally uses the same deterministic Pillow raster path as the
|
|
seven canonical render views. The output contains compact contour evidence
|
|
suitable for inspection without sending a STEP file or full B-rep.
|
|
"""
|
|
del settings
|
|
ready, detail = renderer_status()
|
|
if not ready:
|
|
raise RenderBundleError(detail)
|
|
if not step_path.is_file():
|
|
raise RenderBundleError(f"STEP section source is missing: {step_path.name}")
|
|
origin = _number_list(origin_mm, size=3)
|
|
direction = _number_list(normal, size=3)
|
|
if origin is None or direction is None:
|
|
raise RenderBundleError("Section origin_mm and normal must each contain three finite numbers")
|
|
length = math.sqrt(sum(value * value for value in direction))
|
|
if length <= 1e-9:
|
|
raise RenderBundleError("Section normal must not be zero")
|
|
normal_unit = [value / length for value in direction]
|
|
_, _, import_step = _render_modules()
|
|
try:
|
|
b3d = importlib.import_module("build123d")
|
|
shape = import_step(str(step_path))
|
|
plane = b3d.Plane(origin=b3d.Vector(*origin), z_dir=b3d.Vector(*normal_unit))
|
|
# build123d exposes section as a module-level part operation. Older
|
|
# code assumed a Solid.section instance method, which does not exist
|
|
# in the supported 0.11 runtime and made an otherwise successful CAD
|
|
# task fail while collecting optional author evidence.
|
|
section = b3d.section(shape, section_by=plane)
|
|
edges = list(section.edges())
|
|
except Exception as error:
|
|
raise RenderBundleError(f"OpenCascade section operation failed: {error}") from error
|
|
if not edges:
|
|
raise RenderBundleError("Section plane does not intersect the model")
|
|
|
|
# Choose a deterministic right-handed in-plane frame. Projecting exact
|
|
# OCC section edges into this frame preserves holes and internal contours.
|
|
seed = [0.0, 0.0, 1.0] if abs(normal_unit[2]) < 0.9 else [0.0, 1.0, 0.0]
|
|
x_axis = [
|
|
seed[1] * normal_unit[2] - seed[2] * normal_unit[1],
|
|
seed[2] * normal_unit[0] - seed[0] * normal_unit[2],
|
|
seed[0] * normal_unit[1] - seed[1] * normal_unit[0],
|
|
]
|
|
x_length = math.sqrt(sum(value * value for value in x_axis))
|
|
x_axis = [value / x_length for value in x_axis]
|
|
y_axis = [
|
|
normal_unit[1] * x_axis[2] - normal_unit[2] * x_axis[1],
|
|
normal_unit[2] * x_axis[0] - normal_unit[0] * x_axis[2],
|
|
normal_unit[0] * x_axis[1] - normal_unit[1] * x_axis[0],
|
|
]
|
|
|
|
projected: list[list[tuple[float, float]]] = []
|
|
for edge in edges:
|
|
try:
|
|
count = max(2, min(1024, int(math.ceil(float(edge.length) / 0.25)) + 1))
|
|
points = edge.positions([index / (count - 1) for index in range(count)])
|
|
except Exception:
|
|
points = [edge.position_at(0), edge.position_at(1)]
|
|
line: list[tuple[float, float]] = []
|
|
for point in points:
|
|
offset = [float(point.X) - origin[0], float(point.Y) - origin[1], float(point.Z) - origin[2]]
|
|
line.append((sum(offset[index] * x_axis[index] for index in range(3)), sum(offset[index] * y_axis[index] for index in range(3))))
|
|
if len(line) >= 2:
|
|
projected.append(line)
|
|
if not projected:
|
|
raise RenderBundleError("Section operation produced no drawable contours")
|
|
xs = [point[0] for line in projected for point in line]
|
|
ys = [point[1] for line in projected for point in line]
|
|
minimum_x, maximum_x, minimum_y, maximum_y = min(xs), max(xs), min(ys), max(ys)
|
|
extent = max(maximum_x - minimum_x, maximum_y - minimum_y, 1.0)
|
|
padding = extent * FRAME_PADDING
|
|
frame = (minimum_x - padding, maximum_x + padding, minimum_y - padding, maximum_y + padding)
|
|
pillow_image, pillow_draw, _ = _render_modules()
|
|
image = pillow_image.new("RGB", (RENDER_SIZE, RENDER_SIZE), BACKGROUND_RGB)
|
|
draw = pillow_draw.Draw(image)
|
|
for line in projected:
|
|
pixels = [_pixel(point, frame, RENDER_SIZE) for point in line]
|
|
draw.line(pixels, fill=VISIBLE_EDGE_RGB, width=4, joint="curve")
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
high_path = output_dir / "section-2x.png"
|
|
image.save(high_path, optimize=True)
|
|
output_path = output_dir / "section.png"
|
|
image.resize((REVIEW_SIZE, REVIEW_SIZE), resample=pillow_image.Resampling.LANCZOS).save(output_path, optimize=True)
|
|
result = {
|
|
"schema_version": "cad.section-render.v1",
|
|
"renderer": "python-occ-section-pillow",
|
|
"path": str(output_path),
|
|
"high_resolution_path": str(high_path),
|
|
"plane": {"origin_mm": origin, "normal": normal_unit},
|
|
"contour_count": len(projected),
|
|
"bounds_mm": [minimum_x, maximum_x, minimum_y, maximum_y],
|
|
"resolution": [REVIEW_SIZE, REVIEW_SIZE],
|
|
}
|
|
(output_dir / "section-manifest.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
return result
|