- tech draw draft v2
This commit is contained in:
Generated
+9
-12
@@ -6,10 +6,7 @@
|
|||||||
<component name="ChangeListManager">
|
<component name="ChangeListManager">
|
||||||
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- tech draw draft v2">
|
<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$/.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/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" />
|
<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>
|
</list>
|
||||||
<option name="SHOW_DIALOG" value="false" />
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
@@ -122,14 +119,6 @@
|
|||||||
<option name="presentableId" value="Default" />
|
<option name="presentableId" value="Default" />
|
||||||
<updated>1703867682707</updated>
|
<updated>1703867682707</updated>
|
||||||
</task>
|
</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 :)">
|
<task id="LOCAL-00004" summary="- Sketch projection partly works again :)">
|
||||||
<option name="closed" value="true" />
|
<option name="closed" value="true" />
|
||||||
<created>1735585968733</created>
|
<created>1735585968733</created>
|
||||||
@@ -514,7 +503,15 @@
|
|||||||
<option name="project" value="LOCAL" />
|
<option name="project" value="LOCAL" />
|
||||||
<updated>1786910497589</updated>
|
<updated>1786910497589</updated>
|
||||||
</task>
|
</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 />
|
<servers />
|
||||||
</component>
|
</component>
|
||||||
<component name="TypeScriptGeneratedFilesManager">
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
|
|||||||
+477
-150
@@ -85,7 +85,13 @@ class DrawingRenderResult:
|
|||||||
warnings: Tuple[str, ...]
|
warnings: Tuple[str, ...]
|
||||||
# Per-view model→sheet transform: view_id → (scale, offset_x, offset_y)
|
# Per-view model→sheet transform: view_id → (scale, offset_x, offset_y)
|
||||||
# with sheet(x, y) = (x*scale + offset_x, y*scale + 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 ───────────────────────────────────────────────────────────
|
# ── View presets ───────────────────────────────────────────────────────────
|
||||||
@@ -325,7 +331,8 @@ def _assemble_view(
|
|||||||
view: DrawingView,
|
view: DrawingView,
|
||||||
slot: Optional[Tuple[float, float, float, float]] = None,
|
slot: Optional[Tuple[float, float, float, float]] = None,
|
||||||
scale_override: Optional[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, ...]]:
|
) -> Tuple[Tuple[DrawingPrimitive, ...], Tuple[DrawingCandidate, ...], Tuple[str, ...]]:
|
||||||
"""Fit projected edges into *slot* and emit primitives + candidates.
|
"""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
|
whole sheet. *scale_override* forces a specific model→sheet scale
|
||||||
(used to keep all orthographic views at one shared scale).
|
(used to keep all orthographic views at one shared scale).
|
||||||
When *transforms* is given, the resolved
|
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)
|
warnings = list(warnings)
|
||||||
primitives: List[DrawingPrimitive] = []
|
primitives: List[DrawingPrimitive] = []
|
||||||
@@ -344,6 +356,35 @@ def _assemble_view(
|
|||||||
warnings.append(f"View '{view.name or view.kind}': no projected edges")
|
warnings.append(f"View '{view.name or view.kind}': no projected edges")
|
||||||
return tuple(primitives), tuple(candidates), tuple(warnings)
|
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).
|
# Separate geometry by curve type (model units).
|
||||||
line_segments: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
|
line_segments: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
|
||||||
circle_data: List[Tuple[float, float, float]] = [] # (cx, cy, r)
|
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).
|
# 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)
|
view_id = view.kind if view.kind in _STANDARD_VIEWS else (view.name or view.id)
|
||||||
if transforms is not None:
|
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]:
|
def _to_sheet(x: float, y: float) -> Tuple[float, float]:
|
||||||
return (x * scale + offset_x, y * scale + offset_y)
|
return (x * scale + offset_x, y * scale + offset_y)
|
||||||
@@ -400,6 +451,28 @@ def _assemble_view(
|
|||||||
view_id=view_id,
|
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:
|
else:
|
||||||
primitives.append(
|
primitives.append(
|
||||||
DrawingPrimitive(
|
DrawingPrimitive(
|
||||||
@@ -786,161 +859,383 @@ def _extract_angle_candidates(
|
|||||||
angle_count += 1
|
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(
|
def _layout_views_on_sheet(
|
||||||
views: Sequence[DrawingView],
|
views: Sequence[DrawingView],
|
||||||
bboxes: Dict[str, Tuple[float, float, float, float]],
|
bboxes: Dict[str, Tuple[float, float, float, float]],
|
||||||
) -> Tuple[Dict[str, Tuple[float, float, float, float]], Optional[float]]:
|
) -> Tuple[
|
||||||
"""Compute a slot rectangle for each view in standard orthographic layout.
|
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
|
*bboxes* maps view_id → ``(min_x, min_y, max_x, max_y)`` in model
|
||||||
units (from :func:`_edges_bounds`). Returns ``(slots, common_scale)``:
|
units (from :func:`_edges_bounds`). Returns ``(slots, common_scale,
|
||||||
slots are ``(left, bottom, width, height)`` in sheet mm (origin at the
|
rotations)``: slots are ``(left, bottom, width, height)`` in sheet mm
|
||||||
sheet's bottom-left corner, +y up).
|
(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)::
|
The sheet is filled, not just used: every candidate arrangement
|
||||||
|
(classic third-angle cross, the cross with the view families swapped,
|
||||||
top isometric
|
and 2/3-row grids) is combined with every per-view 90° rotation
|
||||||
left front right back
|
assignment (with 1–2 views the projections stay upright and only the
|
||||||
bottom
|
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
|
||||||
All orthographic views share one scale (the tightest fit that keeps
|
fewer rotated views, then the more conventional arrangement, so layouts
|
||||||
every projection in its footprint) so the views stay mutually
|
stay stable and standard whenever they are already the best fit. The
|
||||||
consistent, and each view is centred in its allotted space.
|
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]] = {}
|
slots: Dict[str, Tuple[float, float, float, float]] = {}
|
||||||
|
rotations: Dict[str, float] = {}
|
||||||
common_scale: Optional[float] = None
|
common_scale: Optional[float] = None
|
||||||
|
|
||||||
if present_mid or present_col:
|
ortho = [
|
||||||
row_w = sum(dims(k)[0] for k in present_mid)
|
v for v in views if v.kind in _STANDARD_VIEWS and v.kind != "isometric"
|
||||||
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.
|
used_rects: List[Tuple[float, float, float, float]] = []
|
||||||
total_row = row_w * common_scale + gap * (len(present_mid) - 1)
|
if ortho:
|
||||||
x = margin + (full_w - total_row) / 2.0
|
# Nominal (scale-1) sizes: the per-view ``scale`` factor is applied
|
||||||
row_slots: Dict[str, Tuple[float, float, float]] = {}
|
# at assembly time on top of the shared scale, exactly as before.
|
||||||
for k in present_mid:
|
dims0: Dict[str, Tuple[float, float]] = {}
|
||||||
w, h = dims(k)
|
for v in ortho:
|
||||||
row_slots[k] = (x, w * common_scale, h * common_scale)
|
b = bboxes.get(v.kind)
|
||||||
x += w * common_scale + gap
|
if b is None:
|
||||||
|
dims0[v.kind] = (1.0, 1.0)
|
||||||
# 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:
|
|
||||||
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:
|
else:
|
||||||
# Column view without a mid slot: align with the anchor.
|
dims0[v.kind] = (
|
||||||
sx = anchor_cx - sw / 2.0
|
max(b[2] - b[0], 1e-6),
|
||||||
sy = col_slots[k][0]
|
max(b[3] - b[1], 1e-6),
|
||||||
slots[k] = (sx, sy, sw, sh)
|
)
|
||||||
|
keys = list(dims0)
|
||||||
# Isometric: free region to the right of the main block.
|
arrangements: Tuple[
|
||||||
if any(v.kind == "isometric" for v in views):
|
Tuple[str, int, Callable[[Dict[str, Tuple[float, float]], float], _RectList]]
|
||||||
iso_x0 = ax + aw + gap
|
] = (
|
||||||
if "top" in col_slots:
|
("cross", 0, _place_cross),
|
||||||
iso_x0 = max(iso_x0, anchor_cx + col_slots["top"][1] / 2.0 + gap)
|
("swapped", 1, _place_swapped),
|
||||||
iso_y0 = ay + ah + gap
|
("grid2", 2, lambda d, g: _place_grid(d, g, 2)),
|
||||||
iso_x1 = _A3_WIDTH_MM - margin
|
("grid3", 3, lambda d, g: _place_grid(d, g, 3)),
|
||||||
iso_y1 = _A3_HEIGHT_MM - margin
|
)
|
||||||
if iso_x1 - iso_x0 < 30.0 or iso_y1 - iso_y0 < 30.0:
|
cands: List[
|
||||||
# No room at the right — fall back to the bottom-left corner.
|
Tuple[float, int, int, Tuple[float, float, float, float],
|
||||||
left_x = slots.get("left", (margin + full_w * 0.5,))[0]
|
Dict[str, Tuple[float, float]],
|
||||||
iso_x1 = min(iso_x1, left_x - gap)
|
Callable[[Dict[str, Tuple[float, float]], float], _RectList],
|
||||||
bottom_y = slots.get("bottom", (0.0, col_bottom + col_avail * 0.5, 0, 0))[1]
|
List[bool]]
|
||||||
iso_y1 = min(iso_y1, bottom_y - gap)
|
] = []
|
||||||
slots["isometric"] = (
|
# Rotated views are a packing tool for multi-view layouts; with
|
||||||
iso_x0,
|
# 1–2 views the projection is kept upright and only scaled to fit.
|
||||||
iso_y0,
|
n_masks = 1 << len(keys) if len(keys) >= 3 else 1
|
||||||
max(iso_x1 - iso_x0, 10.0),
|
# Shrink each region for dimension clearance (views are centred in
|
||||||
max(iso_y1 - iso_y0, 10.0),
|
# 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:
|
||||||
|
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]),
|
||||||
)
|
)
|
||||||
else:
|
rx0, _ry0, rw, rh = region
|
||||||
# No standard ortho views — give the isometric most of the sheet.
|
ds = {k: (w * s, h * s) for k, (w, h) in dims_m.items()}
|
||||||
if any(v.kind == "isometric" for v in views):
|
rects = place(ds, _VIEW_GAP_MM)
|
||||||
slots["isometric"] = (margin, margin, full_w * 0.55, col_avail)
|
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.
|
# Isometric and custom views: the largest remaining free rect each,
|
||||||
custom = [v for v in views if v.kind not in _STANDARD_VIEWS]
|
# clearing the title block.
|
||||||
if custom:
|
extra = [
|
||||||
left_edge = slots.get("left", (margin + full_w * 0.4,))[0]
|
v for v in views
|
||||||
bottom_edge = slots.get("bottom", (0.0, col_bottom + col_avail * 0.4, 0, 0))[1]
|
if v.kind == "isometric" or v.kind not in _STANDARD_VIEWS
|
||||||
cw = max(left_edge - margin - gap, 60.0)
|
]
|
||||||
ch = max(bottom_edge - margin - gap, 60.0)
|
assigned = list(used_rects)
|
||||||
for i, v in enumerate(custom):
|
for i, v in enumerate(extra):
|
||||||
vid = v.name or v.id
|
vid = v.kind if v.kind in _STANDARD_VIEWS else (v.name or v.id)
|
||||||
slots[vid] = (margin, margin + i * (ch + gap), cw, ch)
|
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 ───────────────────────────────────────
|
# ── Dimension selection & placement ───────────────────────────────────────
|
||||||
@@ -1365,7 +1660,7 @@ _MANUAL_DIMENSION_KINDS = ("length", "diameter", "angle")
|
|||||||
|
|
||||||
def build_manual_candidates(
|
def build_manual_candidates(
|
||||||
drawing: TechnicalDrawing,
|
drawing: TechnicalDrawing,
|
||||||
view_transforms: Dict[str, Tuple[float, float, float]],
|
view_transforms: Dict[str, Tuple[float, ...]],
|
||||||
) -> Tuple[List[DrawingCandidate], List[str], List[str]]:
|
) -> Tuple[List[DrawingCandidate], List[str], List[str]]:
|
||||||
"""Convert user-placed annotations into renderable dimension candidates.
|
"""Convert user-placed annotations into renderable dimension candidates.
|
||||||
|
|
||||||
@@ -1389,10 +1684,33 @@ def build_manual_candidates(
|
|||||||
if transform is None:
|
if transform is None:
|
||||||
unresolved.append(ann.id)
|
unresolved.append(ann.id)
|
||||||
continue
|
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]:
|
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]
|
a0, a1 = ann.anchors[0], ann.anchors[1]
|
||||||
if ann.dimension_kind == "angle":
|
if ann.dimension_kind == "angle":
|
||||||
@@ -1439,7 +1757,7 @@ def build_manual_candidates(
|
|||||||
dx, dy = a1[0] - a0[0], a1[1] - a0[1]
|
dx, dy = a1[0] - a0[0], a1[1] - a0[1]
|
||||||
# value >= 1e-6 guarantees the fallback vector is non-zero.
|
# value >= 1e-6 guarantees the fallback vector is non-zero.
|
||||||
mag = math.hypot(dx, dy)
|
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}"
|
label = f"{value:.{_DISPLAY_PRECISION}f}"
|
||||||
else: # diameter
|
else: # diameter
|
||||||
direction = ()
|
direction = ()
|
||||||
@@ -1510,7 +1828,9 @@ def generate_drawing(
|
|||||||
_vid_of(view): _edges_bounds(projections[_vid_of(view)][0])
|
_vid_of(view): _edges_bounds(projections[_vid_of(view)][0])
|
||||||
for view in drawing.views
|
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 = {
|
ortho_vids = {
|
||||||
_vid_of(v)
|
_vid_of(v)
|
||||||
@@ -1519,7 +1839,7 @@ def generate_drawing(
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Per-view model→sheet transforms, filled in by _assemble_view.
|
# 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:
|
for view in drawing.views:
|
||||||
vid = _vid_of(view)
|
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
|
common_scale * view.scale if vid in ortho_vids and common_scale else None
|
||||||
)
|
)
|
||||||
prims, cands, vwarns = _assemble_view(
|
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_primitives.extend(prims)
|
||||||
all_candidates.extend(cands)
|
all_candidates.extend(cands)
|
||||||
@@ -1683,6 +2009,7 @@ def render_drawing(
|
|||||||
style_pens = {
|
style_pens = {
|
||||||
"visible": QPen(QColor(0, 0, 0), 1.5),
|
"visible": QPen(QColor(0, 0, 0), 1.5),
|
||||||
"hidden": QPen(QColor(128, 128, 128), 1.0),
|
"hidden": QPen(QColor(128, 128, 128), 1.0),
|
||||||
|
"center": QPen(QColor(0, 0, 0), 0.35),
|
||||||
"construction": QPen(QColor(0, 0, 255), 0.5),
|
"construction": QPen(QColor(0, 0, 255), 0.5),
|
||||||
"dimension": QPen(QColor(0, 0, 0), 1.0),
|
"dimension": QPen(QColor(0, 0, 0), 1.0),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,7 +243,16 @@ class DrawingCanvas(QWidget):
|
|||||||
t = self._render_result.view_transforms.get(view_id)
|
t = self._render_result.view_transforms.get(view_id)
|
||||||
if t is None or t[0] <= 0:
|
if t is None or t[0] <= 0:
|
||||||
return None
|
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)
|
return ((pt[0] - ox) / scale, (pt[1] - oy) / scale)
|
||||||
|
|
||||||
def _pick_feature(self, pos: QPointF) -> Optional[dict]:
|
def _pick_feature(self, pos: QPointF) -> Optional[dict]:
|
||||||
@@ -283,12 +292,19 @@ class DrawingCanvas(QWidget):
|
|||||||
"p2": m2,
|
"p2": m2,
|
||||||
}
|
}
|
||||||
elif prim.kind == "circle" and prim.center and prim.radius:
|
elif prim.kind == "circle" and prim.center and prim.radius:
|
||||||
d = abs(
|
dc = math.hypot(pt[0] - prim.center[0], pt[1] - prim.center[1])
|
||||||
math.hypot(
|
if dc <= tol:
|
||||||
pt[0] - prim.center[0], pt[1] - prim.center[1]
|
# Hit the circle's centre mark: a point feature — circle
|
||||||
)
|
# centres are first-class dimension references.
|
||||||
- prim.radius
|
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:
|
if d < best_d:
|
||||||
best_d = d
|
best_d = d
|
||||||
mc = self._sheet_to_model(prim.view_id, prim.center)
|
mc = self._sheet_to_model(prim.view_id, prim.center)
|
||||||
@@ -506,19 +522,11 @@ class TechnicalDrawingWidget(QWidget):
|
|||||||
(
|
(
|
||||||
"distance",
|
"distance",
|
||||||
"Distance",
|
"Distance",
|
||||||
"Pick two edges — the dimension line is placed "
|
"Pick two edges, an edge and a circle centre, or two "
|
||||||
"perpendicular to them, measuring the distance between",
|
"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",
|
|
||||||
),
|
),
|
||||||
|
("diameter", "Diameter", "Pick a circle — its diameter is added"),
|
||||||
|
("angle", "Angle", "Pick two edges — the angle between them is added"),
|
||||||
):
|
):
|
||||||
btn = QPushButton(label)
|
btn = QPushButton(label)
|
||||||
btn.setCheckable(True)
|
btn.setCheckable(True)
|
||||||
@@ -818,7 +826,7 @@ class TechnicalDrawingWidget(QWidget):
|
|||||||
self._first_pick = None
|
self._first_pick = None
|
||||||
self._canvas.set_pick_mode(tool)
|
self._canvas.set_pick_mode(tool)
|
||||||
prompts = {
|
prompts = {
|
||||||
"distance": "Distance: click the first edge",
|
"distance": "Distance: click an edge or a circle centre",
|
||||||
"diameter": "Diameter: click a circle",
|
"diameter": "Diameter: click a circle",
|
||||||
"angle": "Angle: click the first edge",
|
"angle": "Angle: click the first edge",
|
||||||
}
|
}
|
||||||
@@ -883,6 +891,40 @@ class TechnicalDrawingWidget(QWidget):
|
|||||||
elif mode in ("distance", "angle"):
|
elif mode in ("distance", "angle"):
|
||||||
self._on_edge_pick(info, mode)
|
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:
|
def _on_edge_pick(self, info: dict, mode: str) -> None:
|
||||||
if (
|
if (
|
||||||
self._first_pick is None
|
self._first_pick is None
|
||||||
@@ -891,16 +933,16 @@ class TechnicalDrawingWidget(QWidget):
|
|||||||
# First edge (or picked in a different view: restart there).
|
# First edge (or picked in a different view: restart there).
|
||||||
self._first_pick = info
|
self._first_pick = info
|
||||||
self._status_label.setText(
|
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
|
return
|
||||||
|
|
||||||
a1, a2 = self._first_pick["p1"], self._first_pick["p2"]
|
first = self._first_pick
|
||||||
b1, b2 = info["p1"], info["p2"]
|
|
||||||
view_id = info["view_id"]
|
view_id = info["view_id"]
|
||||||
|
|
||||||
if mode == "distance":
|
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:
|
if dist < 0.01:
|
||||||
self._status_label.setText(
|
self._status_label.setText(
|
||||||
"The two edges coincide — no distance to measure"
|
"The two edges coincide — no distance to measure"
|
||||||
@@ -908,12 +950,20 @@ class TechnicalDrawingWidget(QWidget):
|
|||||||
return
|
return
|
||||||
self._add_manual_dimension(
|
self._add_manual_dimension(
|
||||||
"length",
|
"length",
|
||||||
anchors=(q1, q2),
|
anchors=(p1, p2),
|
||||||
view_id=view_id,
|
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}",
|
done_msg=f"Distance dimension added: {dist:.2f}",
|
||||||
)
|
)
|
||||||
else: # angle
|
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)
|
vertex = _line_intersection(a1, a2, b1, b2)
|
||||||
if vertex is None:
|
if vertex is None:
|
||||||
self._status_label.setText(
|
self._status_label.setText(
|
||||||
@@ -937,6 +987,17 @@ class TechnicalDrawingWidget(QWidget):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _on_diameter_pick(self, info: dict) -> None:
|
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":
|
if info.get("kind") != "circle":
|
||||||
return
|
return
|
||||||
cx, cy = info["center"]
|
cx, cy = info["center"]
|
||||||
|
|||||||
Reference in New Issue
Block a user