"""Deterministic, CPU-only CAD technical renders for visual review. 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 review evidence 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 ReviewRenderError(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 ReviewRenderError 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 ReviewRenderError( "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 ReviewRenderError("STEP review 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 ReviewRenderError("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 ReviewRenderError(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 ReviewRenderError(f"Review render 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 evidence for routine reviewer calls.""" 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, review_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 ReviewRenderError(detail) if not step_path.is_file(): raise ReviewRenderError(f"STEP review source is missing: {step_path.name}") _, _, import_step = _render_modules() try: shape = import_step(str(step_path)) except Exception as error: raise ReviewRenderError(f"Unable to read STEP review 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((review_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 ReviewRenderError("Python review renderer 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"}, "review_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