diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 9af2b5c..74c4fff 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,12 +4,10 @@
-
-
-
+
+
+
-
-
@@ -47,48 +45,48 @@
- {
- "keyToString": {
- "Python.2dtest.executor": "Run",
- "Python.3d_windows.executor": "Run",
- "Python.Unnamed.executor": "Run",
- "Python.base.executor": "Run",
- "Python.data_model.executor": "Run",
- "Python.debug_dragging.executor": "Run",
- "Python.draw_widget2d.executor": "Run",
- "Python.draw_widget_solve.executor": "Run",
- "Python.fluency.executor": "Run",
- "Python.fluencyb.executor": "Run",
- "Python.gl_widget.executor": "Run",
- "Python.gui_ui.executor": "Run",
- "Python.kernel.executor": "Run",
- "Python.main.executor": "Run",
- "Python.main_window.executor": "Run",
- "Python.meshtest.executor": "Run",
- "Python.occ_renderer.executor": "Run",
- "Python.occ_to_mesh.executor": "Run",
- "Python.render_backend.executor": "Run",
- "Python.side_fluency.executor": "Run",
- "Python.simple_mesh.executor": "Run",
- "Python.sketch.executor": "Run",
- "Python.technical_drawing_widget.executor": "Run",
- "Python.vtk_widget.executor": "Run",
- "Python.vulkan.executor": "Run",
- "RunOnceActivity.OpenProjectViewOnStart": "true",
- "RunOnceActivity.ShowReadmeOnStart": "true",
- "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
- "RunOnceActivity.git.unshallow": "true",
- "RunOnceActivity.typescript.service.memoryLimit.init": "true",
- "codeWithMe.voiceChat.enabledByDefault": "false",
- "git-widget-placeholder": "feature/occ-migration",
- "last_opened_file_path": "/Volumes/Data_drive/Programming/fluency",
- "node.js.detected.package.eslint": "true",
- "node.js.selected.package.eslint": "(autodetect)",
- "node.js.selected.package.tslint": "(autodetect)",
- "nodejs_package_manager_path": "npm",
- "settings.editor.selected.configurable": "project.propVCSSupport.DirectoryMappings"
+
+}]]>
+
-
@@ -122,14 +120,6 @@
1703867682707
-
-
- 1735601610504
-
-
-
- 1735601610504
-
1735601786207
@@ -514,7 +504,15 @@
1786995561376
-
+
+
+ 1787058413911
+
+
+
+ 1787058413911
+
+
@@ -535,7 +533,6 @@
-
@@ -560,6 +557,7 @@
-
+
+
\ No newline at end of file
diff --git a/src/fluency/io/project_io.py b/src/fluency/io/project_io.py
index b2c376f..25b87e6 100644
--- a/src/fluency/io/project_io.py
+++ b/src/fluency/io/project_io.py
@@ -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)),
diff --git a/src/fluency/models/data_model.py b/src/fluency/models/data_model.py
index 30cdff2..b138d18 100644
--- a/src/fluency/models/data_model.py
+++ b/src/fluency/models/data_model.py
@@ -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
diff --git a/src/fluency/technical_drawing.py b/src/fluency/technical_drawing.py
index 05e3d90..2ef64e7 100644
--- a/src/fluency/technical_drawing.py
+++ b/src/fluency/technical_drawing.py
@@ -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()
diff --git a/src/fluency/tests/test_iso5457_sheet.py b/src/fluency/tests/test_iso5457_sheet.py
new file mode 100644
index 0000000..7d8892f
--- /dev/null
+++ b/src/fluency/tests/test_iso5457_sheet.py
@@ -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()
diff --git a/src/fluency/ui/technical_drawing_widget.py b/src/fluency/ui/technical_drawing_widget.py
index 1a0d409..4c25579 100644
--- a/src/fluency/ui/technical_drawing_widget.py
+++ b/src/fluency/ui/technical_drawing_widget.py
@@ -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(