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