1785 lines
67 KiB
Python
1785 lines
67 KiB
Python
"""
|
|
Technical Drawing engine for Fluency CAD.
|
|
|
|
Pure module — no Qt widget dependencies. Produces projected vector
|
|
primitives, dimension candidates, and exportable render results from
|
|
component and assembly geometry using OCC hidden-line removal.
|
|
|
|
Exact public API:
|
|
DrawingSourcePart, DrawingCandidate, DrawingPrimitive, DrawingRenderResult
|
|
build_source_parts, generate_view, generate_drawing
|
|
render_drawing, export_drawing_svg, export_drawing_pdf
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
|
|
import numpy as np
|
|
|
|
from fluency.models.data_model import DrawingView, Project, TechnicalDrawing
|
|
from fluency.geometry_occ.kernel import OCGeometryKernel
|
|
|
|
from PySide6.QtCore import Qt, QPointF, QRectF
|
|
from PySide6.QtGui import QPainter, QPen, QColor, QFont
|
|
|
|
# ── Public records ─────────────────────────────────────────────────────────
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DrawingSourcePart:
|
|
"""One geometry source for projection."""
|
|
|
|
part_id: str
|
|
display_name: str
|
|
shape: Any # TopoDS_Shape (OCP wrapped)
|
|
color: Tuple[float, float, float]
|
|
component_id: str
|
|
assembly_instance_id: Optional[str] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DrawingCandidate:
|
|
"""A dimension candidate extracted from projected geometry."""
|
|
|
|
key: str
|
|
view_id: str
|
|
kind: str # "extent", "length", "diameter", "radius", "angle"
|
|
references: Tuple[str, ...]
|
|
value: float
|
|
anchor_points: Tuple[Tuple[float, float], ...]
|
|
label: str
|
|
# Unit vector in sheet space along which the distance is measured
|
|
# (linear/extent candidates). Empty for diameter/angle.
|
|
direction: Tuple[float, float] = ()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DrawingPrimitive:
|
|
"""One vector primitive ready to paint."""
|
|
|
|
kind: str # "line","circle","arc","centerline","dimension","arrowhead","text","balloon","table"
|
|
points: Tuple[Tuple[float, float], ...]
|
|
style: str # "visible","hidden","construction","dimension"
|
|
text: Optional[str] = None
|
|
candidate_key: Optional[str] = None
|
|
center: Optional[Tuple[float, float]] = None
|
|
radius: Optional[float] = None
|
|
dash_pattern: Tuple[float, ...] = ()
|
|
# View this primitive belongs to (geometry primitives); used for
|
|
# hit-testing in the drawing workbench.
|
|
view_id: Optional[str] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DrawingRenderResult:
|
|
"""Complete renderable drawing output."""
|
|
|
|
primitives: Tuple[DrawingPrimitive, ...]
|
|
candidates: Tuple[DrawingCandidate, ...]
|
|
resolved_annotation_ids: Tuple[str, ...]
|
|
unresolved_annotation_ids: Tuple[str, ...]
|
|
source_fingerprint: str
|
|
warnings: Tuple[str, ...]
|
|
# Per-view model→sheet transform: view_id → (scale, offset_x, offset_y)
|
|
# with sheet(x, y) = (x*scale + offset_x, y*scale + offset_y).
|
|
view_transforms: Dict[str, Tuple[float, float, float]] = field(default_factory=dict)
|
|
|
|
|
|
# ── View presets ───────────────────────────────────────────────────────────
|
|
|
|
_STANDARD_VIEWS: Dict[str, Tuple[Tuple[float, float, float], Tuple[float, float, float]]] = {
|
|
"front": ((0.0, -1.0, 0.0), (0.0, 0.0, 1.0)),
|
|
"back": ((0.0, 1.0, 0.0), (0.0, 0.0, 1.0)),
|
|
"top": ((0.0, 0.0, 1.0), (0.0, -1.0, 0.0)),
|
|
"bottom": ((0.0, 0.0, -1.0), (0.0, 1.0, 0.0)),
|
|
"right": ((1.0, 0.0, 0.0), (0.0, 0.0, 1.0)),
|
|
"left": ((-1.0, 0.0, 0.0), (0.0, 0.0, 1.0)),
|
|
"isometric": ((1.0, -1.0, 1.0), (0.0, 0.0, 1.0)),
|
|
}
|
|
|
|
# Stable ordered view rows (id, display name, direction, up) for UI iteration.
|
|
# _STANDARD_VIEWS stays a dict keyed by id; this list gives a predictable order.
|
|
_STANDARD_VIEW_ROWS: List[Tuple[str, str, Tuple[float, float, float], Tuple[float, float, float]]] = [
|
|
(k, k.capitalize(), v[0], v[1]) for k, v in _STANDARD_VIEWS.items()
|
|
]
|
|
|
|
_A3_WIDTH_MM = 420.0
|
|
_A3_HEIGHT_MM = 297.0
|
|
_TITLE_MARGIN_MM = 40.0
|
|
_DISPLAY_PRECISION = 2
|
|
|
|
|
|
def _normalize(v: Tuple[float, float, float]) -> Tuple[float, float, float]:
|
|
x, y, z = v
|
|
norm = math.sqrt(x * x + y * y + z * z)
|
|
if norm < 1e-12:
|
|
return (0.0, 0.0, 1.0)
|
|
inv = 1.0 / norm
|
|
return (x * inv, y * inv, z * inv)
|
|
|
|
|
|
def _cross(a: Tuple[float, float, float], b: Tuple[float, float, float]) -> Tuple[float, float, float]:
|
|
return (
|
|
a[1] * b[2] - a[2] * b[1],
|
|
a[2] * b[0] - a[0] * b[2],
|
|
a[0] * b[1] - a[1] * b[0],
|
|
)
|
|
|
|
|
|
def _dot(a: Tuple[float, float, float], b: Tuple[float, float, float]) -> float:
|
|
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
|
|
|
|
# ── Source-part builders ───────────────────────────────────────────────────
|
|
|
|
|
|
def build_source_parts(
|
|
project: Project,
|
|
source_kind: str,
|
|
source_id: str,
|
|
kernel: OCGeometryKernel,
|
|
) -> Tuple[Tuple[DrawingSourcePart, ...], Tuple[str, ...]]:
|
|
"""Collect visible solid bodies as source parts for projection.
|
|
|
|
Returns ``(parts, warnings)``.
|
|
"""
|
|
warnings: List[str] = []
|
|
parts: List[DrawingSourcePart] = []
|
|
|
|
if source_kind == "component":
|
|
comp = project.get_component_by_id(source_id)
|
|
if comp is None:
|
|
return (), (f"Component {source_id} not found",)
|
|
for bid, body in sorted(comp.bodies.items()):
|
|
if body.geometry is None or not body.visible:
|
|
continue
|
|
shape = kernel._get_shape(body.geometry)
|
|
if shape is None:
|
|
warnings.append(f"Body {body.name} ({bid}) has no extractable shape")
|
|
continue
|
|
parts.append(
|
|
DrawingSourcePart(
|
|
part_id=bid,
|
|
display_name=body.name,
|
|
shape=shape,
|
|
color=body.color,
|
|
component_id=source_id,
|
|
)
|
|
)
|
|
if not parts:
|
|
warnings.append("Component has no visible solid geometry")
|
|
|
|
elif source_kind == "assembly":
|
|
asm = project.assemblies.get(source_id)
|
|
if asm is None:
|
|
return (), (f"Assembly {source_id} not found",)
|
|
for ac_id, ac in sorted(asm.components.items()):
|
|
comp = project.get_component_by_id(ac.component_id)
|
|
if comp is None:
|
|
warnings.append(f"Assembly component {ac_id} refs missing component {ac.component_id}")
|
|
continue
|
|
for bid, body in sorted(comp.bodies.items()):
|
|
if body.geometry is None or not body.visible:
|
|
continue
|
|
shape = kernel._get_shape(body.geometry)
|
|
if shape is None:
|
|
warnings.append(f"Body {body.name} ({bid}) has no extractable shape")
|
|
continue
|
|
transformed = _apply_ocp_transform(shape, ac.position, ac.rotation)
|
|
parts.append(
|
|
DrawingSourcePart(
|
|
part_id=f"{ac_id}/{bid}",
|
|
display_name=f"{comp.name}:{body.name}",
|
|
shape=transformed,
|
|
color=body.color,
|
|
component_id=ac.component_id,
|
|
assembly_instance_id=ac_id,
|
|
)
|
|
)
|
|
if not parts:
|
|
warnings.append("Assembly has no visible solid geometry")
|
|
|
|
else:
|
|
return (), (f"Unknown source kind: {source_kind}",)
|
|
|
|
return tuple(parts), tuple(warnings)
|
|
|
|
|
|
def _apply_ocp_transform(shape: Any, position: np.ndarray, rotation: np.ndarray) -> Any:
|
|
"""Apply position+rotation to an OCP TopoDS_Shape, return new shape."""
|
|
from OCP.gp import gp_Trsf, gp_Vec, gp_Quaternion
|
|
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
|
|
|
trsf = gp_Trsf()
|
|
rx = np.asarray(rotation, dtype=float).reshape(3, 3)
|
|
q = _mat_to_quat(rx)
|
|
q_ocp = gp_Quaternion(q[0], q[1], q[2], q[3])
|
|
trsf.SetRotation(q_ocp)
|
|
pos = np.asarray(position, dtype=float).flatten()
|
|
trsf_vec = gp_Vec(float(pos[0]), float(pos[1]), float(pos[2]))
|
|
trsf.SetTranslationPart(trsf_vec)
|
|
return BRepBuilderAPI_Transform(shape, trsf, True).Shape()
|
|
|
|
|
|
def _mat_to_quat(m: np.ndarray) -> Tuple[float, float, float, float]:
|
|
"""Convert 3x3 rotation matrix to (w, x, y, z) quaternion."""
|
|
trace = m[0, 0] + m[1, 1] + m[2, 2]
|
|
if trace > 0:
|
|
s = math.sqrt(trace + 1.0) * 2.0
|
|
w = 0.25 * s
|
|
x = (m[2, 1] - m[1, 2]) / s
|
|
y = (m[0, 2] - m[2, 0]) / s
|
|
z = (m[1, 0] - m[0, 1]) / s
|
|
elif m[0, 0] > m[1, 1] and m[0, 0] > m[2, 2]:
|
|
s = math.sqrt(1.0 + m[0, 0] - m[1, 1] - m[2, 2]) * 2.0
|
|
w = (m[2, 1] - m[1, 2]) / s
|
|
x = 0.25 * s
|
|
y = (m[0, 1] + m[1, 0]) / s
|
|
z = (m[0, 2] + m[2, 0]) / s
|
|
elif m[1, 1] > m[2, 2]:
|
|
s = math.sqrt(1.0 + m[1, 1] - m[0, 0] - m[2, 2]) * 2.0
|
|
w = (m[0, 2] - m[2, 0]) / s
|
|
x = (m[0, 1] + m[1, 0]) / s
|
|
y = 0.25 * s
|
|
z = (m[1, 2] + m[2, 1]) / s
|
|
else:
|
|
s = math.sqrt(1.0 + m[2, 2] - m[0, 0] - m[1, 1]) * 2.0
|
|
w = (m[1, 0] - m[0, 1]) / s
|
|
x = (m[0, 2] + m[2, 0]) / s
|
|
y = (m[1, 2] + m[2, 1]) / s
|
|
z = 0.25 * s
|
|
return (w, x, y, z)
|
|
|
|
|
|
# ── View projection ────────────────────────────────────────────────────────
|
|
|
|
|
|
def _project_view(
|
|
source_parts: Sequence[DrawingSourcePart],
|
|
view: DrawingView,
|
|
) -> Tuple[
|
|
List[Tuple[Tuple[float, float], Tuple[float, float], str, str]],
|
|
List[str],
|
|
]:
|
|
"""Project one view's edges via HLR.
|
|
|
|
Returns ``(edges, warnings)`` where each edge is
|
|
``(p1, p2, curve_type, style)`` in model (view-plane) units and
|
|
*style* is ``"visible"`` or ``"hidden"``.
|
|
"""
|
|
warnings: List[str] = []
|
|
edges: List[Tuple[Tuple[float, float], Tuple[float, float], str, str]] = []
|
|
|
|
direction = view.direction or (0.0, -1.0, 0.0)
|
|
direction = _normalize(direction)
|
|
|
|
for part in source_parts:
|
|
try:
|
|
vis_edges, hid_edges = _project_part_edges(part.shape, direction)
|
|
except Exception as exc:
|
|
warnings.append(f"HLR projection failed for {part.display_name}: {exc}")
|
|
continue
|
|
edges.extend((p1, p2, ct, "visible") for p1, p2, ct in vis_edges)
|
|
if view.show_hidden_lines:
|
|
edges.extend((p1, p2, ct, "hidden") for p1, p2, ct in hid_edges)
|
|
|
|
return edges, warnings
|
|
|
|
|
|
def _edges_bounds(
|
|
edges: Sequence[Tuple[Tuple[float, float], Tuple[float, float], str, str]],
|
|
) -> Tuple[float, float, float, float]:
|
|
"""Bounding box ``(min_x, min_y, max_x, max_y)`` of projected edges.
|
|
|
|
Full circles contribute their extremes, not just the centre/radius
|
|
marker points.
|
|
"""
|
|
min_x = min_y = math.inf
|
|
max_x = max_y = -math.inf
|
|
for p1, p2, curve_type, _style in edges:
|
|
if curve_type == "circle_full":
|
|
cx, cy = p1
|
|
radius = p2[0] - p1[0]
|
|
min_x = min(min_x, cx - radius)
|
|
max_x = max(max_x, cx + radius)
|
|
min_y = min(min_y, cy - radius)
|
|
max_y = max(max_y, cy + radius)
|
|
else:
|
|
for pt in (p1, p2):
|
|
min_x = min(min_x, pt[0])
|
|
max_x = max(max_x, pt[0])
|
|
min_y = min(min_y, pt[1])
|
|
max_y = max(max_y, pt[1])
|
|
if min_x > max_x:
|
|
return (0.0, 0.0, 0.0, 0.0)
|
|
return (min_x, min_y, max_x, max_y)
|
|
|
|
|
|
|
|
def _assemble_view(
|
|
edges: Sequence[Tuple[Tuple[float, float], Tuple[float, float], str, str]],
|
|
warnings: Sequence[str],
|
|
view: DrawingView,
|
|
slot: Optional[Tuple[float, float, float, float]] = None,
|
|
scale_override: Optional[float] = None,
|
|
transforms: Optional[Dict[str, Tuple[float, float, float]]] = None,
|
|
) -> Tuple[Tuple[DrawingPrimitive, ...], Tuple[DrawingCandidate, ...], Tuple[str, ...]]:
|
|
"""Fit projected edges into *slot* and emit primitives + candidates.
|
|
|
|
*slot* is ``(left, bottom, width, height)`` in sheet mm (origin
|
|
bottom-left, +y up). When omitted the projection is fitted to the
|
|
whole sheet. *scale_override* forces a specific model→sheet scale
|
|
(used to keep all orthographic views at one shared scale).
|
|
When *transforms* is given, the resolved
|
|
``(scale, offset_x, offset_y)`` is recorded under the view id.
|
|
"""
|
|
warnings = list(warnings)
|
|
primitives: List[DrawingPrimitive] = []
|
|
candidates: List[DrawingCandidate] = []
|
|
|
|
if not edges:
|
|
warnings.append(f"View '{view.name or view.kind}': no projected edges")
|
|
return tuple(primitives), tuple(candidates), tuple(warnings)
|
|
|
|
# Separate geometry by curve type (model units).
|
|
line_segments: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
|
|
circle_data: List[Tuple[float, float, float]] = [] # (cx, cy, r)
|
|
for p1, p2, curve_type, _style in edges:
|
|
if curve_type == "circle_full":
|
|
cx, cy = p1
|
|
radius = p2[0] - p1[0]
|
|
if radius > 0.5:
|
|
circle_data.append((cx, cy, radius))
|
|
elif curve_type == "line":
|
|
line_segments.append((p1, p2))
|
|
# "other" = sampled arc points; used for fitting/rendering only.
|
|
|
|
min_x, min_y, max_x, max_y = _edges_bounds(edges)
|
|
geom_w = max(max_x - min_x, 1e-6)
|
|
geom_h = max(max_y - min_y, 1e-6)
|
|
|
|
if slot is not None:
|
|
left, bottom, avail_w, avail_h = slot
|
|
else:
|
|
left, bottom = 0.0, 0.0
|
|
avail_w = _A3_WIDTH_MM - _TITLE_MARGIN_MM * 2
|
|
avail_h = _A3_HEIGHT_MM - _TITLE_MARGIN_MM * 2
|
|
|
|
if scale_override is not None:
|
|
scale = scale_override
|
|
else:
|
|
scale = min(avail_w / geom_w, avail_h / geom_h) * view.scale
|
|
offset_x = left + (avail_w - geom_w * scale) / 2.0 - min_x * scale
|
|
offset_y = bottom + (avail_h - geom_h * scale) / 2.0 - min_y * scale
|
|
|
|
# Use kind as view_id for readability (UUID is opaque to users).
|
|
view_id = view.kind if view.kind in _STANDARD_VIEWS else (view.name or view.id)
|
|
if transforms is not None:
|
|
transforms[view_id] = (scale, offset_x, offset_y)
|
|
|
|
def _to_sheet(x: float, y: float) -> Tuple[float, float]:
|
|
return (x * scale + offset_x, y * scale + offset_y)
|
|
|
|
# Convert edges to primitives (proper circle + hidden-line styles).
|
|
for p1, p2, curve_type, style in edges:
|
|
if curve_type == "circle_full":
|
|
cx, cy = p1
|
|
radius = p2[0] - p1[0]
|
|
if radius <= 0.5:
|
|
continue
|
|
primitives.append(
|
|
DrawingPrimitive(
|
|
kind="circle",
|
|
points=(),
|
|
style=style,
|
|
center=_to_sheet(cx, cy),
|
|
radius=radius * scale,
|
|
view_id=view_id,
|
|
)
|
|
)
|
|
else:
|
|
primitives.append(
|
|
DrawingPrimitive(
|
|
kind="line",
|
|
points=(_to_sheet(*p1), _to_sheet(*p2)),
|
|
style=style,
|
|
view_id=view_id,
|
|
)
|
|
)
|
|
|
|
# ── Dimension candidate extraction ──────────────────────────────
|
|
|
|
# 1. Overall extents (bounding-box width/height), anchored at real
|
|
# bbox corners so extension lines can start at feature extremes.
|
|
# Values are true model dimensions (scale-independent) — the labels
|
|
# must match the part, not the sheet scale.
|
|
width_val = (max_x - min_x)
|
|
height_val = (max_y - min_y)
|
|
bl = _to_sheet(min_x, min_y)
|
|
br = _to_sheet(max_x, min_y)
|
|
tl = _to_sheet(min_x, max_y)
|
|
candidates.append(
|
|
DrawingCandidate(
|
|
key=f"{view_id}:extent:width",
|
|
view_id=view_id,
|
|
kind="extent",
|
|
references=(),
|
|
value=width_val,
|
|
anchor_points=(bl, br),
|
|
label=f"{width_val:.{_DISPLAY_PRECISION}f}",
|
|
direction=(0.0, -1.0),
|
|
)
|
|
)
|
|
candidates.append(
|
|
DrawingCandidate(
|
|
key=f"{view_id}:extent:height",
|
|
view_id=view_id,
|
|
kind="extent",
|
|
references=(),
|
|
value=height_val,
|
|
anchor_points=(bl, tl),
|
|
label=f"{height_val:.{_DISPLAY_PRECISION}f}",
|
|
direction=(-1.0, 0.0),
|
|
)
|
|
)
|
|
|
|
# 2. Diameter candidates from detected circles.
|
|
_extract_diameter_candidates(circle_data, view_id, scale, offset_x, offset_y, candidates)
|
|
|
|
# 3. Linear distance candidates between prominent parallel edges.
|
|
_extract_linear_candidates(line_segments, view_id, scale, offset_x, offset_y, candidates)
|
|
|
|
# 4. Angle candidates from intersecting lines.
|
|
_extract_angle_candidates(line_segments, view_id, scale, offset_x, offset_y, candidates)
|
|
|
|
return tuple(primitives), tuple(candidates), tuple(warnings)
|
|
|
|
|
|
def generate_view(
|
|
source_parts: Sequence[DrawingSourcePart],
|
|
view: DrawingView,
|
|
slot: Optional[Tuple[float, float, float, float]] = None,
|
|
) -> Tuple[Tuple[DrawingPrimitive, ...], Tuple[DrawingCandidate, ...], Tuple[str, ...]]:
|
|
"""Project one view from source parts.
|
|
|
|
*slot* optionally limits the fit to ``(left, bottom, width, height)``
|
|
in sheet mm; when omitted the projection is fitted to the full A3
|
|
sheet. Returns ``(primitives, candidates, warnings)``.
|
|
"""
|
|
edges, warnings = _project_view(source_parts, view)
|
|
return _assemble_view(edges, warnings, view, slot, None)
|
|
|
|
|
|
def _project_part_edges(
|
|
shape: Any,
|
|
direction: Tuple[float, float, float],
|
|
) -> Tuple[
|
|
List[Tuple[Tuple[float, float], Tuple[float, float], str]],
|
|
List[Tuple[Tuple[float, float], Tuple[float, float], str]],
|
|
]:
|
|
"""Project one part's edges using HLRBRep_Algo.
|
|
|
|
HLR output edges already lie in the projector's view plane (Z≈0).
|
|
Returns (visible_edges, hidden_edges) as 2D (p1, p2, curve_type).
|
|
"""
|
|
from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape
|
|
from OCP.HLRAlgo import HLRAlgo_Projector
|
|
from OCP.gp import gp_Ax2, gp_Pnt, gp_Dir
|
|
from OCP.TopExp import TopExp_Explorer
|
|
from OCP.TopAbs import TopAbs_EDGE
|
|
from OCP.TopoDS import TopoDS
|
|
from OCP.BRepLib import BRepLib
|
|
|
|
_HIDE_TOL = 1.0 / 1e6
|
|
|
|
dx, dy, dz = direction
|
|
projector = HLRAlgo_Projector(gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(dx, dy, dz)))
|
|
hlr = HLRBRep_Algo()
|
|
hlr.Add(shape, 0)
|
|
hlr.Projector(projector)
|
|
hlr.Update()
|
|
hlr.Hide()
|
|
|
|
hlr_shapes = HLRBRep_HLRToShape(hlr)
|
|
|
|
visible_edges: List[Tuple[Tuple[float, float], Tuple[float, float], str]] = []
|
|
hidden_edges: List[Tuple[Tuple[float, float], Tuple[float, float], str]] = []
|
|
|
|
def _add_edges(compound: Any, out: List) -> None:
|
|
if compound.IsNull():
|
|
return
|
|
BRepLib.BuildCurves3d_s(compound, _HIDE_TOL)
|
|
exp = TopExp_Explorer(compound, TopAbs_EDGE)
|
|
while exp.More():
|
|
edge = TopoDS.Edge_s(exp.Current())
|
|
_collect_edge(edge, out)
|
|
exp.Next()
|
|
|
|
_add_edges(hlr_shapes.VCompound(), visible_edges)
|
|
_add_edges(hlr_shapes.Rg1LineVCompound(), visible_edges)
|
|
_add_edges(hlr_shapes.OutLineVCompound(), visible_edges)
|
|
_add_edges(hlr_shapes.HCompound(), hidden_edges)
|
|
_add_edges(hlr_shapes.OutLineHCompound(), hidden_edges)
|
|
|
|
return visible_edges, hidden_edges
|
|
|
|
|
|
def _collect_edge(
|
|
edge: Any,
|
|
out: List[Tuple[Tuple[float, float], Tuple[float, float], str]],
|
|
num_samples: int = 32,
|
|
) -> None:
|
|
"""Sample an OCP edge from HLR output (already in view plane) into 2D segments."""
|
|
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
|
from OCP.GeomAbs import GeomAbs_Line, GeomAbs_Circle
|
|
from OCP.Geom import Geom_Circle
|
|
|
|
if edge.IsNull():
|
|
return
|
|
curve = BRepAdaptor_Curve(edge)
|
|
ct = curve.GetType()
|
|
first = curve.FirstParameter()
|
|
last = curve.LastParameter()
|
|
|
|
if ct == GeomAbs_Line:
|
|
p1 = curve.Value(first)
|
|
p2 = curve.Value(last)
|
|
out.append(((p1.X(), p1.Y()), (p2.X(), p2.Y()), "line"))
|
|
elif ct == GeomAbs_Circle:
|
|
# Full circles are recorded as a centre/radius marker (used for
|
|
# rendering + diameter detection); partial arcs are sampled into
|
|
# polyline segments so fillets do not become full circles.
|
|
geom_circ = curve.Circle() # Geom_Circle
|
|
center = geom_circ.Position().Location()
|
|
radius = geom_circ.Radius()
|
|
span = last - first
|
|
if span >= 2.0 * math.pi - 0.05:
|
|
out.append(
|
|
((center.X(), center.Y()), (center.X() + radius, center.Y()), "circle_full"),
|
|
)
|
|
else:
|
|
prev: Optional[Tuple[float, float]] = None
|
|
for i in range(num_samples + 1):
|
|
t = first + span * i / num_samples
|
|
p = curve.Value(t)
|
|
cur = (p.X(), p.Y())
|
|
if prev is not None:
|
|
out.append((prev, cur, "other"))
|
|
prev = cur
|
|
else:
|
|
prev: Optional[Tuple[float, float]] = None
|
|
for i in range(num_samples + 1):
|
|
t = first + (last - first) * i / num_samples
|
|
p = curve.Value(t)
|
|
cur = (p.X(), p.Y())
|
|
if prev is not None:
|
|
out.append((prev, cur, "other"))
|
|
prev = cur
|
|
|
|
|
|
# ── Dimension candidate extraction helpers ─────────────────────────────────
|
|
|
|
def _extract_diameter_candidates(
|
|
circle_data: List[Tuple[float, float, float]],
|
|
view_id: str,
|
|
scale: float,
|
|
offset_x: float,
|
|
offset_y: float,
|
|
candidates: List[DrawingCandidate],
|
|
) -> None:
|
|
"""Detect diameter dimensions from projected circles."""
|
|
if not circle_data:
|
|
return
|
|
|
|
def _to_sheet(x: float, y: float) -> Tuple[float, float]:
|
|
return (x * scale + offset_x, y * scale + offset_y)
|
|
|
|
# Cluster circles by radius (within 1% tolerance) to find distinct diameters.
|
|
clusters: List[List[Tuple[float, float, float]]] = []
|
|
for cx, cy, r in circle_data:
|
|
placed = False
|
|
for cluster in clusters:
|
|
ref_r = cluster[0][2]
|
|
if abs(r - ref_r) / max(ref_r, 1e-9) < 0.01:
|
|
cluster.append((cx, cy, r))
|
|
placed = True
|
|
break
|
|
if not placed:
|
|
clusters.append([(cx, cy, r)])
|
|
|
|
for i, cluster in enumerate(clusters):
|
|
avg_r = sum(c[2] for c in cluster) / len(cluster)
|
|
diam = 2.0 * avg_r # true model diameter, not sheet-scaled
|
|
# Use first circle center as anchor.
|
|
cx, cy = cluster[0][0], cluster[0][1]
|
|
p1 = _to_sheet(cx - avg_r, cy)
|
|
p2 = _to_sheet(cx + avg_r, cy)
|
|
candidates.append(
|
|
DrawingCandidate(
|
|
key=f"{view_id}:diameter:{i}",
|
|
view_id=view_id,
|
|
kind="diameter",
|
|
references=(),
|
|
value=diam,
|
|
anchor_points=(p1, p2),
|
|
label=f"Ø{diam:.{_DISPLAY_PRECISION}f}",
|
|
)
|
|
)
|
|
|
|
|
|
def _extract_linear_candidates(
|
|
line_segments: List[Tuple[Tuple[float, float], Tuple[float, float]]],
|
|
view_id: str,
|
|
scale: float,
|
|
offset_x: float,
|
|
offset_y: float,
|
|
candidates: List[DrawingCandidate],
|
|
) -> None:
|
|
"""Detect linear distance dimensions between prominent parallel edges."""
|
|
if len(line_segments) < 2:
|
|
return
|
|
|
|
def _to_sheet(x: float, y: float) -> Tuple[float, float]:
|
|
return (x * scale + offset_x, y * scale + offset_y)
|
|
|
|
# Find pairs of approximately parallel segments and measure distance between them.
|
|
# Limit to avoid combinatorial explosion.
|
|
max_pairs = 20
|
|
pair_count = 0
|
|
|
|
for i in range(len(line_segments)):
|
|
if pair_count >= max_pairs:
|
|
break
|
|
p1, p2 = line_segments[i]
|
|
dx1 = p2[0] - p1[0]
|
|
dy1 = p2[1] - p1[1]
|
|
len1 = math.sqrt(dx1 * dx1 + dy1 * dy1)
|
|
if len1 < 2.0:
|
|
continue
|
|
|
|
for j in range(i + 1, len(line_segments)):
|
|
if pair_count >= max_pairs:
|
|
break
|
|
q1, q2 = line_segments[j]
|
|
dx2 = q2[0] - q1[0]
|
|
dy2 = q2[1] - q1[1]
|
|
len2 = math.sqrt(dx2 * dx2 + dy2 * dy2)
|
|
if len2 < 2.0:
|
|
continue
|
|
|
|
# Check if segments are approximately parallel (dot product of normalized dirs).
|
|
dot = (dx1 * dx2 + dy1 * dy2) / (len1 * len2)
|
|
if abs(dot) < 0.95:
|
|
continue
|
|
|
|
# Measure perpendicular distance between segment midpoints.
|
|
mx1, my1 = (p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0
|
|
mx2, my2 = (q1[0] + q2[0]) / 2.0, (q1[1] + q2[1]) / 2.0
|
|
|
|
# Distance perpendicular to segment direction (model units —
|
|
# labels must show true part dimensions, not sheet-scaled ones).
|
|
nx, ny = -dy1 / len1, dx1 / len1 # normal
|
|
dist = abs((mx2 - mx1) * nx + (my2 - my1) * ny)
|
|
|
|
if dist < 0.5 or dist > 500.0:
|
|
continue
|
|
|
|
s_m1 = _to_sheet(mx1, my1)
|
|
s_m2 = _to_sheet(mx2, my2)
|
|
candidates.append(
|
|
DrawingCandidate(
|
|
key=f"{view_id}:linear:{pair_count}",
|
|
view_id=view_id,
|
|
kind="length",
|
|
references=(),
|
|
value=dist,
|
|
anchor_points=(s_m1, s_m2),
|
|
label=f"{dist:.{_DISPLAY_PRECISION}f}",
|
|
direction=(nx, ny),
|
|
)
|
|
)
|
|
pair_count += 1
|
|
|
|
def _extract_angle_candidates(
|
|
line_segments: List[Tuple[Tuple[float, float], Tuple[float, float]]],
|
|
view_id: str,
|
|
scale: float,
|
|
offset_x: float,
|
|
offset_y: float,
|
|
candidates: List[DrawingCandidate],
|
|
) -> None:
|
|
"""Detect angle dimensions between intersecting lines.
|
|
|
|
Anchors are ``(vertex, arm1_end, arm2_end)`` in sheet coordinates so
|
|
the renderer can draw a small arc between the two arms.
|
|
"""
|
|
if len(line_segments) < 2:
|
|
return
|
|
|
|
def _to_sheet(x: float, y: float) -> Tuple[float, float]:
|
|
return (x * scale + offset_x, y * scale + offset_y)
|
|
|
|
# Find pairs of segments that share an endpoint and compute angle.
|
|
max_angles = 10
|
|
angle_count = 0
|
|
|
|
for i in range(len(line_segments)):
|
|
if angle_count >= max_angles:
|
|
break
|
|
p1, p2 = line_segments[i]
|
|
|
|
for j in range(i + 1, len(line_segments)):
|
|
if angle_count >= max_angles:
|
|
break
|
|
q1, q2 = line_segments[j]
|
|
|
|
# Check if segments share an endpoint.
|
|
shared = None
|
|
for a, b in [(p1, q1), (p1, q2), (p2, q1), (p2, q2)]:
|
|
if math.dist(a, b) < 0.5:
|
|
shared = a
|
|
break
|
|
|
|
if shared is None:
|
|
continue
|
|
|
|
# Direction vectors from the shared point along each segment.
|
|
other1 = p2 if shared == p1 else p1
|
|
other2 = q2 if shared == q1 else q1
|
|
dx1 = other1[0] - shared[0]
|
|
dy1 = other1[1] - shared[1]
|
|
dx2 = other2[0] - shared[0]
|
|
dy2 = other2[1] - shared[1]
|
|
|
|
dot = dx1 * dx2 + dy1 * dy2
|
|
mag1 = math.sqrt(dx1 * dx1 + dy1 * dy1)
|
|
mag2 = math.sqrt(dx2 * dx2 + dy2 * dy2)
|
|
if mag1 < 0.5 or mag2 < 0.5:
|
|
continue
|
|
|
|
cos_a = dot / (mag1 * mag2)
|
|
angle_rad = math.acos(max(-1.0, min(1.0, cos_a)))
|
|
angle_deg = math.degrees(angle_rad)
|
|
|
|
# Skip near-zero or near-180 angles.
|
|
if angle_deg < 5.0 or angle_deg > 175.0:
|
|
continue
|
|
|
|
candidates.append(
|
|
DrawingCandidate(
|
|
key=f"{view_id}:angle:{angle_count}",
|
|
view_id=view_id,
|
|
kind="angle",
|
|
references=(),
|
|
value=angle_deg,
|
|
anchor_points=(
|
|
_to_sheet(*shared),
|
|
_to_sheet(*other1),
|
|
_to_sheet(*other2),
|
|
),
|
|
label=f"{angle_deg:.{_DISPLAY_PRECISION}f}°",
|
|
)
|
|
)
|
|
angle_count += 1
|
|
|
|
|
|
# ── Drawing generation ─────────────────────────────────────────────────────
|
|
|
|
|
|
def _layout_views_on_sheet(
|
|
views: Sequence[DrawingView],
|
|
bboxes: Dict[str, Tuple[float, float, float, float]],
|
|
) -> Tuple[Dict[str, Tuple[float, float, float, float]], Optional[float]]:
|
|
"""Compute a slot rectangle for each view in standard orthographic layout.
|
|
|
|
*bboxes* maps view_id → ``(min_x, min_y, max_x, max_y)`` in model
|
|
units (from :func:`_edges_bounds`). Returns ``(slots, common_scale)``:
|
|
slots are ``(left, bottom, width, height)`` in sheet mm (origin at the
|
|
sheet's bottom-left corner, +y up).
|
|
|
|
Layout (third-angle projection, aligned projections)::
|
|
|
|
top isometric
|
|
left front right back
|
|
bottom
|
|
|
|
All orthographic views share one scale (the tightest fit that keeps
|
|
every projection in its footprint) so the views stay mutually
|
|
consistent, and each view is centred in its allotted space.
|
|
"""
|
|
if not views:
|
|
return {}, None
|
|
|
|
margin = 10.0
|
|
gap = 12.0
|
|
title_block_h = 55.0
|
|
full_w = _A3_WIDTH_MM - 2 * margin
|
|
# The bottom view sits at the bottom of the projection column, so the
|
|
# whole column stays clear of the title block (bottom-right corner).
|
|
col_bottom = margin + title_block_h
|
|
col_top = _A3_HEIGHT_MM - margin
|
|
col_avail = col_top - col_bottom
|
|
|
|
def dims(vid: str) -> Tuple[float, float]:
|
|
b = bboxes.get(vid)
|
|
if b is None:
|
|
return 1.0, 1.0
|
|
return max(b[2] - b[0], 1e-6), max(b[3] - b[1], 1e-6)
|
|
|
|
ortho_kinds = [
|
|
v.kind for v in views if v.kind in _STANDARD_VIEWS and v.kind != "isometric"
|
|
]
|
|
mid_order = ["left", "front", "right", "back"]
|
|
col_order = ["top", "front", "bottom"] # top → bottom
|
|
present_mid = [k for k in mid_order if k in ortho_kinds]
|
|
present_col = [k for k in col_order if k in ortho_kinds]
|
|
|
|
slots: Dict[str, Tuple[float, float, float, float]] = {}
|
|
common_scale: Optional[float] = None
|
|
|
|
if present_mid or present_col:
|
|
row_w = sum(dims(k)[0] for k in present_mid)
|
|
col_h = sum(dims(k)[1] for k in present_col)
|
|
scale_opts: List[float] = []
|
|
if present_mid:
|
|
scale_opts.append((full_w - gap * (len(present_mid) - 1)) / row_w)
|
|
if present_col:
|
|
scale_opts.append((col_avail - gap * (len(present_col) - 1)) / col_h)
|
|
common_scale = min(scale_opts)
|
|
|
|
# Middle row: left → front → right → back, centred on the sheet.
|
|
total_row = row_w * common_scale + gap * (len(present_mid) - 1)
|
|
x = margin + (full_w - total_row) / 2.0
|
|
row_slots: Dict[str, Tuple[float, float, float]] = {}
|
|
for k in present_mid:
|
|
w, h = dims(k)
|
|
row_slots[k] = (x, w * common_scale, h * common_scale)
|
|
x += w * common_scale + gap
|
|
|
|
# Column: top → front → bottom, stacked from the top edge down and
|
|
# centred in the available column (which stays clear of the title
|
|
# block).
|
|
total_col = col_h * common_scale + gap * (len(present_col) - 1)
|
|
y = col_bottom + col_avail - (col_avail - total_col) / 2.0
|
|
col_slots: Dict[str, Tuple[float, float, float]] = {}
|
|
for k in present_col:
|
|
w, h = dims(k)
|
|
sh = h * common_scale
|
|
col_slots[k] = (y - sh, w * common_scale, sh)
|
|
y -= sh + gap
|
|
|
|
anchor = (
|
|
"front"
|
|
if "front" in present_mid
|
|
else (present_mid[0] if present_mid else present_col[0])
|
|
)
|
|
if anchor in row_slots:
|
|
ax, aw, ah = row_slots[anchor]
|
|
ay = (
|
|
col_slots[anchor][0]
|
|
if anchor in col_slots
|
|
else col_bottom + (col_avail - ah) / 2.0
|
|
)
|
|
else:
|
|
ay, aw, ah = col_slots[anchor]
|
|
ax = margin + (full_w - aw) / 2.0
|
|
anchor_cx = ax + aw / 2.0
|
|
|
|
for k in set(present_mid) | set(present_col):
|
|
w, h = dims(k)
|
|
sw, sh = w * common_scale, h * common_scale
|
|
if k in row_slots and k in col_slots:
|
|
sx = row_slots[k][0]
|
|
sy = col_slots[k][0]
|
|
elif k in row_slots:
|
|
# Mid-row view without a column slot: centre on the anchor.
|
|
sx = row_slots[k][0]
|
|
sy = ay + (ah - sh) / 2.0
|
|
else:
|
|
# Column view without a mid slot: align with the anchor.
|
|
sx = anchor_cx - sw / 2.0
|
|
sy = col_slots[k][0]
|
|
slots[k] = (sx, sy, sw, sh)
|
|
|
|
# Isometric: free region to the right of the main block.
|
|
if any(v.kind == "isometric" for v in views):
|
|
iso_x0 = ax + aw + gap
|
|
if "top" in col_slots:
|
|
iso_x0 = max(iso_x0, anchor_cx + col_slots["top"][1] / 2.0 + gap)
|
|
iso_y0 = ay + ah + gap
|
|
iso_x1 = _A3_WIDTH_MM - margin
|
|
iso_y1 = _A3_HEIGHT_MM - margin
|
|
if iso_x1 - iso_x0 < 30.0 or iso_y1 - iso_y0 < 30.0:
|
|
# No room at the right — fall back to the bottom-left corner.
|
|
left_x = slots.get("left", (margin + full_w * 0.5,))[0]
|
|
iso_x1 = min(iso_x1, left_x - gap)
|
|
bottom_y = slots.get("bottom", (0.0, col_bottom + col_avail * 0.5, 0, 0))[1]
|
|
iso_y1 = min(iso_y1, bottom_y - gap)
|
|
slots["isometric"] = (
|
|
iso_x0,
|
|
iso_y0,
|
|
max(iso_x1 - iso_x0, 10.0),
|
|
max(iso_y1 - iso_y0, 10.0),
|
|
)
|
|
else:
|
|
# No standard ortho views — give the isometric most of the sheet.
|
|
if any(v.kind == "isometric" for v in views):
|
|
slots["isometric"] = (margin, margin, full_w * 0.55, col_avail)
|
|
|
|
# Custom (non-standard) views fill the bottom-left corner.
|
|
custom = [v for v in views if v.kind not in _STANDARD_VIEWS]
|
|
if custom:
|
|
left_edge = slots.get("left", (margin + full_w * 0.4,))[0]
|
|
bottom_edge = slots.get("bottom", (0.0, col_bottom + col_avail * 0.4, 0, 0))[1]
|
|
cw = max(left_edge - margin - gap, 60.0)
|
|
ch = max(bottom_edge - margin - gap, 60.0)
|
|
for i, v in enumerate(custom):
|
|
vid = v.name or v.id
|
|
slots[vid] = (margin, margin + i * (ch + gap), cw, ch)
|
|
|
|
return slots, common_scale
|
|
|
|
|
|
# ── Dimension selection & placement ───────────────────────────────────────
|
|
|
|
# Per-view dimension budget: keep sheets readable for a machinist.
|
|
_DIM_KIND_CAPS = {"diameter": 3, "extent": 2, "length": 4, "angle": 2}
|
|
_MAX_DIMENSIONS_PER_VIEW = 10
|
|
|
|
# Rendering metrics for the drawing font size.
|
|
_DIM_TEXT_H_MM = 3.0 # text cap height (standard A3 drawing)
|
|
_DIM_FONT_W = 1.9 # approx. mm width per character
|
|
_DIM_FONT_H = 4.0 # text box height in mm
|
|
_DIM_OFFSET_MM = 6.0 # dimension-line offset from the measured feature
|
|
_DIM_EXT_OVERSHOOT_MM = 2.0 # extension-line overshoot past the dim line
|
|
_DIM_STANDOFF_MM = 11.0 # min gap between stacked parallel dim lines
|
|
_ARROW_MM = 3.0
|
|
_LEADER_LEAD_MM = 8.0
|
|
_LEADER_TAIL_MM = 10.0
|
|
_ANGLE_ARC_MM = 5.0
|
|
|
|
|
|
def _select_dimensions_for_placement(
|
|
candidates: Sequence[DrawingCandidate],
|
|
view_id: str,
|
|
) -> List[DrawingCandidate]:
|
|
"""Select a subset of dimension candidates to place on the drawing.
|
|
|
|
Filters for manufacturing relevance and avoids redundant dimensions.
|
|
Prioritizes: diameters > extents > significant linear distances > angles.
|
|
Caps the number of placed dimensions per view so sheets stay readable.
|
|
"""
|
|
view_candidates = [c for c in candidates if c.view_id == view_id]
|
|
|
|
def _value_key(val: float) -> float:
|
|
return round(val / 0.5) * 0.5 # bucket by 0.5 for dedup
|
|
|
|
# Overall-extent values: linear candidates matching an extent are
|
|
# redundant (they measure the same overall size).
|
|
extent_keys = {
|
|
_value_key(c.value)
|
|
for c in view_candidates
|
|
if c.kind == "extent" and 0.1 <= c.value <= 2000.0
|
|
}
|
|
|
|
# Sort candidates by priority and value significance.
|
|
def _priority(c: DrawingCandidate) -> Tuple[int, float]:
|
|
kind_order = {"diameter": 0, "extent": 1, "length": 2, "angle": 3, "radius": 4}
|
|
return (kind_order.get(c.kind, 5), -c.value)
|
|
|
|
selected: List[DrawingCandidate] = []
|
|
seen_values: set = set() # track approximate values to avoid duplicates
|
|
per_kind: Dict[str, int] = {}
|
|
|
|
for c in sorted(view_candidates, key=_priority):
|
|
if len(selected) >= _MAX_DIMENSIONS_PER_VIEW:
|
|
break
|
|
# Skip tiny or enormous dimensions.
|
|
if c.value < 0.1 or c.value > 2000.0:
|
|
continue
|
|
# Respect the per-kind budget.
|
|
if per_kind.get(c.kind, 0) >= _DIM_KIND_CAPS.get(c.kind, 3):
|
|
continue
|
|
vkey = _value_key(c.value)
|
|
# Skip duplicates within same kind+view.
|
|
if (c.kind, vkey) in seen_values:
|
|
continue
|
|
# Skip linear duplicates of overall extents.
|
|
if c.kind == "length" and vkey in extent_keys:
|
|
continue
|
|
selected.append(c)
|
|
seen_values.add((c.kind, vkey))
|
|
per_kind[c.kind] = per_kind.get(c.kind, 0) + 1
|
|
|
|
return selected
|
|
|
|
|
|
def _generate_dimension_primitives(
|
|
candidates: Sequence[DrawingCandidate],
|
|
view_center: Tuple[float, float],
|
|
) -> List[DrawingPrimitive]:
|
|
"""Convert dimension candidates into ISO-style renderable primitives.
|
|
|
|
Linear/extent candidates become extension lines + an offset dimension
|
|
line with arrowheads + centred text (dimension line broken for the
|
|
label). Anchors on one feature line yield a dimension line
|
|
perpendicular to the measurement direction; anchors that are the
|
|
closest points of two parallel edges (distance dimensions) yield a
|
|
dimension line parallel to it. Diameters become a 45° leader with a
|
|
horizontal tail. Angles become a small vertex arc + text.
|
|
"""
|
|
# Keep dimension lines inside the sheet (with a small margin).
|
|
_sheet_min_x, _sheet_max_x = 6.0, _A3_WIDTH_MM - 6.0
|
|
_sheet_min_y, _sheet_max_y = 6.0, _A3_HEIGHT_MM - 6.0
|
|
|
|
def _clamp_sheet(pt: Tuple[float, float]) -> Tuple[float, float]:
|
|
return (
|
|
min(max(pt[0], _sheet_min_x), _sheet_max_x),
|
|
min(max(pt[1], _sheet_min_y), _sheet_max_y),
|
|
)
|
|
prims: List[DrawingPrimitive] = []
|
|
|
|
# Track occupied zones to avoid overlapping dimension text.
|
|
occupied: List[Tuple[float, float, float, float]] = [] # (x0, y0, x1, y1) in sheet coords
|
|
# Placed dimension lines: (u_x, u_y, q_x, q_y, t_min, t_max) — unit
|
|
# direction u, point q on the line, foot span relative to q along u.
|
|
placed_dim_lines: List[Tuple[float, float, float, float, float, float]] = []
|
|
|
|
def _would_overlap(x: float, y: float, w: float, h: float) -> bool:
|
|
for x0, y0, x1, y1 in occupied:
|
|
if not (x + w < x0 or x > x1 or y + h < y0 or y > y1):
|
|
return True
|
|
return False
|
|
|
|
def _add_zone(x: float, y: float, w: float, h: float) -> None:
|
|
occupied.append((x - 2, y - 2, x + w + 2, y + h + 2))
|
|
|
|
def _add_text(center_x: float, center_y: float, text: str, key: Optional[str]) -> None:
|
|
w = len(text) * _DIM_FONT_W
|
|
x = center_x - w / 2.0
|
|
base_y = center_y + 1.0 # text sits just above the reference point
|
|
if _would_overlap(x, base_y, w, _DIM_FONT_H):
|
|
base_y = center_y - 1.0 - _DIM_FONT_H # drop below instead
|
|
prims.append(
|
|
DrawingPrimitive(
|
|
kind="text",
|
|
points=((x, base_y),),
|
|
style="dimension",
|
|
text=text,
|
|
candidate_key=key,
|
|
center=(center_x, center_y),
|
|
)
|
|
)
|
|
_add_zone(x, base_y, w, _DIM_FONT_H)
|
|
|
|
def _add_arrow(tip: Tuple[float, float], u: Tuple[float, float]) -> None:
|
|
bx, by = tip[0] - u[0] * _ARROW_MM, tip[1] - u[1] * _ARROW_MM
|
|
px, py = -u[1] * _ARROW_MM * 0.4, u[0] * _ARROW_MM * 0.4
|
|
prims.append(
|
|
DrawingPrimitive(kind="line", points=(tip, (bx + px, by + py)), style="dimension")
|
|
)
|
|
prims.append(
|
|
DrawingPrimitive(kind="line", points=(tip, (bx - px, by - py)), style="dimension")
|
|
)
|
|
|
|
for c in candidates:
|
|
if len(c.anchor_points) < 2:
|
|
continue
|
|
|
|
if c.kind in ("extent", "length"):
|
|
p1, p2 = c.anchor_points[0], c.anchor_points[1]
|
|
d = c.direction
|
|
dl = math.hypot(d[0], d[1]) if d else 0.0
|
|
if dl > 1e-9:
|
|
d = (d[0] / dl, d[1] / dl)
|
|
else:
|
|
vx, vy = p2[0] - p1[0], p2[1] - p1[1]
|
|
vl = math.hypot(vx, vy)
|
|
d = (vx / vl, vy / vl) if vl > 1e-9 else (1.0, 0.0)
|
|
|
|
mx, my = (p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0
|
|
# Decompose the anchor pair: separation along the measurement
|
|
# direction d and perpendicular to it. cross ≈ 0 means the
|
|
# anchors are the closest points on two parallel edges (a
|
|
# distance dimension); otherwise they sit on one feature line.
|
|
wdx, wdy = p2[0] - p1[0], p2[1] - p1[1]
|
|
along = wdx * d[0] + wdy * d[1]
|
|
cross = abs(wdx * d[1] - wdy * d[0])
|
|
|
|
if cross > 0.5:
|
|
# ── Feature-line case ─────────────────────────────────
|
|
# The dimension line is perpendicular to d, offset away
|
|
# from the view centre (half the anchor spread plus the
|
|
# standard offset, so it lands beyond the far feature).
|
|
spread = abs(along) / 2.0
|
|
offset = spread + _DIM_OFFSET_MM
|
|
sign = (
|
|
-1.0
|
|
if (view_center[0] - mx) * d[0] + (view_center[1] - my) * d[1] > 0
|
|
else 1.0
|
|
)
|
|
cdim = _clamp_sheet((mx + sign * d[0] * offset, my + sign * d[1] * offset))
|
|
|
|
# Progressive stacking: push the dimension line away from
|
|
# already-placed parallel lines whose foot spans overlap, so
|
|
# parallel dimensions stay readable (standard CAD behaviour).
|
|
for _ in range(8):
|
|
t1 = (cdim[0] - p1[0]) * d[0] + (cdim[1] - p1[1]) * d[1]
|
|
t2 = (cdim[0] - p2[0]) * d[0] + (cdim[1] - p2[1]) * d[1]
|
|
e1 = (p1[0] + d[0] * t1, p1[1] + d[1] * t1)
|
|
e2 = (p2[0] + d[0] * t2, p2[1] + d[1] * t2)
|
|
half = math.hypot(e2[0] - e1[0], e2[1] - e1[1]) / 2.0
|
|
if half <= 0.5:
|
|
break
|
|
u = (
|
|
(e2[0] - e1[0]) / (2.0 * half),
|
|
(e2[1] - e1[1]) / (2.0 * half),
|
|
)
|
|
pushed = False
|
|
for (ux0, uy0, qx0, qy0, tmin0, tmax0) in placed_dim_lines:
|
|
if abs(u[0] * ux0 + u[1] * uy0) < 0.98:
|
|
continue # not parallel
|
|
ta1 = (e1[0] - qx0) * ux0 + (e1[1] - qy0) * uy0
|
|
ta2 = (e2[0] - qx0) * ux0 + (e2[1] - qy0) * uy0
|
|
if max(ta1, ta2) < tmin0 or min(ta1, ta2) > tmax0:
|
|
continue # spans do not overlap
|
|
sep = (cdim[0] - qx0) * d[0] + (cdim[1] - qy0) * d[1]
|
|
if abs(sep) < _DIM_STANDOFF_MM:
|
|
prev = cdim
|
|
cdim = (
|
|
cdim[0] + sign * d[0] * (_DIM_STANDOFF_MM - abs(sep)),
|
|
cdim[1] + sign * d[1] * (_DIM_STANDOFF_MM - abs(sep)),
|
|
)
|
|
cdim = _clamp_sheet(cdim)
|
|
if cdim == prev:
|
|
# Pushed against the sheet edge — stop stacking.
|
|
break
|
|
pushed = True
|
|
break
|
|
if not pushed:
|
|
break
|
|
|
|
# Final feet of the (possibly stacked) dimension line.
|
|
t1 = (cdim[0] - p1[0]) * d[0] + (cdim[1] - p1[1]) * d[1]
|
|
t2 = (cdim[0] - p2[0]) * d[0] + (cdim[1] - p2[1]) * d[1]
|
|
e1 = (p1[0] + d[0] * t1, p1[1] + d[1] * t1)
|
|
e2 = (p2[0] + d[0] * t2, p2[1] + d[1] * t2)
|
|
half = math.hypot(e2[0] - e1[0], e2[1] - e1[1]) / 2.0
|
|
ext_dir = (sign * d[0], sign * d[1])
|
|
else:
|
|
# ── Inter-edge distance case ──────────────────────────
|
|
# The measured distance runs along d; the dimension line
|
|
# is parallel to d, offset from the picked line on the
|
|
# side away from the view centre.
|
|
n = (-d[1], d[0])
|
|
sign = (
|
|
-1.0
|
|
if (view_center[0] - mx) * n[0] + (view_center[1] - my) * n[1] > 0
|
|
else 1.0
|
|
)
|
|
cdim = _clamp_sheet(
|
|
(
|
|
mx + sign * n[0] * _DIM_OFFSET_MM,
|
|
my + sign * n[1] * _DIM_OFFSET_MM,
|
|
)
|
|
)
|
|
half = abs(along) / 2.0
|
|
u_dir = (d[0], d[1]) if along >= 0 else (-d[0], -d[1])
|
|
|
|
# Progressive stacking (same rules, pushing along n).
|
|
for _ in range(8):
|
|
if half <= 0.5:
|
|
break
|
|
e1 = (cdim[0] - u_dir[0] * half, cdim[1] - u_dir[1] * half)
|
|
e2 = (cdim[0] + u_dir[0] * half, cdim[1] + u_dir[1] * half)
|
|
pushed = False
|
|
for (ux0, uy0, qx0, qy0, tmin0, tmax0) in placed_dim_lines:
|
|
if abs(u_dir[0] * ux0 + u_dir[1] * uy0) < 0.98:
|
|
continue # not parallel
|
|
ta1 = (e1[0] - qx0) * ux0 + (e1[1] - qy0) * uy0
|
|
ta2 = (e2[0] - qx0) * ux0 + (e2[1] - qy0) * uy0
|
|
if max(ta1, ta2) < tmin0 or min(ta1, ta2) > tmax0:
|
|
continue # spans do not overlap
|
|
sep = (cdim[0] - qx0) * n[0] + (cdim[1] - qy0) * n[1]
|
|
if abs(sep) < _DIM_STANDOFF_MM:
|
|
prev = cdim
|
|
cdim = (
|
|
cdim[0] + sign * n[0] * (_DIM_STANDOFF_MM - abs(sep)),
|
|
cdim[1] + sign * n[1] * (_DIM_STANDOFF_MM - abs(sep)),
|
|
)
|
|
cdim = _clamp_sheet(cdim)
|
|
if cdim == prev:
|
|
# Pushed against the sheet edge — stop stacking.
|
|
break
|
|
pushed = True
|
|
break
|
|
if not pushed:
|
|
break
|
|
|
|
e1 = (cdim[0] - u_dir[0] * half, cdim[1] - u_dir[1] * half)
|
|
e2 = (cdim[0] + u_dir[0] * half, cdim[1] + u_dir[1] * half)
|
|
ext_dir = (sign * n[0], sign * n[1])
|
|
|
|
# ── Shared rendering of the positioned dimension line ─────
|
|
if half > 0.5:
|
|
u = ((e2[0] - e1[0]) / (2.0 * half), (e2[1] - e1[1]) / (2.0 * half))
|
|
s1 = (e1[0] - cdim[0]) * u[0] + (e1[1] - cdim[1]) * u[1]
|
|
s2 = (e2[0] - cdim[0]) * u[0] + (e2[1] - cdim[1]) * u[1]
|
|
placed_dim_lines.append(
|
|
(u[0], u[1], cdim[0], cdim[1], min(s1, s2), max(s1, s2))
|
|
)
|
|
# Extension lines: anchor → 2mm past the dimension line.
|
|
for anchor, foot in ((p1, e1), (p2, e2)):
|
|
end = (
|
|
foot[0] + ext_dir[0] * _DIM_EXT_OVERSHOOT_MM,
|
|
foot[1] + ext_dir[1] * _DIM_EXT_OVERSHOOT_MM,
|
|
)
|
|
prims.append(
|
|
DrawingPrimitive(
|
|
kind="line",
|
|
points=(anchor, end),
|
|
style="dimension",
|
|
candidate_key=c.key,
|
|
)
|
|
)
|
|
# Dimension line, broken for the centred label.
|
|
label_w = len(c.label) * _DIM_FONT_W
|
|
gap = label_w / 2.0 + 1.5
|
|
if half > gap + 2.0:
|
|
prims.append(
|
|
DrawingPrimitive(
|
|
kind="line",
|
|
points=(e1, (cdim[0] - u[0] * gap, cdim[1] - u[1] * gap)),
|
|
style="dimension",
|
|
candidate_key=c.key,
|
|
)
|
|
)
|
|
prims.append(
|
|
DrawingPrimitive(
|
|
kind="line",
|
|
points=((cdim[0] + u[0] * gap, cdim[1] + u[1] * gap), e2),
|
|
style="dimension",
|
|
candidate_key=c.key,
|
|
)
|
|
)
|
|
# Arrowheads at both ends, pointing inward.
|
|
if half > _ARROW_MM + 1.0:
|
|
_add_arrow(e1, u)
|
|
_add_arrow(e2, (-u[0], -u[1]))
|
|
_add_text(cdim[0], cdim[1], c.label, c.key)
|
|
|
|
elif c.kind == "diameter":
|
|
p1, p2 = c.anchor_points[0], c.anchor_points[1]
|
|
cx, cy = (p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0
|
|
r = math.hypot(p2[0] - p1[0], p2[1] - p1[1]) / 2.0
|
|
placed = False
|
|
for flip in (0.0, math.pi):
|
|
ux = math.cos(math.pi / 4.0 + flip)
|
|
uy = math.sin(math.pi / 4.0 + flip)
|
|
lead_start = (cx + ux * r, cy + uy * r)
|
|
lead_end = (
|
|
cx + ux * (r + _LEADER_LEAD_MM),
|
|
cy + uy * (r + _LEADER_LEAD_MM),
|
|
)
|
|
tail_end = (lead_end[0] + ux * _LEADER_TAIL_MM, lead_end[1])
|
|
label_w = len(c.label) * _DIM_FONT_W
|
|
tx = lead_end[0] + 1.5 if flip == 0.0 else lead_end[0] - 1.5 - label_w
|
|
ty = lead_end[1] - _DIM_FONT_H / 2.0
|
|
if _would_overlap(tx, ty, label_w, _DIM_FONT_H):
|
|
continue
|
|
prims.append(
|
|
DrawingPrimitive(
|
|
kind="line",
|
|
points=(lead_start, tail_end),
|
|
style="dimension",
|
|
candidate_key=c.key,
|
|
)
|
|
)
|
|
prims.append(
|
|
DrawingPrimitive(
|
|
kind="text",
|
|
points=((tx, ty),),
|
|
style="dimension",
|
|
text=c.label,
|
|
candidate_key=c.key,
|
|
center=(tx + label_w / 2.0, ty + _DIM_FONT_H / 2.0),
|
|
)
|
|
)
|
|
_add_zone(tx, ty, label_w, _DIM_FONT_H)
|
|
placed = True
|
|
break
|
|
if not placed:
|
|
# No free diagonal: emit the text at the 45° leader end
|
|
# without a zone check rather than dropping it.
|
|
ux = math.cos(math.pi / 4.0)
|
|
lead_end = (
|
|
cx + ux * (r + _LEADER_LEAD_MM),
|
|
cy + ux * (r + _LEADER_LEAD_MM),
|
|
)
|
|
prims.append(
|
|
DrawingPrimitive(
|
|
kind="text",
|
|
points=((lead_end[0] + 1.5, lead_end[1] - _DIM_FONT_H / 2.0),),
|
|
style="dimension",
|
|
text=c.label,
|
|
candidate_key=c.key,
|
|
)
|
|
)
|
|
|
|
elif c.kind == "angle" and len(c.anchor_points) >= 3:
|
|
vertex, a1, a2 = c.anchor_points[0], c.anchor_points[1], c.anchor_points[2]
|
|
a1d = math.atan2(a1[1] - vertex[1], a1[0] - vertex[0])
|
|
a2d = math.atan2(a2[1] - vertex[1], a2[0] - vertex[0])
|
|
delta = (a2d - a1d + math.pi) % (2.0 * math.pi) - math.pi
|
|
prev: Optional[Tuple[float, float]] = None
|
|
steps = 8
|
|
for i in range(steps + 1):
|
|
t = a1d + delta * i / steps
|
|
pt = (
|
|
vertex[0] + math.cos(t) * _ANGLE_ARC_MM,
|
|
vertex[1] + math.sin(t) * _ANGLE_ARC_MM,
|
|
)
|
|
if prev is not None:
|
|
prims.append(
|
|
DrawingPrimitive(
|
|
kind="line", points=(prev, pt), style="dimension", candidate_key=c.key
|
|
)
|
|
)
|
|
prev = pt
|
|
bis = a1d + delta / 2.0
|
|
_add_text(
|
|
vertex[0] + math.cos(bis) * 9.0,
|
|
vertex[1] + math.sin(bis) * 9.0,
|
|
c.label,
|
|
c.key,
|
|
)
|
|
|
|
return prims
|
|
|
|
|
|
_MANUAL_DIMENSION_KINDS = ("length", "diameter", "angle")
|
|
|
|
|
|
def build_manual_candidates(
|
|
drawing: TechnicalDrawing,
|
|
view_transforms: Dict[str, Tuple[float, float, float]],
|
|
) -> Tuple[List[DrawingCandidate], List[str], List[str]]:
|
|
"""Convert user-placed annotations into renderable dimension candidates.
|
|
|
|
Manual annotations store their feature geometry in view-plane model
|
|
coordinates (see ``DrawingAnnotation.anchors``); here it is re-projected
|
|
through the current view transforms so the dimensions re-lay out
|
|
correctly whenever views, slots, or the shared scale change. Returns
|
|
``(candidates, resolved_ids, unresolved_ids)``.
|
|
"""
|
|
candidates: List[DrawingCandidate] = []
|
|
resolved: List[str] = []
|
|
unresolved: List[str] = []
|
|
|
|
for ann in drawing.annotations:
|
|
if not ann.visible or ann.dimension_kind not in _MANUAL_DIMENSION_KINDS:
|
|
continue
|
|
if len(ann.anchors) < 2:
|
|
unresolved.append(ann.id)
|
|
continue
|
|
transform = view_transforms.get(ann.view_id)
|
|
if transform is None:
|
|
unresolved.append(ann.id)
|
|
continue
|
|
scale, offset_x, offset_y = transform
|
|
|
|
def to_sheet(pt: Tuple[float, float]) -> Tuple[float, float]:
|
|
return (pt[0] * scale + offset_x, pt[1] * scale + offset_y)
|
|
|
|
a0, a1 = ann.anchors[0], ann.anchors[1]
|
|
if ann.dimension_kind == "angle":
|
|
if len(ann.anchors) < 3:
|
|
unresolved.append(ann.id)
|
|
continue
|
|
vertex, arm1, arm2 = ann.anchors
|
|
v1x, v1y = arm1[0] - vertex[0], arm1[1] - vertex[1]
|
|
v2x, v2y = arm2[0] - vertex[0], arm2[1] - vertex[1]
|
|
m1 = math.hypot(v1x, v1y)
|
|
m2 = math.hypot(v2x, v2y)
|
|
if m1 < 1e-9 or m2 < 1e-9:
|
|
unresolved.append(ann.id)
|
|
continue
|
|
cos_a = (v1x * v2x + v1y * v2y) / (m1 * m2)
|
|
value = math.degrees(math.acos(max(-1.0, min(1.0, cos_a))))
|
|
if value < 0.5 or value > 179.5:
|
|
unresolved.append(ann.id)
|
|
continue
|
|
candidates.append(
|
|
DrawingCandidate(
|
|
key=f"manual:{ann.id}",
|
|
view_id=ann.view_id,
|
|
kind="angle",
|
|
references=(ann.id,),
|
|
value=value,
|
|
anchor_points=(to_sheet(vertex), to_sheet(arm1), to_sheet(arm2)),
|
|
label=f"{value:.{_DISPLAY_PRECISION}f}°",
|
|
)
|
|
)
|
|
resolved.append(ann.id)
|
|
continue
|
|
|
|
value = math.dist(a0, a1)
|
|
if value < 1e-6:
|
|
unresolved.append(ann.id)
|
|
continue
|
|
if ann.dimension_kind == "length":
|
|
if ann.direction:
|
|
dx, dy = float(ann.direction[0]), float(ann.direction[1])
|
|
if math.hypot(dx, dy) < 1e-9:
|
|
dx, dy = a1[0] - a0[0], a1[1] - a0[1]
|
|
else:
|
|
dx, dy = a1[0] - a0[0], a1[1] - a0[1]
|
|
# value >= 1e-6 guarantees the fallback vector is non-zero.
|
|
mag = math.hypot(dx, dy)
|
|
direction: Tuple[float, float] = (dx / mag, dy / mag)
|
|
label = f"{value:.{_DISPLAY_PRECISION}f}"
|
|
else: # diameter
|
|
direction = ()
|
|
label = f"Ø{value:.{_DISPLAY_PRECISION}f}"
|
|
|
|
candidates.append(
|
|
DrawingCandidate(
|
|
key=f"manual:{ann.id}",
|
|
view_id=ann.view_id,
|
|
kind=ann.dimension_kind,
|
|
references=(ann.id,),
|
|
value=value,
|
|
anchor_points=(to_sheet(a0), to_sheet(a1)),
|
|
label=label,
|
|
direction=direction,
|
|
)
|
|
)
|
|
resolved.append(ann.id)
|
|
|
|
return candidates, resolved, unresolved
|
|
|
|
|
|
def generate_drawing(
|
|
drawing: TechnicalDrawing,
|
|
project: Project,
|
|
kernel: OCGeometryKernel,
|
|
) -> DrawingRenderResult:
|
|
"""Generate a complete drawing from a TechnicalDrawing definition."""
|
|
warnings: List[str] = []
|
|
all_primitives: List[DrawingPrimitive] = []
|
|
all_candidates: List[DrawingCandidate] = []
|
|
|
|
parts, part_warnings = build_source_parts(
|
|
project, drawing.source_kind, drawing.source_id, kernel
|
|
)
|
|
warnings.extend(part_warnings)
|
|
|
|
if not parts:
|
|
warnings.append("No source geometry available for drawing")
|
|
return DrawingRenderResult(
|
|
primitives=(),
|
|
candidates=(),
|
|
resolved_annotation_ids=(),
|
|
unresolved_annotation_ids=(),
|
|
source_fingerprint=project.compute_source_fingerprint(
|
|
drawing.source_kind, drawing.source_id
|
|
),
|
|
warnings=tuple(warnings),
|
|
)
|
|
|
|
def _vid_of(view: DrawingView) -> str:
|
|
return view.kind if view.kind in _STANDARD_VIEWS else (view.name or view.id)
|
|
|
|
# Project every view once (HLR is the expensive step).
|
|
projections: Dict[
|
|
str,
|
|
Tuple[
|
|
List[Tuple[Tuple[float, float], Tuple[float, float], str, str]],
|
|
List[str],
|
|
],
|
|
] = {}
|
|
for view in drawing.views:
|
|
projections[_vid_of(view)] = _project_view(parts, view)
|
|
|
|
# Layout: slots + shared ortho scale derived from the actual
|
|
# projected sizes.
|
|
bboxes = {
|
|
_vid_of(view): _edges_bounds(projections[_vid_of(view)][0])
|
|
for view in drawing.views
|
|
}
|
|
view_slots, common_scale = _layout_views_on_sheet(drawing.views, bboxes)
|
|
|
|
ortho_vids = {
|
|
_vid_of(v)
|
|
for v in drawing.views
|
|
if v.kind in _STANDARD_VIEWS and v.kind != "isometric"
|
|
}
|
|
|
|
# Per-view model→sheet transforms, filled in by _assemble_view.
|
|
transforms: Dict[str, Tuple[float, float, float]] = {}
|
|
|
|
for view in drawing.views:
|
|
vid = _vid_of(view)
|
|
slot = view_slots.get(vid)
|
|
edges, view_warnings = projections[vid]
|
|
scale_override = (
|
|
common_scale * view.scale if vid in ortho_vids and common_scale else None
|
|
)
|
|
prims, cands, vwarns = _assemble_view(
|
|
edges, view_warnings, view, slot, scale_override, transforms
|
|
)
|
|
all_primitives.extend(prims)
|
|
all_candidates.extend(cands)
|
|
warnings.extend(vwarns)
|
|
|
|
# User-placed dimensions: convert the model-space annotations into
|
|
# sheet-space candidates so they re-lay out with the current views.
|
|
manual_cands, manual_resolved, manual_unresolved = build_manual_candidates(
|
|
drawing, transforms
|
|
)
|
|
manual_ids = {c.references[0] for c in manual_cands if c.references}
|
|
resolved_ids: List[str] = list(manual_resolved)
|
|
unresolved_ids: List[str] = list(manual_unresolved)
|
|
|
|
# Place dimensions per view: the auto selection (only while the user
|
|
# has auto dimensions enabled) plus the manual dimensions, so the
|
|
# stacking/overlap logic treats them alike. Isometric views are never
|
|
# dimensioned on real drawings.
|
|
for view in drawing.views:
|
|
if view.kind == "isometric":
|
|
continue
|
|
vid = _vid_of(view)
|
|
slot = view_slots.get(vid)
|
|
center = (
|
|
(slot[0] + slot[2] / 2.0, slot[1] + slot[3] / 2.0)
|
|
if slot
|
|
else (_A3_WIDTH_MM / 2.0, _A3_HEIGHT_MM / 2.0)
|
|
)
|
|
selected: List[DrawingCandidate] = []
|
|
if drawing.auto_dimensions:
|
|
selected = _select_dimensions_for_placement(all_candidates, vid)
|
|
selected.extend(c for c in manual_cands if c.view_id == vid)
|
|
all_primitives.extend(_generate_dimension_primitives(selected, center))
|
|
|
|
# Match legacy (reference-based) annotations to candidates. Manual
|
|
# dimensions were already rendered above as full dimension primitives.
|
|
candidate_by_key: Dict[str, DrawingCandidate] = {c.key: c for c in all_candidates}
|
|
|
|
for ann in drawing.annotations:
|
|
if not ann.visible:
|
|
continue
|
|
if ann.id in manual_ids:
|
|
continue
|
|
if ann.kind == "note":
|
|
resolved_ids.append(ann.id)
|
|
continue
|
|
matched = False
|
|
for ref in ann.references:
|
|
if ref in candidate_by_key:
|
|
ann_prim = DrawingPrimitive(
|
|
kind="dimension",
|
|
points=(ann.sheet_position,),
|
|
style="dimension",
|
|
text=ann.text or candidate_by_key[ref].label,
|
|
candidate_key=ref,
|
|
)
|
|
all_primitives.append(ann_prim)
|
|
resolved_ids.append(ann.id)
|
|
matched = True
|
|
break
|
|
if not matched:
|
|
unresolved_ids.append(ann.id)
|
|
|
|
# Title block.
|
|
title_prims = _title_block_primitives(drawing)
|
|
all_primitives.extend(title_prims)
|
|
|
|
return DrawingRenderResult(
|
|
primitives=tuple(all_primitives),
|
|
candidates=tuple(all_candidates),
|
|
resolved_annotation_ids=tuple(resolved_ids),
|
|
unresolved_annotation_ids=tuple(unresolved_ids),
|
|
source_fingerprint=project.compute_source_fingerprint(
|
|
drawing.source_kind, drawing.source_id
|
|
),
|
|
warnings=tuple(warnings),
|
|
view_transforms=transforms,
|
|
)
|
|
|
|
|
|
def _title_block_primitives(drawing: TechnicalDrawing) -> List[DrawingPrimitive]:
|
|
"""Generate title block primitives at the bottom-right of the sheet."""
|
|
prims: List[DrawingPrimitive] = []
|
|
margin = 5.0
|
|
box_h = 52.0
|
|
box_w = 180.0
|
|
left = _A3_WIDTH_MM - box_w - margin
|
|
bottom = margin
|
|
line_h = 12.0
|
|
|
|
# Border.
|
|
for sx, sy, ex, ey in [
|
|
(left, bottom, left + box_w, bottom),
|
|
(left, bottom + box_h, left + box_w, bottom + box_h),
|
|
(left, bottom, left, bottom + box_h),
|
|
(left + box_w, bottom, left + box_w, bottom + box_h),
|
|
]:
|
|
prims.append(DrawingPrimitive(kind="line", points=((sx, sy), (ex, ey)), style="visible"))
|
|
|
|
fields = [
|
|
("Title:", drawing.title, 0),
|
|
("Part No:", drawing.part_number, 1),
|
|
("Material:", drawing.material, 2),
|
|
("Rev:", drawing.revision, 3),
|
|
]
|
|
for label, value, row in fields:
|
|
ty = bottom + box_h - line_h * (row + 1) + 3
|
|
prims.append(
|
|
DrawingPrimitive(
|
|
kind="text",
|
|
points=((left + 4, ty),),
|
|
style="dimension",
|
|
text=f"{label} {value}",
|
|
)
|
|
)
|
|
|
|
return prims
|
|
|
|
|
|
|
|
def render_drawing(
|
|
painter: QPainter,
|
|
render_result: DrawingRenderResult,
|
|
sheet_rect: QRectF,
|
|
) -> None:
|
|
"""Paint a drawing render result onto a QPainter.
|
|
|
|
*sheet_rect* defines the canvas area in device coordinates (mm).
|
|
"""
|
|
sx = sheet_rect.width() / _A3_WIDTH_MM
|
|
sy = sheet_rect.height() / _A3_HEIGHT_MM
|
|
scale = min(sx, sy)
|
|
|
|
draw_w = _A3_WIDTH_MM * scale
|
|
draw_h = _A3_HEIGHT_MM * scale
|
|
ox = sheet_rect.x() + (sheet_rect.width() - draw_w) / 2.0
|
|
oy = sheet_rect.y() + (sheet_rect.height() - draw_h) / 2.0
|
|
|
|
def _to_device(x_mm: float, y_mm: float) -> QPointF:
|
|
return QPointF(ox + x_mm * scale, oy + (_A3_HEIGHT_MM - y_mm) * scale)
|
|
|
|
# White background.
|
|
painter.fillRect(QRectF(ox, oy, draw_w, draw_h), QColor(255, 255, 255))
|
|
|
|
# Sheet border.
|
|
border_pen = QPen(QColor(0, 0, 0), 1.0 * scale)
|
|
border_pen.setCosmetic(True)
|
|
painter.setPen(border_pen)
|
|
painter.drawRect(QRectF(ox, oy, draw_w, draw_h))
|
|
|
|
style_pens = {
|
|
"visible": QPen(QColor(0, 0, 0), 1.5),
|
|
"hidden": QPen(QColor(128, 128, 128), 1.0),
|
|
"construction": QPen(QColor(0, 0, 255), 0.5),
|
|
"dimension": QPen(QColor(0, 0, 0), 1.0),
|
|
}
|
|
for sp in style_pens.values():
|
|
sp.setCosmetic(True)
|
|
|
|
style_pens["hidden"].setStyle(Qt.PenStyle.DashLine)
|
|
style_pens["construction"].setStyle(Qt.PenStyle.DashDotLine)
|
|
|
|
font = QFont("sans-serif")
|
|
# Font size in device px so the text height is a constant sheet mm.
|
|
font.setPixelSize(max(6, int(_DIM_TEXT_H_MM * scale)))
|
|
painter.setFont(font)
|
|
|
|
for prim in render_result.primitives:
|
|
pen = style_pens.get(prim.style, style_pens["visible"])
|
|
painter.setPen(pen)
|
|
|
|
if prim.kind == "line":
|
|
p1 = _to_device(*prim.points[0])
|
|
p2 = _to_device(*prim.points[1])
|
|
painter.drawLine(p1, p2)
|
|
|
|
elif prim.kind == "circle":
|
|
if prim.center and prim.radius:
|
|
c = _to_device(*prim.center)
|
|
r = prim.radius * scale
|
|
painter.drawEllipse(c, r, r)
|
|
|
|
elif prim.kind == "text":
|
|
if not prim.points:
|
|
continue
|
|
p = _to_device(*prim.points[0])
|
|
if prim.text:
|
|
painter.drawText(p, prim.text)
|
|
|
|
elif prim.kind == "dimension":
|
|
if not prim.points:
|
|
continue
|
|
p = _to_device(*prim.points[0])
|
|
if prim.text:
|
|
painter.drawText(p + QPointF(0, -4 * scale), prim.text)
|
|
painter.drawLine(p, p + QPointF(0, -6 * scale))
|
|
|
|
|
|
def export_drawing_svg(
|
|
render_result: DrawingRenderResult,
|
|
filepath: str,
|
|
) -> None:
|
|
"""Export a drawing as SVG.
|
|
|
|
Raises ValueError if the render result is empty.
|
|
"""
|
|
if not render_result.primitives:
|
|
raise ValueError("No renderable content; generate the drawing first.")
|
|
|
|
from PySide6.QtSvg import QSvgGenerator
|
|
|
|
from PySide6.QtCore import QSize
|
|
|
|
generator = QSvgGenerator()
|
|
generator.setFileName(filepath)
|
|
generator.setSize(QSize(2100, 1485)) # ~A3 at 5px/mm
|
|
generator.setViewBox(QRectF(0, 0, _A3_WIDTH_MM, _A3_HEIGHT_MM))
|
|
generator.setTitle("Fluency Technical Drawing")
|
|
|
|
painter = QPainter(generator)
|
|
try:
|
|
render_drawing(painter, render_result, QRectF(0, 0, _A3_WIDTH_MM, _A3_HEIGHT_MM))
|
|
finally:
|
|
painter.end()
|
|
|
|
|
|
def export_drawing_pdf(
|
|
render_result: DrawingRenderResult,
|
|
filepath: str,
|
|
) -> None:
|
|
"""Export a drawing as PDF.
|
|
|
|
Raises ValueError if the render result is empty.
|
|
"""
|
|
if not render_result.primitives:
|
|
raise ValueError("No renderable content; generate the drawing first.")
|
|
|
|
from PySide6.QtPrintSupport import QPrinter
|
|
from PySide6.QtGui import QPageSize, QPageLayout
|
|
|
|
printer = QPrinter(QPrinter.PrinterMode.HighResolution)
|
|
printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat)
|
|
printer.setOutputFileName(filepath)
|
|
printer.setPageSize(QPageSize(QPageSize.PageSizeId.A3))
|
|
printer.setPageOrientation(QPageLayout.Orientation.Landscape)
|
|
printer.setFullPage(True)
|
|
|
|
painter = QPainter(printer)
|
|
try:
|
|
render_drawing(painter, render_result, QRectF(0, 0, _A3_WIDTH_MM, _A3_HEIGHT_MM))
|
|
finally:
|
|
painter.end()
|