Files
cdsl-cad/backend/app/services/render_bundle.py
T

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