From 318345e7a81baf0bb342deb688b49d2535313b57 Mon Sep 17 00:00:00 2001
From: Rick <64953373+RickUsingGitHub@users.noreply.github.com>
Date: Fri, 6 Nov 2026 13:34:16 +1100
Subject: [PATCH 1/2] Fix browser state loss and add shared desktop/web design
tools
---
.github/workflows/pages.yml | 77 +-
.github/workflows/tests.yml | 22 +
README.md | 944 ++++-----
tests/test_browser.cjs | 40 +
tests/test_design_tools.py | 78 +
tests/test_web_state.cjs | 32 +
tests/test_workspace.py | 136 ++
tools/build_web.py | 160 +-
vaseinator/app.py | 3724 ++++++++++++++++++-----------------
vaseinator/design_tools.py | 237 +++
vaseinator/geometry.py | 3231 +++++++++++++++---------------
vaseinator/params.py | 682 +++----
vaseinator/workspace.py | 313 +++
web/app.js | 2292 ++++++++++-----------
web/bridge.py | 601 +++---
web/index.html | 505 ++---
web/library.js | 48 +
web/style.css | 819 ++++----
web/worker.js | 156 +-
19 files changed, 7600 insertions(+), 6497 deletions(-)
create mode 100644 .github/workflows/tests.yml
create mode 100644 tests/test_browser.cjs
create mode 100644 tests/test_design_tools.py
create mode 100644 tests/test_web_state.cjs
create mode 100644 tests/test_workspace.py
create mode 100644 vaseinator/design_tools.py
create mode 100644 vaseinator/workspace.py
create mode 100644 web/library.js
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 02545e6..c6e4d5c 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -1,37 +1,40 @@
-# The web version: tools/build_web.py puts the page and the Python it runs in site/, and GitHub Pages
-# publishes it at https://rickusinggithub.github.io/vaseinator/ on every push to main.
-name: Web version
-
-on:
- push:
- branches: [main]
- workflow_dispatch:
-
-permissions:
- contents: read
- pages: write
- id-token: write
-
-concurrency:
- group: pages
- cancel-in-progress: true
-
-jobs:
- deploy:
- runs-on: ubuntu-latest
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-python@v5
- with:
- python-version: "3.12"
- - name: Put the site together
- run: python tools/build_web.py
- - uses: actions/configure-pages@v5
- - uses: actions/upload-pages-artifact@v3
- with:
- path: site
- - id: deployment
- uses: actions/deploy-pages@v4
+# The web version: tools/build_web.py puts the page and the Python it runs in site/, and GitHub Pages
+# publishes it at https://rickusinggithub.github.io/vaseinator/ on every push to main.
+name: Web version
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ verify:
+ uses: ./.github/workflows/tests.yml
+ deploy:
+ needs: verify
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: Put the site together
+ run: python tools/build_web.py
+ - uses: actions/configure-pages@v5
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: site
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..5322d08
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,22 @@
+name: Regression checks
+on:
+ pull_request:
+ workflow_call:
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ shared:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ - run: python -m pip install numpy pyfqmr
+ - run: python -m tests.test_vase
+ - run: python -m unittest tests.test_workspace -v
+ - run: node tests/test_web_state.cjs
diff --git a/README.md b/README.md
index af08c71..e94fe8a 100644
--- a/README.md
+++ b/README.md
@@ -1,449 +1,495 @@
-# Vaseinator
-
-A Windows desktop app that designs **vases that print without supports** – as an ordinary print with a
-few walls, in any colours, or **seamless** in the slicer's spiral vase mode. Every vase it makes is a
-closed, watertight model, every layer is one single loop, and no part of the wall leans further than you
-allow.
-
-**Simple** mode is a handful of choices made from pictures – easy enough for anyone – and one big
-**Save for printing** button; **Advanced** has every setting.
-
-**Use it in your browser – nothing to install:** . The web
-version is the whole app – Simple and Advanced, every setting, the curves, drawing your own cross-section,
-the views and the exports – built by the app's own Python running right in the browser.
-
-Shape the outline, pick a cross-section and what it turns into on the way up, **spin** it, cover it in
-ribs, rings, bumps, organic lumps or **your own formula**, and colour it in bands, stripes or a
-gradient spool. Cut **holes** through it, open it at the bottom, close it over at the top – or give it a
-**face** and make a **mask or a helmet**, sized for the head it fits, or a **tissue box cover** sized for
-the box of tissues it goes over. The app shows the vase in 3D as it will print, cuts it in half to show the wall, colours
-every face by how far it leans, plays the print back in real print time, and exports an STL or 3MF
-ready to slice.
-
-
-
-
-
-| Advanced: draw how things change up the vase | Section: the printed wall |
-|---|---|
-|  |  |
-| **Overhangs: green is easy, red needs support** | **Watch it print, in real print time** |
-|  |  |
-| **Draw any cross-section, sharp corners and all** | **Draw one lobe, repeat it: three bulbs become six** |
-|  |  |
-
-## Features
-
-* **Outline** from twelve presets (classic, bulb, tulip, amphora, bottle, gourd, trumpet, …) or drawn
- by hand: drag points on the outline curve; the curve through them never overshoots.
-* **Cross-section**: circle, polygon (with rounded corners), rectangle, star, flower, gear, ellipse or
- pebble – and
- a second shape it **turns into** on the way up – evenly, early, late, **back and forth** as many times
- as you like (circle → pentagon → circle → pentagon), or along your own curve.
-* **Draw any cross-section**: a spline editor with smooth points and sharp **cusps**, for shapes no
- formula makes – lobes that swell past their necks, a crescent, a claw, a heart, nothing symmetrical at
- all. Draw one lobe and **repeat it around** – up to 100 times – start from a built-in shape, or
- **trace a picture**. Draw close up on one repeat, or in a **full window** with zoom.
-* **Spin**: twist the shape round as it rises – evenly, easing in and out, speeding up, slowing down,
- **back and forth**, or along your own curve – or **both ways at once** for a lattice of crossing ridges.
-* **Surface patterns**: ribs (that can spiral, or cross for diamond knurling), rings, staggered bumps
- and smooth organic lumps, each with its own depth and shape, a strength that can change up the vase,
- and a plain foot and rim.
-* **Low-poly facets**: the whole vase from flat triangles, like a cut gem, with an optional rough cut.
-* **Your own function**: type a formula in millimetres – `1.5 * sin(6*theta + 4*pi*h)` – added to the
- radius everywhere. Variables for the angle, the angle turning with the twist, height, share of the
- height and the radius so far; trig, waves, steps and smooth noise. A long formula can go over several
- lines – they are read as one, so start each new line with + - * or / – with notes after a `#`. It is
- checked and run safely: numbers, the listed variables and functions only; a mistake is pointed out by
- line and character.
-* **A normal print, or seamless**: a vase prints like any other print, and is exported as the **hollow pot**
- it is – the wall as thick as it prints, the floor and the rim – so the slicer needs **no special
- settings**: sturdy, watertight and in colour. Or, in one colour (or a gradient spool), **seamless** in the
- slicer's spiral vase mode: one line climbing round and round, with no seam and a single wall that glows
- when lit. Spiral vase mode is hardly any quicker, so a normal print is the usual.
-* **Holes** right through the wall: **Voronoi** cells (a honeycomb when regular), **round holes**,
- **diamonds** or **slots**, as big and as far apart as you like, kept clear of a plain foot and rim, and
- turning with the twist. Every hole's top comes to a **point** that closes no faster than the overhang
- limit, so it prints **without support** – with at most a short bridge, a few millimetres, at its very tip.
- Voronoi holes are **ovals** instead, each as long, as wide and as tilted as its cell and as big as fits
- inside it, so the wall between them is thin where two holes come close and thick in the corners; their
- round tops close over a bridge of at most 14 mm. **Rounded edges** swells the wall round every hole, so
- it curves smoothly from the outside, through the hole, to the inside.
- And **open at the bottom** too, for lampshades, sleeves and holders.
-* **Closed at the top**: the top comes in to a point, leaning no more than the overhang limit, over a
- ceiling a few millimetres across that the printer bridges – a helmet, a dome, a bell, a sealed egg. The
- outline is rounded in to meet the point.
-* **Part way round**: the wall goes only so far round – about 200° makes a mask – with the gap at the back.
-* **Masks and helmets**: a **face** at the front – **eyes** (almond or round, apart, tilted), a raised
- **nose**, a **mouth** (open, a grille of slots that can smile, or open down to the chin like an old
- Greek helmet's) and **slots for an elastic strap** – every hole with a pointed top, so it prints without
- support. **Head size** fits one to a head: measure round it above the eyebrows and ears, as for a hat,
- and the whole design – height, outline and face – is scaled so the inside fits with a centimetre to
- spare. Stylised, not anatomical.
-* **Tissue box covers**: measure the box of tissues – long, wide, tall – and the cover is made to go over
- it, with a little room to spare (more inside rounded corners, so a square-cornered box still fits). Pick
- the hole the tissues come out of – a long **slot**, an **oval** or a **round** hole – and any pattern,
- holes or colours. It is made the way it prints: **upside down**, the hole through its first layers on the
- bed and the open end at the top, so nothing needs supports. Turn it over to put it on. Its ribs, bumps
- and lumps are spread **evenly round the wall**, as close together on the long sides as round the ends.
-* **A lid for a tissue box cover**, printed on its own – with the cover, or later: a flat frame (or a
- solid plate) the shape of the rim, which the cover stands on. Inside each end of the cover is a small
- **ledge**. The lid goes on like a battery cover: tilted, its stiff **hooks** go in behind the ledge at
- one end, and then its springy **latches** click in behind the ledge at the other – **Gentle**, **Snug**
- or **Firm**. It stays on when the cover is picked up, and its **pull tab** takes it off to change the
- tissues. It is made from the cover's own design; a cover printed before 1.13 has no ledges.
-* **A fine mesh, a light file**: the surface is built every 0.2 mm, and **saving for the slicer simplifies
- it** – triangles are merged wherever that moves the surface by no more than a few hundredths of a
- millimetre – so the file stays small and the slicer opens it quickly. A box's flat sides need only a
- few rows and columns of points to begin with.
-* **Colours**: one colour or a **gradient spool** – shown where its colours will really land, worked out
- from the filament each height uses – and, printed normally, up to four filaments in **bands**
- (even, or thicker at the top or the bottom) or **stripes**, with palettes.
-* **Looks**: matte, silk, glossy or translucent in the 3D view.
-* **Printable, always**: each layer of the wall rests on the one below it, so it may lean neither out nor
- in further than the limit (45° by default). **Soften to fit** reshapes only what leans too far – and
- tells you by how much – and every triangle is checked afterwards.
-* **Views**: the vase, a **section** through the printed wall, an **overhang check**, and the print
- layout on your bed inside the printer's build volume. **Spin** turns it on a turntable; **Watch it
- print** builds it up in real print time (wide layers take longer).
-* **The sums**: size, base and opening, how much water it holds, filament in grams and metres, print
- time, steepest overhang, and the mesh – closed and watertight.
-* **Gallery** of twenty-seven ready-made designs – lampshades and baskets with holes among them, three masks
- and a helmet – a **plain cylinder** to start from blank, **Surprise me** (a new random vase that still
- prints), and **undo / redo** of everything.
-* **Save for printing**: a **3MF** in the middle of the bed – each colour band its own part, set to its
- filament (one solid for a seamless vase) – as Lenticular Sign Maker and Maze Tube Designer do, with plain
- steps to print it and **Open in slicer** (Flash Studio, OrcaSlicer, Bambu Studio, PrusaSlicer, Cura… –
- whichever is installed). Also a solid **STL**. Designs save as `.vase` files (JSON).
-* **Simple**: a design chosen from pictures, how big (in centimetres – or, for a mask or helmet, the head
- size), the shape from above and from the side, the twist, a pattern and a colour – each a picture to
- click. **Advanced** has every setting and
- the curves, your own function among them. **Bigger text** and a **light theme** are in the View menu.
-
-## Download and run
-
-* **In your browser:** – the whole app, Simple and Advanced:
- every setting, the curves, drawing and tracing your own cross-section (in a full window too), your own
- function, the Vase, Section, Overhangs and Print layout views, Spin and Watch it print, the side and
- from-above pictures, and Save for printing (3MF), STL, a picture and `.vase` designs – which the Windows
- app opens too, and back. The first visit takes a little while to load; after that it starts quickly. The
- one thing only the Windows app does is open the file in your slicer for you.
-* **Windows app (no Python needed):** download `Vaseinator.exe` from the
- [Releases](../../releases) page and run it. It is a single file: nothing to unzip or install.
- It isn't code-signed, so Windows SmartScreen may ask once; choose **More info → Run anyway**.
-* **From source** (Python 3.10+, any OS with OpenGL):
- ```
- git clone https://github.com/RickUsingGitHub/vaseinator.git
- cd vaseinator
- python -m venv .venv
- .venv\Scripts\pip install -r requirements.txt # macOS/Linux: .venv/bin/pip
- .venv\Scripts\python main.py
- ```
- On Windows you can also just double-click `run.bat`, which sets everything up on first run.
-* **Build the Windows app yourself:** `build_exe.bat` (uses PyInstaller; the result is
- `dist\Vaseinator.exe`).
-
-Vaseinator is free. If it has made you something you like, you can [buy me a coffee](https://ko-fi.com/rickapps).
-
-`examples\` holds the gallery as `.vase` designs to open. `tools\make_examples.py` writes them again, and
-six of them to `examples\stl\` as finished STLs, checked and ready to slice: Lagoon Twist, Star to Moon,
-Lattice Lantern, Gem Facets, Coral Reef and Claw Shell. (STLs are big, so they are not kept in git.)
-
-On first run the app borrows your printer's bed size and height from Maze Tube Designer or Lenticular
-Sign Maker, if you have used them. Printer settings are remembered for new designs.
-
-## Using it
-
-In **Simple** mode, click **Choose a design…** (every ready-made vase as a picture) or **Surprise me**,
-then click the pictures to change it: the shape from above, the shape from the side, a pattern, a colour;
-**Tall** and **Wide** size it – for a mask or helmet, **Head size**; for a tissue box cover, the box of
-tissues it goes over, and the hole for the tissues, and **Save a lid for it…** – and the slider twists it. **Draw my own shape…** opens a big window to draw
-the shape from above yourself. When it looks right, **Save for printing…** saves it for the slicer and
-says, in plain steps, how to print it – with a button to open it in your slicer (File ▸ Choose slicer
-program picks a different one).
-
-Or start blank: **Plain cylinder**, first in the gallery (or **File ▸ New plain cylinder**, Ctrl+Shift+N),
-is a cylinder 200 mm across and 200 mm tall with everything else off, ready for your own function or a
-drawn cross-section; your printer settings are kept. Every change rebuilds the vase at once; while you
-drag a point on a curve, a quicker rough version follows your hand.
-
-The curves for the twist, the change of shape and the pattern strength are ordinary graphs: the vase
-from bottom to top runs left to right, and the value runs up – drag a point up for more, down for
-less. Double-click to add a point (a few make a wave), right-click to take one away. The outline's
-curve is drawn upright and mirrored instead, like the vase from the side.
-
-| Group | What it does |
-| --- | --- |
-| **Size** | Height, the widest diameter (scales the whole outline), and the outline: a preset, or your own curve – diameter at each height, drawn as the vase seen from the side. |
-| **Shape** | The cross-section at the bottom, and what it turns into at the top. Each shape has its own settings: sides, points, petals or teeth; how deep it cuts in; how sharp it is; how far it is turned. **Drawn – any outline** gives you the cross-section editor instead (below). **Change happens** says how the first shape becomes the second on the way up – including back and forth, as many **Changes** as you like, and your own curve. |
-| **Spin** | How far the shape turns from bottom to top, and how the turning is spread up the vase – including back and forth, and your own curve. **Both ways** twists it both ways at once. |
-| **Surface** | Ribs, rings, bumps and organic lumps; whether they turn with the twist; whether they are spread **evenly round the wall** (by distance along it, not by angle – for boxes, rectangles and ovals); how strong they are up the vase; and a plain foot and rim. |
-| **Low-poly** | Flat facets: how many round and up, and how rough the cut. |
-| **Holes and openings** | The **holes** – none, Voronoi, round, diamonds or slots – their size, the wall between them, how irregular the Voronoi cells are and their pattern, how much plain wall to keep at the foot and the rim, and **rounded edges** round them; **open at the bottom**; **closed at the top**; and how far the wall **goes round**. Holes, an open bottom and a wall part way round are printed normally. |
-| **Masks and helmets** | **For a head** and its **head size**; the **eyes** – almond or round, their size, how far apart, how high and tilted; a raised **nose** – its width, length, how far it sticks out and how high its tip is; the **mouth** – open, a grille of slots (how many, and how much they smile) or open down to the chin; and **strap slots**. Sizes and heights are in millimetres along the face, from the bottom. |
-| **Tissue box** | **Tissue box cover**; the **box** of tissues it goes over – its length, width and height; how **rounded** the cover's corners are; and the **tissue hole** – a long slot, an oval, a round hole or none – and its length and width; and its **lid** (File ▸ Save the lid for printing): how far its latches reach in behind the ledge, and whether it is a frame open in the middle or a solid plate. |
-| **Your own function** | A formula added to the radius. **Examples** has eight to try; **Help…** lists the variables and functions. |
-| **Colours** | The colour, or – printed normally – a palette or four filament colours, and how they go up the vase: one colour, bands, stripes or a gradient spool. How it looks: matte, silk, glossy, translucent. |
-| **Printing** | **Print as**: a normal print (walls, any colours – the usual) or seamless (spiral vase mode, one colour). For a normal print, how many **walls**. The steepest overhang allowed and **Soften to fit**; the line width and layer height; the solid bottom; the printer's height and bed; speed and flow (for the print time); filament diameter and density (for the weight); and the mesh detail (0.2 mm; saving simplifies the mesh, so the file stays small). |
-
-The mouse wheel scrolls the panel: a drop-down list only changes with it once it is dropped down, a number
-box only over its arrows, and a slider never.
-
-**3D view:** left-drag turns it, right-drag or shift-drag moves it, the wheel zooms, double-click or F goes
-back to the chosen view. **S** spins it, **F5** watches it print, **Ctrl+1–4** switch views, **Ctrl+R**
-is Surprise me, **Ctrl+Z / Ctrl+Y** undo and redo.
-
-**Drawing a cross-section:** the middle of the editor is the vase's axis and the dashed circle its widest
-(the outline is scaled so its furthest point touches it). Drag a point; double-click the line to add one,
-and double-click a point to switch it between smooth (a circle) and a sharp corner (a diamond);
-right-click a point to take it away. **Repeat around** copies what you draw round the axis – draw one lobe,
-get five, or up to a hundred; change the number and the lobe narrows or widens to fit, just as deep.
-**Close up** shows one repeat big, turned to point upwards (above 8 repeats the editor starts that way),
-and double-clicking any copy adds the point to the lobe you draw. **Full window…** opens the editor as big
-as the screen: the wheel zooms, a right or middle drag moves it, **F** fits it again, and the vase follows
-as you draw. **Start from** has ready-made outlines – a claw, bulbs, a heart – and **Trace a
-picture…** (a dark closed line or filled shape on a light ground). Switching a built-in shape to Drawn
-starts from it: a five-petalled flower becomes one petal, repeated five times. Twist, the change into a
-second shape, the patterns and colour bands all work on a drawn outline; it turns red if it crosses itself.
-
-Under the 3D view, **From the side** shows the outline in the colour of each height (the dashed line is
-the narrowest radius, so you can see how deep the shape goes; an orange tick on the left marks heights
-that were softened, a red tick on the right anything still leaning too far), and **Layers from above**
-shows fifteen layers stacked, so the twist and the change of shape are easy to see.
-
-## Printing
-
-**Save for printing** gives the steps for your vase. *Advanced ▸ Printing ▸ Print as* chooses a normal
-print (the usual) or seamless.
-
-**A normal print**
-
-* Leave spiral vase mode **off**, and otherwise print with your **usual settings**. The vase comes as the
- hollow pot it is – a wall as thick as *Walls* × *Line width* (1.2 mm as it comes), the floor, the rim and
- any holes – so the slicer simply fills it in: there is nothing to set.
-* The colour bands and stripes are parts of their own filaments in the 3MF, so the slicer prints them in
- colour by itself; on a printer with several toolheads, like the Creator 5 Pro, each band uses its own
- head. It prints a layer at a time, with a seam.
-
-**Seamless (spiral vase mode)**
-
-* **Slice with spiral vase mode on** (Spiralize). The exported model is **solid**: the slicer prints only
- its outside, as one continuous line. Don't slice it normally with infill – it would print as a solid
- block.
-* Set **bottom layers** to the number the panel gives (6 for a 1.2 mm bottom at 0.2 mm layers), **0 top
- layers**, 0% infill, 1 wall.
-* Set the **outer wall line width** to the app's line width. 0.6–0.8 mm on a 0.4 mm nozzle is much
- stronger and more watertight than 0.4 mm.
-* It prints **one filament**. Flash Studio won't change filament in spiral vase mode (*Change Filament* is
- greyed out on the layer slider), and won't spiral an object made of parts of several filaments. A
- gradient spool is one filament, so it works.
-
-**Either way**
-
-* **No supports.** The panel shows the steepest lean of the wall; the Overhangs view shows where it is.
-* **To make sure it holds water:** a little extra flow (103–105%), 5–10 °C hotter than usual for strong
- layer bonds, and a moderate speed (30–50 mm/s for the wall). PETG is more watertight than PLA; silk PLA
- looks lovely but bonds a little less well. Test it in a sink. A coat of clear epoxy or varnish inside
- seals any weep.
-* A **brim** helps a vase with a narrow foot stay put – and a mask, which stands on the thin edge of its
- chin, or a helmet on its rim.
-
-`tools\verify_stl.py` checks any STL for spiral vase printing, straight from the file: closed and
-watertight, faces outwards, one piece, flat on the bed, no wall leaning past the limit, and every layer
-one closed loop.
-
-```
-.venv\Scripts\python tools\verify_stl.py examples\stl\lagoon_twist_vase.stl
-```
-
-## Opening the 3MF in your slicer
-
-**Save for printing** (and **Export 3MF**) writes the vase as one object in the middle of the bed – the
-same kind of file as Lenticular Sign Maker and Maze Tube Designer. **Open in slicer** opens it for you; in
-**Flash Studio or OrcaSlicer** you can also use **File ▸ Import** (Ctrl+I).
-
-* **A normal print:** the hollow pot, each colour band its own part, set to filaments 1, 2, 3… in the
- vase's colours; reassign them in the slicer as you like. Leave spiral vase mode off and print with your
- usual settings.
-* **Seamless:** the vase is one solid part. Turn on spiral vase mode and print.
-
-## Project layout
-
-| File | What it holds |
-| --- | --- |
-| `main.py` | Starts the app. |
-| `vaseinator/app.py` | The main window: the Advanced settings, views, animations, gallery, undo, exports. |
-| `vaseinator/simple_panel.py` | Simple mode: the picture choices, and the gallery of pictures. |
-| `vaseinator/choices.py` | What each of Simple mode's pictures sets – shared with the web version. |
-| `vaseinator/settings.py` | Every Advanced setting, the rules for which are shown and what a change brings with it – shared with the web version. |
-| `vaseinator/report.py` | The panel beside the 3D view, the steps to print it, and the help – shared with the web version. |
-| `vaseinator/slicers.py` | Finding the slicers installed, to open a vase in one. |
-| `vaseinator/geometry.py` | The vase itself: the surface, softening, the solid and the shown shell, and the sums. |
-| `vaseinator/shapes.py` | Outlines, cross-sections, curves up the height, textures, palettes. |
-| `vaseinator/expr.py` | Your own function: checked, then evaluated with numpy. |
-| `vaseinator/hollow.py` | The hollow pot a normal print makes, the hole patterns, a face's eyes, nose, mouth and strap slots, and their pointed tops. |
-| `vaseinator/sections.py` | Drawn cross-sections: the spline through the points, tracing a picture, filling and insetting outlines. |
-| `vaseinator/curves.py` | The curve editor. |
-| `vaseinator/outline_editor.py` | The cross-section editor. |
-| `vaseinator/plots.py` | The side and from-above pictures. |
-| `vaseinator/viewer.py` | The OpenGL 3D view. |
-| `vaseinator/presets.py` | The gallery and Surprise me. |
-| `vaseinator/params.py` | Settings, and saving and opening designs. |
-| `vaseinator/stl.py`, `threemf.py` | The exports. |
-| `tests/test_vase.py`, `tests/test_app.py` | Self-tests of the geometry and the window. |
-| `assets/gallery/` | A picture of each ready-made vase, drawn by `tools/make_thumbnails.py`. |
-| `tools/` | The icon, the gallery pictures, the example files, the STL checker, and `build_web.py`. |
-| `web/` | The web version: the page (`index.html`, `app.js`, `style.css`), the 3D view (`view.js`), the curve and cross-section editors (`curves.js`, `outline.js`), the side and from-above pictures (`plots.js`), the worker that runs the Python (`worker.js`) and the Python it calls (`bridge.py`). |
-| `.github/workflows/pages.yml` | Publishes the web version on GitHub Pages at every push to `main`. |
-
-### How the geometry works
-
-The vase is a radial height map r(θ, z): one radius in each direction round the axis at each height,
-built on a grid of heights and angles. Because each height has exactly one radius in each direction,
-every layer is one simple closed loop – what spiral vase mode needs – and the mesh can't cross itself:
-each triangle lies in its own wedge of angle between two heights. The wall is joined to a flat bottom
-and a flat top, so the solid is closed, watertight and one piece, with no boolean operations.
-
-A normal print is exported as the **hollow pot**: the outer face, an inner face moved in square to it by
-the wall's thickness, a flat floor (or an open bottom) and the rim, built row by row on the same grid as
-the surface. A **hole pattern** is a number at each grid point – how far inside a hole it is, in mm,
-below nought in the wall – from the nearest hole centres. A Voronoi cell's sides are the lines halfway to
-its neighbouring centres, and it leaves room for a hole half a strut in from them. Its hole is the oval
-with the same spread as that room – as long, as wide and as tilted, the tilt damped a little, the cells a
-little taller than a honeycomb's so most ovals stand upright – made as big as fits wholly inside it. Its
-round top closes over a bridge where its sides grow flatter than the overhang limit allows; an oval whose
-bridge would be too wide is made smaller. The
-wall is cut where that number is nought (marching squares), the same on both faces, with a side face
-joining them round each hole; each cut is one vertex shared by every face beside it, so the pot stays
-closed. Going up row by row, the wall over a hole may only reach in so far past the wall below it –
-measured from the point really below, so on a twisting vase the holes' sides lean no more than their
-tops – so every hole's top closes to a point at no more than the overhang limit. Where it finally closes
-between two grid points there is a short bridge (a Voronoi oval's is the width found for it), which the
-app measures and tells you about. **Rounded edges** moves the outer and inner faces apart round each
-hole, by a smooth curve of the distance from its edge that fades away a little further off and grows no
-faster going up than the overhang limit allows; the cut edge itself stays put, so the wall curves round
-through the hole. Colour
-bands of the pot are cut on rows put in at the band heights, each a closed part.
-
-A **face** is more of the same: the eyes, mouth and strap slots are hole fields too, placed in
-millimetres along the surface from the front, and the gap at the back of a wall that goes only part way
-round is one more – so they all get pointed tops, and a pattern of holes keeps a strut's width of wall
-round them. The nose is added to the radius, and softening keeps its underside within the limit. A
-**closed top** caps the outline's radius with a cone leaning a little under the limit up to a point –
-the two blended where they meet, so the outline curves in – and the pot's inner face ends a floor's
-thickness under the point in a small flat ceiling (the app measures the widest circle that fits in it:
-the span the printer bridges). **Head size** measures round the inner face where the head goes in – the
-narrowest place below the widest, or for a mask the widest – less a centimetre to spare, and scales the
-whole design until that is the head asked for.
-
-A **tissue box cover** is a vase too, upside down: a **rectangle** cross-section – its corners quarter
-circles – straight up, sized from the box of tissues: as wide and long inside as the box with room to spare
-each side (more where a rounded corner has to clear a square one, and more for patterns that reach in),
-and as tall as the box with a little over it plus the solid bottom, which is the cover's top. The hole
-for the tissues – a slot with round ends, an oval or a circle, kept clear of the walls – is cut through
-that bottom: the floor is filled between the wall and the hole, row by row round the middle, on the bed
-and on its top, with the hole's side between them, so the mesh stays closed. Everything else – holes in
-the wall, rounded edges, colour bands – works as on any vase. **Evenly round the wall** lays the patterns
-out by how far along the wall each point is, as a share of the way round, rather than by its angle from the
-middle – which on a box would crowd them along the middles of the long sides.
-
-Each end of a cover has a **ledge** inside, across the straight part of the end, for its lid: 1.2 mm deep,
-from 4 mm above the rim (the cover on), with a long gentle ramp below and a steep side above. Printed upside
-down, the ramp faces up and the steep side down, leaning 35° from upright, so it needs no supports. The ends
-have 2 mm more room inside for the lid's tabs, and holes keep 13 mm clear of the rim.
-
-Its **lid** is made from the cover as built: the plate is the outline of the rim, turned over as the cover
-is to go on, with a pull tab added at the end of the long way. Two tabs stand at each end, a quarter of the
-way in from the corners of its straight part (one in the middle, if the end is short): stiff **hooks**,
-1.6 mm thick, at the far end, and springy **latches**, 1 mm thick, by the pull tab. For each tab the inside
-of the wall is measured across the tab's width at every row up from the rim – swollen round any holes – and
-the tab stands 0.3 mm clear of where it comes nearest. Its bump reaches out behind the ledge – a hook's by
-0.7 mm, a latch's by the grip, 0.5, 0.7 or 0.9 mm – with its underside along the ledge's steep side, 0.3 mm
-above it. A loose guide stands in the middle of each long side, so the lid can't slide about.
-
-The **pot** is built on the grid, then the rows and columns that lie within 0.02 mm of the straight line
-between their neighbours are left out – on a box most of them, on a twisting vase few – and **saving**
-simplifies the mesh once more, merging triangles wherever the surface moves by no more than a few hundredths
-of a millimetre (pyfqmr in the app, meshoptimizer in the web version), keeping it closed.
-
-A **drawn** cross-section needn't have one radius in each direction – a lobe can swell past its neck – so
-it is built another way: as rows of points up the vase, each row the outline (blended into the second
-one, sized by the outline curve and turned by the twist) cut into the same number of points at even
-distances along it, the patterns pushed out square to it. Neighbouring rows join point to point, the
-bottom and top are filled by ear clipping, and any outline that crosses itself is refused, so every layer
-is still one simple loop. Softening limits how fast the size, the twist and the change of shape may go at
-any height where a face leans too far, and fades the patterns there gently.
-
-Shapes are smoothed to the grid's own spacing, so sharp points and valleys that twist across it don't
-alias into rows of little teeth.
-
-How far the wall leans is measured on the true surface normal: tan(lean) = r·|∂r/∂z| / √(r² + (∂r/∂θ)²),
-out or in alike. **Soften to fit** works in two steps. A shape twisting faster than its flanks can follow
-first has its flanks softened round each layer (a flank carried sideways at r·ω leans by
-r·ω·|r_θ| / √(r² + r_θ²)), so it stays one clean twisted shape. Then each column up the vase is limited to
-the slope the lean allows there – the largest surface under it and the smallest over it that keep within
-the limit, averaged – with the correction smoothed round the vase so neighbouring columns agree. Every
-finished triangle is then checked, and the limit tightened just round any that still lean too far.
-
-`.venv\Scripts\python -m tests.test_vase` builds every gallery vase, a dozen surprises and some hard
-cases – drawn outlines among them – and checks that each is closed and watertight with outward faces, flat on the bed, within the
-overhang limit, and one closed loop at every height tested; that leans are measured right; that a
-cylinder has the right volume and capacity; that STL and 3MF files read back the same; that designs save
-and open; that your own function can reach nothing it shouldn't; that colour changes land on layers; and
-that a drawn outline goes through its points, matches the built-in shape it came from, holds what a round
-vase holds, is refused if it crosses itself, can be traced from a picture, and repeated a hundred times –
-narrowed to fit, checked, filled and built closed in a few seconds; that a normal print is the hollow pot,
-with the right volume, its colour bands parts that make it up exactly; and that every pattern of holes,
-on round and drawn shapes, twisting or not, makes a closed pot whose holes' sides lean no more than the
-limit but for short bridges at their points, that holds nothing, with an open bottom too – and Voronoi
-ovals that leave every wall between them as thick as set, and rounded edges that swell as set and keep
-within the limit; and that a
-closed top comes to a point over a small ceiling (drawn and banded too), a wall part way round stops where
-it should, a face's holes and nose are where they should be and print without support, and a design sized
-for a head fits it – the masks and helmet a head 57 cm round; that a tissue box cover clears its box of
-tissues by the room to spare everywhere inside, with its hole through the bottom layers alone, holes and
-bands and all, and that its lid is closed, clear of the cover's printed wall everywhere when it is on, and
-caught by the ledges when it is pulled down a millimetre; and that the web version's Python, put
-together as the site has it, runs with no Qt at all – it starts, builds a mask, fits it to a head and makes
-the slicer's file.
-`.venv\Scripts\python -m tests.test_app` drives the window itself: edits, curves, undo, the gallery,
-Surprise me, every view, the print animation, saving and exporting, and drawing (with the mouse, close
-up and in a full window, zoomed with the wheel), repeating up to a hundred times and tracing a
-cross-section, and Simple mode: each kind of picture, the gallery of pictures, Save for printing with its
-steps and Open in slicer, bigger text and the light theme, and a mask sized by the head it fits, its face's
-settings and Undo. Test windows open on the second screen, if there is one.
-
-Either can run just part of itself, for a quick check after a small change: name a word, and only the tests
-(or the window's sections) with it in their names run – `tests.test_vase lid box` runs the tissue box
-cover's and its lid's tests, `tests.test_app box` the window's tissue box section. The window's sections
-are edits, gallery, morph, views, saving, drawn, simple, holes, masks, box, look and function; each starts
-from a design of its own, so any can run alone. A name that matches nothing lists them all.
-
-### The web version
-
-The page runs the **same Python** as the desktop app – the parts of `vaseinator` that need no Qt: the
-geometry, the designs, the settings and their rules, the report and the exports – in the browser with
-[Pyodide](https://pyodide.org) and numpy. It runs two web workers, so the page stays smooth: one builds the
-vase, the other answers everything else at once – what a change of setting brings with it, which settings
-to show, tracing a picture, fitting a head. `web/bridge.py` is the little bit between them and the page: it
-builds a vase and hands back its mesh as raw arrays, which the page draws with
-[three.js](https://threejs.org), along with the desktop app's own report, the pictures' data and the
-print's timing, and makes the 3MF, STL and `.vase` files the page saves. The Advanced panel is made from
-the desktop app's own list of settings. A quick draft shows at once – and follows the hand while a curve
-or an outline is dragged – and the full detail follows a moment later. Nothing is sent anywhere: the vase
-is built on your own computer.
-
-`tools\build_web.py` puts the site together in `site\`: the page, the Python and the gallery pictures,
-the page's files stamped with a version so that after an update a browser never mixes old files with new.
-To try it locally:
-
-```
-.venv\Scripts\python tools\build_web.py
-.venv\Scripts\python -m http.server 8000 -d site
-```
-
-and open . GitHub Pages builds and publishes it the same way on every push to `main`.
-
-## License
-
-MIT – see [LICENSE](LICENSE).
+# Vaseinator
+
+A Windows desktop app that designs **vases that print without supports** – as an ordinary print with a
+few walls, in any colours, or **seamless** in the slicer's spiral vase mode. Every vase it makes is a
+closed, watertight model, every layer is one single loop, and no part of the wall leans further than you
+allow.
+
+**Simple** mode is a handful of choices made from pictures – easy enough for anyone – and one big
+**Save for printing** button; **Advanced** has every setting.
+
+**Use it in your browser – nothing to install:** . The web
+version is the whole app – Simple and Advanced, every setting, the curves, drawing your own cross-section,
+the views and the exports – built by the app's own Python running right in the browser.
+
+Shape the outline, pick a cross-section and what it turns into on the way up, **spin** it, cover it in
+ribs, rings, bumps, organic lumps or **your own formula**, and colour it in bands, stripes or a
+gradient spool. Cut **holes** through it, open it at the bottom, close it over at the top – or give it a
+**face** and make a **mask or a helmet**, sized for the head it fits, or a **tissue box cover** sized for
+the box of tissues it goes over. The app shows the vase in 3D as it will print, cuts it in half to show the wall, colours
+every face by how far it leans, plays the print back in real print time, and exports an STL or 3MF
+ready to slice.
+
+
+
+
+
+| Advanced: draw how things change up the vase | Section: the printed wall |
+|---|---|
+|  |  |
+| **Overhangs: green is easy, red needs support** | **Watch it print, in real print time** |
+|  |  |
+| **Draw any cross-section, sharp corners and all** | **Draw one lobe, repeat it: three bulbs become six** |
+|  |  |
+
+## Features
+
+* **Outline** from twelve presets (classic, bulb, tulip, amphora, bottle, gourd, trumpet, …) or drawn
+ by hand: drag points on the outline curve; the curve through them never overshoots.
+* **Cross-section**: circle, polygon (with rounded corners), rectangle, star, flower, gear, ellipse or
+ pebble – and
+ a second shape it **turns into** on the way up – evenly, early, late, **back and forth** as many times
+ as you like (circle → pentagon → circle → pentagon), or along your own curve.
+* **Draw any cross-section**: a spline editor with smooth points and sharp **cusps**, for shapes no
+ formula makes – lobes that swell past their necks, a crescent, a claw, a heart, nothing symmetrical at
+ all. Draw one lobe and **repeat it around** – up to 100 times – start from a built-in shape, or
+ **trace a picture**. Draw close up on one repeat, or in a **full window** with zoom.
+* **Spin**: twist the shape round as it rises – evenly, easing in and out, speeding up, slowing down,
+ **back and forth**, or along your own curve – or **both ways at once** for a lattice of crossing ridges.
+* **Surface patterns**: ribs (that can spiral, or cross for diamond knurling), rings, staggered bumps
+ and smooth organic lumps, each with its own depth and shape, a strength that can change up the vase,
+ and a plain foot and rim.
+* **Low-poly facets**: the whole vase from flat triangles, like a cut gem, with an optional rough cut.
+* **Your own function**: type a formula in millimetres – `1.5 * sin(6*theta + 4*pi*h)` – added to the
+ radius everywhere. Variables for the angle, the angle turning with the twist, height, share of the
+ height and the radius so far; trig, waves, steps and smooth noise. A long formula can go over several
+ lines – they are read as one, so start each new line with + - * or / – with notes after a `#`. It is
+ checked and run safely: numbers, the listed variables and functions only; a mistake is pointed out by
+ line and character.
+* **A normal print, or seamless**: a vase prints like any other print, and is exported as the **hollow pot**
+ it is – the wall as thick as it prints, the floor and the rim – so the slicer needs **no special
+ settings**: sturdy, watertight and in colour. Or, in one colour (or a gradient spool), **seamless** in the
+ slicer's spiral vase mode: one line climbing round and round, with no seam and a single wall that glows
+ when lit. Spiral vase mode is hardly any quicker, so a normal print is the usual.
+* **Holes** right through the wall: **Voronoi** cells (a honeycomb when regular), **round holes**,
+ **diamonds** or **slots**, as big and as far apart as you like, kept clear of a plain foot and rim, and
+ turning with the twist. Every hole's top comes to a **point** that closes no faster than the overhang
+ limit, so it prints **without support** – with at most a short bridge, a few millimetres, at its very tip.
+ Voronoi holes are **ovals** instead, each as long, as wide and as tilted as its cell and as big as fits
+ inside it, so the wall between them is thin where two holes come close and thick in the corners; their
+ round tops close over a bridge of at most 14 mm. **Rounded edges** swells the wall round every hole, so
+ it curves smoothly from the outside, through the hole, to the inside.
+ And **open at the bottom** too, for lampshades, sleeves and holders.
+* **Closed at the top**: the top comes in to a point, leaning no more than the overhang limit, over a
+ ceiling a few millimetres across that the printer bridges – a helmet, a dome, a bell, a sealed egg. The
+ outline is rounded in to meet the point.
+* **Part way round**: the wall goes only so far round – about 200° makes a mask – with the gap at the back.
+* **Masks and helmets**: a **face** at the front – **eyes** (almond or round, apart, tilted), a raised
+ **nose**, a **mouth** (open, a grille of slots that can smile, or open down to the chin like an old
+ Greek helmet's) and **slots for an elastic strap** – every hole with a pointed top, so it prints without
+ support. **Head size** fits one to a head: measure round it above the eyebrows and ears, as for a hat,
+ and the whole design – height, outline and face – is scaled so the inside fits with a centimetre to
+ spare. Stylised, not anatomical.
+* **Tissue box covers**: measure the box of tissues – long, wide, tall – and the cover is made to go over
+ it, with a little room to spare (more inside rounded corners, so a square-cornered box still fits). Pick
+ the hole the tissues come out of – a long **slot**, an **oval** or a **round** hole – and any pattern,
+ holes or colours. It is made the way it prints: **upside down**, the hole through its first layers on the
+ bed and the open end at the top, so nothing needs supports. Turn it over to put it on. Its ribs, bumps
+ and lumps are spread **evenly round the wall**, as close together on the long sides as round the ends.
+* **A lid for a tissue box cover**, printed on its own – with the cover, or later: a flat frame (or a
+ solid plate) the shape of the rim, which the cover stands on. Inside each end of the cover is a small
+ **ledge**. The lid goes on like a battery cover: tilted, its stiff **hooks** go in behind the ledge at
+ one end, and then its springy **latches** click in behind the ledge at the other – **Gentle**, **Snug**
+ or **Firm**. It stays on when the cover is picked up, and its **pull tab** takes it off to change the
+ tissues. It is made from the cover's own design; a cover printed before 1.13 has no ledges.
+* **A fine mesh, a light file**: the surface is built every 0.2 mm, and **saving for the slicer simplifies
+ it** – triangles are merged wherever that moves the surface by no more than a few hundredths of a
+ millimetre – so the file stays small and the slicer opens it quickly. A box's flat sides need only a
+ few rows and columns of points to begin with.
+* **Colours**: one colour or a **gradient spool** – shown where its colours will really land, worked out
+ from the filament each height uses – and, printed normally, up to four filaments in **bands**
+ (even, or thicker at the top or the bottom) or **stripes**, with palettes.
+* **Looks**: matte, silk, glossy or translucent in the 3D view.
+* **Printable, always**: each layer of the wall rests on the one below it, so it may lean neither out nor
+ in further than the limit (45° by default). **Soften to fit** reshapes only what leans too far – and
+ tells you by how much – and every triangle is checked afterwards.
+* **Views**: the vase, a **section** through the printed wall, an **overhang check**, and the print
+ layout on your bed inside the printer's build volume. **Spin** turns it on a turntable; **Watch it
+ print** builds it up in real print time (wide layers take longer).
+* **The sums**: size, base and opening, how much water it holds, filament in grams and metres, print
+ time, steepest overhang, and the mesh – closed and watertight.
+* **Gallery** of twenty-seven ready-made designs – lampshades and baskets with holes among them, three masks
+ and a helmet – a **plain cylinder** to start from blank, **Surprise me** (a new random vase that still
+ prints), and **undo / redo** of everything.
+* **Save for printing**: a **3MF** in the middle of the bed – each colour band its own part, set to its
+ filament (one solid for a seamless vase) – as Lenticular Sign Maker and Maze Tube Designer do, with plain
+ steps to print it and **Open in slicer** (Flash Studio, OrcaSlicer, Bambu Studio, PrusaSlicer, Cura… –
+ whichever is installed). Also a solid **STL**. Designs save as `.vase` files (JSON).
+* **Simple**: a design chosen from pictures, how big (in centimetres – or, for a mask or helmet, the head
+ size), the shape from above and from the side, the twist, a pattern and a colour – each a picture to
+ click. **Advanced** has every setting and
+ the curves, your own function among them. **Bigger text** and a **light theme** are in the View menu.
+
+## Download and run
+
+### Design library and profiles (desktop and web)
+
+Open **Designs → Library, profiles and Surprise locks** on desktop or in the browser.
+Both interfaces use the same Python rules and portable library format:
+
+* **Surprise locks:** preserve Dimensions (height and widest diameter), Silhouette (including its width),
+ Cross-section, Twist, or Colours while exploring new random variations. Printer settings are always kept.
+ Locks preserve the requested settings; softening can still alter the printable surface. Existing masks
+ and tissue-box modes are not locks: Surprise me still creates a vase.
+* **My designs:** save named variants, browse silhouette sketches, mark favourites, restore, or remove them.
+ Restoring a variant remains undoable. These are silhouette thumbnails, not full 3D renders.
+* **Printer and material profiles:** name and apply printer settings separately from material properties.
+ Printer profiles store bed/build height, line/layer widths, bottom, walls, overhang limit and mesh detail.
+ Material profiles store filament diameter, density, speed and maximum flow. Applying a profile keeps the
+ design's shape and colours and can be undone.
+* **Recovery:** retains up to 30 settled snapshots, beyond the current session's undo history. It is local
+ to this computer/browser. Export a library backup before clearing browser storage or moving computers.
+* **Export/import library:** transfer variants, profiles, recovery and locks between desktop and browser.
+ Import merges entries by identity, leaves existing entries intact, and combines locks. Each library can
+ hold up to 500 saved designs and 500 profiles. There is no automatic cloud synchronisation.
+
+In **View → Compare original and softened**, blue shows the requested side envelope and orange shows
+the softened envelope. Connecting strokes highlight changed widths; the maximum surface movement is
+also reported. This side view can overlap where changes are angular rather than changes in outer width.
+
+**File → Save clearance samples** exports five socket-and-pin pairs as one 3MF. Left to right, the radial
+clearances are 0.1, 0.2, 0.3, 0.4 and 0.5 mm (diametral clearances are double those values). Print with the
+intended material and settings, keep the pairs in order, and test the fit before committing to a larger
+print. These are general clearance samples, not replicas of the lid latches; they do not change a design's
+clearance automatically. The sample layout is approximately 92 × 33 × 3 mm.
+
+### Regression checks
+
+```text
+python -m tests.test_vase
+python -m unittest tests.test_workspace -v
+node tests/test_web_state.cjs
+python -m tests.test_app
+python -m unittest tests.test_design_tools -v
+```
+
+The desktop tests require the packages in `requirements.txt`. They can run with
+`QT_QPA_PLATFORM=offscreen`. The optional `node tests/test_browser.cjs` end-to-end test needs Playwright,
+a Chrome/Chromium executable in `BROWSER_PATH`, and a built site served at `http://127.0.0.1:8765`
+(or `VASE_URL`). Shared geometry, library and browser-state regressions run before Pages deployment.
+
+* **In your browser:** – the whole app, Simple and Advanced:
+ every setting, the curves, drawing and tracing your own cross-section (in a full window too), your own
+ function, the Vase, Section, Overhangs and Print layout views, Spin and Watch it print, the side and
+ from-above pictures, and Save for printing (3MF), STL, a picture and `.vase` designs – which the Windows
+ app opens too, and back. The first visit takes a little while to load; after that it starts quickly. The
+ one thing only the Windows app does is open the file in your slicer for you.
+* **Windows app (no Python needed):** download `Vaseinator.exe` from the
+ [Releases](../../releases) page and run it. It is a single file: nothing to unzip or install.
+ It isn't code-signed, so Windows SmartScreen may ask once; choose **More info → Run anyway**.
+* **From source** (Python 3.10+, any OS with OpenGL):
+ ```
+ git clone https://github.com/RickUsingGitHub/vaseinator.git
+ cd vaseinator
+ python -m venv .venv
+ .venv\Scripts\pip install -r requirements.txt # macOS/Linux: .venv/bin/pip
+ .venv\Scripts\python main.py
+ ```
+ On Windows you can also just double-click `run.bat`, which sets everything up on first run.
+* **Build the Windows app yourself:** `build_exe.bat` (uses PyInstaller; the result is
+ `dist\Vaseinator.exe`).
+
+Vaseinator is free. If it has made you something you like, you can [buy me a coffee](https://ko-fi.com/rickapps).
+
+`examples\` holds the gallery as `.vase` designs to open. `tools\make_examples.py` writes them again, and
+six of them to `examples\stl\` as finished STLs, checked and ready to slice: Lagoon Twist, Star to Moon,
+Lattice Lantern, Gem Facets, Coral Reef and Claw Shell. (STLs are big, so they are not kept in git.)
+
+On first run the app borrows your printer's bed size and height from Maze Tube Designer or Lenticular
+Sign Maker, if you have used them. Printer settings are remembered for new designs.
+
+## Using it
+
+In **Simple** mode, click **Choose a design…** (every ready-made vase as a picture) or **Surprise me**,
+then click the pictures to change it: the shape from above, the shape from the side, a pattern, a colour;
+**Tall** and **Wide** size it – for a mask or helmet, **Head size**; for a tissue box cover, the box of
+tissues it goes over, and the hole for the tissues, and **Save a lid for it…** – and the slider twists it. **Draw my own shape…** opens a big window to draw
+the shape from above yourself. When it looks right, **Save for printing…** saves it for the slicer and
+says, in plain steps, how to print it – with a button to open it in your slicer (File ▸ Choose slicer
+program picks a different one).
+
+Or start blank: **Plain cylinder**, first in the gallery (or **File ▸ New plain cylinder**, Ctrl+Shift+N),
+is a cylinder 200 mm across and 200 mm tall with everything else off, ready for your own function or a
+drawn cross-section; your printer settings are kept. Every change rebuilds the vase at once; while you
+drag a point on a curve, a quicker rough version follows your hand.
+
+The curves for the twist, the change of shape and the pattern strength are ordinary graphs: the vase
+from bottom to top runs left to right, and the value runs up – drag a point up for more, down for
+less. Double-click to add a point (a few make a wave), right-click to take one away. The outline's
+curve is drawn upright and mirrored instead, like the vase from the side.
+
+| Group | What it does |
+| --- | --- |
+| **Size** | Height, the widest diameter (scales the whole outline), and the outline: a preset, or your own curve – diameter at each height, drawn as the vase seen from the side. |
+| **Shape** | The cross-section at the bottom, and what it turns into at the top. Each shape has its own settings: sides, points, petals or teeth; how deep it cuts in; how sharp it is; how far it is turned. **Drawn – any outline** gives you the cross-section editor instead (below). **Change happens** says how the first shape becomes the second on the way up – including back and forth, as many **Changes** as you like, and your own curve. |
+| **Spin** | How far the shape turns from bottom to top, and how the turning is spread up the vase – including back and forth, and your own curve. **Both ways** twists it both ways at once. |
+| **Surface** | Ribs, rings, bumps and organic lumps; whether they turn with the twist; whether they are spread **evenly round the wall** (by distance along it, not by angle – for boxes, rectangles and ovals); how strong they are up the vase; and a plain foot and rim. |
+| **Low-poly** | Flat facets: how many round and up, and how rough the cut. |
+| **Holes and openings** | The **holes** – none, Voronoi, round, diamonds or slots – their size, the wall between them, how irregular the Voronoi cells are and their pattern, how much plain wall to keep at the foot and the rim, and **rounded edges** round them; **open at the bottom**; **closed at the top**; and how far the wall **goes round**. Holes, an open bottom and a wall part way round are printed normally. |
+| **Masks and helmets** | **For a head** and its **head size**; the **eyes** – almond or round, their size, how far apart, how high and tilted; a raised **nose** – its width, length, how far it sticks out and how high its tip is; the **mouth** – open, a grille of slots (how many, and how much they smile) or open down to the chin; and **strap slots**. Sizes and heights are in millimetres along the face, from the bottom. |
+| **Tissue box** | **Tissue box cover**; the **box** of tissues it goes over – its length, width and height; how **rounded** the cover's corners are; and the **tissue hole** – a long slot, an oval, a round hole or none – and its length and width; and its **lid** (File ▸ Save the lid for printing): how far its latches reach in behind the ledge, and whether it is a frame open in the middle or a solid plate. |
+| **Your own function** | A formula added to the radius. **Examples** has eight to try; **Help…** lists the variables and functions. |
+| **Colours** | The colour, or – printed normally – a palette or four filament colours, and how they go up the vase: one colour, bands, stripes or a gradient spool. How it looks: matte, silk, glossy, translucent. |
+| **Printing** | **Print as**: a normal print (walls, any colours – the usual) or seamless (spiral vase mode, one colour). For a normal print, how many **walls**. The steepest overhang allowed and **Soften to fit**; the line width and layer height; the solid bottom; the printer's height and bed; speed and flow (for the print time); filament diameter and density (for the weight); and the mesh detail (0.2 mm; saving simplifies the mesh, so the file stays small). |
+
+The mouse wheel scrolls the panel: a drop-down list only changes with it once it is dropped down, a number
+box only over its arrows, and a slider never.
+
+**3D view:** left-drag turns it, right-drag or shift-drag moves it, the wheel zooms, double-click or F goes
+back to the chosen view. **S** spins it, **F5** watches it print, **Ctrl+1–4** switch views, **Ctrl+R**
+is Surprise me, **Ctrl+Z / Ctrl+Y** undo and redo.
+
+**Drawing a cross-section:** the middle of the editor is the vase's axis and the dashed circle its widest
+(the outline is scaled so its furthest point touches it). Drag a point; double-click the line to add one,
+and double-click a point to switch it between smooth (a circle) and a sharp corner (a diamond);
+right-click a point to take it away. **Repeat around** copies what you draw round the axis – draw one lobe,
+get five, or up to a hundred; change the number and the lobe narrows or widens to fit, just as deep.
+**Close up** shows one repeat big, turned to point upwards (above 8 repeats the editor starts that way),
+and double-clicking any copy adds the point to the lobe you draw. **Full window…** opens the editor as big
+as the screen: the wheel zooms, a right or middle drag moves it, **F** fits it again, and the vase follows
+as you draw. **Start from** has ready-made outlines – a claw, bulbs, a heart – and **Trace a
+picture…** (a dark closed line or filled shape on a light ground). Switching a built-in shape to Drawn
+starts from it: a five-petalled flower becomes one petal, repeated five times. Twist, the change into a
+second shape, the patterns and colour bands all work on a drawn outline; it turns red if it crosses itself.
+
+Under the 3D view, **From the side** shows the outline in the colour of each height (the dashed line is
+the narrowest radius, so you can see how deep the shape goes; an orange tick on the left marks heights
+that were softened, a red tick on the right anything still leaning too far), and **Layers from above**
+shows fifteen layers stacked, so the twist and the change of shape are easy to see.
+
+## Printing
+
+**Save for printing** gives the steps for your vase. *Advanced ▸ Printing ▸ Print as* chooses a normal
+print (the usual) or seamless.
+
+**A normal print**
+
+* Leave spiral vase mode **off**, and otherwise print with your **usual settings**. The vase comes as the
+ hollow pot it is – a wall as thick as *Walls* × *Line width* (1.2 mm as it comes), the floor, the rim and
+ any holes – so the slicer simply fills it in: there is nothing to set.
+* The colour bands and stripes are parts of their own filaments in the 3MF, so the slicer prints them in
+ colour by itself; on a printer with several toolheads, like the Creator 5 Pro, each band uses its own
+ head. It prints a layer at a time, with a seam.
+
+**Seamless (spiral vase mode)**
+
+* **Slice with spiral vase mode on** (Spiralize). The exported model is **solid**: the slicer prints only
+ its outside, as one continuous line. Don't slice it normally with infill – it would print as a solid
+ block.
+* Set **bottom layers** to the number the panel gives (6 for a 1.2 mm bottom at 0.2 mm layers), **0 top
+ layers**, 0% infill, 1 wall.
+* Set the **outer wall line width** to the app's line width. 0.6–0.8 mm on a 0.4 mm nozzle is much
+ stronger and more watertight than 0.4 mm.
+* It prints **one filament**. Flash Studio won't change filament in spiral vase mode (*Change Filament* is
+ greyed out on the layer slider), and won't spiral an object made of parts of several filaments. A
+ gradient spool is one filament, so it works.
+
+**Either way**
+
+* **No supports.** The panel shows the steepest lean of the wall; the Overhangs view shows where it is.
+* **To make sure it holds water:** a little extra flow (103–105%), 5–10 °C hotter than usual for strong
+ layer bonds, and a moderate speed (30–50 mm/s for the wall). PETG is more watertight than PLA; silk PLA
+ looks lovely but bonds a little less well. Test it in a sink. A coat of clear epoxy or varnish inside
+ seals any weep.
+* A **brim** helps a vase with a narrow foot stay put – and a mask, which stands on the thin edge of its
+ chin, or a helmet on its rim.
+
+`tools\verify_stl.py` checks any STL for spiral vase printing, straight from the file: closed and
+watertight, faces outwards, one piece, flat on the bed, no wall leaning past the limit, and every layer
+one closed loop.
+
+```
+.venv\Scripts\python tools\verify_stl.py examples\stl\lagoon_twist_vase.stl
+```
+
+## Opening the 3MF in your slicer
+
+**Save for printing** (and **Export 3MF**) writes the vase as one object in the middle of the bed – the
+same kind of file as Lenticular Sign Maker and Maze Tube Designer. **Open in slicer** opens it for you; in
+**Flash Studio or OrcaSlicer** you can also use **File ▸ Import** (Ctrl+I).
+
+* **A normal print:** the hollow pot, each colour band its own part, set to filaments 1, 2, 3… in the
+ vase's colours; reassign them in the slicer as you like. Leave spiral vase mode off and print with your
+ usual settings.
+* **Seamless:** the vase is one solid part. Turn on spiral vase mode and print.
+
+## Project layout
+
+| File | What it holds |
+| --- | --- |
+| `main.py` | Starts the app. |
+| `vaseinator/app.py` | The main window: the Advanced settings, views, animations, gallery, undo, exports. |
+| `vaseinator/simple_panel.py` | Simple mode: the picture choices, and the gallery of pictures. |
+| `vaseinator/choices.py` | What each of Simple mode's pictures sets – shared with the web version. |
+| `vaseinator/settings.py` | Every Advanced setting, the rules for which are shown and what a change brings with it – shared with the web version. |
+| `vaseinator/report.py` | The panel beside the 3D view, the steps to print it, and the help – shared with the web version. |
+| `vaseinator/slicers.py` | Finding the slicers installed, to open a vase in one. |
+| `vaseinator/geometry.py` | The vase itself: the surface, softening, the solid and the shown shell, and the sums. |
+| `vaseinator/shapes.py` | Outlines, cross-sections, curves up the height, textures, palettes. |
+| `vaseinator/expr.py` | Your own function: checked, then evaluated with numpy. |
+| `vaseinator/hollow.py` | The hollow pot a normal print makes, the hole patterns, a face's eyes, nose, mouth and strap slots, and their pointed tops. |
+| `vaseinator/sections.py` | Drawn cross-sections: the spline through the points, tracing a picture, filling and insetting outlines. |
+| `vaseinator/curves.py` | The curve editor. |
+| `vaseinator/outline_editor.py` | The cross-section editor. |
+| `vaseinator/plots.py` | The side and from-above pictures. |
+| `vaseinator/viewer.py` | The OpenGL 3D view. |
+| `vaseinator/presets.py` | The gallery and Surprise me. |
+| `vaseinator/params.py` | Settings, and saving and opening designs. |
+| `vaseinator/stl.py`, `threemf.py` | The exports. |
+| `tests/test_vase.py`, `tests/test_app.py` | Self-tests of the geometry and the window. |
+| `assets/gallery/` | A picture of each ready-made vase, drawn by `tools/make_thumbnails.py`. |
+| `tools/` | The icon, the gallery pictures, the example files, the STL checker, and `build_web.py`. |
+| `web/` | The web version: the page (`index.html`, `app.js`, `style.css`), the 3D view (`view.js`), the curve and cross-section editors (`curves.js`, `outline.js`), the side and from-above pictures (`plots.js`), the worker that runs the Python (`worker.js`) and the Python it calls (`bridge.py`). |
+| `.github/workflows/pages.yml` | Publishes the web version on GitHub Pages at every push to `main`. |
+
+### How the geometry works
+
+The vase is a radial height map r(θ, z): one radius in each direction round the axis at each height,
+built on a grid of heights and angles. Because each height has exactly one radius in each direction,
+every layer is one simple closed loop – what spiral vase mode needs – and the mesh can't cross itself:
+each triangle lies in its own wedge of angle between two heights. The wall is joined to a flat bottom
+and a flat top, so the solid is closed, watertight and one piece, with no boolean operations.
+
+A normal print is exported as the **hollow pot**: the outer face, an inner face moved in square to it by
+the wall's thickness, a flat floor (or an open bottom) and the rim, built row by row on the same grid as
+the surface. A **hole pattern** is a number at each grid point – how far inside a hole it is, in mm,
+below nought in the wall – from the nearest hole centres. A Voronoi cell's sides are the lines halfway to
+its neighbouring centres, and it leaves room for a hole half a strut in from them. Its hole is the oval
+with the same spread as that room – as long, as wide and as tilted, the tilt damped a little, the cells a
+little taller than a honeycomb's so most ovals stand upright – made as big as fits wholly inside it. Its
+round top closes over a bridge where its sides grow flatter than the overhang limit allows; an oval whose
+bridge would be too wide is made smaller. The
+wall is cut where that number is nought (marching squares), the same on both faces, with a side face
+joining them round each hole; each cut is one vertex shared by every face beside it, so the pot stays
+closed. Going up row by row, the wall over a hole may only reach in so far past the wall below it –
+measured from the point really below, so on a twisting vase the holes' sides lean no more than their
+tops – so every hole's top closes to a point at no more than the overhang limit. Where it finally closes
+between two grid points there is a short bridge (a Voronoi oval's is the width found for it), which the
+app measures and tells you about. **Rounded edges** moves the outer and inner faces apart round each
+hole, by a smooth curve of the distance from its edge that fades away a little further off and grows no
+faster going up than the overhang limit allows; the cut edge itself stays put, so the wall curves round
+through the hole. Colour
+bands of the pot are cut on rows put in at the band heights, each a closed part.
+
+A **face** is more of the same: the eyes, mouth and strap slots are hole fields too, placed in
+millimetres along the surface from the front, and the gap at the back of a wall that goes only part way
+round is one more – so they all get pointed tops, and a pattern of holes keeps a strut's width of wall
+round them. The nose is added to the radius, and softening keeps its underside within the limit. A
+**closed top** caps the outline's radius with a cone leaning a little under the limit up to a point –
+the two blended where they meet, so the outline curves in – and the pot's inner face ends a floor's
+thickness under the point in a small flat ceiling (the app measures the widest circle that fits in it:
+the span the printer bridges). **Head size** measures round the inner face where the head goes in – the
+narrowest place below the widest, or for a mask the widest – less a centimetre to spare, and scales the
+whole design until that is the head asked for.
+
+A **tissue box cover** is a vase too, upside down: a **rectangle** cross-section – its corners quarter
+circles – straight up, sized from the box of tissues: as wide and long inside as the box with room to spare
+each side (more where a rounded corner has to clear a square one, and more for patterns that reach in),
+and as tall as the box with a little over it plus the solid bottom, which is the cover's top. The hole
+for the tissues – a slot with round ends, an oval or a circle, kept clear of the walls – is cut through
+that bottom: the floor is filled between the wall and the hole, row by row round the middle, on the bed
+and on its top, with the hole's side between them, so the mesh stays closed. Everything else – holes in
+the wall, rounded edges, colour bands – works as on any vase. **Evenly round the wall** lays the patterns
+out by how far along the wall each point is, as a share of the way round, rather than by its angle from the
+middle – which on a box would crowd them along the middles of the long sides.
+
+Each end of a cover has a **ledge** inside, across the straight part of the end, for its lid: 1.2 mm deep,
+from 4 mm above the rim (the cover on), with a long gentle ramp below and a steep side above. Printed upside
+down, the ramp faces up and the steep side down, leaning 35° from upright, so it needs no supports. The ends
+have 2 mm more room inside for the lid's tabs, and holes keep 13 mm clear of the rim.
+
+Its **lid** is made from the cover as built: the plate is the outline of the rim, turned over as the cover
+is to go on, with a pull tab added at the end of the long way. Two tabs stand at each end, a quarter of the
+way in from the corners of its straight part (one in the middle, if the end is short): stiff **hooks**,
+1.6 mm thick, at the far end, and springy **latches**, 1 mm thick, by the pull tab. For each tab the inside
+of the wall is measured across the tab's width at every row up from the rim – swollen round any holes – and
+the tab stands 0.3 mm clear of where it comes nearest. Its bump reaches out behind the ledge – a hook's by
+0.7 mm, a latch's by the grip, 0.5, 0.7 or 0.9 mm – with its underside along the ledge's steep side, 0.3 mm
+above it. A loose guide stands in the middle of each long side, so the lid can't slide about.
+
+The **pot** is built on the grid, then the rows and columns that lie within 0.02 mm of the straight line
+between their neighbours are left out – on a box most of them, on a twisting vase few – and **saving**
+simplifies the mesh once more, merging triangles wherever the surface moves by no more than a few hundredths
+of a millimetre (pyfqmr in the app, meshoptimizer in the web version), keeping it closed.
+
+A **drawn** cross-section needn't have one radius in each direction – a lobe can swell past its neck – so
+it is built another way: as rows of points up the vase, each row the outline (blended into the second
+one, sized by the outline curve and turned by the twist) cut into the same number of points at even
+distances along it, the patterns pushed out square to it. Neighbouring rows join point to point, the
+bottom and top are filled by ear clipping, and any outline that crosses itself is refused, so every layer
+is still one simple loop. Softening limits how fast the size, the twist and the change of shape may go at
+any height where a face leans too far, and fades the patterns there gently.
+
+Shapes are smoothed to the grid's own spacing, so sharp points and valleys that twist across it don't
+alias into rows of little teeth.
+
+How far the wall leans is measured on the true surface normal: tan(lean) = r·|∂r/∂z| / √(r² + (∂r/∂θ)²),
+out or in alike. **Soften to fit** works in two steps. A shape twisting faster than its flanks can follow
+first has its flanks softened round each layer (a flank carried sideways at r·ω leans by
+r·ω·|r_θ| / √(r² + r_θ²)), so it stays one clean twisted shape. Then each column up the vase is limited to
+the slope the lean allows there – the largest surface under it and the smallest over it that keep within
+the limit, averaged – with the correction smoothed round the vase so neighbouring columns agree. Every
+finished triangle is then checked, and the limit tightened just round any that still lean too far.
+
+`.venv\Scripts\python -m tests.test_vase` builds every gallery vase, a dozen surprises and some hard
+cases – drawn outlines among them – and checks that each is closed and watertight with outward faces, flat on the bed, within the
+overhang limit, and one closed loop at every height tested; that leans are measured right; that a
+cylinder has the right volume and capacity; that STL and 3MF files read back the same; that designs save
+and open; that your own function can reach nothing it shouldn't; that colour changes land on layers; and
+that a drawn outline goes through its points, matches the built-in shape it came from, holds what a round
+vase holds, is refused if it crosses itself, can be traced from a picture, and repeated a hundred times –
+narrowed to fit, checked, filled and built closed in a few seconds; that a normal print is the hollow pot,
+with the right volume, its colour bands parts that make it up exactly; and that every pattern of holes,
+on round and drawn shapes, twisting or not, makes a closed pot whose holes' sides lean no more than the
+limit but for short bridges at their points, that holds nothing, with an open bottom too – and Voronoi
+ovals that leave every wall between them as thick as set, and rounded edges that swell as set and keep
+within the limit; and that a
+closed top comes to a point over a small ceiling (drawn and banded too), a wall part way round stops where
+it should, a face's holes and nose are where they should be and print without support, and a design sized
+for a head fits it – the masks and helmet a head 57 cm round; that a tissue box cover clears its box of
+tissues by the room to spare everywhere inside, with its hole through the bottom layers alone, holes and
+bands and all, and that its lid is closed, clear of the cover's printed wall everywhere when it is on, and
+caught by the ledges when it is pulled down a millimetre; and that the web version's Python, put
+together as the site has it, runs with no Qt at all – it starts, builds a mask, fits it to a head and makes
+the slicer's file.
+`.venv\Scripts\python -m tests.test_app` drives the window itself: edits, curves, undo, the gallery,
+Surprise me, every view, the print animation, saving and exporting, and drawing (with the mouse, close
+up and in a full window, zoomed with the wheel), repeating up to a hundred times and tracing a
+cross-section, and Simple mode: each kind of picture, the gallery of pictures, Save for printing with its
+steps and Open in slicer, bigger text and the light theme, and a mask sized by the head it fits, its face's
+settings and Undo. Test windows open on the second screen, if there is one.
+
+Either can run just part of itself, for a quick check after a small change: name a word, and only the tests
+(or the window's sections) with it in their names run – `tests.test_vase lid box` runs the tissue box
+cover's and its lid's tests, `tests.test_app box` the window's tissue box section. The window's sections
+are edits, gallery, morph, views, saving, drawn, simple, holes, masks, box, look and function; each starts
+from a design of its own, so any can run alone. A name that matches nothing lists them all.
+
+### The web version
+
+The page runs the **same Python** as the desktop app – the parts of `vaseinator` that need no Qt: the
+geometry, the designs, the settings and their rules, the report and the exports – in the browser with
+[Pyodide](https://pyodide.org) and numpy. It runs two web workers, so the page stays smooth: one builds the
+vase, the other answers everything else at once – what a change of setting brings with it, which settings
+to show, tracing a picture, fitting a head. `web/bridge.py` is the little bit between them and the page: it
+builds a vase and hands back its mesh as raw arrays, which the page draws with
+[three.js](https://threejs.org), along with the desktop app's own report, the pictures' data and the
+print's timing, and makes the 3MF, STL and `.vase` files the page saves. The Advanced panel is made from
+the desktop app's own list of settings. A quick draft shows at once – and follows the hand while a curve
+or an outline is dragged – and the full detail follows a moment later. Nothing is sent anywhere: the vase
+is built on your own computer.
+
+`tools\build_web.py` puts the site together in `site\`: the page, the Python and the gallery pictures,
+the page's files stamped with a version so that after an update a browser never mixes old files with new.
+To try it locally:
+
+```
+.venv\Scripts\python tools\build_web.py
+.venv\Scripts\python -m http.server 8000 -d site
+```
+
+and open . GitHub Pages builds and publishes it the same way on every push to `main`.
+
+## License
+
+MIT – see [LICENSE](LICENSE).
diff --git a/tests/test_browser.cjs b/tests/test_browser.cjs
new file mode 100644
index 0000000..ebf2a3a
--- /dev/null
+++ b/tests/test_browser.cjs
@@ -0,0 +1,40 @@
+// Optional end-to-end test: NODE_PATH points to Playwright; BROWSER_PATH to Chrome/Edge.
+const {chromium}=require('playwright');
+const assert=require('node:assert/strict');
+(async()=>{
+ const browser=await chromium.launch({headless:true,executablePath:process.env.BROWSER_PATH});
+ try {
+ const page=await browser.newPage({viewport:{width:1440,height:1000}});
+ const errors=[];page.on('pageerror',e=>errors.push(e.message));
+ await page.goto(process.env.VASE_URL||'http://127.0.0.1:8765');
+ await page.waitForFunction(()=>window.vaseinator?.meta,{timeout:180000});
+ console.log('PASS: browser boots and builds');
+ await page.locator('#advancedbtn').click();
+ await page.locator('#set-line_width').evaluate(el=>{el.value='.8';el.dispatchEvent(new Event('change'))});
+ await page.waitForFunction(()=>window.vaseinator.params.line_width===.8);
+ await page.reload();await page.waitForFunction(()=>window.vaseinator?.meta,{timeout:180000});
+ assert.equal(await page.evaluate(()=>window.vaseinator.params.line_width),.8);
+ await page.locator('[data-do="library"]').evaluate(el=>el.click());
+ const dialog=page.locator('dialog.library');await dialog.getByText('My designs',{exact:true}).waitFor();
+ await dialog.getByLabel('Colours',{exact:true}).check();
+ page.once('dialog',d=>d.accept('Browser test variant'));
+ await dialog.getByRole('button',{name:'Save current variant',exact:true}).click();
+ await dialog.getByText(/Browser test variant —/).waitFor();
+ await dialog.getByRole('button',{name:'Favourite',exact:true}).click();
+ await dialog.getByRole('button',{name:'Unfavourite',exact:true}).waitFor();
+ page.once('dialog',d=>d.accept('Test printer'));
+ await dialog.getByRole('button',{name:'Save printer',exact:true}).click();
+ await dialog.getByText(/Test printer —/).waitFor();
+ await page.screenshot({path:'work-temp/browser-library.png'});
+ await dialog.getByRole('button',{name:'Close',exact:true}).click();
+ await page.locator('[data-do="compare"]').evaluate(el=>el.click());
+ await page.locator('#help[open] img').waitFor({timeout:180000});
+ await page.screenshot({path:'work-temp/browser-comparison.png'});
+ await page.locator('#help').evaluate(el=>el.close());
+ await page.locator('[data-do="calibration"]').evaluate(el=>el.click());
+ await page.locator('#steps[open]').waitFor({timeout:60000});
+ assert.equal(await page.locator('#stepsfile').getAttribute('download'),'clearance_samples.3mf');
+ assert.deepEqual(errors,[]);
+ console.log('PASS: printer reload, library, favourites, profile, locks, comparison and sample export; no browser errors');
+ } finally {await browser.close();}
+})().catch(e=>{console.error(e);process.exitCode=1});
diff --git a/tests/test_design_tools.py b/tests/test_design_tools.py
new file mode 100644
index 0000000..dcb9cf1
--- /dev/null
+++ b/tests/test_design_tools.py
@@ -0,0 +1,78 @@
+"""Desktop feature controls; runs without a display using QT_QPA_PLATFORM=offscreen."""
+
+import json
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from PySide6.QtCore import QSettings
+from PySide6.QtGui import QFontDatabase, QFont
+from PySide6.QtWidgets import QApplication, QCheckBox, QMessageBox
+from vaseinator.app import MainWindow
+from vaseinator.design_tools import DesignTools, save_calibration
+from vaseinator.threemf import read_3mf
+
+
+class DesktopToolsTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.app = QApplication.instance() or QApplication([])
+ font = Path("C:/Windows/Fonts/segoeui.ttf")
+ if font.exists():
+ ident = QFontDatabase.addApplicationFont(str(font))
+ families = QFontDatabase.applicationFontFamilies(ident)
+ if families:
+ cls.app.setFont(QFont(families[0], 10))
+
+ def test_library_profile_recovery_and_samples(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ settings = QSettings(str(Path(tmp) / "test.ini"), QSettings.IniFormat)
+ win = MainWindow(settings)
+ win.params.facet_size = 2
+ dialog = DesignTools(win)
+ dialog.show()
+ self.app.processEvents()
+ colour = next(
+ c for c in dialog.findChildren(QCheckBox) if c.text() == "Colours"
+ )
+ colour.setChecked(True)
+ self.assertIn("Colours", win.workspace.data["locks"])
+ dialog.run("save", dict(category="designs", name="Variant"))
+ entry = win.workspace.data["designs"][0]
+ dialog.run("favorite", {"id": entry["id"]})
+ self.assertTrue(entry["favorite"])
+ win.params.line_width = 0.8
+ dialog.run(
+ "save", dict(category="profiles", name="Printer", kind="printer")
+ )
+ profile = win.workspace.data["profiles"][0]
+ win.params.line_width = 0.4
+ dialog.run("restore", dict(category="profiles", id=profile["id"]))
+ self.assertEqual(win.params.line_width, 0.8)
+ win.current_file = "old.vase"
+ dialog.run("restore", dict(category="designs", id=entry["id"]))
+ self.assertIsNone(win.current_file)
+ self.assertEqual(win.params.line_width, entry["params"]["line_width"])
+ self.assertTrue(win.workspace.data["recovery"])
+ self.assertEqual(
+ json.loads(settings.value("workspace")), win.workspace.data
+ )
+ path = str(Path(tmp) / "samples.3mf")
+ with (
+ patch(
+ "vaseinator.design_tools.QFileDialog.getSaveFileName",
+ return_value=(path, ""),
+ ),
+ patch.object(QMessageBox, "information"),
+ ):
+ save_calibration(win)
+ self.assertEqual(len(read_3mf(path)), 10)
+ dialog.grab().save("work-temp/desktop-library.png")
+ dialog.close()
+ win._dirty = False
+ win.close()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_web_state.cjs b/tests/test_web_state.cjs
new file mode 100644
index 0000000..7770252
--- /dev/null
+++ b/tests/test_web_state.cjs
@@ -0,0 +1,32 @@
+// Exercise the actual state functions with a delayed Python worker and minimal presentation stubs.
+const {readFileSync}=require('node:fs');
+const vm=require('node:vm');
+const assert=require('node:assert/strict');
+const source=readFileSync('web/app.js','utf8');
+const context=vm.createContext({console,setTimeout:()=>0,clearTimeout(){}});
+vm.runInContext(`
+let C={printer_keys:['line_width']}, K={REAL_VASE:0,FAKE_VASE:1}, params={height:180,color1:'blue',line_width:.6,vase_mode:1};
+let liveFrom=null,shown,reply,recoveryTimer=0;
+const undoStack=[],redoStack=[],saved={};
+const store={set(k,v){saved[k]=JSON.parse(JSON.stringify(v))},get(k){return saved[k]}};
+function pushUndo(s){undoStack.push(s);redoStack.length=0}
+function undoButtons(){} function status(){} function showProblem(e){throw new Error(e)}
+function refreshSimple(){} function refreshAdvanced(){} function rebuild(){}
+const helper={call(name,text,key,value){if(name==='shown')return Promise.resolve('[]');return new Promise(resolve=>{reply=()=>resolve(JSON.stringify({params:{...JSON.parse(text),[key]:JSON.parse(value)},shown:[]}))})}};
+`+source.slice(source.indexOf('let queue ='),source.indexOf('function pushUndo'))+
+source.slice(source.indexOf('function needsNormal'),source.indexOf('// ================================================================== building it'))+
+source.slice(source.indexOf('function rememberPrinter'),source.indexOf('async function fitHead')),context);
+const run=s=>vm.runInContext(s,context);
+const tick=()=>new Promise(resolve=>setImmediate(resolve));
+(async()=>{
+ run(`commit('height',200)`);await tick();run(`change({color1:'red'});reply()`);await run('queue');
+ assert.equal(run('params.height'),200);assert.equal(run('params.color1'),'red');
+ run(`commit('line_width',.8)`);await tick();run('reply()');await run('queue');
+ run(source.slice(source.indexOf(' const printer = store.get'),source.indexOf(' $("loading").hidden = true;')));
+ assert.equal(run('params.line_width'),.8);
+ run('step(undoStack,redoStack)');await run('queue');assert.equal(run('params.line_width'),.6);assert.equal(run('saved.printer.line_width'),.6);
+ run('step(redoStack,undoStack)');await run('queue');assert.equal(run('params.line_width'),.8);
+ run(`commit('height',250)`);await tick();run('step(undoStack,redoStack);reply()');await run('queue');assert.equal(run('params.height'),200);
+ run(`commit('height',260)`);await tick();run(`replace({height:99,color1:'green',line_width:.4,vase_mode:1});reply()`);await run('queue');assert.equal(run('params.height'),99);
+ console.log('PASS: delayed edits, colours, reload, undo/redo and replacement ordering');
+})().catch(e=>{console.error(e);process.exitCode=1});
diff --git a/tests/test_workspace.py b/tests/test_workspace.py
new file mode 100644
index 0000000..fdef2e8
--- /dev/null
+++ b/tests/test_workspace.py
@@ -0,0 +1,136 @@
+"""Shared feature and export regression tests: python -m unittest tests.test_workspace."""
+
+import json
+import tempfile
+import unittest
+from pathlib import Path
+from types import SimpleNamespace
+
+import numpy as np
+
+from vaseinator.params import VaseParams, clean_curve
+from vaseinator.presets import plain_design
+from vaseinator.geometry import VaseDesign, edge_check, mesh_volume, require_exportable
+from vaseinator.workspace import (
+ Workspace,
+ LOCKS,
+ locked_surprise,
+ comparison,
+ calibration_parts,
+)
+from vaseinator.threemf import write_3mf, read_3mf
+from web import bridge
+
+
+class WorkspaceTests(unittest.TestCase):
+ def test_bad_numbers(self):
+ for values in (
+ {"height": "NaN"},
+ {"height": "Infinity"},
+ {"count1": float("inf")},
+ ):
+ with self.subTest(values=values), self.assertRaises(ValueError):
+ VaseParams.from_dict(values)
+ self.assertIsNone(clean_curve([[0, 2], [1, float("nan")]]))
+
+ def test_export_gate(self):
+ for design in (
+ SimpleNamespace(errors=["bad"], closed=True),
+ SimpleNamespace(errors=[], closed=False),
+ ):
+ with self.assertRaises(ValueError):
+ require_exportable(design)
+
+ def test_export_gate_in_browser(self):
+ from unittest.mock import patch
+
+ with patch.object(
+ bridge, "VaseDesign", return_value=SimpleNamespace(errors=[], closed=False)
+ ):
+ for export in (bridge.export_stl, bridge.export_3mf, bridge.export_lid):
+ with self.assertRaises(ValueError):
+ export("{}")
+
+ def test_locks_and_bridge_parity(self):
+ p = VaseParams(height=123, line_width=0.8)
+ for group, keys in LOCKS.items():
+ q = locked_surprise(p, 42, [group])
+ for key in keys:
+ self.assertEqual(getattr(p, key), getattr(q, key))
+ self.assertEqual(q.line_width, 0.8)
+ q = locked_surprise(p, 42, ["Dimensions"])
+ self.assertAlmostEqual(
+ max(v for _, v in q.silhouette), max(v for _, v in p.silhouette)
+ )
+ q = locked_surprise(p, 42, list(LOCKS))
+ self.assertEqual(
+ q.to_dict(),
+ json.loads(
+ bridge.surprise_me(json.dumps(p.to_dict()), 42, json.dumps(list(LOCKS)))
+ ),
+ )
+
+ def test_library_roundtrip_profiles_favourites_and_recovery(self):
+ w = Workspace()
+ p = VaseParams(name="My variant", height=140, line_width=0.8, density=1.27)
+ entry = w.add("designs", p)
+ w.command("favorite", p, {"id": entry["id"]})
+ printer = w.add("profiles", p, "Printer", kind="printer")
+ material = w.add("profiles", p, "PETG", kind="material")
+ base = VaseParams(height=99, density=1.1)
+ q = w.apply_profile(base, printer["id"])
+ self.assertEqual(q.height, 99)
+ self.assertEqual(q.line_width, 0.8)
+ self.assertEqual(q.density, 1.1)
+ q = w.apply_profile(base, material["id"])
+ self.assertEqual(q.density, 1.27)
+ self.assertEqual(q.line_width, base.line_width)
+ for n in range(35):
+ w.add("recovery", VaseParams(height=100 + n))
+ w.add("recovery", VaseParams(height=134))
+ self.assertEqual(len(w.data["recovery"]), 30)
+ restored = Workspace(json.loads(w.dumps()))
+ self.assertEqual(restored.data, w.data)
+ restored.merge(w.data)
+ self.assertEqual(restored.data, w.data)
+ result = json.loads(
+ bridge.workspace_action(
+ w.dumps(),
+ json.dumps(base.to_dict()),
+ "restore",
+ json.dumps({"category": "designs", "id": entry["id"]}),
+ )
+ )
+ self.assertEqual(result["params"], p.to_dict())
+ self.assertTrue(result["workspace"]["designs"][0]["favorite"])
+ with self.assertRaises(ValueError):
+ Workspace({"format": "other"})
+
+ def test_comparison_and_calibration(self):
+ p = plain_design()
+ p.facet_size = 2
+ d = VaseDesign(p)
+ data = comparison(d)
+ self.assertEqual(data["moved"], 0)
+ self.assertIn("