- added iso sheet for tech draw

This commit is contained in:
bklronin
2026-08-18 16:12:09 +02:00
parent 67b73c13b8
commit b184ade967
6 changed files with 542 additions and 173 deletions
+3 -1
View File
@@ -705,7 +705,9 @@ def _technical_drawing_from_dict(data: Dict[str, Any]) -> TechnicalDrawing:
material=data.get("material", ""),
revision=data.get("revision", ""),
notes=data.get("notes", ""),
sheet_size=data.get("sheet_size", "A3"),
# The renderer only supports the A4 ISO 5457 sheet; normalize
# legacy drawings (stored as A3) instead of surfacing stale sizes.
sheet_size="A4",
units=data.get("units", "mm"),
auto_dimensions=bool(data.get("auto_dimensions", False)),
auto_views=bool(data.get("auto_views", True)),
+2 -2
View File
@@ -975,8 +975,8 @@ class TechnicalDrawing:
revision: str = ""
notes: str = ""
# Sheet size (A0..A4 or custom mm). Default A3.
sheet_size: str = "A3"
# Sheet size (A0..A4 or custom mm). Default A4 (ISO 5457 template).
sheet_size: str = "A4"
units: str = "mm" # mm, in
# Auto-generation flags. Auto dimensions are opt-in: the drawing
+261 -106
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import math
from dataclasses import dataclass, field
from datetime import date
from typing import Any, Dict, List, Optional, Sequence, Tuple
import numpy as np
@@ -68,6 +69,8 @@ class DrawingPrimitive:
center: Optional[Tuple[float, float]] = None
radius: Optional[float] = None
dash_pattern: Tuple[float, ...] = ()
# Text cap height in sheet mm (None = the default dimension font).
font_size: Optional[float] = None
# View this primitive belongs to (geometry primitives); used for
# hit-testing in the drawing workbench.
view_id: Optional[str] = None
@@ -112,11 +115,29 @@ _STANDARD_VIEW_ROWS: List[Tuple[str, str, Tuple[float, float, float], Tuple[floa
(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
# Sheet size: A4 landscape (ISO 5457 title block, see the
# A4_Landscape_ISO5457_minimal.svg template).
_SHEET_WIDTH_MM = 297.0
_SHEET_HEIGHT_MM = 210.0
_DISPLAY_PRECISION = 2
# ISO 5457 sheet furniture geometry in sheet mm (origin bottom-left,
# +y up; the reference SVG is y-down, so y_sheet = 210 - y_svg).
_FRAME_X0_MM = 20.0 # drawing-space frame
_FRAME_Y0_MM = 10.0
_FRAME_W_MM = 267.0
_FRAME_H_MM = 190.0
_TB_LEFT_MM = 107.0 # ISO 5457 title block (180 x 36)
_TB_BOTTOM_MM = 10.0
_TB_W_MM = 180.0
_TB_H_MM = 36.0
# Zone the views must clear: the title block plus the part-material /
# general-tolerance / scale strip above it and the projection symbol.
_TB_RESERVE_X0_MM = 102.0
_TB_RESERVE_TOP_MM = 64.0
# ISO dash-dot centreline pattern in mm.
_CENTERLINE_DASH_MM = (6.0, 0.75, 0.125, 0.75)
def _normalize(v: Tuple[float, float, float]) -> Tuple[float, float, float]:
x, y, z = v
@@ -417,9 +438,9 @@ def _assemble_view(
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
left, bottom = _FRAME_X0_MM, _FRAME_Y0_MM
avail_w = _FRAME_W_MM
avail_h = _FRAME_H_MM
if scale_override is not None:
scale = scale_override
@@ -474,6 +495,7 @@ def _assemble_view(
kind="line",
points=((sc[0] - ext, sc[1]), (sc[0] + ext, sc[1])),
style="center",
dash_pattern=_CENTERLINE_DASH_MM,
view_id=view_id,
)
)
@@ -482,6 +504,7 @@ def _assemble_view(
kind="line",
points=((sc[0], sc[1] - ext), (sc[0], sc[1] + ext)),
style="center",
dash_pattern=_CENTERLINE_DASH_MM,
view_id=view_id,
)
)
@@ -551,8 +574,8 @@ def generate_view(
"""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)``.
in sheet mm; when omitted the projection is fitted to the full sheet
frame. Returns ``(primitives, candidates, warnings)``.
"""
edges, warnings = _project_view(source_parts, view)
return _assemble_view(edges, warnings, view, slot, None)
@@ -873,41 +896,37 @@ def _extract_angle_candidates(
# ── Sheet layout regions ────────────────────────────────────────────────────
_LAYOUT_MARGIN_MM = 10.0
_VIEW_GAP_MM = 12.0
# Title block box (see _title_block_primitives): 180 × 52 at the bottom-right
# corner with a 5 mm sheet margin. Views must clear it (plus clearance).
_TB_LEFT_MM = _A3_WIDTH_MM - 180.0 - 5.0
_TB_TOP_MM = 5.0 + 52.0
_TB_CLEARANCE_MM = 5.0
# Sheet interior (border margin) as (x0, y0, x1, y1) in sheet mm.
# The sheet interior is the ISO 5457 drawing-space frame.
_SHEET_INNER = (
_LAYOUT_MARGIN_MM,
_LAYOUT_MARGIN_MM,
_A3_WIDTH_MM - _LAYOUT_MARGIN_MM,
_A3_HEIGHT_MM - _LAYOUT_MARGIN_MM,
_FRAME_X0_MM,
_FRAME_Y0_MM,
_FRAME_X0_MM + _FRAME_W_MM,
_FRAME_Y0_MM + _FRAME_H_MM,
)
# Regions the orthographic layout may occupy, as (x0, y0, w, h):
# - UPPER: full sheet width above the title block
# - LEFT: full sheet height in the left strip beside the title block
# - UPPER: frame width above the title-block reserve zone
# - LEFT: full frame height in the left strip beside the reserve zone
_REGION_UPPER = (
_LAYOUT_MARGIN_MM,
_TB_TOP_MM + _TB_CLEARANCE_MM,
_A3_WIDTH_MM - 2 * _LAYOUT_MARGIN_MM,
_A3_HEIGHT_MM - _LAYOUT_MARGIN_MM - (_TB_TOP_MM + _TB_CLEARANCE_MM),
_FRAME_X0_MM,
_TB_RESERVE_TOP_MM,
_FRAME_W_MM,
_FRAME_Y0_MM + _FRAME_H_MM - _TB_RESERVE_TOP_MM,
)
_REGION_LEFT = (
_LAYOUT_MARGIN_MM,
_LAYOUT_MARGIN_MM,
_TB_LEFT_MM - _TB_CLEARANCE_MM - _LAYOUT_MARGIN_MM,
_A3_HEIGHT_MM - 2 * _LAYOUT_MARGIN_MM,
_FRAME_X0_MM,
_FRAME_Y0_MM,
_TB_RESERVE_X0_MM - _FRAME_X0_MM,
_FRAME_H_MM,
)
_MID_ORDER = ("left", "front", "right", "back")
_COL_ORDER = ("top", "front", "bottom") # sheet top → bottom
# First-angle projection (ISO 128): the view from the right sits left of
# the front view, the view from the left right of it, and the top view
# below the front view.
_MID_ORDER = ("right", "front", "left", "back")
_COL_ORDER = ("bottom", "front", "top") # sheet top → bottom
_GRID_ORDER = ("front", "right", "back", "top", "left", "bottom")
# Leave room for dimension lines: the shared scale fits the views into
@@ -920,9 +939,10 @@ _RectList = List[Tuple[str, float, float, float, float]]
def _place_cross(
dims: Dict[str, Tuple[float, float]], gap: float
) -> _RectList:
"""Classic third-angle cross: mid views run left→right (left, front,
right, back), col views stack topbottom (top, front, bottom), with the
anchor view (front, or the first present one) at the intersection.
"""Classic first-angle cross: mid views run left→right (right, front,
left, back), col views stack sheet-topbottom (bottom, front, top),
with the anchor view (front, or the first present one) at the
intersection.
*dims* maps view id ``(w, h)`` in sheet units. Returns local
``(vid, x, y, w, h)`` rects (origin arbitrary the caller centres the
@@ -978,8 +998,8 @@ def _place_cross(
def _place_swapped(dims: Dict[str, Tuple[float, float]], gap: float) -> _RectList:
"""Cross with the view families swapped: the mid views stack vertically
(left, front, right, back from the top) and the col views run
horizontally (bottom, front, top from the left) the classic cross
(right, front, left, back from the top) and the col views run
horizontally (top, front, bottom from the left) the classic cross
turned a quarter turn, for sheets where that orientation fits more.
"""
mid = [k for k in _MID_ORDER if k in dims]
@@ -1086,10 +1106,10 @@ def _free_rects(
else:
cands = [(ix0, iy0, ix1, iy1)]
tb = (
_TB_LEFT_MM - _TB_CLEARANCE_MM,
_TB_RESERVE_X0_MM,
0.0,
_A3_WIDTH_MM - (_TB_LEFT_MM - _TB_CLEARANCE_MM),
_TB_TOP_MM + _TB_CLEARANCE_MM,
_SHEET_WIDTH_MM,
_TB_RESERVE_TOP_MM,
)
out: List[Tuple[float, float, float, float]] = []
for x0, y0, x1, y1 in cands:
@@ -1129,13 +1149,14 @@ def _layout_views_on_sheet(
rotations maps view_id sheet rotation in degrees (0 or 90).
The sheet is filled, not just used: every candidate arrangement
(classic third-angle cross, the cross with the view families swapped,
(classic first-angle cross, the cross with the view families swapped,
and 2/3-row grids) is combined with every per-view 90° rotation
assignment (with 12 views the projections stay upright and only the
scale is optimised), and the candidate giving the largest shared scale
is used. Candidates within 0.5% of the best scale prefer the one with
fewer rotated views, then the more conventional arrangement, so layouts
stay stable and standard whenever they are already the best fit. The
is used. A grid may pack views slightly tighter than the cross, but
only the cross keeps the projections in their standard alignment, so
candidates within 10% of the best scale prefer the one with fewer
rotated views, then the more conventional arrangement. The
shared scale is further reduced to leave room for dimension lines
between and around the views. All orthographic views share one scale
so the projections stay mutually consistent. Isometric and custom
@@ -1211,7 +1232,7 @@ def _layout_views_on_sheet(
if cands:
best_s = max(c[0] for c in cands)
s, _nrot, _rank, region, dims_m, place, rotated = min(
(c for c in cands if c[0] >= best_s * 0.995),
(c for c in cands if c[0] >= best_s * 0.9),
key=lambda c: (c[1], c[2], -c[0]),
)
rx0, _ry0, rw, rh = region
@@ -1243,7 +1264,12 @@ def _layout_views_on_sheet(
slot = max(free, key=lambda r: r[2] * r[3])
else:
# No free rect left — park in the bottom-left corner stack.
slot = (_LAYOUT_MARGIN_MM, _LAYOUT_MARGIN_MM + i * 60.0, 120.0, 50.0)
slot = (
_FRAME_X0_MM,
_FRAME_Y0_MM + i * 50.0,
_TB_RESERVE_X0_MM - _FRAME_X0_MM,
40.0,
)
slots[vid] = slot
assigned.append(slot)
@@ -1257,9 +1283,9 @@ _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_TEXT_H_MM = 3.5 # text cap height (ISO 3098 group C, A4 drawing)
_DIM_FONT_W = 2.2 # approx. mm width per character
_DIM_FONT_H = 5.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
@@ -1339,8 +1365,8 @@ def _generate_dimension_primitives(
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
_sheet_min_x, _sheet_max_x = 6.0, _SHEET_WIDTH_MM - 6.0
_sheet_min_y, _sheet_max_y = 6.0, _SHEET_HEIGHT_MM - 6.0
def _clamp_sheet(pt: Tuple[float, float]) -> Tuple[float, float]:
return (
@@ -1894,7 +1920,7 @@ def generate_drawing(
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)
else (_SHEET_WIDTH_MM / 2.0, _SHEET_HEIGHT_MM / 2.0)
)
selected: List[DrawingCandidate] = []
if drawing.auto_dimensions:
@@ -1932,8 +1958,11 @@ def generate_drawing(
unresolved_ids.append(ann.id)
# Title block.
title_prims = _title_block_primitives(drawing)
all_primitives.extend(title_prims)
# Sheet furniture: ISO 5457 drawing frame, grid reference system,
# trimming marks, projection symbol and title block.
all_primitives.extend(
_sheet_frame_primitives(drawing, _format_drawing_scale(common_scale))
)
return DrawingRenderResult(
primitives=tuple(all_primitives),
@@ -1948,42 +1977,158 @@ def generate_drawing(
)
def _title_block_primitives(drawing: TechnicalDrawing) -> List[DrawingPrimitive]:
"""Generate title block primitives at the bottom-right of the sheet."""
def _format_drawing_scale(scale: Optional[float]) -> str:
"""Format the shared view scale for the title block's scale field."""
if scale is None or scale <= 0:
return "1 : 1"
def _num(v: float) -> str:
s = f"{v:.2f}".rstrip("0").rstrip(".")
return s or "0"
if scale >= 1.0:
return f"{_num(scale)} : 1"
return f"1 : {_num(round(1.0 / scale, 2))}"
def _sheet_frame_primitives(
drawing: TechnicalDrawing, scale_label: str
) -> List[DrawingPrimitive]:
"""ISO 5457 sheet furniture: drawing-space frame, centring marks,
grid reference system, trimming marks, first-angle projection symbol
and title block mirroring the A4_Landscape_ISO5457_minimal.svg
template (y-down) onto sheet coordinates (origin bottom-left, +y up).
"""
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"))
def line(p1: Tuple[float, float], p2: Tuple[float, float], style: str) -> None:
prims.append(DrawingPrimitive(kind="line", points=(p1, p2), style=style))
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
def text(x: float, y: float, t: str, size: float, anchor: str = "start") -> None:
if anchor == "middle":
# Pre-centre: the renderer draws text left-aligned at the point.
x -= len(t) * size * 0.6 / 2.0
prims.append(
DrawingPrimitive(
kind="text",
points=((left + 4, ty),),
style="dimension",
text=f"{label} {value}",
points=((x, y),),
style="sheet_text",
text=t,
font_size=size,
)
)
def rect(x: float, y: float, w: float, h: float, style: str) -> None:
line((x, y), (x + w, y), style)
line((x + w, y), (x + w, y + h), style)
line((x + w, y + h), (x, y + h), style)
line((x, y + h), (x, y), style)
# ── Drawing-space frame and centring marks (0.7 mm) ─────────────
rect(20.0, 10.0, 267.0, 190.0, "frame")
line((148.5, 205.0), (148.5, 195.0), "frame") # top
line((292.0, 105.0), (282.0, 105.0), "frame") # right
line((148.5, 5.0), (148.5, 11.0), "frame") # bottom
line((15.0, 105.0), (25.0, 105.0), "frame") # left
# ── Grid reference border and ticks (0.35 mm) ───────────────────
rect(15.0, 5.0, 277.0, 200.0, "sheet")
for x in (48.5, 98.5, 198.5, 248.5):
line((x, 205.0), (x, 200.0), "sheet") # upper ticks
line((x, 5.0), (x, 10.0), "sheet") # lower ticks
for y in (155.0, 55.0):
line((292.0, y), (287.0, y), "sheet") # right ticks
line((15.0, y), (20.0, y), "sheet") # left ticks
# Grid reference labels (3.5 mm).
for i, x in enumerate((23.5, 73.5, 123.5, 173.5, 223.5, 273.5), 1):
text(x, 201.2, str(i), 3.5, anchor="middle")
for i, y in enumerate((178.7, 128.7, 78.7, 28.7)):
text(289.5, y, "ABCD"[i], 3.5, anchor="middle")
text(273.5, 6.2, "A4", 3.5, anchor="middle")
# ── Trimming marks (corner steps, 0.7 mm) ───────────────────────
for step in (
((0.0, 200.0), (5.0, 200.0), (5.0, 205.0), (10.0, 205.0), (10.0, 210.0)),
((287.0, 200.0), (292.0, 200.0), (292.0, 205.0), (297.0, 205.0), (297.0, 210.0)),
((287.0, 10.0), (292.0, 10.0), (292.0, 5.0), (297.0, 5.0), (297.0, 0.0)),
((10.0, 10.0), (5.0, 10.0), (5.0, 5.0), (0.0, 5.0), (0.0, 0.0)),
):
for a, b in zip(step, step[1:]):
line(a, b, "frame")
# ── First-angle projection symbol (0.5 mm outline) ──────────────
trap = ((266.0, 63.0), (256.0, 60.5), (256.0, 55.5), (266.0, 53.0))
for a, b in zip(trap, trap[1:] + trap[:1]):
line(a, b, "medium")
for r in (2.5, 5.0):
prims.append(
DrawingPrimitive(
kind="circle", points=(), style="medium",
center=(273.0, 58.0), radius=r,
)
)
for p1, p2 in (((255.0, 58.0), (279.0, 58.0)), ((273.0, 52.0), (273.0, 64.0))):
prims.append(
DrawingPrimitive(
kind="line", points=(p1, p2), style="center",
dash_pattern=_CENTERLINE_DASH_MM,
)
)
# ── Title block borders ─────────────────────────────────────────
rect(_TB_LEFT_MM, _TB_BOTTOM_MM, _TB_W_MM, _TB_H_MM, "frame")
for x, y, w, h in (
(247.0, 22.0, 40.0, 24.0), # owner
(107.0, 10.0, 80.0, 12.0), # drawing number
(247.0, 10.0, 20.0, 12.0), # revision
(207.0, 10.0, 40.0, 12.0), # issue date
(267.0, 10.0, 20.0, 12.0), # sheet
(187.0, 10.0, 20.0, 12.0), # language
(107.0, 34.0, 80.0, 12.0), # title
(187.0, 22.0, 60.0, 12.0), # approved by
(187.0, 34.0, 60.0, 12.0), # created by
(107.0, 22.0, 80.0, 12.0), # document type
):
rect(x, y, w, h, "sheet")
# ── Title block labels (3.5 mm) ─────────────────────────────────
for x, y, t in (
(247.5, 42.4, "Owner:"),
(107.5, 18.4, "Drawing number:"),
(247.5, 18.4, "Revision:"),
(207.5, 18.4, "Issue date:"),
(267.5, 18.4, "Sheet:"),
(187.5, 18.4, "Language:"),
(107.5, 42.4, "Title:"),
(187.5, 30.4, "Approved by:"),
(187.5, 42.4, "Created by:"),
(107.5, 30.4, "Document type:"),
(227.5, 54.4, "Scale:"),
(187.5, 54.4, "General tolerances:"),
(107.5, 54.4, "Part Material:"),
):
text(x, y, t, 3.5)
# ── Title block data (5 mm) ─────────────────────────────────────
doc_type = (
"Assembly Drawing" if drawing.source_kind == "assembly" else "Component Drawing"
)
for x, y, t, anchor in (
(108.0, 36.8, drawing.title, "start"),
(108.0, 24.8, doc_type, "start"),
(108.0, 48.8, drawing.material, "start"),
(188.0, 48.8, "ISO 2768-m", "start"),
(237.0, 48.8, scale_label, "middle"),
(197.0, 12.8, "EN", "middle"),
(277.0, 12.8, "1 / 1", "middle"),
(208.0, 12.8, date.today().isoformat(), "start"),
(108.0, 12.8, drawing.part_number, "start"),
(257.0, 12.8, drawing.revision, "middle"),
):
if t:
text(x, y, t, 5.0, anchor=anchor)
return prims
@@ -1997,33 +2142,33 @@ def render_drawing(
*sheet_rect* defines the canvas area in device coordinates (mm).
"""
sx = sheet_rect.width() / _A3_WIDTH_MM
sy = sheet_rect.height() / _A3_HEIGHT_MM
sx = sheet_rect.width() / _SHEET_WIDTH_MM
sy = sheet_rect.height() / _SHEET_HEIGHT_MM
scale = min(sx, sy)
draw_w = _A3_WIDTH_MM * scale
draw_h = _A3_HEIGHT_MM * scale
draw_w = _SHEET_WIDTH_MM * scale
draw_h = _SHEET_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)
return QPointF(ox + x_mm * scale, oy + (_SHEET_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))
# Pen weights follow the ISO 5457 template (0.35 / 0.5 / 0.7 mm),
# scaled to device px so line weights are constant on the sheet.
style_pens = {
"visible": QPen(QColor(0, 0, 0), 1.5),
"hidden": QPen(QColor(128, 128, 128), 1.0),
"center": QPen(QColor(0, 0, 0), 0.35),
"construction": QPen(QColor(0, 0, 255), 0.5),
"dimension": QPen(QColor(0, 0, 0), 1.0),
"visible": QPen(QColor(0, 0, 0), 0.5 * scale),
"hidden": QPen(QColor(128, 128, 128), 0.35 * scale),
"center": QPen(QColor(0, 0, 0), 0.25 * scale),
"construction": QPen(QColor(0, 0, 255), 0.35 * scale),
"dimension": QPen(QColor(0, 0, 0), 0.35 * scale),
"frame": QPen(QColor(0, 0, 0), 0.7 * scale),
"medium": QPen(QColor(0, 0, 0), 0.5 * scale),
"sheet": QPen(QColor(0, 0, 0), 0.35 * scale),
"sheet_text": QPen(QColor(0, 0, 0), 0.35 * scale),
}
for sp in style_pens.values():
sp.setCosmetic(True)
@@ -2038,6 +2183,12 @@ def render_drawing(
for prim in render_result.primitives:
pen = style_pens.get(prim.style, style_pens["visible"])
if prim.dash_pattern:
# Qt dash values are multiples of the pen width; the pattern
# is specified in sheet mm, so convert through device px.
w = max(pen.width(), 1e-6)
pen = QPen(pen)
pen.setDashPattern([d * scale / w for d in prim.dash_pattern])
painter.setPen(pen)
if prim.kind == "line":
@@ -2052,11 +2203,15 @@ def render_drawing(
painter.drawEllipse(c, r, r)
elif prim.kind == "text":
if not prim.points:
if not prim.points or not prim.text:
continue
p = _to_device(*prim.points[0])
if prim.text:
painter.drawText(p, prim.text)
if prim.font_size:
text_font = QFont(font)
text_font.setPixelSize(max(5, int(prim.font_size * scale)))
painter.setFont(text_font)
painter.drawText(_to_device(*prim.points[0]), prim.text)
if prim.font_size:
painter.setFont(font)
elif prim.kind == "dimension":
if not prim.points:
@@ -2084,13 +2239,13 @@ def export_drawing_svg(
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.setSize(QSize(1485, 1050)) # A4 at 5px/mm
generator.setViewBox(QRectF(0, 0, _SHEET_WIDTH_MM, _SHEET_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))
render_drawing(painter, render_result, QRectF(0, 0, _SHEET_WIDTH_MM, _SHEET_HEIGHT_MM))
finally:
painter.end()
@@ -2112,12 +2267,12 @@ def export_drawing_pdf(
printer = QPrinter(QPrinter.PrinterMode.HighResolution)
printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat)
printer.setOutputFileName(filepath)
printer.setPageSize(QPageSize(QPageSize.PageSizeId.A3))
printer.setPageSize(QPageSize(QPageSize.PageSizeId.A4))
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))
render_drawing(painter, render_result, QRectF(0, 0, _SHEET_WIDTH_MM, _SHEET_HEIGHT_MM))
finally:
painter.end()
+213
View File
@@ -0,0 +1,213 @@
"""Tests for the A4 ISO 5457 sheet: layout regions, first-angle view
ordering, sheet furniture primitives, scale formatting, and the A4
normalisation of drawings loaded from .fluency files.
"""
import os
import sys
import unittest
# Allow running this file directly: ``python tests/test_iso5457_sheet.py``.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src"))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from fluency.models.data_model import DrawingView, TechnicalDrawing
from fluency.technical_drawing import (
_SHEET_HEIGHT_MM,
_SHEET_WIDTH_MM,
_format_drawing_scale,
_layout_views_on_sheet,
_sheet_frame_primitives,
)
def _view(kind):
return DrawingView(kind=kind, name=kind)
class TestSheetSize(unittest.TestCase):
"""The workbench renders a single A4 landscape sheet."""
def test_a4_dimensions(self):
self.assertEqual((_SHEET_WIDTH_MM, _SHEET_HEIGHT_MM), (297.0, 210.0))
def test_default_drawing_is_a4(self):
self.assertEqual(TechnicalDrawing().sheet_size, "A4")
class TestScaleFormatting(unittest.TestCase):
"""Title-block scale field formatting (``N : 1`` / ``1 : N``)."""
def test_none_and_one(self):
self.assertEqual(_format_drawing_scale(None), "1 : 1")
self.assertEqual(_format_drawing_scale(0.0), "1 : 1")
self.assertEqual(_format_drawing_scale(1.0), "1 : 1")
def test_enlarged(self):
self.assertEqual(_format_drawing_scale(2.0), "2 : 1")
self.assertEqual(_format_drawing_scale(1.5), "1.5 : 1")
def test_reduced(self):
self.assertEqual(_format_drawing_scale(0.5), "1 : 2")
self.assertEqual(_format_drawing_scale(0.33333), "1 : 3")
class TestFirstAngleLayout(unittest.TestCase):
"""View slots stay inside the ISO 5457 drawing-space frame, clear the
title-block reserve zone, and follow first-angle ordering (view from
the right left of front, view from the left right of front, top view
below front)."""
def _slots(self, kinds):
bboxes = {k: (0.0, 0.0, 40.0, 30.0) for k in kinds}
slots, scale, _rots = _layout_views_on_sheet(
[_view(k) for k in kinds], bboxes
)
return slots, scale
def test_slots_inside_frame(self):
slots, scale = self._slots(
("front", "top", "right", "left", "isometric")
)
self.assertIsNotNone(scale)
self.assertGreater(scale, 0)
for vid, (x, y, w, h) in slots.items():
# Drawing-space frame: 20..287 x 10..200.
self.assertGreaterEqual(x, 19.9, vid)
self.assertGreaterEqual(y, 9.9, vid)
self.assertLessEqual(x + w, 287.1, vid)
self.assertLessEqual(y + h, 200.1, vid)
# Title-block reserve zone: x >= 102, y <= 64.
self.assertTrue(
x + w <= 102.01 or y + h <= 0.01 or y >= 63.99,
(vid, (x, y, w, h)),
)
def test_first_angle_ordering(self):
slots, _ = self._slots(("front", "top", "right", "left"))
xf, wf = slots["front"][0], slots["front"][2]
yf = slots["front"][1]
# View from the right sits left of the front view.
self.assertLessEqual(
slots["right"][0] + slots["right"][2], xf + 0.01
)
# View from the left sits right of the front view.
self.assertGreaterEqual(slots["left"][0], xf + wf + 0.01)
# Top view sits below the front view.
self.assertLessEqual(slots["top"][1] + slots["top"][3], yf + 0.01)
def test_single_view_centered_upright(self):
slots, scale, rots = _layout_views_on_sheet(
[_view("front")], {"front": (0.0, 0.0, 40.0, 30.0)}
)
self.assertIsNotNone(scale)
self.assertEqual(rots.get("front", 0.0), 0.0)
self.assertGreater(scale, 0)
x, y, w, h = slots["front"]
self.assertGreaterEqual(x, 19.9)
self.assertGreaterEqual(y, 9.9)
class TestSheetFurniture(unittest.TestCase):
"""ISO 5457 furniture primitives mirror the reference SVG template."""
def _prims(self):
d = TechnicalDrawing(
title="Test Bracket",
part_number="PN-42",
material="Alu 6082",
revision="B",
)
return _sheet_frame_primitives(d, "1 : 2")
def test_all_geometry_inside_sheet(self):
for p in self._prims():
for (x, y) in p.points:
self.assertGreaterEqual(x, -0.01)
self.assertLessEqual(x, 297.01)
self.assertGreaterEqual(y, -0.01)
self.assertLessEqual(y, 210.01)
def test_drawing_space_frame(self):
# Frame rect 20,10 267x190: four edges present as line prims.
lines = [
(tuple(p.points[0]), tuple(p.points[1]))
for p in self._prims()
if p.kind == "line" and p.style == "frame"
]
for a, b in (
((20.0, 10.0), (287.0, 10.0)),
((287.0, 10.0), (287.0, 200.0)),
((287.0, 200.0), (20.0, 200.0)),
((20.0, 200.0), (20.0, 10.0)),
):
self.assertIn((a, b), lines)
def test_grid_reference_labels(self):
texts = [p.text for p in self._prims() if p.kind == "text" and p.text]
for t in ("1", "2", "3", "4", "5", "6", "A", "B", "C", "D", "A4"):
self.assertIn(t, texts)
def test_title_block_fields(self):
texts = [p.text for p in self._prims() if p.kind == "text" and p.text]
for expect in (
"Owner:",
"Drawing number:",
"Revision:",
"Issue date:",
"Sheet:",
"Language:",
"Title:",
"Approved by:",
"Created by:",
"Document type:",
"Part Material:",
"General tolerances:",
"Scale:",
"Test Bracket",
"PN-42",
"Alu 6082",
"B",
"1 : 2",
"EN",
"1 / 1",
"ISO 2768-m",
"Component Drawing",
):
self.assertIn(expect, texts)
def test_first_angle_projection_symbol(self):
# Two concentric circles at the symbol centre (273, 58) in sheet
# coords (SVG y-down 152 → 210 - 152 = 58).
circles = [
p
for p in self._prims()
if p.kind == "circle" and p.center == (273.0, 58.0)
]
self.assertEqual(len(circles), 2)
self.assertEqual(
sorted(c.radius for c in circles if c.radius), [2.5, 5.0]
)
class TestSheetSizeNormalisation(unittest.TestCase):
"""Drawings stored as A3 in .fluency files load as A4 — the renderer
only supports the A4 ISO 5457 sheet."""
def test_legacy_a3_loads_as_a4(self):
from fluency.io.project_io import _technical_drawing_from_dict
d = _technical_drawing_from_dict(
{
"id": "x",
"source_kind": "component",
"source_id": "c",
"sheet_size": "A3",
}
)
self.assertEqual(d.sheet_size, "A4")
if __name__ == "__main__":
unittest.main()
+7 -6
View File
@@ -54,8 +54,9 @@ from fluency.technical_drawing import (
)
# Sheet layout constants.
_A3_MM_W = 420.0
_A3_MM_H = 297.0
# Sheet layout constants (A4 landscape, ISO 5457 template).
_A4_MM_W = 297.0
_A4_MM_H = 210.0
# ── 2D geometry helpers (view-plane model coordinates) ────────────────────
@@ -202,7 +203,7 @@ class DrawingCanvas(QWidget):
def _sheet_rect(self) -> QRectF:
"""Sheet rect in device coordinates (zoom/pan applied)."""
aspect = _A3_MM_W / _A3_MM_H
aspect = _A4_MM_W / _A4_MM_H
w = self.width()
h = self.height()
@@ -220,18 +221,18 @@ class DrawingCanvas(QWidget):
def to_sheet(self, pos: QPointF) -> Tuple[float, float]:
"""Convert a device pixel position to sheet mm coordinates."""
rect = self._sheet_rect()
scale = rect.width() / _A3_MM_W
scale = rect.width() / _A4_MM_W
if scale <= 0:
return (0.0, 0.0)
return (
(pos.x() - rect.x()) / scale,
_A3_MM_H - (pos.y() - rect.y()) / scale,
_A4_MM_H - (pos.y() - rect.y()) / scale,
)
def _pick_tolerance_mm(self) -> float:
"""Pick radius in sheet mm (about 6 device pixels)."""
rect = self._sheet_rect()
scale = rect.width() / _A3_MM_W
scale = rect.width() / _A4_MM_W
return 6.0 / scale if scale > 0 else 6.0
def _sheet_to_model(