ventforge 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,206 @@
1
+ """MCP server for VentForge. Works on one "current" design file; the GUI watches that file,
2
+ so every change made here shows up live in an open editor window."""
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from dataclasses import fields, is_dataclass
8
+ from pathlib import Path
9
+
10
+ from mcp.server.mcpserver import Image, MCPServer
11
+
12
+ from . import model
13
+ from .model import Design
14
+
15
+ PRESETS = Path(__file__).resolve().parent / "presets"
16
+ HOME = Path.home() / "Documents" / "VentForge"
17
+
18
+ mcp = MCPServer("ventforge", instructions=(
19
+ "Design vent hole patterns for 3D parts. Units mm, canvas centred at origin, y up, angles in degrees. "
20
+ "Workflow: new_design (or open_design) -> describe_parameters -> set_params / add_control -> preview "
21
+ "(look at the image and stats: open_area_ratio, min_wall_violations) -> iterate -> export. "
22
+ "The current design is a .vent.json file; call open_in_gui to let the user watch it live."))
23
+
24
+ _current: Path | None = None
25
+
26
+
27
+ def _path() -> Path:
28
+ global _current
29
+ if _current is None:
30
+ HOME.mkdir(parents=True, exist_ok=True)
31
+ _current = HOME / "current.vent.json"
32
+ if not _current.exists():
33
+ Design().save(_current)
34
+ return _current
35
+
36
+
37
+ def _load() -> Design:
38
+ return Design.load(_path())
39
+
40
+
41
+ def _generate(d: Design):
42
+ from .pattern import generate
43
+ return generate(d)
44
+
45
+
46
+ def _stats(r) -> dict:
47
+ return {k: (round(v, 4) if isinstance(v, float) else v) for k, v in r.stats.items()}
48
+
49
+
50
+ @mcp.tool()
51
+ def new_design(path: str = "", preset: str = "", name: str = "") -> dict:
52
+ """Start a design (default file ~/Documents/VentForge/current.vent.json), optionally from a preset name
53
+ (see list_presets). Returns the file path and stats."""
54
+ global _current
55
+ d = Design.load(PRESETS / f"{preset}.vent.json") if preset else Design()
56
+ if name:
57
+ d.name = name
58
+ _current = Path(path).expanduser().resolve() if path else _path()
59
+ _current.parent.mkdir(parents=True, exist_ok=True)
60
+ d.save(_current)
61
+ return {"path": str(_current), "stats": _stats(_generate(d))}
62
+
63
+
64
+ @mcp.tool()
65
+ def open_design(path: str) -> dict:
66
+ """Make an existing .vent.json file the current design."""
67
+ global _current
68
+ p = Path(path).expanduser().resolve()
69
+ d = Design.load(p)
70
+ _current = p
71
+ return {"path": str(p), "name": d.name, "stats": _stats(_generate(d))}
72
+
73
+
74
+ @mcp.tool()
75
+ def list_presets() -> list[str]:
76
+ """Names of the built-in presets usable with new_design(preset=...)."""
77
+ return sorted(p.name.removesuffix(".vent.json") for p in PRESETS.glob("*.vent.json"))
78
+
79
+
80
+ @mcp.tool()
81
+ def get_design() -> dict:
82
+ """The full current design as a dict (same shape as the .vent.json file)."""
83
+ return {"path": str(_path()), "design": _load().to_dict()}
84
+
85
+
86
+ def _schema(obj) -> dict:
87
+ return {f.name: _schema(getattr(obj, f.name)) if is_dataclass(getattr(obj, f.name))
88
+ else f"{type(getattr(obj, f.name)).__name__} = {getattr(obj, f.name)!r}" for f in fields(obj)}
89
+
90
+
91
+ @mcp.tool()
92
+ def describe_parameters() -> dict:
93
+ """Every parameter with its type and default, plus the allowed enum values. Parameter meanings are
94
+ documented in ventforge/model.py; pattern.<mode> holds the settings of each layout mode."""
95
+ return {"enums": {"dot.shape": model.SHAPES, "pattern.mode": model.MODES, "boundary.type": model.BOUNDARIES,
96
+ "gradients.target_shape": model.TARGET_SHAPES,
97
+ "pattern.grid.arrangement": ("square", "diamond", "hexagon"),
98
+ "rotate_by": ("row", "col", "both"), "flowlines.edge_style": ("flat", "round"),
99
+ "control.kind": ("point", "line", "curve"), "gradient.falloff": ("linear", "smooth")},
100
+ "gradient_semantics": "size/spacing are multipliers (edge 1 = neutral), angle adds degrees, "
101
+ "shape morphs 0..1 towards target_shape. A control has `value` at its "
102
+ "location blending to the gradient's `edge` value at distance `radius`.",
103
+ "defaults": _schema(Design())}
104
+
105
+
106
+ @mcp.tool()
107
+ def set_params(patch: dict) -> dict:
108
+ """Deep-merge a partial design, e.g. {"dot": {"shape": "hexagon", "diameter": 3},
109
+ "pattern": {"mode": "grid", "grid": {"spacing_x": 4.5}}}. Lists (points, controls) are replaced whole.
110
+ Saves the file (live-updates the GUI) and returns stats."""
111
+ d = _load().patched(patch).resolve_images(_path().parent)
112
+ r = _generate(d) # generate before saving so a bad patch never lands in the file
113
+ d.save(_path())
114
+ return {"stats": _stats(r)}
115
+
116
+
117
+ @mcp.tool()
118
+ def add_control(target: str, kind: str = "point", points: list[list[float]] | None = None,
119
+ value: float = 0.2, radius: float = 50.0, enable: bool = True) -> dict:
120
+ """Add a gradient control to target size|spacing|angle|shape. kind point (1 point), line (2), curve (>=3)."""
121
+ d = _load()
122
+ g = getattr(d.gradients, target)
123
+ g.controls.append(model.Control(kind=kind, points=points or [[0.0, 0.0]], value=value, radius=radius))
124
+ g.enabled = g.enabled or enable
125
+ r = _generate(d)
126
+ d.save(_path())
127
+ return {"controls": len(g.controls), "stats": _stats(r)}
128
+
129
+
130
+ @mcp.tool()
131
+ def clear_controls(target: str) -> dict:
132
+ """Remove all controls of gradient target size|spacing|angle|shape."""
133
+ d = _load()
134
+ getattr(d.gradients, target).controls.clear()
135
+ d.save(_path())
136
+ return {"stats": _stats(_generate(d))}
137
+
138
+
139
+ @mcp.tool()
140
+ def preview(px: int = 900) -> list:
141
+ """Render the current design as a PNG (holes on the plate outline) plus stats. Look at it before exporting."""
142
+ from .export import to_png
143
+ d = _load()
144
+ r = _generate(d)
145
+ return [Image(data=to_png(r, d, px=px, transparent=False), format="png"), {"stats": _stats(r)}]
146
+
147
+
148
+ @mcp.tool()
149
+ def export(path: str) -> dict:
150
+ """Export the current design; format from the extension: .svg .png .dxf .step/.stp .stl (3D plate of
151
+ canvas.thickness)."""
152
+ from .export import export as do_export
153
+ d = _load()
154
+ r = _generate(d)
155
+ out = Path(path).expanduser().resolve()
156
+ out.parent.mkdir(parents=True, exist_ok=True)
157
+ do_export(r, d, str(out))
158
+ return {"path": str(out), "bytes": out.stat().st_size, "stats": _stats(r)}
159
+
160
+
161
+ @mcp.tool()
162
+ def wrap_cylinder(path: str, radius: float | None = None, wall: float | None = None,
163
+ height: float | None = None) -> dict:
164
+ """Wrap the pattern around a tube (canvas width = arc length) and cut it through the wall.
165
+ Defaults: radius = width/2π (wraps once), wall = thickness, height = canvas height. Writes .step or .stl."""
166
+ from .export import wrap_cylinder as wrap
167
+ d = _load()
168
+ out = Path(path).expanduser().resolve()
169
+ wrap(_generate(d), d, str(out), radius=radius, wall=wall, height=height)
170
+ return {"path": str(out), "bytes": out.stat().st_size}
171
+
172
+
173
+ @mcp.tool()
174
+ def cut_into_part(part_path: str, out_path: str, origin: list[float] = [0, 0, 0],
175
+ normal: list[float] = [0, 0, 1], x_dir: list[float] = [1, 0, 0],
176
+ depth: float | None = None) -> dict:
177
+ """Cut the vent pattern into an existing STEP part. The pattern plane sits at `origin` facing `normal`
178
+ (holes are cut along -normal), canvas x along `x_dir`. depth None = through the whole part."""
179
+ from .export import cut_into_part as cut
180
+ d = _load()
181
+ out = Path(out_path).expanduser().resolve()
182
+ cut(_generate(d), d, str(Path(part_path).expanduser().resolve()), str(out),
183
+ origin=tuple(origin), normal=tuple(normal), x_dir=tuple(x_dir), depth=depth)
184
+ return {"path": str(out), "bytes": out.stat().st_size}
185
+
186
+
187
+ @mcp.tool()
188
+ def open_in_gui() -> str:
189
+ """Open the current design in the VentForge desktop editor (it live-reloads when this server edits it)."""
190
+ flags = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
191
+ if getattr(sys, "frozen", False): # bundled app: the executable itself is the editor
192
+ cmd = [sys.executable, str(_path())]
193
+ else:
194
+ exe = Path(sys.executable)
195
+ gui_exe = exe.with_name("pythonw.exe") if exe.with_name("pythonw.exe").exists() else exe
196
+ cmd = [str(gui_exe), "-m", "ventforge", str(_path())]
197
+ subprocess.Popen(cmd, creationflags=flags)
198
+ return f"opened {_path()}"
199
+
200
+
201
+ def main():
202
+ mcp.run()
203
+
204
+
205
+ if __name__ == "__main__":
206
+ main()
ventforge/model.py ADDED
@@ -0,0 +1,301 @@
1
+ """Design model: every parameter of a vent design, stored as JSON (*.vent.json).
2
+
3
+ Coordinates are millimetres, origin at the canvas centre, x right, y UP (CAD convention).
4
+ The GUI flips y for display. All angles are degrees, counter-clockwise.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import time
10
+ from dataclasses import asdict, dataclass, field, fields, is_dataclass
11
+ from pathlib import Path
12
+ from typing import get_type_hints
13
+
14
+ Pt = list[float] # [x, y]
15
+
16
+ SHAPES = ("circle", "rounded_rectangle", "hexagon", "polygon", "star", "custom")
17
+ MODES = ("grid", "random", "path", "spiral", "concentric", "fibonacci",
18
+ "crosslines", "voronoi", "flowlines", "custom_points")
19
+ BOUNDARIES = ("none", "circle", "rounded_rectangle", "custom")
20
+ TARGET_SHAPES = ("circle", "rectangle", "polygon", "star")
21
+
22
+
23
+ @dataclass
24
+ class Canvas:
25
+ width: float = 100.0
26
+ height: float = 100.0
27
+ thickness: float = 2.0 # plate thickness for 3D exports
28
+
29
+
30
+ @dataclass
31
+ class Boundary:
32
+ type: str = "none" # BOUNDARIES; "none" = the canvas rectangle
33
+ corner_radius: float = 10.0 # rounded_rectangle
34
+ margin: float = 2.0 # min distance from holes to the boundary edge
35
+ points: list[Pt] = field(default_factory=list) # custom outline polygon
36
+ clip: bool = False # False: drop holes crossing the edge; True: clip them
37
+
38
+
39
+ @dataclass
40
+ class DotStyle:
41
+ shape: str = "circle" # SHAPES
42
+ diameter: float = 4.0 # outer size (circumscribed width)
43
+ aspect: float = 1.0 # rounded_rectangle height / width
44
+ corner_radius: float = 0.5 # rounded_rectangle
45
+ sides: int = 6 # polygon
46
+ star_points: int = 5
47
+ star_inner: float = 0.5 # inner radius ratio of star
48
+ rotation: float = 0.0
49
+ custom_points: list[Pt] = field(default_factory=list) # unit shape, scaled to diameter
50
+ color: str = "#F7BB3B" # display only
51
+
52
+
53
+ @dataclass
54
+ class Grid:
55
+ arrangement: str = "diamond" # square | diamond (staggered rows) | hexagon (honeycomb pitch)
56
+ spacing_x: float = 6.0
57
+ spacing_y: float = 6.0
58
+ offset_x: float = 0.0
59
+ offset_y: float = 0.0
60
+ rotation: float = 0.0 # whole grid
61
+ row_rotation: float = 0.0 # extra dot rotation applied to alternate rows/cols
62
+ rotate_by: str = "row" # row | col | both (chessboard)
63
+
64
+
65
+ @dataclass
66
+ class RandomP:
67
+ count: int = 300
68
+ min_distance: float = 5.0 # Poisson-disc style rejection
69
+ seed: int = 1
70
+
71
+
72
+ @dataclass
73
+ class PathP:
74
+ points: list[Pt] = field(default_factory=list) # empty = default circle
75
+ closed: bool = True
76
+ smooth: bool = True # Catmull-Rom through points
77
+ count: int = 40 # dots on the base path; other layers scale by length
78
+ layers: int = 1 # parallel offset copies of the path
79
+ layer_spacing: float = 6.0
80
+ align: bool = True # rotate dots to path tangent
81
+
82
+
83
+ @dataclass
84
+ class Spiral:
85
+ radius: float = 45.0
86
+ turns: float = 5.0
87
+ inner_radius: float = 3.0
88
+ count: int = 120
89
+ rotation: float = 0.0
90
+ even_spacing: bool = True # equal arc length between dots
91
+ align: bool = True
92
+
93
+
94
+ @dataclass
95
+ class Concentric:
96
+ ring_spacing: float = 6.0
97
+ dot_spacing: float = 6.0 # arc distance between dots on a ring
98
+ inner_radius: float = 0.0 # 0 = a dot in the centre
99
+ outer_radius: float = 0.0 # 0 = fill the boundary
100
+ rotation: float = 0.0
101
+ stagger: bool = True # offset alternate rings by half a step
102
+ align: bool = True
103
+
104
+
105
+ @dataclass
106
+ class Fibonacci:
107
+ count: int = 400
108
+ spread: float = 3.5 # c in r = c * sqrt(n)
109
+ inner_radius: float = 0.0
110
+ rotation: float = 0.0
111
+ align: bool = False
112
+
113
+
114
+ @dataclass
115
+ class Crosslines:
116
+ left_angle: float = 60.0
117
+ right_angle: float = 120.0
118
+ left_spacing: float = 6.0
119
+ right_spacing: float = 6.0
120
+ rotation: float = 0.0
121
+ row_rotation: float = 0.0
122
+ rotate_by: str = "both" # left | right | both — dots sit at line intersections
123
+
124
+
125
+ @dataclass
126
+ class Voronoi:
127
+ cell_size: float = 8.0
128
+ irregularity: float = 60.0 # percent jitter
129
+ crack_width: float = 1.2 # wall width between cells (holes are shrunken cells)
130
+ corner_radius: float = 0.5
131
+ rotation: float = 0.0
132
+ seed: int = 1
133
+
134
+
135
+ @dataclass
136
+ class Flowlines:
137
+ line_spacing: float = 5.0
138
+ line_width: float = 2.0 # slot width
139
+ rotation: float = 0.0
140
+ edge_style: str = "round" # flat | round slot ends
141
+ follow_boundary: bool = False # lines are offsets of the boundary instead of straight
142
+ invert: bool = False # swap lines and gaps
143
+ amplitude: float = 3.0 # waviness
144
+ wavelength: float = 30.0
145
+ seed: int = 1
146
+
147
+
148
+ @dataclass
149
+ class CustomPoints:
150
+ """Explicit dots, e.g. written by an AI through MCP. size/angle optional per dot."""
151
+ points: list[Pt] = field(default_factory=list)
152
+ sizes: list[float] = field(default_factory=list) # multipliers of DotStyle.diameter
153
+ angles: list[float] = field(default_factory=list) # added to DotStyle.rotation
154
+
155
+
156
+ @dataclass
157
+ class Pattern:
158
+ mode: str = "grid" # MODES
159
+ grid: Grid = field(default_factory=Grid)
160
+ random: RandomP = field(default_factory=RandomP)
161
+ path: PathP = field(default_factory=PathP)
162
+ spiral: Spiral = field(default_factory=Spiral)
163
+ concentric: Concentric = field(default_factory=Concentric)
164
+ fibonacci: Fibonacci = field(default_factory=Fibonacci)
165
+ crosslines: Crosslines = field(default_factory=Crosslines)
166
+ voronoi: Voronoi = field(default_factory=Voronoi)
167
+ flowlines: Flowlines = field(default_factory=Flowlines)
168
+ custom_points: CustomPoints = field(default_factory=CustomPoints)
169
+
170
+
171
+ @dataclass
172
+ class Control:
173
+ kind: str = "point" # point | line | curve (smooth polyline)
174
+ points: list[Pt] = field(default_factory=lambda: [[0.0, 0.0]])
175
+ value: float = 0.2 # field value AT the control
176
+ radius: float = 50.0 # falloff distance to the edge value
177
+
178
+
179
+ @dataclass
180
+ class Gradient:
181
+ """A scalar field over the canvas. Far from all controls it equals `edge`."""
182
+ enabled: bool = False
183
+ controls: list[Control] = field(default_factory=list)
184
+ edge: float = 1.0 # value far away (neutral: 1 for size/spacing, 0 for angle/shape)
185
+ falloff: str = "smooth" # linear | smooth
186
+ noise: float = 0.0 # 0..1 amplitude, relative
187
+ noise_scale: float = 20.0 # mm feature size
188
+ noise_seed: int = 1
189
+ image: str = "" # grayscale image path used as the field (fit to canvas)
190
+ image_min: float = 0.2 # value at black
191
+ image_max: float = 1.0 # value at white
192
+ invert_image: bool = False
193
+
194
+
195
+ @dataclass
196
+ class Gradients:
197
+ size: Gradient = field(default_factory=Gradient) # multiplier on diameter
198
+ spacing: Gradient = field(default_factory=Gradient) # local spacing multiplier
199
+ angle: Gradient = field(default_factory=lambda: Gradient(edge=0.0)) # degrees added
200
+ shape: Gradient = field(default_factory=lambda: Gradient(edge=0.0)) # 0..1 morph to target
201
+ target_shape: str = "star" # TARGET_SHAPES
202
+ target_sides: int = 4 # for target polygon
203
+
204
+
205
+ @dataclass
206
+ class Background:
207
+ image: str = "" # reference image shown under the pattern (GUI only)
208
+ opacity: float = 0.5
209
+
210
+
211
+ @dataclass
212
+ class Design:
213
+ name: str = "Untitled"
214
+ canvas: Canvas = field(default_factory=Canvas)
215
+ boundary: Boundary = field(default_factory=Boundary)
216
+ dot: DotStyle = field(default_factory=DotStyle)
217
+ pattern: Pattern = field(default_factory=Pattern)
218
+ gradients: Gradients = field(default_factory=Gradients)
219
+ background: Background = field(default_factory=Background)
220
+ min_wall: float = 0.8 # holes closer than this are flagged in stats
221
+ version: int = 1
222
+
223
+ # ---- (de)serialisation -------------------------------------------------
224
+ def to_dict(self) -> dict:
225
+ return asdict(self)
226
+
227
+ @classmethod
228
+ def from_dict(cls, d: dict) -> "Design":
229
+ return _build(cls, d)
230
+
231
+ def to_json(self) -> str:
232
+ return json.dumps(self.to_dict(), indent=2)
233
+
234
+ @classmethod
235
+ def load(cls, path: str | Path) -> "Design":
236
+ d = cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
237
+ return d.resolve_images(Path(path).resolve().parent)
238
+
239
+ def resolve_images(self, base: str | Path) -> "Design":
240
+ """Make relative image paths absolute against `base` (the design file's folder), so the GUI
241
+ and the MCP server, which run from different working folders, find the same files."""
242
+ for g in (self.gradients.size, self.gradients.spacing, self.gradients.angle, self.gradients.shape,
243
+ self.background):
244
+ if g.image and not Path(g.image).is_absolute():
245
+ g.image = str((Path(base) / g.image).resolve())
246
+ return self
247
+
248
+ def save(self, path: str | Path) -> None:
249
+ p = Path(path)
250
+ tmp = p.with_suffix(p.suffix + ".tmp")
251
+ tmp.write_text(self.to_json(), encoding="utf-8")
252
+ for i in range(6): # atomic replace, so a watching GUI never reads half a file
253
+ try:
254
+ return tmp.replace(p)
255
+ except PermissionError: # Windows: AV/indexer/a reader briefly holds the target
256
+ if i == 5:
257
+ raise
258
+ time.sleep(0.05 * (i + 1))
259
+
260
+ def patched(self, patch: dict) -> "Design":
261
+ """Deep-merge a partial dict, e.g. {"dot": {"diameter": 3}}. Unknown keys raise."""
262
+ return Design.from_dict(_merge(self.to_dict(), patch))
263
+
264
+
265
+ def _merge(base: dict, patch: dict, path: str = "") -> dict:
266
+ out = dict(base)
267
+ for k, v in patch.items():
268
+ if k not in base:
269
+ raise KeyError(f"unknown parameter: {path}{k}")
270
+ out[k] = _merge(base[k], v, f"{path}{k}.") if isinstance(v, dict) and isinstance(base[k], dict) else v
271
+ return out
272
+
273
+
274
+ def _build(cls, d: dict):
275
+ hints = get_type_hints(cls)
276
+ kw = {}
277
+ for f in fields(cls):
278
+ if f.name not in d:
279
+ continue
280
+ t, v = hints[f.name], d[f.name]
281
+ if is_dataclass(t):
282
+ kw[f.name] = _build(t, v or {})
283
+ elif getattr(t, "__origin__", None) is list and t.__args__[0] is Control:
284
+ kw[f.name] = [_build(Control, c) for c in v]
285
+ elif t is float:
286
+ kw[f.name] = float(v)
287
+ elif t is int:
288
+ kw[f.name] = int(v)
289
+ else:
290
+ kw[f.name] = v
291
+ return cls(**kw)
292
+
293
+
294
+ @dataclass
295
+ class Result:
296
+ """Output of pattern.generate(design). Shared by exporters, GUI and MCP."""
297
+ holes: list # shapely Polygons, mm, y-up, already clipped/filtered to the outline
298
+ outline: object # shapely Polygon: the plate outline (canvas rect or boundary shape)
299
+ stats: dict # count, open_area_ratio (0..1), hole_area, plate_area, min_wall_violations
300
+ circles: list = field(default_factory=list) # per hole: (cx, cy, r) if it is an exact unclipped circle, else None
301
+ # (lets SVG/DXF/STEP write true circles instead of polygons; same length as holes, or empty)