b3c3e2a3b2
- backend/app: FastAPI API wrapping the CAD skill modules - upload -> job -> poll -> model / BOM / artifacts -> geometry query - SQLite via SQLModel (Model, Job, BomRow, QueryLog) - ThreadPoolExecutor worker, serialized, with live stage updates - docker-compose.yml: dev server (mounts source, --reload) on :8000 - api-test.sh: end-to-end live validation script - requirements.txt: add fastapi, uvicorn, python-multipart, sqlmodel - external_diagram.py: port active-area detection OCC.Core -> OCP - .gitignore, PHASE1.md Validated live: MR16 round-trip passes (28 BOM rows, 12 artifacts, bounding-box query, xlsx download; active-area detection working). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1148 lines
45 KiB
Python
1148 lines
45 KiB
Python
"""
|
||
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":
|
||
# build123d ships the OpenCASCADE kernel as OCP (cadquery-ocp), not the
|
||
# pythonocc `OCC.Core` package. OCP mirrors the same module names and the
|
||
# same `_s` static-method suffix, so this is a 1:1 import rename.
|
||
from OCP.BRepGProp import BRepGProp
|
||
from OCP.GProp import GProp_GProps
|
||
from OCP.GeomAdaptor import GeomAdaptor_Surface
|
||
from OCP.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 OCP.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
|