""" external_diagram.py — External Dimensional Diagram generator. Produces a standardized installation reference sheet from a STEP file. NOT a manufacturing drawing. Optimized for readability and non-CAD users. Primary engine: build123d Fallback engine: FreeCAD headless Output: SVG (always) + PNG + optional PDF via cairosvg Style: line_drawing (MR28-style) or rendered (MR16-style) Layout: single_sheet (auto) or multi_page (large/complex models) Entry point: step_external_diagram(path, mode, mapping_file=None, datablock_file=None, options=None) """ import json import logging import math import re import textwrap from datetime import datetime, timezone from pathlib import Path from typing import Optional import numpy as np from .loader import StepModel, load_step logger = logging.getLogger("step_processor.external_diagram") MM_TO_IN = 1 / 25.4 # Sheet sizes in mm (width x height) SHEET_SIZES = { "A4_landscape": (297, 210), "A4_portrait": (210, 297), "A3_landscape": (420, 297), "A3_portrait": (297, 420), "A2_landscape": (594, 420), "letter_landscape": (279.4, 215.9), "letter_portrait": (215.9, 279.4), } # Complexity thresholds for auto layout decisions MULTIPAGE_THRESHOLD_MM = 1200 # any dimension > this triggers multi_page consideration MULTIPAGE_PART_COUNT = 60 # part count above this triggers multi_page # --------------------------------------------------------------------------- # Public entry point # --------------------------------------------------------------------------- def step_external_diagram( path: str | Path, mode: str = "enclosure_only", mapping_file: str | Path | None = None, datablock_file: str | Path | None = None, options: dict | None = None, ) -> dict: """ Generate an external dimensional diagram from a STEP file. Parameters ---------- path : Path to .step/.stp file mode : 'enclosure_only' | 'enclosure_plus_mounting' | 'mounting_only' mapping_file : Optional path to parts_mapping JSON datablock_file: Optional path to datablock .md file options : Dict of overrides: style : 'line_drawing' | 'rendered' (default: auto) layout_mode : 'single_sheet' | 'multi_page' (default: auto) sheet_size : key from SHEET_SIZES or 'auto' (default: auto) pdf : bool (default: False) svg : bool (default: True) views : list of view names to include mounting_variant : specific variant name to render dpi : PNG export DPI (default: 150) Returns ------- dict matching ExternalDimensionalDiagram schema (external_diagram_schema.json) """ opts = options or {} step_path = Path(path).expanduser().resolve() warnings = [] notes = [] if not step_path.exists(): raise FileNotFoundError(f"STEP file not found: {step_path}") # --- Load geometry --- model = load_step(step_path) if model is None: raise RuntimeError(f"Could not load {step_path} with build123d or FreeCAD.") if model.backend != "build123d": warnings.append("Fallback engine (FreeCAD) was used — some geometry queries may be less precise.") # --- Load mapping + datablock --- mapping = _load_mapping(mapping_file) if mapping_file else None datablock = _load_datablock(datablock_file, step_path) # --- Select geometry scope --- selected_parts, excluded_parts = _select_parts( model, mode, mapping, opts.get("mounting_variant") ) if excluded_parts: notes.append(f"{len(excluded_parts)} parts excluded per mode/mapping.") # --- Compute geometry --- bbox = _compute_bounding_box(model, selected_parts) overall_w = round(bbox["x_max"] - bbox["x_min"], 2) overall_h = round(bbox["z_max"] - bbox["z_min"], 2) overall_d = round(bbox["y_max"] - bbox["y_min"], 2) active_area = _detect_active_area(model, selected_parts) if active_area is None: warnings.append("Active area (screen aperture) not detected — omitted from diagram.") else: notes.append(f"Active area detected: {active_area['width']}×{active_area['height']} mm " f"(confidence: {active_area['detection_confidence']})") mounting_dims = None if mode != "enclosure_only": mounting_dims = _detect_mounting_dimensions(model, selected_parts) if mounting_dims is None: warnings.append("Mounting dimensions not detected.") mounting_variants = _detect_mounting_variants(model, mapping) if mounting_variants: notes.append(f"{len(mounting_variants)} mounting variant(s) detected in assembly tree.") # --- Auto-select style and layout --- style = opts.get("style") or _auto_style(model, overall_w, overall_h, overall_d) layout_mode = opts.get("layout_mode") or _auto_layout(overall_w, overall_h, overall_d, model) sheet_size_key = opts.get("sheet_size") or _auto_sheet(overall_w, overall_h, overall_d, layout_mode) dpi = opts.get("dpi", 150) # --- Select views --- views = opts.get("views") or _auto_views(overall_w, overall_h, overall_d, mode) # --- Dimension style --- dim_style = _auto_dim_style(overall_w, overall_h, mounting_dims) # --- Generate SVG --- svg_generator = DiagramSVG( model=model, bbox=bbox, overall=(overall_w, overall_h, overall_d), active_area=active_area, mounting_dims=mounting_dims, style=style, views=views, layout_mode=layout_mode, sheet_size_key=sheet_size_key, dim_style=dim_style, datablock=datablock, selected_parts=selected_parts, ) svg_content, layout_info = svg_generator.render() # --- Write outputs --- stem = step_path.stem out_dir = step_path.parent svg_path = out_dir / f"{stem}__external-diagram.svg" png_path = out_dir / f"{stem}__external-diagram.png" pdf_path = out_dir / f"{stem}__external-diagram.pdf" if opts.get("pdf") else None meta_path = out_dir / f"{stem}__meta.json" svg_path.write_text(svg_content, encoding="utf-8") notes.append(f"SVG written: {svg_path.name}") _svg_to_png(svg_content, png_path, dpi=dpi) if pdf_path: _svg_to_pdf(svg_content, pdf_path) # Individual view PNGs (always generated for rendered style, optional for line) view_paths = {} if style == "rendered" or opts.get("individual_views"): view_paths = _render_individual_views(model, step_path, views, selected_parts, dpi) # --- Variant diagrams --- variant_outputs = None if mounting_variants and opts.get("render_variants"): variant_outputs = [] for variant in mounting_variants: try: v_result = step_external_diagram( path=step_path, mode="enclosure_plus_mounting", mapping_file=mapping_file, datablock_file=datablock_file, options={**opts, "mounting_variant": variant["variant_name"], "render_variants": False}, ) variant_outputs.append({ "variant_name": variant["variant_name"], "diagram_png": v_result["outputs"].get("diagram_png"), "diagram_pdf": v_result["outputs"].get("diagram_pdf"), "meta_json": v_result["outputs"].get("meta_json"), }) except Exception as e: warnings.append(f"Variant '{variant['variant_name']}' diagram failed: {e}") # --- Assemble metadata --- meta = { "schema_version": "1.0", "generated_at": datetime.now(timezone.utc).isoformat(), "source_path": str(step_path), "model_name": datablock.get("display_name") or datablock.get("model_number") or stem, "mode": mode, "style": style, "layout_mode": layout_mode, "engine_used": model.backend, "fallback_invoked": model.backend != "build123d", "units": {"primary": "mm", "secondary": "in"}, "overall_width": overall_w, "overall_height": overall_h, "overall_depth": overall_d, "bounding_box": bbox, "active_area": active_area, "mounting_dimensions": mounting_dims, "mounting_variants": mounting_variants, "selected_parts": selected_parts, "excluded_parts": excluded_parts, "mapping_file_used": str(mapping_file) if mapping_file else None, "datablock": datablock, "layout": { "views_included": views, "sheet_size": sheet_size_key, "scale_ratio": "NTS", "dimension_style": dim_style, "iso_style": "shaded_render" if style == "rendered" else "line_wireframe", }, "outputs": { "diagram_png": str(png_path) if png_path.exists() else None, "diagram_pdf": str(pdf_path) if pdf_path and pdf_path.exists() else None, "diagram_svg": str(svg_path) if svg_path.exists() else None, "iso_png": view_paths.get("isometric_front"), "front_png": view_paths.get("front"), "side_png": view_paths.get("left") or view_paths.get("right"), "rear_png": view_paths.get("rear"), "meta_json": str(meta_path), "variant_outputs": variant_outputs, }, "warnings": warnings, "notes": notes, } meta_path.write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8") return meta # --------------------------------------------------------------------------- # SVG Diagram Generator # --------------------------------------------------------------------------- class DiagramSVG: """ Generates a complete dimensional diagram SVG. Handles layout, view arrangement, dimension lines, data block. """ # Design constants (mm in SVG coordinate space, 1 unit = 1mm) MARGIN = 15 VIEW_GAP = 20 DIM_OFFSET = 12 # gap between geometry edge and first dim line DIM_LINE_EXT = 3 # extension line overshoot DIM_ARROW_SIZE = 2.5 FONT_SIZE_DIM = 3.5 # mm equiv FONT_SIZE_DIM_IMP = 2.5 # imperial secondary, italic FONT_SIZE_LABEL = 3.0 FONT_SIZE_TITLE = 5.0 DATABLOCK_H = 22 LINE_COLOR = "#1a1a1a" DIM_COLOR_METRIC = "#1565C0" # blue (MR16 style) for rendered, black for line DIM_COLOR_LINE = "#1a1a1a" HIDDEN_COLOR = "#888888" DATABLOCK_BG = "#f5f5f5" def __init__(self, model, bbox, overall, active_area, mounting_dims, style, views, layout_mode, sheet_size_key, dim_style, datablock, selected_parts): self.model = model self.bbox = bbox self.W, self.H, self.D = overall # width, height, depth self.active_area = active_area self.mounting_dims = mounting_dims self.style = style self.views = views self.layout_mode = layout_mode self.sheet_w, self.sheet_h = SHEET_SIZES.get(sheet_size_key, (420, 297)) self.dim_style = dim_style self.datablock = datablock self.selected_parts = selected_parts self.dim_color = self.DIM_COLOR_METRIC if style == "rendered" else self.DIM_COLOR_LINE def render(self) -> tuple[str, dict]: """Returns (svg_string, layout_info_dict).""" drawable_h = self.sheet_h - self.MARGIN * 2 - self.DATABLOCK_H drawable_w = self.sheet_w - self.MARGIN * 2 # Compute scale to fit largest view max_dim = max(self.W, self.H, self.D) # Rough target: primary view takes ~40% of drawable width target_view_w = drawable_w * 0.38 self.scale = target_view_w / max(self.W, 1) # Clamp scale so drawings aren't too tiny or too large self.scale = max(0.05, min(self.scale, 3.0)) svg_parts = [] layout_info = {"scale": self.scale, "views": {}} # Build view definitions view_defs = self._arrange_views(drawable_w, drawable_h) layout_info["views"] = {v["name"]: (v["ox"], v["oy"]) for v in view_defs} # SVG header svg_parts.append(self._svg_header()) # Background svg_parts.append( f'' ) # Border m = self.MARGIN / 2 svg_parts.append( f'' ) # Draw each view for vdef in view_defs: svg_parts.append(self._render_view(vdef)) # Data block svg_parts.append(self._render_datablock()) svg_parts.append("") return "\n".join(svg_parts), layout_info def _svg_header(self) -> str: return ( f'\n' f'\n' f' \n' f' \n' f' \n' f' \n' f' \n' f' \n' f'' ) def _arrange_views(self, dw: float, dh: float) -> list[dict]: """ Arrange views on the sheet. Standard layout mirrors reference images: - front (center-left, largest) - top (above front) - side (right of front) - rear (right of side) - isometric (bottom-right) - bottom (below front, if included) Returns list of view dicts with position info. """ s = self.scale g = self.VIEW_GAP m = self.MARGIN # Scaled dimensions with dim-line clearance sw = self.W * s # scaled width sh = self.H * s # scaled height sd = self.D * s # scaled depth (appears as height in top/bottom views) # Primary view origin (front): leave room above for top view + dims top_h = sd + g * 2 if "top" in self.views else g front_x = m + g + sd + g # room on left for side/left view front_y = m + top_h vdefs = [] if "front" in self.views: vdefs.append({ "name": "front", "label": "FRONT", "ox": front_x, "oy": front_y, "vw": sw, "vh": sh, "vd": sd, "projection": "front", }) if "top" in self.views: vdefs.append({ "name": "top", "label": "TOP", "ox": front_x, "oy": m + g, "vw": sw, "vh": sd, "vd": sd, "projection": "top", }) if "bottom" in self.views: vdefs.append({ "name": "bottom", "label": "BOTTOM", "ox": front_x, "oy": front_y + sh + g * 2, "vw": sw, "vh": sd, "vd": sd, "projection": "bottom", }) # Left/side view to the left of front if "left" in self.views or "right" in self.views: side_name = "left" if "left" in self.views else "right" vdefs.append({ "name": side_name, "label": "SIDE", "ox": m + g, "oy": front_y, "vw": sd, "vh": sh, "vd": sd, "projection": side_name, }) # Rear view to the right of front if "rear" in self.views: vdefs.append({ "name": "rear", "label": "REAR", "ox": front_x + sw + g + sd + g if ("left" in self.views or "right" in self.views) else front_x + sw + g, "oy": front_y, "vw": sw, "vh": sh, "vd": sd, "projection": "rear", }) # Isometric — bottom right, roughly square iso_size = min(sw, sh) * 1.4 if "isometric_front" in self.views: vdefs.append({ "name": "isometric_front", "label": "ISO", "ox": self.sheet_w - self.MARGIN - iso_size - g, "oy": self.sheet_h - self.DATABLOCK_H - self.MARGIN - iso_size - g * 2, "vw": iso_size, "vh": iso_size, "vd": sd, "projection": "isometric_front", }) return vdefs def _render_view(self, vdef: dict) -> str: """Render a single view with geometry outline and dimensions.""" parts = [] name = vdef["name"] ox, oy = vdef["ox"], vdef["oy"] vw, vh = vdef["vw"], vdef["vh"] s = self.scale parts.append(f'') if vdef["projection"] == "isometric_front": parts.append(self._render_iso_box(ox, oy, vw, vh)) else: # Outer enclosure rectangle parts.append( f'' ) # Active area aperture (front view only) if name == "front" and self.active_area: aa = self.active_area ax = ox + aa.get("offset_left", (self.W - aa["width"]) / 2) * s ay = oy + aa.get("offset_top", (self.H - aa["height"]) / 2) * s aw = aa["width"] * s ah = aa["height"] * s parts.append( f'' ) # Diagonal measurement line (like MR28 reference) if aa.get("diagonal_inches"): parts.append( f'' ) diag_label = f'{aa["diagonal"]:.2f} ({aa["diagonal_inches"]:.1f}")' mid_x = ax + aw / 2 mid_y = oy + vh / 2 parts.append( f'' f'{diag_label}' ) # View label parts.append( f'{vdef["label"]}' ) # Dimensions if name == "front": parts.append(self._dim_horizontal(ox, oy + vh, vw, self.W, offset_factor=1.5)) parts.append(self._dim_vertical(ox, oy, vh, self.H, side="left")) # Active area width if self.active_area: aa = self.active_area ax_offset = aa.get("offset_left", (self.W - aa["width"]) / 2) * s parts.append(self._dim_horizontal( ox + ax_offset, oy + vh, aa["width"] * s, aa["width"], offset_factor=0.7, style="inner" )) elif name in ("left", "right"): parts.append(self._dim_horizontal(ox, oy + vh, vw, self.D, offset_factor=1.5)) parts.append(self._dim_vertical(ox + vw, oy, vh, self.H, side="right")) # Mounting chain on side view if vertical if self.mounting_dims and self.mounting_dims.get("spacing_chain_y"): parts.append(self._dim_chain_vertical( ox + vw, oy, s, self.mounting_dims["spacing_chain_y"] )) elif name == "rear": parts.append(self._dim_horizontal(ox, oy + vh, vw, self.W, offset_factor=1.5)) elif name == "top": parts.append(self._dim_horizontal(ox, oy - self.DIM_OFFSET * 0.5, vw, self.W, offset_factor=0, above=True)) parts.append(self._dim_vertical(ox + vw, oy, vh, self.D, side="right")) # Mounting hole chain on top view if horizontal if self.mounting_dims and self.mounting_dims.get("spacing_chain_x"): parts.append(self._dim_chain_horizontal( ox, oy + vh, s, self.mounting_dims["spacing_chain_x"] )) elif name == "bottom": parts.append(self._dim_horizontal(ox, oy + vh, vw, self.W, offset_factor=1.5)) if self.mounting_dims and self.mounting_dims.get("spacing_chain_x"): parts.append(self._dim_chain_horizontal( ox, oy + vh + self.DIM_OFFSET, s, self.mounting_dims["spacing_chain_x"] )) parts.append("") return "\n".join(parts) def _render_iso_box(self, ox: float, oy: float, vw: float, vh: float) -> str: """Render an isometric box projection of the enclosure.""" # Simple oblique/isometric line box — no geometry engine needed # Uses 30° isometric projection W = self.W * self.scale * 0.6 H = self.H * self.scale * 0.6 D = self.D * self.scale * 0.4 # Isometric angles ax = math.cos(math.radians(30)) * D ay = math.sin(math.radians(30)) * D # Front face corners (bottom-left origin) cx = ox + D * 0.4 cy = oy + ay * 0.5 p = { "fl": (cx, cy + H), # front left bottom "fr": (cx + W, cy + H), # front right bottom "flt": (cx, cy), # front left top "frt": (cx + W, cy), # front right top "rl": (cx + ax, cy + H - ay), # rear left bottom "rlt": (cx + ax, cy - ay), # rear left top "rrt": (cx + W + ax, cy - ay), # rear right top } def pt(k): return f"{p[k][0]:.2f},{p[k][1]:.2f}" lc = self.LINE_COLOR sw = "0.6" lines = [ # Front face f'', # Top face f'', # Right face f'', ] return "\n".join(lines) # ------------------------------------------------------------------- # Dimension line helpers # ------------------------------------------------------------------- def _dim_horizontal(self, x: float, y: float, length: float, value_mm: float, offset_factor: float = 1.5, style: str = "outer", above: bool = False) -> str: """Draw a horizontal dimension line below (or above) a view.""" off = self.DIM_OFFSET * offset_factor * (-1 if above else 1) dy = y + off dc = self.dim_color ars = self.DIM_ARROW_SIZE label = self._format_dim(value_mm) mid_x = x + length / 2 return ( # Extension lines f'\n' f'\n' # Dimension line with arrows f'\n' # Value f'' f'{label[0]}\n' + ( f'{label[1]}\n' if label[1] else "" ) ) def _dim_vertical(self, x: float, y: float, length: float, value_mm: float, side: str = "left") -> str: """Draw a vertical dimension line to the left or right of a view.""" off = self.DIM_OFFSET * 1.5 * (-1 if side == "left" else 1) dx = x + off dc = self.dim_color label = self._format_dim(value_mm) mid_y = y + length / 2 return ( f'\n' f'\n' f'\n' f'' f'{label[0]}\n' + ( f'' f'{label[1]}\n' if label[1] else "" ) ) def _dim_chain_horizontal(self, ox: float, oy: float, scale: float, chain: list[float]) -> str: """Draw a horizontal dimension chain (e.g. 113-200-200-113).""" parts = [] dc = self.dim_color dy = oy + self.DIM_OFFSET * 2.5 cx = ox for seg in chain: sw = seg * scale label = self._format_dim(seg) mid_x = cx + sw / 2 parts.append( f'\n' f'\n' f'' f'{label[0]}\n' ) cx += sw # Close last extension line parts.append( f'' ) return "\n".join(parts) def _dim_chain_vertical(self, ox: float, oy: float, scale: float, chain: list[float]) -> str: """Draw a vertical dimension chain on the side view.""" parts = [] dc = self.dim_color dx = ox + self.DIM_OFFSET * 2.5 cy = oy for seg in chain: sh = seg * scale label = self._format_dim(seg) mid_y = cy + sh / 2 parts.append( f'\n' f'\n' f'' f'{label[0]}\n' ) cy += sh parts.append( f'' ) return "\n".join(parts) def _render_datablock(self) -> str: """Render the bottom data block / title block.""" db = self.datablock x = self.MARGIN y = self.sheet_h - self.DATABLOCK_H - self.MARGIN / 2 w = self.sheet_w - self.MARGIN * 2 h = self.DATABLOCK_H bg = self.DATABLOCK_BG lc = self.LINE_COLOR fs = self.FONT_SIZE_LABEL parts = [ f'', ] # Left section: model name + number parts.append( f'' f'{db.get("display_name", db.get("model_number", ""))} ' f'— {db.get("model_number", "")}' ) # Second row: standard fields standard = [ ("Units", db.get("units_note", "mm (in)")), ("Scale", db.get("scale", "NTS")), ("Rev", db.get("revision", "—")), ("Date", db.get("drawing_date", "—")), ("By", db.get("drawn_by", "—")), ] col_x = x + 3 row_y = y + 13 for label, val in standard: parts.append( f'' f'{label}: {val}' ) col_x += 45 # Right section: custom fields custom = db.get("custom_fields", {}) if custom: cx = self.sheet_w - self.MARGIN - 3 cy = y + 7 for k, v in list(custom.items())[:4]: parts.append( f'' f'{k}: {v}' ) cy += 5 # Company name top-right corner parts.append( f'' f'{db.get("company", "")}' ) return "\n".join(parts) # ------------------------------------------------------------------- # Formatting helpers # ------------------------------------------------------------------- def _format_dim(self, value_mm: float) -> tuple[str, str]: """Returns (metric_label, imperial_label). Imperial in italic smaller.""" metric = f"{value_mm:.2f}" if value_mm < 10 else f"{value_mm:.1f}" imperial = f"({value_mm * MM_TO_IN:.3f}\")" if value_mm > 1 else "" return metric, imperial def _atan_deg(self, w: float, h: float) -> float: return math.degrees(math.atan2(h, w)) # --------------------------------------------------------------------------- # Export helpers # --------------------------------------------------------------------------- def _svg_to_png(svg_content: str, out_path: Path, dpi: int = 150): """Convert SVG to PNG using cairosvg.""" try: import cairosvg cairosvg.svg2png( bytestring=svg_content.encode("utf-8"), write_to=str(out_path), dpi=dpi, ) logger.info(f"PNG written: {out_path.name}") except ImportError: logger.warning("cairosvg not installed — PNG export skipped. pip install cairosvg") except Exception as e: logger.error(f"PNG export failed: {e}") def _svg_to_pdf(svg_content: str, out_path: Path): """Convert SVG to PDF using cairosvg.""" try: import cairosvg cairosvg.svg2pdf( bytestring=svg_content.encode("utf-8"), write_to=str(out_path), ) logger.info(f"PDF written: {out_path.name}") except ImportError: logger.warning("cairosvg not installed — PDF export skipped.") except Exception as e: logger.error(f"PDF export failed: {e}") def _render_individual_views(model, step_path, views, selected_parts, dpi) -> dict: """Render individual PNG views via the renderer module.""" from .renderer import render_views view_map = { "front": "front", "rear": "right", # approximate mappings "left": "left", "right": "right", "isometric_front": "iso_left", "isometric_rear": "iso_right", "top": "top", "bottom": "bottom", } render_views_list = list({view_map[v] for v in views if v in view_map}) paths = render_views(model, step_path, render_views_list, width=1024, height=768) return {v: str(p) for v, p in zip(render_views_list, paths)} # --------------------------------------------------------------------------- # Geometry analysis helpers # --------------------------------------------------------------------------- def _compute_bounding_box(model: StepModel, selected_parts: list) -> dict: try: if model.backend == "build123d": bb = model.shape.bounding_box() return { "x_min": round(bb.min.X, 2), "x_max": round(bb.max.X, 2), "y_min": round(bb.min.Y, 2), "y_max": round(bb.max.Y, 2), "z_min": round(bb.min.Z, 2), "z_max": round(bb.max.Z, 2), } else: bb = model.shape.BoundBox return { "x_min": round(bb.XMin, 2), "x_max": round(bb.XMax, 2), "y_min": round(bb.YMin, 2), "y_max": round(bb.YMax, 2), "z_min": round(bb.ZMin, 2), "z_max": round(bb.ZMax, 2), } except Exception as e: logger.warning(f"Bounding box computation failed: {e}") return {"x_min": 0, "x_max": 0, "y_min": 0, "y_max": 0, "z_min": 0, "z_max": 0} def _detect_active_area(model: StepModel, selected_parts: list) -> dict | None: """ Detect the largest rectangular recessed face or cutout on the front face. Used for screen aperture / display area annotation. """ try: if model.backend == "build123d": from OCC.Core.BRepGProp import BRepGProp from OCC.Core.GProp import GProp_GProps from OCC.Core.GeomAdaptor import GeomAdaptor_Surface from OCC.Core.GeomAbs import GeomAbs_Plane best_area = 0 best_face_data = None overall_bb = _compute_bounding_box(model, selected_parts) front_y = overall_bb["y_min"] for face in model.shape.faces(): try: from OCC.Core.BRep import BRep_Tool surf = BRep_Tool.Surface_s(face.wrapped) adaptor = GeomAdaptor_Surface(surf) if adaptor.GetType() != GeomAbs_Plane: continue props = GProp_GProps() BRepGProp.SurfaceProperties_s(face.wrapped, props) area = props.Mass() if area > best_area: fb = face.bounding_box() # Must be on a face roughly perpendicular to Y (front-facing) # and smaller than overall enclosure (it's a recess, not the face itself) face_w = fb.size.X face_h = fb.size.Z overall_w = overall_bb["x_max"] - overall_bb["x_min"] overall_h = overall_bb["z_max"] - overall_bb["z_min"] if face_w < overall_w * 0.98 and face_h < overall_h * 0.98 and area > 100: best_area = area diag = math.sqrt(face_w**2 + face_h**2) best_face_data = { "width": round(face_w, 2), "height": round(face_h, 2), "diagonal": round(diag, 2), "diagonal_inches": round(diag * MM_TO_IN, 1), "offset_left": round(fb.min.X - overall_bb["x_min"], 2), "offset_top": round(overall_bb["z_max"] - fb.max.Z, 2), "detection_confidence": "medium", } except Exception: continue return best_face_data except Exception as e: logger.warning(f"Active area detection error: {e}") return None def _detect_mounting_dimensions(model: StepModel, selected_parts: list) -> dict | None: """Detect mounting hole pattern from cylindrical faces.""" from .query_engine import _extract_holes_build123d, _extract_holes_freecad try: if model.backend == "build123d": holes = _extract_holes_build123d(model.shape) else: holes = _extract_holes_freecad(model.shape) if not holes: return None # Filter to likely mounting holes: small diameter (M3-M8 range = 3-8.5mm) mount_holes = [h for h in holes if 2.5 <= h.get("diameter", 0) <= 9.0] if not mount_holes: mount_holes = holes # use all if none in range positions = [h["position"] for h in mount_holes] if len(positions) < 2: return None # Compute x and z extents of hole centers xs = sorted(set(round(p[0], 1) for p in positions)) zs = sorted(set(round(p[2], 1) for p in positions)) # Build spacing chains def chain(vals): if len(vals) < 2: return None return [round(vals[i+1] - vals[i], 2) for i in range(len(vals)-1)] chain_x = chain(xs) chain_z = chain(zs) # VESA detection vesa = None if chain_x and chain_z: sx = sum(chain_x) sz = sum(chain_z) for std in [75, 100, 200, 300, 400, 600, 800]: if abs(sx - std) < 3 and abs(sz - std) < 3: vesa = f"VESA {std}x{std}" break # Offsets from bounding box bb = _compute_bounding_box(model, selected_parts) first_x = xs[0] - bb["x_min"] last_x = bb["x_max"] - xs[-1] first_z = bb["z_max"] - zs[-1] last_z = zs[0] - bb["z_min"] return { "pattern_type": "vesa" if vesa else ("linear" if len(zs) == 1 or len(xs) == 1 else "grid"), "vesa_standard": vesa, "hole_diameter": round(mount_holes[0].get("diameter", 0), 2), "hole_count": len(mount_holes), "spacing_x": round(sum(chain_x) / len(chain_x), 2) if chain_x else None, "spacing_y": round(sum(chain_z) / len(chain_z), 2) if chain_z else None, "spacing_chain_x": chain_x, "spacing_chain_y": chain_z, "offset_from_left": round(first_x, 2), "offset_from_right": round(last_x, 2), "offset_from_top": round(first_z, 2), "offset_from_bottom": round(last_z, 2), } except Exception as e: logger.warning(f"Mounting dimension detection error: {e}") return None def _detect_mounting_variants(model: StepModel, mapping: dict | None) -> list: if not mapping: return [] return mapping.get("mounting_variants") or [] def _select_parts(model: StepModel, mode: str, mapping: dict | None, variant: str | None) -> tuple: """Return (selected_parts, excluded_parts) based on mode and mapping.""" from .bom import extract_bom bom = extract_bom(model) all_parts = bom["part_name_original"].tolist() if not mapping: return all_parts, [] parts_map = mapping.get("parts", {}) selected, excluded = [], [] for part in all_parts: cfg = parts_map.get(part, {}) cat = cfg.get("category", "other") include_flag = cfg.get("include_in_diagram", True) part_variant = cfg.get("mounting_variant") if not include_flag: excluded.append(part) continue if mode == "enclosure_only" and cat != "enclosure": excluded.append(part) elif mode == "mounting_only" and cat not in ("mounting",): excluded.append(part) elif mode == "enclosure_plus_mounting": if cat in ("internal", "fastener") and not include_flag: excluded.append(part) elif cat == "mounting" and variant and part_variant and part_variant != variant: excluded.append(part) else: selected.append(part) else: selected.append(part) return selected, excluded # --------------------------------------------------------------------------- # Auto-selection helpers # --------------------------------------------------------------------------- def _auto_style(model, w, h, d) -> str: """line_drawing for simple enclosures, rendered for complex assemblies.""" try: face_count = sum(1 for _ in model.shape.faces()) if model.backend == "build123d" else 0 if face_count > 200: return "rendered" except Exception: pass return "line_drawing" def _auto_layout(w, h, d, model) -> str: """Multi-page for very large or complex models.""" if max(w, h, d) > MULTIPAGE_THRESHOLD_MM: return "multi_page" return "single_sheet" def _auto_sheet(w, h, d, layout_mode) -> str: """Select sheet size based on model aspect ratio.""" ratio = max(w, d) / max(h, 1) if ratio > 3.5: return "A3_landscape" if ratio > 2: return "A3_landscape" if h > w: return "A4_portrait" return "A4_landscape" def _auto_views(w, h, d, mode) -> list: """Select view set. Always include front, side, rear, iso.""" views = ["front", "isometric_front"] if d > 30: views.append("left") views.append("rear") if h > w * 0.8: # roughly square or portrait — add top views.append("top") else: views.append("bottom") return views def _auto_dim_style(w, h, mounting_dims) -> str: if mounting_dims and mounting_dims.get("spacing_chain_x"): return "chain" return "baseline" # --------------------------------------------------------------------------- # Datablock loader # --------------------------------------------------------------------------- def _load_datablock(datablock_file: Path | None, step_path: Path) -> dict: """Parse the datablock MD file or return minimal defaults.""" result = { "model_number": step_path.stem, "display_name": step_path.stem, "revision": "A", "drawing_date": datetime.now().strftime("%Y-%m-%d"), "drawn_by": "", "company": "MPMedia", "units_note": "Dimensions in mm (in)", "scale": "NTS", "custom_fields": {}, } if not datablock_file: # Look for a matching MD file alongside the STEP file candidate = step_path.with_suffix(".md") if candidate.exists(): datablock_file = candidate if datablock_file: try: text = Path(datablock_file).read_text(encoding="utf-8") for line in text.splitlines(): line = line.strip() if line.startswith("