diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 42a2482..942e505 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,10 +4,10 @@
-
-
+
-
+
+
@@ -44,48 +44,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"
}
-}]]>
+}
1703867682707
-
-
- 1703951701948
-
-
-
- 1703951701948
-
1729958532384
@@ -511,7 +503,15 @@
1786180549405
-
+
+
+ 1786888483687
+
+
+
+ 1786888483688
+
+
@@ -532,7 +532,6 @@
-
@@ -557,6 +556,7 @@
-
+
+
\ No newline at end of file
diff --git a/src/fluency/models/data_model.py b/src/fluency/models/data_model.py
index 0cfe638..7eee380 100644
--- a/src/fluency/models/data_model.py
+++ b/src/fluency/models/data_model.py
@@ -891,8 +891,8 @@ class DrawingView:
direction: Optional[Tuple[float, float, float]] = None
up_vector: Optional[Tuple[float, float, float]] = None
- show_hidden_lines: bool = True
- show_centerlines: bool = True
+ show_hidden_lines: bool = False
+ show_centerlines: bool = False
scale: float = 1.0
# Sheet position (mm from sheet origin) — set by layout engine.
diff --git a/src/fluency/technical_drawing.py b/src/fluency/technical_drawing.py
index 510937a..e714e5e 100644
--- a/src/fluency/technical_drawing.py
+++ b/src/fluency/technical_drawing.py
@@ -51,6 +51,9 @@ class DrawingCandidate:
value: float
anchor_points: Tuple[Tuple[float, float], ...]
label: str
+ # Unit vector in sheet space along which the distance is measured
+ # (linear/extent candidates). Empty for diameter/angle.
+ direction: Tuple[float, float] = ()
@dataclass(frozen=True)
@@ -248,95 +251,162 @@ def _mat_to_quat(m: np.ndarray) -> Tuple[float, float, float, float]:
# ── View projection ────────────────────────────────────────────────────────
-def generate_view(
+def _project_view(
source_parts: Sequence[DrawingSourcePart],
view: DrawingView,
-) -> Tuple[Tuple[DrawingPrimitive, ...], Tuple[DrawingCandidate, ...], Tuple[str, ...]]:
- """Project one view from source parts.
+) -> Tuple[
+ List[Tuple[Tuple[float, float], Tuple[float, float], str, str]],
+ List[str],
+]:
+ """Project one view's edges via HLR.
- Returns ``(primitives, candidates, warnings)``.
+ Returns ``(edges, warnings)`` where each edge is
+ ``(p1, p2, curve_type, style)`` in model (view-plane) units and
+ *style* is ``"visible"`` or ``"hidden"``.
"""
- primitives: List[DrawingPrimitive] = []
- candidates: List[DrawingCandidate] = []
warnings: List[str] = []
+ edges: List[Tuple[Tuple[float, float], Tuple[float, float], str, str]] = []
direction = view.direction or (0.0, -1.0, 0.0)
direction = _normalize(direction)
- # Collect 2D edges from HLR projection (HLR output is already in view plane).
- all_edges_2d: List[Tuple[Tuple[float, float], Tuple[float, float], str]] = []
- circle_data: List[Tuple[float, float, float]] = [] # (cx, cy, radius) from circles
- arc_segments: List[List[Tuple[float, float]]] = [] # sampled arc points
-
for part in source_parts:
try:
vis_edges, hid_edges = _project_part_edges(part.shape, direction)
except Exception as exc:
warnings.append(f"HLR projection failed for {part.display_name}: {exc}")
continue
-
- all_edges_2d.extend(vis_edges)
+ edges.extend((p1, p2, ct, "visible") for p1, p2, ct in vis_edges)
if view.show_hidden_lines:
- all_edges_2d.extend(hid_edges)
+ edges.extend((p1, p2, ct, "hidden") for p1, p2, ct in hid_edges)
- if not all_edges_2d:
+ return edges, warnings
+
+
+def _edges_bounds(
+ edges: Sequence[Tuple[Tuple[float, float], Tuple[float, float], str, str]],
+) -> Tuple[float, float, float, float]:
+ """Bounding box ``(min_x, min_y, max_x, max_y)`` of projected edges.
+
+ Full circles contribute their extremes, not just the centre/radius
+ marker points.
+ """
+ min_x = min_y = math.inf
+ max_x = max_y = -math.inf
+ for p1, p2, curve_type, _style in edges:
+ if curve_type == "circle_full":
+ cx, cy = p1
+ radius = p2[0] - p1[0]
+ min_x = min(min_x, cx - radius)
+ max_x = max(max_x, cx + radius)
+ min_y = min(min_y, cy - radius)
+ max_y = max(max_y, cy + radius)
+ else:
+ for pt in (p1, p2):
+ min_x = min(min_x, pt[0])
+ max_x = max(max_x, pt[0])
+ min_y = min(min_y, pt[1])
+ max_y = max(max_y, pt[1])
+ if min_x > max_x:
+ return (0.0, 0.0, 0.0, 0.0)
+ return (min_x, min_y, max_x, max_y)
+
+
+
+def _assemble_view(
+ edges: Sequence[Tuple[Tuple[float, float], Tuple[float, float], str, str]],
+ warnings: Sequence[str],
+ view: DrawingView,
+ slot: Optional[Tuple[float, float, float, float]] = None,
+ scale_override: Optional[float] = None,
+) -> Tuple[Tuple[DrawingPrimitive, ...], Tuple[DrawingCandidate, ...], Tuple[str, ...]]:
+ """Fit projected edges into *slot* and emit primitives + candidates.
+
+ *slot* is ``(left, bottom, width, height)`` in sheet mm (origin
+ bottom-left, +y up). When omitted the projection is fitted to the
+ whole sheet. *scale_override* forces a specific model→sheet scale
+ (used to keep all orthographic views at one shared scale).
+ """
+ warnings = list(warnings)
+ primitives: List[DrawingPrimitive] = []
+ candidates: List[DrawingCandidate] = []
+
+ if not edges:
warnings.append(f"View '{view.name or view.kind}': no projected edges")
return tuple(primitives), tuple(candidates), tuple(warnings)
- # Separate circles/arcs from line segments for dimension extraction.
+ # Separate geometry by curve type (model units).
line_segments: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
- circle_data: List[Tuple[float, float, float]] = []
- arc_segments: List[List[Tuple[float, float]]] = []
- for p1, p2, curve_type in all_edges_2d:
- if curve_type == "circle_center_radius":
- # Accurate circle data from OCC Geom_Circle.
+ circle_data: List[Tuple[float, float, float]] = [] # (cx, cy, r)
+ for p1, p2, curve_type, _style in edges:
+ if curve_type == "circle_full":
cx, cy = p1
radius = p2[0] - p1[0]
if radius > 0.5:
circle_data.append((cx, cy, radius))
- elif curve_type == "circle":
- # Fallback approximation from arc endpoints.
- mid = ((p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0)
- r = math.dist(p1, mid)
- if r > 0.5:
- circle_data.append((mid[0], mid[1], r))
- elif curve_type == "other":
- arc_segments.append([p1, p2])
- else:
+ elif curve_type == "line":
line_segments.append((p1, p2))
+ # "other" = sampled arc points; used for fitting/rendering only.
- # Fit to A3 sheet.
- all_pts = [pt for e in all_edges_2d for pt in (e[0], e[1])]
- min_x = min(p[0] for p in all_pts)
- max_x = max(p[0] for p in all_pts)
- min_y = min(p[1] for p in all_pts)
- max_y = max(p[1] for p in all_pts)
- geom_w = max(max_x - min_x, 1.0)
- geom_h = max(max_y - min_y, 1.0)
- available_w = _A3_WIDTH_MM - _TITLE_MARGIN_MM * 2
- available_h = _A3_HEIGHT_MM - _TITLE_MARGIN_MM * 2
- scale = min(available_w / geom_w, available_h / geom_h) * view.scale
- offset_x = (_A3_WIDTH_MM - geom_w * scale) / 2.0 - min_x * scale
- offset_y = (_A3_HEIGHT_MM - geom_h * scale) / 2.0 - min_y * scale
+ min_x, min_y, max_x, max_y = _edges_bounds(edges)
+ geom_w = max(max_x - min_x, 1e-6)
+ geom_h = max(max_y - min_y, 1e-6)
+
+ if slot is not None:
+ left, bottom, avail_w, avail_h = slot
+ else:
+ left, bottom = 0.0, 0.0
+ avail_w = _A3_WIDTH_MM - _TITLE_MARGIN_MM * 2
+ avail_h = _A3_HEIGHT_MM - _TITLE_MARGIN_MM * 2
+
+ if scale_override is not None:
+ scale = scale_override
+ else:
+ scale = min(avail_w / geom_w, avail_h / geom_h) * view.scale
+ offset_x = left + (avail_w - geom_w * scale) / 2.0 - min_x * scale
+ offset_y = bottom + (avail_h - geom_h * scale) / 2.0 - min_y * scale
def _to_sheet(x: float, y: float) -> Tuple[float, float]:
return (x * scale + offset_x, y * scale + offset_y)
- # Convert edges to primitives.
- for p1, p2, style in all_edges_2d:
- s1 = _to_sheet(*p1)
- s2 = _to_sheet(*p2)
- primitives.append(DrawingPrimitive(kind="line", points=(s1, s2), style=style))
+ # Convert edges to primitives (proper circle + hidden-line styles).
+ for p1, p2, curve_type, style in edges:
+ if curve_type == "circle_full":
+ cx, cy = p1
+ radius = p2[0] - p1[0]
+ if radius <= 0.5:
+ continue
+ primitives.append(
+ DrawingPrimitive(
+ kind="circle",
+ points=(),
+ style=style,
+ center=_to_sheet(cx, cy),
+ radius=radius * scale,
+ )
+ )
+ else:
+ primitives.append(
+ DrawingPrimitive(
+ kind="line",
+ points=(_to_sheet(*p1), _to_sheet(*p2)),
+ style=style,
+ )
+ )
# ── Dimension candidate extraction ──────────────────────────────
# Use kind as view_id for readability (UUID is opaque to users).
view_id = view.kind if view.kind in _STANDARD_VIEWS else (view.name or view.id)
- # 1. Overall extents (width and height of projected geometry).
- width_val = (max_x - min_x) * scale
- height_val = (max_y - min_y) * scale
- bw = _to_sheet((min_x + max_x) / 2.0, min_y - 5.0 / scale)
- tw = _to_sheet((min_x + max_x) / 2.0, max_y + 5.0 / scale)
+ # 1. Overall extents (bounding-box width/height), anchored at real
+ # bbox corners so extension lines can start at feature extremes.
+ # Values are true model dimensions (scale-independent) — the labels
+ # must match the part, not the sheet scale.
+ width_val = (max_x - min_x)
+ height_val = (max_y - min_y)
+ bl = _to_sheet(min_x, min_y)
+ br = _to_sheet(max_x, min_y)
+ tl = _to_sheet(min_x, max_y)
candidates.append(
DrawingCandidate(
key=f"{view_id}:extent:width",
@@ -344,13 +414,11 @@ def generate_view(
kind="extent",
references=(),
value=width_val,
- anchor_points=(bw, tw),
- label=f"Ø{width_val:.{_DISPLAY_PRECISION}f}" if abs(width_val - height_val) < width_val * 0.01 else f"{width_val:.{_DISPLAY_PRECISION}f}",
+ anchor_points=(bl, br),
+ label=f"{width_val:.{_DISPLAY_PRECISION}f}",
+ direction=(0.0, -1.0),
)
)
-
- bh = _to_sheet(min_x - 5.0 / scale, (min_y + max_y) / 2.0)
- th = _to_sheet(max_x + 5.0 / scale, (min_y + max_y) / 2.0)
candidates.append(
DrawingCandidate(
key=f"{view_id}:extent:height",
@@ -358,8 +426,9 @@ def generate_view(
kind="extent",
references=(),
value=height_val,
- anchor_points=(bh, th),
+ anchor_points=(bl, tl),
label=f"{height_val:.{_DISPLAY_PRECISION}f}",
+ direction=(-1.0, 0.0),
)
)
@@ -375,6 +444,21 @@ def generate_view(
return tuple(primitives), tuple(candidates), tuple(warnings)
+def generate_view(
+ source_parts: Sequence[DrawingSourcePart],
+ view: DrawingView,
+ slot: Optional[Tuple[float, float, float, float]] = None,
+) -> Tuple[Tuple[DrawingPrimitive, ...], Tuple[DrawingCandidate, ...], Tuple[str, ...]]:
+ """Project one view from source parts.
+
+ *slot* optionally limits the fit to ``(left, bottom, width, height)``
+ in sheet mm; when omitted the projection is fitted to the full A3
+ sheet. Returns ``(primitives, candidates, warnings)``.
+ """
+ edges, warnings = _project_view(source_parts, view)
+ return _assemble_view(edges, warnings, view, slot, None)
+
+
def _project_part_edges(
shape: Any,
direction: Tuple[float, float, float],
@@ -451,17 +535,26 @@ def _collect_edge(
p2 = curve.Value(last)
out.append(((p1.X(), p1.Y()), (p2.X(), p2.Y()), "line"))
elif ct == GeomAbs_Circle:
- # Extract real circle geometry for accurate diameter detection.
+ # Full circles are recorded as a centre/radius marker (used for
+ # rendering + diameter detection); partial arcs are sampled into
+ # polyline segments so fillets do not become full circles.
geom_circ = curve.Circle() # Geom_Circle
center = geom_circ.Position().Location()
radius = geom_circ.Radius()
- p1 = curve.Value(first)
- p2 = curve.Value(last)
- out.append(((p1.X(), p1.Y()), (p2.X(), p2.Y()), "circle"))
- # Also record center and radius as special marker.
- out.append(
- ((center.X(), center.Y()), (center.X() + radius, center.Y()), "circle_center_radius"),
- )
+ span = last - first
+ if span >= 2.0 * math.pi - 0.05:
+ out.append(
+ ((center.X(), center.Y()), (center.X() + radius, center.Y()), "circle_full"),
+ )
+ else:
+ prev: Optional[Tuple[float, float]] = None
+ for i in range(num_samples + 1):
+ t = first + span * i / num_samples
+ p = curve.Value(t)
+ cur = (p.X(), p.Y())
+ if prev is not None:
+ out.append((prev, cur, "other"))
+ prev = cur
else:
prev: Optional[Tuple[float, float]] = None
for i in range(num_samples + 1):
@@ -505,7 +598,7 @@ def _extract_diameter_candidates(
for i, cluster in enumerate(clusters):
avg_r = sum(c[2] for c in cluster) / len(cluster)
- diam = 2.0 * avg_r * scale
+ diam = 2.0 * avg_r # true model diameter, not sheet-scaled
# Use first circle center as anchor.
cx, cy = cluster[0][0], cluster[0][1]
p1 = _to_sheet(cx - avg_r, cy)
@@ -572,9 +665,10 @@ def _extract_linear_candidates(
mx1, my1 = (p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0
mx2, my2 = (q1[0] + q2[0]) / 2.0, (q1[1] + q2[1]) / 2.0
- # Distance perpendicular to segment direction.
+ # Distance perpendicular to segment direction (model units —
+ # labels must show true part dimensions, not sheet-scaled ones).
nx, ny = -dy1 / len1, dx1 / len1 # normal
- dist = abs((mx2 - mx1) * nx + (my2 - my1) * ny) * scale
+ dist = abs((mx2 - mx1) * nx + (my2 - my1) * ny)
if dist < 0.5 or dist > 500.0:
continue
@@ -590,11 +684,11 @@ def _extract_linear_candidates(
value=dist,
anchor_points=(s_m1, s_m2),
label=f"{dist:.{_DISPLAY_PRECISION}f}",
+ direction=(nx, ny),
)
)
pair_count += 1
-
def _extract_angle_candidates(
line_segments: List[Tuple[Tuple[float, float], Tuple[float, float]]],
view_id: str,
@@ -603,7 +697,11 @@ def _extract_angle_candidates(
offset_y: float,
candidates: List[DrawingCandidate],
) -> None:
- """Detect angle dimensions between intersecting lines."""
+ """Detect angle dimensions between intersecting lines.
+
+ Anchors are ``(vertex, arm1_end, arm2_end)`` in sheet coordinates so
+ the renderer can draw a small arc between the two arms.
+ """
if len(line_segments) < 2:
return
@@ -618,8 +716,6 @@ def _extract_angle_candidates(
if angle_count >= max_angles:
break
p1, p2 = line_segments[i]
- dx1 = p2[0] - p1[0]
- dy1 = p2[1] - p1[1]
for j in range(i + 1, len(line_segments)):
if angle_count >= max_angles:
@@ -636,13 +732,13 @@ def _extract_angle_candidates(
if shared is None:
continue
- # Direction vectors from shared point.
- dx2 = q2[0] - q1[0]
- dy2 = q2[1] - q1[1]
-
- # Adjust direction based on which endpoint is shared.
- if shared == q2:
- dx2, dy2 = -dx2, -dy2
+ # Direction vectors from the shared point along each segment.
+ other1 = p2 if shared == p1 else p1
+ other2 = q2 if shared == q1 else q1
+ dx1 = other1[0] - shared[0]
+ dy1 = other1[1] - shared[1]
+ dx2 = other2[0] - shared[0]
+ dy2 = other2[1] - shared[1]
dot = dx1 * dx2 + dy1 * dy2
mag1 = math.sqrt(dx1 * dx1 + dy1 * dy1)
@@ -658,7 +754,6 @@ def _extract_angle_candidates(
if angle_deg < 5.0 or angle_deg > 175.0:
continue
- s_shared = _to_sheet(*shared)
candidates.append(
DrawingCandidate(
key=f"{view_id}:angle:{angle_count}",
@@ -666,7 +761,11 @@ def _extract_angle_candidates(
kind="angle",
references=(),
value=angle_deg,
- anchor_points=(s_shared, s_shared),
+ anchor_points=(
+ _to_sheet(*shared),
+ _to_sheet(*other1),
+ _to_sheet(*other2),
+ ),
label=f"{angle_deg:.{_DISPLAY_PRECISION}f}°",
)
)
@@ -678,77 +777,175 @@ def _extract_angle_candidates(
def _layout_views_on_sheet(
views: Sequence[DrawingView],
-) -> Dict[str, Tuple[float, float]]:
- """Compute sheet positions for each view in standard orthographic layout.
+ bboxes: Dict[str, Tuple[float, float, float, float]],
+) -> Tuple[Dict[str, Tuple[float, float, float, float]], Optional[float]]:
+ """Compute a slot rectangle for each view in standard orthographic layout.
- Returns mapping view_id → (sheet_x, sheet_y) in mm from sheet origin.
- Uses third-angle projection convention (common in manufacturing).
+ *bboxes* maps view_id → ``(min_x, min_y, max_x, max_y)`` in model
+ units (from :func:`_edges_bounds`). Returns ``(slots, common_scale)``:
+ slots are ``(left, bottom, width, height)`` in sheet mm (origin at the
+ sheet's bottom-left corner, +y up).
+
+ Layout (third-angle projection, aligned projections)::
+
+ top isometric
+ left front right back
+ bottom
+
+ All orthographic views share one scale (the tightest fit that keeps
+ every projection in its footprint) so the views stay mutually
+ consistent, and each view is centred in its allotted space.
"""
if not views:
- return {}
+ return {}, None
- positions: Dict[str, Tuple[float, float]] = {}
-
- # Sheet usable area (leave room for title block at bottom-right).
margin = 10.0
- title_block_w = 120.0
- title_block_h = 60.0
- avail_w = _A3_WIDTH_MM - margin * 2 - title_block_w
- avail_h = _A3_HEIGHT_MM - margin * 2 - title_block_h
+ gap = 12.0
+ title_block_h = 55.0
+ full_w = _A3_WIDTH_MM - 2 * margin
+ # The bottom view sits at the bottom of the projection column, so the
+ # whole column stays clear of the title block (bottom-right corner).
+ col_bottom = margin + title_block_h
+ col_top = _A3_HEIGHT_MM - margin
+ col_avail = col_top - col_bottom
- # View spacing and size estimate (fraction of available area).
- view_gap = 15.0
- max_view_w = avail_w * 0.45
- max_view_h = avail_h * 0.35
+ def dims(vid: str) -> Tuple[float, float]:
+ b = bboxes.get(vid)
+ if b is None:
+ return 1.0, 1.0
+ return max(b[2] - b[0], 1e-6), max(b[3] - b[1], 1e-6)
- # Find anchor view: prefer "front", then first standard view.
- front_id = None
- for v in views:
- if v.kind == "front":
- front_id = v.kind
- break
- if front_id is None:
- for v in views:
- if v.kind in ("front", "top", "right"):
- front_id = v.kind
- break
- if front_id is None and views:
- front_id = views[0].kind
+ ortho_kinds = [
+ v.kind for v in views if v.kind in _STANDARD_VIEWS and v.kind != "isometric"
+ ]
+ mid_order = ["left", "front", "right", "back"]
+ col_order = ["top", "front", "bottom"] # top → bottom
+ present_mid = [k for k in mid_order if k in ortho_kinds]
+ present_col = [k for k in col_order if k in ortho_kinds]
- # Center the anchor view horizontally on available area.
- center_x = margin + avail_w / 2.0
- center_y = margin + avail_h * 0.55
+ slots: Dict[str, Tuple[float, float, float, float]] = {}
+ common_scale: Optional[float] = None
- for v in views:
- vid = v.kind if v.kind in _STANDARD_VIEWS else (v.name or v.id)
- if v.kind == "front":
- positions[vid] = (center_x - max_view_w / 2.0, center_y)
- elif v.kind == "top":
- fy = positions.get(front_id, (center_x, center_y))[1]
- positions[vid] = (center_x - max_view_w / 2.0, fy - max_view_h - view_gap)
- elif v.kind == "right":
- fx = positions.get(front_id, (center_x, center_y))[0]
- positions[vid] = (fx + max_view_w + view_gap, center_y)
- elif v.kind == "left":
- fx = positions.get(front_id, (center_x, center_y))[0]
- positions[vid] = (fx - max_view_w - view_gap, center_y)
- elif v.kind == "bottom":
- fy = positions.get(front_id, (center_x, center_y))[1]
- positions[vid] = (center_x - max_view_w / 2.0, fy + max_view_h + view_gap)
- elif v.kind == "back":
- fx = positions.get(front_id, (center_x, center_y))[0]
- positions[vid] = (fx + max_view_w + view_gap * 2, center_y)
- elif v.kind == "isometric":
- fx = positions.get(front_id, (center_x, center_y))[0]
- fy = positions.get(front_id, (center_x, center_y))[1]
- positions[vid] = (fx + max_view_w / 2.0 + view_gap, fy - max_view_h * 1.5 - view_gap)
+ if present_mid or present_col:
+ row_w = sum(dims(k)[0] for k in present_mid)
+ col_h = sum(dims(k)[1] for k in present_col)
+ scale_opts: List[float] = []
+ if present_mid:
+ scale_opts.append((full_w - gap * (len(present_mid) - 1)) / row_w)
+ if present_col:
+ scale_opts.append((col_avail - gap * (len(present_col) - 1)) / col_h)
+ common_scale = min(scale_opts)
+
+ # Middle row: left → front → right → back, centred on the sheet.
+ total_row = row_w * common_scale + gap * (len(present_mid) - 1)
+ x = margin + (full_w - total_row) / 2.0
+ row_slots: Dict[str, Tuple[float, float, float]] = {}
+ for k in present_mid:
+ w, h = dims(k)
+ row_slots[k] = (x, w * common_scale, h * common_scale)
+ x += w * common_scale + gap
+
+ # Column: top → front → bottom, stacked from the top edge down and
+ # centred in the available column (which stays clear of the title
+ # block).
+ total_col = col_h * common_scale + gap * (len(present_col) - 1)
+ y = col_bottom + col_avail - (col_avail - total_col) / 2.0
+ col_slots: Dict[str, Tuple[float, float, float]] = {}
+ for k in present_col:
+ w, h = dims(k)
+ sh = h * common_scale
+ col_slots[k] = (y - sh, w * common_scale, sh)
+ y -= sh + gap
+
+ anchor = (
+ "front"
+ if "front" in present_mid
+ else (present_mid[0] if present_mid else present_col[0])
+ )
+ if anchor in row_slots:
+ ax, aw, ah = row_slots[anchor]
+ ay = (
+ col_slots[anchor][0]
+ if anchor in col_slots
+ else col_bottom + (col_avail - ah) / 2.0
+ )
else:
- used_x = [p[0] for p in positions.values()]
- nx = margin if not used_x else max(used_x) + max_view_w + view_gap
- ny = center_y
- positions[vid] = (nx, ny)
+ ay, aw, ah = col_slots[anchor]
+ ax = margin + (full_w - aw) / 2.0
+ anchor_cx = ax + aw / 2.0
- return positions
+ for k in set(present_mid) | set(present_col):
+ w, h = dims(k)
+ sw, sh = w * common_scale, h * common_scale
+ if k in row_slots and k in col_slots:
+ sx = row_slots[k][0]
+ sy = col_slots[k][0]
+ elif k in row_slots:
+ # Mid-row view without a column slot: centre on the anchor.
+ sx = row_slots[k][0]
+ sy = ay + (ah - sh) / 2.0
+ else:
+ # Column view without a mid slot: align with the anchor.
+ sx = anchor_cx - sw / 2.0
+ sy = col_slots[k][0]
+ slots[k] = (sx, sy, sw, sh)
+
+ # Isometric: free region to the right of the main block.
+ if any(v.kind == "isometric" for v in views):
+ iso_x0 = ax + aw + gap
+ if "top" in col_slots:
+ iso_x0 = max(iso_x0, anchor_cx + col_slots["top"][1] / 2.0 + gap)
+ iso_y0 = ay + ah + gap
+ iso_x1 = _A3_WIDTH_MM - margin
+ iso_y1 = _A3_HEIGHT_MM - margin
+ if iso_x1 - iso_x0 < 30.0 or iso_y1 - iso_y0 < 30.0:
+ # No room at the right — fall back to the bottom-left corner.
+ left_x = slots.get("left", (margin + full_w * 0.5,))[0]
+ iso_x1 = min(iso_x1, left_x - gap)
+ bottom_y = slots.get("bottom", (0.0, col_bottom + col_avail * 0.5, 0, 0))[1]
+ iso_y1 = min(iso_y1, bottom_y - gap)
+ slots["isometric"] = (
+ iso_x0,
+ iso_y0,
+ max(iso_x1 - iso_x0, 10.0),
+ max(iso_y1 - iso_y0, 10.0),
+ )
+ else:
+ # No standard ortho views — give the isometric most of the sheet.
+ if any(v.kind == "isometric" for v in views):
+ slots["isometric"] = (margin, margin, full_w * 0.55, col_avail)
+
+ # Custom (non-standard) views fill the bottom-left corner.
+ custom = [v for v in views if v.kind not in _STANDARD_VIEWS]
+ if custom:
+ left_edge = slots.get("left", (margin + full_w * 0.4,))[0]
+ bottom_edge = slots.get("bottom", (0.0, col_bottom + col_avail * 0.4, 0, 0))[1]
+ cw = max(left_edge - margin - gap, 60.0)
+ ch = max(bottom_edge - margin - gap, 60.0)
+ for i, v in enumerate(custom):
+ vid = v.name or v.id
+ slots[vid] = (margin, margin + i * (ch + gap), cw, ch)
+
+ return slots, common_scale
+
+
+# ── Dimension selection & placement ───────────────────────────────────────
+
+# Per-view dimension budget: keep sheets readable for a machinist.
+_DIM_KIND_CAPS = {"diameter": 3, "extent": 2, "length": 4, "angle": 2}
+_MAX_DIMENSIONS_PER_VIEW = 10
+
+# Rendering metrics for the drawing font size.
+_DIM_TEXT_H_MM = 3.0 # text cap height (standard A3 drawing)
+_DIM_FONT_W = 1.9 # approx. mm width per character
+_DIM_FONT_H = 4.0 # text box height in mm
+_DIM_OFFSET_MM = 6.0 # dimension-line offset from the measured feature
+_DIM_EXT_OVERSHOOT_MM = 2.0 # extension-line overshoot past the dim line
+_DIM_STANDOFF_MM = 11.0 # min gap between stacked parallel dim lines
+_ARROW_MM = 3.0
+_LEADER_LEAD_MM = 8.0
+_LEADER_TAIL_MM = 10.0
+_ANGLE_ARC_MM = 5.0
def _select_dimensions_for_placement(
@@ -759,44 +956,80 @@ def _select_dimensions_for_placement(
Filters for manufacturing relevance and avoids redundant dimensions.
Prioritizes: diameters > extents > significant linear distances > angles.
+ Caps the number of placed dimensions per view so sheets stay readable.
"""
- selected: List[DrawingCandidate] = []
- seen_values: set = set() # track approximate values to avoid duplicates
+ view_candidates = [c for c in candidates if c.view_id == view_id]
def _value_key(val: float) -> float:
return round(val / 0.5) * 0.5 # bucket by 0.5 for dedup
+ # Overall-extent values: linear candidates matching an extent are
+ # redundant (they measure the same overall size).
+ extent_keys = {
+ _value_key(c.value)
+ for c in view_candidates
+ if c.kind == "extent" and 0.1 <= c.value <= 2000.0
+ }
+
# Sort candidates by priority and value significance.
def _priority(c: DrawingCandidate) -> Tuple[int, float]:
kind_order = {"diameter": 0, "extent": 1, "length": 2, "angle": 3, "radius": 4}
return (kind_order.get(c.kind, 5), -c.value)
- for c in sorted(candidates, key=_priority):
- if c.view_id != view_id:
+ selected: List[DrawingCandidate] = []
+ seen_values: set = set() # track approximate values to avoid duplicates
+ per_kind: Dict[str, int] = {}
+
+ for c in sorted(view_candidates, key=_priority):
+ if len(selected) >= _MAX_DIMENSIONS_PER_VIEW:
+ break
+ # Skip tiny or enormous dimensions.
+ if c.value < 0.1 or c.value > 2000.0:
+ continue
+ # Respect the per-kind budget.
+ if per_kind.get(c.kind, 0) >= _DIM_KIND_CAPS.get(c.kind, 3):
continue
vkey = _value_key(c.value)
# Skip duplicates within same kind+view.
if (c.kind, vkey) in seen_values:
continue
- # Skip tiny or enormous dimensions.
- if c.value < 0.1 or c.value > 2000.0:
+ # Skip linear duplicates of overall extents.
+ if c.kind == "length" and vkey in extent_keys:
continue
selected.append(c)
seen_values.add((c.kind, vkey))
+ per_kind[c.kind] = per_kind.get(c.kind, 0) + 1
return selected
def _generate_dimension_primitives(
candidates: Sequence[DrawingCandidate],
- offset_x: float,
- offset_y: float,
+ view_center: Tuple[float, float],
) -> List[DrawingPrimitive]:
- """Convert dimension candidates into renderable primitives with overlap avoidance."""
+ """Convert dimension candidates into ISO-style renderable primitives.
+
+ Linear/extent candidates become extension lines + an offset dimension
+ line with arrowheads + centred text (dimension line broken for the
+ label). Diameters become a 45° leader with a horizontal tail. Angles
+ become a small vertex arc + text.
+ """
+ # Keep dimension lines inside the sheet (with a small margin).
+ _sheet_min_x, _sheet_max_x = 6.0, _A3_WIDTH_MM - 6.0
+ _sheet_min_y, _sheet_max_y = 6.0, _A3_HEIGHT_MM - 6.0
+
+ def _clamp_sheet(pt: Tuple[float, float]) -> Tuple[float, float]:
+ return (
+ min(max(pt[0], _sheet_min_x), _sheet_max_x),
+ min(max(pt[1], _sheet_min_y), _sheet_max_y),
+ )
prims: List[DrawingPrimitive] = []
# Track occupied zones to avoid overlapping dimension text.
occupied: List[Tuple[float, float, float, float]] = [] # (x0, y0, x1, y1) in sheet coords
+ # Placed dimension lines: (u_x, u_y, q_x, q_y, t_min, t_max) — unit
+ # direction u, point q on the line, foot span relative to q along u.
+ placed_dim_lines: List[Tuple[float, float, float, float, float, float]] = []
def _would_overlap(x: float, y: float, w: float, h: float) -> bool:
for x0, y0, x1, y1 in occupied:
@@ -807,102 +1040,241 @@ def _generate_dimension_primitives(
def _add_zone(x: float, y: float, w: float, h: float) -> None:
occupied.append((x - 2, y - 2, x + w + 2, y + h + 2))
+ def _add_text(center_x: float, center_y: float, text: str, key: Optional[str]) -> None:
+ w = len(text) * _DIM_FONT_W
+ x = center_x - w / 2.0
+ base_y = center_y + 1.0 # text sits just above the reference point
+ if _would_overlap(x, base_y, w, _DIM_FONT_H):
+ base_y = center_y - 1.0 - _DIM_FONT_H # drop below instead
+ prims.append(
+ DrawingPrimitive(
+ kind="text",
+ points=((x, base_y),),
+ style="dimension",
+ text=text,
+ candidate_key=key,
+ center=(center_x, center_y),
+ )
+ )
+ _add_zone(x, base_y, w, _DIM_FONT_H)
+
+ def _add_arrow(tip: Tuple[float, float], u: Tuple[float, float]) -> None:
+ bx, by = tip[0] - u[0] * _ARROW_MM, tip[1] - u[1] * _ARROW_MM
+ px, py = -u[1] * _ARROW_MM * 0.4, u[0] * _ARROW_MM * 0.4
+ prims.append(
+ DrawingPrimitive(kind="line", points=(tip, (bx + px, by + py)), style="dimension")
+ )
+ prims.append(
+ DrawingPrimitive(kind="line", points=(tip, (bx - px, by - py)), style="dimension")
+ )
+
for c in candidates:
if len(c.anchor_points) < 2:
continue
- p1, p2 = c.anchor_points[0], c.anchor_points[1]
- label_w = len(c.label) * 4.5 # approximate text width at drawing font size
- label_h = 6.0
-
- if c.kind == "diameter":
- # Diameter: place text near center with leader line.
- cx, cy = (p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0
- tx, ty = cx - label_w / 2.0, cy - 8.0
- if _would_overlap(tx, ty, label_w, label_h):
- ty = cy + 8.0
-
- prims.append(
- DrawingPrimitive(
- kind="text",
- points=(),
- style="dimension",
- text=c.label,
- candidate_key=c.key,
- center=(tx, ty),
- )
- )
- _add_zone(tx, ty, label_w, label_h)
-
- elif c.kind in ("extent", "length"):
- # Linear: place dimension line and text between anchors.
- mx, my = (p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0
-
- # Offset dimension line away from geometry by ~8mm.
- dx = p2[0] - p1[0]
- dy = p2[1] - p1[1]
- dist = math.sqrt(dx * dx + dy * dy)
- if dist > 0.5:
- nx, ny = -dy / dist, dx / dist
- dim_x = mx + nx * 8.0
- dim_y = my + ny * 8.0
+ if c.kind in ("extent", "length"):
+ p1, p2 = c.anchor_points[0], c.anchor_points[1]
+ d = c.direction
+ dl = math.hypot(d[0], d[1]) if d else 0.0
+ if dl > 1e-9:
+ d = (d[0] / dl, d[1] / dl)
else:
- dim_x, dim_y = mx, my
+ vx, vy = p2[0] - p1[0], p2[1] - p1[1]
+ vl = math.hypot(vx, vy)
+ d = (vx / vl, vy / vl) if vl > 1e-9 else (1.0, 0.0)
- # Dimension line endpoints (offset from anchors along normal).
- tx = dim_x - label_w / 2.0
- ty = dim_y - label_h / 2.0
-
- if _would_overlap(tx, ty, label_w, label_h):
- tx += 5.0
- ty += 5.0
-
- prims.append(
- DrawingPrimitive(
- kind="line",
- points=((p1[0], p1[1]), (dim_x, dim_y)),
- style="dimension",
- candidate_key=c.key,
- )
+ mx, my = (p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0
+ # Half the anchor spread along the measurement direction: the
+ # dimension line lands this far (plus the offset) beyond the
+ # far feature line.
+ spread = abs((p2[0] - p1[0]) * d[0] + (p2[1] - p1[1]) * d[1]) / 2.0
+ offset = spread + _DIM_OFFSET_MM
+ # Place the dimension line on the side of the anchor pair away
+ # from the view centre (outside the feature).
+ sign = (
+ -1.0
+ if (view_center[0] - mx) * d[0] + (view_center[1] - my) * d[1] > 0
+ else 1.0
)
- prims.append(
- DrawingPrimitive(
- kind="line",
- points=((p2[0], p2[1]), (dim_x, dim_y)),
- style="dimension",
- candidate_key=c.key,
- )
- )
- prims.append(
- DrawingPrimitive(
- kind="text",
- points=(),
- style="dimension",
- text=c.label,
- candidate_key=c.key,
- center=(tx, ty),
- )
- )
- _add_zone(tx, ty, label_w, label_h)
+ cdim = _clamp_sheet((mx + sign * d[0] * offset, my + sign * d[1] * offset))
- elif c.kind == "angle":
- # Angle: place text near vertex with arc indicator.
- tx = p1[0] + 5.0
- ty = p1[1] - 8.0
- if _would_overlap(tx, ty, label_w, label_h):
- ty = p1[1] + 8.0
-
- prims.append(
- DrawingPrimitive(
- kind="text",
- points=(),
- style="dimension",
- text=c.label,
- candidate_key=c.key,
- center=(tx, ty),
+ # Progressive stacking: push the dimension line away from
+ # already-placed parallel lines whose foot spans overlap, so
+ # parallel dimensions stay readable (standard CAD behaviour).
+ for _ in range(8):
+ t1 = (cdim[0] - p1[0]) * d[0] + (cdim[1] - p1[1]) * d[1]
+ t2 = (cdim[0] - p2[0]) * d[0] + (cdim[1] - p2[1]) * d[1]
+ e1 = (p1[0] + d[0] * t1, p1[1] + d[1] * t1)
+ e2 = (p2[0] + d[0] * t2, p2[1] + d[1] * t2)
+ half = math.hypot(e2[0] - e1[0], e2[1] - e1[1]) / 2.0
+ if half <= 0.5:
+ break
+ u = (
+ (e2[0] - e1[0]) / (2.0 * half),
+ (e2[1] - e1[1]) / (2.0 * half),
)
+ pushed = False
+ for (ux0, uy0, qx0, qy0, tmin0, tmax0) in placed_dim_lines:
+ if abs(u[0] * ux0 + u[1] * uy0) < 0.98:
+ continue # not parallel
+ ta1 = (e1[0] - qx0) * ux0 + (e1[1] - qy0) * uy0
+ ta2 = (e2[0] - qx0) * ux0 + (e2[1] - qy0) * uy0
+ if max(ta1, ta2) < tmin0 or min(ta1, ta2) > tmax0:
+ continue # spans do not overlap
+ sep = (cdim[0] - qx0) * d[0] + (cdim[1] - qy0) * d[1]
+ if abs(sep) < _DIM_STANDOFF_MM:
+ prev = cdim
+ cdim = (
+ cdim[0] + sign * d[0] * (_DIM_STANDOFF_MM - abs(sep)),
+ cdim[1] + sign * d[1] * (_DIM_STANDOFF_MM - abs(sep)),
+ )
+ cdim = _clamp_sheet(cdim)
+ if cdim == prev:
+ # Pushed against the sheet edge — stop stacking.
+ break
+ pushed = True
+ break
+ if not pushed:
+ break
+
+ # Final feet of the (possibly stacked) dimension line.
+ t1 = (cdim[0] - p1[0]) * d[0] + (cdim[1] - p1[1]) * d[1]
+ t2 = (cdim[0] - p2[0]) * d[0] + (cdim[1] - p2[1]) * d[1]
+ e1 = (p1[0] + d[0] * t1, p1[1] + d[1] * t1)
+ e2 = (p2[0] + d[0] * t2, p2[1] + d[1] * t2)
+ half = math.hypot(e2[0] - e1[0], e2[1] - e1[1]) / 2.0
+ if half > 0.5:
+ u = ((e2[0] - e1[0]) / (2.0 * half), (e2[1] - e1[1]) / (2.0 * half))
+ s1 = (e1[0] - cdim[0]) * u[0] + (e1[1] - cdim[1]) * u[1]
+ s2 = (e2[0] - cdim[0]) * u[0] + (e2[1] - cdim[1]) * u[1]
+ placed_dim_lines.append(
+ (u[0], u[1], cdim[0], cdim[1], min(s1, s2), max(s1, s2))
+ )
+ # Extension lines: anchor → 2mm past the dimension line.
+ for anchor, t in ((p1, t1), (p2, t2)):
+ end = (
+ anchor[0] + d[0] * (t + sign * _DIM_EXT_OVERSHOOT_MM),
+ anchor[1] + d[1] * (t + sign * _DIM_EXT_OVERSHOOT_MM),
+ )
+ prims.append(
+ DrawingPrimitive(
+ kind="line",
+ points=(anchor, end),
+ style="dimension",
+ candidate_key=c.key,
+ )
+ )
+ # Dimension line, broken for the centred label.
+ label_w = len(c.label) * _DIM_FONT_W
+ gap = label_w / 2.0 + 1.5
+ if half > gap + 2.0:
+ prims.append(
+ DrawingPrimitive(
+ kind="line",
+ points=(e1, (cdim[0] - u[0] * gap, cdim[1] - u[1] * gap)),
+ style="dimension",
+ candidate_key=c.key,
+ )
+ )
+ prims.append(
+ DrawingPrimitive(
+ kind="line",
+ points=((cdim[0] + u[0] * gap, cdim[1] + u[1] * gap), e2),
+ style="dimension",
+ candidate_key=c.key,
+ )
+ )
+ # Arrowheads at both ends, pointing inward.
+ if half > _ARROW_MM + 1.0:
+ _add_arrow(e1, u)
+ _add_arrow(e2, (-u[0], -u[1]))
+ _add_text(cdim[0], cdim[1], c.label, c.key)
+
+ elif c.kind == "diameter":
+ p1, p2 = c.anchor_points[0], c.anchor_points[1]
+ cx, cy = (p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0
+ r = math.hypot(p2[0] - p1[0], p2[1] - p1[1]) / 2.0
+ placed = False
+ for flip in (0.0, math.pi):
+ ux = math.cos(math.pi / 4.0 + flip)
+ uy = math.sin(math.pi / 4.0 + flip)
+ lead_start = (cx + ux * r, cy + uy * r)
+ lead_end = (
+ cx + ux * (r + _LEADER_LEAD_MM),
+ cy + uy * (r + _LEADER_LEAD_MM),
+ )
+ tail_end = (lead_end[0] + ux * _LEADER_TAIL_MM, lead_end[1])
+ label_w = len(c.label) * _DIM_FONT_W
+ tx = lead_end[0] + 1.5 if flip == 0.0 else lead_end[0] - 1.5 - label_w
+ ty = lead_end[1] - _DIM_FONT_H / 2.0
+ if _would_overlap(tx, ty, label_w, _DIM_FONT_H):
+ continue
+ prims.append(
+ DrawingPrimitive(
+ kind="line",
+ points=(lead_start, tail_end),
+ style="dimension",
+ candidate_key=c.key,
+ )
+ )
+ prims.append(
+ DrawingPrimitive(
+ kind="text",
+ points=((tx, ty),),
+ style="dimension",
+ text=c.label,
+ candidate_key=c.key,
+ center=(tx + label_w / 2.0, ty + _DIM_FONT_H / 2.0),
+ )
+ )
+ _add_zone(tx, ty, label_w, _DIM_FONT_H)
+ placed = True
+ break
+ if not placed:
+ # No free diagonal: emit the text at the 45° leader end
+ # without a zone check rather than dropping it.
+ ux = math.cos(math.pi / 4.0)
+ lead_end = (
+ cx + ux * (r + _LEADER_LEAD_MM),
+ cy + ux * (r + _LEADER_LEAD_MM),
+ )
+ prims.append(
+ DrawingPrimitive(
+ kind="text",
+ points=((lead_end[0] + 1.5, lead_end[1] - _DIM_FONT_H / 2.0),),
+ style="dimension",
+ text=c.label,
+ candidate_key=c.key,
+ )
+ )
+
+ elif c.kind == "angle" and len(c.anchor_points) >= 3:
+ vertex, a1, a2 = c.anchor_points[0], c.anchor_points[1], c.anchor_points[2]
+ a1d = math.atan2(a1[1] - vertex[1], a1[0] - vertex[0])
+ a2d = math.atan2(a2[1] - vertex[1], a2[0] - vertex[0])
+ delta = (a2d - a1d + math.pi) % (2.0 * math.pi) - math.pi
+ prev: Optional[Tuple[float, float]] = None
+ steps = 8
+ for i in range(steps + 1):
+ t = a1d + delta * i / steps
+ pt = (
+ vertex[0] + math.cos(t) * _ANGLE_ARC_MM,
+ vertex[1] + math.sin(t) * _ANGLE_ARC_MM,
+ )
+ if prev is not None:
+ prims.append(
+ DrawingPrimitive(
+ kind="line", points=(prev, pt), style="dimension", candidate_key=c.key
+ )
+ )
+ prev = pt
+ bis = a1d + delta / 2.0
+ _add_text(
+ vertex[0] + math.cos(bis) * 9.0,
+ vertex[1] + math.sin(bis) * 9.0,
+ c.label,
+ c.key,
)
- _add_zone(tx, ty, label_w, label_h)
return prims
@@ -935,42 +1307,62 @@ def generate_drawing(
warnings=tuple(warnings),
)
- # Layout views on sheet before generating individual projections.
- view_positions = _layout_views_on_sheet(drawing.views)
+ def _vid_of(view: DrawingView) -> str:
+ return view.kind if view.kind in _STANDARD_VIEWS else (view.name or view.id)
+
+ # Project every view once (HLR is the expensive step).
+ projections: Dict[
+ str,
+ Tuple[
+ List[Tuple[Tuple[float, float], Tuple[float, float], str, str]],
+ List[str],
+ ],
+ ] = {}
+ for view in drawing.views:
+ projections[_vid_of(view)] = _project_view(parts, view)
+
+ # Layout: slots + shared ortho scale derived from the actual
+ # projected sizes.
+ bboxes = {
+ _vid_of(view): _edges_bounds(projections[_vid_of(view)][0])
+ for view in drawing.views
+ }
+ view_slots, common_scale = _layout_views_on_sheet(drawing.views, bboxes)
+
+ ortho_vids = {
+ _vid_of(v)
+ for v in drawing.views
+ if v.kind in _STANDARD_VIEWS and v.kind != "isometric"
+ }
for view in drawing.views:
- vid = view.kind if view.kind in _STANDARD_VIEWS else (view.name or view.id)
- pos = view_positions.get(vid, (0.0, 0.0))
- prims, cands, view_warnings = generate_view(parts, view)
- # Offset primitives to their sheet position.
- if pos != (0.0, 0.0):
- offset_prims = []
- for p in prims:
- new_pts = tuple(
- (pt[0] + pos[0], pt[1] + pos[1]) for pt in p.points
- )
- new_center = None
- if p.center is not None:
- new_center = (p.center[0] + pos[0], p.center[1] + pos[1])
- offset_prims.append(
- DrawingPrimitive(
- kind=p.kind, points=new_pts, style=p.style, text=p.text,
- candidate_key=p.candidate_key, center=new_center, radius=p.radius,
- dash_pattern=p.dash_pattern,
- )
- )
- prims = offset_prims
-
+ vid = _vid_of(view)
+ slot = view_slots.get(vid)
+ edges, view_warnings = projections[vid]
+ scale_override = (
+ common_scale * view.scale if vid in ortho_vids and common_scale else None
+ )
+ prims, cands, vwarns = _assemble_view(
+ edges, view_warnings, view, slot, scale_override
+ )
all_primitives.extend(prims)
all_candidates.extend(cands)
- warnings.extend(view_warnings)
+ warnings.extend(vwarns)
- # Auto-place dimensions from candidates.
+ # Auto-place dimensions from candidates (isometric views are never
+ # dimensioned on real drawings).
for view in drawing.views:
- vid = view.kind if view.kind in _STANDARD_VIEWS else (view.name or view.id)
+ if view.kind == "isometric":
+ continue
+ vid = _vid_of(view)
+ slot = view_slots.get(vid)
+ center = (
+ (slot[0] + slot[2] / 2.0, slot[1] + slot[3] / 2.0)
+ if slot
+ else (_A3_WIDTH_MM / 2.0, _A3_HEIGHT_MM / 2.0)
+ )
selected = _select_dimensions_for_placement(all_candidates, vid)
- dim_prims = _generate_dimension_primitives(selected, 0.0, 0.0)
- all_primitives.extend(dim_prims)
+ all_primitives.extend(_generate_dimension_primitives(selected, center))
# Match annotations to candidates.
resolved_ids: List[str] = []
@@ -1020,7 +1412,7 @@ def _title_block_primitives(drawing: TechnicalDrawing) -> List[DrawingPrimitive]
"""Generate title block primitives at the bottom-right of the sheet."""
prims: List[DrawingPrimitive] = []
margin = 5.0
- box_h = _TITLE_MARGIN_MM - 10.0
+ box_h = 52.0
box_w = 180.0
left = _A3_WIDTH_MM - box_w - margin
bottom = margin
@@ -1042,7 +1434,7 @@ def _title_block_primitives(drawing: TechnicalDrawing) -> List[DrawingPrimitive]
("Rev:", drawing.revision, 3),
]
for label, value, row in fields:
- ty = bottom + box_h - line_h * (row + 1) - 4
+ ty = bottom + box_h - line_h * (row + 1) + 3
prims.append(
DrawingPrimitive(
kind="text",
@@ -1098,7 +1490,9 @@ def render_drawing(
style_pens["hidden"].setStyle(Qt.PenStyle.DashLine)
style_pens["construction"].setStyle(Qt.PenStyle.DashDotLine)
- font = QFont("sans-serif", max(6, int(8 * scale)))
+ font = QFont("sans-serif")
+ # Font size in device px so the text height is a constant sheet mm.
+ font.setPixelSize(max(6, int(_DIM_TEXT_H_MM * scale)))
painter.setFont(font)
for prim in render_result.primitives:
diff --git a/src/fluency/ui/technical_drawing_widget.py b/src/fluency/ui/technical_drawing_widget.py
index cd0a585..375d9c2 100644
--- a/src/fluency/ui/technical_drawing_widget.py
+++ b/src/fluency/ui/technical_drawing_widget.py
@@ -290,12 +290,12 @@ class TechnicalDrawingWidget(QWidget):
for vid in ("front", "top", "right", "isometric"):
self._view_checkboxes[vid].setChecked(True)
# Match the DrawingView model defaults (hidden lines + centerlines
- # on) so Generate produces the same drawing as the tab-switch
+ # off) so Generate produces the same drawing as the tab-switch
# auto-load in MainWindow._load_drawing_tab_source.
for cb in self._hidden_line_checks.values():
- cb.setChecked(True)
+ cb.setChecked(False)
for cb in self._centerline_checks.values():
- cb.setChecked(True)
+ cb.setChecked(False)
left_layout.addWidget(views_group)