"""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