- tech draw draft v2

This commit is contained in:
bklronin
2026-08-17 21:39:20 +02:00
parent 37e5335446
commit 813ddc3596
3 changed files with 573 additions and 188 deletions
+9 -12
View File
@@ -6,10 +6,7 @@
<component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- tech draw draft v2">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/io/project_io.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/io/project_io.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/models/data_model.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/models/data_model.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/technical_drawing.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/technical_drawing.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/main_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/main_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/technical_drawing_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/technical_drawing_widget.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
@@ -122,14 +119,6 @@
<option name="presentableId" value="Default" />
<updated>1703867682707</updated>
</task>
<task id="LOCAL-00003" summary="- Sketch projection partly works again :)">
<option name="closed" value="true" />
<created>1735563255455</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1735563255455</updated>
</task>
<task id="LOCAL-00004" summary="- Sketch projection partly works again :)">
<option name="closed" value="true" />
<created>1735585968733</created>
@@ -514,7 +503,15 @@
<option name="project" value="LOCAL" />
<updated>1786910497589</updated>
</task>
<option name="localTasksCounter" value="52" />
<task id="LOCAL-00052" summary="- tech draw draft v2">
<option name="closed" value="true" />
<created>1786984519780</created>
<option name="number" value="00052" />
<option name="presentableId" value="LOCAL-00052" />
<option name="project" value="LOCAL" />
<updated>1786984519781</updated>
</task>
<option name="localTasksCounter" value="53" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
+475 -148
View File
@@ -85,7 +85,13 @@ class DrawingRenderResult:
warnings: Tuple[str, ...]
# Per-view model→sheet transform: view_id → (scale, offset_x, offset_y)
# with sheet(x, y) = (x*scale + offset_x, y*scale + offset_y).
view_transforms: Dict[str, Tuple[float, float, float]] = field(default_factory=dict)
# view_id → (scale, sheet_cx, sheet_cy, angle_deg, cx, cy) with
# sheet(p) = scale · R(angle_deg) · (p (cx, cy)) + (sheet_cx, sheet_cy),
# i.e. (sheet_cx, sheet_cy) is the sheet position of the view's
# geometry centre and angle_deg is the view's sheet rotation (0/90).
# Legacy 3-tuples (scale, offset_x, offset_y) — sheet(p) = p*scale +
# offset — are still accepted by the consumers.
view_transforms: Dict[str, Tuple[float, ...]] = field(default_factory=dict)
# ── View presets ───────────────────────────────────────────────────────────
@@ -325,7 +331,8 @@ def _assemble_view(
view: DrawingView,
slot: Optional[Tuple[float, float, float, float]] = None,
scale_override: Optional[float] = None,
transforms: Optional[Dict[str, Tuple[float, float, float]]] = None,
transforms: Optional[Dict[str, Tuple[float, ...]]] = None,
rotation: float = 0.0,
) -> Tuple[Tuple[DrawingPrimitive, ...], Tuple[DrawingCandidate, ...], Tuple[str, ...]]:
"""Fit projected edges into *slot* and emit primitives + candidates.
@@ -334,7 +341,12 @@ def _assemble_view(
whole sheet. *scale_override* forces a specific model→sheet scale
(used to keep all orthographic views at one shared scale).
When *transforms* is given, the resolved
``(scale, offset_x, offset_y)`` is recorded under the view id.
transform is recorded under the view id as
``(scale, sheet_cx, sheet_cy, angle_deg, cx, cy)`` — the sheet
position of the geometry centre plus the rotation actually applied.
*rotation* turns the projection 90° steps about its centre before
fitting (0 or 90), so a long thin view can be drawn sideways to make
the whole sheet layout fit better.
"""
warnings = list(warnings)
primitives: List[DrawingPrimitive] = []
@@ -344,6 +356,35 @@ def _assemble_view(
warnings.append(f"View '{view.name or view.kind}': no projected edges")
return tuple(primitives), tuple(candidates), tuple(warnings)
# Rotate the projection about its centre first: everything downstream
# (fitting, candidates, direction vectors) then works on the rotated
# frame with a plain uniform scale + translation, and rotation
# preserves all measured lengths.
min_x, min_y, max_x, max_y = _edges_bounds(edges)
rcx = (min_x + max_x) / 2.0
rcy = (min_y + max_y) / 2.0
if rotation:
th = math.radians(rotation)
cos_t, sin_t = math.cos(th), math.sin(th)
def _rot_pt(p: Tuple[float, float]) -> Tuple[float, float]:
dx, dy = p[0] - rcx, p[1] - rcy
return (
dx * cos_t - dy * sin_t + rcx,
dx * sin_t + dy * cos_t + rcy,
)
rotated_edges: List[Tuple[Tuple[float, float], Tuple[float, float], str, str]] = []
for p1, p2, curve_type, style in edges:
if curve_type == "circle_full":
# p2 only encodes the radius as the x-offset of p1.
r = p2[0] - p1[0]
nc = _rot_pt(p1)
rotated_edges.append((nc, (nc[0] + r, nc[1]), curve_type, style))
else:
rotated_edges.append((_rot_pt(p1), _rot_pt(p2), curve_type, style))
edges = rotated_edges
# Separate geometry by curve type (model units).
line_segments: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
circle_data: List[Tuple[float, float, float]] = [] # (cx, cy, r)
@@ -378,7 +419,17 @@ def _assemble_view(
# Use kind as view_id for readability (UUID is opaque to users).
view_id = view.kind if view.kind in _STANDARD_VIEWS else (view.name or view.id)
if transforms is not None:
transforms[view_id] = (scale, offset_x, offset_y)
# Record o = offset + scale·c: the sheet position of the model
# centre, so consumers can invert via
# model = R(-angle)·(sheet o)/scale + c.
transforms[view_id] = (
scale,
offset_x + scale * rcx,
offset_y + scale * rcy,
float(rotation),
rcx,
rcy,
)
def _to_sheet(x: float, y: float) -> Tuple[float, float]:
return (x * scale + offset_x, y * scale + offset_y)
@@ -400,6 +451,28 @@ def _assemble_view(
view_id=view_id,
)
)
# ISO 14128 centre mark: a thin cross extending just past the
# circle, so the centre is visible and usable as a dimension
# reference (centre-to-centre, centre-to-edge distances).
sc = _to_sheet(cx, cy)
sr = radius * scale
ext = sr + max(2.0, sr * 0.15)
primitives.append(
DrawingPrimitive(
kind="line",
points=((sc[0] - ext, sc[1]), (sc[0] + ext, sc[1])),
style="center",
view_id=view_id,
)
)
primitives.append(
DrawingPrimitive(
kind="line",
points=((sc[0], sc[1] - ext), (sc[0], sc[1] + ext)),
style="center",
view_id=view_id,
)
)
else:
primitives.append(
DrawingPrimitive(
@@ -786,161 +859,383 @@ def _extract_angle_candidates(
angle_count += 1
# ── Drawing generation ────────────────────────────────────────────────────
# ── 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.
_SHEET_INNER = (
_LAYOUT_MARGIN_MM,
_LAYOUT_MARGIN_MM,
_A3_WIDTH_MM - _LAYOUT_MARGIN_MM,
_A3_HEIGHT_MM - _LAYOUT_MARGIN_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
_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),
)
_REGION_LEFT = (
_LAYOUT_MARGIN_MM,
_LAYOUT_MARGIN_MM,
_TB_LEFT_MM - _TB_CLEARANCE_MM - _LAYOUT_MARGIN_MM,
_A3_HEIGHT_MM - 2 * _LAYOUT_MARGIN_MM,
)
_MID_ORDER = ("left", "front", "right", "back")
_COL_ORDER = ("top", "front", "bottom") # sheet top → bottom
_GRID_ORDER = ("front", "right", "back", "top", "left", "bottom")
# Leave room for dimension lines: the shared scale fits the views into
# this fraction of the region, so extension lines and labels have space
# between and around the views instead of colliding with neighbours.
_DIM_ROOM_FACTOR = 0.8
_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.
*dims* maps view id → ``(w, h)`` in sheet units. Returns local
``(vid, x, y, w, h)`` rects (origin arbitrary — the caller centres the
union on the sheet).
"""
mid = [k for k in _MID_ORDER if k in dims]
col = [k for k in _COL_ORDER if k in dims]
if not mid and not col:
return []
rects: Dict[str, Tuple[float, float, float, float]] = {}
if col:
# Stack bottom → top.
y = 0.0
for k in reversed(col):
w, h = dims[k]
rects[k] = (0.0, y, w, h)
y += h + gap
col_h = y - gap
cx = max(dims[k][0] for k in col) / 2.0
for k in col:
_x, yy, w, h = rects[k]
rects[k] = (cx - w / 2.0, yy, w, h)
else:
col_h = 0.0
cx = 0.0
anchor = "front" if "front" in dims else (mid[0] if mid else col[0])
if anchor in rects:
ax, ay, aw, _ah = rects[anchor]
anchor_cy = ay + _ah / 2.0
else:
aw, ah = dims[anchor]
ax = cx - aw / 2.0
ay = col_h / 2.0 - ah / 2.0
rects[anchor] = (ax, ay, aw, ah)
anchor_cy = col_h / 2.0
ia = mid.index(anchor) if anchor in mid else -1
x = ax
for k in reversed(mid[:ia]):
w, h = dims[k]
x -= w + gap
rects[k] = (x, anchor_cy - h / 2.0, w, h)
x = ax + aw
for k in mid[ia + 1 :]:
w, h = dims[k]
x += gap
rects[k] = (x, anchor_cy - h / 2.0, w, h)
x += w
return [(k, *r) for k, r in rects.items()]
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
turned a quarter turn, for sheets where that orientation fits more.
"""
mid = [k for k in _MID_ORDER if k in dims]
col = [k for k in _COL_ORDER if k in dims]
if not mid or not col:
return []
rects: Dict[str, Tuple[float, float, float, float]] = {}
cx = max(dims[k][0] for k in mid) / 2.0
y = 0.0
for k in mid: # top → bottom
w, h = dims[k]
rects[k] = (cx - w / 2.0, y, w, h)
y += h + gap
anchor = "front" if "front" in dims else mid[0]
anchor_cy = rects[anchor][1] + dims[anchor][1] / 2.0
row: Dict[str, Tuple[float, float, float, float]] = {}
x = 0.0
x_anchor = 0.0
for k in reversed(col): # bottom, front, top → left to right
w, h = dims[k]
if k == anchor:
x_anchor = x
row[k] = (x, anchor_cy - h / 2.0, w, h)
x += w + gap
shift = rects[anchor][0] - x_anchor
for k, r in row.items():
if k == anchor:
continue
x0, y0, w, h = r
rects[k] = (x0 + shift, y0, w, h)
return [(k, *r) for k, r in rects.items()]
def _place_grid(
dims: Dict[str, Tuple[float, float]], gap: float, rows: int
) -> _RectList:
"""Wrap the present standard views into a grid of *rows* rows, filled
bottom→top and left→right (so the primary views sit near the bottom,
like in the cross)."""
order = [k for k in _GRID_ORDER if k in dims]
if not order:
return []
cols = max(1, -(-len(order) // rows))
rects: _RectList = []
y = 0.0
for r in range(rows):
chunk = order[r * cols : (r + 1) * cols]
if not chunk:
break
x = 0.0
row_h = 0.0
for k in chunk:
w, h = dims[k]
rects.append((k, x, y, w, h))
x += w + gap
row_h = max(row_h, h)
y += row_h + gap
return rects
def _fit_scale(
place: Callable[[Dict[str, Tuple[float, float]], float], _RectList],
dims_m: Dict[str, Tuple[float, float]],
region: Tuple[float, float, float, float],
) -> float:
"""Largest shared scale at which *dims_m* (model units) laid out by
*place* fits the ``(x0, y0, w, h)`` *region* of the sheet.
Every placer scales its output linearly with the input dims, so the
union size at scale s is s times its size at scale 1 — the fit is the
closed-form ``min(region_w / u_w, region_h / u_h)``.
"""
_rx0, _ry0, rw, rh = region
rects = place(dims_m, _VIEW_GAP_MM)
if not rects:
return 0.0
minx = min(r[1] for r in rects)
miny = min(r[2] for r in rects)
maxx = max(r[1] + r[3] for r in rects)
maxy = max(r[2] + r[4] for r in rects)
uw = max(maxx - minx, 1e-9)
uh = max(maxy - miny, 1e-9)
return min(rw / uw, rh / uh)
def _free_rects(
used_rects: Sequence[Tuple[float, float, float, float]]
) -> List[Tuple[float, float, float, float]]:
"""Axis-aligned free rects ``(x0, y0, w, h)`` around *used_rects*,
clearing the sheet border and the title block zone."""
ix0, iy0, ix1, iy1 = _SHEET_INNER
if used_rects:
ux0 = min(r[0] for r in used_rects)
uy0 = min(r[1] for r in used_rects)
ux1 = max(r[0] + r[2] for r in used_rects)
uy1 = max(r[1] + r[3] for r in used_rects)
cands = [
(ux1 + _VIEW_GAP_MM, iy0, ix1, iy1), # right of the used block
(ix0, iy0, ux0 - _VIEW_GAP_MM, iy1), # left
(ix0, uy1 + _VIEW_GAP_MM, ix1, iy1), # above
(ix0, iy0, ix1, uy0 - _VIEW_GAP_MM), # below
]
else:
cands = [(ix0, iy0, ix1, iy1)]
tb = (
_TB_LEFT_MM - _TB_CLEARANCE_MM,
0.0,
_A3_WIDTH_MM - (_TB_LEFT_MM - _TB_CLEARANCE_MM),
_TB_TOP_MM + _TB_CLEARANCE_MM,
)
out: List[Tuple[float, float, float, float]] = []
for x0, y0, x1, y1 in cands:
x0, y0 = max(x0, ix0), max(y0, iy0)
x1, y1 = min(x1, ix1), min(y1, iy1)
if x1 - x0 < 1.0 or y1 - y0 < 1.0:
continue
if not (x1 <= tb[0] or tb[2] <= x0 or y1 <= tb[1] or tb[3] <= y0):
# Overlaps the title block zone — keep the parts above/left of it.
subs = [
(x0, max(y0, tb[3]), x1, y1),
(x0, y0, min(x1, tb[0]), y1),
]
else:
subs = [(x0, y0, x1, y1)]
for sx0, sy0, sx1, sy1 in subs:
if sx1 - sx0 > 1.0 and sy1 - sy0 > 1.0:
out.append((sx0, sy0, sx1 - sx0, sy1 - sy0))
return out
def _layout_views_on_sheet(
views: Sequence[DrawingView],
bboxes: Dict[str, Tuple[float, float, float, float]],
) -> Tuple[Dict[str, Tuple[float, float, float, float]], Optional[float]]:
"""Compute a slot rectangle for each view in standard orthographic layout.
) -> Tuple[
Dict[str, Tuple[float, float, float, float]],
Optional[float],
Dict[str, float],
]:
"""Compute a slot rectangle and sheet rotation for each view.
*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).
units (from :func:`_edges_bounds`). Returns ``(slots, common_scale,
rotations)``: slots are ``(left, bottom, width, height)`` in sheet mm
(origin at the sheet's bottom-left corner, +y up), common_scale is the
shared model→sheet scale of the standard orthographic views, and
rotations maps view_id → sheet rotation in degrees (0 or 90).
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.
The sheet is filled, not just used: every candidate arrangement
(classic third-angle cross, the cross with the view families swapped,
and 2/3-row grids) is combined with every per-view 90° rotation
assignment (with 12 views the projections stay upright and only the
scale is optimised), and the candidate giving the largest shared scale
is used. Candidates within 0.5% of the best scale prefer the one with
fewer rotated views, then the more conventional arrangement, so layouts
stay stable and standard whenever they are already the best fit. The
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
views take the largest remaining free rect (clearing the title block).
"""
if not views:
return {}, None
margin = 10.0
gap = 12.0
title_block_h = 55.0
full_w = _A3_WIDTH_MM - 2 * margin
# The bottom view sits at the bottom of the projection column, so the
# whole column stays clear of the title block (bottom-right corner).
col_bottom = margin + title_block_h
col_top = _A3_HEIGHT_MM - margin
col_avail = col_top - col_bottom
def dims(vid: str) -> Tuple[float, float]:
b = bboxes.get(vid)
if b is None:
return 1.0, 1.0
return max(b[2] - b[0], 1e-6), max(b[3] - b[1], 1e-6)
ortho_kinds = [
v.kind for v in views if v.kind in _STANDARD_VIEWS and v.kind != "isometric"
]
mid_order = ["left", "front", "right", "back"]
col_order = ["top", "front", "bottom"] # top → bottom
present_mid = [k for k in mid_order if k in ortho_kinds]
present_col = [k for k in col_order if k in ortho_kinds]
slots: Dict[str, Tuple[float, float, float, float]] = {}
rotations: Dict[str, float] = {}
common_scale: Optional[float] = None
if present_mid or present_col:
row_w = sum(dims(k)[0] for k in present_mid)
col_h = sum(dims(k)[1] for k in present_col)
scale_opts: List[float] = []
if present_mid:
scale_opts.append((full_w - gap * (len(present_mid) - 1)) / row_w)
if present_col:
scale_opts.append((col_avail - gap * (len(present_col) - 1)) / col_h)
common_scale = min(scale_opts)
ortho = [
v for v in views if v.kind in _STANDARD_VIEWS and v.kind != "isometric"
]
# 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
)
used_rects: List[Tuple[float, float, float, float]] = []
if ortho:
# Nominal (scale-1) sizes: the per-view ``scale`` factor is applied
# at assembly time on top of the shared scale, exactly as before.
dims0: Dict[str, Tuple[float, float]] = {}
for v in ortho:
b = bboxes.get(v.kind)
if b is None:
dims0[v.kind] = (1.0, 1.0)
else:
ay, aw, ah = col_slots[anchor]
ax = margin + (full_w - aw) / 2.0
anchor_cx = ax + aw / 2.0
for k in set(present_mid) | set(present_col):
w, h = dims(k)
sw, sh = w * common_scale, h * common_scale
if k in row_slots and k in col_slots:
sx = row_slots[k][0]
sy = col_slots[k][0]
elif k in row_slots:
# Mid-row view without a column slot: centre on the anchor.
sx = row_slots[k][0]
sy = ay + (ah - sh) / 2.0
else:
# Column view without a mid slot: align with the anchor.
sx = anchor_cx - sw / 2.0
sy = col_slots[k][0]
slots[k] = (sx, sy, sw, sh)
# Isometric: free region to the right of the main block.
if any(v.kind == "isometric" for v in views):
iso_x0 = ax + aw + gap
if "top" in col_slots:
iso_x0 = max(iso_x0, anchor_cx + col_slots["top"][1] / 2.0 + gap)
iso_y0 = ay + ah + gap
iso_x1 = _A3_WIDTH_MM - margin
iso_y1 = _A3_HEIGHT_MM - margin
if iso_x1 - iso_x0 < 30.0 or iso_y1 - iso_y0 < 30.0:
# No room at the right — fall back to the bottom-left corner.
left_x = slots.get("left", (margin + full_w * 0.5,))[0]
iso_x1 = min(iso_x1, left_x - gap)
bottom_y = slots.get("bottom", (0.0, col_bottom + col_avail * 0.5, 0, 0))[1]
iso_y1 = min(iso_y1, bottom_y - gap)
slots["isometric"] = (
iso_x0,
iso_y0,
max(iso_x1 - iso_x0, 10.0),
max(iso_y1 - iso_y0, 10.0),
dims0[v.kind] = (
max(b[2] - b[0], 1e-6),
max(b[3] - b[1], 1e-6),
)
keys = list(dims0)
arrangements: Tuple[
Tuple[str, int, Callable[[Dict[str, Tuple[float, float]], float], _RectList]]
] = (
("cross", 0, _place_cross),
("swapped", 1, _place_swapped),
("grid2", 2, lambda d, g: _place_grid(d, g, 2)),
("grid3", 3, lambda d, g: _place_grid(d, g, 3)),
)
cands: List[
Tuple[float, int, int, Tuple[float, float, float, float],
Dict[str, Tuple[float, float]],
Callable[[Dict[str, Tuple[float, float]], float], _RectList],
List[bool]]
] = []
# Rotated views are a packing tool for multi-view layouts; with
# 12 views the projection is kept upright and only scaled to fit.
n_masks = 1 << len(keys) if len(keys) >= 3 else 1
# Shrink each region for dimension clearance (views are centred in
# the full region, so this only reduces the scale).
upper = _REGION_UPPER[:2] + tuple(
d * _DIM_ROOM_FACTOR for d in _REGION_UPPER[2:]
)
left = _REGION_LEFT[:2] + tuple(
d * _DIM_ROOM_FACTOR for d in _REGION_LEFT[2:]
)
for mask in range(n_masks):
rotated = [bool(mask & (1 << i)) for i in range(len(keys))]
dims_m = {
k: (
dims0[k][1] if rotated[i] else dims0[k][0],
dims0[k][0] if rotated[i] else dims0[k][1],
)
for i, k in enumerate(keys)
}
for _name, rank, place in arrangements:
s_up = _fit_scale(place, dims_m, upper)
s_left = _fit_scale(place, dims_m, left)
if s_up >= s_left:
s, region = s_up, _REGION_UPPER
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)
s, region = s_left, _REGION_LEFT
if s <= 0.0:
continue
cands.append((s, sum(rotated), rank, region, dims_m, place, rotated))
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),
key=lambda c: (c[1], c[2], -c[0]),
)
rx0, _ry0, rw, rh = region
ds = {k: (w * s, h * s) for k, (w, h) in dims_m.items()}
rects = place(ds, _VIEW_GAP_MM)
minx = min(r[1] for r in rects)
miny = min(r[2] for r in rects)
maxx = max(r[1] + r[3] for r in rects)
maxy = max(r[2] + r[4] for r in rects)
ox = rx0 + (rw - (maxx - minx)) / 2.0
oy = _ry0 + (rh - (maxy - miny)) / 2.0
for vid, x, y, w, h in rects:
slots[vid] = (x - minx + ox, y - miny + oy, w, h)
rotations[vid] = 90.0 if rotated[keys.index(vid)] else 0.0
common_scale = s
used_rects = [(ox, oy, maxx - minx, maxy - miny)]
# 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)
# Isometric and custom views: the largest remaining free rect each,
# clearing the title block.
extra = [
v for v in views
if v.kind == "isometric" or v.kind not in _STANDARD_VIEWS
]
assigned = list(used_rects)
for i, v in enumerate(extra):
vid = v.kind if v.kind in _STANDARD_VIEWS else (v.name or v.id)
free = _free_rects(assigned)
if free:
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)
slots[vid] = slot
assigned.append(slot)
return slots, common_scale
return slots, common_scale, rotations
# ── Dimension selection & placement ───────────────────────────────────────
@@ -1365,7 +1660,7 @@ _MANUAL_DIMENSION_KINDS = ("length", "diameter", "angle")
def build_manual_candidates(
drawing: TechnicalDrawing,
view_transforms: Dict[str, Tuple[float, float, float]],
view_transforms: Dict[str, Tuple[float, ...]],
) -> Tuple[List[DrawingCandidate], List[str], List[str]]:
"""Convert user-placed annotations into renderable dimension candidates.
@@ -1389,10 +1684,33 @@ def build_manual_candidates(
if transform is None:
unresolved.append(ann.id)
continue
scale, offset_x, offset_y = transform
scale, ox, oy = transform[0], transform[1], transform[2]
if len(transform) >= 6:
# New form: sheet(p) = s·R(θ)(p c) + o, where o is the sheet
# position of the geometry centre c and θ the view rotation.
th = math.radians(transform[3])
cos_t, sin_t = math.cos(th), math.sin(th)
rcx, rcy = transform[4], transform[5]
def to_sheet(pt: Tuple[float, float]) -> Tuple[float, float]:
return (pt[0] * scale + offset_x, pt[1] * scale + offset_y)
dx, dy = pt[0] - rcx, pt[1] - rcy
return (
(dx * cos_t - dy * sin_t) * scale + ox,
(dx * sin_t + dy * cos_t) * scale + oy,
)
def rot_dir(d: Tuple[float, float]) -> Tuple[float, float]:
return (
d[0] * cos_t - d[1] * sin_t,
d[0] * sin_t + d[1] * cos_t,
)
else:
def to_sheet(pt: Tuple[float, float]) -> Tuple[float, float]:
return (pt[0] * scale + ox, pt[1] * scale + oy)
def rot_dir(d: Tuple[float, float]) -> Tuple[float, float]:
return d
a0, a1 = ann.anchors[0], ann.anchors[1]
if ann.dimension_kind == "angle":
@@ -1439,7 +1757,7 @@ def build_manual_candidates(
dx, dy = a1[0] - a0[0], a1[1] - a0[1]
# value >= 1e-6 guarantees the fallback vector is non-zero.
mag = math.hypot(dx, dy)
direction: Tuple[float, float] = (dx / mag, dy / mag)
direction: Tuple[float, float] = rot_dir((dx / mag, dy / mag))
label = f"{value:.{_DISPLAY_PRECISION}f}"
else: # diameter
direction = ()
@@ -1510,7 +1828,9 @@ def generate_drawing(
_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)
view_slots, common_scale, view_rotations = _layout_views_on_sheet(
drawing.views, bboxes
)
ortho_vids = {
_vid_of(v)
@@ -1519,7 +1839,7 @@ def generate_drawing(
}
# Per-view model→sheet transforms, filled in by _assemble_view.
transforms: Dict[str, Tuple[float, float, float]] = {}
transforms: Dict[str, Tuple[float, ...]] = {}
for view in drawing.views:
vid = _vid_of(view)
@@ -1529,7 +1849,13 @@ def generate_drawing(
common_scale * view.scale if vid in ortho_vids and common_scale else None
)
prims, cands, vwarns = _assemble_view(
edges, view_warnings, view, slot, scale_override, transforms
edges,
view_warnings,
view,
slot,
scale_override,
transforms,
rotation=view_rotations.get(vid, 0.0),
)
all_primitives.extend(prims)
all_candidates.extend(cands)
@@ -1683,6 +2009,7 @@ def render_drawing(
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),
}
+87 -26
View File
@@ -243,7 +243,16 @@ class DrawingCanvas(QWidget):
t = self._render_result.view_transforms.get(view_id)
if t is None or t[0] <= 0:
return None
scale, ox, oy = t
scale, ox, oy = t[0], t[1], t[2]
if len(t) >= 6:
# sheet(p) = s·R(θ)(p c) + o → invert around the centre.
th = math.radians(t[3])
cos_t, sin_t = math.cos(th), math.sin(th)
sx, sy = (pt[0] - ox) / scale, (pt[1] - oy) / scale
return (
sx * cos_t + sy * sin_t + t[4],
-sx * sin_t + sy * cos_t + t[5],
)
return ((pt[0] - ox) / scale, (pt[1] - oy) / scale)
def _pick_feature(self, pos: QPointF) -> Optional[dict]:
@@ -283,12 +292,19 @@ class DrawingCanvas(QWidget):
"p2": m2,
}
elif prim.kind == "circle" and prim.center and prim.radius:
d = abs(
math.hypot(
pt[0] - prim.center[0], pt[1] - prim.center[1]
)
- prim.radius
)
dc = math.hypot(pt[0] - prim.center[0], pt[1] - prim.center[1])
if dc <= tol:
# Hit the circle's centre mark: a point feature — circle
# centres are first-class dimension references.
mc = self._sheet_to_model(prim.view_id, prim.center)
if mc:
return {
"kind": "point",
"view_id": prim.view_id,
"point": mc,
"radius": prim.radius / scale,
}
d = abs(dc - prim.radius)
if d < best_d:
best_d = d
mc = self._sheet_to_model(prim.view_id, prim.center)
@@ -506,19 +522,11 @@ class TechnicalDrawingWidget(QWidget):
(
"distance",
"Distance",
"Pick two edges — the dimension line is placed "
"perpendicular to them, measuring the distance between",
),
(
"diameter",
"Diameter",
"Pick a circle — its diameter is added",
),
(
"angle",
"Angle",
"Pick two edges — the angle between them is added",
"Pick two edges, an edge and a circle centre, or two "
"circle centres — measures the distance between them",
),
("diameter", "Diameter", "Pick a circle — its diameter is added"),
("angle", "Angle", "Pick two edges — the angle between them is added"),
):
btn = QPushButton(label)
btn.setCheckable(True)
@@ -818,7 +826,7 @@ class TechnicalDrawingWidget(QWidget):
self._first_pick = None
self._canvas.set_pick_mode(tool)
prompts = {
"distance": "Distance: click the first edge",
"distance": "Distance: click an edge or a circle centre",
"diameter": "Diameter: click a circle",
"angle": "Angle: click the first edge",
}
@@ -883,6 +891,40 @@ class TechnicalDrawingWidget(QWidget):
elif mode in ("distance", "angle"):
self._on_edge_pick(info, mode)
@staticmethod
def _pick_point(info: dict) -> Optional[Tuple[float, float]]:
"""The measurable point of a pick: a point pick is its point, a
circle pick counts as its centre; a bare segment is None."""
if info.get("kind") == "point":
return info["point"]
if info.get("kind") == "circle":
return info["center"]
return None
@classmethod
def _distance_anchors(
cls, first: dict, second: dict
) -> Tuple[Tuple[float, float], Tuple[float, float]]:
"""Anchor pair for a distance between two picks (model coords).
A pick may be a segment (edge), a circle (measured at its centre)
or a point (a picked circle centre). Point/segment mixes use the
closest point on the segment so the dimension lands perpendicular
to the edge, ISO style.
"""
fp = cls._pick_point(first)
sp = cls._pick_point(second)
if fp is not None and sp is not None:
return fp, sp
if fp is not None:
return fp, _closest_point_on_segment(fp, second["p1"], second["p2"])
if sp is not None:
return sp, _closest_point_on_segment(sp, first["p1"], first["p2"])
q1, q2, _d = _closest_points_on_segments(
first["p1"], first["p2"], second["p1"], second["p2"]
)
return q1, q2
def _on_edge_pick(self, info: dict, mode: str) -> None:
if (
self._first_pick is None
@@ -891,16 +933,16 @@ class TechnicalDrawingWidget(QWidget):
# First edge (or picked in a different view: restart there).
self._first_pick = info
self._status_label.setText(
"Select the second edge in the same view (Esc cancels)"
"Select the second feature in the same view (Esc cancels)"
)
return
a1, a2 = self._first_pick["p1"], self._first_pick["p2"]
b1, b2 = info["p1"], info["p2"]
first = self._first_pick
view_id = info["view_id"]
if mode == "distance":
q1, q2, dist = _closest_points_on_segments(a1, a2, b1, b2)
p1, p2 = self._distance_anchors(first, info)
dist = math.hypot(p2[0] - p1[0], p2[1] - p1[1])
if dist < 0.01:
self._status_label.setText(
"The two edges coincide — no distance to measure"
@@ -908,12 +950,20 @@ class TechnicalDrawingWidget(QWidget):
return
self._add_manual_dimension(
"length",
anchors=(q1, q2),
anchors=(p1, p2),
view_id=view_id,
direction=((q2[0] - q1[0]) / dist, (q2[1] - q1[1]) / dist),
direction=((p2[0] - p1[0]) / dist, (p2[1] - p1[1]) / dist),
done_msg=f"Distance dimension added: {dist:.2f}",
)
else: # angle
if first.get("kind") != "segment" or info.get("kind") != "segment":
self._status_label.setText(
"Angle needs two edges — cancel and pick edge lines"
)
self._first_pick = None
return
a1, a2 = first["p1"], first["p2"]
b1, b2 = info["p1"], info["p2"]
vertex = _line_intersection(a1, a2, b1, b2)
if vertex is None:
self._status_label.setText(
@@ -937,6 +987,17 @@ class TechnicalDrawingWidget(QWidget):
)
def _on_diameter_pick(self, info: dict) -> None:
if info.get("kind") == "point" and info.get("radius"):
# Picked the centre mark of a circle.
cx, cy = info["point"]
r = info["radius"]
self._add_manual_dimension(
"diameter",
anchors=((cx - r, cy), (cx + r, cy)),
view_id=info["view_id"],
done_msg=f"Diameter dimension added: Ø{2 * r:.2f}",
)
return
if info.get("kind") != "circle":
return
cx, cy = info["center"]