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.
- ventforge/__init__.py +0 -0
- ventforge/__main__.py +22 -0
- ventforge/export.py +629 -0
- ventforge/gui.py +1667 -0
- ventforge/mcp_server.py +206 -0
- ventforge/model.py +301 -0
- ventforge/pattern.py +746 -0
- ventforge/presets/halftone_fade.vent.json +186 -0
- ventforge/presets/hex_honeycomb.vent.json +174 -0
- ventforge/presets/spiral_dashes.vent.json +174 -0
- ventforge/presets/sunflower.vent.json +186 -0
- ventforge/presets/voronoi_stone.vent.json +174 -0
- ventforge/presets/wavy_flowlines.vent.json +174 -0
- ventforge-0.1.0.dist-info/METADATA +84 -0
- ventforge-0.1.0.dist-info/RECORD +19 -0
- ventforge-0.1.0.dist-info/WHEEL +5 -0
- ventforge-0.1.0.dist-info/entry_points.txt +3 -0
- ventforge-0.1.0.dist-info/licenses/LICENSE +674 -0
- ventforge-0.1.0.dist-info/top_level.txt +1 -0
ventforge/mcp_server.py
ADDED
|
@@ -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)
|