Files
step-parse/skill.src/modules/external_diagram.py
T
Jason Stedwell c1abe36822 phase 0
2026-06-17 16:03:26 -05:00

1145 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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'<rect width="{self.sheet_w}" height="{self.sheet_h}" '
f'fill="white" stroke="none"/>'
)
# Border
m = self.MARGIN / 2
svg_parts.append(
f'<rect x="{m}" y="{m}" width="{self.sheet_w - m}" height="{self.sheet_h - m}" '
f'fill="none" stroke="{self.LINE_COLOR}" stroke-width="0.5"/>'
)
# 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("</svg>")
return "\n".join(svg_parts), layout_info
def _svg_header(self) -> str:
return (
f'<svg xmlns="http://www.w3.org/2000/svg" '
f'width="{self.sheet_w}mm" height="{self.sheet_h}mm" '
f'viewBox="0 0 {self.sheet_w} {self.sheet_h}" '
f'font-family="Arial, Helvetica, sans-serif">\n'
f'<defs>\n'
f' <marker id="arrow" markerWidth="6" markerHeight="6" '
f'refX="3" refY="3" orient="auto">\n'
f' <path d="M0,0 L6,3 L0,6 Z" fill="{self.dim_color}"/>\n'
f' </marker>\n'
f' <marker id="arrowR" markerWidth="6" markerHeight="6" '
f'refX="3" refY="3" orient="auto-start-reverse">\n'
f' <path d="M0,0 L6,3 L0,6 Z" fill="{self.dim_color}"/>\n'
f' </marker>\n'
f'</defs>'
)
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'<g id="view-{name}">')
if vdef["projection"] == "isometric_front":
parts.append(self._render_iso_box(ox, oy, vw, vh))
else:
# Outer enclosure rectangle
parts.append(
f'<rect x="{ox:.2f}" y="{oy:.2f}" width="{vw:.2f}" height="{vh:.2f}" '
f'fill="none" stroke="{self.LINE_COLOR}" stroke-width="0.7"/>'
)
# 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'<rect x="{ax:.2f}" y="{ay:.2f}" width="{aw:.2f}" height="{ah:.2f}" '
f'fill="none" stroke="{self.LINE_COLOR}" stroke-width="0.5"/>'
)
# Diagonal measurement line (like MR28 reference)
if aa.get("diagonal_inches"):
parts.append(
f'<line x1="{ax:.2f}" y1="{ay + ah:.2f}" '
f'x2="{ax + aw:.2f}" y2="{ay:.2f}" '
f'stroke="{self.LINE_COLOR}" stroke-width="0.35" stroke-dasharray="2,1"/>'
)
diag_label = f'{aa["diagonal"]:.2f} ({aa["diagonal_inches"]:.1f}")'
mid_x = ax + aw / 2
mid_y = oy + vh / 2
parts.append(
f'<text x="{mid_x:.2f}" y="{mid_y:.2f}" '
f'font-size="{self.FONT_SIZE_DIM:.1f}" fill="{self.LINE_COLOR}" '
f'text-anchor="middle" transform="rotate(-{self._atan_deg(aw, ah):.1f} {mid_x:.2f} {mid_y:.2f})">'
f'{diag_label}</text>'
)
# View label
parts.append(
f'<text x="{ox:.2f}" y="{oy - 3:.2f}" '
f'font-size="{self.FONT_SIZE_LABEL:.1f}" fill="{self.LINE_COLOR}" '
f'font-weight="bold">{vdef["label"]}</text>'
)
# 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("</g>")
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'<polygon points="{pt("fl")} {pt("fr")} {pt("frt")} {pt("flt")}" '
f'fill="#f0f0f0" stroke="{lc}" stroke-width="{sw}"/>',
# Top face
f'<polygon points="{pt("flt")} {pt("frt")} {pt("rrt")} {pt("rlt")}" '
f'fill="#e0e0e0" stroke="{lc}" stroke-width="{sw}"/>',
# Right face
f'<polygon points="{pt("fr")} {pt("frt")} {pt("rrt")}" '
f'fill="#d0d0d0" stroke="{lc}" stroke-width="{sw}"/>',
]
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'<line x1="{x:.2f}" y1="{y:.2f}" x2="{x:.2f}" y2="{dy:.2f}" '
f'stroke="{dc}" stroke-width="0.3"/>\n'
f'<line x1="{x + length:.2f}" y1="{y:.2f}" x2="{x + length:.2f}" y2="{dy:.2f}" '
f'stroke="{dc}" stroke-width="0.3"/>\n'
# Dimension line with arrows
f'<line x1="{x:.2f}" y1="{dy:.2f}" x2="{x + length:.2f}" y2="{dy:.2f}" '
f'stroke="{dc}" stroke-width="0.4" '
f'marker-start="url(#arrowR)" marker-end="url(#arrow)"/>\n'
# Value
f'<text x="{mid_x:.2f}" y="{dy - 1.5:.2f}" '
f'font-size="{self.FONT_SIZE_DIM:.1f}" fill="{dc}" text-anchor="middle">'
f'{label[0]}</text>\n'
+ (
f'<text x="{mid_x:.2f}" y="{dy + self.FONT_SIZE_DIM_IMP + 0.5:.2f}" '
f'font-size="{self.FONT_SIZE_DIM_IMP:.1f}" fill="{dc}" '
f'font-style="italic" text-anchor="middle">{label[1]}</text>\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'<line x1="{x:.2f}" y1="{y:.2f}" x2="{dx:.2f}" y2="{y:.2f}" '
f'stroke="{dc}" stroke-width="0.3"/>\n'
f'<line x1="{x:.2f}" y1="{y + length:.2f}" x2="{dx:.2f}" y2="{y + length:.2f}" '
f'stroke="{dc}" stroke-width="0.3"/>\n'
f'<line x1="{dx:.2f}" y1="{y:.2f}" x2="{dx:.2f}" y2="{y + length:.2f}" '
f'stroke="{dc}" stroke-width="0.4" '
f'marker-start="url(#arrowR)" marker-end="url(#arrow)"/>\n'
f'<text x="{dx - 1.5:.2f}" y="{mid_y:.2f}" '
f'font-size="{self.FONT_SIZE_DIM:.1f}" fill="{dc}" text-anchor="middle" '
f'transform="rotate(-90 {dx - 1.5:.2f} {mid_y:.2f})">'
f'{label[0]}</text>\n'
+ (
f'<text x="{dx + self.FONT_SIZE_DIM_IMP:.2f}" y="{mid_y:.2f}" '
f'font-size="{self.FONT_SIZE_DIM_IMP:.1f}" fill="{dc}" '
f'font-style="italic" text-anchor="middle" '
f'transform="rotate(-90 {dx + self.FONT_SIZE_DIM_IMP:.2f} {mid_y:.2f})">'
f'{label[1]}</text>\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'<line x1="{cx:.2f}" y1="{oy:.2f}" x2="{cx:.2f}" y2="{dy:.2f}" '
f'stroke="{dc}" stroke-width="0.3"/>\n'
f'<line x1="{cx:.2f}" y1="{dy:.2f}" x2="{cx + sw:.2f}" y2="{dy:.2f}" '
f'stroke="{dc}" stroke-width="0.4" '
f'marker-start="url(#arrowR)" marker-end="url(#arrow)"/>\n'
f'<text x="{mid_x:.2f}" y="{dy - 1.5:.2f}" '
f'font-size="{self.FONT_SIZE_DIM:.1f}" fill="{dc}" text-anchor="middle">'
f'{label[0]}</text>\n'
)
cx += sw
# Close last extension line
parts.append(
f'<line x1="{cx:.2f}" y1="{oy:.2f}" x2="{cx:.2f}" y2="{dy:.2f}" '
f'stroke="{dc}" stroke-width="0.3"/>'
)
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'<line x1="{ox:.2f}" y1="{cy:.2f}" x2="{dx:.2f}" y2="{cy:.2f}" '
f'stroke="{dc}" stroke-width="0.3"/>\n'
f'<line x1="{dx:.2f}" y1="{cy:.2f}" x2="{dx:.2f}" y2="{cy + sh:.2f}" '
f'stroke="{dc}" stroke-width="0.4" '
f'marker-start="url(#arrowR)" marker-end="url(#arrow)"/>\n'
f'<text x="{dx + 1.5:.2f}" y="{mid_y:.2f}" '
f'font-size="{self.FONT_SIZE_DIM:.1f}" fill="{dc}" text-anchor="middle" '
f'transform="rotate(90 {dx + 1.5:.2f} {mid_y:.2f})">'
f'{label[0]}</text>\n'
)
cy += sh
parts.append(
f'<line x1="{ox:.2f}" y1="{cy:.2f}" x2="{dx:.2f}" y2="{cy:.2f}" '
f'stroke="{dc}" stroke-width="0.3"/>'
)
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'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
f'fill="{bg}" stroke="{lc}" stroke-width="0.4"/>',
]
# Left section: model name + number
parts.append(
f'<text x="{x + 3:.1f}" y="{y + 7:.1f}" '
f'font-size="{self.FONT_SIZE_TITLE:.1f}" fill="{lc}" font-weight="bold">'
f'{db.get("display_name", db.get("model_number", ""))} '
f'{db.get("model_number", "")}</text>'
)
# 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'<text x="{col_x:.1f}" y="{row_y:.1f}" '
f'font-size="{fs:.1f}" fill="{lc}">'
f'<tspan font-weight="bold">{label}: </tspan>{val}</text>'
)
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'<text x="{cx:.1f}" y="{cy:.1f}" '
f'font-size="{fs:.1f}" fill="{lc}" text-anchor="end">'
f'<tspan font-weight="bold">{k}: </tspan>{v}</text>'
)
cy += 5
# Company name top-right corner
parts.append(
f'<text x="{self.sheet_w - self.MARGIN - 3:.1f}" y="{y + 13:.1f}" '
f'font-size="{fs + 0.5:.1f}" fill="{lc}" text-anchor="end" font-weight="bold">'
f'{db.get("company", "")}</text>'
)
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("<!--") or not line or line.startswith("#"):
continue
if ":" in line:
k, _, v = line.partition(":")
k, v = k.strip(), v.strip()
std_keys = {"model_number", "display_name", "revision", "drawing_date",
"drawn_by", "company", "units_note", "scale"}
if k in std_keys:
result[k] = v
elif k and v:
result["custom_fields"][k] = v
except Exception as e:
logger.warning(f"Datablock file parse error: {e}")
return result
def _load_mapping(mapping_file: Path) -> dict | None:
try:
return json.loads(Path(mapping_file).read_text(encoding="utf-8"))
except Exception as e:
logger.warning(f"Mapping file load error: {e}")
return None