16 Commits

Author SHA1 Message Date
bklronin 9abeb6266a - Assembly instaiated operations
- assembly forward proagation
2026-08-19 20:14:14 +02:00
bklronin 6b6f7de5ab Fiexed highlighting of operations 2026-08-19 00:11:24 +02:00
bklronin b184ade967 - added iso sheet for tech draw 2026-08-18 16:12:09 +02:00
bklronin 67b73c13b8 - tech drawing and render improv 2026-08-18 15:06:51 +02:00
bklronin 813ddc3596 - tech draw draft v2 2026-08-17 21:39:20 +02:00
bklronin 37e5335446 - tech draw draft v2 2026-08-17 18:35:18 +02:00
bklronin 108ad2d5b5 - tech draw draft v2 2026-08-16 22:01:36 +02:00
bklronin 1eee203ba8 - tech draw draft 2026-08-16 15:54:41 +02:00
bklronin a00129603c - Operation highlighting, body highlighting 2026-08-08 13:49:59 +02:00
bklronin 3d63f033f2 - Operation highlighting, body highlighting 2026-08-08 13:46:12 +02:00
bklronin b29bc11b42 - Operation highlighting, body highlighting 2026-08-08 11:15:49 +02:00
bklronin 79ad85594e - arc improvements, fillets, operations, bodys 2026-08-07 19:55:15 +02:00
bklronin 655df71cd8 - arc improvements, fillets, operations, bodys 2026-08-07 15:44:53 +02:00
bklronin 7072dcee08 - arc improvements, fillets, operations, bodys 2026-08-05 18:53:48 +02:00
bklronin 1e9b0cab1c - arc improvements, fillets, operations, bodys 2026-08-05 16:47:25 +02:00
bklronin a7e7a9f2c3 - arc improvements, fillets, operations, bodys 2026-08-05 09:44:17 +02:00
34 changed files with 14709 additions and 824 deletions
+38
View File
@@ -36,3 +36,41 @@ uv.lock
.vscode/
*.swp
*.swo
/src/fluency/Tesfiles/bordo_adapter3.fluency
/CONSTRAINT_STATUS_FINAL.md
/CONSTRAINT_STATUS_IMPLEMENTATION.md
/src/fluency/rendering/first.png
/littlebrother.md
/src/fluency/Tesfiles/multiboidy.fluency
/src/fluency/rendering/nromal_test.png
/package.json
/package-lock.json
/src/fluency/Screenshot 2026-06-28 at 17.57.52.png
/src/fluency/rendering/Screenshot 2026-07-12 at 16.54.14.png
/src/fluency/Screenshot 2026-07-26 at 20.23.42.png
/Screenshot 2026-08-05 at 10.14.15.png
/Screenshot 2026-08-05 at 11.15.33.png
/Screenshot 2026-08-05 at 11.22.42.png
/SURFACE_MODIFIER_PLAN.md
/test.step
/src/fluency/test333.step
/src/fluency/tests/test_arc_attached_to_rectangle.py
/src/fluency/tests/test_array_pattern.py
/src/fluency/tests/test_chamfer.py
/src/fluency/tests/test_circle_diameter_constraint.py
/tests/test_distance_constraint_picking.py
/tests/test_extrude_geometry.py
/src/fluency/tests/test_feature_replay.py
/src/fluency/tests/test_fillet.py
/tests/test_mirror.py
/test_modifier.py
/src/fluency/tests/test_projection_constraints.py
/tests/test_re_extrude.py
/scripts/test_render_zoom.py
/test_thread.py
/test_thread_hole.step
/test_thread_m3.step
/test_thread_m5_hole.step
/test_thread_shaft.step
/test_thread_tilted.step
/src/fluency/testpart.step
+119 -60
View File
@@ -4,14 +4,16 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="- added &quot;measurement lines&quot;">
<list default="true" id="8f0bafd6-58a0-4b20-aa2b-ddc3ba278873" name="Changes" comment="Fiexed highlighting of operations">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/kernel.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/geometry_occ/sketch.py" 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/rendering/occ_renderer.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/rendering/occ_renderer.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/dialogs.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/dialogs.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/render_window.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/render_window.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/sketch_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/sketch_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" />
<change beforePath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" beforeDir="false" afterPath="$PROJECT_DIR$/src/fluency/ui/viewer_widget.py" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
@@ -73,6 +75,7 @@
&quot;Python.side_fluency.executor&quot;: &quot;Run&quot;,
&quot;Python.simple_mesh.executor&quot;: &quot;Run&quot;,
&quot;Python.sketch.executor&quot;: &quot;Run&quot;,
&quot;Python.technical_drawing_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.vtk_widget.executor&quot;: &quot;Run&quot;,
&quot;Python.vulkan.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.OpenProjectViewOnStart&quot;: &quot;true&quot;,
@@ -99,9 +102,9 @@
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/src/fluency" />
<recent name="$PROJECT_DIR$" />
<recent name="$PROJECT_DIR$/src/fluency/Tesfiles" />
<recent name="$PROJECT_DIR$/src/fluency/rendering" />
<recent name="$PROJECT_DIR$/drawing_modules" />
<recent name="$PROJECT_DIR$/modules" />
</key>
<key name="MoveFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$" />
@@ -123,54 +126,6 @@
<option name="presentableId" value="Default" />
<updated>1703867682707</updated>
</task>
<task id="LOCAL-00001" summary="init">
<option name="closed" value="true" />
<created>1703951701948</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1703951701948</updated>
</task>
<task id="LOCAL-00002" summary="- Basic oop sketch widget implement">
<option name="closed" value="true" />
<created>1729958532384</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1729958532384</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>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1735585968733</updated>
</task>
<task id="LOCAL-00005" summary="- Added new componnt controls">
<option name="closed" value="true" />
<created>1735601610504</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1735601610504</updated>
</task>
<task id="LOCAL-00006" summary="- Added new componnt controls">
<option name="closed" value="true" />
<created>1735601786207</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1735601786207</updated>
</task>
<task id="LOCAL-00007" summary="- changing compos for sketches works">
<option name="closed" value="true" />
<created>1735652081552</created>
@@ -459,7 +414,111 @@
<option name="project" value="LOCAL" />
<updated>1785697123545</updated>
</task>
<option name="localTasksCounter" value="43" />
<task id="LOCAL-00043" summary="- arc improvements, fillets, operations, bodys">
<option name="closed" value="true" />
<created>1785701099363</created>
<option name="number" value="00043" />
<option name="presentableId" value="LOCAL-00043" />
<option name="project" value="LOCAL" />
<updated>1785701099363</updated>
</task>
<task id="LOCAL-00044" summary="- arc improvements, fillets, operations, bodys">
<option name="closed" value="true" />
<created>1785915859137</created>
<option name="number" value="00044" />
<option name="presentableId" value="LOCAL-00044" />
<option name="project" value="LOCAL" />
<updated>1785915859137</updated>
</task>
<task id="LOCAL-00045" summary="- arc improvements, fillets, operations, bodys">
<option name="closed" value="true" />
<created>1785941246695</created>
<option name="number" value="00045" />
<option name="presentableId" value="LOCAL-00045" />
<option name="project" value="LOCAL" />
<updated>1785941246695</updated>
</task>
<task id="LOCAL-00046" summary="- arc improvements, fillets, operations, bodys">
<option name="closed" value="true" />
<created>1785948828454</created>
<option name="number" value="00046" />
<option name="presentableId" value="LOCAL-00046" />
<option name="project" value="LOCAL" />
<updated>1785948828454</updated>
</task>
<task id="LOCAL-00047" summary="- arc improvements, fillets, operations, bodys">
<option name="closed" value="true" />
<created>1786110295218</created>
<option name="number" value="00047" />
<option name="presentableId" value="LOCAL-00047" />
<option name="project" value="LOCAL" />
<updated>1786110295218</updated>
</task>
<task id="LOCAL-00048" summary="- arc improvements, fillets, operations, bodys">
<option name="closed" value="true" />
<created>1786125317688</created>
<option name="number" value="00048" />
<option name="presentableId" value="LOCAL-00048" />
<option name="project" value="LOCAL" />
<updated>1786125317688</updated>
</task>
<task id="LOCAL-00049" summary="- Operation highlighting, body highlighting">
<option name="closed" value="true" />
<created>1786180549405</created>
<option name="number" value="00049" />
<option name="presentableId" value="LOCAL-00049" />
<option name="project" value="LOCAL" />
<updated>1786180549405</updated>
</task>
<task id="LOCAL-00050" summary="- tech draw draft">
<option name="closed" value="true" />
<created>1786888483687</created>
<option name="number" value="00050" />
<option name="presentableId" value="LOCAL-00050" />
<option name="project" value="LOCAL" />
<updated>1786888483688</updated>
</task>
<task id="LOCAL-00051" summary="- tech draw draft v2">
<option name="closed" value="true" />
<created>1786910497589</created>
<option name="number" value="00051" />
<option name="presentableId" value="LOCAL-00051" />
<option name="project" value="LOCAL" />
<updated>1786910497589</updated>
</task>
<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>
<task id="LOCAL-00053" summary="- tech draw draft v2">
<option name="closed" value="true" />
<created>1786995561375</created>
<option name="number" value="00053" />
<option name="presentableId" value="LOCAL-00053" />
<option name="project" value="LOCAL" />
<updated>1786995561376</updated>
</task>
<task id="LOCAL-00054" summary="- tech drawing and render improv">
<option name="closed" value="true" />
<created>1787058413911</created>
<option name="number" value="00054" />
<option name="presentableId" value="LOCAL-00054" />
<option name="project" value="LOCAL" />
<updated>1787058413911</updated>
</task>
<task id="LOCAL-00055" summary="Fiexed highlighting of operations">
<option name="closed" value="true" />
<created>1787091089061</created>
<option name="number" value="00055" />
<option name="presentableId" value="LOCAL-00055" />
<option name="project" value="LOCAL" />
<updated>1787091089061</updated>
</task>
<option name="localTasksCounter" value="56" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -480,12 +539,6 @@
<ignored-roots>
<path value="$PROJECT_DIR$/pythonProject" />
</ignored-roots>
<MESSAGE value="- Added new componnt controls" />
<MESSAGE value="- changing compos for sketches works" />
<MESSAGE value="- changing compos including sketches and bodies" />
<MESSAGE value="- Drawing bodys depending on the selected compo&#10;- Cut working&#10;- Edit sketch working" />
<MESSAGE value="- delete sketch working&#10;- added mid point snap&#10;- added hovering line with distance" />
<MESSAGE value="- Added new buttons and settings" />
<MESSAGE value="- Added construction lines switching&#10;- Moved callbacks into sketchwidget from main.&#10;- Changed reset on right click" />
<MESSAGE value="- Added contrain displayed next to line&#10;- Slight change to point check from solver." />
<MESSAGE value="- Added enabling of midpsnap and prepared others&#10;- Show dimesnion on hover" />
@@ -505,6 +558,12 @@
<MESSAGE value="- added renderer&#10;- Added undo" />
<MESSAGE value="- Render improvements, camera plane, update" />
<MESSAGE value="- added &quot;measurement lines&quot;" />
<option name="LAST_COMMIT_MESSAGE" value="- added &quot;measurement lines&quot;" />
<MESSAGE value="- arc improvements, fillets, operations, bodys" />
<MESSAGE value="- Operation highlighting, body highlighting" />
<MESSAGE value="- tech draw draft" />
<MESSAGE value="- tech draw draft v2" />
<MESSAGE value="- tech drawing and render improv" />
<MESSAGE value="Fiexed highlighting of operations" />
<option name="LAST_COMMIT_MESSAGE" value="Fiexed highlighting of operations" />
</component>
</project>
+376
View File
@@ -0,0 +1,376 @@
# ── 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")
_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.
The union size grows monotonically with the scale, so a bisection
converges to the tight fit.
"""
_rx0, _ry0, rw, rh = region
def fits(s: float) -> bool:
rects = place(
{k: (w * s, h * s) for k, (w, h) in dims_m.items()}, _VIEW_GAP_MM
)
if not rects:
return True
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)
return (maxx - minx) <= rw + 1e-9 and (maxy - miny) <= rh + 1e-9
s_lo, s_hi = 0.0, 1.0
if fits(s_hi):
s_lo = s_hi
while s_hi < 1.0e6 and fits(s_hi * 2.0):
s_hi *= 2.0
for _ in range(60):
mid = 0.5 * (s_lo + s_hi)
if fits(mid):
s_lo = mid
else:
s_hi = mid
return s_lo
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],
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,
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).
The sheet is filled, not just used: every candidate arrangement
(classic third-angle cross, the cross with the view families swapped,
and 1/2/3-row grids) is combined with every per-view 90° rotation
assignment, 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. 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).
"""
slots: Dict[str, Tuple[float, float, float, float]] = {}
rotations: Dict[str, float] = {}
common_scale: Optional[float] = None
ortho = [
v for v in views if v.kind in _STANDARD_VIEWS and v.kind != "isometric"
]
used_rects: List[Tuple[float, float, float, float]] = []
if ortho:
dims0: Dict[str, Tuple[float, float]] = {}
for v in ortho:
b = bboxes.get(v.kind)
vs = max(v.scale, 1e-9)
if b is None:
dims0[v.kind] = (1.0 * vs, 1.0 * vs)
else:
dims0[v.kind] = (
max(b[2] - b[0], 1e-6) * vs,
max(b[3] - b[1], 1e-6) * vs,
)
keys = list(dims0)
arrangements: Tuple[
Tuple[str, int, Callable[[Dict[str, Tuple[float, float]], float], _RectList]]
] = (
("cross", 0, _place_cross),
("swapped", 1, _place_swapped),
("grid1", 2, lambda d, g: _place_grid(d, g, 1)),
("grid2", 3, lambda d, g: _place_grid(d, g, 2)),
("grid3", 4, 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]]
] = []
for mask in range(1 << len(keys)):
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, _REGION_UPPER)
s_left = _fit_scale(place, dims_m, _REGION_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]),
)
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)]
# 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, rotations
+50
View File
@@ -0,0 +1,50 @@
import os, sys
os.environ["QT_QPA_PLATFORM"] = "offscreen"
sys.path.insert(0, "/Volumes/Data_drive/Programming/fluency/src")
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QPixmap, QPainter, QColor
from PySide6.QtCore import QRectF
import math
app = QApplication.instance() or QApplication([])
from fluency.geometry.base import Point2D
from fluency.geometry_occ.kernel import OCGeometryKernel
from fluency.models.data_model import Body, Component, Project, DrawingView, TechnicalDrawing
from fluency.technical_drawing import generate_drawing, render_drawing, _A3_WIDTH_MM, _A3_HEIGHT_MM
kernel = OCGeometryKernel()
# Long thin bar: 120 x 25 x 30 (matches the "wide bar" screenshot case).
points = [Point2D(0, 0), Point2D(120, 0), Point2D(120, 25), Point2D(0, 25)]
box = kernel.extrude(kernel.create_polygon(points), 30.0)
body = Body(name="Bar", geometry=box)
comp = Component(name="BarComp")
comp.bodies[body.id] = body
project = Project()
project.components[comp.id] = comp
project.active_component = comp.id
W = 2400
H = int(W * _A3_HEIGHT_MM / _A3_WIDTH_MM)
pm = QPixmap(W, H)
pm.fill(QColor(255, 255, 255))
for name, kinds in [
("four", ["front", "top", "right", "back"]),
("six", ["front", "top", "right", "left", "back", "bottom"]),
("sixiso", ["front", "top", "right", "left", "back", "bottom", "isometric"]),
]:
drawing = TechnicalDrawing(
source_kind="component", source_id=comp.id,
views=[DrawingView(kind=k) for k in kinds],
auto_dimensions=True, title=name,
)
result = generate_drawing(drawing, project, kernel)
p = QPainter(pm)
render_drawing(p, result, QRectF(0, 0, W, H))
p.end()
out = f"/tmp/drawing_{name}.png"
pm.save(out)
print(name, "saved", out, "prims", len(result.primitives), "scale",
round(result.view_transforms.get("front", (None,))[0] or 0, 3))
+159
View File
@@ -0,0 +1,159 @@
"""Smoke test: layout optimizer fills the page, no overlaps, title block clear."""
import math
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, "/Volumes/Data_drive/Programming/fluency/src")
from fluency.models.data_model import DrawingView
from fluency.technical_drawing import (
_layout_views_on_sheet,
build_manual_candidates,
)
A3W, A3H = 420.0, 297.0
TB = (235.0, 0.0, 420.0, 62.0) # title block zone incl. clearance
def b(x, y, w, h):
return (x, y, x + w, y + h)
def overlaps(r1, r2, clear=0.0):
x0, y0, w, h = r1
x1, y1, w2, h2 = r2
return not (x0 + w <= x1 + clear or x1 + w2 <= x0 + clear
or y0 + h <= y1 + clear or y1 + h2 <= y0 + clear)
def tb_overlap(r, clear=5.0):
x0, y0, w, h = r
x1, y1, w2, h2 = TB
return not (x0 + w <= x1 + clear or x1 + w2 <= x0 + clear
or y0 + h <= y1 + clear or y1 + h2 <= y0 + clear)
def union_rect(rects):
x0 = min(r[0] for r in rects)
y0 = min(r[1] for r in rects)
x1 = max(r[0] + r[2] for r in rects)
y1 = max(r[1] + r[3] for r in rects)
return (x0, y0, x1 - x0, y1 - y0)
def check(name, kinds, boxes, expect_rot=None):
views = [DrawingView(kind=k) for k in kinds]
bboxes = {k: boxes[k] for k in kinds}
slots, scale, rots = _layout_views_on_sheet(views, bboxes)
print(f"--- {name}: scale={scale:.4f} rots={rots}")
# all slots within sheet
for k, s in slots.items():
assert 10 - 1e-6 <= s[0] and 10 - 1e-6 <= s[1], f"{k} outside sheet {s}"
assert s[0] + s[2] <= A3W - 10 + 1e-6, f"{k} beyond right {s}"
assert s[1] + s[3] <= A3H - 10 + 1e-6, f"{k} beyond top {s}"
# no overlaps between slots
ks = list(slots)
for i in range(len(ks)):
for j in range(i + 1, len(ks)):
assert not overlaps(slots[ks[i]], slots[ks[j]], 11.9), \
f"{ks[i]} overlaps {ks[j]}: {slots[ks[i]]} / {slots[ks[j]]}"
# title block clear
for k, s in slots.items():
assert not tb_overlap(s), f"{k} intrudes title block {s}"
# fill report
u = union_rect(list(slots.values()))
area = u[2] * u[3]
print(f" union: x0={u[0]:.1f} y0={u[1]:.1f} w={u[2]:.1f} h={u[3]:.1f} "
f"area={area:.0f}mm^2 ({100*area/(A3W*A3H):.0f}% of sheet)")
for k in ks:
print(f" {k}: {tuple(round(v,1) for v in slots[k])}")
if expect_rot is not None:
assert rots == expect_rot, f"expected {expect_rot}, got {rots}"
return slots, scale, rots
# 1. Two square views (front+top): should fill the page, no rotation.
check("two square", ["front", "top"],
{"front": b(0, 0, 40, 40), "top": b(0, 0, 40, 20)})
# 2. Wide bar, 4 views (old screenshot case): front+back wide, top+right.
check("wide bar 4", ["front", "top", "right", "back"],
{"front": b(0, 0, 120, 25), "top": b(0, 0, 25, 40),
"right": b(0, 0, 25, 40), "back": b(0, 0, 120, 25)})
# 3. Six views of a long thin part: rotation should kick in.
check("thin part 6", ["front", "top", "right", "left", "back", "bottom"],
{"front": b(0, 0, 200, 30), "top": b(0, 0, 30, 50),
"right": b(0, 0, 50, 30), "left": b(0, 0, 50, 30),
"back": b(0, 0, 200, 30), "bottom": b(0, 0, 30, 50)})
# 4. Single front view: fills the whole page.
check("single", ["front"], {"front": b(0, 0, 10, 20)})
# 5. All 6 + isometric + custom.
check("everything",
["front", "top", "right", "left", "back", "bottom", "isometric"],
{"front": b(0, 0, 80, 40), "top": b(0, 0, 80, 30),
"right": b(0, 0, 30, 40), "left": b(0, 0, 30, 40),
"back": b(0, 0, 80, 40), "bottom": b(0, 0, 80, 30),
"isometric": b(0, 0, 60, 60)})
# ── Inverse-transform roundtrip ─────────────────────────────────────────
# A 90°-rotated view: forward via _assemble_view's recorded 6-tuple,
# inverse via the widget's formula.
t = (2.0, 150.0, 80.0, 90.0, 10.0, 5.0) # s, o_x, o_y, deg, cx, cy
scale, ox, oy, deg, cx, cy = t
th = math.radians(deg)
cos_t, sin_t = math.cos(th), math.sin(th)
def fwd(p):
dx, dy = p[0] - cx, p[1] - cy
return ((dx * cos_t - dy * sin_t) * scale + ox,
(dx * sin_t + dy * cos_t) * scale + oy)
def inv(p):
sx, sy = (p[0] - ox) / scale, (p[1] - oy) / scale
return (sx * cos_t + sy * sin_t + cx, -sx * sin_t + sy * cos_t + cy)
for p in [(0, 0), (10, 5), (3, -7), (42.5, 11.25)]:
rt = inv(fwd(p))
assert abs(rt[0] - p[0]) < 1e-9 and abs(rt[1] - p[1]) < 1e-9, (p, rt)
print("inverse roundtrip OK")
# Legacy 3-tuple still works through build_manual_candidates.
from fluency.models.data_model import DrawingAnnotation, TechnicalDrawing
d = TechnicalDrawing(source_kind="component", source_id="c")
ann = DrawingAnnotation(kind="dimension", dimension_kind="length",
view_id="front", anchors=[(0.0, 0.0), (0.0, 12.5)],
direction=(0.0, 1.0))
d.annotations.append(ann)
cands, res, unres = build_manual_candidates(d, {"front": (2.0, 10.0, 20.0)})
assert unres == [] and cands[0].anchor_points[1] == (10.0, 45.0)
print("legacy 3-tuple OK")
# 6-tuple manual: rotated length direction must rotate too.
ann2 = DrawingAnnotation(kind="dimension", dimension_kind="length",
view_id="front",
anchors=[(0.0, 0.0), (0.0, 10.0)],
direction=(0.0, 1.0))
d2 = TechnicalDrawing(source_kind="component", source_id="c")
d2.annotations.append(ann2)
# 90° rotation about centre c=(5,5), scale 2, o=(100,80)
cands, res, unres = build_manual_candidates(
d2, {"front": (2.0, 100.0, 80.0, 90.0, 5.0, 5.0)}
)
c = cands[0]
# anchors: (0,0)->rot90 about (5,5) = (5-(0-5)*0 - ... compute: dx=-5,dy=-5
# fwd: (dx*cos - dy*sin)*2+100 = (0 - (-5))*2+100 = 110 ; (dx*sin+dy*cos)*2+80 = (-5)*2+80=70
# (0,10): dx=-5, dy=5 -> (0-5)*2+100=90 ; (5*1+0)*2+80=70
assert c.anchor_points[0] == (110.0, 70.0), c.anchor_points
assert c.anchor_points[1] == (90.0, 70.0), c.anchor_points
# direction (0,1) rotated 90° CCW -> (-1, 0)
assert c.direction[0] == -1.0 and abs(c.direction[1]) < 1e-9, c.direction
print("6-tuple manual (rotated) OK")
print("ALL SMOKE CHECKS PASSED")
+238
View File
@@ -0,0 +1,238 @@
"""Headless test: connectors follow moved features across ALL assemblies.
Simulates the test-file scenario: a plate with a hole, mated hole-to-face
in two different assemblies. The hole is moved (rebuilt geometry) and the
body-update connector recalculation must:
1. re-locate the hole connector on every instance of the component
(both the active and a non-active assembly),
2. re-solve each mated pair so the partner parts follow,
3. never snap a planar connector onto the cylindrical hole (type match),
4. mark a connector invalid when its feature disappears.
5. auto-follow a FAR move when the candidate is unambiguous (single
feature of its class) — the demo case,
6. NOT auto-apply an ambiguous far candidate (another same-class feature
is nearer) — that needs a manual pick, simulated here.
7. backfill legacy connectors' entity_type from their auto-generated name.
"""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
import numpy as np
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
from PySide6.QtWidgets import QApplication
from fluency.ui.main_window import MainWindow
from fluency.models.data_model import Assembly, Body
from fluency.geometry_occ.kernel import OCCGeometryObject
app = QApplication.instance() or QApplication([])
w = MainWindow()
comp = w._current_component
def make_plate(hole_xy):
box = BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape()
ax = gp_Ax2(gp_Pnt(float(hole_xy[0]), float(hole_xy[1]), 0.0), gp_Dir(0, 0, 1))
cyl = BRepPrimAPI_MakeCylinder(ax, 3.0, 6.0).Shape()
return BRepAlgoAPI_Cut(box, cyl).Shape()
body = Body(name="plate")
comp.bodies[body.id] = body
body.geometry = OCCGeometryObject(make_plate((10.0, 5.0)))
partner = w._project.add_component()
pbody = Body(name="partner")
partner.bodies[pbody.id] = pbody
pbody.geometry = OCCGeometryObject(BRepPrimAPI_MakeBox(30.0, 30.0, 10.0).Shape())
def make_pair(asm):
ac1 = asm.add_component_instance(comp.id, name="A")
ac2 = asm.add_component_instance(partner.id, name="B")
ac1.position = np.zeros(3)
ac1.rotation = np.eye(3)
ac2.position = np.array([0.0, 0.0, 15.0])
ac2.rotation = np.eye(3)
ac1.geom_cache[body.id] = body.geometry
c1 = ac1.add_connector(
position=(10.0, 5.0, 2.5),
normal=(0.0, 0.0, 1.0),
x_dir=(1.0, 0.0, 0.0),
source_obj_id=f"asm_{ac1.id}_{body.id}",
name="Conn hole A",
entity_type="cylindrical_face",
)
c2 = ac2.add_connector(
position=(15.0, 15.0, 0.0),
normal=(0.0, 0.0, -1.0),
x_dir=(1.0, 0.0, 0.0),
source_obj_id=f"asm_{ac2.id}_{pbody.id}",
name="Conn face B",
entity_type="planar_face",
)
c1.is_grounded = True
c1.partner_ac_id = ac2.id
c1.partner_connector_id = c2.id
c2.partner_ac_id = ac1.id
c2.partner_connector_id = c1.id
aconn = asm.add_connection(ac1.id, ac2.id)
aconn.first_connector_id = c1.id
aconn.second_connector_id = c2.id
return ac1, ac2, c1, c2, aconn
asm1 = w._project.get_active_assembly()
asm2 = w._project.add_assembly(Assembly(name="second"))
pair1 = make_pair(asm1)
pair2 = make_pair(asm2)
assert w._project.active_assembly == asm1.id # asm2 is the NON-active one
# ── Move the hole: rebuild the plate with the hole at (25, 12) ──────────
new_geom = OCCGeometryObject(make_plate((25.0, 12.0)))
body.geometry = new_geom
for asm in (asm1, asm2):
for ac in asm.components.values():
if ac.component_id == comp.id:
ac.geom_cache[body.id] = new_geom
# ── The body-update auto path ───────────────────────────────────────────
w._recalculate_connectors()
for (ac1, ac2, c1, c2, aconn), label in ((pair1, "asm1"), (pair2, "asm2")):
# The hole connector followed the hole on every instance.
assert np.allclose(c1.position, (25.0, 12.0, 2.5), atol=1e-6), (label, c1.position)
# The mated pair re-aligned: both world connectors coincide.
w1 = ac1.position + ac1.rotation @ np.asarray(c1.position)
w2 = ac2.position + ac2.rotation @ np.asarray(c2.position)
assert np.allclose(w1, w2, atol=1e-6), (label, w1, w2)
print(f"{label}: connector at {np.round(c1.position, 3)}, "
f"partner moved to {np.round(ac2.position, 3)}")
# ── The 'Upd' button path: move the hole again, re-run the handler ─────
w._refresh_connection_list()
w._connection_list.setCurrentRow(0) # active assembly = asm1
geom3 = OCCGeometryObject(make_plate((35.0, 20.0)))
body.geometry = geom3
for ac in asm1.components.values():
if ac.component_id == comp.id:
ac.geom_cache[body.id] = geom3
w._on_update_connection_from_list()
ac1, ac2, c1, c2, aconn = pair1
assert np.allclose(c1.position, (35.0, 20.0, 2.5), atol=1e-6), c1.position
w1 = ac1.position + ac1.rotation @ np.asarray(c1.position)
w2 = ac2.position + ac2.rotation @ np.asarray(c2.position)
assert np.allclose(w1, w2, atol=1e-6), (w1, w2)
print("Upd button: connector at", np.round(c1.position, 3),
"partner at", np.round(ac2.position, 3))
# ── Type matching: a planar connector must not snap onto the hole ───────
ac1 = pair1[0]
c3 = ac1.add_connector(
position=(30.0, 20.0, 5.0),
normal=(0.0, 0.0, 1.0),
x_dir=(1.0, 0.0, 0.0),
source_obj_id=f"asm_{ac1.id}_{body.id}",
name="Conn face",
entity_type="planar_face",
)
res = w._redetect_connector_on_geometry(c3, ac1, comp)
assert res is not None, "planar connector candidate missing"
assert not c3.is_invalid, "pure relocator must not mutate the connector"
assert np.allclose(c3.position, (30.0, 20.0, 5.0), atol=1e-6), c3.position
assert np.allclose(res[1], (30.0, 20.0, 5.0), atol=1e-6), res[1]
print("planar connector stayed on the face:", np.round(res[1], 3))
# ── Feature removed: relocator finds nothing; auto path marks invalid ──
plain = OCCGeometryObject(BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape())
body.geometry = plain
ac1.geom_cache[body.id] = plain
res = w._redetect_connector_on_geometry(pair1[2], ac1, comp)
assert res is None, "hole connector should find no candidate on a plain box"
w._recalculate_connectors()
assert pair1[2].is_invalid, "auto path must mark the connector invalid"
print("removed feature -> connector marked invalid")
# ── Far move with a decoy: ambiguous candidate is NOT auto-applied ─────
# The real hole moved to (25, 12) — ~12.8mm from the stored (35, 20) — but
# a SECOND hole now sits at (28, 16), only ~8mm away. The nearest
# candidate is ambiguous (different feature), so the auto path must leave
# the connector alone and queue it for a manual pick.
def make_plate2(holes):
box = BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape()
for hx, hy in holes:
ax = gp_Ax2(gp_Pnt(hx, hy, 0.0), gp_Dir(0, 0, 1))
box = BRepAlgoAPI_Cut(box, BRepPrimAPI_MakeCylinder(ax, 3.0, 6.0).Shape()).Shape()
return box
# ── Stage 1: the user's demo — a SINGLE hole moved far away ─────────────
# 12.8mm from the stored position, but the only cylindrical face on the
# body → unambiguous → must be auto-applied (and the mate re-solved).
geom4 = OCCGeometryObject(make_plate2([(25.0, 12.0)]))
body.geometry = geom4
for asm in (asm1, asm2):
for ac in asm.components.values():
if ac.component_id == comp.id:
ac.geom_cache[body.id] = geom4
w._recalculate_connectors()
assert np.allclose(pair1[2].position, (25.0, 12.0, 2.5), atol=1e-6), \
"unique far candidate must be auto-applied (the demo case)"
assert not pair1[2].is_invalid
w1 = ac1.position + ac1.rotation @ np.asarray(pair1[2].position)
w2 = pair1[1].position + pair1[1].rotation @ np.asarray(pair1[3].position)
assert np.allclose(w1, w2, atol=1e-6), (w1, w2)
print("single far hole: auto-followed to", np.round(pair1[2].position, 3))
# ── Stage 2: far move with a decoy — ambiguous, NOT auto-applied ───────
# The real hole now sits at (32, 20) — 10.6mm from the stored (25, 12) —
# while a decoy hole at (21, 7) is only 6.4mm away. The nearest
# candidate is likely a DIFFERENT feature, so the auto path must leave
# the connector alone and queue it for a manual pick.
geom5 = OCCGeometryObject(make_plate2([(32.0, 20.0), (21.0, 7.0)]))
body.geometry = geom5
for asm in (asm1, asm2):
for ac in asm.components.values():
if ac.component_id == comp.id:
ac.geom_cache[body.id] = geom5
w._recalculate_connectors()
assert np.allclose(pair1[2].position, (25.0, 12.0, 2.5), atol=1e-6), \
"ambiguous far candidate must not be auto-applied"
assert not pair1[2].is_invalid, "ambiguous candidate is not a missing feature"
# Simulate the user clicking the REAL hole in the relocate pick flow:
w._relocate_pending = [(asm1, ac1, pair1[2])]
w._on_relocate_picked(
(32.0, 20.0, 2.5), (0.0, 0.0, 1.0), (1.0, 0.0, 0.0),
"cylindrical_face", f"asm_{ac1.id}_{body.id}",
)
assert np.allclose(pair1[2].position, (32.0, 20.0, 2.5), atol=1e-6), pair1[2].position
assert not pair1[2].is_invalid, "manual pick must re-validate the connector"
assert w._relocate_pending is None, "pending queue must drain after the pick"
w1 = ac1.position + ac1.rotation @ np.asarray(pair1[2].position)
w2 = pair1[1].position + pair1[1].rotation @ np.asarray(pair1[3].position)
assert np.allclose(w1, w2, atol=1e-6), (w1, w2)
print("far move: manual pick re-homed connector at", np.round(pair1[2].position, 3),
"partner at", np.round(pair1[1].position, 3))
# ── Legacy backfill: empty entity_type recovered from the auto name ────
from fluency.models.data_model import Connector
legacy = Connector(
name="Conn cylindrical_face anchor",
position=(32.0, 20.0, 2.5),
source_obj_id=f"asm_{ac1.id}_{body.id}",
)
assert legacy.entity_type == "cylindrical_face", legacy.entity_type
res = w._redetect_connector_on_geometry(legacy, ac1, comp)
assert res is not None and res[0] < 1e-3, res
print("legacy name backfill: entity_type =", legacy.entity_type)
print("CONNECTOR_RELOCATE_OK")
+158
View File
@@ -0,0 +1,158 @@
"""Headless repro: load assemblytest.fluency, compare sketch circle centers
vs. saved body hole axes vs. connector positions, then run the real
body-update path and check where connectors land.
"""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
import numpy as np
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Cylinder
from OCP.TopAbs import TopAbs_FACE
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS
from PySide6.QtWidgets import QApplication
from fluency.ui.main_window import MainWindow
def etype(e):
if isinstance(e, dict):
return e.get("type")
return getattr(e, "entity_type", None) or getattr(e, "type", None)
def egeom(e):
if isinstance(e, dict):
return e.get("geometry")
return getattr(e, "geometry", None)
app = QApplication.instance() or QApplication([])
w = MainWindow()
path = os.path.join(os.path.dirname(__file__), "assemblytest.fluency")
ok = w._open_project_file(path)
assert ok, "failed to open demo file"
proj = w._project
for comp in proj.components.values():
print(f"=== {comp.name} ({comp.id})")
for sk in comp.sketches.values():
occ = sk.occ_sketch
if occ is None:
print(" sketch with no occ_sketch:", sk.id)
continue
for e in occ._entities.values():
t = etype(e)
if t == "circle":
g = egeom(e)
cid = e.get("id") if isinstance(e, dict) else e.id
print(f" circle id={cid} center=({g[0][0]!r}, {g[0][1]!r}) r={g[1]!r}")
for body in comp.bodies.values():
if not body.geometry:
print(f" body {body.name}: no geometry")
continue
shape = w._kernel._get_shape(body.geometry)
print(f" body {body.name}: extrude len={body.extrude_length}")
expl = TopExp_Explorer(shape, TopAbs_FACE)
while expl.More():
face = TopoDS.Face_s(expl.Current())
try:
adaptor = BRepAdaptor_Surface(face)
if adaptor.GetType() == GeomAbs_Cylinder:
cyl = adaptor.Cylinder()
loc = cyl.Location()
d = cyl.Axis().Direction()
print(
f" cyl axis at ({loc.X()!r}, {loc.Y()!r}) "
f"dir=({d.X():.4f},{d.Y():.4f},{d.Z():.4f})"
)
except Exception:
pass
expl.Next()
asm = proj.get_active_assembly()
print("=== active assembly:", asm.name)
for ac in asm.components.values():
comp = proj.get_component_by_id(ac.component_id)
print(f" instance '{ac.name}' -> {comp.name}, pos={np.round(ac.position, 4)}")
for conn in ac.connectors.values():
print(
f" conn '{conn.name}' pos={np.round(conn.position, 6)} "
f"normal={np.round(conn.normal, 3)} et={conn.entity_type!r} "
f"invalid={conn.is_invalid}"
)
# ── Run the real update path: rebuild bodies from sketch, recalc connectors ──
# Activate the component with the holes (Component 1).
comp1 = None
for comp in proj.components.values():
if any(
etype(e) == "circle"
for sk in comp.sketches.values()
for e in (sk.occ_sketch._entities.values() if sk.occ_sketch else [])
):
comp1 = comp
break
assert comp1 is not None
w._current_component = comp1
print("=== running _update_bodies_from_sketch()")
w._update_bodies_from_sketch()
print("=== running _recalculate_connectors()")
w._recalculate_connectors()
print("=== after update")
for comp in proj.components.values():
for body in comp.bodies.values():
if not body.geometry:
continue
shape = w._kernel._get_shape(body.geometry)
expl = TopExp_Explorer(shape, TopAbs_FACE)
axes = []
while expl.More():
face = TopoDS.Face_s(expl.Current())
try:
adaptor = BRepAdaptor_Surface(face)
if adaptor.GetType() == GeomAbs_Cylinder:
loc = adaptor.Cylinder().Location()
axes.append((round(loc.X(), 9), round(loc.Y(), 9)))
except Exception:
pass
expl.Next()
print(f" {comp.name} body axes: {axes}")
for sk in comp.sketches.values():
for e in sk.occ_sketch._entities.values():
t = etype(e)
if t == "circle":
g = egeom(e)
print(f" {comp.name} circle: ({g[0][0]!r}, {g[0][1]!r})")
asm = proj.get_active_assembly()
for ac in asm.components.values():
for conn in ac.connectors.values():
print(
f" conn '{conn.name}' pos={np.round(conn.position, 6)} "
f"invalid={conn.is_invalid}"
)
# Partner alignment check: for each connection, world positions of the pair.
for aconn in asm.connections:
a1 = asm.components.get(aconn.first_ac_id)
a2 = asm.components.get(aconn.second_ac_id)
c1 = a1.connectors.get(aconn.first_connector_id)
c2 = a2.connectors.get(aconn.second_connector_id)
if c1 is None or c2 is None:
continue
w1 = a1.position + a1.rotation @ np.asarray(c1.position, dtype=float)
w2 = a2.position + a2.rotation @ np.asarray(c2.position, dtype=float)
print(
f" conn {aconn.id[:8]}: w1={np.round(w1, 6)} w2={np.round(w2, 6)} "
f"gap={float(np.linalg.norm(w1 - w2))!r}"
)
print("DEMO_REPRO_DONE")
+89
View File
@@ -0,0 +1,89 @@
"""Round-trip smoke test: instance-local sketches + modifiers survive save/load.
Builds a minimal project (component with a body, assembly with two instances,
one carrying an instance sketch + cut modifier + fillet modifier), saves to a
temp .fluency, reloads, and asserts:
1. the shared component is untouched by instance work,
2. the instance sketch + modifiers round-trip with sketch refs intact,
3. the plain instance has none.
"""
import os
import tempfile
import numpy as np
from fluency.models.data_model import (
Project, Component, Body, Sketch, Feature, Assembly, AssemblyComponent,
)
from fluency.io.project_io import save_project, load_project
def main():
project = Project(name="inst test")
comp = project.add_component()
comp.name = "BasePart"
body = comp.add_body(Body(name="MainBody"))
base_sketch = comp.add_sketch(Sketch(name="BaseSketch"))
body.features.append(
Feature(operation="extrude", sketch=base_sketch, length=10.0)
)
asm = project.add_assembly(Assembly(name="TestAsm"))
ac1 = asm.add_component_instance(comp.id, name="Instance A")
ac1.position = np.array([0.0, 0.0, 0.0])
ac2 = asm.add_component_instance(comp.id, name="Instance B")
ac2.position = np.array([50.0, 0.0, 0.0])
# Instance A: local sketch + cut modifier referencing it + fillet.
inst_sketch = ac1.add_instance_sketch()
inst_sketch.name = "InstCutSketch"
ac1.add_modifier(body.id, Feature(operation="cut", sketch=inst_sketch,
length=5.0, through_all=True))
ac1.add_modifier(body.id, Feature(operation="fillet", radius=1.0))
# ---- save / load ----
fd, path = tempfile.mkstemp(suffix=".fluency")
os.close(fd)
try:
save_project(project, path)
loaded, _view = load_project(path)
lcomp = loaded.components[comp.id]
lac1 = None
lac2 = None
for lasm in loaded.assemblies.values():
for ac in lasm.components.values():
if ac.name == "Instance A":
lac1 = ac
elif ac.name == "Instance B":
lac2 = ac
assert lac1 is not None and lac2 is not None, "instances missing"
# 1. component untouched
assert len(lcomp.sketches) == 1, "component sketch count changed"
assert len(lcomp.bodies[body.id].features) == 1, "feature chain changed"
assert not getattr(lcomp.bodies[body.id], "modifiers", None)
# 2. instance A round-trip
assert len(lac1.sketches) == 1, "instance sketch missing"
lsk_id, lsk = next(iter(lac1.sketches.items()))
assert lsk.name == "InstCutSketch"
mods = lac1.modifiers
lbody_id = next(iter(lcomp.bodies))
assert len(mods.get(lbody_id, [])) == 2, f"modifiers missing: {mods}"
cut, fil = mods[lbody_id][0], mods[lbody_id][1]
assert cut.operation == "cut" and fil.operation == "fillet"
assert cut.sketch is not None and cut.sketch.id == lsk_id, \
"cut sketch ref did not resolve to instance sketch"
assert cut.length == 5.0 and cut.through_all
# 3. plain instance clean
assert not lac2.sketches and not lac2.modifiers
print("ROUND_TRIP_OK")
finally:
os.unlink(path)
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
"""Headless smoke: the manual re-pick fallback path (prompt -> frame -> pick mode)."""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
import numpy as np
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2
from OCP.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut
from PySide6.QtWidgets import QApplication, QMessageBox
from fluency.ui.main_window import MainWindow
from fluency.models.data_model import Body
from fluency.geometry_occ.kernel import OCCGeometryObject
app = QApplication.instance() or QApplication([])
w = MainWindow()
comp = w._current_component
body = Body(name="plate")
comp.bodies[body.id] = body
box = BRepPrimAPI_MakeBox(60.0, 40.0, 5.0).Shape()
ax = gp_Ax2(gp_Pnt(10.0, 5.0, 0.0), gp_Dir(0, 0, 1))
box = BRepAlgoAPI_Cut(box, BRepPrimAPI_MakeCylinder(ax, 3.0, 6.0).Shape()).Shape()
body.geometry = OCCGeometryObject(box)
partner = w._project.add_component()
pbody = Body(name="partner")
partner.bodies[pbody.id] = pbody
pbody.geometry = OCCGeometryObject(BRepPrimAPI_MakeBox(30.0, 30.0, 10.0).Shape())
asm = w._project.get_active_assembly()
ac1 = asm.add_component_instance(comp.id, name="A")
ac2 = asm.add_component_instance(partner.id, name="B")
ac1.geom_cache[body.id] = body.geometry
c1 = ac1.add_connector(
position=(10.0, 5.0, 2.5), normal=(0, 0, 1), x_dir=(1, 0, 0),
source_obj_id=f"asm_{ac1.id}_{body.id}",
name="Conn hole", entity_type="cylindrical_face",
)
c1.partner_ac_id = ac2.id
# Activate the assembly view so the prompt path is taken.
w._assembly_view_active = True
w._selected_assembly_component_id = ac1.id
# Force a Yes from the question dialog, and record that it actually fired.
asked = {}
def fake_question(parent, title, text, buttons, default):
asked["title"] = title
return QMessageBox.StandardButton.Yes
QMessageBox.question = staticmethod(fake_question)
def fake_warning(*a, **k):
return QMessageBox.StandardButton.Ok
QMessageBox.warning = staticmethod(fake_warning)
w._prompt_relocate_unresolved([(asm, ac1, c1)])
assert asked.get("title") == "Connector Position Needed", asked
assert w._relocate_pending == [(asm, ac1, c1)], w._relocate_pending
assert w._viewer_3d._connector_pick_mode, "pick mode must be active"
# A wrong-part click must not consume the pending entry.
w._on_relocate_picked((0, 0, 0), (0, 0, 1), (1, 0, 0), "planar_face", f"asm_{ac2.id}_{pbody.id}")
assert w._relocate_pending == [(asm, ac1, c1)], "wrong part must not consume the pick"
# The right click re-homes and drains.
w._on_relocate_picked((40.0, 30.0, 2.5), (0, 0, 1), (1, 0, 0), "cylindrical_face", f"asm_{ac1.id}_{body.id}")
assert w._relocate_pending is None
assert np.allclose(c1.position, (40.0, 30.0, 2.5)), c1.position
assert not w._viewer_3d._connector_pick_mode, "pick mode must be off after completion"
# Esc cancel mid-flight clears the state.
w._relocate_pending = [(asm, ac1, c1)]
w._start_relocate_pick_next()
w._on_connector_pick_cancelled()
assert w._relocate_pending is None
assert not w._viewer_3d._connector_pick_mode
print("RELOCATE_PICK_FALLBACK_OK")
+149
View File
@@ -0,0 +1,149 @@
"""Headless test: update_external_entities handles circle/arc dict entries.
Reproduces the crash where a re-projected face contains a circular edge
(e.g. an instance cut hole): _project_face_to_uv returns a mixed list of
polylines + curve dicts, and update_external_entities used to unpack the
dict entries as (u, v) tuples.
Covers:
1. mixed projection (polylines + circle dict) -> rebuild + rebind path,
no crash, no duplicate/orphan curve entities, user geometry re-anchored.
2. repeated update with the same mixed projection -> stable (idempotent).
3. polylines-only same-topology projection -> in-place path still works
(external ids preserved).
"""
import math
from fluency.geometry_occ.sketch import OCCSketch
RECT = [
[(0.0, 0.0), (10.0, 0.0)],
[(10.0, 0.0), (10.0, 10.0)],
[(10.0, 10.0), (0.0, 10.0)],
[(0.0, 10.0), (0.0, 0.0)],
]
def _counts(sk):
ents = list(sk._entities.values())
return {
"ext_points": sum(
1
for e in ents
if e.entity_type == "point" and getattr(e, "is_external", False)
),
"ext_lines": sum(
1
for e in ents
if e.entity_type == "line" and getattr(e, "is_external", False)
),
"circles": sum(1 for e in ents if e.entity_type == "circle"),
"arcs": sum(1 for e in ents if e.entity_type == "arc"),
"user_points": sum(
1
for e in ents
if e.entity_type == "point" and not getattr(e, "is_external", False)
),
}
def test_mixed_projection_rebuild():
sk = OCCSketch()
sk.add_external_polylines([list(p) for p in RECT])
center = sk.add_external_point(5.0, 5.0)
sk.add_circle(center, 2.0)
user = sk.add_point(5.0, 5.0)
assert sk.constrain_coincident(user, center)
assert sk.solve()
# Re-projection: same rectangle, circle moved + resized -> mixed list.
new_proj = [
list(p) for p in RECT
] + [
{"type": "circle", "center": [6.0, 6.0], "radius": 1.5},
]
old_ext_ids = set(sk._external_entity_ids)
assert sk.update_external_entities(new_proj), "rebuild + rebind solve failed"
c = _counts(sk)
assert c["circles"] == 1, f"duplicate circle entities: {c}"
assert c["ext_points"] == 5, f"ext point count wrong (expect 4 corners + 1 centre): {c}"
assert c["ext_lines"] == 4, f"ext line count wrong (expect 4): {c}"
assert c["user_points"] == 1
# The coincident rebind must anchor the user point to the NEW centre.
ux, uy = user.geometry
assert math.hypot(ux - 6.0, uy - 6.0) < 1e-6, f"user point at {(ux, uy)}"
# Rebuild path: fresh external ids.
assert not (old_ext_ids & sk._external_entity_ids)
# Idempotent second pass with the same projection.
assert sk.update_external_entities(list(new_proj)), "second pass failed"
c2 = _counts(sk)
assert c2 == c, f"counts changed on second pass: {c} -> {c2}"
ux, uy = user.geometry
assert math.hypot(ux - 6.0, uy - 6.0) < 1e-6
print("test_mixed_projection_rebuild OK")
def test_polylines_only_inplace():
sk = OCCSketch()
sk.add_external_polylines([list(p) for p in RECT])
corner = None
for eid in sk._external_entity_ids:
ent = sk._entities[eid]
if ent.entity_type == "point" and ent.geometry == (0.0, 0.0):
corner = ent
break
assert corner is not None
user = sk.add_point(0.0, 0.0)
assert sk.constrain_coincident(user, corner)
assert sk.solve()
# Same topology, slightly shifted rectangle -> in-place move.
moved = [[(u + 1.0, v + 2.0) for (u, v) in poly] for poly in RECT]
old_ext_ids = set(sk._external_entity_ids)
assert sk.update_external_entities(moved), "in-place solve failed"
assert sk._external_entity_ids == old_ext_ids, "in-place path must keep ids"
ux, uy = user.geometry
assert math.hypot(ux - 1.0, uy - 2.0) < 1e-6, f"user point at {(ux, uy)}"
print("test_polylines_only_inplace OK")
def test_arc_import_shares_corners():
"""_import_external_curves must merge arc endpoints with existing
polyline corner points (no floating duplicate endpoints)."""
sk = OCCSketch()
# Rectangle with the top-right corner filleted: the arc endpoints must
# land on the truncated-edge corner points, not create new ones.
r = 2.0
sk.add_external_polylines([
[(0.0, 0.0), (10.0, 0.0)],
[(10.0, 0.0), (10.0, 10.0 - r)],
[(10.0 - r, 10.0), (0.0, 10.0)],
[(0.0, 10.0), (0.0, 0.0)],
])
sk._import_external_curves(
[],
[
{
"type": "arc",
"center": [10.0 - r, 10.0 - r],
"start": [10.0, 10.0 - r],
"end": [10.0 - r, 10.0],
"radius": r,
},
],
)
c = _counts(sk)
# 5 corners + 1 arc centre, NO extra endpoint entities.
assert c["ext_points"] == 6, f"expected 6 ext points, got {c}"
assert c["arcs"] == 1, f"expected 1 arc, got {c}"
assert sk.solve()
print("test_arc_import_shares_corners OK")
if __name__ == "__main__":
test_mixed_projection_rebuild()
test_polylines_only_inplace()
test_arc_import_shares_corners()
print("UNDERLAY_CURVES_OK")
-147
View File
@@ -1,147 +0,0 @@
# WARP.md
This file provides guidance to WARP (warp.dev) when working with code in this repository.
## Project Overview
Fluency is a CAD (Computer Aided Design) application built with Python/PySide6 that provides parametric 3D modeling through a timeline-based project system. The application combines 2D sketching with constraint solving, 3D visualization using VTK, and SDF (Signed Distance Function) based mesh generation.
## Common Commands
### Development Environment Setup
```bash
# Activate virtual environment (if exists)
source .venv/bin/activate
# Install dependencies
pip install -r requirements.txt
```
### Running the Application
```bash
# Run the main application
python main.py
# Run with debugging
python -u main.py
```
### UI Development
```bash
# Convert Qt Designer UI file to Python code
pyside6-uic gui.ui > Gui.py -g python
```
### Building Executable
The project uses Nuitka for compilation (configured in `main.py` header):
```bash
# Build standalone executable
nuitka --standalone --plugin-enable=pyside6 --plugin-enable=numpy --macos-create-app-bundle main.py
```
### Testing
```bash
# Run mesh generation test
python meshtest.py
```
## Architecture Overview
### Core Components
#### Main Application (`main.py`)
- **MainWindow**: Central UI controller that manages all widgets and user interactions
- **Project System**: Hierarchical structure: `Project → Timeline → Component → Sketch/Body`
- **Signal-based Communication**: Qt signals coordinate between 2D sketching and 3D rendering
#### Project Hierarchy
```
Project
├── Timeline (list of Components)
└── Component
├── Sketches (dict)
├── Bodies (dict)
└── Connectors (for assembly)
```
#### Drawing Modules (`drawing_modules/`)
- **SketchWidget** (`draw_widget_solve.py`): 2D parametric sketching with SolverSpace constraint solving
- **VTKWidget** (`vtk_widget.py`): 3D visualization and mesh interaction using VTK
- **PyVistaWidget** (`vysta_widget.py`): Alternative 3D rendering backend
#### Mesh Generation (`mesh_modules/`)
- **VESTA** (`vesta_mesh.py`): Multi-threaded SDF-to-mesh conversion using marching cubes
- **Interactor Mesh** (`interactor_mesh.py`): Simplified edge-based meshes for 3D selection
- **Simple Mesh** (`simple_mesh.py`): Basic mesh utilities
### Data Flow Architecture
#### 2D to 3D Pipeline
1. **2D Sketching**: User draws in SketchWidget using Qt coordinate system
2. **Constraint Solving**: SolverSpace resolves geometric constraints
3. **SDF Generation**: Sketch converted to Signed Distance Functions for 3D operations
4. **Mesh Generation**: VESTA generates triangle meshes from SDF using marching cubes
5. **3D Rendering**: VTK displays both solid meshes and interactive edges
#### Signal Flow (from `doc/flow.md`)
- 2D QPoint → cartesian space → SolverSpace dict → constraint solving → display
- 3D mesh selection → projection to 2D → sketch widget integration
### Key Classes
#### Core Data Structures
- **Sketch**: 2D geometric data with origin, normal, points, and constraints
- **Body**: 3D mesh representation containing SDF objects and interactor meshes
- **Component**: Container grouping related sketches and bodies
- **Interactor**: Simplified edge-based mesh for 3D manipulation
#### Constraint Solving
The application uses `python_solvespace` for parametric constraint solving:
- Point-to-point constraints
- Distance constraints
- Horizontal/vertical line constraints
- Point-to-line constraints
### Technology Stack
- **GUI**: PySide6 (Qt for Python)
- **3D Graphics**: VTK for rendering, PyVista as alternative
- **Constraint Solving**: SolverSpace for parametric geometry
- **Mesh Generation**: SDF library with custom VESTA marching cubes implementation
- **Scientific Computing**: NumPy for mathematical operations
## Development Workflow
### Adding New Sketch Tools
1. Add UI button in `gui.ui`
2. Convert UI: `pyside6-uic gui.ui > Gui.py -g python`
3. Connect signal in `MainWindow.__init__()`
4. Implement tool logic in `SketchWidget`
### Adding New 3D Operations
1. Extend operation buttons in the Modify group
2. Implement operation logic using SDF functions
3. Update Body creation and timeline management
4. Handle interactor mesh generation for selection
### Debugging Tips
- Monitor solver results through `SolverSystem` status
- Use VTK's built-in debugging for rendering issues
- Check coordinate transformations between 2D sketch and 3D space
- Verify SDF function outputs before mesh generation
### File Structure
- `main.py`: Application entry point and main window
- `Gui.py`: Auto-generated UI code (do not edit directly)
- `gui.ui`: Qt Designer UI definition file
- `drawing_modules/`: 2D and 3D rendering widgets
- `mesh_modules/`: Mesh generation and processing
- `doc/`: Architecture and command documentation
## Dependencies
Primary external libraries:
- `PySide6`: Qt GUI framework
- `vtk`: 3D visualization toolkit
- `python-solvespace`: Constraint solving
- `sdf`: Signed Distance Function operations
- `numpy`: Numerical computations
- `scikit-image`: Marching cubes algorithm
- `names`: Random name generation for sketches
Binary file not shown.
+37 -30
View File
@@ -1170,41 +1170,27 @@
<string>Modify</string>
</property>
<layout class="QGridLayout" name="gridLayout_3">
<item row="1" column="0">
<widget class="QPushButton" name="pb_combop">
<item row="2" column="1">
<widget class="QPushButton" name="pb_chamfer_op">
<property name="text">
<string>Comb</string>
<string>Chamfer</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QPushButton" name="pb_arrayop">
<property name="text">
<string>Arry</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QPushButton" name="pb_moveop">
<property name="text">
<string>Mve</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QPushButton" name="pb_revop">
<property name="text">
<string>Rev</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QPushButton" name="pb_fillet_op">
<property name="text">
<string>Fillet</string>
</property>
</widget>
</item>
<item row="6" column="0">
<widget class="QPushButton" name="pb_thread">
<property name="text">
<string>Thread</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="pb_extrdop">
<property name="text">
@@ -1212,6 +1198,13 @@
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QPushButton" name="pb_revop">
<property name="text">
<string>Rev</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QPushButton" name="pb_cutop">
<property name="text">
@@ -1219,17 +1212,31 @@
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QPushButton" name="pb_face_op">
<item row="4" column="0">
<widget class="QPushButton" name="pb_arrayop">
<property name="text">
<string>Phase</string>
<string>Arry</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QPushButton" name="pb_thread">
<item row="1" column="0">
<widget class="QPushButton" name="pb_combop">
<property name="text">
<string>Thread</string>
<string>Comb</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QPushButton" name="pb_moveop">
<property name="text">
<string>Mve</string>
</property>
</widget>
</item>
<item row="6" column="1">
<widget class="QPushButton" name="pb_mirror_op">
<property name="text">
<string>Mirror</string>
</property>
</widget>
</item>
+38 -32
View File
@@ -628,50 +628,55 @@ class Ui_fluencyCAD(object):
self.groupBox.setMaximumSize(QSize(200, 16777215))
self.gridLayout_3 = QGridLayout(self.groupBox)
self.gridLayout_3.setObjectName(u"gridLayout_3")
self.pb_combop = QPushButton(self.groupBox)
self.pb_combop.setObjectName(u"pb_combop")
self.pb_chamfer_op = QPushButton(self.groupBox)
self.pb_chamfer_op.setObjectName(u"pb_chamfer_op")
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
self.pb_arrayop = QPushButton(self.groupBox)
self.pb_arrayop.setObjectName(u"pb_arrayop")
self.gridLayout_3.addWidget(self.pb_arrayop, 2, 0, 1, 1)
self.pb_moveop = QPushButton(self.groupBox)
self.pb_moveop.setObjectName(u"pb_moveop")
self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1)
self.pb_revop = QPushButton(self.groupBox)
self.pb_revop.setObjectName(u"pb_revop")
self.gridLayout_3.addWidget(self.pb_revop, 2, 1, 1, 1)
self.gridLayout_3.addWidget(self.pb_chamfer_op, 2, 1, 1, 1)
self.pb_fillet_op = QPushButton(self.groupBox)
self.pb_fillet_op.setObjectName(u"pb_fillet_op")
self.gridLayout_3.addWidget(self.pb_fillet_op, 3, 0, 1, 1)
self.gridLayout_3.addWidget(self.pb_fillet_op, 2, 0, 1, 1)
self.pb_thread = QPushButton(self.groupBox)
self.pb_thread.setObjectName(u"pb_thread")
self.gridLayout_3.addWidget(self.pb_thread, 6, 0, 1, 1)
self.pb_extrdop = QPushButton(self.groupBox)
self.pb_extrdop.setObjectName(u"pb_extrdop")
self.gridLayout_3.addWidget(self.pb_extrdop, 0, 0, 1, 1)
self.pb_revop = QPushButton(self.groupBox)
self.pb_revop.setObjectName(u"pb_revop")
self.gridLayout_3.addWidget(self.pb_revop, 4, 1, 1, 1)
self.pb_cutop = QPushButton(self.groupBox)
self.pb_cutop.setObjectName(u"pb_cutop")
self.gridLayout_3.addWidget(self.pb_cutop, 0, 1, 1, 1)
self.pb_face_op = QPushButton(self.groupBox)
self.pb_face_op.setObjectName(u"pb_face_op")
self.pb_arrayop = QPushButton(self.groupBox)
self.pb_arrayop.setObjectName(u"pb_arrayop")
self.gridLayout_3.addWidget(self.pb_face_op, 3, 1, 1, 1)
self.gridLayout_3.addWidget(self.pb_arrayop, 4, 0, 1, 1)
self.pb_thread = QPushButton(self.groupBox)
self.pb_thread.setObjectName(u"pb_thread")
self.pb_combop = QPushButton(self.groupBox)
self.pb_combop.setObjectName(u"pb_combop")
self.gridLayout_3.addWidget(self.pb_thread, 4, 0, 1, 1)
self.gridLayout_3.addWidget(self.pb_combop, 1, 0, 1, 1)
self.pb_moveop = QPushButton(self.groupBox)
self.pb_moveop.setObjectName(u"pb_moveop")
self.gridLayout_3.addWidget(self.pb_moveop, 1, 1, 1, 1)
self.pb_mirror_op = QPushButton(self.groupBox)
self.pb_mirror_op.setObjectName(u"pb_mirror_op")
self.gridLayout_3.addWidget(self.pb_mirror_op, 6, 1, 1, 1)
self.gridLayout.addWidget(self.groupBox, 0, 3, 1, 1)
@@ -922,15 +927,16 @@ class Ui_fluencyCAD(object):
self.pb_offset_tool.setText(QCoreApplication.translate("fluencyCAD", u"Offst", None))
self.assembly_box.setTitle(QCoreApplication.translate("fluencyCAD", u"Assembly", None))
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
self.pb_chamfer_op.setText(QCoreApplication.translate("fluencyCAD", u"Chamfer", None))
self.pb_fillet_op.setText(QCoreApplication.translate("fluencyCAD", u"Fillet", None))
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
self.pb_face_op.setText(QCoreApplication.translate("fluencyCAD", u"Phase", None))
self.pb_thread.setText(QCoreApplication.translate("fluencyCAD", u"Thread", None))
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
self.pb_mirror_op.setText(QCoreApplication.translate("fluencyCAD", u"Mirror", None))
self.groupBox_10.setTitle(QCoreApplication.translate("fluencyCAD", u"Bodys / Operations", None))
self.groupBox_8.setTitle(QCoreApplication.translate("fluencyCAD", u"Tools", None))
self.pb_body_hide.setText(QCoreApplication.translate("fluencyCAD", u"Hide", None))
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

+501 -19
View File
@@ -5,6 +5,7 @@ This module provides a concrete implementation of the geometry kernel
using OCP (OpenCASCADE Python bindings).
"""
import logging
from typing import List, Tuple, Optional, Any, Dict
import numpy as np
@@ -16,6 +17,35 @@ from fluency.geometry.base import (
)
logger = logging.getLogger(__name__)
def _curve_is_linear(occ_edge: Any) -> bool:
"""Return True if *occ_edge* has a linear or chamferable curve type.
``BRepFilletAPI_MakeChamfer`` and ``BRepFilletAPI_MakeFillet`` crash
(segfault) on circular/elliptical curves. We pre-filter those out.
"""
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GeomAbs import GeomAbs_CurveType
try:
ad = BRepAdaptor_Curve(occ_edge)
ct = ad.GetType()
except Exception:
# If we can't classify, assume it's safe (will be caught later).
return True
# Chamfer/fillet only support linear curves reliably.
return ct in (
GeomAbs_CurveType.GeomAbs_Line,
GeomAbs_CurveType.GeomAbs_BSplineCurve,
GeomAbs_CurveType.GeomAbs_BezierCurve,
GeomAbs_CurveType.GeomAbs_OffsetCurve,
GeomAbs_CurveType.GeomAbs_Parabola,
GeomAbs_CurveType.GeomAbs_Hyperbola,
)
class OCCGeometryObject(GeometryObject):
"""Geometry object wrapper for OpenCASCADE shapes."""
@@ -75,7 +105,7 @@ class OCGeometryKernel(GeometryKernel):
return OCCGeometryObject(edge, {"type": "circle"})
def create_arc(
self, center: Point2D, radius: float, start_angle: float, end_angle: float
self, center: Point2D, radius: float , start_angle: float, end_angle: float
) -> GeometryObject:
"""Create a 2D arc."""
import math
@@ -443,51 +473,82 @@ class OCGeometryKernel(GeometryKernel):
def fillet(
self, body: GeometryObject, radius: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply fillet to edges."""
shape = self._get_shape(body)
"""Apply fillet to edges. Skips edges that cannot be filleted."""
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet
fillet = BRepFilletAPI_MakeFillet(shape)
shape: Any = self._get_shape(body)
if shape is None:
return OCCGeometryObject(None, {"type": "fillet"})
if edges:
for edge in edges:
fillet.Add(radius, edge)
# Collect candidate edges
if edges is not None:
candidates = list(edges)
else:
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
from OCP.TopoDS import TopoDS
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
candidates = []
while explorer.More():
fillet.Add(radius, TopoDS.Edge_s(explorer.Current()))
e = TopoDS.Edge_s(explorer.Current())
if _curve_is_linear(e):
candidates.append(e)
explorer.Next()
fillet.Build()
return OCCGeometryObject(fillet.Shape(), {"type": "fillet"})
# Add all edges, then Build once — avoids OCC internal crashes
fl = BRepFilletAPI_MakeFillet(shape)
for edge in candidates:
try:
fl.Add(radius, edge)
except Exception:
pass # skip edges that fail to add
fl.Build()
if fl.IsDone():
return OCCGeometryObject(fl.Shape(), {"type": "fillet"})
# If Build failed, return original shape (no fillet applied)
return OCCGeometryObject(shape, {"type": "fillet"})
def chamfer(
self, body: GeometryObject, size: float, edges: Optional[List[Any]] = None
) -> GeometryObject:
"""Apply chamfer to edges."""
shape = self._get_shape(body)
"""Apply chamfer to edges. Skips edges that cannot be chamfered."""
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer
chamfer = BRepFilletAPI_MakeChamfer(shape)
shape: Any = self._get_shape(body)
if shape is None:
return OCCGeometryObject(None, {"type": "chamfer"})
if edges:
for edge in edges:
chamfer.Add(size, edge)
# Collect candidate edges
if edges is not None:
candidates = list(edges)
else:
from OCP.TopExp import TopExp_Explorer
from OCP.TopAbs import TopAbs_EDGE
from OCP.TopoDS import TopoDS
explorer = TopExp_Explorer(shape, TopAbs_EDGE)
candidates = []
while explorer.More():
chamfer.Add(size, explorer.Current())
e = TopoDS.Edge_s(explorer.Current())
if _curve_is_linear(e):
candidates.append(e)
explorer.Next()
chamfer.Build()
return OCCGeometryObject(chamfer.Shape(), {"type": "chamfer"})
# Add all edges, then Build once — avoids OCC internal crashes
mc = BRepFilletAPI_MakeChamfer(shape)
for edge in candidates:
try:
mc.Add(size, edge)
except Exception:
pass # skip edges that fail to add
mc.Build()
if mc.IsDone():
return OCCGeometryObject(mc.Shape(), {"type": "chamfer"})
# If Build failed, return original shape (no chamfer applied)
return OCCGeometryObject(shape, {"type": "chamfer"})
def shell(
self, body: GeometryObject, thickness: float, faces_to_remove: Optional[List[Any]] = None
@@ -576,6 +637,67 @@ class OCGeometryKernel(GeometryKernel):
return OCCGeometryObject(transformer.Shape(), {"type": "mirrored"})
def pattern(
self,
body: GeometryObject,
pattern_type: str = "linear",
count: int = 2,
direction: Tuple[float, float, float] = (1, 0, 0),
spacing: float = 10.0,
axis: Tuple[float, float, float] = (0, 0, 1),
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0),
angle: float = 360.0,
) -> GeometryObject:
"""Repeat *body* in a linear or circular array (pattern).
Linear: *count* copies spaced *spacing* mm apart along
*direction* (a negative spacing reverses the direction).
Circular: *count* copies rotated evenly around *axis* passing
through *origin*, distributed over a total angular span of
*angle* degrees (step = angle / count). ``angle=360`` gives the
classic evenly-spaced full-circle bolt pattern.
Returns the union (compound when the copies don't touch) of the
original solid and all its copies — disjoint copies keep their
separate volumes inside one result object, touching copies fuse.
"""
count = max(1, int(count))
if count <= 1:
return body
import math as _math
instances: list = [body]
if pattern_type == "circular":
# Normalize the rotation axis.
ax = float(axis[0]), float(axis[1]), float(axis[2])
norm = _math.sqrt(ax[0] * ax[0] + ax[1] * ax[1] + ax[2] * ax[2])
if norm < 1e-12:
ax = (0.0, 0.0, 1.0)
else:
ax = (ax[0] / norm, ax[1] / norm, ax[2] / norm)
step = _math.radians(float(angle)) / count
for i in range(1, count):
instances.append(self.rotate(body, ax, step * i, origin))
else:
d = float(direction[0]), float(direction[1]), float(direction[2])
norm = _math.sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2])
if norm < 1e-12:
d = (1.0, 0.0, 0.0)
else:
d = (d[0] / norm, d[1] / norm, d[2] / norm)
step = float(spacing)
for i in range(1, count):
instances.append(
self.translate(
body,
(d[0] * step * i, d[1] * step * i, d[2] * step * i),
)
)
return self.boolean_union(*instances)
def export_step(self, body: GeometryObject, filepath: str, schema: str = "AP214") -> bool:
"""Export to STEP format."""
try:
@@ -868,3 +990,363 @@ class OCGeometryKernel(GeometryKernel):
cg = props.CentreOfMass()
return Point3D(cg.X(), cg.Y(), cg.Z())
def create_thread(
self,
body: GeometryObject,
cylindrical_face: Any,
nominal_diameter: float,
pitch: float,
thread_length: Optional[float] = None,
internal: bool = False,
) -> Optional[GeometryObject]:
"""Cut (or add) an ISO metric thread on the cylindrical face of *body*.
The geometry is driven by the PICKED face's actual radius and axis
(``nominal_diameter`` is only metadata used for the feature record).
External threads cut the ISO groove trapezoid (7P/8 at the surface,
P/4 at the root, 5H/8 deep) out of the shaft; internal threads fuse
the ISO ridge trapezoid (3P/4 at the wall, P/8 crest) into the hole.
"""
import math
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Cylinder
from OCP.TopoDS import TopoDS
from OCP.gp import gp_Pnt, gp_Pnt2d, gp_Dir2d
from OCP.BRepBuilderAPI import (
BRepBuilderAPI_MakeEdge,
BRepBuilderAPI_MakeWire,
)
# ── 1. Cylinder parameters from the picked face ─────────────────
try:
surf = BRepAdaptor_Surface(cylindrical_face)
except Exception:
try:
surf = BRepAdaptor_Surface(TopoDS.Face_s(cylindrical_face))
except Exception as exc:
logger.warning(f"create_thread: cannot adapt face: {exc}")
return None
if surf.GetType() != GeomAbs_Cylinder:
logger.warning("create_thread: face is not cylindrical")
return None
cyl = surf.Cylinder() # gp_Cylinder
radius = cyl.Radius() # ACTUAL picked radius
ax3 = cyl.Position() # gp_Ax3 (location, Z, X)
loc = ax3.Location()
zdir = ax3.Direction()
xdir = ax3.XDirection()
axis_origin = np.array([loc.X(), loc.Y(), loc.Z()])
axis_dir = np.array([zdir.X(), zdir.Y(), zdir.Z()])
axis_dir = axis_dir / np.linalg.norm(axis_dir)
axis_x = np.array([xdir.X(), xdir.Y(), xdir.Z()])
axis_x = axis_x / np.linalg.norm(axis_x)
axis_y = np.cross(axis_dir, axis_x)
u_start = surf.FirstUParameter() # angular start of face
v1, v2 = surf.FirstVParameter(), surf.LastVParameter()
v_lo, v_hi = min(v1, v2), max(v1, v2)
face_height = v_hi - v_lo
if not thread_length or thread_length <= 0:
thread_length = face_height
thread_length = min(thread_length, face_height)
num_turns = thread_length / pitch
if num_turns < 0.05:
logger.warning("create_thread: thread too short for one turn")
return None
# ── 2. ISO metric profile dimensions ────────────────────────────
# Basic profile (H = P·√3/2, thread engagement depth 5H/8):
# • external shaft: groove cut is a trapezoid 7P/8 wide at the
# surface narrowing to P/4 at the root.
# • internal hole: ridge fused onto the wall is a trapezoid 3P/4
# wide at the wall narrowing to P/8 at the inner crest, leaving
# the 7P/8-wide groove open at the bore.
H = pitch * math.sqrt(3.0) / 2.0
depth = (5.0 / 8.0) * H
overcut = max(0.1 * depth, 0.02) # overhang past the surface
if internal:
w_surf = 3.0 * pitch / 4.0
w_deep = pitch / 8.0
else:
w_surf = 7.0 * pitch / 8.0
w_deep = pitch / 4.0
# ── 3. Helix spine ON the picked cylinder's surface ─────────────
# The swept profile sits in the helix's normal plane, tilted by the
# lead angle; its end caps therefore stick out past the spine ends
# by roughly half the profile width along the axis. For a CUT that
# is harmless (the groove simply runs to the part edge), but a FUSE
# would leave the protruding cap as floating material outside the
# part, so inset the internal helix by exactly that amount.
lead = math.atan2(pitch, 2.0 * math.pi * radius)
cap_axial = (w_surf / 2.0) * math.cos(lead) # cap half-extent along axis
if internal:
v_start = v_lo + cap_axial
v_end = min(v_lo + thread_length, v_hi) - cap_axial
else:
# extend one pitch past each face end so the groove runs off
# the part edges cleanly
v_start = v_lo - pitch
v_end = min(v_lo + thread_length + pitch, v_hi + pitch)
thread_span = v_end - v_start
if thread_span < 0.5 * pitch:
logger.warning("create_thread: part too short for a thread")
return None
turns_ext = thread_span / pitch
spine_wire = None
# 3a. TRUE helix: a 2D straight line on the cylinder surface.
#
# NOTE 1: gp_Dir2d NORMALIZES its argument, so the 2D line
# parameter t advances the point by t·|(2π, pitch)| in (u, v)
# space — scale the trim range so t = n turns covers exactly
# n revolutions plus n·pitch of axial travel.
# NOTE 2: the edge from a pcurve has no 3D curve; the pipe sweep
# needs one, so force it with BRepLib.BuildCurves3d.
spine_wire = None
try:
from OCP.Geom import Geom_CylindricalSurface
from OCP.Geom2d import Geom2d_Line, Geom2d_TrimmedCurve
from OCP.BRepLib import BRepLib
dir_len = math.hypot(2.0 * math.pi, pitch)
cyl_surf = Geom_CylindricalSurface(cyl)
line2d = Geom2d_Line(
gp_Pnt2d(u_start, v_start), gp_Dir2d(2.0 * math.pi, pitch)
)
seg = Geom2d_TrimmedCurve(line2d, 0.0, turns_ext * dir_len)
helix_edge = BRepBuilderAPI_MakeEdge(seg, cyl_surf).Edge()
BRepLib.BuildCurves3d_s(helix_edge)
spine_wire = BRepBuilderAPI_MakeWire(helix_edge).Wire()
logger.info("create_thread: using exact helix spine")
except Exception as exc:
logger.info(f"create_thread: exact helix failed ({exc})")
# 3b. Fallback: smooth BSpline through sampled helix points
# (only if the exact construction is unavailable).
if spine_wire is None:
try:
from OCP.GeomAPI import GeomAPI_PointsToBSpline
from OCP.TColgp import TColgp_Array1OfPnt
from OCP.GeomAbs import GeomAbs_C2
pts_per_turn = 96
n_total = max(int(turns_ext * pts_per_turn) + 1, 2)
arr = TColgp_Array1OfPnt(1, n_total)
for i in range(1, n_total + 1):
u = u_start + ((i - 1) / pts_per_turn) * 2.0 * math.pi
v = v_start + ((i - 1) / pts_per_turn) * pitch
p = (
axis_origin
+ radius * (math.cos(u) * axis_x + math.sin(u) * axis_y)
+ v * axis_dir
)
arr.SetValue(i, gp_Pnt(float(p[0]), float(p[1]), float(p[2])))
bspline = GeomAPI_PointsToBSpline(arr, 3, 8, GeomAbs_C2, 1e-5)
bs_edge = BRepBuilderAPI_MakeEdge(bspline.Curve()).Edge()
spine_wire = BRepBuilderAPI_MakeWire(bs_edge).Wire()
logger.info("create_thread: using BSpline helix fallback")
except Exception as exc:
logger.warning(f"create_thread: BSpline helix failed ({exc})")
if spine_wire is None:
logger.warning("create_thread: no usable helix spine")
return None
# Start frame (same for both spine types — computed analytically).
def _cyl_pt(u: float, v: float) -> np.ndarray:
return (
axis_origin
+ radius * (math.cos(u) * axis_x + math.sin(u) * axis_y)
+ v * axis_dir
)
start_S = _cyl_pt(u_start, v_start)
start_T = (
2.0 * math.pi * radius
* (-math.sin(u_start) * axis_x + math.cos(u_start) * axis_y)
+ pitch * axis_dir
)
start_T = start_T / np.linalg.norm(start_T)
start_R = math.cos(u_start) * axis_x + math.sin(u_start) * axis_y # outward
# Profile width direction: perpendicular to tangent in the surface
# plane (≈ axial direction). Trapezoid is symmetric so sign is fine.
binormal = np.cross(start_T, start_R)
binormal = binormal / np.linalg.norm(binormal)
# ── 4. Trapezoidal profile at the spine start ───────────────────
# Built directly in world coords: base sits *overcut* OUTSIDE the
# surface so the boolean fuses/cuts cleanly across it; the working
# end reaches *depth* INSIDE the surface.
def _mk(b: float, r: float) -> gp_Pnt:
p = start_S + b * binormal + r * start_R
return gp_Pnt(float(p[0]), float(p[1]), float(p[2]))
p0 = _mk(-w_surf / 2.0, overcut)
p1 = _mk(-w_deep / 2.0, -depth)
p2 = _mk(+w_deep / 2.0, -depth)
p3 = _mk(+w_surf / 2.0, overcut)
prof_wb = BRepBuilderAPI_MakeWire()
for a, b in ((p0, p1), (p1, p2), (p2, p3), (p3, p0)):
prof_wb.Add(BRepBuilderAPI_MakeEdge(a, b).Edge())
profile_wire = prof_wb.Wire()
# ── 5. Sweep the profile along the helix ────────────────────────
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell
try:
pipe = BRepOffsetAPI_MakePipeShell(spine_wire)
pipe.SetMode(True) # Frenet frame
pipe.Add(profile_wire, False, False)
pipe.Build()
if not pipe.IsDone():
logger.warning("create_thread: pipe sweep failed")
return None
solid_ok = False
try:
solid_ok = bool(pipe.MakeSolid()) # cap the tube ends
except Exception as exc:
logger.info(f"create_thread: MakeSolid unavailable ({exc})")
tool_shape = pipe.Shape()
if not solid_ok:
logger.warning("create_thread: sweep is not a solid")
except Exception as exc:
logger.warning(f"create_thread: sweep failed: {exc}")
return None
# ── 6. Boolean cut (shaft) or fuse (hole) ───────────────────────
body_shape = self._get_shape(body)
if body_shape is None:
logger.warning("create_thread: body has no shape")
return None
tool = OCCGeometryObject(tool_shape)
vol_before = self.get_volume(body)
if internal:
result = self.boolean_union(body, tool)
else:
result = self.boolean_difference(body, tool)
if result is None or self._get_shape(result) is None:
logger.warning("create_thread: boolean op produced no shape")
return None
try:
vol_after = self.get_volume(result)
except Exception:
vol_after = -1.0
if internal and vol_after <= vol_before:
logger.warning(
f"create_thread: fuse did not add volume "
f"({vol_before:.4f}{vol_after:.4f}) — tool missed the body?"
)
return None
if not internal and vol_after >= vol_before:
logger.warning(
f"create_thread: cut did not remove volume "
f"({vol_before:.4f}{vol_after:.4f}) — tool missed the body?"
)
return None
logger.info(
f"create_thread: {'internal' if internal else 'external'} thread OK, "
f"volume {vol_before:.4f}{vol_after:.4f}"
)
return result
def detect_cylindrical_face(
self,
face: Any,
) -> Optional[Dict[str, Any]]:
"""Check if *face* is cylindrical and return its parameters.
The *face* can be a ``TopoDS_Face`` (from the picker) or a
``TopoDS_Shape`` that contains a face. We try several paths to
extract the underlying cylindrical surface.
Returns a dict with keys ``radius``, ``axis_origin``, ``axis_dir``,
``height``, or *None* if the face isn't cylindrical.
"""
import logging
import numpy as np
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Cylinder
from OCP.TopoDS import TopoDS
_log = logging.getLogger(__name__)
# ── Resolve the actual face from whatever the caller handed us ──
actual_face: Any = None
# Try direct BRepAdaptor_Surface first — the picker already returns
# a valid TopoDS_Face, and calling TopoDS.Face_s() again on an
# already-downcast face can fail in some OCP versions.
try:
surf = BRepAdaptor_Surface(face)
surf_type_test = surf.GetType()
actual_face = face
except Exception:
pass
if actual_face is None:
# Fallback: try the explicit TopoDS.Face_s downcast path.
try:
candidate = TopoDS.Face_s(face)
_ = BRepAdaptor_Surface(candidate)
actual_face = candidate
except Exception:
pass
if actual_face is None:
_log.warning("detect_cylindrical_face: could not resolve face from pick result")
return None
# ── Probe the surface type ──
try:
surf = BRepAdaptor_Surface(actual_face)
surf_type = surf.GetType()
if surf_type != GeomAbs_Cylinder:
type_names = {
0: "Plane", 1: "Cylinder", 2: "Cone", 3: "Sphere",
4: "Torus", 5: "Bezier", 6: "BSpline", 7: "Revolution",
8: "Extrusion", 9: "Offset", 10: "Other",
}
type_name = type_names.get(int(surf_type), f"Unknown({int(surf_type)})")
_log.warning(
f"detect_cylindrical_face: face is {type_name}, not a Cylinder"
)
return None
cyl = surf.Cylinder()
radius = cyl.Radius()
axis = cyl.Axis()
origin = axis.Location()
direction = axis.Direction()
# BRepAdaptor_Surface uses FirstUParameter/LastUParameter etc.
u1 = surf.FirstUParameter()
u2 = surf.LastUParameter()
v1 = surf.FirstVParameter()
v2 = surf.LastVParameter()
height = abs(v2 - v1)
return {
"radius": radius,
"diameter": 2.0 * radius,
"axis_origin": (origin.X(), origin.Y(), origin.Z()),
"axis_dir": (direction.X(), direction.Y(), direction.Z()),
"height": height,
}
except Exception as exc:
_log.warning(f"detect_cylindrical_face: surface probe failed: {exc}")
return None
+323 -36
View File
@@ -327,9 +327,16 @@ class OCCSketch(SketchInterface):
start_point: SketchEntity,
end_point: SketchEntity,
sweep: Optional[float] = None,
register: bool = True,
) -> OCCSketchEntity:
"""Add an arc (added to solver + tracked).
*register* False keeps the arc tracked-only (no solver entity, no
handle) — for reference geometry whose three reference points are
all already fixed, e.g. external underlay arcs: registering the
arc on top of three dragged points over-constrains the solver
(SolveSpace reports the system as inconsistent).
The arc is registered with SolveSpace so its three reference points
are linked: start, end, and centre. SolveSpace's arc entity
implicitly enforces ``distance(start, centre) = distance(end, centre)``,
@@ -362,7 +369,11 @@ class OCCSketch(SketchInterface):
if center_entity is None or start_entity is None or end_entity is None:
raise ValueError("Arc points not found in sketch")
if center_entity.handle is None or start_entity.handle is None or end_entity.handle is None:
if register and (
center_entity.handle is None
or start_entity.handle is None
or end_entity.handle is None
):
raise ValueError("Arc endpoints must already be in the solver")
cx, cy = center_entity.geometry
@@ -386,17 +397,20 @@ class OCCSketch(SketchInterface):
# whenever the workplane orientation changes. The normal is
# invalidated by ``clear`` / ``_rebuild_solver`` /
# ``set_workplane`` (the workplane reference changes).
if self._wp_normal_handle is None:
self._wp_normal_handle = self._make_arc_normal_3d()
nm: Any = self._wp_normal_handle
assert nm is not None # _make_arc_normal_3d always returns a handle
arc_handle = self._solver.add_arc(
nm,
center_entity.handle,
start_entity.handle,
end_entity.handle,
self._wp,
)
if register:
if self._wp_normal_handle is None:
self._wp_normal_handle = self._make_arc_normal_3d()
nm: Any = self._wp_normal_handle
assert nm is not None # _make_arc_normal_3d always returns a handle
arc_handle = self._solver.add_arc(
nm,
center_entity.handle,
start_entity.handle,
end_entity.handle,
self._wp,
)
else:
arc_handle = None
entity = OCCSketchEntity(
entity_id=entity_id,
@@ -418,6 +432,9 @@ class OCCSketch(SketchInterface):
"end": end_point.id,
"radius": radius,
"sweep": sweep,
# Tracked-only arcs (register=False) have no solver entity and
# are skipped by ``_rebuild_solver``.
"in_solver": register,
# ``original_sweep`` captures the angular span the user drew
# the arc with. When the host geometry (e.g. a rectangle
# the arc is attached to) resizes, ``_sync_solved_positions``
@@ -587,6 +604,70 @@ class OCCSketch(SketchInterface):
pass
return all_points, all_lines
def _import_external_curves(
self,
circles: List[Dict[str, Any]],
arcs: List[Dict[str, Any]],
) -> None:
"""Import projected circle/arc dicts as external underlay entities.
Mirrors the widget's initial underlay import: a circle becomes a
fixed external centre point plus a tracked circle entity; an arc
becomes a fixed external centre point, endpoint entities (shared
with the polyline corners already imported, so the arc connects
to the adjacent lines), and an arc entity. Must be called after
``add_external_polylines`` and before ``_rebuild_solver`` — the
rebuild re-registers arcs in the fresh solver and re-fixes every
external point.
"""
for c in circles:
try:
center_uv = (float(c["center"][0]), float(c["center"][1]))
center_pt = self.add_external_point(center_uv[0], center_uv[1])
self.add_circle(center_pt, float(c["radius"]))
except Exception as exc:
logger.debug("external circle import failed: %s", exc)
# Generous tolerance: arc endpoints come from a fresh projection of
# the face and must land on the existing corner points despite float
# drift (same rule as the widget's initial import).
merge_tol = 1e-3
def find_pt(u: float, v: float) -> Optional[OCCSketchEntity]:
best: Optional[OCCSketchEntity] = None
best_d = merge_tol
for eid in self._external_entity_ids:
ent = self._entities.get(eid)
if ent is None or ent.entity_type != "point" or ent.geometry is None:
continue
d = math.hypot(ent.geometry[0] - u, ent.geometry[1] - v)
if d <= best_d:
best_d = d
best = ent
return best
for a in arcs:
try:
center_uv = (float(a["center"][0]), float(a["center"][1]))
start_uv = (float(a["start"][0]), float(a["start"][1]))
end_uv = (float(a["end"][0]), float(a["end"][1]))
center_pt = self.add_external_point(center_uv[0], center_uv[1])
start_pt = find_pt(start_uv[0], start_uv[1])
if start_pt is None:
start_pt = self.add_external_point(start_uv[0], start_uv[1])
end_pt = find_pt(end_uv[0], end_uv[1])
if end_pt is None:
end_pt = self.add_external_point(end_uv[0], end_uv[1])
# register=False: all three reference points are external
# (dragged/fixed) — registering the arc on top would
# over-constrain the solver (inconsistent).
self.add_arc(
center_pt, float(a["radius"]), start_pt, end_pt,
sweep=None, register=False,
)
except Exception as exc:
logger.debug("external arc import failed: %s", exc)
def _drop_external_entities(self) -> set:
"""Remove external entities from local tracking + prune their constraints.
@@ -606,6 +687,21 @@ class OCCSketch(SketchInterface):
self._lines.pop(eid, None)
self._circles.pop(eid, None)
self._arcs.pop(eid, None)
# Underlay circle/arc entities are tracked (not tagged external):
# drop any whose reference points just went away, or a re-import
# would pile stale duplicates on top of the fresh ones.
for cid, (cent_id, _radius) in list(self._circles.items()):
if cent_id in removed:
del self._circles[cid]
self._entities.pop(cid, None)
for aid, arc_data in list(self._arcs.items()):
if (
arc_data.get("center") in removed
or arc_data.get("start") in removed
or arc_data.get("end") in removed
):
del self._arcs[aid]
self._entities.pop(aid, None)
# Also clean lines that USE an external point as an endpoint but
# somehow aren't themselves external (defensive — shouldn't happen
# via the public API, but rebuild_solver needs a clean graph).
@@ -637,7 +733,7 @@ class OCCSketch(SketchInterface):
"""Return the set of external (underlay) entity ids currently in the sketch."""
return set(self._external_entity_ids)
def update_external_entities(self, polylines: List[List[Tuple[float, float]]]) -> bool:
def update_external_entities(self, polylines: List[Any]) -> bool:
"""Re-project external (underlay) entities from updated source geometry.
Called when the 3D body the underlay was projected from has been
@@ -646,6 +742,11 @@ class OCCSketch(SketchInterface):
body so user geometry constrained to it propagates through the
solver.
*polylines* is the raw output of ``_project_face_to_uv``: a mixed
list of plain polylines (lists of ``(u, v)``) and curve dicts
(``{"type": "circle", ...}`` / ``{"type": "arc", ...}``) for
circular/arc face edges.
Two paths:
* **In-place update** (same topology): when the new projection has
@@ -663,6 +764,22 @@ class OCCSketch(SketchInterface):
Returns True when the underlay was updated and solved OK.
"""
# Flatten the new projection into unique corner positions + segments.
# The projection mixes plain polylines with curve dicts; polylines
# carry the corner/segment topology used below, curve dicts are
# only handled by the rebuild + rebind path.
polys: List[List[Tuple[float, float]]] = []
circles: List[Dict[str, Any]] = []
arcs: List[Dict[str, Any]] = []
for entry in polylines:
if isinstance(entry, dict):
etype = entry.get("type")
if etype == "circle":
circles.append(entry)
elif etype == "arc":
arcs.append(entry)
elif isinstance(entry, (list, tuple)):
polys.append(list(entry))
tol = self._EXTERNAL_MERGE_TOL
new_pts: List[Tuple[float, float]] = []
@@ -674,7 +791,7 @@ class OCCSketch(SketchInterface):
return len(new_pts) - 1
new_segs: List[Tuple[int, int]] = []
for poly in polylines:
for poly in polys:
if len(poly) < 2:
continue
idx = [new_index(float(u), float(v)) for (u, v) in poly]
@@ -694,7 +811,13 @@ class OCCSketch(SketchInterface):
lid for lid in sorted(self._lines.keys()) if lid in self._external_entity_ids
]
same_topology = len(new_pts) == len(old_ext_points) and len(new_segs) == len(old_ext_lines)
# Curve entries force the rebuild path: the in-place branch only
# moves point/line entities and cannot represent a circle or arc.
same_topology = (
not (circles or arcs)
and len(new_pts) == len(old_ext_points)
and len(new_segs) == len(old_ext_lines)
)
if same_topology and old_ext_points:
# Greedy one-to-one nearest matching old point -> new position.
@@ -769,7 +892,8 @@ class OCCSketch(SketchInterface):
# points and (per the add_point guard) does not auto-anchor a user
# point — which would conflict with the re-bound coincidents below.
self._drop_external_entities()
self.add_external_polylines(polylines)
self.add_external_polylines(polys)
self._import_external_curves(circles, arcs)
self._rebuild_solver()
self._rebuild_labels()
@@ -952,7 +1076,17 @@ class OCCSketch(SketchInterface):
elif ctype == "distance":
if h(ids[0]) is None or h(ids[1]) is None:
return False
self._solver.distance(h(ids[0]), h(ids[1]), params[0], self._wp)
ent0 = self._entities.get(ids[0])
ent1 = self._entities.get(ids[1])
# Normalise (line, point) -> (point, line) like constrain_distance
# does, and drop legacy line-line entries the solver can't hold.
if ent0 is not None and ent1 is not None:
if ent0.entity_type == "line" and ent1.entity_type == "line":
return False
if ent0.entity_type == "line" and ent1.entity_type == "point":
self._solver.distance(h(ids[1]), h(ids[0]), params[0], self._wp)
else:
self._solver.distance(h(ids[0]), h(ids[1]), params[0], self._wp)
elif ctype == "angle":
if h(ids[0]) is None or h(ids[1]) is None:
return False
@@ -1115,6 +1249,8 @@ class OCCSketch(SketchInterface):
assert nm is not None
for aid in sorted(self._arcs.keys()):
arc_data = self._arcs[aid]
if not arc_data.get("in_solver", True):
continue # tracked-only (underlay) arc — no solver state
c_id = arc_data.get("center")
s_id = arc_data.get("start")
e_id = arc_data.get("end")
@@ -1175,16 +1311,92 @@ class OCCSketch(SketchInterface):
entity.constraints.append("vrt")
return True
def _point_line_signed_offset(
self, point_ent: OCCSketchEntity, line_ent: OCCSketchEntity
) -> float:
"""Signed perpendicular offset of *point_ent* from *line_ent*, in the
solver's sign convention — i.e. the ``valA`` that pins the point on
its current side of the line.
Mirrors SolveSpace's ``PT_LINE_DISTANCE`` equation exactly
(a = line start, b = line end, d = a - b):
proj = dv·(ua u) du·(va v), m = |d|
offset = proj / m
Returns 0.0 when the line is degenerate or the point lies on it
(the side is then undefined — callers fall back to the unsigned
value).
"""
if line_ent.id not in self._lines:
return 0.0
sid, eid2 = self._lines[line_ent.id]
s_ent = self._entities.get(sid)
e_ent = self._entities.get(eid2)
if s_ent is None or e_ent is None or not s_ent.geometry or not e_ent.geometry:
return 0.0
ua, va = s_ent.geometry
ub, vb = e_ent.geometry
u, v = point_ent.geometry
du = ua - ub
dv = va - vb
m = math.hypot(du, dv)
if m < 1e-12:
return 0.0
return (dv * (ua - u) - du * (va - v)) / m
def constrain_distance(
self, entity1: SketchEntity, entity2: SketchEntity, distance: float
) -> bool:
"""Constrain distance between two entities."""
"""Constrain distance between two entities.
python-solvespace's ``distance`` accepts point-point and point-line
(in that order) only, so line-point pairs are normalised and line-line
pairs are rejected with a warning instead of raising TypeError.
A point constrained to itself with a non-zero value is always
inconsistent — reject it up front so the UI never creates a constraint
the solver cannot satisfy.
"""
e1 = self._entities.get(entity1.id)
e2 = self._entities.get(entity2.id)
if e1 is None or e2 is None or e1.handle is None or e2.handle is None:
return False
self._solver.distance(e1.handle, e2.handle, distance, self._wp)
self._record_constraint("distance", (entity1.id, entity2.id), (distance,))
if e1 is e2 and e1.entity_type == "point" and distance != 0.0:
logger.warning("distance: refusing point-to-itself constraint")
return False
t1, t2 = e1.entity_type, e2.entity_type
if t1 == "line" and t2 == "line":
logger.warning(
"distance: line-to-line distance is not supported by the solver "
"(select a point and a line instead)"
)
return False
# python-solvespace only accepts (point, line) ordering.
if t1 == "line" and t2 == "point":
e1, e2 = e2, e1
entity1, entity2 = entity2, entity1
# Point-to-line distance is SIGNED in SolveSpace: the constraint
# equation is (signed perpendicular offset) = valA, so a positive
# valA always drives the point onto one fixed side of the line
# (for a vertical line, always +x). Pass a value whose sign
# matches the side the point is currently on so the constraint
# pins it there instead of flipping it across the line. The sign
# is recorded with the value so a solver rebuild reproduces the
# same side.
solver_value = distance
if e2.entity_type == "line" and e1.entity_type == "point" and distance != 0.0:
signed = self._point_line_signed_offset(e1, e2)
if abs(signed) > 1e-9:
solver_value = math.copysign(distance, signed)
self._solver.distance(e1.handle, e2.handle, solver_value, self._wp)
# Record in the normalised (point-first) order so replayed / legacy
# constraint logs are consistent.
self._record_constraint("distance", (entity1.id, entity2.id), (solver_value,))
return True
def constrain_angle(self, line1: SketchEntity, line2: SketchEntity, angle: float) -> bool:
@@ -1779,6 +1991,39 @@ class OCCSketch(SketchInterface):
return points
def get_line_axis(self, line_id: int) -> Optional[Tuple[Tuple[float, float, float], Tuple[float, float, float]]]:
"""Return ``((origin_x, origin_y, origin_z), (dir_x, dir_y, dir_z))`` for a line.
The axis is computed from the line's solved endpoints mapped into world
coordinates on the sketch workplane: origin = line start, direction =
normalized start→end. Returns ``None`` if the id is missing, not a
line, or degenerate. Used by the revolve tool and feature replay so
the revolve axis tracks the sketch line even after it is dragged.
"""
import math
ent = self._entities.get(line_id)
if ent is None or ent.entity_type != "line" or ent.is_external:
return None
geom = ent.geometry
if not geom or len(geom) != 2 or not geom[0] or not geom[1]:
return None
try:
start = self._uv_to_world(*geom[0])
end = self._uv_to_world(*geom[1])
except Exception:
return None
dx = end.X() - start.X()
dy = end.Y() - start.Y()
dz = end.Z() - start.Z()
length = math.sqrt(dx * dx + dy * dy + dz * dz)
if length < 1e-9:
return None
return (
(float(start.X()), float(start.Y()), float(start.Z())),
(dx / length, dy / length, dz / length),
)
def get_polygon_points(self) -> List[Point2D]:
"""Get ordered polygon points from connected lines (uses solved positions).
@@ -1830,7 +2075,7 @@ class OCCSketch(SketchInterface):
_SNAP_TOL: float = 1e-2 # world-unit tolerance for snapping line endpoints in loop detection
def _line_segments(self) -> List[Tuple[Tuple[float, float], Tuple[float, float]]]:
def _line_segments(self) -> List[Tuple[float, float, float, float, int]]:
"""Current line segments as world-coordinate tuples (uses solved positions).
Returns both straight line segments AND tessellated arc segments so
@@ -1838,10 +2083,13 @@ class OCCSketch(SketchInterface):
and external entities are excluded — they're reference geometry and
must not affect the sketch profile.
Each segment is ``(x1, y1, x2, y2, entity_id)`` — the entity_id lets
callers trace which sketch entity produced each segment.
Tessellation density: roughly 12 segments per π radians of arc sweep,
which gives smooth-looking closed loops for face detection.
"""
segs: List[Tuple[Tuple[float, float], Tuple[float, float]]] = []
segs: List[Tuple[float, float, float, float, int]] = []
# ── Straight line segments ──
for line_id, (sid, eid2) in self._lines.items():
@@ -1855,8 +2103,9 @@ class OCCSketch(SketchInterface):
if s_ent and e_ent and s_ent.geometry and e_ent.geometry:
segs.append(
(
(float(s_ent.geometry[0]), float(s_ent.geometry[1])),
(float(e_ent.geometry[0]), float(e_ent.geometry[1])),
float(s_ent.geometry[0]), float(s_ent.geometry[1]),
float(e_ent.geometry[0]), float(e_ent.geometry[1]),
line_id,
)
)
@@ -1891,7 +2140,7 @@ class OCCSketch(SketchInterface):
a2 = start_angle + t2 * sweep
p1 = (cx + radius * math.cos(a1), cy + radius * math.sin(a1))
p2 = (cx + radius * math.cos(a2), cy + radius * math.sin(a2))
segs.append((p1, p2))
segs.append((p1[0], p1[1], p2[0], p2[1], arc_id))
return segs
@@ -1899,8 +2148,8 @@ class OCCSketch(SketchInterface):
"""Detect closed loops: polygon cycles from connected lines + each circle.
Each loop is one of:
{"type": "polygon", "points": [(x,y), ...]} (closed, last == first)
{"type": "circle", "center": (x,y), "radius": r}
{"type": "polygon", "points": [(x,y), ...], "entity_ids": [int, ...]} (closed, last == first)
{"type": "circle", "center": (x,y), "radius": r, "entity_ids": [int]}
Line endpoint coordinates are snapped to ``_SNAP_TOL`` so a closed
rectangle's four corners join into one cycle even after solver floating
point jitter. Only connected components where every node has degree 2
@@ -1916,11 +2165,13 @@ class OCCSketch(SketchInterface):
reprs: Dict[Any, Tuple[float, float]] = {} # key -> averaged world pt
edges: List[Tuple[Any, Any]] = []
for p1, p2 in segs:
k1, k2 = key(p1), key(p2)
reprs.setdefault(k1, p1)
reprs.setdefault(k2, p2)
edge_eids: Dict[Tuple[Any, Any], int] = {} # (k1,k2) -> entity_id
for x1, y1, x2, y2, eid in segs:
k1, k2 = key((x1, y1)), key((x2, y2))
reprs.setdefault(k1, (x1, y1))
reprs.setdefault(k2, (x2, y2))
edges.append((k1, k2))
edge_eids[(k1, k2) if k1 < k2 else (k2, k1)] = eid
# Undirected adjacency.
adj: Dict[Any, List[Any]] = {}
@@ -1947,8 +2198,9 @@ class OCCSketch(SketchInterface):
if nb not in comp_seen:
stack.append(nb)
if all(len(adj[n]) == 2 for n in comp) and len(comp) >= 3:
# Order the cycle by following each node's neighbor not yet visited.
# Order the cycle by following each node's neighbour not yet visited.
ordered: List[Any] = []
eids: List[int] = []
cur = comp[0]
prev = None
for _ in range(len(comp)):
@@ -1956,16 +2208,26 @@ class OCCSketch(SketchInterface):
nbrs = [nb for nb in adj[cur] if nb != prev]
if not nbrs:
break
ekey = (cur, nbrs[0]) if cur < nbrs[0] else (nbrs[0], cur)
if ekey in edge_eids:
eids.append(edge_eids[ekey])
prev = cur
cur = nbrs[0]
if len(ordered) == len(comp):
pts = [reprs[k] for k in ordered]
pts.append(pts[0])
loops.append({"type": "polygon", "points": pts})
loops.append(
{"type": "polygon", "points": pts, "entity_ids": sorted(set(eids))}
)
seen |= comp_seen
# Circles are closed loops of their own.
for cid, (center_id, r) in self._circles.items():
circle_ent = self._entities.get(cid)
if circle_ent is not None and circle_ent.is_construction:
continue
if cid in self._external_entity_ids:
continue
c_ent = self._entities.get(center_id)
if c_ent and c_ent.geometry and r > 0:
loops.append(
@@ -1973,6 +2235,7 @@ class OCCSketch(SketchInterface):
"type": "circle",
"center": (float(c_ent.geometry[0]), float(c_ent.geometry[1])),
"radius": float(r),
"entity_ids": [cid],
}
)
return loops
@@ -2123,8 +2386,7 @@ class OCCSketch(SketchInterface):
that is the rectangle minus the circle — exactly the
"shape within a shape = closed without inner" behavior. A shape nested
inside a hole (depth 2) becomes its own solid face again.
Returns a list of ``{"outer": loop, "holes": [loop, ...], "depth": int}``.
Returns a list of ``{"outer": loop, "holes": [loop, ...], "depth": int, "entity_ids": [int, ...]}``.
"""
loops = self.get_closed_loops()
if not loops:
@@ -2148,7 +2410,11 @@ class OCCSketch(SketchInterface):
# directly nested: depth one greater, and outer contains inner.
if depths[j] == depths[i] + 1 and OCCSketch._loop_contains(inner, outer):
holes.append(inner)
faces.append({"outer": outer, "holes": holes, "depth": depths[i]})
# Face entity_ids = union of outer + hole loop entity_ids.
eids = set(outer.get("entity_ids", []))
for h in holes:
eids.update(h.get("entity_ids", []))
faces.append({"outer": outer, "holes": holes, "depth": depths[i], "entity_ids": sorted(eids)})
return faces
def find_face_at(self, x: float, y: float) -> Optional[Dict[str, Any]]:
@@ -2585,6 +2851,9 @@ class OCCSketch(SketchInterface):
"end": tuple(e_ent.geometry),
"radius": radius_val,
"sweep": sweep_val,
# Tracked-only underlay arcs must not be
# re-registered with the solver on load.
"in_solver": bool(arc_data.get("in_solver", True)),
}
entities_payload.append(
{
@@ -2801,12 +3070,30 @@ class OCCSketch(SketchInterface):
if c_id is None or s_id is None or e_id is None:
logger.warning("Skipping arc %s during load: endpoints not found", eid)
return
in_solver = bool(geom.get("in_solver", True))
if "in_solver" not in geom:
# Old files: an arc whose three reference points are
# all external is an underlay arc — re-registering it
# over-constrains the solver (inconsistent).
c_ent_r = entities_by_id.get(c_id)
s_ent_r = entities_by_id.get(s_id)
e_ent_r = entities_by_id.get(e_id)
if (
c_ent_r is not None
and s_ent_r is not None
and e_ent_r is not None
and getattr(c_ent_r, "is_external", False)
and getattr(s_ent_r, "is_external", False)
and getattr(e_ent_r, "is_external", False)
):
in_solver = False
ent = self.add_arc(
entities_by_id[c_id],
radius,
entities_by_id[s_id],
entities_by_id[e_id],
sweep=sweep,
register=in_solver,
)
else:
logger.warning("Unknown sketch entity type %r; skipping", etype)
+265 -8
View File
@@ -43,9 +43,12 @@ from fluency.models.data_model import (
Body,
Component,
Connector,
DrawingAnnotation,
DrawingView,
Feature,
Project,
Sketch,
TechnicalDrawing,
Workplane,
)
from fluency.geometry_occ.kernel import OCCGeometryObject, OCGeometryKernel
@@ -219,10 +222,22 @@ def _feature_to_dict(feat: Feature) -> Dict[str, Any]:
"cut_all_bodies": bool(feat.cut_all_bodies),
"face_index": feat.face_index,
"angle": _to_float(feat.angle, 360.0),
"axis": [float(v) for v in (feat.axis or (0, 0, 1))],
"origin": [float(v) for v in (feat.origin or (0.0, 0.0, 0.0))],
"axis_line_id": feat.axis_line_id,
"radius": feat.radius,
"tangent_propagation": bool(feat.tangent_propagation),
"scope": feat.scope,
"edge_refs": list(feat.edge_refs),
"face_keys": json.dumps(feat.face_keys) if feat.face_keys is not None else None,
"face_keys_sketch_id": feat.face_keys_sketch_id,
"pattern_type": feat.pattern_type,
"count": feat.count,
"spacing": feat.spacing,
"direction": [float(v) for v in (feat.direction or (1, 0, 0))],
"mirror_plane_origin": [float(v) for v in (feat.mirror_plane_origin or (0.0, 0.0, 0.0))],
"mirror_plane_normal": [float(v) for v in (feat.mirror_plane_normal or (1, 0, 0))],
"keep_original": bool(feat.keep_original),
}
@@ -239,11 +254,33 @@ def _feature_from_dict(data: Dict[str, Any], sketches: Dict[str, Sketch]) -> Fea
cut_all_bodies=bool(data.get("cut_all_bodies", False)),
face_index=data.get("face_index"),
angle=_to_float(data.get("angle"), 360.0),
axis=tuple(float(v) for v in (data.get("axis") or (0, 0, 1))),
origin=tuple(float(v) for v in (data.get("origin") or (0.0, 0.0, 0.0))),
axis_line_id=data.get("axis_line_id"),
radius=data.get("radius"),
tangent_propagation=bool(data.get("tangent_propagation", False)),
scope=data.get("scope", "selected"),
edge_refs=list(data.get("edge_refs") or []),
face_keys=None,
face_keys_sketch_id=data.get("face_keys_sketch_id"),
pattern_type=data.get("pattern_type", "linear"),
count=int(data.get("count") or 2),
spacing=_to_float(data.get("spacing"), 10.0),
direction=tuple(float(v) for v in (data.get("direction") or (1, 0, 0))),
mirror_plane_origin=tuple(float(v) for v in (data.get("mirror_plane_origin") or (0.0, 0.0, 0.0))),
mirror_plane_normal=tuple(float(v) for v in (data.get("mirror_plane_normal") or (1, 0, 0))),
keep_original=bool(data.get("keep_original", True)),
)
# Deserialize face_keys from JSON if present.
fk_raw = data.get("face_keys")
if fk_raw and isinstance(fk_raw, str):
try:
feat.face_keys = json.loads(fk_raw)
except (json.JSONDecodeError, TypeError):
pass
elif isinstance(fk_raw, list):
feat.face_keys = fk_raw
sid = data.get("sketch_id")
if sid and sid in sketches:
feat.sketch = sketches[sid]
@@ -458,6 +495,8 @@ def _connector_to_dict(conn: Connector) -> Dict[str, Any]:
"offset": _to_float(conn.offset, 0.0),
"assembly_component_id": conn.assembly_component_id,
"source_obj_id": conn.source_obj_id,
"entity_type": conn.entity_type,
"normal_flip": bool(conn.normal_flip),
"partner_ac_id": conn.partner_ac_id,
"partner_connector_id": conn.partner_connector_id,
"is_grounded": bool(conn.is_grounded),
@@ -478,6 +517,8 @@ def _connector_from_dict(data: Dict[str, Any]) -> Connector:
offset=_to_float(data.get("offset"), 0.0),
assembly_component_id=data.get("assembly_component_id", ""),
source_obj_id=data.get("source_obj_id", ""),
entity_type=data.get("entity_type", ""),
normal_flip=bool(data.get("normal_flip", False)),
)
conn.partner_ac_id = data.get("partner_ac_id")
conn.partner_connector_id = data.get("partner_connector_id")
@@ -496,12 +537,22 @@ def _assembly_component_to_dict(ac: AssemblyComponent) -> Dict[str, Any]:
"position": _coerce_listlike(ac.position),
"rotation": _coerce_listlike(ac.rotation),
"connectors": {cid: _connector_to_dict(c) for cid, c in ac.connectors.items()},
# Instance-local sketches + per-body modifier ops (kept apart from
# the shared component so save/load never mutates the base model).
"sketches": {sid: _sketch_to_dict(sk) for sid, sk in ac.sketches.items()},
"modifiers": {
bid: [_feature_to_dict(f) for f in mods]
for bid, mods in ac.modifiers.items()
},
"created_at": ac.created_at.isoformat() if ac.created_at else None,
"modified_at": ac.modified_at.isoformat() if ac.modified_at else None,
}
def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
def _assembly_component_from_dict(
data: Dict[str, Any],
component: Optional[Component] = None,
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
) -> AssemblyComponent:
ac = AssemblyComponent(
id=_saved_id(data),
component_id=data.get("component_id", ""),
@@ -513,6 +564,36 @@ def _assembly_component_from_dict(data: Dict[str, Any]) -> AssemblyComponent:
ac.modified_at = _parse_iso(data.get("modified_at"))
for cid, c_data in (data.get("connectors") or {}).items():
ac.connectors[cid] = _connector_from_dict(c_data)
# Instance-local sketches first, so modifier sketch references can
# resolve against them (they live in component-local coordinates).
for sid, sk_data in (data.get("sketches") or {}).items():
try:
ac.sketches[sid] = _sketch_from_dict(sk_data, sketch_geometry_loader)
except Exception:
logger.warning("Skipping corrupt instance sketch %s", sid)
# Modifiers resolve sketch refs against the owning component's sketches
# first, then this instance's own sketches.
registry: Dict[str, Sketch] = {}
if component is not None:
registry.update(component.sketches)
registry.update(ac.sketches)
for bid, f_list in (data.get("modifiers") or {}).items():
kept: List[Feature] = []
for f_data in f_list or []:
try:
feat = _feature_from_dict(f_data, registry)
except Exception:
logger.warning("Skipping corrupt instance modifier on body %s", bid)
continue
# A sketch-based op whose sketch failed to load can never
# replay — dropping it keeps the rest of the chain usable.
if feat.operation in ("extrude", "cut", "union", "revolve") and feat.sketch is None:
continue
kept.append(feat)
if kept:
ac.modifiers[bid] = kept
return ac
@@ -550,8 +631,11 @@ def _assembly_to_dict(asm: Assembly) -> Dict[str, Any]:
"modified_at": asm.modified_at.isoformat() if asm.modified_at else None,
}
def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
def _assembly_from_dict(
data: Dict[str, Any],
components: Optional[Dict[str, Component]] = None,
sketch_geometry_loader: Optional[Callable[[str], Optional[OCCGeometryObject]]] = None,
) -> Assembly:
asm = Assembly(
id=_saved_id(data),
name=data.get("name", "Untitled Assembly"),
@@ -560,12 +644,133 @@ def _assembly_from_dict(data: Dict[str, Any]) -> Assembly:
asm.created_at = _parse_iso(data.get("created_at"))
asm.modified_at = _parse_iso(data.get("modified_at"))
for cid, ac_data in (data.get("components") or {}).items():
asm.components[cid] = _assembly_component_from_dict(ac_data)
comp = (components or {}).get(ac_data.get("component_id", ""))
asm.components[cid] = _assembly_component_from_dict(
ac_data, component=comp, sketch_geometry_loader=sketch_geometry_loader
)
for c_data in data.get("connections") or []:
asm.connections.append(_assembly_connection_from_dict(c_data))
return asm
def _drawing_view_to_dict(v: DrawingView) -> Dict[str, Any]:
return {
"id": v.id,
"kind": v.kind,
"name": v.name,
"direction": _coerce_listlike(v.direction),
"up_vector": _coerce_listlike(v.up_vector),
"show_hidden_lines": bool(v.show_hidden_lines),
"show_centerlines": bool(v.show_centerlines),
"scale": _to_float(v.scale, 1.0),
"sheet_origin": _coerce_listlike(v.sheet_origin),
}
def _drawing_view_from_dict(data: Dict[str, Any]) -> DrawingView:
view = DrawingView(
id=_saved_id(data),
kind=data.get("kind", "front"),
name=data.get("name"),
direction=_to_3tuple(data.get("direction")) or None,
up_vector=_to_3tuple(data.get("up_vector")) or None,
show_hidden_lines=bool(data.get("show_hidden_lines", False)),
show_centerlines=bool(data.get("show_centerlines", False)),
scale=_to_float(data.get("scale"), 1.0),
)
origin = data.get("sheet_origin")
if isinstance(origin, (list, tuple)) and len(origin) >= 2:
view.sheet_origin = (_to_float(origin[0]), _to_float(origin[1]))
return view
def _drawing_annotation_to_dict(a: DrawingAnnotation) -> Dict[str, Any]:
return {
"id": a.id,
"kind": a.kind,
"text": a.text,
"visible": bool(a.visible),
"references": list(a.references),
"sheet_position": _coerce_listlike(a.sheet_position),
"view_id": a.view_id,
"anchors": [list(pt) for pt in a.anchors],
"dimension_kind": a.dimension_kind,
"direction": _coerce_listlike(a.direction),
}
def _drawing_annotation_from_dict(data: Dict[str, Any]) -> DrawingAnnotation:
ann = DrawingAnnotation(
id=_saved_id(data),
kind=data.get("kind", "dimension"),
text=data.get("text"),
visible=bool(data.get("visible", True)),
references=list(data.get("references") or []),
view_id=data.get("view_id", ""),
dimension_kind=data.get("dimension_kind", ""),
)
pos = data.get("sheet_position")
if isinstance(pos, (list, tuple)) and len(pos) >= 2:
ann.sheet_position = (_to_float(pos[0]), _to_float(pos[1]))
anchors = data.get("anchors") or []
for pt in anchors:
if isinstance(pt, (list, tuple)) and len(pt) >= 2:
ann.anchors.append((_to_float(pt[0]), _to_float(pt[1])))
direction = data.get("direction")
if isinstance(direction, (list, tuple)) and len(direction) >= 2:
ann.direction = (_to_float(direction[0]), _to_float(direction[1]))
return ann
def _technical_drawing_to_dict(d: TechnicalDrawing) -> Dict[str, Any]:
return {
"id": d.id,
"name": d.name,
"source_kind": d.source_kind,
"source_id": d.source_id,
"views": [_drawing_view_to_dict(v) for v in d.views],
"annotations": [_drawing_annotation_to_dict(a) for a in d.annotations],
"title": d.title,
"part_number": d.part_number,
"material": d.material,
"revision": d.revision,
"notes": d.notes,
"sheet_size": d.sheet_size,
"units": d.units,
"auto_dimensions": bool(d.auto_dimensions),
"auto_views": bool(d.auto_views),
"created_at": d.created_at.isoformat() if d.created_at else None,
"modified_at": d.modified_at.isoformat() if d.modified_at else None,
}
def _technical_drawing_from_dict(data: Dict[str, Any]) -> TechnicalDrawing:
drawing = TechnicalDrawing(
id=_saved_id(data),
name=data.get("name", "Untitled Drawing"),
source_kind=data.get("source_kind", "component"),
source_id=data.get("source_id", ""),
title=data.get("title", ""),
part_number=data.get("part_number", ""),
material=data.get("material", ""),
revision=data.get("revision", ""),
notes=data.get("notes", ""),
# The renderer only supports the A4 ISO 5457 sheet; normalize
# legacy drawings (stored as A3) instead of surfacing stale sizes.
sheet_size="A4",
units=data.get("units", "mm"),
auto_dimensions=bool(data.get("auto_dimensions", False)),
auto_views=bool(data.get("auto_views", True)),
)
drawing.created_at = _parse_iso(data.get("created_at"))
drawing.modified_at = _parse_iso(data.get("modified_at"))
for v_data in data.get("views") or []:
drawing.views.append(_drawing_view_from_dict(v_data))
for a_data in data.get("annotations") or []:
drawing.annotations.append(_drawing_annotation_from_dict(a_data))
return drawing
def _project_to_dict(
project: Project,
view_state: Optional[Dict[str, Any]] = None,
@@ -578,6 +783,7 @@ def _project_to_dict(
"active_assembly": project.active_assembly,
"components": {cid: _component_to_dict(c) for cid, c in project.components.items()},
"assemblies": {aid: _assembly_to_dict(a) for aid, a in project.assemblies.items()},
"drawings": [_technical_drawing_to_dict(d) for d in project.drawings],
"created_at": project.created_at.isoformat() if project.created_at else None,
"modified_at": project.modified_at.isoformat() if project.modified_at else None,
"view_state": view_state or {},
@@ -738,6 +944,40 @@ def save_project(
sketch_files.append((arcname, step_bytes))
manifest["components"][comp_id]["sketches"][sketch_id]["geometry_ref"] = arcname
# Instance-local sketches (assembly components) get the same sidecar
# treatment as component sketches; the manifest nodes are patched in
# place under the assembly's component entry.
for asm_id, asm in project.assemblies.items():
for ac_id, ac in asm.components.items():
for sketch_id, sketch in ac.sketches.items():
node = manifest["assemblies"][asm_id]["components"][ac_id]["sketches"].get(sketch_id)
if node is None:
continue
occ = sketch.occ_sketch.to_dict() if sketch.occ_sketch is not None else None
meta = {
"id": sketch.id,
"name": sketch.name,
"workplane_origin": _coerce_listlike(sketch.workplane_origin),
"workplane_normal": _coerce_listlike(sketch.workplane_normal),
"workplane_x_dir": _coerce_listlike(sketch.workplane_x_dir),
"is_solved": bool(sketch.is_solved),
"is_fully_constrained": bool(sketch.is_fully_constrained),
"occ_sketch": occ,
}
meta_arc = f"sketches/{sketch_id}/meta.json"
sketch_meta_files.append((meta_arc, _to_json(meta).encode("utf-8")))
node["occ_sketch"] = None
node["occ_sketch_ref"] = meta_arc
if sketch.geometry is None:
continue
step_bytes = _write_step_for_body(kernel, sketch.geometry)
if step_bytes is None:
continue
arcname = f"sketches/{sketch_id}/solved.step"
sketch_files.append((arcname, step_bytes))
node["geometry_ref"] = arcname
# Write the ZIP. Use a temp file + rename so a partial write can't
# clobber an existing good file.
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".fluency")
@@ -803,8 +1043,9 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
# If a sketch's occ_sketch is referenced as a separate file, read
# it in now and patch the manifest so _sketch_from_dict sees it.
for comp_id, comp_data in (manifest.get("components") or {}).items():
for sk_id, sk_data in (comp_data.get("sketches") or {}).items():
# Applies to both component sketches and instance-local sketches.
def _patch_sketch_sidecars(sketches_dict: Dict[str, Any]) -> None:
for sk_id, sk_data in (sketches_dict or {}).items():
ref = sk_data.get("occ_sketch_ref")
if not ref:
continue
@@ -831,6 +1072,12 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
if k in meta:
sk_data[k] = meta[k]
for comp_id, comp_data in (manifest.get("components") or {}).items():
_patch_sketch_sidecars(comp_data.get("sketches"))
for aid, a_data in (manifest.get("assemblies") or {}).items():
for ac_id, ac_data in (a_data.get("components") or {}).items():
_patch_sketch_sidecars(ac_data.get("sketches"))
project = Project(
name=manifest.get("name", "Untitled Project"),
description=manifest.get("description", ""),
@@ -850,7 +1097,17 @@ def load_project(filepath: str) -> Tuple[Project, Dict[str, Any]]:
)
for aid, a_data in (manifest.get("assemblies") or {}).items():
project.assemblies[aid] = _assembly_from_dict(a_data)
project.assemblies[aid] = _assembly_from_dict(
a_data,
components=project.components,
sketch_geometry_loader=sketch_geometry_loader,
)
for d_data in manifest.get("drawings") or []:
try:
project.drawings.append(_technical_drawing_from_dict(d_data))
except Exception as exc:
logger.warning("Skipping corrupt drawing in archive: %s", exc)
# After all components are loaded, re-wire connector partner ids so
# they point to the freshly-loaded AssemblyComponents. (The dict
+2
View File
@@ -24,6 +24,7 @@ from fluency.ui.dialogs import (
ExtrudeDialog,
OffsetDialog,
RevolveDialog,
ThreadDialog,
WorkplaneOrientationDialog,
)
from fluency.ui.main_window import MainWindow
@@ -37,6 +38,7 @@ __all__ = [
"ExtrudeDialog",
"RevolveDialog",
"OffsetDialog",
"ThreadDialog",
"WorkplaneOrientationDialog",
"main",
]
+240 -33
View File
@@ -226,9 +226,24 @@ class Feature:
- "fillet": round a set of edges of the running geometry
(``radius``, ``tangent_propagation``, ``scope``,
``edge_refs`` see below)
- "chamfer": bevel a set of edges of the running geometry
(same fields as "fillet"; ``radius`` holds the
chamfer size)
- "mirror": mirror the running solid across a plane defined by
``mirror_plane_origin`` and ``mirror_plane_normal``.
When ``keep_original`` is True (the default), the
original solid is boolean-union'd with its mirror
- "array" / "pattern": repeat the running solid in a linear or
circular array. ``pattern_type`` is "linear" or
"circular"; ``count`` is the total number of items
(original + copies). Linear uses ``direction``
(unit vector) and ``spacing`` (mm between adjacent
items); circular reuses ``axis`` + ``origin`` for the
rotation axis and ``angle`` for the total angular
span in degrees (copies evenly distributed).
- "base": frozen geometry snapshot (``geometry`` field) used
to migrate legacy bodies whose original base feature
is unknown. Never the result of a user operation.
to migrate legacy bodies whose original base feature
is unknown. Never the result of a user operation.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
@@ -242,20 +257,47 @@ class Feature:
cut_all_bodies: bool = False
face_index: Optional[int] = None # which sketch face was selected
angle: float = 360.0 # revolve only (degrees)
axis: Tuple[float, float, float] = (0, 0, 1) # revolve only: unit axis vector
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0) # revolve only: axis point
axis_line_id: Optional[int] = None # revolve only: sketch line entity used as axis
# "base" features only: frozen pre-feature geometry snapshot.
geometry: Optional[OCCGeometryObject] = None
# "fillet" features only: radius (mm) of the round, whether the fillet
# should extend along edges tangent to the picked ones, the edge scope
# ("selected" = edges between the two picked faces, "all" = every edge
# of the body), and stable fingerprints of the selected edges so the
# replay can re-find them after the base geometry is rebuilt.
# "fillet" / "chamfer" features only: radius (mm) of the round or the
# chamfer size, whether the op should extend along edges tangent to the
# picked ones, the edge scope ("selected" = edges between the two
# picked faces, "all" = every edge of the body), and stable
# fingerprints of the selected edges so the replay can re-find them
# after the base geometry is rebuilt.
radius: Optional[float] = None
tangent_propagation: bool = False
scope: str = "selected"
edge_refs: List[str] = field(default_factory=list)
# FaceKey references for fillet/chamfer: stable face classification
# that survives sketch dimension changes. ``face_keys`` is a list of
# (FaceKey, FaceKey) pairs — one pair per user-picked face;
# ``face_keys_sketch_id`` tracks which sketch produced the body these
# faces belong to.
face_keys: Optional[List[Tuple[Dict[str, Any], Dict[str, Any]]]] = None
face_keys_sketch_id: Optional[str] = None
# "array" / "pattern" features only: repeat the running solid.
# ``pattern_type`` is "linear" or "circular"; ``count`` is the total
# number of items including the original. Linear arrays use
# ``direction`` (unit vector) and ``spacing`` (mm); circular arrays
# reuse ``axis`` / ``origin`` / ``angle`` (total angular span, deg).
pattern_type: str = "linear"
count: int = 2
spacing: float = 10.0
direction: Tuple[float, float, float] = (1.0, 0.0, 0.0)
# "mirror" feature only: mirror plane definition.
mirror_plane_origin: Tuple[float, float, float] = (0.0, 0.0, 0.0)
mirror_plane_normal: Tuple[float, float, float] = (1.0, 0.0, 0.0)
keep_original: bool = True
created_at: datetime = field(default_factory=datetime.now)
@@ -445,6 +487,16 @@ class Connector:
assembly_component_id: str = ""
# Which body/face this connector was placed on (renderer obj_id).
source_obj_id: str = ""
# Entity class the connector was picked on ("planar_face",
# "cylindrical_face", "edge", "vertex"). Used to re-locate the
# connector on rebuilt geometry: only features of the same class are
# considered, so a hole connector can never jump onto a flat face.
# Empty for legacy files (all classes are then searched).
entity_type: str = ""
# Flip chosen in the placement dialog (bolt enters from the opposite
# side). Re-applied when a mated pair is re-solved so the original
# mate pose is reproduced exactly.
normal_flip: bool = False
# --- Rigid-group pairing (set when two connectors are mated) ---
# The id of the partner AssemblyComponent this connector is mated to.
@@ -464,6 +516,16 @@ class Connector:
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
def __post_init__(self) -> None:
# Legacy files predate the entity_type field: recover it from the
# auto-generated connector name ("Conn cylindrical_face anchor") so
# relocation can restrict its search to the same feature class.
if not self.entity_type:
for t in ("cylindrical_face", "planar_face", "edge", "vertex"):
if self.name in (f"Conn {t} anchor", f"Conn {t} mover"):
self.entity_type = t
break
@dataclass
class AssemblyComponent:
@@ -487,6 +549,20 @@ class AssemblyComponent:
# Connectors defined on this component instance.
connectors: Dict[str, Connector] = field(default_factory=dict)
# Instance-local (per-instantiation) state. Kept separate from the
# shared component so per-instance work never leaks back into the base
# model. ``sketches`` are instance-local sketches stored in
# component-local coordinates (so they stay valid when the instance is
# moved / rotated); ``modifiers`` maps body_id to an ordered list of
# Feature ops applied ON TOP of the live component body feature history
# when the instance geometry is rebuilt.
sketches: Dict[str, Sketch] = field(default_factory=dict)
modifiers: Dict[str, List[Feature]] = field(default_factory=dict)
# Runtime-only cache of rebuilt instance geometry (body_id -> geometry).
# Never serialized; invalidated on component updates and modifier edits.
geom_cache: Dict[str, Any] = field(default_factory=dict, repr=False)
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
@@ -497,6 +573,8 @@ class AssemblyComponent:
x_dir: Tuple[float, float, float],
source_obj_id: str = "",
name: Optional[str] = None,
entity_type: str = "",
normal_flip: bool = False,
) -> Connector:
"""Add a connector to this component instance."""
conn = Connector(
@@ -506,6 +584,8 @@ class AssemblyComponent:
x_dir=x_dir,
assembly_component_id=self.id,
source_obj_id=source_obj_id,
entity_type=entity_type,
normal_flip=normal_flip,
)
self.connectors[conn.id] = conn
self.modified_at = datetime.now()
@@ -519,6 +599,45 @@ class AssemblyComponent:
return True
return False
def add_instance_sketch(self, sketch: Optional[Sketch] = None) -> Sketch:
"""Add an instance-local sketch (component-local coordinates)."""
if sketch is None:
sketch = Sketch(name=f"Instance Sketch {len(self.sketches) + 1}")
self.sketches[sketch.id] = sketch
self.modified_at = datetime.now()
return sketch
def remove_instance_sketch(self, sketch_id: str) -> bool:
"""Remove an instance sketch and every modifier that references it."""
if sketch_id not in self.sketches:
return False
del self.sketches[sketch_id]
for body_id in list(self.modifiers.keys()):
kept = [
f for f in self.modifiers[body_id]
if not (f.sketch is not None and f.sketch.id == sketch_id)
]
if kept:
self.modifiers[body_id] = kept
else:
del self.modifiers[body_id]
self.geom_cache.pop(body_id, None)
self.modified_at = datetime.now()
return True
def add_modifier(self, body_id: str, feat: Feature) -> Feature:
"""Append a modifier op to *body_id*'s instance-local history."""
self.modifiers.setdefault(body_id, []).append(feat)
self.geom_cache.pop(body_id, None)
self.modified_at = datetime.now()
return feat
def invalidate_geom_cache(self, body_id: Optional[str] = None) -> None:
"""Drop cached rebuilt instance geometry (one body, or all)."""
if body_id is None:
self.geom_cache.clear()
else:
self.geom_cache.pop(body_id, None)
@dataclass
class AssemblyConnection:
@@ -682,6 +801,10 @@ class Project:
assemblies: Dict[str, Assembly] = field(default_factory=dict)
active_assembly: Optional[str] = None
# Technical drawings keyed to their source component/assembly.
# Manual dimensions and view options added in the drawing workbench
# are persisted here so they survive save/load.
drawings: List["TechnicalDrawing"] = field(default_factory=list)
kernel: OCGeometryKernel = field(default_factory=OCGeometryKernel)
created_at: datetime = field(default_factory=datetime.now)
@@ -758,36 +881,21 @@ class Project:
"""Look up a component by id across all project components."""
return self.components.get(component_id)
def add_component(self, component: Optional[Component] = None) -> Component:
"""Add a component to the project."""
if component is None:
component = Component(name=f"Component {len(self.components) + 1}")
self.components[component.id] = component
if self.active_component is None:
self.active_component = component.id
self.modified_at = datetime.now()
return component
# ── Drawing helpers ──
def remove_component(self, component_id: str) -> bool:
"""Remove a component from the project."""
if component_id in self.components:
del self.components[component_id]
if self.active_component == component_id:
self.active_component = next(iter(self.components.keys()), None)
self.modified_at = datetime.now()
return True
return False
def get_active_component(self) -> Optional[Component]:
"""Get the currently active component."""
if self.active_component and self.active_component in self.components:
return self.components[self.active_component]
def get_drawing_for(self, source_kind: str, source_id: str) -> Optional["TechnicalDrawing"]:
"""Return the drawing bound to *(source_kind, source_id)*, if any."""
for drawing in self.drawings:
if drawing.source_kind == source_kind and drawing.source_id == source_id:
return drawing
return None
def set_active_component(self, component_id: Optional[str]) -> None:
"""Set the active component."""
self.active_component = component_id
def add_drawing(self, drawing: "TechnicalDrawing") -> "TechnicalDrawing":
"""Register *drawing* with the project and return it."""
self.drawings.append(drawing)
self.modified_at = datetime.now()
return drawing
def export_step(self, filepath: str) -> bool:
"""Export all visible bodies to STEP."""
@@ -856,3 +964,102 @@ class Project:
for comp in self.components.values():
sketches.extend(comp.sketches.values())
return sketches
def compute_source_fingerprint(
self, source_kind: str, source_id: str
) -> str:
"""Compute a simple fingerprint for a source reference."""
import hashlib
data = f"{source_kind}:{source_id}"
return hashlib.sha256(data.encode()).hexdigest()[:16]
# ── Technical Drawing models ───────────────────────────────────────────────
@dataclass
class DrawingView:
"""One projected view in a technical drawing."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
kind: str = "front" # front, back, top, bottom, right, left, isometric, custom
name: Optional[str] = None # human-readable label; defaults from kind
# View direction and up vector in world coords (for custom views).
# Ignored when kind is one of the standard presets.
direction: Optional[Tuple[float, float, float]] = None
up_vector: Optional[Tuple[float, float, float]] = None
show_hidden_lines: bool = False
show_centerlines: bool = False
scale: float = 1.0
# Sheet position (mm from sheet origin) — set by layout engine.
sheet_origin: Tuple[float, float] = (0.0, 0.0)
@dataclass
class DrawingAnnotation:
"""One annotation (dimension or note) on a technical drawing."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
kind: str = "dimension" # dimension, note, tolerance, surface_finish, weld_symbol
text: Optional[str] = None
visible: bool = True
# References to DrawingCandidate keys this annotation is bound to.
references: List[str] = field(default_factory=list)
# Sheet position (mm). For dimensions, anchor point; for notes, placement.
sheet_position: Tuple[float, float] = (0.0, 0.0)
# Associated view id (empty means global/note block).
view_id: str = ""
# Manual-dimension geometry in view-plane model coordinates (model
# units in the view's projection plane). Populated for user-placed
# dimensions, empty for reference-based annotations:
# "length": (point_on_edge1, point_on_edge2) — closest points
# "diameter": (left, right) — antipodal points across the centre
# "angle": (vertex, arm1_point, arm2_point)
anchors: List[Tuple[float, float]] = field(default_factory=list)
# Sub-kind of the manual dimension: "length" | "diameter" | "angle".
# Empty for non-dimension annotations.
dimension_kind: str = ""
# Unit vector along the measured distance in view-plane coordinates
# (length dimensions only). The model→sheet transform is a uniform
# scale + translation, so the direction is valid in sheet space too.
direction: Optional[Tuple[float, float]] = None
@dataclass
class TechnicalDrawing:
"""A complete technical drawing definition."""
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = "Untitled Drawing"
# Source geometry reference.
source_kind: str = "component" # component, assembly
source_id: str = ""
views: List[DrawingView] = field(default_factory=list)
annotations: List[DrawingAnnotation] = field(default_factory=list)
# Title block metadata.
title: str = ""
part_number: str = ""
material: str = ""
revision: str = ""
notes: str = ""
# Sheet size (A0..A4 or custom mm). Default A4 (ISO 5457 template).
sheet_size: str = "A4"
units: str = "mm" # mm, in
# Auto-generation flags. Auto dimensions are opt-in: the drawing
# workbench shows them only while the user has the toggle enabled.
auto_dimensions: bool = False
auto_views: bool = True
created_at: datetime = field(default_factory=datetime.now)
modified_at: datetime = field(default_factory=datetime.now)
+634 -4
View File
@@ -97,6 +97,35 @@ def _compute_viewport_aligned_xdir(
return (1.0, 0.0, 0.0)
def _dist_point_segment_sq(px: float, py: float, a: Tuple[float, float], b: Tuple[float, float]) -> float:
"""Squared distance from point (px, py) to segment a→b in 2D screen space."""
ax, ay = a
bx, by = b
dx = bx - ax
dy = by - ay
if dx == 0.0 and dy == 0.0:
return (px - ax) ** 2 + (py - ay) ** 2
t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)
t = max(0.0, min(1.0, t))
cx = ax + t * dx
cy = ay + t * dy
return (px - cx) ** 2 + (py - cy) ** 2
def _point_in_poly(px: float, py: float, poly: List[Tuple[float, float]]) -> bool:
"""Ray-casting point-in-polygon test for a 2D polygon (list of (x, y))."""
inside = False
n = len(poly)
j = n - 1
for i in range(n):
xi, yi = poly[i]
xj, yj = poly[j]
if (yi > py) != (yj > py) and px < (xj - xi) * (py - yi) / (yj - yi + 1e-30) + xi:
inside = not inside
j = i
return inside
@dataclass
class OCCRenderObject(RenderObject):
"""Internal object state for the OCC renderer."""
@@ -128,11 +157,24 @@ class OCCRenderer(Renderer):
self._highlight_ais: Any = None
# Overlays for the fillet tool's two picked faces (one AIS per face).
self._faces_highlight_ais: List[Any] = []
# Hot-pink translucent overlay for operation-history highlighting.
self._op_highlight_ais: Any = None
# Temporary transparent preview AIS for the live extrude/cut dialog.
self._preview_ais: Any = None
# Smart entity picker gizmo objects (snap markers, axis lines, rings).
# Keyed by a synthetic id; values are raw AIS_InteractiveObject.
self._gizmo_objects: Dict[str, Any] = {}
# Persistent connector gizmo objects (first pick) not cleared by hover.
self._persistent_gizmo_objects: Dict[str, Any] = {}
# World-anchored sketch reference gizmo (a triad at the sketch
# midpoint): part kind ("center" / "axis_x" / … / "plane_xy" …) →
# dict {"ais": [AIS…], "color": rgb, "pick": descriptor}.
self._sketch_gizmo_parts: Dict[str, Any] = {}
# Part kind currently highlighted on hover (for restore-on-leave).
self._sketch_gizmo_highlighted: Optional[str] = None
# Cache for shape classification to avoid re-classifying same OCC sub-shapes
# during repeated probe/hover calls. Key = (id(shape), owner_obj_id).
self._classify_cache: dict = {}
def initialize(self, parent_widget: Any) -> bool:
"""Initialise OCC viewer inside *parent_widget* (a QWidget)."""
@@ -298,10 +340,16 @@ class OCCRenderer(Renderer):
shape: Any,
color: Optional[Tuple[float, float, float]] = None,
name: Optional[str] = None,
auto_fit: bool = True,
) -> str:
"""Display an OCC ``TopoDS_Shape`` directly via ``AIS_Shape``.
Returns a unique object ID (or *name* if provided).
With *auto_fit* (default), the first object added to an empty
scene triggers a camera fit. Pass ``auto_fit=False`` when
rebuilding a scene under explicit camera control (e.g. the
assembly view), so the rebuild doesn't move the camera.
"""
from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
@@ -361,7 +409,7 @@ class OCCRenderer(Renderer):
self._objects[obj_id] = robj
# Fit camera on first shape added.
if len(self._objects) == 1:
if auto_fit and len(self._objects) == 1:
try:
self.fit_camera()
except Exception:
@@ -508,6 +556,16 @@ class OCCRenderer(Renderer):
return False
return True
def set_color(
self, obj_id: str, color: Tuple[float, float, float]
) -> bool:
"""Set the colour of an object by ID. Returns True on success."""
obj = self._objects.get(obj_id)
if obj is None:
return False
self.set_object_color(obj, color)
return True
# ─── Live preview (extrude/cut preview) ──────────────────────────────
_PREVIEW_ID = "__extrude_preview__"
@@ -573,7 +631,10 @@ class OCCRenderer(Renderer):
return
self.clear_preview()
self.clear_face_highlight()
self.clear_operation_highlight()
self.clear_entity_gizmo()
# The sketch reference gizmo is scene-anchored — drop it with the rest.
self.remove_sketch_gizmo()
# Remove every displayed AIS object. ``RemoveAll`` is the cleanest
# path; fall back to iterating the displayed list if unavailable.
try:
@@ -784,6 +845,59 @@ class OCCRenderer(Renderer):
self._view.FitAll(margin)
self._view.ZFitAll()
def fit_camera_to_box(self, bnd_box: Any, padding: float = 0.05) -> None:
"""Fit the camera to a specific bounding box (``Bnd_Box``).
Used e.g. by the array tool: as the dialog opens, the camera
frames exactly the space the array copies will occupy (rather
than the whole scene). Falls back to a no-op when the view is
not initialised or the box is empty.
"""
if self._view is None or bnd_box is None:
return
try:
if bnd_box.IsVoid():
return
margin = max(0.0, min(padding, 0.99))
self._view.FitAll(bnd_box, margin)
self._view.ZFitAll()
self._view.Redraw()
except Exception:
logger.warning("fit_camera_to_box failed", exc_info=True)
def box_fully_visible(self, bnd_box: Any, margin: float = 0.05) -> bool:
"""True when all 8 corners of *bnd_box* project inside the viewport.
Used by the array dialog live preview: when the user grows the
pattern (more repeats / bigger spacing) the copies can slide out
of view, and the host re-fits the camera. Unknown / unprojectable
corners count as NOT visible so the host refits.
"""
if self._view is None or bnd_box is None or bnd_box.IsVoid():
return True
xmin, ymin, zmin, xmax, ymax, zmax = bnd_box.Get()
w, h = self.get_screen_size()
if w <= 0 or h <= 0:
return True
pad = margin * min(w, h)
corners = (
(xmin, ymin, zmin),
(xmax, ymin, zmin),
(xmin, ymax, zmin),
(xmax, ymax, zmin),
(xmin, ymin, zmax),
(xmax, ymin, zmax),
(xmin, ymax, zmax),
(xmax, ymax, zmax),
)
for cx, cy, cz in corners:
px = self._project_to_screen((float(cx), float(cy), float(cz)))
if px is None:
return False
if px[0] < -pad or px[0] > w + pad or px[1] < -pad or px[1] > h + pad:
return False
return True
def set_view_orientation(self, orientation: str = "iso") -> None:
"""Snap the camera to a standard CAD view.
@@ -1295,6 +1409,60 @@ class OCCRenderer(Renderer):
logger.debug("clear_faces_highlight remove failed", exc_info=True)
self._faces_highlight_ais = []
# ─── Operation history highlight ─────────────────────────────────────
def highlight_operation_shape(
self, shape: Any, color: Tuple[float, float, float] = (1.0, 0.08, 0.58)
) -> None:
"""Overlay a translucent hot-pink *shape* on the 3D view.
Used to show the intermediate geometry at a selected operation in
the feature-history list. The overlay is an independent
``AIS_Shape`` with polygon offset so it draws on top of the
coincident body surface without z-fighting. Replaces any previous
operation highlight.
"""
if self._context is None:
return
self.clear_operation_highlight()
from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
ais = AIS_Shape(shape)
try:
ais.SetMaterial(self._default_material())
except Exception:
logger.debug("op highlight material set failed", exc_info=True)
ais.SetColor(Quantity_Color(*color, Quantity_TOC_RGB))
ais.SetDisplayMode(1) # shaded
try:
ais.SetTransparency(0.2)
except Exception:
logger.debug("op highlight transparency set failed", exc_info=True)
try:
ais.SetSelectability(0)
except Exception:
logger.debug("op highlight selectability set failed", exc_info=True)
try:
ais.SetPolygonOffsets(3, 1.0, -0.5)
except Exception:
logger.debug("op highlight polygon offset failed", exc_info=True)
self._context.Display(ais, False)
self._op_highlight_ais = ais
if self._view is not None:
self._view.Redraw()
def clear_operation_highlight(self) -> None:
"""Remove the operation-history overlay, if any."""
if self._context is None or self._op_highlight_ais is None:
return
try:
self._context.Remove(self._op_highlight_ais, True)
except Exception:
logger.debug("clear_operation_highlight remove failed", exc_info=True)
self._op_highlight_ais = None
# ─── General entity picking (for assembly connectors / snaps) ───────────
def pick_entity(self, x: int, y: int) -> Optional[Dict[str, Any]]:
@@ -1357,6 +1525,10 @@ class OCCRenderer(Renderer):
"""
if shape is None:
return []
# Cache lookup
cache_key = (id(shape), owner_obj_id)
if cache_key in self._classify_cache:
return self._classify_cache[cache_key]
from OCP.TopoDS import TopoDS
from OCP.BRepAdaptor import BRepAdaptor_Surface, BRepAdaptor_Curve
@@ -1418,7 +1590,7 @@ class OCCRenderer(Renderer):
# x_dir: viewport-aligned so connector gizmo matches screen.
x_dir = _compute_viewport_aligned_xdir((nx, ny, nz), self._view)
return [
res = [
{
"type": "planar_face",
"position": origin,
@@ -1428,6 +1600,8 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id,
}
]
self._classify_cache[cache_key] = res
return res
elif stype == GeomAbs_Cylinder:
cyl = adaptor.Cylinder()
@@ -1535,6 +1709,7 @@ class OCCRenderer(Renderer):
"radius": radius,
}
)
self._classify_cache[cache_key] = results
return results
# Try edge.
@@ -1578,7 +1753,7 @@ class OCCRenderer(Renderer):
x = x / xlen
x_dir = (float(x[0]), float(x[1]), float(x[2]))
return [
res = [
{
"type": "edge",
"position": position,
@@ -1588,6 +1763,8 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id,
}
]
self._classify_cache[cache_key] = res
return res
# Try vertex.
vertex = None
@@ -1595,7 +1772,7 @@ class OCCRenderer(Renderer):
vertex = TopoDS.Vertex_s(shape)
p = BRep_Tool.Pnt_s(vertex)
position = (p.X(), p.Y(), p.Z())
return [
res = [
{
"type": "vertex",
"position": position,
@@ -1605,9 +1782,12 @@ class OCCRenderer(Renderer):
"owner_obj_id": owner_obj_id,
}
]
self._classify_cache[cache_key] = res
return res
except Exception:
pass
self._classify_cache[cache_key] = []
return []
def probe_snap_candidates(
@@ -1792,6 +1972,341 @@ class OCCRenderer(Renderer):
if had_any and self._view is not None:
self._view.Update()
# ─── World Sketch Reference Gizmo (triad at the sketch midpoint) ────────
#
# A selectable X/Y/Z triad rendered IN the 3D world at the midpoint of
# the active sketch (in sync with the sketch's workplane frame). Parts:
# • center — small white sphere at the triad origin
# • axis_x / axis_y / axis_z — coloured shaft + arrow cone
# • plane_xy / plane_yz / plane_xz — translucent squares
# Picking is done geometrically in screen space (no OCC selection system
# involvement), so it works even while connector/assembly modes have the
# selection modes deactivated.
# Base colour per part kind (plane colours double as the boundary colour).
_SKETCH_GIZMO_COLORS: Dict[str, Tuple[float, float, float]] = {
"center": (1.0, 1.0, 1.0),
"axis_x": (0.95, 0.25, 0.22),
"axis_y": (0.25, 0.85, 0.30),
"axis_z": (0.25, 0.45, 1.0),
"plane_xy": (0.55, 0.85, 1.0),
"plane_yz": (1.0, 0.62, 0.80),
"plane_xz": (0.68, 1.0, 0.70),
}
_SKETCH_GIZMO_HIGHLIGHT: Tuple[float, float, float] = (1.0, 1.0, 0.30)
def show_sketch_gizmo(
self,
origin: Tuple[float, float, float],
normal: Tuple[float, float, float],
x_dir: Tuple[float, float, float],
size: float = 40.0,
) -> None:
"""Display the world-space reference triad at *origin*.
*origin* is typically the midpoint of the active sketch's geometry;
the triad is aligned to the sketch's workplane frame (x_dir → red
X axis, normal blue Z axis, normal × x_dir green Y axis).
*size* is the axis shaft length in world units (the caller should
size it relative to the sketch, e.g. a fraction of the sketch
diagonal, so it stays proportional to the model at any zoom).
Replaces any previously shown triad.
"""
self.remove_sketch_gizmo()
if self._context is None or self._view is None:
return
import numpy as np
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2
from OCP.BRepBuilderAPI import (
BRepBuilderAPI_MakeEdge,
BRepBuilderAPI_MakePolygon,
BRepBuilderAPI_MakeFace,
)
from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeCone
from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
# Orthonormal frame.
n = np.asarray(normal, dtype=float)
nn = np.linalg.norm(n)
if nn < 1e-9:
n = np.array([0.0, 0.0, 1.0])
else:
n = n / nn
u = np.asarray(x_dir, dtype=float)
u = u - np.dot(u, n) * n
un = np.linalg.norm(u)
if un < 1e-9:
fb = np.array([1.0, 0.0, 0.0]) if abs(n[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
u = fb - np.dot(fb, n) * n
un = np.linalg.norm(u)
u = u / un
v = np.cross(n, u)
o = np.asarray(origin, dtype=float)
# World-anchored size: proportional to the sketch, zooms with it.
L = float(size)
if L < 1e-6:
return
def _vec(a) -> Tuple[float, float, float]:
return (float(a[0]), float(a[1]), float(a[2]))
def _ais(shape, color, transparency=None, display_mode=1) -> Any:
ais = AIS_Shape(shape)
ais.SetColor(Quantity_Color(*color, Quantity_TOC_RGB))
if transparency is not None:
ais.SetTransparency(transparency)
ais.SetDisplayMode(display_mode)
self._context.Display(ais, True)
return ais
def _add_axis(kind: str, direction, color) -> None:
d = direction
tip = o + L * d
shaft = _ais(
BRepBuilderAPI_MakeEdge(gp_Pnt(*_vec(o)), gp_Pnt(*_vec(tip))).Edge(),
color,
display_mode=0,
)
# Arrow cone with apex at the tip.
cone_h = 0.22 * L
cone_r = 0.09 * L
base = tip - cone_h * d
ax2 = gp_Ax2(gp_Pnt(*_vec(base)), gp_Dir(*_vec(d)))
cone = _ais(
BRepPrimAPI_MakeCone(ax2, cone_r, 0.0, cone_h).Shape(),
color,
display_mode=1,
)
self._sketch_gizmo_parts[kind] = {
"ais": [shaft, cone],
"color": color,
"pick": {"type": "segment", "start": _vec(o), "end": _vec(tip)},
}
_add_axis("axis_x", u, self._SKETCH_GIZMO_COLORS["axis_x"])
_add_axis("axis_y", v, self._SKETCH_GIZMO_COLORS["axis_y"])
_add_axis("axis_z", n, self._SKETCH_GIZMO_COLORS["axis_z"])
# Center sphere.
center_color = self._SKETCH_GIZMO_COLORS["center"]
center_ais = _ais(
BRepPrimAPI_MakeSphere(gp_Pnt(*_vec(o)), 0.12 * L).Shape(),
center_color,
display_mode=1,
)
self._sketch_gizmo_parts["center"] = {
"ais": [center_ais],
"color": center_color,
"pick": {"type": "point", "pos": _vec(o)},
}
def _add_plane(kind: str, normal_dir, a_dir, b_dir, color) -> None:
"""Translucent square in the plane (a_dir, b_dir), nudged along its
normal so it sits slightly off the origin; spans [0.4L, 0.7L]."""
offset = 0.03 * L * normal_dir
corners = []
for sa in (0.40, 0.70):
for sb in (0.40, 0.70):
corners.append(o + offset + sa * L * a_dir + sb * L * b_dir)
# CCW order when viewed from +normal_dir (so the face faces out).
ordered = [corners[0], corners[1], corners[3], corners[2]]
poly = BRepBuilderAPI_MakePolygon()
for c in ordered:
poly.Add(gp_Pnt(*_vec(c)))
poly.Close()
wire = poly.Wire()
face_ais = _ais(
BRepBuilderAPI_MakeFace(wire, True).Face(),
color,
transparency=0.62,
display_mode=1,
)
wire_ais = _ais(wire, color, display_mode=0)
center_p = o + offset + 0.55 * L * a_dir + 0.55 * L * b_dir
self._sketch_gizmo_parts[kind] = {
"ais": [face_ais, wire_ais],
"color": color,
"pick": {
"type": "quad",
"corners": [_vec(c) for c in ordered],
"center": _vec(center_p),
"normal": _vec(normal_dir),
},
}
_add_plane("plane_xy", n, u, v, self._SKETCH_GIZMO_COLORS["plane_xy"])
_add_plane("plane_yz", u, v, n, self._SKETCH_GIZMO_COLORS["plane_yz"])
_add_plane("plane_xz", v, u, n, self._SKETCH_GIZMO_COLORS["plane_xz"])
self._view.Update()
def update_sketch_gizmo(
self,
origin: Tuple[float, float, float],
normal: Tuple[float, float, float],
x_dir: Tuple[float, float, float],
size: float = 40.0,
) -> None:
"""Rebuild the triad in place — used when the sketch midpoint moves."""
self.show_sketch_gizmo(origin, normal, x_dir, size)
def remove_sketch_gizmo(self) -> None:
"""Remove the world sketch triad, if any."""
if self._context is not None:
for part in self._sketch_gizmo_parts.values():
for ais in part.get("ais", []):
try:
self._context.Remove(ais, True)
except Exception:
pass
had = bool(self._sketch_gizmo_parts)
self._sketch_gizmo_parts.clear()
self._sketch_gizmo_highlighted = None
if had and self._view is not None:
self._view.Update()
def pick_sketch_gizmo(
self, x: int, y: int, tolerance: float = 18.0
) -> Optional[str]:
"""Return the gizmo part under screen pixel (x, y), or None.
Nearest-part hit test in screen space (the centre sphere wins when
the cursor is close to the triad origin). Returns a kind string:
"center", "axis_x", "axis_y", "axis_z", "plane_xy", "plane_yz"
or "plane_xz".
"""
if not self._sketch_gizmo_parts or self._view is None:
return None
tol = float(tolerance)
# Centre point takes priority when the cursor is near the origin.
center = self._sketch_gizmo_parts.get("center")
if center is not None:
pick = center.get("pick")
if pick is not None and pick.get("type") == "point":
sp = self._project_to_screen(pick["pos"])
if sp is not None:
d2 = (sp[0] - x) ** 2 + (sp[1] - y) ** 2
if d2 <= (tol * 0.8) ** 2:
return "center"
best: Optional[str] = None
best_d2 = tol * tol
for kind, part in self._sketch_gizmo_parts.items():
if kind == "center":
continue
pick = part.get("pick")
if not pick:
continue
ptype = pick.get("type")
if ptype == "segment":
s = self._project_to_screen(pick["start"])
e = self._project_to_screen(pick["end"])
if s is None or e is None:
continue
d2 = _dist_point_segment_sq(x, y, s, e)
elif ptype == "quad":
pts = []
ok = True
for c in pick["corners"]:
sp = self._project_to_screen(c)
if sp is None:
ok = False
break
pts.append(sp)
if not ok:
continue
if _point_in_poly(x, y, pts):
d2 = 0.0
else:
d2 = min(
_dist_point_segment_sq(x, y, pts[i], pts[(i + 1) % 4])
for i in range(4)
)
else:
continue
if d2 <= best_d2:
best_d2 = d2
best = kind
return best
def sketch_gizmo_pick_info(self, kind: str) -> Optional[Dict[str, Any]]:
"""World-space pick metadata for a part kind (position + direction).
For axes the direction is the axis unit vector; for planes it is the
plane normal; for the centre it is the zero vector. Position is the
triad origin for axes/centre and the quad centre for planes.
"""
part = self._sketch_gizmo_parts.get(kind)
if part is None:
return None
pick = part.get("pick") or {}
ptype = pick.get("type")
if ptype == "point":
return {"position": pick["pos"], "direction": (0.0, 0.0, 0.0)}
if ptype == "segment":
s = np.asarray(pick["start"], dtype=float)
e = np.asarray(pick["end"], dtype=float)
d = e - s
norm = np.linalg.norm(d)
if norm < 1e-9:
direction = (0.0, 0.0, 0.0)
else:
d = d / norm
direction = (float(d[0]), float(d[1]), float(d[2]))
return {"position": pick["start"], "direction": direction}
if ptype == "quad":
return {
"position": pick["center"],
"direction": pick.get("normal") or (0.0, 0.0, 1.0),
}
return None
def highlight_sketch_gizmo_part(self, kind: str) -> None:
"""Tint a gizmo part yellow to show it is under the cursor."""
if kind not in self._sketch_gizmo_parts:
self.clear_sketch_gizmo_highlight()
return
if self._sketch_gizmo_highlighted == kind:
return
self.clear_sketch_gizmo_highlight()
part = self._sketch_gizmo_parts[kind]
if self._context is None:
return
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
for ais in part.get("ais", []):
try:
ais.SetColor(Quantity_Color(*self._SKETCH_GIZMO_HIGHLIGHT, Quantity_TOC_RGB))
self._context.Redisplay(ais, True)
except Exception:
pass
self._sketch_gizmo_highlighted = kind
if self._view is not None:
self._view.Update()
def clear_sketch_gizmo_highlight(self) -> None:
"""Restore every gizmo part to its base colour."""
if self._context is None:
self._sketch_gizmo_highlighted = None
return
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
changed = self._sketch_gizmo_highlighted is not None
for part in self._sketch_gizmo_parts.values():
for ais in part.get("ais", []):
try:
ais.SetColor(Quantity_Color(*part["color"], Quantity_TOC_RGB))
self._context.Redisplay(ais, True)
except Exception:
pass
self._sketch_gizmo_highlighted = None
if changed and self._view is not None:
self._view.Update()
def show_entity_gizmo(
self,
entity_type: str,
@@ -1975,6 +2490,114 @@ class OCCRenderer(Renderer):
if self._view is not None:
self._view.Update()
def show_persistent_entity_gizmo(
self,
entity_type: str,
position: Tuple[float, float, float],
normal: Optional[Tuple[float, float, float]] = None,
x_dir: Optional[Tuple[float, float, float]] = None,
radius: Optional[float] = None,
color: Tuple[float, float, float] = (0.0, 1.0, 0.0),
) -> None:
"""Display a persistent green gizmo for a confirmed first connector pick.
Unlike show_entity_gizmo, this does not clear the hover gizmo and stores
its AIS objects in _persistent_gizmo_objects so they survive hover updates.
"""
if self._context is None:
return
# Clear previous persistent gizmo
self.clear_persistent_entity_gizmo()
gizmo_scale = self._get_gizmo_scale(position)
from OCP.gp import gp_Pnt, gp_Dir, gp_Ax2, gp_Circ
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
from OCP.AIS import AIS_Shape
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
from OCP.BRepPrimAPI import BRepPrimAPI_MakeSphere
def _store(obj, key):
self._context.Display(obj, True)
self._persistent_gizmo_objects[key] = obj
def _make_sphere(p, c, size):
try:
s = BRepPrimAPI_MakeSphere(gp_Pnt(*p), size).Shape()
a = AIS_Shape(s)
a.SetColor(Quantity_Color(*c, Quantity_TOC_RGB))
a.SetDisplayMode(1)
_store(a, f"__pg_sphere_{id(a)}")
except Exception as exc:
logger.debug(f"persistent gizmo sphere failed: {exc}")
px, py, pz = position
_make_sphere(position, color, 5.6 * gizmo_scale)
axis_length = 30.0 * gizmo_scale
def _make_axis_line(origin, direction, length, line_color, label):
try:
dx, dy, dz = direction
norm = (dx*dx + dy*dy + dz*dz) ** 0.5
if norm < 1e-9:
return
ux, uy, uz = dx/norm, dy/norm, dz/norm
ex = origin[0] + ux * length
ey = origin[1] + uy * length
ez = origin[2] + uz * length
edge = BRepBuilderAPI_MakeEdge(gp_Pnt(*origin), gp_Pnt(ex, ey, ez)).Edge()
ais = AIS_Shape(edge)
ais.SetColor(Quantity_Color(*line_color, Quantity_TOC_RGB))
ais.SetDisplayMode(0)
_store(ais, f"__pg_{label}_{id(ais)}")
except Exception as exc:
logger.debug(f"persistent gizmo axis failed: {exc}")
if entity_type == "planar_face" and normal is not None:
_make_axis_line(position, normal, axis_length, (1.0, 1.0, 1.0), "normal")
if x_dir is not None:
_make_axis_line(position, x_dir, axis_length * 0.6, color, "xdir")
elif entity_type == "cylindrical_face" and normal is not None:
_make_axis_line(position, normal, axis_length * 1.4, (1.0, 1.0, 1.0), "axis_in")
_make_axis_line(position, (-normal[0], -normal[1], -normal[2]), axis_length * 0.4, (0.6, 0.6, 0.6), "axis_stub")
if x_dir is not None:
_make_axis_line(position, x_dir, radius or (axis_length * 0.5), color, "radial")
# ring
if radius is not None:
try:
center = gp_Pnt(px, py, pz)
ax2 = gp_Ax2(center, gp_Dir(*normal))
circ = gp_Circ(ax2, radius)
ring_edge = BRepBuilderAPI_MakeEdge(circ).Edge()
ring_ais = AIS_Shape(ring_edge)
ring_ais.SetColor(Quantity_Color(*color, Quantity_TOC_RGB))
ring_ais.SetDisplayMode(0)
_store(ring_ais, f"__pg_ring_{id(ring_ais)}")
except Exception as exc:
logger.debug(f"persistent gizmo ring failed: {exc}")
elif entity_type == "edge" and normal is not None:
_make_axis_line(position, normal, axis_length, color, "tangent")
elif entity_type == "vertex":
_make_axis_line(position, (1,0,0), axis_length * 0.5, (1.0,0.3,0.3), "cross_x")
_make_axis_line(position, (0,1,0), axis_length * 0.5, (0.3,1.0,0.3), "cross_y")
_make_axis_line(position, (0,0,1), axis_length * 0.5, (0.3,0.3,1.0), "cross_z")
if self._view is not None:
self._view.Update()
def clear_persistent_entity_gizmo(self) -> None:
"""Remove the persistent first-pick gizmo."""
if self._context is None:
return
for obj in list(self._persistent_gizmo_objects.values()):
try:
self._context.Erase(obj, True)
except Exception:
pass
self._persistent_gizmo_objects.clear()
if self._view is not None:
self._view.Update()
# ─── Selection mode control ───────────────────────────────────────────
#
# When connector gizmo mode is active, standard OCC face/edge/vertex
@@ -2211,6 +2834,13 @@ class OCCRenderer(Renderer):
(c.get("screen", (x, y))[0] - x) ** 2 + (c.get("screen", (x, y))[1] - y) ** 2
)
)
# Early exit if we already found a very close candidate — avoids unnecessary work.
if results:
best = results[0]
best_sp = best.get("screen", (x, y))
best_dist2 = (best_sp[0] - x) ** 2 + (best_sp[1] - y) ** 2
if best_dist2 <= 25: # within 5 px
return [best]
return results
def recognize_composite_features(
+17 -7
View File
@@ -187,19 +187,29 @@ class SolverSketch(SolverSystem):
def constrain_distance(
self, entity_a, entity_b, distance: float
) -> bool:
"""Constrain distance between point-point or point-line."""
"""Constrain distance between point-point or point-line.
python-solvespace's ``distance`` accepts (point, line) ordering only,
so line-point pairs are normalised; line-line pairs and a point
constrained to itself are rejected (the solver cannot hold them).
"""
try:
handle_a = entity_a.handle if isinstance(entity_a, SolverPoint) else entity_a.handle
handle_b = entity_b.handle if isinstance(entity_b, SolverPoint) else entity_b.handle
if isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
logger.warning("distance: line-to-line distance is not supported")
return False
if isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
# Normalise to (point, line) ordering.
entity_a, entity_b = entity_b, entity_a
if entity_a is entity_b and distance != 0.0:
logger.warning("distance: refusing point-to-itself constraint")
return False
handle_a = entity_a.handle
handle_b = entity_b.handle
if isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverLine):
self.distance(handle_a, handle_b, distance, self.wp)
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverPoint):
self.distance(handle_b, handle_a, distance, self.wp)
elif isinstance(entity_a, SolverPoint) and isinstance(entity_b, SolverPoint):
self.distance(handle_a, handle_b, distance, self.wp)
elif isinstance(entity_a, SolverLine) and isinstance(entity_b, SolverLine):
self.distance(handle_a, handle_b, distance, self.wp)
else:
logger.warning(f"distance: unsupported types {type(entity_a)}, {type(entity_b)}")
return False
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
"""Tests for the A4 ISO 5457 sheet: layout regions, first-angle view
ordering, sheet furniture primitives, scale formatting, and the A4
normalisation of drawings loaded from .fluency files.
"""
import os
import sys
import unittest
# Allow running this file directly: ``python tests/test_iso5457_sheet.py``.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src"))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from fluency.models.data_model import DrawingView, TechnicalDrawing
from fluency.technical_drawing import (
_SHEET_HEIGHT_MM,
_SHEET_WIDTH_MM,
_format_drawing_scale,
_layout_views_on_sheet,
_sheet_frame_primitives,
)
def _view(kind):
return DrawingView(kind=kind, name=kind)
class TestSheetSize(unittest.TestCase):
"""The workbench renders a single A4 landscape sheet."""
def test_a4_dimensions(self):
self.assertEqual((_SHEET_WIDTH_MM, _SHEET_HEIGHT_MM), (297.0, 210.0))
def test_default_drawing_is_a4(self):
self.assertEqual(TechnicalDrawing().sheet_size, "A4")
class TestScaleFormatting(unittest.TestCase):
"""Title-block scale field formatting (``N : 1`` / ``1 : N``)."""
def test_none_and_one(self):
self.assertEqual(_format_drawing_scale(None), "1 : 1")
self.assertEqual(_format_drawing_scale(0.0), "1 : 1")
self.assertEqual(_format_drawing_scale(1.0), "1 : 1")
def test_enlarged(self):
self.assertEqual(_format_drawing_scale(2.0), "2 : 1")
self.assertEqual(_format_drawing_scale(1.5), "1.5 : 1")
def test_reduced(self):
self.assertEqual(_format_drawing_scale(0.5), "1 : 2")
self.assertEqual(_format_drawing_scale(0.33333), "1 : 3")
class TestFirstAngleLayout(unittest.TestCase):
"""View slots stay inside the ISO 5457 drawing-space frame, clear the
title-block reserve zone, and follow first-angle ordering (view from
the right left of front, view from the left right of front, top view
below front)."""
def _slots(self, kinds):
bboxes = {k: (0.0, 0.0, 40.0, 30.0) for k in kinds}
slots, scale, _rots = _layout_views_on_sheet(
[_view(k) for k in kinds], bboxes
)
return slots, scale
def test_slots_inside_frame(self):
slots, scale = self._slots(
("front", "top", "right", "left", "isometric")
)
self.assertIsNotNone(scale)
self.assertGreater(scale, 0)
for vid, (x, y, w, h) in slots.items():
# Drawing-space frame: 20..287 x 10..200.
self.assertGreaterEqual(x, 19.9, vid)
self.assertGreaterEqual(y, 9.9, vid)
self.assertLessEqual(x + w, 287.1, vid)
self.assertLessEqual(y + h, 200.1, vid)
# Title-block reserve zone: x >= 102, y <= 64.
self.assertTrue(
x + w <= 102.01 or y + h <= 0.01 or y >= 63.99,
(vid, (x, y, w, h)),
)
def test_first_angle_ordering(self):
slots, _ = self._slots(("front", "top", "right", "left"))
xf, wf = slots["front"][0], slots["front"][2]
yf = slots["front"][1]
# View from the right sits left of the front view.
self.assertLessEqual(
slots["right"][0] + slots["right"][2], xf + 0.01
)
# View from the left sits right of the front view.
self.assertGreaterEqual(slots["left"][0], xf + wf + 0.01)
# Top view sits below the front view.
self.assertLessEqual(slots["top"][1] + slots["top"][3], yf + 0.01)
def test_single_view_centered_upright(self):
slots, scale, rots = _layout_views_on_sheet(
[_view("front")], {"front": (0.0, 0.0, 40.0, 30.0)}
)
self.assertIsNotNone(scale)
self.assertEqual(rots.get("front", 0.0), 0.0)
self.assertGreater(scale, 0)
x, y, w, h = slots["front"]
self.assertGreaterEqual(x, 19.9)
self.assertGreaterEqual(y, 9.9)
class TestSheetFurniture(unittest.TestCase):
"""ISO 5457 furniture primitives mirror the reference SVG template."""
def _prims(self):
d = TechnicalDrawing(
title="Test Bracket",
part_number="PN-42",
material="Alu 6082",
revision="B",
)
return _sheet_frame_primitives(d, "1 : 2")
def test_all_geometry_inside_sheet(self):
for p in self._prims():
for (x, y) in p.points:
self.assertGreaterEqual(x, -0.01)
self.assertLessEqual(x, 297.01)
self.assertGreaterEqual(y, -0.01)
self.assertLessEqual(y, 210.01)
def test_drawing_space_frame(self):
# Frame rect 20,10 267x190: four edges present as line prims.
lines = [
(tuple(p.points[0]), tuple(p.points[1]))
for p in self._prims()
if p.kind == "line" and p.style == "frame"
]
for a, b in (
((20.0, 10.0), (287.0, 10.0)),
((287.0, 10.0), (287.0, 200.0)),
((287.0, 200.0), (20.0, 200.0)),
((20.0, 200.0), (20.0, 10.0)),
):
self.assertIn((a, b), lines)
def test_grid_reference_labels(self):
texts = [p.text for p in self._prims() if p.kind == "text" and p.text]
for t in ("1", "2", "3", "4", "5", "6", "A", "B", "C", "D", "A4"):
self.assertIn(t, texts)
def test_title_block_fields(self):
texts = [p.text for p in self._prims() if p.kind == "text" and p.text]
for expect in (
"Owner:",
"Drawing number:",
"Revision:",
"Issue date:",
"Sheet:",
"Language:",
"Title:",
"Approved by:",
"Created by:",
"Document type:",
"Part Material:",
"General tolerances:",
"Scale:",
"Test Bracket",
"PN-42",
"Alu 6082",
"B",
"1 : 2",
"EN",
"1 / 1",
"ISO 2768-m",
"Component Drawing",
):
self.assertIn(expect, texts)
def test_first_angle_projection_symbol(self):
# Two concentric circles at the symbol centre (273, 58) in sheet
# coords (SVG y-down 152 → 210 - 152 = 58).
circles = [
p
for p in self._prims()
if p.kind == "circle" and p.center == (273.0, 58.0)
]
self.assertEqual(len(circles), 2)
self.assertEqual(
sorted(c.radius for c in circles if c.radius), [2.5, 5.0]
)
class TestSheetSizeNormalisation(unittest.TestCase):
"""Drawings stored as A3 in .fluency files load as A4 — the renderer
only supports the A4 ISO 5457 sheet."""
def test_legacy_a3_loads_as_a4(self):
from fluency.io.project_io import _technical_drawing_from_dict
d = _technical_drawing_from_dict(
{
"id": "x",
"source_kind": "component",
"source_id": "c",
"sheet_size": "A3",
}
)
self.assertEqual(d.sheet_size, "A4")
if __name__ == "__main__":
unittest.main()
+43 -1
View File
@@ -13,16 +13,58 @@ import unittest
# Allow running this file directly: ``python tests/test_project_io.py``.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir, "src"))
from fluency.io.project_io import save_project, load_project
from fluency.io.project_io import save_project, load_project, _feature_to_dict, _feature_from_dict
from fluency.models.data_model import (
Project,
Component,
Body,
Sketch,
Workplane,
Assembly,
Feature,
)
class TestRevolveAxisSerialization(unittest.TestCase):
"""Revolve features persist their revolve axis across save/load."""
def test_axis_round_trip(self):
sk = Sketch()
feat = Feature(
operation="revolve",
angle=180.0,
axis=(1, 0, 0),
origin=(10.0, 5.0, 0.0),
axis_line_id=7,
)
feat.sketch = sk
data = _feature_to_dict(feat)
self.assertEqual(data["axis"], [1.0, 0.0, 0.0])
self.assertEqual(data["origin"], [10.0, 5.0, 0.0])
self.assertEqual(data["axis_line_id"], 7)
restored = _feature_from_dict(data, {sk.id: sk})
self.assertEqual(tuple(restored.axis), (1.0, 0.0, 0.0))
self.assertEqual(tuple(restored.origin), (10.0, 5.0, 0.0))
self.assertEqual(restored.axis_line_id, 7)
self.assertEqual(restored.angle, 180.0)
def test_default_axis(self):
"""Old files without axis fields fall back to Z axis at the origin."""
sk = Sketch()
data = _feature_to_dict(Feature(operation="revolve", angle=90.0))
restored = _feature_from_dict(data, {sk.id: sk})
self.assertEqual(tuple(restored.axis), (0, 0, 1))
self.assertEqual(tuple(restored.origin), (0.0, 0.0, 0.0))
self.assertIsNone(restored.axis_line_id)
# legacy file without the keys
del data["axis"]
del data["origin"]
del data["axis_line_id"]
restored = _feature_from_dict(data, {sk.id: sk})
self.assertEqual(tuple(restored.axis), (0, 0, 1))
self.assertEqual(tuple(restored.origin), (0.0, 0.0, 0.0))
class TestProjectIO(unittest.TestCase):
"""Round-trip the same project through save/load and check equivalence."""
+936 -3
View File
@@ -14,11 +14,13 @@ from PySide6.QtWidgets import (
QDoubleSpinBox,
QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QRadioButton,
QSpinBox,
QVBoxLayout,
QWidget,
)
@@ -162,12 +164,20 @@ class ExtrudeDialog(QDialog):
class RevolveDialog(QDialog):
"""Dialog for revolve options."""
"""Dialog for revolve options.
def __init__(self, parent: Optional[QWidget] = None):
``line_axis`` is ``(line_entity_id, origin, direction)`` from a line
selected in the sketch (see Sketch2DWidget.get_selected_revolve_axis).
When provided the dialog offers Selected sketch line as the revolve
axis (default); X / Y / Z world axes through the origin remain available
as a fallback.
"""
def __init__(self, parent: Optional[QWidget] = None, line_axis: Optional[Tuple] = None):
super().__init__(parent)
self.setWindowTitle("Revolve Options")
self.setMinimumWidth(300)
self.setMinimumWidth(340)
self._line_axis = line_axis # (line_id, origin, direction) or None
layout = QVBoxLayout(self)
@@ -181,6 +191,30 @@ class RevolveDialog(QDialog):
angle_layout.addWidget(self.angle_input)
layout.addLayout(angle_layout)
axis_layout = QHBoxLayout()
axis_layout.addWidget(QLabel("Axis:"))
self._axis_group = QButtonGroup(self)
self._axis_buttons = {}
for label, vec in (("X", (1, 0, 0)), ("Y", (0, 1, 0)), ("Z", (0, 0, 1))):
btn = QRadioButton(label)
btn.setProperty("axis", vec)
self._axis_group.addButton(btn)
self._axis_buttons[label] = btn
axis_layout.addWidget(btn)
if line_axis is not None:
self._line_button = QRadioButton("Selected line")
self._line_button.setToolTip(
"Revolve around the line selected in the sketch (origin = its start point)"
)
self._axis_group.addButton(self._line_button)
axis_layout.addWidget(self._line_button)
self._line_button.setChecked(True)
else:
self._line_button = None
self._axis_buttons["Z"].setChecked(True) # backward compatible default
axis_layout.addStretch()
layout.addLayout(axis_layout)
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
@@ -195,6 +229,25 @@ class RevolveDialog(QDialog):
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
def get_values(self) -> Tuple[float, Tuple[float, float, float], Tuple[float, float, float], bool]:
"""Return ``(angle_deg, axis_vector, origin, use_line)``.
``use_line`` is True when the revolve should use the sketch line that
was selected before the dialog opened (axis/origin from the line).
Otherwise axis is one of the X / Y / Z unit vectors and origin is
``(0, 0, 0)``.
"""
checked = self._axis_group.checkedButton()
if checked is not None and checked is self._line_button and self._line_axis is not None:
_, origin, direction = self._line_axis
return self.angle_input.value(), tuple(direction), tuple(origin), True
axis = (0, 0, 1)
if checked is not None:
vec = checked.property("axis")
if vec:
axis = tuple(float(v) for v in vec)
return self.angle_input.value(), axis, (0.0, 0.0, 0.0), False
class OffsetDialog(QDialog):
"""Dialog for 2D sketch offset options.
@@ -270,6 +323,564 @@ class OffsetDialog(QDialog):
return (self.distance_input.value(), self.inward_checkbox.isChecked())
class ArrayDialog(QDialog):
"""Dialog for the array (pattern) tool: linear or circular repeats.
Asks the user how many repeats (the total item count, original
included) plus the pattern geometry:
- **Linear**: spacing between adjacent items and the direction
(X / Y / Z presets or a custom 3D vector negative spacing
flips the direction).
- **Circular**: total angular span (default 360°) over which the
copies are evenly distributed, the rotation axis (X / Y / Z or
custom vector) and the axis origin point.
A live-preview callback (``set_preview_callback``) fires on every
change so the host can show the repeated copies plus a direction /
axis indicator in the 3D view before committing.
``get_values()`` returns a dict:
``{"pattern_type", "count", "spacing", "direction", "angle",
"axis", "origin"}``.
"""
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setWindowTitle("Array Options")
self.setMinimumWidth(400)
self._preview_callback: Optional[Callable[[Any], None]] = None
layout = QVBoxLayout(self)
# ── Pattern type ──
type_layout = QHBoxLayout()
type_layout.addWidget(QLabel("Pattern type:"))
self.linear_radio = QRadioButton("Linear")
self.linear_radio.setChecked(True)
self.circular_radio = QRadioButton("Circular")
self.type_group = QButtonGroup(self)
self.type_group.addButton(self.linear_radio)
self.type_group.addButton(self.circular_radio)
type_layout.addWidget(self.linear_radio)
type_layout.addWidget(self.circular_radio)
type_layout.addStretch()
layout.addLayout(type_layout)
# ── Repeats (shared by both types) ──
count_layout = QHBoxLayout()
count_layout.addWidget(QLabel("Repeats (items, incl. original):"))
self.count_input = QSpinBox()
self.count_input.setRange(1, 1000)
self.count_input.setValue(3)
self.count_input.setToolTip(
"Total number of items in the array, including the original body."
)
count_layout.addWidget(self.count_input)
count_layout.addStretch()
layout.addLayout(count_layout)
# ── Linear group ──
self.linear_group = QGroupBox("Linear Pattern")
lin = QVBoxLayout(self.linear_group)
spacing_layout = QHBoxLayout()
spacing_layout.addWidget(QLabel("Spacing (mm):"))
self.spacing_input = QDoubleSpinBox()
self.spacing_input.setDecimals(2)
self.spacing_input.setRange(-100000.0, 100000.0)
self.spacing_input.setValue(10.0)
self.spacing_input.setSingleStep(1.0)
self.spacing_input.setToolTip(
"Distance between adjacent items. Negative flips the direction."
)
spacing_layout.addWidget(self.spacing_input)
spacing_layout.addStretch()
lin.addLayout(spacing_layout)
lin.addWidget(QLabel("Direction:"))
dir_btns = QHBoxLayout()
self.dir_x_radio = QRadioButton("X")
self.dir_x_radio.setChecked(True)
self.dir_y_radio = QRadioButton("Y")
self.dir_z_radio = QRadioButton("Z")
self.dir_custom_radio = QRadioButton("Custom")
self.dir_group = QButtonGroup(self)
for b in (self.dir_x_radio, self.dir_y_radio, self.dir_z_radio, self.dir_custom_radio):
self.dir_group.addButton(b)
dir_btns.addWidget(b)
dir_btns.addStretch()
lin.addLayout(dir_btns)
self.dir_custom_row = QHBoxLayout()
self.dir_custom_row.addWidget(QLabel("Vector:"))
self.dir_x_input = self._vector_spin()
self.dir_y_input = self._vector_spin()
self.dir_z_input = self._vector_spin()
self.dir_custom_row.addWidget(self.dir_x_input)
self.dir_custom_row.addWidget(self.dir_y_input)
self.dir_custom_row.addWidget(self.dir_z_input)
self.dir_custom_row.addStretch()
lin.addLayout(self.dir_custom_row)
self._set_custom_enabled(self.dir_x_input, self.dir_y_input, self.dir_z_input, enabled=False)
layout.addWidget(self.linear_group)
# ── Circular group ──
self.circular_group = QGroupBox("Circular Pattern")
circ = QVBoxLayout(self.circular_group)
angle_layout = QHBoxLayout()
angle_layout.addWidget(QLabel("Total angle (°):"))
self.angle_input = QDoubleSpinBox()
self.angle_input.setDecimals(1)
self.angle_input.setRange(-3600.0, 3600.0)
self.angle_input.setValue(360.0)
self.angle_input.setSingleStep(15.0)
self.angle_input.setSuffix("°")
self.angle_input.setToolTip(
"Total angular span over which the copies are evenly distributed. "
"360° gives a full evenly-spaced ring (step = angle / count)."
)
angle_layout.addWidget(self.angle_input)
angle_layout.addStretch()
circ.addLayout(angle_layout)
circ.addWidget(QLabel("Rotation axis:"))
axis_btns = QHBoxLayout()
self.axis_z_radio = QRadioButton("Z")
self.axis_z_radio.setChecked(True)
self.axis_x_radio = QRadioButton("X")
self.axis_y_radio = QRadioButton("Y")
self.axis_custom_radio = QRadioButton("Custom")
self.axis_group = QButtonGroup(self)
for b in (self.axis_x_radio, self.axis_y_radio, self.axis_z_radio, self.axis_custom_radio):
self.axis_group.addButton(b)
axis_btns.addWidget(b)
axis_btns.addStretch()
circ.addLayout(axis_btns)
self.axis_custom_row = QHBoxLayout()
self.axis_custom_row.addWidget(QLabel("Vector:"))
self.axis_x_input = self._vector_spin()
self.axis_y_input = self._vector_spin()
self.axis_z_input = self._vector_spin()
self.axis_custom_row.addWidget(self.axis_x_input)
self.axis_custom_row.addWidget(self.axis_y_input)
self.axis_custom_row.addWidget(self.axis_z_input)
self.axis_custom_row.addStretch()
circ.addLayout(self.axis_custom_row)
self._set_custom_enabled(self.axis_x_input, self.axis_y_input, self.axis_z_input, enabled=False)
origin_layout = QHBoxLayout()
origin_layout.addWidget(QLabel("Axis origin (mm):"))
self.origin_x_input = self._vector_spin(-100000, 100000)
self.origin_y_input = self._vector_spin(-100000, 100000)
self.origin_z_input = self._vector_spin(-100000, 100000)
origin_layout.addWidget(self.origin_x_input)
origin_layout.addWidget(self.origin_y_input)
origin_layout.addWidget(self.origin_z_input)
origin_layout.addStretch()
circ.addLayout(origin_layout)
layout.addWidget(self.circular_group)
# ── Buttons ──
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line)
button_layout = QHBoxLayout()
ok_button = QPushButton("Apply Array")
ok_button.clicked.connect(self.accept)
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
# ── Signals / live preview ──
self.linear_radio.toggled.connect(self._on_type_changed)
self.circular_radio.toggled.connect(self._on_type_changed)
self.count_input.valueChanged.connect(self._emit_preview)
self.spacing_input.valueChanged.connect(self._emit_preview)
self.angle_input.valueChanged.connect(self._emit_preview)
self.dir_group.buttonToggled.connect(self._on_direction_changed)
self.axis_group.buttonToggled.connect(self._on_axis_changed)
for w in (
self.dir_x_input,
self.dir_y_input,
self.dir_z_input,
self.axis_x_input,
self.axis_y_input,
self.axis_z_input,
self.origin_x_input,
self.origin_y_input,
self.origin_z_input,
):
w.valueChanged.connect(self._emit_preview)
# ── Widget builders ──
@staticmethod
def _vector_spin(lo: float = -100000.0, hi: float = 100000.0) -> QDoubleSpinBox:
"""A compact spinbox for a vector component (axis/direction/origin)."""
spin = QDoubleSpinBox()
spin.setDecimals(2)
spin.setRange(lo, hi)
spin.setValue(0.0)
spin.setSingleStep(1.0)
spin.setFixedWidth(80)
return spin
@staticmethod
def _set_custom_enabled(*spins: QDoubleSpinBox, enabled: bool) -> None:
for s in spins:
s.setEnabled(enabled)
# ── State handling ──
def _on_type_changed(self) -> None:
"""Enable only the group matching the selected pattern type."""
linear = self.linear_radio.isChecked()
self.linear_group.setEnabled(linear)
self.circular_group.setEnabled(not linear)
self._emit_preview()
def _on_direction_changed(self, *args: Any) -> None:
custom = self.dir_custom_radio.isChecked()
self._set_custom_enabled(self.dir_x_input, self.dir_y_input, self.dir_z_input, enabled=custom)
self._emit_preview()
def _on_axis_changed(self, *args: Any) -> None:
custom = self.axis_custom_radio.isChecked()
self._set_custom_enabled(self.axis_x_input, self.axis_y_input, self.axis_z_input, enabled=custom)
self._emit_preview()
def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None:
"""Install the live-preview callback; fires immediately with defaults."""
self._preview_callback = callback
self._emit_preview()
def _emit_preview(self, *args: Any) -> None:
if self._preview_callback is None:
return
try:
self._preview_callback(self.get_values())
except Exception as exc:
logger.debug("array preview callback raised: %s", exc)
def hideEvent(self, event: Any) -> None:
if self._preview_callback is not None:
try:
self._preview_callback(None)
except Exception:
pass
super().hideEvent(event)
# ── Accessors ──
def _selected_vector(
self,
x_radio: QRadioButton,
y_radio: QRadioButton,
z_radio: QRadioButton,
custom_radio: QRadioButton,
x_in: QDoubleSpinBox,
y_in: QDoubleSpinBox,
z_in: QDoubleSpinBox,
) -> Tuple[float, float, float]:
"""Return the vector from the preset/custom radio selection."""
if x_radio.isChecked():
return (1.0, 0.0, 0.0)
if y_radio.isChecked():
return (0.0, 1.0, 0.0)
if z_radio.isChecked():
return (0.0, 0.0, 1.0)
if custom_radio.isChecked():
return (x_in.value(), y_in.value(), z_in.value())
return (1.0, 0.0, 0.0)
def get_values(self) -> Dict[str, Any]:
"""Return the current pattern parameters as a dict.
Keys: ``pattern_type``, ``count``, ``spacing``, ``direction``,
``angle``, ``axis``, ``origin``.
"""
linear = self.linear_radio.isChecked()
direction = self._selected_vector(
self.dir_x_radio,
self.dir_y_radio,
self.dir_z_radio,
self.dir_custom_radio,
self.dir_x_input,
self.dir_y_input,
self.dir_z_input,
)
axis = self._selected_vector(
self.axis_x_radio,
self.axis_y_radio,
self.axis_z_radio,
self.axis_custom_radio,
self.axis_x_input,
self.axis_y_input,
self.axis_z_input,
)
return {
"pattern_type": "linear" if linear else "circular",
"count": self.count_input.value(),
"spacing": self.spacing_input.value(),
"direction": direction,
"angle": self.angle_input.value(),
"axis": axis,
"origin": (
self.origin_x_input.value(),
self.origin_y_input.value(),
self.origin_z_input.value(),
),
}
class MirrorDialog(QDialog):
"""Dialog for the mirror tool: plane selection with live preview.
Mirrors the selected body across a plane. The user can pick a
plane from:
- A list of existing workplanes (combo box, passed by the caller)
- Six axis-aligned presets (XY, XZ, YZ and their negatives)
- Custom origin + normal enterable through spinboxes
``keep_original`` (checked by default) boolean-unions the original
solid with its mirror image so you get a symmetric part from half
the modeling work.
A live-preview callback (``set_preview_callback``) fires on every
change so the host can show the mirrored shape in the 3D view
before committing.
``get_values()`` returns a dict:
``{"mirror_plane_origin", "mirror_plane_normal", "keep_original"}``.
"""
def __init__(
self,
parent: Optional[QWidget] = None,
workplanes: Optional[list] = None,
):
super().__init__(parent)
self.setWindowTitle("Mirror Options")
self.setMinimumWidth(400)
self._preview_callback: Optional[Callable[[Any], None]] = None
self._workplanes: list = workplanes if workplanes is not None else []
# Preset plane definitions: (label, normal, origin)
self._presets: list = [
("XY (Top)", (0, 0, 1), (0, 0, 0)),
("XZ (Front)", (0, 1, 0), (0, 0, 0)),
("YZ (Right)", (1, 0, 0), (0, 0, 0)),
("-XY (Bottom)", (0, 0, -1), (0, 0, 0)),
("-XZ (Back)", (0, -1, 0), (0, 0, 0)),
("-YZ (Left)", (-1, 0, 0), (0, 0, 0)),
]
layout = QVBoxLayout(self)
# ── Plane source ──
src_layout = QHBoxLayout()
src_layout.addWidget(QLabel("Mirror plane:"))
self._wp_combo = QComboBox()
if self._workplanes:
for name, _origin, _normal in self._workplanes:
self._wp_combo.addItem(name)
else:
self._wp_combo.addItem("(no workplanes)")
self._wp_combo.setEnabled(False)
self._wp_combo.setToolTip("Pick an existing workplane as the mirror plane.")
src_layout.addWidget(self._wp_combo, 1)
layout.addLayout(src_layout)
# ── Preset radio buttons ──
preset_label = QLabel("Preset planes:")
layout.addWidget(preset_label)
self._preset_group = QButtonGroup(self)
preset_grid = QGridLayout()
for idx, (label, normal, origin) in enumerate(self._presets):
btn = QRadioButton(label)
btn.setChecked(idx == 0) # XY default
self._preset_group.addButton(btn, idx)
preset_grid.addWidget(btn, idx // 2, idx % 2)
layout.addLayout(preset_grid)
# ── Custom origin ──
line1 = QFrame()
line1.setFrameShape(QFrame.Shape.HLine)
line1.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line1)
origin_label = QLabel("Plane origin (mm):")
layout.addWidget(origin_label)
origin_row = QHBoxLayout()
origin_row.addWidget(QLabel("X:"))
self._origin_x = QDoubleSpinBox()
self._origin_x.setRange(-100000.0, 100000.0)
self._origin_x.setDecimals(2)
self._origin_x.setValue(0.0)
origin_row.addWidget(self._origin_x)
origin_row.addWidget(QLabel("Y:"))
self._origin_y = QDoubleSpinBox()
self._origin_y.setRange(-100000.0, 100000.0)
self._origin_y.setDecimals(2)
self._origin_y.setValue(0.0)
origin_row.addWidget(self._origin_y)
origin_row.addWidget(QLabel("Z:"))
self._origin_z = QDoubleSpinBox()
self._origin_z.setRange(-100000.0, 100000.0)
self._origin_z.setDecimals(2)
self._origin_z.setValue(0.0)
origin_row.addWidget(self._origin_z)
layout.addLayout(origin_row)
# ── Custom normal ──
normal_label = QLabel("Plane normal:")
layout.addWidget(normal_label)
normal_row = QHBoxLayout()
normal_row.addWidget(QLabel("X:"))
self._normal_x = QDoubleSpinBox()
self._normal_x.setRange(-1.0, 1.0)
self._normal_x.setSingleStep(1.0)
self._normal_x.setDecimals(0)
self._normal_x.setValue(0.0)
normal_row.addWidget(self._normal_x)
normal_row.addWidget(QLabel("Y:"))
self._normal_y = QDoubleSpinBox()
self._normal_y.setRange(-1.0, 1.0)
self._normal_y.setSingleStep(1.0)
self._normal_y.setDecimals(0)
self._normal_y.setValue(0.0)
normal_row.addWidget(self._normal_y)
normal_row.addWidget(QLabel("Z:"))
self._normal_z = QDoubleSpinBox()
self._normal_z.setRange(-1.0, 1.0)
self._normal_z.setSingleStep(1.0)
self._normal_z.setDecimals(0)
self._normal_z.setValue(1.0)
normal_row.addWidget(self._normal_z)
layout.addLayout(normal_row)
# ── Keep original ──
self._keep_original_cb = QCheckBox("Keep original")
self._keep_original_cb.setChecked(True)
self._keep_original_cb.setToolTip(
"When checked, the original solid is kept and union'd with its mirror image."
)
layout.addWidget(self._keep_original_cb)
# ── Buttons ──
line2 = QFrame()
line2.setFrameShape(QFrame.Shape.HLine)
line2.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line2)
btn_layout = QHBoxLayout()
ok_btn = QPushButton("Mirror")
ok_btn.clicked.connect(self.accept)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
btn_layout.addWidget(ok_btn)
btn_layout.addWidget(cancel_btn)
layout.addLayout(btn_layout)
# ── Wire signals ──
self._wp_combo.currentIndexChanged.connect(self._on_workplane_selected)
self._preset_group.buttonClicked.connect(self._on_preset_changed)
for w in (self._origin_x, self._origin_y, self._origin_z,
self._normal_x, self._normal_y, self._normal_z):
w.valueChanged.connect(self._emit_preview)
self._keep_original_cb.toggled.connect(self._emit_preview)
# Set initial values from the first preset (XY)
self._on_preset_changed(self._preset_group.button(0))
# ── Helpers ─────────────────────────────────────────────────────
def _on_workplane_selected(self, index: int) -> None:
"""Fill origin / normal from the selected workplane."""
if index < 0 or index >= len(self._workplanes):
return
_name, origin, normal = self._workplanes[index]
self._origin_x.setValue(float(origin[0]))
self._origin_y.setValue(float(origin[1]))
self._origin_z.setValue(float(origin[2]))
self._normal_x.setValue(float(normal[0]))
self._normal_y.setValue(float(normal[1]))
self._normal_z.setValue(float(normal[2]))
self._emit_preview()
def _on_preset_changed(self, btn: QRadioButton) -> None:
"""Fill origin / normal from the selected preset."""
idx = self._preset_group.id(btn)
if idx < 0 or idx >= len(self._presets):
return
_label, normal, origin = self._presets[idx]
self._origin_x.setValue(float(origin[0]))
self._origin_y.setValue(float(origin[1]))
self._origin_z.setValue(float(origin[2]))
self._normal_x.setValue(float(normal[0]))
self._normal_y.setValue(float(normal[1]))
self._normal_z.setValue(float(normal[2]))
self._emit_preview()
def _emit_preview(self) -> None:
"""Fire the preview callback (if installed) with current values."""
if self._preview_callback is None:
return
values = self.get_values()
self._preview_callback(values)
def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None:
"""Install a callback for live 3D preview of the mirrored shape.
The callback receives a ``dict`` with keys
``mirror_plane_origin``, ``mirror_plane_normal``, and
``keep_original``, or *None* to clear the preview.
"""
self._preview_callback = callback
if callback is not None:
self._emit_preview()
def get_values(self) -> Dict[str, Any]:
"""Return the current mirror parameters as a dict.
Keys: ``mirror_plane_origin`` (3-tuple), ``mirror_plane_normal``
(3-tuple, auto-normalized), ``keep_original`` (bool).
"""
normal = (
self._normal_x.value(),
self._normal_y.value(),
self._normal_z.value(),
)
# Auto-normalize the normal vector (zero-vector → +Z).
import math as _math
nx, ny, nz = normal
norm = _math.sqrt(nx * nx + ny * ny + nz * nz)
if norm < 1e-12:
normal = (0.0, 0.0, 1.0)
else:
normal = (nx / norm, ny / norm, nz / norm)
return {
"mirror_plane_origin": (
self._origin_x.value(),
self._origin_y.value(),
self._origin_z.value(),
),
"mirror_plane_normal": normal,
"keep_original": self._keep_original_cb.isChecked(),
}
class WorkplaneOrientationDialog(QDialog):
"""Modal dialog to choose the orientation of a new workplane.
@@ -509,6 +1120,197 @@ class WorkplaneOrientationDialog(QDialog):
)
# ── Metric thread data (coarse pitch) ──────────────────────────────────
METRIC_THREADS = {
"M1": (1.0, 0.25),
"M1.2": (1.2, 0.25),
"M1.4": (1.4, 0.30),
"M1.6": (1.6, 0.35),
"M1.8": (1.8, 0.35),
"M2": (2.0, 0.40),
"M2.5": (2.5, 0.45),
"M3": (3.0, 0.50),
"M3.5": (3.5, 0.60),
"M4": (4.0, 0.70),
"M5": (5.0, 0.80),
"M6": (6.0, 1.00),
"M7": (7.0, 1.00),
"M8": (8.0, 1.25),
"M10": (10.0, 1.50),
"M12": (12.0, 1.75),
"M14": (14.0, 2.00),
"M16": (16.0, 2.00),
"M18": (18.0, 2.50),
"M20": (20.0, 2.50),
"M22": (22.0, 2.50),
"M24": (24.0, 3.00),
"M27": (27.0, 3.00),
"M30": (30.0, 3.50),
"M32": (32.0, 3.50),
}
def _closest_metric_thread(diameter_mm: float) -> Optional[Tuple[str, float, float]]:
"""Return the closest metric thread ``(name, nominal_dia, pitch)``
for a cylinder of *diameter_mm*, or *None* if no close match.
"""
best: Optional[Tuple[str, float, float, float]] = None # name, dia, pitch, diff
for name, (dia, pitch) in METRIC_THREADS.items():
diff = abs(dia - diameter_mm)
if best is None or diff < best[3]:
best = (name, dia, pitch, diff)
if best is None:
return None
return (best[0], best[1], best[2])
class ThreadDialog(QDialog):
"""Dialog for applying an ISO metric thread to a cylindrical face.
The user picks a metric size (M1M32) and optionally overrides the
pitch. When the dialog was opened with a detected cylinder diameter
the closest size is pre-selected.
"""
def __init__(
self,
parent: Optional[QWidget] = None,
detected_diameter: Optional[float] = None,
):
super().__init__(parent)
self.setWindowTitle("Thread Options")
self.setMinimumWidth(340)
self._preview_callback: Optional[Callable[[Any], None]] = None
layout = QVBoxLayout(self)
# ── Size selection ──
layout.addWidget(QLabel("Thread Size:"))
self.size_combo = QComboBox()
self.size_combo.setToolTip("Select the metric thread size.")
for name in METRIC_THREADS:
dia, pitch = METRIC_THREADS[name]
self.size_combo.addItem(f"{name}{dia:g} mm, pitch {pitch:g} mm)", name)
layout.addWidget(self.size_combo)
# ── Pitch override ──
pitch_row = QHBoxLayout()
pitch_row.addWidget(QLabel("Pitch (mm):"))
self.pitch_input = QDoubleSpinBox()
self.pitch_input.setDecimals(2)
self.pitch_input.setRange(0.1, 10.0)
self.pitch_input.setValue(1.0)
self.pitch_input.setSingleStep(0.05)
self.pitch_input.setToolTip("Override the standard pitch if needed.")
pitch_row.addWidget(self.pitch_input)
layout.addLayout(pitch_row)
# ── Thread type ──
type_row = QHBoxLayout()
type_row.addWidget(QLabel("Type:"))
self.external_radio = QRadioButton("External (shaft)")
self.external_radio.setChecked(True)
self.internal_radio = QRadioButton("Internal (hole)")
type_row.addWidget(self.external_radio)
type_row.addWidget(self.internal_radio)
layout.addLayout(type_row)
# ── Length ──
len_row = QHBoxLayout()
len_row.addWidget(QLabel("Thread Length (mm):"))
self.length_input = QDoubleSpinBox()
self.length_input.setDecimals(2)
self.length_input.setRange(0.1, 10000.0)
self.length_input.setValue(20.0)
self.length_input.setToolTip(
"Length of the threaded section. 0 = use full cylinder height."
)
len_row.addWidget(self.length_input)
layout.addLayout(len_row)
# ── Cylinder info ──
self.info_label = QLabel("")
self.info_label.setStyleSheet("color: #8a8a8a;")
layout.addWidget(self.info_label)
if detected_diameter is not None:
closest = _closest_metric_thread(detected_diameter)
if closest is not None:
name, dia, pitch = closest
idx = self.size_combo.findData(name)
if idx >= 0:
self.size_combo.setCurrentIndex(idx)
self.pitch_input.setValue(pitch)
self.length_input.setValue(dia * 3.0) # sensible default length
self.info_label.setText(
f"Detected cylinder Ø ≈ {detected_diameter:.2f} mm → closest: {name}"
)
# ── Signals ──
self.size_combo.currentIndexChanged.connect(self._on_size_changed)
self.pitch_input.valueChanged.connect(self._emit_preview)
self.external_radio.toggled.connect(self._emit_preview)
self.internal_radio.toggled.connect(self._emit_preview)
self.length_input.valueChanged.connect(self._emit_preview)
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line)
button_layout = QHBoxLayout()
ok_button = QPushButton("Apply Thread")
ok_button.clicked.connect(self.accept)
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
def _on_size_changed(self) -> None:
"""Update pitch when the user picks a different size."""
name = self.size_combo.currentData()
if name and name in METRIC_THREADS:
_dia, pitch = METRIC_THREADS[name]
self.pitch_input.setValue(pitch)
self._emit_preview()
def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None:
"""Install a live-preview callback; fires immediately."""
self._preview_callback = callback
self._emit_preview()
def _emit_preview(self, *args: Any) -> None:
if self._preview_callback is None:
return
try:
self._preview_callback(self.get_values())
except Exception as exc:
logger.debug("thread preview callback raised: %s", exc)
def hideEvent(self, event: Any) -> None:
if self._preview_callback is not None:
try:
self._preview_callback(None)
except Exception:
pass
super().hideEvent(event)
def get_values(self) -> Tuple[str, float, float, bool, float]:
"""Return ``(size_name, nominal_diameter, pitch, internal, length)``."""
name = self.size_combo.currentData()
dia, _std_pitch = METRIC_THREADS.get(name, (0.0, 0.0))
return (
name or "M6",
dia,
self.pitch_input.value(),
self.internal_radio.isChecked(),
self.length_input.value(),
)
class FilletDialog(QDialog):
"""Dialog for fillet options — the common settings from CAD fillet tools.
@@ -653,3 +1455,134 @@ class FilletDialog(QDialog):
self.tangent_checkbox.isChecked(),
"all" if self.scope_all_radio.isChecked() else "selected",
)
class ChamferDialog(QDialog):
"""Dialog for chamfer options — shown after the user picks two faces.
The user picks the two faces whose shared edges will be beveled
(same flow as the fillet tool). This dialog offers:
- chamfer **size** (mm) the equal distance cut from each face
along the shared edge (BRepFilletAPI_MakeChamfer.Add(size, edge)
cuts the same amount on both faces),
- edge **scope** (only the edges between the two picked faces vs
every edge of the body),
- **tangent propagation** (extend the chamfer along tangent-
connected edges, like the fillet tool),
- a live 3D preview (``set_preview_callback``).
``get_values()`` returns ``(size, tangent_propagation, scope)`` where
*scope* is ``"selected"`` or ``"all"``.
"""
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setWindowTitle("Chamfer Options")
self.setMinimumWidth(360)
self._preview_callback: Optional[Callable[[Any], None]] = None
layout = QVBoxLayout(self)
# ── Size ──
size_row = QHBoxLayout()
size_row.addWidget(QLabel("Size (mm):"))
self.size_input = QDoubleSpinBox()
self.size_input.setDecimals(2)
self.size_input.setRange(0.01, 100000.0)
self.size_input.setValue(2.0)
self.size_input.setSingleStep(0.5)
self.size_input.setSuffix(" mm")
self.size_input.setToolTip(
"Chamfer size: the equal distance cut from each face along "
"the shared edge (a symmetric 45\u00b0 bevel)."
)
size_row.addWidget(self.size_input)
layout.addLayout(size_row)
# ── Edge scope ──
self.scope_group = QButtonGroup(self)
scope_layout = QGridLayout()
self.scope_selected_radio = QRadioButton("Edges between faces")
self.scope_selected_radio.setChecked(True)
self.scope_selected_radio.setToolTip("Bevel only the edges shared by the two picked faces.")
self.scope_all_radio = QRadioButton("All edges of body")
self.scope_all_radio.setToolTip(
"Bevel every edge of the body (the picked faces only choose which body is modified)."
)
self.scope_group.addButton(self.scope_selected_radio)
self.scope_group.addButton(self.scope_all_radio)
scope_layout.addWidget(self.scope_selected_radio, 0, 0)
scope_layout.addWidget(self.scope_all_radio, 1, 0)
layout.addLayout(scope_layout)
# ── Tangent propagation ──
self.tangent_checkbox = QCheckBox("Tangent propagation")
self.tangent_checkbox.setChecked(True)
self.tangent_checkbox.setToolTip(
"Extend the chamfer along edges that are tangent to the picked "
"ones (e.g. a smooth chain of lines and arcs). Off = only the "
"exact edges between the two faces."
)
layout.addWidget(self.tangent_checkbox)
# ── Edge count feedback ──
self.edge_label = QLabel("")
self.edge_label.setStyleSheet("color: #8a8a8a;")
layout.addWidget(self.edge_label)
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line)
button_layout = QHBoxLayout()
ok_button = QPushButton("Apply Chamfer")
ok_button.clicked.connect(self.accept)
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
# ── Live preview on every change ──
self.size_input.valueChanged.connect(self._emit_preview)
self.scope_selected_radio.toggled.connect(self._emit_preview)
self.tangent_checkbox.stateChanged.connect(self._emit_preview)
def set_edge_count(self, count: int) -> None:
"""Show how many edges the current scope will bevel."""
self.edge_label.setText(
f"Chamfers {count} edge{'s' if count != 1 else ''} between the picked faces."
)
def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None:
"""Install a live-preview callback; fires immediately with defaults."""
self._preview_callback = callback
self._emit_preview()
def _emit_preview(self, *args: Any) -> None:
if self._preview_callback is None:
return
try:
self._preview_callback(self.get_values())
except Exception as exc: # preview must never break the dialog
logger.debug("chamfer preview callback raised: %s", exc)
def hideEvent(self, event: Any) -> None:
if self._preview_callback is not None:
try:
self._preview_callback(None)
except Exception:
pass
super().hideEvent(event)
def get_values(self) -> Tuple[float, bool, str]:
"""Return ``(size, tangent_propagation, scope)``."""
return (
self.size_input.value(),
self.tangent_checkbox.isChecked(),
"all" if self.scope_all_radio.isChecked() else "selected",
)
+1 -1
View File
@@ -892,7 +892,7 @@ class Ui_fluencyCAD(object):
self.groupBox.setTitle(QCoreApplication.translate("fluencyCAD", u"Modify", None))
self.pb_revop.setText(QCoreApplication.translate("fluencyCAD", u"Rev", None))
self.pb_extrdop.setText(QCoreApplication.translate("fluencyCAD", u"Extrd", None))
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Arry", None))
self.pb_arrayop.setText(QCoreApplication.translate("fluencyCAD", u"Array", None))
self.pb_cutop.setText(QCoreApplication.translate("fluencyCAD", u"Cut", None))
self.pb_combop.setText(QCoreApplication.translate("fluencyCAD", u"Comb", None))
self.pb_moveop.setText(QCoreApplication.translate("fluencyCAD", u"Mve", None))
File diff suppressed because it is too large Load Diff
+477 -70
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import logging
import os
import warnings
from typing import Optional
import numpy as np
@@ -48,6 +49,24 @@ from fluency.rendering.render_backend import (
logger = logging.getLogger(__name__)
def _unlink_quiet(path: Optional[str]) -> None:
"""Unlink *path*, ignoring missing files and OS errors."""
if not path:
return
try:
if os.path.exists(path):
os.unlink(path)
except OSError:
pass
# Threads still running after a cancel could not finish in time. Kept
# referenced (never terminate()'d, reparented from their widget) at
# module level so they can safely outlive the widget/window that spawned
# them — destroying a still-running QThread is a Qt fatal error.
_RETIRED_THREADS: list = []
# ── Background render thread ────────────────────────────────────────
@@ -153,6 +172,77 @@ class _AssemblyRenderThread(QThread):
self.error.emit(str(e))
class _MeshThread(QThread):
"""Tessellates OCC shapes to PLY files off the GUI thread.
``BRepMesh_IncrementalMesh`` is a single blocking C++ call, so the
cancel flag is checked between parts (assemblies) and at completion;
a cancelled thread discards its result instead of emitting it, so it
cannot clobber the UI.
"""
mesh_ready = Signal(str) # single-shape: mesh path
assembly_ready = Signal(list, object, object) # parts, bounds, first_bounds
error = Signal(str)
def __init__(self, shapes, is_assembly: bool, parent=None):
super().__init__(parent)
# Single: (TopoDS_Shape,) | Assembly: [(TopoDS_Shape, mat_name), ...]
self._shapes = shapes
self._is_assembly = is_assembly
self._cancelled = False
def cancel(self):
self._cancelled = True
def run(self):
try:
if self._is_assembly:
self._run_assembly()
else:
mesh_path = occ_shape_to_ply(
self._shapes[0], linear_deflection=0.1, angular_deflection=0.15
)
if not self._cancelled:
self.mesh_ready.emit(mesh_path)
except Exception as e:
if not self._cancelled:
self.error.emit(str(e))
def _run_assembly(self):
from fluency.rendering.material_presets import get_preset
parts: list = []
all_mins: list = []
all_maxs: list = []
first_bounds = None
for shape, mat_name in self._shapes:
if self._cancelled:
return
try:
mesh_path = occ_shape_to_ply(
shape, linear_deflection=0.1, angular_deflection=0.15
)
material = get_preset(mat_name) if mat_name else get_preset("Brushed Steel")
parts.append((mesh_path, material))
bounds = occ_shape_bounds(shape)
all_mins.append(list(bounds[0]))
all_maxs.append(list(bounds[1]))
if first_bounds is None:
first_bounds = bounds
except Exception as e:
logger.warning(f"Failed to tessellate assembly part: {e}")
if self._cancelled:
return
combined = None
if all_mins and all_maxs:
combined = (
[min(a[i] for a in all_mins) for i in range(3)],
[max(a[i] for a in all_maxs) for i in range(3)],
)
self.assembly_ready.emit(parts, combined, first_bounds)
# ── Render window ───────────────────────────────────────────────────
@@ -777,17 +867,63 @@ class RenderWindow(QMainWindow):
self._status_badge.setStyleSheet("color: #a6e3a1; font-size: 11px; padding: 2px;")
def _cancel_active_thread(self):
"""Cancel whichever thread is currently running."""
"""Cancel whichever thread is currently running.
Deliberately avoids ``QThread.terminate()``: it kills the thread
mid-instruction inside Mitsuba/OCC C++ code and corrupts native
state (SIGSEGV). Threads are cancelled cooperatively and, if
still running, detached until they exit on their own.
"""
if self._active_mode == "preview" and self._preview_thread:
self._preview_thread.cancel()
self._preview_thread.terminate()
self._preview_thread.wait(2000)
self._stop_thread(self._preview_thread)
elif self._active_mode == "render" and self._render_thread:
self._render_thread.cancel()
self._render_thread.terminate()
self._render_thread.wait(2000)
self._stop_thread(self._render_thread)
self._active_mode = None
def _stop_thread(self, thread, block: bool = False):
"""Cancel *thread*; detach without ever using ``terminate()``.
``QThread.terminate()`` kills the thread mid-instruction inside
Mitsuba/OCC C++ code and corrupts native state (SIGSEGV). Instead
the cooperative cancel flag is set and, if the thread is still
running, its result signals are disconnected and it is kept
referenced (``_retired_threads``) until it exits on its own a
cancelled ``run()`` emits no results, so it cannot clobber the UI.
``block=True`` (shutdown paths only) additionally waits up to 3 s
so a thread does not outlive the application. Interactive paths
keep the default and never stall the GUI thread.
"""
if thread is None:
return
# Drop retired threads that have exited.
for t in list(_RETIRED_THREADS):
if not t.isRunning():
_RETIRED_THREADS.remove(t)
thread.cancel()
if thread.isRunning():
# Disconnect so a detached thread can't update the UI. Signals
# with no receiver only emit a RuntimeWarning on disconnect, so
# silence that specific case. Not every thread class defines
# every signal, so skip missing attributes.
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
for name in ("finished", "error", "progress",
"mesh_ready", "assembly_ready"):
sig = getattr(thread, name, None)
if sig is None:
continue
try:
sig.disconnect()
except (RuntimeError, TypeError):
pass
_RETIRED_THREADS.append(thread)
# Reparent so destroying the owning widget can't delete a
# still-running QThread (a Qt fatal error).
thread.setParent(None)
if block:
thread.wait(3000)
def _set_buttons_rendering(self, mode: str):
"""Disable buttons while rendering."""
self._preview_btn.setEnabled(False)
@@ -974,10 +1110,8 @@ class RenderWindow(QMainWindow):
# Kill both possible threads
for thread in (self._preview_thread, self._render_thread):
if thread and thread.isRunning():
thread.cancel()
thread.terminate()
thread.wait(2000)
# block=True: at window close a thread must not outlive the app.
self._stop_thread(thread, block=True)
# Clean up temp mesh file
if self._mesh_path and os.path.exists(self._mesh_path):
@@ -1009,17 +1143,31 @@ class RenderTabContent(QWidget):
self._backend = None
self._mesh_path: Optional[str] = None
# Framing: percentage of screen the part should occupy (10-100).
self._framing_percentage: float = 80.0
# Assembly support: list of (mesh_path, RenderMaterial)
self._assembly_parts: list = []
# Rendering threads & images
self._render_thread: Optional[_RenderThread] = None
self._preview_thread: Optional[_RenderThread] = None
# Background tessellation thread — meshing never blocks the GUI
self._mesh_thread: Optional[_MeshThread] = None
# Raw (TopoDS_Shape, mat_name) tuples awaiting background tessellation
self._assembly_pending: list = []
# Bumped on every load/clear/cleanup; mesh results carry the
# generation they belong to so stale results are discarded.
self._mesh_generation: int = 0
self._last_image: Optional[np.ndarray] = None
self._last_preview: Optional[np.ndarray] = None
self._camera: Optional[RenderCamera] = None
self._ground_color: tuple[float, float, float] = (0.5, 0.5, 0.5)
self._active_mode: Optional[str] = None
self._framing_slider: QSlider | None = None
self._framing_label: QLabel | None = None
# Combined bbox for assembly rendering (used by _compute_framed_origin)
self._assembly_bounds: Optional[tuple] = None
self._auto_preview_timer: Optional[QTimer] = None
self._init_ui()
@@ -1031,22 +1179,39 @@ class RenderTabContent(QWidget):
def set_shape(self, shape, camera: Optional[RenderCamera] = None) -> None:
"""Load a new OCC TopoDS_Shape for rendering.
Returns immediately: tessellation runs in a background thread
(``_MeshThread``) so callers (e.g. component switching) never
block the GUI thread. The auto-preview is scheduled once the
mesh is ready.
*camera* if provided, overrides the stored camera. Pass the
viewport\'s render camera to match the 3D view framing.
"""
self._mesh_generation += 1
# Cancel any in-progress render so the new shape gets a fresh preview.
self._cancel_active_thread()
# Cancel any in-flight tessellation from a previous load.
self._stop_thread(self._mesh_thread)
self._mesh_thread = None
# Drop any previously loaded assembly state so the single-shape
# render path is used (prevents re-rendering a stale assembly).
self._assembly_parts = []
self._assembly_pending = []
self._assembly_bounds = None
# Reset the mesh path so a stale/failed tessellation cannot
# trigger an auto-preview of the previous shape's mesh.
self._mesh_path = None
self._shape = shape
if camera is not None:
self._camera = camera
self._camera = self._apply_framing(camera)
self._last_image = None
self._last_preview = None
self._image_label.setPixmap(QPixmap())
self._image_label.setText("Click Preview or Render to start")
self._image_label.setText("Tessellating…")
self._status_badge.setText("")
self._export_btn.setEnabled(False)
self._prepare_mesh()
self._populate_camera_controls()
# Trigger auto-preview when a new shape is loaded
self._schedule_auto_preview()
self._start_meshing()
def get_camera(self) -> Optional[RenderCamera]:
"""Return the current camera (from UI controls or initial)."""
@@ -1061,21 +1226,30 @@ class RenderTabContent(QWidget):
*parts* is a list of ``(TopoDS_Shape, Optional[str])`` tuples
where the second element is an optional material preset name.
Returns immediately; the parts are tessellated in a background
thread and the auto-preview is scheduled once they are ready.
"""
self._mesh_generation += 1
# Cancel any in-progress render so the new assembly gets a fresh preview.
self._cancel_active_thread()
self._stop_thread(self._mesh_thread)
self._mesh_thread = None
self._shape = None
self._mesh_path = None
self._assembly_parts = []
self._assembly_pending = list(parts)
self._assembly_bounds = None
if camera is not None:
self._camera = camera
self._camera = self._apply_framing(camera)
self._last_image = None
self._last_preview = None
self._image_label.setPixmap(QPixmap())
self._image_label.setText("Click Preview or Render to start")
self._image_label.setText("Tessellating…")
self._status_badge.setText("")
self._export_btn.setEnabled(False)
self._prepare_assembly_mesh(parts)
self._populate_camera_controls()
self._schedule_auto_preview()
self._start_meshing()
def set_camera(self, camera: RenderCamera) -> None:
"""Update the render camera from an external source (e.g. 3D viewport).
@@ -1086,7 +1260,7 @@ class RenderTabContent(QWidget):
"""
if camera is None:
return
self._camera = camera
self._camera = self._apply_framing(camera)
self._cam_fov_spin.blockSignals(True)
try:
self._cam_fov_spin.setValue(camera.fov)
@@ -1100,20 +1274,48 @@ class RenderTabContent(QWidget):
# For full renders or idle: schedule a preview if auto-preview is on.
self._schedule_auto_preview()
def clear(self) -> None:
"""Remove any loaded shape/assembly and reset the display."""
self._mesh_generation += 1
self._cancel_active_thread()
self._stop_thread(self._mesh_thread)
self._mesh_thread = None
self._shape = None
self._mesh_path = None
self._assembly_parts = []
self._assembly_pending = []
self._assembly_bounds = None
self._last_image = None
self._last_preview = None
self._image_label.setPixmap(QPixmap())
self._image_label.setText("Click Preview or Render to start")
self._status_badge.setText("")
self._export_btn.setEnabled(False)
def cleanup(self) -> None:
"""Stop threads and delete temp files. Call when the tab is hidden/closed."""
self._mesh_generation += 1
if self._auto_preview_timer and self._auto_preview_timer.isActive():
self._auto_preview_timer.stop()
for thread in (self._preview_thread, self._render_thread):
if thread and thread.isRunning():
thread.cancel()
thread.terminate()
thread.wait(2000)
if self._mesh_path and os.path.exists(self._mesh_path):
try:
os.unlink(self._mesh_path)
except OSError:
pass
# block=True: at tab exit / app shutdown a thread must not outlive
# the owning widget.
for thread in (self._mesh_thread, self._preview_thread, self._render_thread):
self._stop_thread(thread, block=True)
self._mesh_thread = None
self._preview_thread = None
self._render_thread = None
self._active_mode = None
# Delete temp PLY files (single shape plus all assembly parts).
paths = []
if self._mesh_path:
paths.append(self._mesh_path)
paths.extend(p for p, _ in self._assembly_parts)
for path in paths:
if path and os.path.exists(path):
try:
os.unlink(path)
except OSError:
pass
self._mesh_path = None
# ── UI Setup ───────────────────────────────────────────────────
@@ -1248,6 +1450,23 @@ class RenderTabContent(QWidget):
layout.addWidget(camera_gb)
# ── Framing ───────────────────────────────────────────────
framing_gb = QGroupBox("Framing")
framing_layout = QVBoxLayout(framing_gb)
framing_layout.setSpacing(4)
self._framing_slider = QSlider(Qt.Horizontal)
self._framing_slider.setRange(10, 100)
self._framing_slider.setValue(80)
self._framing_slider.valueChanged.connect(self._on_framing_changed)
framing_layout.addWidget(self._framing_slider)
self._framing_label = QLabel("80 %")
self._framing_label.setAlignment(Qt.AlignCenter)
framing_layout.addWidget(self._framing_label)
layout.addWidget(framing_gb)
# ── Lighting ────────────────────────────────────────────────
light_gb = QGroupBox("Lighting")
light_layout = QVBoxLayout(light_gb)
@@ -1452,44 +1671,72 @@ class RenderTabContent(QWidget):
self._preview_btn.setEnabled(False)
self._preview_btn.setToolTip("No render backend installed (pip install mitsuba)")
def _prepare_mesh(self):
if self._shape is None:
return
try:
self._mesh_path = occ_shape_to_ply(
self._shape, linear_deflection=0.1, angular_deflection=0.15
)
if self._camera is None:
mn, mx = occ_shape_bounds(self._shape)
self._camera = self._backend.default_camera_from_bounds(mn, mx)
logger.info(f"Prepared mesh: {self._mesh_path}")
except Exception as e:
logger.error(f"Failed to prepare mesh: {e}")
QMessageBox.warning(self, "Render Error", f"Failed to tessellate shape:\n{e}")
def _start_meshing(self):
"""Kick off background tessellation of the current shape/assembly.
def _prepare_assembly_mesh(self, parts: list):
"""Tessellate multiple shapes to separate PLY files.
*parts* is a list of ``(TopoDS_Shape, Optional[str])`` tuples.
Each material preset name is resolved via ``get_preset``.
The GUI thread is never blocked: the tab shows "Tessellating…"
until the mesh is ready, then the auto-preview is scheduled.
"""
from fluency.rendering.material_presets import get_preset
if self._shape is not None:
thread = _MeshThread((self._shape,), is_assembly=False, parent=self)
gen = self._mesh_generation
thread.mesh_ready.connect(lambda path, g=gen: self._on_mesh_ready(path, g))
thread.error.connect(lambda msg, g=gen: self._on_mesh_error(msg, g))
elif self._assembly_pending:
thread = _MeshThread(self._assembly_pending, is_assembly=True, parent=self)
gen = self._mesh_generation
thread.assembly_ready.connect(
lambda parts, bounds, first, g=gen: self._on_assembly_ready(parts, bounds, first, g)
)
thread.error.connect(lambda msg, g=gen: self._on_mesh_error(msg, g))
else:
self._image_label.setText("Click Preview or Render to start")
return
thread.start()
self._mesh_thread = thread
self._assembly_parts = []
first_bounds = None
for shape, mat_name in parts:
try:
mesh_path = occ_shape_to_ply(shape, linear_deflection=0.1, angular_deflection=0.15)
material = get_preset(mat_name) if mat_name else get_preset("Brushed Steel")
self._assembly_parts.append((mesh_path, material))
if first_bounds is None:
first_bounds = occ_shape_bounds(shape)
except Exception as e:
logger.warning(f"Failed to tessellate assembly part: {e}")
if first_bounds and self._camera is None:
def _on_mesh_ready(self, mesh_path: str, gen: int) -> None:
"""Background tessellation finished (single shape)."""
if gen != self._mesh_generation:
# The load was replaced mid-tessellation — discard the stale mesh.
_unlink_quiet(mesh_path)
return
self._mesh_path = mesh_path
if self._camera is None and self._backend is not None:
mn, mx = occ_shape_bounds(self._shape)
self._camera = self._backend.default_camera_from_bounds(mn, mx)
self._populate_camera_controls()
if self._active_mode is None:
self._image_label.setText("Click Preview or Render to start")
logger.info(f"Prepared mesh: {self._mesh_path}")
# Trigger auto-preview when a new shape is loaded
self._schedule_auto_preview()
def _on_assembly_ready(self, parts: list, bounds, first_bounds, gen: int) -> None:
"""Background tessellation finished (assembly)."""
if gen != self._mesh_generation:
for p, _ in parts:
_unlink_quiet(p)
return
self._assembly_parts = parts
self._assembly_bounds = bounds
if self._camera is None and self._backend is not None and first_bounds is not None:
mn, mx = first_bounds
self._camera = self._backend.default_camera_from_bounds(mn, mx)
self._populate_camera_controls()
if self._active_mode is None:
self._image_label.setText("Click Preview or Render to start")
logger.info(f"Prepared assembly: {len(self._assembly_parts)} parts")
# Trigger auto-preview when a new assembly is loaded
self._schedule_auto_preview()
def _on_mesh_error(self, msg: str, gen: int) -> None:
if gen != self._mesh_generation:
return
logger.error(f"Failed to tessellate shape: {msg}")
self._image_label.setText("Click Preview or Render to start")
self._status_badge.setText("")
QMessageBox.warning(self, "Render Error", f"Failed to tessellate shape:\n{msg}")
def _setup_auto_preview(self):
self._auto_preview_timer = QTimer(self)
@@ -1503,12 +1750,132 @@ class RenderTabContent(QWidget):
return
self._cam_fov_spin.setValue(self._camera.fov)
# ── Framing ──────────────────────────────────────────────────
def _on_framing_changed(self, value: int) -> None:
"""Slider moved — update label and re-frame if we have a camera."""
self._framing_percentage = float(value)
self._framing_label.setText(f"{value} %")
# Re-apply framing with current direction
if self._camera is not None:
self._camera = self._apply_framing(self._camera)
# Sync the FOV spinbox to match (important for preview consistency)
self._cam_fov_spin.blockSignals(True)
try:
self._cam_fov_spin.setValue(self._camera.fov)
finally:
self._cam_fov_spin.blockSignals(False)
self._schedule_auto_preview()
def _apply_framing(self, camera: RenderCamera) -> RenderCamera:
"""Apply framing to a camera, returning a new one with adjusted origin.
Keeps the direction and target from *camera*, adjusts distance
so the part fills _framing_percentage of the screen.
"""
eye_dir = np.array(camera.origin) - np.array(camera.target)
diag = float(np.linalg.norm(eye_dir))
if diag > 1e-9:
eye_dir /= diag
target = camera.target
new_origin = self._compute_framed_origin(
eye_dir, target, camera.fov,
)
return RenderCamera(
origin=tuple(new_origin),
target=target,
up=camera.up,
fov=camera.fov,
)
return camera
def _compute_framed_origin(
self, eye_dir: np.ndarray, target: tuple[float, float, float], fov: float
) -> np.ndarray:
"""Compute camera origin so the part fills _framing_percentage of screen.
The viewing direction (eye_dir) and target define the line of sight.
The bounding box is projected onto the view plane and the distance
is chosen so that its larger projected dimension occupies exactly
``_framing_percentage`` of the corresponding screen axis.
"""
# Get bounding box — from assembly or single shape.
if self._assembly_bounds is not None:
mn, mx = self._assembly_bounds
elif self._shape is not None:
mn, mx = occ_shape_bounds(self._shape)
else:
# Fallback: place camera at a large but safe distance.
return np.array(target, dtype=float) + eye_dir * 1000.0
mn_arr = np.asarray(mn, dtype=float)
mx_arr = np.asarray(mx, dtype=float)
diag = float(np.linalg.norm(mx_arr - mn_arr))
# View-plane basis vectors.
up_world = np.array([0.0, 0.0, 1.0], dtype=float)
right = np.cross(up_world, eye_dir)
right_norm = float(np.linalg.norm(right))
if right_norm < 1e-9:
# Eye dir is parallel to world up — pick arbitrary right.
right = np.array([1.0, 0.0, 0.0], dtype=float)
else:
right /= right_norm
screen_up = np.cross(eye_dir, right)
# Project bbox axes onto view plane.
dx = mx_arr[0] - mn_arr[0]
dy = mx_arr[1] - mn_arr[1]
dz = mx_arr[2] - mn_arr[2]
# Project all 8 bbox corners onto the view plane
# to get the actual bounding-box extent.
half_x = dx / 2.0
half_y = dy / 2.0
half_z = dz / 2.0
# Corner offsets from centre in local axes.
corner_offsets = [
(sx * half_x, sy * half_y, sz * half_z)
for sx in (-1, 1) for sy in (-1, 1) for sz in (-1, 1)
]
# Project each corner onto view-plane basis vectors.
proj_right_vals = [
ox * right[0] + oy * right[1] + oz * right[2]
for ox, oy, oz in corner_offsets
]
proj_up_vals = [
ox * screen_up[0] + oy * screen_up[1] + oz * screen_up[2]
for ox, oy, oz in corner_offsets
]
proj_right = max(proj_right_vals) - min(proj_right_vals)
proj_up = max(proj_up_vals) - min(proj_up_vals)
half_fov_rad = np.radians(fov / 2.0)
tan_half_fov = float(np.tan(half_fov_rad))
if tan_half_fov < 1e-9:
return np.array(target, dtype=float) + eye_dir * 1000.0
# Distance: D = full_extent / (2 * pct * tan(fov/2))
pct = self._framing_percentage / 100.0
dist_x = proj_right / (2.0 * pct * tan_half_fov) if proj_right > 0 else float("inf")
dist_y = proj_up / (2.0 * pct * tan_half_fov) if proj_up > 0 else float("inf")
dist = min(dist_x, dist_y)
# Minimum distance to avoid camera inside the object.
if dist < diag * 0.1:
dist = diag * 0.1
return np.array(target, dtype=float) + eye_dir * dist
def _schedule_auto_preview(self):
if not self._auto_preview_cb.isChecked():
return
if self._active_mode is not None:
return
if self._backend is None or self._mesh_path is None:
if self._backend is None or (self._mesh_path is None and not self._assembly_parts):
return
self._auto_preview_timer.start(500)
@@ -1560,15 +1927,55 @@ class RenderTabContent(QWidget):
def _cancel_active_thread(self):
if self._active_mode == "preview" and self._preview_thread:
self._preview_thread.cancel()
self._preview_thread.terminate()
self._preview_thread.wait(2000)
self._stop_thread(self._preview_thread)
elif self._active_mode == "render" and self._render_thread:
self._render_thread.cancel()
self._render_thread.terminate()
self._render_thread.wait(2000)
self._stop_thread(self._render_thread)
self._active_mode = None
def _stop_thread(self, thread, block: bool = False):
"""Cancel *thread*; detach without ever using ``terminate()``.
``QThread.terminate()`` kills the thread mid-instruction inside
Mitsuba/OCC C++ code and corrupts native state (SIGSEGV). Instead
the cooperative cancel flag is set and, if the thread is still
running, its result signals are disconnected and it is kept
referenced (``_retired_threads``) until it exits on its own a
cancelled ``run()`` emits no results, so it cannot clobber the UI.
``block=True`` (shutdown paths only) additionally waits up to 3 s
so a thread does not outlive the application. Interactive paths
keep the default and never stall the GUI thread.
"""
if thread is None:
return
# Drop retired threads that have exited.
for t in list(_RETIRED_THREADS):
if not t.isRunning():
_RETIRED_THREADS.remove(t)
thread.cancel()
if thread.isRunning():
# Disconnect so a detached thread can't update the UI. Signals
# with no receiver only emit a RuntimeWarning on disconnect, so
# silence that specific case. Not every thread class defines
# every signal, so skip missing attributes.
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
for name in ("finished", "error", "progress",
"mesh_ready", "assembly_ready"):
sig = getattr(thread, name, None)
if sig is None:
continue
try:
sig.disconnect()
except (RuntimeError, TypeError):
pass
_RETIRED_THREADS.append(thread)
# Reparent so destroying the owning widget can't delete a
# still-running QThread (a Qt fatal error).
thread.setParent(None)
if block:
thread.wait(3000)
def _set_buttons_rendering(self, mode: str):
self._preview_btn.setEnabled(False)
self._render_btn.setEnabled(False)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+381 -4
View File
@@ -27,6 +27,17 @@ class Viewer3DWidget(QWidget):
# Emitted when fillet pick mode is cancelled (Esc).
filletPickCancelled = Signal()
# Emitted when the user picks a face for the chamfer tool (ANY face,
# planar or curved). Payload: the raw TopoDS_Face.
chamferFacePicked = Signal(object)
# Emitted when chamfer pick mode is cancelled (Esc).
chamferPickCancelled = Signal()
# Emitted when the user picks a cylindrical face for the thread tool.
threadFacePicked = Signal(object)
# Emitted when thread pick mode is cancelled (Esc).
threadPickCancelled = Signal()
# Emitted when the user picks an entity for a connector point (assembly).
# Payload: (origin, normal, x_dir, entity_type, face_or_edge_or_vertex, owner_obj_id).
connectorPicked = Signal(tuple, tuple, tuple, str, object, str)
@@ -48,6 +59,22 @@ class Viewer3DWidget(QWidget):
# Payload: (eye, at, up) — each is a tuple of 3 floats.
cameraChanged = Signal(tuple, tuple, tuple)
# Emitted when the user clicks a part of the world-space sketch gizmo
# (the triad at the sketch midpoint). Payload:
# kind — "center" | "axis_x" | "axis_y" | "axis_z" |
# "plane_xy" | "plane_yz" | "plane_xz"
# position — picked world point (triad origin for axes/center,
# plane quad centre for planes)
# direction — axis unit vector / plane normal / (0,0,0) for center
# normal — sketch workplane normal
# x_dir — sketch workplane x direction
sketchGizmoPicked = Signal(str, tuple, tuple, tuple, tuple)
# Hover feedback: part kind string under the cursor, or None when the
# cursor left the gizmo.
sketchGizmoHover = Signal(object)
# Emitted when gizmo pick mode is cancelled (Esc) so the host can reset.
sketchGizmoCancelled = Signal()
def __init__(self, parent=None):
super().__init__(parent)
# For OCC's direct OpenGL rendering we need Qt to not paint over it.
@@ -73,6 +100,10 @@ class Viewer3DWidget(QWidget):
self._pick_face_mode: bool = False
# When True, a left-click picks ANY face for the fillet tool.
self._fillet_pick_mode: bool = False
# When True, a left-click picks ANY face for the chamfer tool.
self._chamfer_pick_mode: bool = False
# When True, a left-click picks a cylindrical face for the thread tool.
self._thread_pick_mode: bool = False
# When True, a left-click picks an entity for a connector point
# (assembly component connection).
self._connector_pick_mode: bool = False
@@ -96,6 +127,16 @@ class Viewer3DWidget(QWidget):
# target a cut/union extrude against the body the sketch was
# projected onto).
self._last_pick_owner_obj_id: Optional[str] = None
# World-space sketch reference gizmo (triad at the sketch midpoint).
# ``_sketch_gizmo_frame`` is (origin, normal, x_dir) of the gizmo;
# None while no triad is shown.
self._sketch_gizmo_frame: Optional[Tuple[tuple, tuple, tuple]] = None
# When True, a left-click picks gizmo parts only (no orbit); Esc
# cancels. Otherwise the gizmo is pickable implicitly during normal
# navigation (Fusion-style) whenever it is shown.
self._sketch_gizmo_pick_mode: bool = False
# Currently hovered gizmo part kind (for highlight bookkeeping).
self._sketch_gizmo_hover_kind: Optional[str] = None
def _init_renderer(self) -> None:
"""Create the best available renderer."""
@@ -148,17 +189,27 @@ class Viewer3DWidget(QWidget):
self._ensure_initialized()
return self._renderer
def show_shape(self, shape: Any, color=None, name=None) -> str:
def show_shape(
self,
shape: Any,
color=None,
name=None,
auto_fit: bool = True,
) -> str:
"""Display an OCC TopoDS_Shape.
Uses OCCRenderer.add_shape for native AIS display, or falls back to
triangulation + add_mesh for the PygfxRenderer.
*auto_fit* is forwarded to the renderer: pass ``False`` when
rebuilding a scene under explicit camera control so the first
shape does not trigger a whole-scene camera fit.
"""
self._ensure_initialized()
from fluency.rendering.occ_renderer import OCCRenderer
if isinstance(self._renderer, OCCRenderer):
oid = self._renderer.add_shape(shape, color, name)
oid = self._renderer.add_shape(shape, color, name, auto_fit)
self._renderer.render()
return oid
# Fallback: tessellate and use the mesh pipeline.
@@ -238,6 +289,19 @@ class Viewer3DWidget(QWidget):
self._renderer.render()
return ok
def set_body_color(
self, mesh_id: str, color: Tuple[float, float, float]
) -> bool:
"""Change the colour of a body in the 3D view. Returns True on success."""
self._ensure_initialized()
fn = getattr(self._renderer, "set_color", None)
if fn is None:
return False
ok = fn(mesh_id, color)
if ok:
self._renderer.render()
return ok
def set_transparency(self, mesh_id: str, transparency: float) -> bool:
"""Set a previously-added mesh's transparency (0..1).
@@ -284,6 +348,35 @@ class Viewer3DWidget(QWidget):
self._renderer.fit_camera()
self._renderer.render()
def fit_camera_to_box(self, bnd_box: Any, padding: float = 0.05) -> None:
"""Fit the camera to a specific 3D bounding box (``Bnd_Box``).
Used by the array tool so the dialog preview frames exactly the
space the pattern copies occupy. Falls back to fitting the whole
scene on renderers without box fitting (e.g. the Pygfx fallback).
"""
self._ensure_initialized()
fn = getattr(self._renderer, "fit_camera_to_box", None)
if fn is not None:
fn(bnd_box, padding)
self._renderer.render()
return
self.fit_camera()
def box_fully_visible(self, bnd_box: Any, margin: float = 0.05) -> bool:
"""True when the box's 8 corners all project inside the viewport.
The array preview uses this to decide when a grown pattern has
moved copies off-screen and the camera needs re-fitting. On
renderers without the check (Pygfx fallback) it returns True so
no re-fit is forced.
"""
self._ensure_initialized()
fn = getattr(self._renderer, "box_fully_visible", None)
if fn is not None:
return bool(fn(bnd_box, margin))
return True
# ─── Workplane visualization ───────────────────────────────────────────
def show_workplane(
@@ -328,6 +421,14 @@ class Viewer3DWidget(QWidget):
if self._fillet_pick_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_fillet_face_pick(event)
return
# Chamfer pick mode: a left-click selects any face (planar or curved).
if self._chamfer_pick_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_chamfer_face_pick(event)
return
# Thread pick mode: a left-click selects a cylindrical face.
if self._thread_pick_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_thread_face_pick(event)
return
# Connector pick mode: a left-click selects a face for a connection point.
if self._connector_pick_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_connector_pick(event)
@@ -336,6 +437,20 @@ class Viewer3DWidget(QWidget):
if self._assembly_move_mode and event.button() == Qt.MouseButton.LeftButton:
self._handle_assembly_move_press(event)
return
# World sketch gizmo: a click on a part selects it (Fusion-style),
# even during normal navigation. In explicit gizmo-pick mode an
# off-gizmo click does nothing (no orbit); otherwise it falls
# through to orbit/pan below.
if event.button() == Qt.MouseButton.LeftButton and self._sketch_gizmo_enabled():
fn = getattr(self._renderer, "pick_sketch_gizmo", None)
if fn is not None:
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
kind = fn(pos.x(), pos.y())
if kind is not None:
self._handle_sketch_gizmo_pick(event, kind)
return
if self._sketch_gizmo_pick_mode:
return # explicit mode: off-gizmo clicks do not orbit
self._renderer.handle_mouse_press(event)
super().mousePressEvent(event)
@@ -365,11 +480,29 @@ class Viewer3DWidget(QWidget):
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
return
# In chamfer pick mode, keep dynamic highlighting too.
if self._chamfer_pick_mode:
if hasattr(self._renderer, "handle_mouse_move"):
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
return
# In thread pick mode, keep dynamic highlighting.
if self._thread_pick_mode:
if hasattr(self._renderer, "handle_mouse_move"):
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
return
# Active drag in assembly move mode.
if self._move_drag_active:
self._handle_assembly_move_move(event)
super().mouseMoveEvent(event)
return
# World sketch gizmo hover: highlight the part under the cursor.
if self._sketch_gizmo_enabled():
self._handle_sketch_gizmo_hover(event)
if self._sketch_gizmo_pick_mode:
super().mouseMoveEvent(event)
return
self._renderer.handle_mouse_move(event)
super().mouseMoveEvent(event)
@@ -456,6 +589,127 @@ class Viewer3DWidget(QWidget):
return self._renderer.get_camera_fov()
return 45.0
# ─── World sketch reference gizmo (triad at the sketch midpoint) ────────
def show_sketch_gizmo(
self,
origin: Tuple[float, float, float],
normal: Tuple[float, float, float],
x_dir: Tuple[float, float, float],
size: float = 40.0,
) -> None:
"""Show the selectable X/Y/Z triad in the 3D world at *origin*.
*origin* should be the midpoint of the active sketch's geometry and
the triad is aligned to the sketch's workplane frame. Call again
with a new origin to keep it in sync as the sketch is edited.
No-op on renderers without gizmo support (Pygfx fallback).
"""
self._ensure_initialized()
fn = getattr(self._renderer, "show_sketch_gizmo", None)
if fn is None:
return
fn(origin, normal, x_dir, size)
self._sketch_gizmo_frame = (tuple(origin), tuple(normal), tuple(x_dir))
self._sketch_gizmo_hover_kind = None
self._renderer.render()
def remove_sketch_gizmo(self) -> None:
"""Hide the world sketch triad, if any."""
if self._initialized and self._renderer is not None:
fn = getattr(self._renderer, "remove_sketch_gizmo", None)
if fn is not None:
fn()
self._renderer.render()
self._sketch_gizmo_frame = None
self._sketch_gizmo_hover_kind = None
def set_sketch_gizmo_pick_mode(self, enabled: bool) -> None:
"""Toggle explicit gizmo-pick mode.
When enabled, left-clicks select gizmo parts only (the camera does
not orbit) and Esc exits the mode. When disabled, the gizmo is
still pickable implicitly during normal navigation (Fusion-style)
whenever it is shown. Mutually exclusive with the other pick modes.
"""
self._sketch_gizmo_pick_mode = bool(enabled)
if enabled:
self._pick_face_mode = False
self._fillet_pick_mode = False
self._chamfer_pick_mode = False
self._thread_pick_mode = False
self._connector_pick_mode = False
self._assembly_move_mode = False
self._move_drag_active = False
self.setCursor(Qt.CursorShape.CrossCursor)
self.setFocus()
elif not (
self._pick_face_mode
or self._fillet_pick_mode
or self._chamfer_pick_mode
or self._thread_pick_mode
or self._connector_pick_mode
):
self.unsetCursor()
def is_sketch_gizmo_pick_mode(self) -> bool:
return self._sketch_gizmo_pick_mode
def get_sketch_gizmo_frame(self) -> Optional[Tuple[tuple, tuple, tuple]]:
"""Return the (origin, normal, x_dir) of the shown triad, or None."""
return self._sketch_gizmo_frame
def _sketch_gizmo_enabled(self) -> bool:
"""True when the triad is shown AND no other mode owns the pointer."""
if self._sketch_gizmo_frame is None:
return False
if self._sketch_gizmo_pick_mode:
return True
if self._assembly_move_mode or self._move_drag_active:
return False
return not (
self._pick_face_mode
or self._fillet_pick_mode
or self._chamfer_pick_mode
or self._thread_pick_mode
or self._connector_pick_mode
)
def _handle_sketch_gizmo_hover(self, event) -> None:
"""Highlight the gizmo part under the cursor and emit hover signal."""
fn = getattr(self._renderer, "pick_sketch_gizmo", None)
if fn is None:
return
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
kind = fn(pos.x(), pos.y())
if kind == self._sketch_gizmo_hover_kind:
return
self._sketch_gizmo_hover_kind = kind
if kind is not None:
hl = getattr(self._renderer, "highlight_sketch_gizmo_part", None)
if hl is not None:
hl(kind)
else:
cl = getattr(self._renderer, "clear_sketch_gizmo_highlight", None)
if cl is not None:
cl()
self.sketchGizmoHover.emit(kind)
def _handle_sketch_gizmo_pick(self, event, kind: str) -> None:
"""Emit sketchGizmoPicked for the clicked part with world metadata."""
frame = self._sketch_gizmo_frame
normal = tuple(frame[1]) if frame else (0.0, 0.0, 1.0)
x_dir = tuple(frame[2]) if frame else (1.0, 0.0, 0.0)
position = tuple(frame[0]) if frame else (0.0, 0.0, 0.0)
direction = (0.0, 0.0, 0.0)
info_fn = getattr(self._renderer, "sketch_gizmo_pick_info", None)
if info_fn is not None:
info = info_fn(kind)
if info:
position = tuple(info["position"])
direction = tuple(info["direction"])
self.sketchGizmoPicked.emit(kind, position, direction, normal, x_dir)
# ─── Face-pick mode (sketch-on-surface) ────────────────────────────────
def set_pick_face_mode(self, enabled: bool) -> None:
@@ -466,6 +720,7 @@ class Viewer3DWidget(QWidget):
"""
self._pick_face_mode = bool(enabled)
if enabled:
self._sketch_gizmo_pick_mode = False
self.setCursor(Qt.CursorShape.CrossCursor)
else:
self.unsetCursor()
@@ -484,19 +739,59 @@ class Viewer3DWidget(QWidget):
"""
self._fillet_pick_mode = bool(enabled)
if enabled:
self._sketch_gizmo_pick_mode = False
# Pick modes are mutually exclusive — entering fillet mode
# disables sketch-on-surface / connector / assembly modes.
# disables chamfer / sketch-on-surface / connector / assembly modes.
self._chamfer_pick_mode = False
self._pick_face_mode = False
self._connector_pick_mode = False
self._assembly_move_mode = False
self._move_drag_active = False
self.setCursor(Qt.CursorShape.CrossCursor)
elif not self._pick_face_mode and not self._connector_pick_mode:
elif not self._pick_face_mode and not self._chamfer_pick_mode and not self._connector_pick_mode:
self.unsetCursor()
def is_fillet_pick_mode(self) -> bool:
return self._fillet_pick_mode
# ─── Chamfer pick mode (any-face picking) ──────────────────────────────
def set_chamfer_pick_mode(self, enabled: bool) -> None:
"""Toggle chamfer face-pick mode (any face — planar or curved).
The cursor selects faces for the chamfer tool instead of orbiting
the camera. Mutually exclusive with the other pick modes.
"""
self._chamfer_pick_mode = bool(enabled)
if enabled:
self._sketch_gizmo_pick_mode = False
self._fillet_pick_mode = False
self._pick_face_mode = False
self._connector_pick_mode = False
self._assembly_move_mode = False
self._move_drag_active = False
self.setCursor(Qt.CursorShape.CrossCursor)
elif not self._pick_face_mode and not self._fillet_pick_mode and not self._connector_pick_mode:
self.unsetCursor()
def is_chamfer_pick_mode(self) -> bool:
return self._chamfer_pick_mode
def _handle_chamfer_face_pick(self, event: Any) -> None:
"""Detect any face under the click and emit chamferFacePicked."""
self._ensure_initialized()
picker = getattr(self._renderer, "pick_face", None)
if picker is None:
logger.warning("Renderer has no pick_face support")
return
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info = picker(pos.x(), pos.y())
if info is None:
logger.info("Chamfer face pick: no face under cursor")
return
self._last_pick_owner_obj_id = info.get("owner_obj_id")
self.chamferFacePicked.emit(info["face"])
def highlight_faces(self, faces: List[Any]) -> None:
"""Tint all faces in *faces* so both fillet picks stay visible."""
self._ensure_initialized()
@@ -535,6 +830,45 @@ class Viewer3DWidget(QWidget):
self._last_pick_owner_obj_id = info.get("owner_obj_id")
self.filletFacePicked.emit(info["face"])
# ─── Thread pick mode ─────────────────────────────────────────────────
def set_thread_pick_mode(self, enabled: bool) -> None:
"""Toggle thread face-pick mode (cylindrical face only).
When enabled, a left-click picks a cylindrical face for the thread
tool. Mutually exclusive with other pick modes.
"""
self._thread_pick_mode = bool(enabled)
if enabled:
self._sketch_gizmo_pick_mode = False
self._pick_face_mode = False
self._fillet_pick_mode = False
self._chamfer_pick_mode = False
self._connector_pick_mode = False
self._assembly_move_mode = False
self._move_drag_active = False
self.setCursor(Qt.CursorShape.CrossCursor)
elif not self._pick_face_mode and not self._fillet_pick_mode and not self._chamfer_pick_mode and not self._connector_pick_mode:
self.unsetCursor()
def is_thread_pick_mode(self) -> bool:
return self._thread_pick_mode
def _handle_thread_face_pick(self, event: Any) -> None:
"""Detect any face under the click and emit threadFacePicked."""
self._ensure_initialized()
picker = getattr(self._renderer, "pick_face", None)
if picker is None:
logger.warning("Renderer has no pick_face support")
return
pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
info = picker(pos.x(), pos.y())
if info is None:
logger.info("Thread face pick: no face under cursor")
return
self._last_pick_owner_obj_id = info.get("owner_obj_id")
self.threadFacePicked.emit(info["face"])
def highlight_face(self, face: Any) -> None:
"""Tint the picked face light-blue/transparent in the 3D viewer."""
self._ensure_initialized()
@@ -551,6 +885,22 @@ class Viewer3DWidget(QWidget):
fn()
self._renderer.render()
def highlight_operation(self, shape: Any) -> None:
"""Overlay a hot-pink translucent *shape* to show operation history."""
self._ensure_initialized()
fn = getattr(self._renderer, "highlight_operation_shape", None)
if fn is not None:
fn(shape)
self._renderer.render()
def clear_operation_highlight(self) -> None:
"""Remove the operation-history overlay, if any."""
self._ensure_initialized()
fn = getattr(self._renderer, "clear_operation_highlight", None)
if fn is not None:
fn()
self._renderer.render()
# ─── Connector pick mode (assembly) ────────────────────────────────────
def set_connector_pick_mode(self, enabled: bool, clear_gizmo: bool = True) -> None:
@@ -570,6 +920,7 @@ class Viewer3DWidget(QWidget):
"""
self._connector_pick_mode = bool(enabled)
if enabled:
self._sketch_gizmo_pick_mode = False
self.setCursor(Qt.CursorShape.CrossCursor)
# Disable standard OCC selection so gizmo visuals are not
# interfered with by dynamic face highlighting.
@@ -589,6 +940,16 @@ class Viewer3DWidget(QWidget):
def is_connector_pick_mode(self) -> bool:
return self._connector_pick_mode
def show_persistent_connector_gizmo(self, origin, normal, x_dir, entity_type, color=(0.0, 1.0, 0.0)):
fn = getattr(self._renderer, "show_persistent_entity_gizmo", None)
if fn is not None:
fn(entity_type=entity_type, position=origin, normal=normal, x_dir=x_dir, color=color)
def clear_persistent_connector_gizmo(self):
fn = getattr(self._renderer, "clear_persistent_entity_gizmo", None)
if fn is not None:
fn()
def _clear_connector_snap(self) -> None:
"""Remove the hover gizmo."""
fn = getattr(self._renderer, "clear_entity_gizmo", None)
@@ -795,6 +1156,7 @@ class Viewer3DWidget(QWidget):
"""
self._assembly_move_mode = bool(enabled)
if enabled:
self._sketch_gizmo_pick_mode = False
self.setCursor(Qt.CursorShape.SizeAllCursor)
elif not self._pick_face_mode and not self._connector_pick_mode:
self.unsetCursor()
@@ -964,6 +1326,16 @@ class Viewer3DWidget(QWidget):
self.set_fillet_pick_mode(False)
self.filletPickCancelled.emit()
return
# Esc cancels chamfer pick mode.
if self._chamfer_pick_mode and event.key() == Qt.Key.Key_Escape:
self.set_chamfer_pick_mode(False)
self.chamferPickCancelled.emit()
return
# Esc cancels thread pick mode.
if self._thread_pick_mode and event.key() == Qt.Key.Key_Escape:
self.set_thread_pick_mode(False)
self.threadPickCancelled.emit()
return
# Esc cancels connector pick mode.
if self._connector_pick_mode and event.key() == Qt.Key.Key_Escape:
self.set_connector_pick_mode(False)
@@ -973,6 +1345,11 @@ class Viewer3DWidget(QWidget):
if self._assembly_move_mode and event.key() == Qt.Key.Key_Escape:
self.set_assembly_move_mode(False)
return
# Esc cancels explicit sketch-gizmo pick mode.
if self._sketch_gizmo_pick_mode and event.key() == Qt.Key.Key_Escape:
self.set_sketch_gizmo_pick_mode(False)
self.sketchGizmoCancelled.emit()
return
# Navigation shortcuts (lowercase = view presets, F = fit,
# P/O = perspective/orthographic, R = reset).
self._ensure_initialized()
+916
View File
@@ -0,0 +1,916 @@
"""Tests for the technical drawing workbench.
Covers the manual-dimension pipeline (model-space annotations
sheet-space candidates placed primitives), the 2D pick geometry
helpers, and persistence of drawings (manual dimensions included) in
the .fluency project file.
"""
import json
import math
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
from fluency.models.data_model import (
Body,
Component,
DrawingAnnotation,
DrawingView,
Project,
TechnicalDrawing,
)
from fluency.technical_drawing import (
build_manual_candidates,
generate_drawing,
_layout_views_on_sheet,
)
from fluency.io.project_io import (
_technical_drawing_from_dict,
_technical_drawing_to_dict,
load_project,
save_project,
)
from fluency.ui.technical_drawing_widget import (
_closest_point_on_segment,
_closest_points_on_segments,
_line_intersection,
_point_to_segment,
)
# ── Fixtures ───────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def qapp():
"""Offscreen QApplication for widget-level tests."""
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
yield app
def _drawing_with_views(view_kinds=("front",)):
drawing = TechnicalDrawing(source_kind="component", source_id="comp-1")
for kind in view_kinds:
drawing.views.append(DrawingView(kind=kind))
return drawing
def _manual_annotation(
dimension_kind: str,
view_id: str,
anchors,
direction=None,
) -> DrawingAnnotation:
return DrawingAnnotation(
kind="dimension",
dimension_kind=dimension_kind,
view_id=view_id,
anchors=list(anchors),
direction=direction,
)
# ── build_manual_candidates ────────────────────────────────────────────────
class TestBuildManualCandidates:
def test_length_candidate_reprojects_anchors(self):
drawing = _drawing_with_views()
ann = _manual_annotation(
"length", "front", ((0.0, 0.0), (0.0, 12.5)), direction=(0.0, 1.0)
)
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(
drawing, {"front": (2.0, 10.0, 20.0)}
)
assert unresolved == []
assert resolved == [ann.id]
c = cands[0]
assert c.kind == "length"
assert c.view_id == "front"
assert c.key == f"manual:{ann.id}"
assert c.references == (ann.id,)
assert c.value == pytest.approx(12.5)
assert c.label == "12.50"
# sheet = model * scale + offset
assert c.anchor_points[0] == pytest.approx((10.0, 20.0))
assert c.anchor_points[1] == pytest.approx((10.0, 45.0))
assert c.direction == (0.0, 1.0)
def test_length_without_transform_is_unresolved(self):
drawing = _drawing_with_views()
ann = _manual_annotation("length", "front", ((0.0, 0.0), (3.0, 4.0)))
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(drawing, {})
assert cands == []
assert unresolved == [ann.id]
cands, resolved, unresolved = build_manual_candidates(
drawing, {"front": (1.0, 0.0, 0.0)}
)
assert unresolved == []
c = cands[0]
assert c.value == pytest.approx(5.0)
assert c.direction == pytest.approx((0.6, 0.8))
def test_diameter_candidate(self):
drawing = _drawing_with_views()
ann = _manual_annotation("diameter", "top", ((-5.0, 0.0), (5.0, 0.0)))
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(
drawing, {"top": (1.0, 0.0, 0.0)}
)
assert unresolved == []
c = cands[0]
assert c.kind == "diameter"
assert c.value == pytest.approx(10.0)
assert c.label == "Ø10.00"
assert c.direction == ()
def test_angle_candidate(self):
drawing = _drawing_with_views()
ann = _manual_annotation(
"angle", "front", ((0.0, 0.0), (10.0, 0.0), (0.0, 10.0))
)
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(
drawing, {"front": (1.0, 0.0, 0.0)}
)
assert unresolved == []
c = cands[0]
assert c.kind == "angle"
assert c.value == pytest.approx(90.0)
assert c.label == "90.00°"
assert len(c.anchor_points) == 3
def test_degenerate_angle_is_unresolved(self):
# Collinear arms → 180° → not a usable angle dimension.
drawing = _drawing_with_views()
ann = _manual_annotation(
"angle", "front", ((0.0, 0.0), (10.0, 0.0), (-10.0, 0.0))
)
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(
drawing, {"front": (1.0, 0.0, 0.0)}
)
assert cands == []
assert resolved == []
assert unresolved == [ann.id]
def test_hidden_annotation_is_skipped(self):
drawing = _drawing_with_views()
ann = _manual_annotation("length", "front", ((0.0, 0.0), (1.0, 0.0)))
ann.visible = False
drawing.annotations.append(ann)
cands, resolved, unresolved = build_manual_candidates(
drawing, {"front": (1.0, 0.0, 0.0)}
)
assert cands == []
assert resolved == []
assert unresolved == []
# ── generate_drawing: auto vs manual dimensions ───────────────────────────
@pytest.fixture(scope="module")
def kernel():
from fluency.geometry_occ.kernel import OCGeometryKernel
return OCGeometryKernel()
@pytest.fixture(scope="module")
def box_project(kernel):
"""Project with one 10 x 20 x 5 box (x: 0..10, y: 0..20, z: 0..5)."""
from fluency.geometry.base import Point2D
points = [Point2D(0, 0), Point2D(10, 0), Point2D(10, 20), Point2D(0, 20)]
polygon = kernel.create_polygon(points)
box = kernel.extrude(polygon, 5.0)
body = Body(name="Box", geometry=box)
comp = Component(name="BoxComp")
comp.bodies[body.id] = body
project = Project()
project.components[comp.id] = comp
project.active_component = comp.id
return project, comp
class TestGenerateDrawingManualDimensions:
def test_manual_dimension_placed_with_auto_off(self, kernel, box_project):
project, comp = box_project
drawing = TechnicalDrawing(
source_kind="component",
source_id=comp.id,
views=[DrawingView(kind="front")],
auto_dimensions=False,
)
# Distance between the two vertical edges of the box front face.
ann = _manual_annotation(
"length", "front", ((0.0, 0.0), (10.0, 0.0)), direction=(1.0, 0.0)
)
drawing.annotations.append(ann)
result = generate_drawing(drawing, project, kernel)
assert result.view_transforms, "view transforms must be published"
manual_keys = [
p.candidate_key for p in result.primitives if p.candidate_key
]
assert f"manual:{ann.id}" in manual_keys
label_texts = [
p.text
for p in result.primitives
if p.candidate_key == f"manual:{ann.id}" and p.kind == "text"
]
assert label_texts == ["10.00"]
# Auto off → no auto-placed dimensions.
assert not any(
k for k in manual_keys if not k.startswith("manual:")
), "auto dimensions must stay out while auto_dimensions is off"
assert ann.id in result.resolved_annotation_ids
def test_auto_off_places_no_auto_dimensions(self, kernel, box_project):
project, comp = box_project
drawing = TechnicalDrawing(
source_kind="component",
source_id=comp.id,
views=[DrawingView(kind="front")],
auto_dimensions=False,
)
result = generate_drawing(drawing, project, kernel)
dim_keys = [p.candidate_key for p in result.primitives if p.candidate_key]
assert dim_keys == [], f"expected no dimensions, got {dim_keys}"
def test_auto_on_places_auto_dimensions(self, kernel, box_project):
project, comp = box_project
drawing = TechnicalDrawing(
source_kind="component",
source_id=comp.id,
views=[DrawingView(kind="front")],
auto_dimensions=True,
)
result = generate_drawing(drawing, project, kernel)
dim_keys = [p.candidate_key for p in result.primitives if p.candidate_key]
assert dim_keys, "auto dimensions expected with auto_dimensions on"
assert all(not k.startswith("manual:") for k in dim_keys)
def test_auto_and_manual_coexist(self, kernel, box_project):
project, comp = box_project
drawing = TechnicalDrawing(
source_kind="component",
source_id=comp.id,
views=[DrawingView(kind="front")],
auto_dimensions=True,
)
ann = _manual_annotation(
"length", "front", ((0.0, 0.0), (10.0, 0.0)), direction=(1.0, 0.0)
)
drawing.annotations.append(ann)
result = generate_drawing(drawing, project, kernel)
dim_keys = {p.candidate_key for p in result.primitives if p.candidate_key}
assert f"manual:{ann.id}" in dim_keys
assert any(k for k in dim_keys if not k.startswith("manual:"))
def test_diameter_manual_on_cylinder(self, kernel):
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt
from fluency.geometry_occ.kernel import OCCGeometryObject
ax = gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1))
cyl = OCCGeometryObject(
BRepPrimAPI_MakeCylinder(ax, 4.0, 8.0).Shape(),
{"type": "cylinder"},
)
body = Body(name="Cyl", geometry=cyl)
comp = Component(name="CylComp")
comp.bodies[body.id] = body
project = Project()
project.components[comp.id] = comp
project.active_component = comp.id
drawing = TechnicalDrawing(
source_kind="component",
source_id=comp.id,
views=[DrawingView(kind="front")],
auto_dimensions=False,
)
ann = _manual_annotation("diameter", "front", ((-4.0, 0.0), (4.0, 0.0)))
drawing.annotations.append(ann)
result = generate_drawing(drawing, project, kernel)
label_texts = [
p.text
for p in result.primitives
if p.candidate_key == f"manual:{ann.id}" and p.kind == "text"
]
assert label_texts == ["Ø8.00"]
# ── Circle centres: ISO center marks + centre-point dimensioning ──────────
def _cylinder_project(kernel):
"""One Ø8 x 8 cylinder (axis +Z) as a draw-able component.
HLR may split a circle's edge into sampled segments for some shapes,
so tests that need a guaranteed circle primitive build it directly
(see :class:`TestCircleCenterMarks` / :class:`TestCircleCenterPick`).
"""
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt
from fluency.geometry_occ.kernel import OCCGeometryObject
ax = gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1))
cyl = OCCGeometryObject(
BRepPrimAPI_MakeCylinder(ax, 4.0, 8.0).Shape(),
{"type": "cylinder"},
)
body = Body(name="Cyl", geometry=cyl)
comp = Component(name="CylComp")
comp.bodies[body.id] = body
project = Project()
project.components[comp.id] = comp
project.active_component = comp.id
return project, comp
class TestCircleCenterMarks:
"""ISO 14128 center marks: a thin cross at each projected circle's
centre, crossing at the centre and extending past the circle edge."""
def test_center_marks_emitted_for_circles(self):
from fluency.technical_drawing import _assemble_view
# Synthetic view plane: a 20 × 16 box with a r4 circle at (10, 8).
edges = [
((0.0, 0.0), (20.0, 0.0), "line", "visible"),
((20.0, 0.0), (20.0, 16.0), "line", "visible"),
((20.0, 16.0), (0.0, 16.0), "line", "visible"),
((0.0, 16.0), (0.0, 0.0), "line", "visible"),
((10.0, 8.0), (14.0, 8.0), "circle_full", "visible"),
]
view = DrawingView(kind="top")
prims, _cands, _warns = _assemble_view(edges, [], view, (10, 10, 200, 150))
circles = [p for p in prims if p.kind == "circle"]
assert len(circles) == 1
c = circles[0]
cx, cy = c.center
marks = [p for p in prims if p.kind == "line" and p.style == "center"]
assert len(marks) == 2, "one horizontal and one vertical center mark"
horiz = next(p for p in marks if p.points[0][1] == p.points[1][1])
vert = next(p for p in marks if p.points[0][0] == p.points[1][0])
# The marks cross at the circle centre.
assert horiz.points[0][1] == cy and horiz.points[1][1] == cy
assert vert.points[0][0] == cx and vert.points[1][0] == cx
# And each extends past the circle edge.
half_h = abs(horiz.points[1][0] - horiz.points[0][0]) / 2.0
half_v = abs(vert.points[1][1] - vert.points[0][1]) / 2.0
assert half_h > c.radius
assert half_v > c.radius
class TestCircleCenterAnchors:
"""Distance picks between circle centres, and between a centre and an
edge, resolve to the right model-space anchor pair."""
def _w(self):
from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget
return TechnicalDrawingWidget
def test_pick_point_kinds(self):
W = self._w()
assert W._pick_point({"kind": "point", "point": (1.0, 2.0)}) == (1.0, 2.0)
assert W._pick_point(
{"kind": "circle", "center": (3.0, 4.0), "radius": 1.0}
) == (3.0, 4.0)
assert W._pick_point({"kind": "segment", "p1": (0, 0), "p2": (1, 1)}) is None
def test_center_to_center(self):
W = self._w()
a = {"kind": "circle", "view_id": "v", "center": (0.0, 0.0), "radius": 2.0}
b = {"kind": "circle", "view_id": "v", "center": (5.0, 12.0), "radius": 3.0}
p1, p2 = W._distance_anchors(a, b)
assert p1 == (0.0, 0.0)
assert p2 == (5.0, 12.0)
def test_center_to_edge(self):
W = self._w()
a = {"kind": "point", "view_id": "v", "point": (4.0, 6.0)}
b = {"kind": "segment", "view_id": "v", "p1": (0.0, 0.0), "p2": (10.0, 0.0)}
p1, p2 = W._distance_anchors(a, b)
assert p1 == (4.0, 6.0)
# Closest point on the edge is straight below the centre.
assert p2 == pytest.approx((4.0, 0.0))
def test_edge_to_edge_unchanged(self):
W = self._w()
a = {"kind": "segment", "view_id": "v", "p1": (0.0, 0.0), "p2": (10.0, 0.0)}
b = {"kind": "segment", "view_id": "v", "p1": (2.0, 5.0), "p2": (8.0, 5.0)}
p1, p2 = W._distance_anchors(a, b)
assert p1 == pytest.approx((2.0, 0.0))
assert p2 == pytest.approx((2.0, 5.0))
# ── View layout: page fill, no overlaps, title-block clearance ───────────
class TestViewLayout:
"""_layout_views_on_sheet packs the views to fill the sheet, keeps
them apart and clear of the title block, and rotates individual
views when that makes the set fit more."""
A3W, A3H = 420.0, 297.0
# Title block box + 5 mm clearance zone (see _title_block_primitives).
TB = (235.0, 0.0, 420.0, 62.0)
@staticmethod
def _bbox(w, h):
return (0.0, 0.0, float(w), float(h))
def _layout(self, kinds, boxes):
views = [DrawingView(kind=k) for k in kinds]
return _layout_views_on_sheet(views, {k: boxes[k] for k in kinds})
def _assert_valid(self, slots):
for k, s in slots.items():
assert s[0] >= 10.0 - 1e-6 and s[1] >= 10.0 - 1e-6, (k, s)
assert s[0] + s[2] <= self.A3W - 10.0 + 1e-6, (k, s)
assert s[1] + s[3] <= self.A3H - 10.0 + 1e-6, (k, s)
x0, y0, w, h = s
x1, y1, w2, h2 = self.TB
assert x0 + w <= x1 or x1 + w2 <= x0 or y0 + h <= y1 or y1 + h2 <= y0, \
f"{k} intrudes title block: {s}"
ks = list(slots)
for i in range(len(ks)):
for j in range(i + 1, len(ks)):
a, b_ = slots[ks[i]], slots[ks[j]]
sep = (
a[0] + a[2] <= b_[0] + 0.1 or b_[0] + b_[2] <= a[0] + 0.1
or a[1] + a[3] <= b_[1] + 0.1 or b_[1] + b_[3] <= a[1] + 0.1
)
assert sep, f"{ks[i]} overlaps {ks[j]}: {a} / {b_}"
@staticmethod
def _fill(slots):
x0 = min(s[0] for s in slots.values())
y0 = min(s[1] for s in slots.values())
x1 = max(s[0] + s[2] for s in slots.values())
y1 = max(s[1] + s[3] for s in slots.values())
return (x1 - x0) * (y1 - y0) / (420.0 * 297.0)
def test_single_view_fills_page(self):
slots, scale, rots = self._layout(["front"], {"front": self._bbox(10, 20)})
self._assert_valid(slots)
# 12 views stay upright — no sideways single view.
assert rots == {"front": 0.0}
# The tall 10 × 20 view is scaled until it touches the full-height
# left strip's reduced height (0.8 × 277 mm).
assert scale == pytest.approx(221.6 / 20.0)
assert slots["front"][3] == pytest.approx(221.6)
def test_two_views_share_page(self):
slots, _scale, _rots = self._layout(
["front", "top"],
{"front": self._bbox(40, 40), "top": self._bbox(40, 20)},
)
self._assert_valid(slots)
assert self._fill(slots) > 0.30
def test_classic_three_view_keeps_cross(self):
boxes = {k: self._bbox(40, 40) for k in ("front", "top", "right")}
slots, _scale, rots = self._layout(list(boxes), boxes)
self._assert_valid(slots)
assert set(rots.values()) == {0.0}, "classic cross must not rotate"
# Top sits directly above front; right directly to its right.
assert abs(slots["top"][0] - slots["front"][0]) < 1e-6
assert slots["top"][1] > slots["front"][1] + slots["front"][3]
assert slots["right"][0] > slots["front"][0] + slots["front"][2]
assert abs(slots["right"][1] - slots["front"][1]) < 1e-6
def test_thin_part_gets_rotated_views(self):
boxes = {
"front": self._bbox(200, 30),
"top": self._bbox(30, 50),
"right": self._bbox(50, 30),
"left": self._bbox(50, 30),
"back": self._bbox(200, 30),
"bottom": self._bbox(30, 50),
}
slots, scale, rots = self._layout(list(boxes), boxes)
self._assert_valid(slots)
assert any(r == 90.0 for r in rots.values()), "rotation must kick in"
# Each slot is the (possibly swapped) model size times the scale.
for k, s in slots.items():
w, h = boxes[k][2], boxes[k][3]
sw, sh = s[2] / scale, s[3] / scale
assert (
(sw == pytest.approx(w) and sh == pytest.approx(h))
or (sw == pytest.approx(h) and sh == pytest.approx(w))
), (k, s, w, h)
def test_all_views_plus_isometric_fill_page(self):
boxes = {
"front": self._bbox(80, 40),
"top": self._bbox(80, 30),
"right": self._bbox(30, 40),
"left": self._bbox(30, 40),
"back": self._bbox(80, 40),
"bottom": self._bbox(80, 30),
"isometric": self._bbox(60, 60),
}
slots, _scale, _rots = self._layout(list(boxes), boxes)
self._assert_valid(slots)
assert self._fill(slots) > 0.55
# ── Project drawing persistence ────────────────────────────────────────────
def _drawing_with_manual_dim():
drawing = TechnicalDrawing(
source_kind="component",
source_id="comp-42",
views=[DrawingView(kind="front"), DrawingView(kind="top")],
auto_dimensions=True,
title="Persisted Drawing",
revision="B",
)
drawing.annotations.append(
_manual_annotation(
"length", "front", ((0.0, 0.0), (0.0, 12.5)), direction=(0.0, 1.0)
)
)
drawing.annotations.append(
_manual_annotation("diameter", "top", ((-5.0, 0.0), (5.0, 0.0)))
)
return drawing
class TestDrawingPersistence:
def test_drawing_dict_roundtrip(self):
drawing = _drawing_with_manual_dim()
data = _technical_drawing_to_dict(drawing)
restored = _technical_drawing_from_dict(json.loads(json.dumps(data)))
assert restored.id == drawing.id
assert restored.source_kind == "component"
assert restored.source_id == "comp-42"
assert restored.auto_dimensions is True
assert [v.kind for v in restored.views] == ["front", "top"]
assert len(restored.annotations) == 2
a = restored.annotations[0]
assert a.dimension_kind == "length"
assert a.view_id == "front"
assert a.anchors == [(0.0, 0.0), (0.0, 12.5)]
assert a.direction == (0.0, 1.0)
b = restored.annotations[1]
assert b.dimension_kind == "diameter"
assert b.anchors == [(-5.0, 0.0), (5.0, 0.0)]
def test_project_drawings_lookup(self):
project = Project()
drawing = _drawing_with_manual_dim()
project.add_drawing(drawing)
assert project.get_drawing_for("component", "comp-42") is drawing
assert project.get_drawing_for("assembly", "comp-42") is None
assert project.get_drawing_for("component", "other") is None
def test_project_save_load_roundtrip(self, tmp_path):
project = Project(name="Drawing Project")
drawing = _drawing_with_manual_dim()
project.add_drawing(drawing)
path = save_project(project, str(tmp_path / "proj.fluency"))
loaded, _view_state = load_project(path)
assert len(loaded.drawings) == 1
restored = loaded.drawings[0]
assert restored.source_id == "comp-42"
assert restored.auto_dimensions is True
assert restored.title == "Persisted Drawing"
assert len(restored.annotations) == 2
a = restored.annotations[0]
assert a.dimension_kind == "length"
assert a.view_id == "front"
assert a.anchors == [(0.0, 0.0), (0.0, 12.5)]
assert a.direction == (0.0, 1.0)
assert a.id == drawing.annotations[0].id
# The restored drawing must still build the same candidates.
cands, resolved, unresolved = build_manual_candidates(
restored, {"front": (1.0, 0.0, 0.0), "top": (1.0, 0.0, 0.0)}
)
assert unresolved == []
labels = sorted(c.label for c in cands)
assert labels == ["12.50", "Ø10.00"]
def test_load_ignores_corrupt_drawing_entry(self, tmp_path):
import zipfile
project = Project(name="Mixed")
project.add_drawing(_drawing_with_manual_dim())
path = save_project(project, str(tmp_path / "proj.fluency"))
with zipfile.ZipFile(path, "r") as zf:
names = zf.namelist()
contents = {n: zf.read(n) for n in names}
manifest = json.loads(contents["project.json"])
manifest["drawings"].append({"id": "broken", "views": "not-a-list"})
contents["project.json"] = json.dumps(manifest).encode("utf-8")
with zipfile.ZipFile(path, "w") as zf:
for name in names:
zf.writestr(name, contents[name])
loaded, _ = load_project(path)
# Corrupt entry skipped, valid one kept.
assert len(loaded.drawings) == 1
assert loaded.drawings[0].source_id == "comp-42"
def test_auto_dimensions_default_off(self):
assert TechnicalDrawing().auto_dimensions is False
# ── Pick geometry helpers ──────────────────────────────────────────────────
class TestPickGeometry:
def test_point_to_segment_inside(self):
q, d = _point_to_segment((5.0, 3.0), (0.0, 0.0), (10.0, 0.0))
assert q == pytest.approx((5.0, 0.0))
assert d == pytest.approx(3.0)
def test_point_to_segment_clamps_at_endpoint(self):
q, d = _point_to_segment((-2.0, 1.0), (0.0, 0.0), (10.0, 0.0))
assert q == pytest.approx((0.0, 0.0))
assert d == pytest.approx(math.hypot(2.0, 1.0))
def test_crossing_segments(self):
q1, q2, d = _closest_points_on_segments(
(0.0, 0.0), (10.0, 0.0), (4.0, -2.0), (4.0, 8.0)
)
assert q1 == pytest.approx((4.0, 0.0))
assert q2 == pytest.approx((4.0, 0.0))
assert d == pytest.approx(0.0, abs=1e-9)
def test_parallel_overlapping_segments(self):
# The classic "distance between two parallel edges" pick:
# result must be the true perpendicular distance.
q1, q2, d = _closest_points_on_segments(
(0.0, 0.0), (10.0, 0.0), (2.0, 5.0), (8.0, 5.0)
)
assert d == pytest.approx(5.0)
assert q1[1] == pytest.approx(0.0)
assert q2[1] == pytest.approx(5.0)
assert q1[0] == pytest.approx(q2[0])
def test_parallel_disjoint_segments(self):
q1, q2, d = _closest_points_on_segments(
(0.0, 0.0), (2.0, 0.0), (5.0, 3.0), (7.0, 3.0)
)
assert d == pytest.approx(math.hypot(3.0, 3.0))
def test_line_intersection(self):
pt = _line_intersection(
(0.0, 0.0), (10.0, 0.0), (4.0, -2.0), (4.0, 8.0)
)
assert pt == pytest.approx((4.0, 0.0))
def test_line_intersection_parallel_is_none(self):
assert _line_intersection(
(0.0, 0.0), (10.0, 0.0), (2.0, 5.0), (8.0, 5.0)
) is None
def test_closest_point_on_segment(self):
q = _closest_point_on_segment((4.0, 9.0), (0.0, 0.0), (10.0, 0.0))
assert q == pytest.approx((4.0, 0.0))
# ── Widget: dimension tool plumbing (offscreen) ────────────────────────────
class TestDrawingWidgetTools:
def _widget(self, qapp):
from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget
return TechnicalDrawingWidget()
def test_widget_starts_without_pick_mode(self, qapp):
w = self._widget(qapp)
assert w._canvas._pick_mode == ""
assert not w._auto_dim_check.isChecked()
assert not any(b.isChecked() for b in w._tool_buttons.values())
def test_tool_toggle_enters_pick_mode(self, qapp):
w = self._widget(qapp)
btn = w._tool_buttons["distance"]
btn.setChecked(True)
assert w._canvas._pick_mode == "distance"
assert "Distance" in w._status_label.text()
assert "edge" in w._status_label.text()
# Switching tools re-targets the canvas and unchecks the old tool.
w._tool_buttons["diameter"].setChecked(True)
assert btn.isChecked() is False
assert w._canvas._pick_mode == "diameter"
# Escape path: cancels the tool, clears all buttons and mode.
w._cancel_pick()
assert w._canvas._pick_mode == ""
for other in w._tool_buttons.values():
assert not other.isChecked()
def test_add_manual_dimension_appends_and_emits(self, qapp):
w = self._widget(qapp)
w.set_drawing(
TechnicalDrawing(source_kind="component", source_id="c1")
)
changes = []
w.drawing_changed.connect(lambda: changes.append(1))
w._add_manual_dimension(
"length",
anchors=((0.0, 0.0), (10.0, 0.0)),
view_id="front",
direction=(1.0, 0.0),
)
anns = w._drawing.annotations
assert len(anns) == 1
assert anns[0].dimension_kind == "length"
assert anns[0].view_id == "front"
assert anns[0].anchors == [(0.0, 0.0), (10.0, 0.0)]
assert changes == [1]
def test_clear_removes_manual_dimensions_only(self, qapp):
w = self._widget(qapp)
drawing = TechnicalDrawing(source_kind="component", source_id="c1")
drawing.annotations.append(
DrawingAnnotation(kind="note", text="keep me")
)
drawing.annotations.append(
_manual_annotation(
"length", "front", ((0.0, 0.0), (5.0, 0.0))
)
)
w.set_drawing(drawing)
w._on_clear_clicked()
assert len(drawing.annotations) == 1
assert drawing.annotations[0].kind == "note"
def test_adopt_stored_project_drawing(self, qapp, kernel):
w = self._widget(qapp)
project = Project()
comp = Component(name="ExistingComp")
project.components[comp.id] = comp
stored = TechnicalDrawing(
source_kind="component", source_id=comp.id
)
stored.annotations.append(
_manual_annotation(
"diameter", "front", ((-4.0, 0.0), (4.0, 0.0))
)
)
project.add_drawing(stored)
w.set_project(project, kernel)
w.set_active_component(comp)
# The stored drawing is re-adopted (not replaced).
assert w._drawing is stored
assert len(w._drawing.annotations) == 1
def test_new_source_creates_and_registers_drawing(self, qapp, kernel):
w = self._widget(qapp)
project = Project()
comp = Component(name="NewComp")
project.components[comp.id] = comp
w.set_project(project, kernel)
w.set_active_component(comp)
assert w._drawing is not None
assert w._drawing.source_kind == "component"
assert w._drawing.source_id == comp.id
assert len(project.drawings) == 1
assert project.drawings[0] is w._drawing
class TestCircleCenterPick:
"""Clicking a circle's centre mark while a distance tool is active
picks a point feature at the circle centre (model coords + radius).
HLR only projects a true circle for some hole shapes (a plain
cylinder discretises into segments), so the render result is built
directly with one guaranteed circle primitive.
"""
def _canvas_with_circle(self, qapp):
from PySide6.QtCore import QPointF
from fluency.technical_drawing import (
DrawingPrimitive,
DrawingRenderResult,
)
from fluency.ui.technical_drawing_widget import DrawingCanvas
# One r4 circle at model (20, 15), drawn at 10× scale at the
# sheet centre: model (20,15) → sheet (200, 150).
circle = DrawingPrimitive(
kind="circle",
points=(),
style="visible",
center=(200.0, 150.0),
radius=40.0,
view_id="top",
)
result = DrawingRenderResult(
primitives=(circle,),
candidates=(),
resolved_annotation_ids=(),
unresolved_annotation_ids=(),
source_fingerprint="",
warnings=(),
view_transforms={"top": (10.0, 0.0, 0.0)},
)
canvas = DrawingCanvas()
canvas.resize(840, 600)
canvas.set_render_result(result)
canvas.set_pick_mode("distance")
return canvas, QPointF
def test_pick_center_mark_returns_point(self, qapp):
canvas, QPointF = self._canvas_with_circle(qapp)
# Sheet → device position of the circle centre.
rect = canvas._sheet_rect()
scale = rect.width() / 420.0
pos = QPointF(rect.x() + 200.0 * scale, rect.y() + (297.0 - 150.0) * scale)
hit = canvas._pick_feature(pos)
assert hit is not None, "clicking the centre mark must hit something"
assert hit["kind"] == "point"
assert hit["view_id"] == "top"
assert hit["radius"] == pytest.approx(4.0, abs=1e-6)
# The picked model point is the inverse-transformed sheet centre:
# (200, 150) at 10× scale → (20, 15).
assert hit["point"] == pytest.approx((20.0, 15.0), abs=1e-9)
def test_distance_tool_accepts_center_then_edge(self, qapp, kernel):
from fluency.ui.technical_drawing_widget import TechnicalDrawingWidget
w = TechnicalDrawingWidget()
project, comp = _cylinder_project(kernel)
w.set_project(project, kernel)
w.set_active_component(comp)
assert w._drawing is not None
# First pick: a circle centre at model (10, 12) — the dict a
# centre-mark click produces (see test above).
w._first_pick = {
"kind": "point",
"view_id": "top",
"point": (10.0, 12.0),
"radius": 4.0,
}
# Second pick: a horizontal edge 6 mm above the centre.
second = {
"kind": "segment",
"view_id": "top",
"p1": (0.0, 18.0),
"p2": (20.0, 18.0),
}
w._on_edge_pick(second, "distance")
assert w._drawing.annotations, "a manual dimension must be appended"
ann = w._drawing.annotations[-1]
assert ann.dimension_kind == "length"
assert ann.view_id == "top"
# First anchor is the picked centre; the second is the closest
# point on the edge, straight above it.
assert ann.anchors[0] == pytest.approx((10.0, 12.0), abs=1e-9)
assert ann.anchors[1] == pytest.approx((10.0, 18.0), abs=1e-6)