- added iso sheet for tech draw
This commit is contained in:
@@ -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)),
|
||||
|
||||
@@ -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
@@ -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 top→bottom (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-top→bottom (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 1–2 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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user