ventforge 0.1.1__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {ventforge-0.1.1 → ventforge-0.2.0}/PKG-INFO +16 -5
  2. {ventforge-0.1.1 → ventforge-0.2.0}/README.md +15 -4
  3. {ventforge-0.1.1 → ventforge-0.2.0}/pyproject.toml +1 -1
  4. {ventforge-0.1.1 → ventforge-0.2.0}/tests/test_export.py +4 -1
  5. ventforge-0.2.0/tests/test_mcp.py +94 -0
  6. {ventforge-0.1.1 → ventforge-0.2.0}/tests/test_pattern.py +103 -0
  7. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/export.py +24 -3
  8. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/gui.py +108 -9
  9. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/mcp_server.py +127 -23
  10. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/model.py +62 -8
  11. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/pattern.py +121 -11
  12. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge.egg-info/PKG-INFO +16 -5
  13. ventforge-0.1.1/tests/test_mcp.py +0 -12
  14. {ventforge-0.1.1 → ventforge-0.2.0}/LICENSE +0 -0
  15. {ventforge-0.1.1 → ventforge-0.2.0}/setup.cfg +0 -0
  16. {ventforge-0.1.1 → ventforge-0.2.0}/tests/test_gui_smoke.py +0 -0
  17. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/__init__.py +0 -0
  18. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/__main__.py +0 -0
  19. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/presets/halftone_fade.vent.json +0 -0
  20. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/presets/hex_honeycomb.vent.json +0 -0
  21. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/presets/spiral_dashes.vent.json +0 -0
  22. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/presets/sunflower.vent.json +0 -0
  23. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/presets/voronoi_stone.vent.json +0 -0
  24. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge/presets/wavy_flowlines.vent.json +0 -0
  25. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge.egg-info/SOURCES.txt +0 -0
  26. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge.egg-info/dependency_links.txt +0 -0
  27. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge.egg-info/entry_points.txt +0 -0
  28. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge.egg-info/requires.txt +0 -0
  29. {ventforge-0.1.1 → ventforge-0.2.0}/ventforge.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ventforge
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Parametric vent pattern designer with native GUI and MCP server
5
5
  License-Expression: GPL-3.0-or-later
6
6
  Keywords: vent,pattern,3d-printing,cnc,cad,mcp,parametric
@@ -43,10 +43,20 @@ MCP server. Both work on the same design file.
43
43
  - **Gradients:** vary size, spacing, angle or shape across the plate. Drive them with points, lines or
44
44
  curves, with noise, or with a grayscale image.
45
45
  - **Boundary:** none, circle, rounded rectangle, or a drawn outline, with margin and clipping.
46
+ - **Fan grilles:** one click for a 40–140 mm fan: holes only over the blades, 4 screw holes, and the
47
+ share of the fan opening left open (airflow).
48
+ - **Keep-outs:** circles or rectangles that stay free of holes (bosses, logos, screws), optionally cut as holes.
49
+ Draw them on the canvas with the Keep-out tool (K; Shift for a circle, right-click to remove).
50
+ - **Machining:** CNC tool radius (rounds inside corners, drops holes the cutter cannot reach) and laser
51
+ kerf compensation for SVG/DXF. Set a minimum feature (about 2× nozzle for 3D printing) to flag holes
52
+ that would close up.
46
53
  - **Export:** PNG, SVG, DXF, STEP, STL. You can also wrap the pattern onto a tube or cut it into an
47
54
  existing STEP part.
48
- - **Checks:** hole count, open area, and warnings where walls get thinner than your minimum.
49
- - Undo/redo, presets, and a personal library.
55
+ - **Checks:** hole count, open area, the thinnest wall in mm (marked on the canvas), and warnings where walls
56
+ get thinner than your minimum.
57
+ - **Airflow estimate:** pressure loss of the grille (Idelchik thin perforated plate) at 2 m/s, so two designs
58
+ can be compared by airflow. A rough figure, not a flow simulation.
59
+ - Undo/redo, presets, and a personal library (also from the AI assistant: `save_to_library`).
50
60
 
51
61
  ## Install
52
62
 
@@ -124,8 +134,9 @@ edges, and export it as STEP."*
124
134
  The assistant works on `~/Documents/VentForge/current.vent.json`. If you open that file in the editor
125
135
  (or ask the assistant to "open it in the GUI"), you can watch the design change live.
126
136
 
127
- **Tools the server offers:** `new_design`, `open_design`, `list_presets`, `get_design`,
128
- `describe_parameters`, `set_params`, `add_control`, `clear_controls`, `preview`, `export`,
137
+ **Tools the server offers:** `new_design`, `open_design`, `list_presets`, `save_to_library`, `get_design`,
138
+ `describe_parameters`, `set_params`, `add_control`, `clear_controls`, `fan_grille`,
139
+ `fit_open_area` (solve a parameter for a target open area), `preview`, `export`,
129
140
  `wrap_cylinder`, `cut_into_part`, `open_in_gui`.
130
141
 
131
142
  ## Project layout
@@ -12,10 +12,20 @@ MCP server. Both work on the same design file.
12
12
  - **Gradients:** vary size, spacing, angle or shape across the plate. Drive them with points, lines or
13
13
  curves, with noise, or with a grayscale image.
14
14
  - **Boundary:** none, circle, rounded rectangle, or a drawn outline, with margin and clipping.
15
+ - **Fan grilles:** one click for a 40–140 mm fan: holes only over the blades, 4 screw holes, and the
16
+ share of the fan opening left open (airflow).
17
+ - **Keep-outs:** circles or rectangles that stay free of holes (bosses, logos, screws), optionally cut as holes.
18
+ Draw them on the canvas with the Keep-out tool (K; Shift for a circle, right-click to remove).
19
+ - **Machining:** CNC tool radius (rounds inside corners, drops holes the cutter cannot reach) and laser
20
+ kerf compensation for SVG/DXF. Set a minimum feature (about 2× nozzle for 3D printing) to flag holes
21
+ that would close up.
15
22
  - **Export:** PNG, SVG, DXF, STEP, STL. You can also wrap the pattern onto a tube or cut it into an
16
23
  existing STEP part.
17
- - **Checks:** hole count, open area, and warnings where walls get thinner than your minimum.
18
- - Undo/redo, presets, and a personal library.
24
+ - **Checks:** hole count, open area, the thinnest wall in mm (marked on the canvas), and warnings where walls
25
+ get thinner than your minimum.
26
+ - **Airflow estimate:** pressure loss of the grille (Idelchik thin perforated plate) at 2 m/s, so two designs
27
+ can be compared by airflow. A rough figure, not a flow simulation.
28
+ - Undo/redo, presets, and a personal library (also from the AI assistant: `save_to_library`).
19
29
 
20
30
  ## Install
21
31
 
@@ -93,8 +103,9 @@ edges, and export it as STEP."*
93
103
  The assistant works on `~/Documents/VentForge/current.vent.json`. If you open that file in the editor
94
104
  (or ask the assistant to "open it in the GUI"), you can watch the design change live.
95
105
 
96
- **Tools the server offers:** `new_design`, `open_design`, `list_presets`, `get_design`,
97
- `describe_parameters`, `set_params`, `add_control`, `clear_controls`, `preview`, `export`,
106
+ **Tools the server offers:** `new_design`, `open_design`, `list_presets`, `save_to_library`, `get_design`,
107
+ `describe_parameters`, `set_params`, `add_control`, `clear_controls`, `fan_grille`,
108
+ `fit_open_area` (solve a parameter for a target open area), `preview`, `export`,
98
109
  `wrap_cylinder`, `cut_into_part`, `open_in_gui`.
99
110
 
100
111
  ## Project layout
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ventforge"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  description = "Parametric vent pattern designer with native GUI and MCP server"
5
5
  readme = "README.md"
6
6
  license = "GPL-3.0-or-later"
@@ -170,7 +170,10 @@ def _box_step(path, lx, ly, lz):
170
170
  ex._write(BRepPrimAPI_MakeBox(gp_Pnt(-lx / 2, -ly / 2, 0), lx, ly, lz).Shape(), path)
171
171
 
172
172
 
173
- def test_cut_into_part(tmp):
173
+ @pytest.mark.parametrize("tiled", [False, True])
174
+ def test_cut_into_part(tmp, monkeypatch, tiled):
175
+ if tiled: # force the split-cut-merge path that big vents (> UNTILED_MAX holes) take
176
+ monkeypatch.setattr(ex, "UNTILED_MAX", 0)
174
177
  _box_step(tmp / "box.step", 120, 120, 10)
175
178
  pts = _grid(10, 8.0)
176
179
  res = _result([Point(x, y).buffer(2, 64) for x, y in pts] + [_hex(0, 0, 3)],
@@ -0,0 +1,94 @@
1
+ import pytest
2
+ """Runs the MCP server over real stdio, the way Claude talks to it."""
3
+ import asyncio
4
+
5
+ from mcp_e2e import main
6
+
7
+
8
+ def test_mcp_server_end_to_end(tmp_path):
9
+ asyncio.run(main(tmp_path))
10
+ assert (tmp_path / "e2e_preview.png").stat().st_size > 0
11
+ for ext in ("svg", "dxf", "step", "stl"):
12
+ assert (tmp_path / f"e2e.{ext}").stat().st_size > 0
13
+ assert (tmp_path / "e2e_tube.step").stat().st_size > 0
14
+
15
+
16
+ def test_bad_patch_leaves_file_untouched(tmp_path):
17
+ import pytest
18
+ from ventforge import mcp_server as m
19
+ p = tmp_path / "d.vent.json"
20
+ m.new_design(path=str(p))
21
+ before = p.read_bytes()
22
+ with pytest.raises(ValueError):
23
+ m.set_params({"dot": {"shape": "blob"}})
24
+ assert p.read_bytes() == before
25
+
26
+
27
+ def test_set_params_keeps_an_edit_saved_meanwhile(tmp_path, monkeypatch):
28
+ """The editor saving while set_params generates must not be overwritten."""
29
+ import os
30
+ from ventforge import mcp_server as m
31
+ from ventforge.model import Design
32
+ p = tmp_path / "d.vent.json"
33
+ m.new_design(path=str(p))
34
+ real, calls = m._generate, []
35
+
36
+ def generate_while_editor_saves(d):
37
+ if not calls:
38
+ e = Design.load(p)
39
+ e.dot.diameter = 2.5
40
+ e.save(p)
41
+ os.utime(p, ns=(p.stat().st_atime_ns, p.stat().st_mtime_ns + 10**9)) # coarse clocks
42
+ calls.append(1)
43
+ return real(d)
44
+
45
+ monkeypatch.setattr(m, "_generate", generate_while_editor_saves)
46
+ m.set_params({"canvas": {"width": 140}})
47
+ d = Design.load(p)
48
+ assert d.dot.diameter == 2.5 and d.canvas.width == 140
49
+
50
+
51
+ def test_fit_open_area_hits_target(tmp_path):
52
+ from ventforge import mcp_server as m
53
+ m.new_design(path=str(tmp_path / "f.vent.json"))
54
+ out = m.fit_open_area(0.3)
55
+ assert abs(out["stats"]["open_area_ratio"] - 0.3) < 0.01
56
+ assert m.get_design()["design"]["dot"]["diameter"] == out["dot.diameter"]
57
+
58
+
59
+ def test_fit_open_area_refuses_unreachable_target(tmp_path):
60
+ from ventforge import mcp_server as m
61
+ m.new_design(path=str(tmp_path / "f.vent.json"))
62
+ before = (tmp_path / "f.vent.json").read_text()
63
+ with pytest.raises(ValueError, match="outside"):
64
+ m.fit_open_area(0.3, low=0.5, high=0.6)
65
+ with pytest.raises(ValueError, match="ratio"):
66
+ m.fit_open_area(30)
67
+ with pytest.raises(ValueError, match="unknown parameter"):
68
+ m.fit_open_area(0.3, param="dot.nope")
69
+ assert (tmp_path / "f.vent.json").read_text() == before
70
+
71
+
72
+ def test_fan_grille_tool_saves_and_reports_airflow(tmp_path):
73
+ from ventforge import mcp_server as m
74
+ m.new_design(path=str(tmp_path / "f.vent.json"))
75
+ out = m.fan_grille(80)
76
+ d = m.get_design()["design"]
77
+ assert d["canvas"]["width"] == 80 and len(d["keepouts"]) == 4
78
+ assert 0 < out["stats"]["fan_open_ratio"] < 1
79
+ fit = m.fit_open_area(0.5) # targets the fan opening now
80
+ assert abs(fit["stats"]["fan_open_ratio"] - 0.5) < 0.01
81
+
82
+
83
+ def test_library_round_trip(tmp_path, monkeypatch):
84
+ from ventforge import mcp_server as m
85
+ monkeypatch.setattr(m, "HOME", tmp_path)
86
+ m.new_design(path=str(tmp_path / "work.vent.json"))
87
+ m.fan_grille(92)
88
+ m.save_to_library("92mm case X")
89
+ assert "92mm case X" in m.list_presets()["library"]
90
+ m.new_design(path=str(tmp_path / "other.vent.json"), preset="92mm case X")
91
+ assert len(m.get_design()["design"]["keepouts"]) == 4
92
+ for bad in ("../evil", "a/b", ""):
93
+ with pytest.raises(ValueError):
94
+ m.save_to_library(bad)
@@ -1,3 +1,4 @@
1
+ import math
1
2
  import time
2
3
  from pathlib import Path
3
4
 
@@ -305,6 +306,14 @@ def test_unknown_choice_rejected():
305
306
  Design().patched({"pattern": {"crosslines": {"rotate_by": "row"}}})
306
307
 
307
308
 
309
+ def test_old_file_with_bad_choice_still_loads(tmp_path):
310
+ # 0.1.0 advertised row/col for crosslines; such files must open, falling back to the default
311
+ p = tmp_path / "old.vent.json"
312
+ p.write_text('{"pattern": {"mode": "crosslines", "crosslines": {"rotate_by": "row"}}}')
313
+ d = Design.load(p)
314
+ assert d.pattern.crosslines.rotate_by == "both" and d.pattern.mode == "crosslines"
315
+
316
+
308
317
  def test_fibonacci_even_density_with_hole():
309
318
  d = Design().patched({"canvas": {"width": 200, "height": 200}, "dot": {"diameter": 1},
310
319
  "pattern": {"mode": "fibonacci", "fibonacci": {
@@ -324,3 +333,97 @@ def test_huge_concentric_radius_and_path_layers_stay_fast():
324
333
  t = time.perf_counter()
325
334
  assert generate(Design().patched(patch)).holes
326
335
  assert time.perf_counter() - t < 4, patch # was out-of-memory / 5 s
336
+
337
+
338
+ @pytest.mark.parametrize("patch", [
339
+ {"canvas": {"width": 2000, "height": 2000}, "pattern": {"grid": {"spacing_x": 1, "spacing_y": 1}}},
340
+ {"canvas": {"width": 3000, "height": 3000}, "pattern": {"mode": "voronoi", "voronoi": {"cell_size": 1}}},
341
+ {"canvas": {"width": 3000, "height": 3000}, "pattern": {"mode": "concentric"}},
342
+ ])
343
+ def test_huge_layouts_refused_fast(patch):
344
+ t = time.perf_counter()
345
+ with pytest.raises(ValueError, match="points"):
346
+ generate(Design().patched(patch))
347
+ assert time.perf_counter() - t < 2 # was minutes and gigabytes
348
+
349
+
350
+ def test_bad_types_rejected():
351
+ with pytest.raises(ValueError, match="boundary.clip"):
352
+ Design().patched({"boundary": {"clip": "false"}}) # bool("false") would switch clipping on
353
+ with pytest.raises(ValueError, match="dot must be an object"):
354
+ Design().patched({"dot": 5})
355
+
356
+
357
+ def test_fan_grille_holes_over_blades_and_screw_holes():
358
+ from ventforge.model import FANS, fan_grille
359
+ d = fan_grille(Design(), 120)
360
+ r = generate(d)
361
+ pitch, screw = FANS[120]
362
+ screws = {(round(c[0], 2), round(c[1], 2)) for c in r.circles if c and abs(c[2] - screw / 2) < 1e-9}
363
+ assert screws == {(sx * pitch / 2, sy * pitch / 2) for sx in (-1, 1) for sy in (-1, 1)}
364
+ opening = shapely.Point(0, 0).buffer(d.boundary.vent_diameter / 2 + 1e-6)
365
+ assert sum(opening.contains(h) for h in r.holes) == len(r.holes) - 4
366
+ assert 0.2 < r.stats["fan_open_ratio"] < 0.8
367
+ with pytest.raises(ValueError, match="fan size"):
368
+ fan_grille(Design(), 77)
369
+
370
+
371
+ def test_keepout_stays_free():
372
+ d = Design().patched({"keepouts": [{"shape": "rectangle", "x": 10, "y": -5, "width": 30, "height": 12,
373
+ "clearance": 2}]})
374
+ zone = shapely.box(10 - 17, -5 - 8, 10 + 17, -5 + 8).buffer(-1e-6) # touching the edge is fine
375
+ holes = generate(d).holes
376
+ assert holes and not any(zone.intersects(h) for h in holes)
377
+
378
+
379
+ def test_tool_radius_rounds_corners_and_drops_too_small():
380
+ d = Design().patched({"dot": {"shape": "rounded_rectangle", "corner_radius": 0, "diameter": 4}})
381
+ sharp = generate(d)
382
+ d.machining.tool_radius = 1.0
383
+ r = generate(d)
384
+ assert len(r.holes) == len(sharp.holes)
385
+ assert r.holes[0].area == pytest.approx(16 - (4 - math.pi) * 1.0, rel=0.02) # 4 corners lose (1 - pi/4) r^2
386
+ d.machining.tool_radius = 2.5 # wider than the 4 mm holes
387
+ assert generate(d).stats["dropped_by_tool"] == len(sharp.holes)
388
+
389
+
390
+ def test_kerf_offsets_cut_paths():
391
+ from ventforge.export import cut_paths
392
+ d = Design()
393
+ d.machining.kerf = 0.2
394
+ r = generate(d)
395
+ c = cut_paths(r, d)
396
+ assert c.circles[0][2] == pytest.approx(r.circles[0][2] - 0.1)
397
+ assert c.outline.area > r.outline.area
398
+
399
+
400
+ def test_keepout_hole_is_cut_with_free_clearance_around_it():
401
+ d = Design().patched({"keepouts": [{"shape": "rectangle", "x": 0, "y": 0, "width": 10, "height": 6,
402
+ "hole": True}]})
403
+ r = generate(d)
404
+ assert any(h.equals_exact(shapely.box(-5, -3, 5, 3), 1e-6) or abs(h.area - 60) < 1e-6 for h in r.holes)
405
+ assert not any(h.intersects(shapely.box(-6.4, -4.4, 6.4, 4.4)) for h in r.holes if abs(h.area - 60) > 1e-6)
406
+
407
+
408
+ def test_thinnest_wall_found_and_located():
409
+ d = Design().patched({"pattern": {"mode": "custom_points", "custom_points": {"points": [[0, 0], [5, 0], [30, 30]]}},
410
+ "dot": {"diameter": 4}})
411
+ s = generate(d).stats
412
+ assert s["thinnest_wall"] == pytest.approx(1.0, abs=0.01) # centres 5 apart, radius 2 each
413
+ assert s["thinnest_wall_at"] == pytest.approx([2.5, 0], abs=0.05)
414
+
415
+
416
+ def test_airflow_loss_drops_as_open_area_grows():
417
+ small = generate(Design().patched({"dot": {"diameter": 2}})).stats
418
+ big = generate(Design().patched({"dot": {"diameter": 4}})).stats
419
+ assert big["open_area_ratio"] > small["open_area_ratio"]
420
+ assert 0 < big["pressure_drop_pa"] < small["pressure_drop_pa"]
421
+ assert big["pressure_drop_pa"] == pytest.approx(big["loss_coefficient"] * 1.2 * 4 / 2)
422
+
423
+
424
+ def test_min_feature_flags_narrow_holes():
425
+ d = Design().patched({"dot": {"diameter": 3}})
426
+ assert "below_min_feature" not in generate(d).stats
427
+ d.machining.min_feature = 3.5
428
+ s = generate(d).stats
429
+ assert s["below_min_feature"] == s["count"]
@@ -12,7 +12,7 @@ import time
12
12
  from pathlib import Path
13
13
 
14
14
  import shapely
15
- from shapely.geometry import MultiPolygon, Polygon
15
+ from shapely.geometry import Polygon
16
16
  from shapely.geometry.polygon import orient
17
17
  from shapely.strtree import STRtree
18
18
 
@@ -618,14 +618,35 @@ def cut_into_part(result: Result, design: Design, part_path, out_path, origin=(0
618
618
 
619
619
 
620
620
  # --------------------------------------------------------------------------- dispatch
621
+ def cut_paths(result: Result, design: Design) -> Result:
622
+ """Laser/waterjet kerf: the beam centre runs kerf/2 inside each hole and outside the outline, so the cut
623
+ part has the designed size. Holes narrower than the kerf are dropped."""
624
+ k = design.machining.kerf / 2
625
+ if k <= 0:
626
+ return result
627
+ holes, circles = [], []
628
+ for i, h in enumerate(result.holes):
629
+ c = _circle(result, i)
630
+ if c:
631
+ if c[2] > k:
632
+ holes.append(h)
633
+ circles.append((c[0], c[1], c[2] - k))
634
+ continue
635
+ for p in _parts(h.buffer(-k, join_style="mitre")):
636
+ holes.append(p)
637
+ circles.append(None)
638
+ return Result(holes=holes, outline=result.outline.buffer(k, join_style="mitre"), stats=result.stats,
639
+ circles=circles)
640
+
641
+
621
642
  def export(result: Result, design: Design, path) -> None:
622
643
  ext = Path(path).suffix.lower()
623
644
  if ext == ".svg":
624
- to_svg(result, design, path)
645
+ to_svg(cut_paths(result, design), design, path)
625
646
  elif ext == ".png":
626
647
  to_png(result, design, path)
627
648
  elif ext == ".dxf":
628
- to_dxf(result, path)
649
+ to_dxf(cut_paths(result, design), path)
629
650
  elif ext in (".step", ".stp"):
630
651
  to_step(result, design, path)
631
652
  elif ext == ".stl":
@@ -12,21 +12,21 @@ from dataclasses import fields, is_dataclass
12
12
  from pathlib import Path
13
13
  from typing import get_type_hints
14
14
 
15
- from PySide6.QtCore import QPointF, QRectF, QSettings, QSize, Qt, QThread, QTimer, QFileSystemWatcher, Signal
15
+ from PySide6.QtCore import QPointF, QRectF, QSettings, QSize, QSizeF, Qt, QThread, QTimer, QFileSystemWatcher, Signal
16
16
  from PySide6.QtGui import (QAction, QBrush, QColor, QIcon, QKeySequence, QPainter, QPainterPath,
17
17
  QPainterPathStroker, QPalette, QPen, QPixmap, QPolygonF, QTransform)
18
18
  from PySide6.QtWidgets import (QAbstractSpinBox, QApplication, QButtonGroup, QCheckBox, QColorDialog, QComboBox,
19
19
  QDialog, QDialogButtonBox, QDockWidget, QDoubleSpinBox, QFileDialog, QFormLayout,
20
20
  QGraphicsEllipseItem, QGraphicsItem, QGraphicsPixmapItem, QGraphicsScene,
21
- QGraphicsView, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QListWidget,
21
+ QGraphicsView, QGridLayout, QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListWidget,
22
22
  QListWidgetItem, QMainWindow, QMessageBox, QProgressBar, QPushButton, QScrollArea,
23
23
  QSizePolicy, QSlider, QSpinBox, QStackedWidget, QStyle, QTabWidget, QToolButton,
24
24
  QVBoxLayout, QWidget)
25
25
 
26
- from .model import (CHOICES, MODES, SHAPES, Control, Design, DotStyle, Gradient, Pattern)
26
+ from .model import (CHOICES, FANS, GRADS, MODES, SHAPES, Control, Design, DotStyle, Gradient, Keepout,
27
+ Machining, Pattern, fan_grille)
27
28
 
28
29
  PRESETS = Path(__file__).resolve().parent / "presets"
29
- GRADS = ("size", "spacing", "angle", "shape")
30
30
 
31
31
 
32
32
  def pattern(): # imported lazily so the GUI module loads even while pattern.py is missing
@@ -45,6 +45,7 @@ META = { # name: (min, max, step, suffix)
45
45
  "opacity": (0, 1, 0.05, ""), "offset_x": (-500, 500, 0.5, " mm"), "offset_y": (-500, 500, 0.5, " mm"),
46
46
  "turns": (0.1, 200, 0.5, ""), "spread": (0.1, 50, 0.1, ""), "width": (1, 5000, 1, " mm"),
47
47
  "height": (1, 5000, 1, " mm"), "thickness": (0.1, 100, 0.1, " mm"), "diameter": (0.1, 500, 0.1, " mm"),
48
+ "kerf": (0, 2, 0.01, " mm"), "tool_radius": (0, 20, 0.05, " mm"), "min_feature": (0, 20, 0.05, " mm"),
48
49
  }
49
50
  MM_WORDS = ("spacing", "radius", "diameter", "distance", "width", "margin", "size", "amplitude",
50
51
  "wavelength", "scale", "min_wall")
@@ -73,7 +74,8 @@ SOFT_PATH = {
73
74
  "pattern.concentric.inner_radius": (0, 60), "pattern.concentric.outer_radius": (0, 70),
74
75
  "pattern.fibonacci.count": (10, 700), "pattern.fibonacci.spread": (1, 10),
75
76
  "pattern.fibonacci.inner_radius": (0, 40), "pattern.crosslines.rotation": (-90, 90),
76
- "pattern.voronoi.corner_radius": (0, 5), "pattern.flowlines.line_spacing": (2, 30),
77
+ "pattern.voronoi.corner_radius": (0, 5), "boundary.vent_diameter": (0, 300),
78
+ "machining.kerf": (0, 0.5), "machining.tool_radius": (0, 3), "machining.min_feature": (0, 3), "pattern.flowlines.line_spacing": (2, 30),
77
79
  }
78
80
  SOFT = {"diameter": (0.3, 20), "thickness": (0.5, 10), "margin": (0, 20), "corner_radius": (0, 20),
79
81
  "aspect": (0.2, 5), "sides": (3, 12), "star_points": (3, 12), "star_inner": (0.1, 0.9),
@@ -196,6 +198,13 @@ def _add_geom(path, g):
196
198
  path.closeSubpath()
197
199
 
198
200
 
201
+ def add_keepout_path(path, k):
202
+ if k.shape == "rectangle":
203
+ path.addRect(QRectF(S(k.x - k.width / 2, k.y + k.height / 2), QSizeF(k.width, k.height)))
204
+ else:
205
+ path.addEllipse(S(k.x, k.y), k.width / 2, k.width / 2)
206
+
207
+
199
208
  def result_paths(r):
200
209
  holes = QPainterPath()
201
210
  holes.setFillRule(Qt.OddEvenFill)
@@ -540,6 +549,12 @@ class Canvas(QGraphicsView):
540
549
  self.decor.setZValue(3)
541
550
  self.draft = sc.addPath(QPainterPath(), QPen(QColor(255, 120, 120), 0))
542
551
  self.draft.setZValue(4)
552
+ self.keep = sc.addPath(QPainterPath(), QPen(QColor(255, 170, 60), 0, Qt.DashLine))
553
+ self.keep.setZValue(3)
554
+ self.weak = sc.addEllipse(-7, -7, 14, 14, QPen(QColor(255, 80, 80), 2)) # thinnest wall marker, in px
555
+ self.weak.setFlag(QGraphicsItem.ItemIgnoresTransformations)
556
+ self.weak.setZValue(6)
557
+ self.weak.setVisible(False)
543
558
  self.ruler = sc.addLine(0, 0, 0, 0, QPen(QColor(255, 255, 255), 0))
544
559
  self.ruler.setZValue(5)
545
560
  self.ruler_txt = sc.addSimpleText("")
@@ -574,6 +589,16 @@ class Canvas(QGraphicsView):
574
589
  self.holes.setBrush(QColor(color))
575
590
  self.plate.setPath(outline)
576
591
 
592
+ def set_overlays(self, keepouts, weak, bad):
593
+ path = QPainterPath()
594
+ for k in keepouts:
595
+ add_keepout_path(path, k)
596
+ self.keep.setPath(path)
597
+ self.weak.setVisible(weak is not None)
598
+ if weak is not None:
599
+ self.weak.setPos(S(*weak))
600
+ self.weak.setPen(QPen(QColor(255, 80, 80) if bad else QColor(240, 210, 90), 2))
601
+
577
602
  def set_background(self, pm, w, h, opacity):
578
603
  self.bg.setVisible(pm is not None)
579
604
  if pm is not None:
@@ -692,6 +717,11 @@ class Canvas(QGraphicsView):
692
717
  if self.tool == "ruler" and e.button() == Qt.LeftButton:
693
718
  self.ruler_start = pos
694
719
  self._ruler(pos)
720
+ elif self.tool == "keepout":
721
+ if e.button() == Qt.RightButton:
722
+ self.win.remove_keepout(pos.x(), -pos.y())
723
+ elif e.button() == Qt.LeftButton:
724
+ self.ruler_start = pos
695
725
  elif self.tool == "draw":
696
726
  if e.button() == Qt.RightButton:
697
727
  self.finish_draw()
@@ -715,6 +745,12 @@ class Canvas(QGraphicsView):
715
745
  self._ruler(pos)
716
746
  elif self.tool == "draw" and self.draft_pts:
717
747
  self._draft(pos)
748
+ elif self.tool == "keepout" and self.ruler_start is not None:
749
+ path = QPainterPath()
750
+ if k := self._keepout(pos, e.modifiers()):
751
+ self.win.statusBar().showMessage(f"{k.shape} {k.width:.1f} × {k.height:.1f} mm at ({k.x:.1f}, {k.y:.1f})")
752
+ add_keepout_path(path, k)
753
+ self.draft.setPath(path)
718
754
  else:
719
755
  super().mouseMoveEvent(e)
720
756
 
@@ -723,6 +759,13 @@ class Canvas(QGraphicsView):
723
759
  self.pan_pos = None
724
760
  self.viewport().setCursor(Qt.CrossCursor if self.tool else Qt.OpenHandCursor)
725
761
  return
762
+ if self.tool == "keepout" and self.ruler_start is not None and e.button() == Qt.LeftButton:
763
+ k = self._keepout(self.mapToScene(e.position().toPoint()), e.modifiers())
764
+ self.ruler_start = None
765
+ self.draft.setPath(QPainterPath())
766
+ if k:
767
+ self.win.add_keepout(k)
768
+ return
726
769
  super().mouseReleaseEvent(e)
727
770
  if self.tool is None:
728
771
  self.win.commit() # end of a handle drag
@@ -761,6 +804,18 @@ class Canvas(QGraphicsView):
761
804
  self.ruler_txt.setPos(pos + QPointF(2 / self.transform().m11(), 0))
762
805
  self.win.statusBar().showMessage(f"Distance {d:.2f} mm, dx {pos.x() - a.x():.2f}, dy {a.y() - pos.y():.2f}")
763
806
 
807
+ def _keepout(self, pos, mods):
808
+ """Drag corner to corner for a rectangle; with Shift, from the centre for a circle."""
809
+ a = self.ruler_start
810
+ if mods & Qt.ShiftModifier:
811
+ r = math.hypot(pos.x() - a.x(), pos.y() - a.y())
812
+ return Keepout("circle", round(a.x(), 2), round(-a.y(), 2), round(2 * r, 2), round(2 * r, 2)) if r > .25 else None
813
+ w, h = abs(pos.x() - a.x()), abs(pos.y() - a.y())
814
+ if min(w, h) < .5:
815
+ return None
816
+ return Keepout("rectangle", round((a.x() + pos.x()) / 2, 2), round(-(a.y() + pos.y()) / 2, 2),
817
+ round(w, 2), round(h, 2))
818
+
764
819
  def _draft(self, cursor):
765
820
  path = QPainterPath(self.draft_pts[0])
766
821
  for q in self.draft_pts[1:] + [cursor]:
@@ -841,7 +896,9 @@ class MainWindow(QMainWindow):
841
896
  tb.addSeparator()
842
897
  self.ruler_act = act("Ruler", lambda on: self.set_tool("ruler" if on else None), "R", True)
843
898
  self.draw_act = act("Draw outline", lambda on: self.set_tool("draw" if on else None), "B", True)
899
+ self.keep_act = act("Keep-out", lambda on: self.set_tool("keepout" if on else None), "K", True)
844
900
  act("Zoom to fit", self.canvas.zoom_fit, "Z")
901
+ act("Fan grille…", self.fan_dialog).setToolTip("Plate for a standard fan: blade opening and 4 screw holes")
845
902
  tb.addSeparator()
846
903
  menu_btn = QToolButton(text=" Export ", popupMode=QToolButton.InstantPopup)
847
904
  menu_btn.setObjectName("export")
@@ -964,6 +1021,10 @@ class MainWindow(QMainWindow):
964
1021
  bf.addRow(row(button("Draw outline", lambda: self.draw_act.trigger(),
965
1022
  "Click points on the canvas; double-click, Enter or right-click to finish"),
966
1023
  button("Clear outline", lambda: self.set_param("boundary.points", []))))
1024
+ bf.addRow("Keep-outs", self.field_widget("keepouts", "keepouts", list))
1025
+ bf.addRow(row(button("Clear keep-outs", lambda: self.set_param("keepouts", []),
1026
+ "Screw holes and hole-free areas (set by Fan grille or the MCP server)")))
1027
+ mcw, _, _ = self.form("machining", Machining)
967
1028
  # background
968
1029
  gw = QWidget()
969
1030
  gf = QFormLayout(gw)
@@ -974,7 +1035,7 @@ class MainWindow(QMainWindow):
974
1035
  gf.addRow(row(button("Load image…", self.load_background),
975
1036
  button("Clear", lambda: self.set_param("background.image", ""))))
976
1037
  self._dock("Design", [Section("Library", lib), Section("Canvas", cw), Section("Boundary", bw),
977
- Section("Background image", gw, False)], Qt.LeftDockWidgetArea)
1038
+ Section("Machining", mcw, False), Section("Background image", gw, False)], Qt.LeftDockWidgetArea)
978
1039
 
979
1040
  def _right_dock(self):
980
1041
  self.grad_tabs = QTabWidget()
@@ -1049,7 +1110,9 @@ class MainWindow(QMainWindow):
1049
1110
  elif isinstance(w, QLineEdit):
1050
1111
  w.setText(str(v))
1051
1112
  elif isinstance(w, QLabel):
1052
- w.setText(f"{len(v)} point(s) — edit via file / MCP" if v else "none (default)")
1113
+ w.setText(f"{len(v)} {'keep-out' if path == 'keepouts' else 'point'}(s)" + (" — draw with K, right-click to remove"
1114
+ if path == 'keepouts' else " — edit via file / MCP")
1115
+ if v else "none (default)")
1053
1116
  w.blockSignals(False)
1054
1117
  for w in self.findChildren(SliderField): # canvas-relative slider ranges
1055
1118
  if getattr(w, "path", None) in SCALE:
@@ -1275,11 +1338,39 @@ class MainWindow(QMainWindow):
1275
1338
  def set_tool(self, tool):
1276
1339
  self.ruler_act.setChecked(tool == "ruler")
1277
1340
  self.draw_act.setChecked(tool == "draw")
1341
+ self.keep_act.setChecked(tool == "keepout")
1278
1342
  self.canvas.set_tool(tool)
1343
+ if tool == "keepout":
1344
+ self.statusBar().showMessage("Drag a hole-free rectangle (Shift: circle from its centre); right-click one to"
1345
+ " remove it, Esc to finish")
1279
1346
  if tool == "draw":
1280
1347
  self.statusBar().showMessage("Click to add outline points; double-click / Enter / right-click to finish,"
1281
1348
  " Esc to cancel")
1282
1349
 
1350
+ def fan_dialog(self):
1351
+ sizes = [f"{s} mm" for s in FANS]
1352
+ s, ok = QInputDialog.getItem(self, "Fan grille", "Fan size (the hole pattern stays):", sizes, sizes.index("120 mm"),
1353
+ False)
1354
+ if ok:
1355
+ self.need_fit = True
1356
+ self.set_design(fan_grille(clone(self.design), int(s.split()[0])))
1357
+ self.dirty = True
1358
+ self.autosave()
1359
+
1360
+ def add_keepout(self, k):
1361
+ self.design.keepouts.append(k)
1362
+ self.changed(sync=True)
1363
+ self.commit()
1364
+
1365
+ def remove_keepout(self, x, y):
1366
+ from .pattern import _keepout_geom
1367
+ from shapely import Point
1368
+ hit = [i for i, k in enumerate(self.design.keepouts) if _keepout_geom(k).buffer(1).contains(Point(x, y))]
1369
+ if hit:
1370
+ del self.design.keepouts[hit[-1]] # the topmost (last drawn)
1371
+ self.changed(sync=True)
1372
+ self.commit()
1373
+
1283
1374
  def set_custom_boundary(self, pts):
1284
1375
  self.design.boundary.points = pts
1285
1376
  self.design.boundary.type = "custom"
@@ -1322,9 +1413,17 @@ class MainWindow(QMainWindow):
1322
1413
  self.canvas.set_result(holes, outline, self.design.dot.color)
1323
1414
  s = r.stats
1324
1415
  self.st["count"].setText(f"{s.get('count', len(r.holes))} dots")
1325
- self.st["open"].setText(f"open {100 * s.get('open_area_ratio', 0):.1f} %")
1416
+ self.st["open"].setText(f"open {100 * s.get('open_area_ratio', 0):.1f} %"
1417
+ + (f" · fan {100 * s['fan_open_ratio']:.0f} % open" if "fan_open_ratio" in s else "")
1418
+ + (f" · {s['dropped_by_tool']} too small for the tool" if s.get("dropped_by_tool") else ""))
1326
1419
  v = s.get("min_wall_violations", 0)
1327
- self.st["viol"].setText(f"min-wall violations {v}")
1420
+ tw = s.get("thinnest_wall")
1421
+ self.st["viol"].setText(f"min-wall violations {v}" + (f" · thinnest wall {tw:.2f} mm" if tw else "")
1422
+ + (f" · {s['below_min_feature']} holes below min feature"
1423
+ if s.get("below_min_feature") else "")
1424
+ + (f" · airflow loss {s['pressure_drop_pa']:.0f} Pa @2 m/s"
1425
+ if "pressure_drop_pa" in s else ""))
1426
+ self.canvas.set_overlays(self.design.keepouts, s.get("thinnest_wall_at"), tw is not None and tw < self.design.min_wall)
1328
1427
  self.st["viol"].setStyleSheet("color:#ff7070" if v else "")
1329
1428
  self.st["time"].setText(f"{dt * 1000:.0f} ms")
1330
1429
  warn = f" ⚠ {v} walls thinner than {self.design.min_wall:g} mm" if v else ""